diff --git a/.cursor/rules/Migrations.mdc b/.cursor/rules/Migrations.mdc new file mode 100644 index 000000000..d9562b4e1 --- /dev/null +++ b/.cursor/rules/Migrations.mdc @@ -0,0 +1,5 @@ +--- +alwaysApply: true +--- + +Don't write or edit migrations in `server/setup` unless specificall instructed to do so. diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 6cefe4a4d..9add9e04d 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -123,6 +123,16 @@ "siteUpdated": "Сайтът е обновен", "siteUpdatedDescription": "Сайтът е актуализиран.", "siteGeneralDescription": "Конфигурирайте общи настройки за този сайт", + "siteRestartTitle": "Рестартирайте Сайта", + "siteRestartDescription": "Рестартирайте WireGuard тунела за този сайт. Това ще прекъсне връзката за кратко.", + "siteRestartBody": "Използвайте това, ако тунелът на сайта не функционира правилно и искате да принудите повторно свързване без да рестартирате хоста.", + "siteRestartButton": "Рестартирайте Сайта", + "siteRestartDialogMessage": "Сигурни ли сте, че искате да рестартирате WireGuard тунела за {name}? Сайтът ще изгуби връзка за кратко.", + "siteRestartWarning": "Сайтът ще се изключи за кратко, докато тунелът се рестартира.", + "siteRestarted": "Сайтът е рестартиран", + "siteRestartedDescription": "WireGuard тунелът е рестартиран.", + "siteErrorRestart": "Неуспешно рестартиране на сайта", + "siteErrorRestartDescription": "Възникна грешка при рестартирането на сайта.", "siteSettingDescription": "Конфигурирайте настройките на сайта", "siteResourcesTab": "Ресурси", "siteResourcesNoneOnSite": "Този сайт все още няма публични или частни ресурси.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Приложи Чернова", "actionListBlueprints": "Списък с планове.", "actionGetBlueprint": "Вземи план.", + "actionCreateOrgWideLauncherView": "Създайте Изглед на Стартирача за Цялата Организация", "setupToken": "Конфигурация на токен", "setupTokenDescription": "Въведете конфигурационния токен от сървърната конзола.", "setupTokenRequired": "Необходим е конфигурационен токен", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Мрежа", "addressDescription": "Вътрешният адрес на клиента. Трябва да пада в подмрежата на организацията.", "selectSites": "Избор на сайтове", + "selectLabels": "Изберете етикети", "sitesDescription": "Клиентът ще има връзка с избраните сайтове", "clientInstallOlm": "Инсталиране на Olm", "clientInstallOlmDescription": "Конфигурирайте Olm да работи на вашата система", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Сайт", "selectSite": "Изберете сайт...", "multiSitesSelectorSitesCount": "{count, plural, one {# сайт} other {# сайтове}}", + "labelsSelectorLabelsCount": "{count, plural, one {# етикет} other {# етикета}}", "noSitesFound": "Не са намерени сайтове.", "createInternalResourceDialogProtocol": "Протокол", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Отдалечени възли", "remoteExitNodeId": "ID.", "remoteExitNodeSecretKey": "Секретен ключ.", + "remoteExitNodeNetworkingTitle": "Настройки на Мрежата", + "remoteExitNodeNetworkingDescription": "Настройте как този отдалечен край маршрутизира трафика и кои сайтове предпочитат да се свържат чрез него. Усъвършенствани функции за използване при конфигурации на бекаул мрежи.", + "remoteExitNodeNetworkingSave": "Запазване на Настройките", + "remoteExitNodeNetworkingSaveSuccessTitle": "Настройките на мрежата са успешно запазени", + "remoteExitNodeNetworkingSaveSuccessDescription": "Настройките на мрежата бяха успешно обновени.", + "remoteExitNodeNetworkingSaveError": "Неуспешно запазване на мрежовите настройки", + "remoteExitNodeNetworkingSubnetsTitle": "Отдалечени Подмрежи", + "remoteExitNodeNetworkingSubnetsDescription": "Определете CIDR диапазоните, които този отдалечен край ще маршрутизира трафика към. Въведете валиден CIDR (e.g. 10.0.0.0/8) и натиснете Enter, за да добавите.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Добавете CIDR диапазон (напр. 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Неуспешно зареждане на подмрежи", + "remoteExitNodeNetworkingLabelsTitle": "Етикети за Предпочитания", + "remoteExitNodeNetworkingLabelsDescription": "Сайтове с тези етикети ще бъдат принудени да се свържат чрез този отдалечен край.", + "remoteExitNodeNetworkingLabelsButtonText": "Изберете етикети...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Търсене на етикети...", + "remoteExitNodeNetworkingLabelsLoadError": "Неуспешно зареждане на етикети", "remoteExitNodeCreate": { "title": "Създаване на отдалечен възел.", "description": "Създайте нов самохостнал отдалечен ретранслатор и прокси сървърен възел.", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Google OAuth2/OIDC доставчик", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC доставчик", "subnet": "Подмрежа", + "utilitySubnet": "Мрежа на Помощни Подмрежи", "subnetDescription": "Подмрежата за конфигурацията на мрежата на тази организация.", "customDomain": "Персонализиран домейн.", "authPage": "Страници за автентификация.", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "Бял списък на имейли", "memberPortalResourceDisabled": "Ресурсът е деактивиран", "memberPortalShowingResources": "Показва {start}-{end} от {total} ресурси", + "resourceLauncherTitle": "Стартер за Ресурси", + "resourceLauncherDescription": "Преглеждайте детайлите на ресурса и ги стартирайте от едно място", + "resourceLauncherSearchPlaceholder": "Търсете във всички сайтове...", + "resourceLauncherDefaultView": "По Подразбиране", + "resourceLauncherSaveView": "Запазете Изгледа", + "resourceLauncherSaveToCurrentView": "Запазете в Текущ Изглед", + "resourceLauncherResetView": "Нулирайте Изгледа", + "resourceLauncherSaveAsNewView": "Запазете като Нов Изглед", + "resourceLauncherSaveAsNewViewDescription": "Дайте име на този изглед, за да запазите текущите си филтри и оформление.", + "resourceLauncherSaveForEveryone": "Запазете за Всеки", + "resourceLauncherSaveForEveryoneDescription": "Споделете този изглед с всички членове на организацията. Когато е изключено, изгледът е видим само за вас.", + "resourceLauncherMakePersonal": "Направи Личен", + "resourceLauncherFilter": "Филтър", + "resourceLauncherSort": "Сортиране", + "resourceLauncherSortAscending": "Сортиране възходящо", + "resourceLauncherSortDescending": "Сортиране низходящо", + "resourceLauncherSettings": "Настройки", + "resourceLauncherGroupBy": "Групирай По", + "resourceLauncherGroupBySite": "Сайт", + "resourceLauncherGroupByLabel": "Етикет", + "resourceLauncherLayout": "Оформление", + "resourceLauncherLayoutGrid": "Мрежа", + "resourceLauncherLayoutList": "Списък", + "resourceLauncherShowLabels": "Показване на Етикети", + "resourceLauncherShowSiteTags": "Показване на Тагове на Сайт", + "resourceLauncherShowRecents": "Показване на Последни", + "resourceLauncherDeleteView": "Изтриване на Изглед", + "resourceLauncherViewAsAdmin": "Вижте като Админ", + "resourceLauncherResourceDetailsDescription": "Вижте детайлите за този ресурс.", + "resourceLauncherUnlabeled": "Без Етикет", + "resourceLauncherNoSite": "Няма Сайт", + "resourceLauncherNoResourcesInGroup": "Няма ресурси в тази група", + "resourceLauncherEmptyStateTitle": "Няма Налични Ресурси", + "resourceLauncherEmptyStateDescription": "Все още нямате достъп до никакви ресурси. Свържете се с вашия администратор, за да поискате достъп.", + "resourceLauncherEmptyStateNoResultsTitle": "Няма Намерени Ресурси", + "resourceLauncherEmptyStateNoResultsDescription": "Никакви ресурси не съвпадат с текущото ви търсене или филтри. Опитайте да ги коригирате, за да намерите това, което търсите.", + "resourceLauncherEmptyStateNoResultsWithQuery": "Никакви ресурси не съвпадат с \"{query}\". Опитайте да коригирате търсенето или да изтриете филтри, за да видите всички ресурси.", + "resourceLauncherCopiedToClipboard": "Копирано в клипборда", + "resourceLauncherCopiedAccessDescription": "Достъпът до ресурса е копиран на вашия клипборд.", + "resourceLauncherViewNamePlaceholder": "Име на Изгледа", + "resourceLauncherViewNameLabel": "Име на Изгледа", + "resourceLauncherViewSaved": "Изгледът е запазен", + "resourceLauncherViewSavedDescription": "Вашият изглед на стартер е запазен.", + "resourceLauncherViewSaveFailed": "Неуспешно запазване на изгледа", + "resourceLauncherViewSaveFailedDescription": "Не можеше да се запази изгледът на стартер. Моля, опитайте отново.", + "resourceLauncherViewDeleted": "Изгледът е изтрит", + "resourceLauncherViewDeletedDescription": "Изгледът на стартер е изтрит.", + "resourceLauncherViewDeleteFailed": "Неуспешно изтриване на изгледа", + "resourceLauncherViewDeleteFailedDescription": "Не можахте да изтриете изгледа на стартер. Моля, опитайте отново.", "memberPortalPrevious": "Предишен", "memberPortalNext": "Следващ", "httpSettings": "HTTP настройки", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----НАЧАЛО НА OPENSSH ЧАСТЕН КЛЮЧ-----", "sshPrivateKeyRequired": "Изисква се частен ключ", "vncTitle": "VNC", - "vncSignInDescription": "Въведете вашата VNC парола за свързване", + "vncSignInDescription": "Въведете VNC данните си за връзка", + "vncUsernameOptional": "Потребителско име (по избор)", "vncPasswordOptional": "Парола (по избор)", "vncNoResourceTarget": "Не е налична цел за ресурса", "vncFailedToLoadNovnc": "Неуспешно зареждане на noVNC", diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 9a175093e..ba5203938 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -123,6 +123,16 @@ "siteUpdated": "Lokalita upravena", "siteUpdatedDescription": "Lokalita byla upravena.", "siteGeneralDescription": "Upravte obecná nastavení pro tuto lokalitu", + "siteRestartTitle": "Restartovat lokalitu", + "siteRestartDescription": "Restartujte tunel WireGuard pro tuto lokalitu. To krátce přeruší konektivitu.", + "siteRestartBody": "Použijte to, pokud tunel lokality nefunguje správně a chcete vynutit opětovné připojení bez restartování hostitele.", + "siteRestartButton": "Restartovat lokalitu", + "siteRestartDialogMessage": "Opravdu chcete restartovat WireGuard tunel pro {name}? Lokalita krátce ztratí konektivitu.", + "siteRestartWarning": "Lokalita bude krátce odpojena, zatímco se tunel restartuje.", + "siteRestarted": "Lokalita restartována", + "siteRestartedDescription": "Tunel WireGuard byl restartován.", + "siteErrorRestart": "Nepodařilo se restartovat lokalitu", + "siteErrorRestartDescription": "Při restartování lokality došlo k chybě.", "siteSettingDescription": "Konfigurace nastavení na webu", "siteResourcesTab": "Zdroje", "siteResourcesNoneOnSite": "Tento web zatím nemá veřejné ani soukromé zdroje.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Použít plán", "actionListBlueprints": "Seznam šablon", "actionGetBlueprint": "Získat šablonu", + "actionCreateOrgWideLauncherView": "Vytvořit organizační pohled", "setupToken": "Nastavit token", "setupTokenDescription": "Zadejte nastavovací token z konzole serveru.", "setupTokenRequired": "Je vyžadován token nastavení", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Podsíť", "addressDescription": "Interní adresa klienta. Musí spadat do podsítě organizace.", "selectSites": "Vyberte stránky", + "selectLabels": "Vyberte názvy", "sitesDescription": "Klient bude mít připojení k vybraným webům", "clientInstallOlm": "Nainstalovat Olm", "clientInstallOlmDescription": "Stáhněte si Olm běžící ve vašem systému", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Lokalita", "selectSite": "Vybrat lokalitu...", "multiSitesSelectorSitesCount": "{count, plural, one {# web} few {# weby} many {# webů} other {# weby}}", + "labelsSelectorLabelsCount": "{count, plural, one {# název} few {# názvy} many {# názvů} other {# názvů}}", "noSitesFound": "Nebyly nalezeny žádné lokality.", "createInternalResourceDialogProtocol": "Protokol", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Vzdálené uzly", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Tajný klíč", + "remoteExitNodeNetworkingTitle": "Nastavení sítě", + "remoteExitNodeNetworkingDescription": "Nastavte, jak tento vzdálený výstupní uzel směruje provoz a které lokality se mají připojit přes něj. Pokročilé funkce pro použití s konfiguracemi zpětné sítě.", + "remoteExitNodeNetworkingSave": "Uložit nastavení", + "remoteExitNodeNetworkingSaveSuccessTitle": "Nastavení sítě bylo úspěšně uloženo", + "remoteExitNodeNetworkingSaveSuccessDescription": "Nastavení sítě bylo úspěšně aktualizováno.", + "remoteExitNodeNetworkingSaveError": "Nepodařilo se uložit nastavení sítě", + "remoteExitNodeNetworkingSubnetsTitle": "Dálkové podsítě", + "remoteExitNodeNetworkingSubnetsDescription": "Definujte rozsahy CIDR, ke kterým tento vzdálený výstupní uzel bude směrovat provoz. Zadejte platné CIDR (např. 10.0.0.0/8) a stiskněte Enter pro přidání.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Přidejte rozsah CIDR (např. 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Nepodařilo se načíst podsítě", + "remoteExitNodeNetworkingLabelsTitle": "Názvy preferencí", + "remoteExitNodeNetworkingLabelsDescription": "Weby s těmito názvy budou nuceny připojit se tímto vzdáleným výstupním uzlem.", + "remoteExitNodeNetworkingLabelsButtonText": "Vyberte názvy...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Hledat názvy...", + "remoteExitNodeNetworkingLabelsLoadError": "Nepodařilo se načíst názvy", "remoteExitNodeCreate": { "title": "Vytvořit vzdálený uzel", "description": "Vytvořte nový vlastní hostovaný vzdálený relační a proxy server uzel", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Poskytovatel Google OAuth2/OIDC", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Podsíť", + "utilitySubnet": "Nástrojová podsíť", "subnetDescription": "Podsíť pro konfiguraci sítě této organizace.", "customDomain": "Vlastní doména", "authPage": "Autentizační stránky", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "Seznam povolených emailů", "memberPortalResourceDisabled": "Zdroj je zakázán", "memberPortalShowingResources": "Zobrazeny {start}-{end} z {total} zdrojů", + "resourceLauncherTitle": "Spouštěč zdrojů", + "resourceLauncherDescription": "Podívejte se na podrobnosti o zdrojích a spusťte je z jednoho místa", + "resourceLauncherSearchPlaceholder": "Hledat všechny lokality...", + "resourceLauncherDefaultView": "Výchozí", + "resourceLauncherSaveView": "Uložit pohled", + "resourceLauncherSaveToCurrentView": "Uložit do aktuálního pohledu", + "resourceLauncherResetView": "Obnovit pohled", + "resourceLauncherSaveAsNewView": "Uložit jako nový pohled", + "resourceLauncherSaveAsNewViewDescription": "Uložte tento pohled k uloženému filtrování a rozvržení.", + "resourceLauncherSaveForEveryone": "Uložit pro všechny", + "resourceLauncherSaveForEveryoneDescription": "Sdílejte tento pohled se všemi členy organizace. Pokud není zaškrtnuto, pohled je viditelný pouze vám.", + "resourceLauncherMakePersonal": "Udělat osobní", + "resourceLauncherFilter": "Filtr", + "resourceLauncherSort": "Řadit", + "resourceLauncherSortAscending": "Řadit vzestupně", + "resourceLauncherSortDescending": "Řadit sestupně", + "resourceLauncherSettings": "Nastavení", + "resourceLauncherGroupBy": "Seskupit podle", + "resourceLauncherGroupBySite": "Lokalita", + "resourceLauncherGroupByLabel": "Název", + "resourceLauncherLayout": "Rozvržení", + "resourceLauncherLayoutGrid": "Mřížka", + "resourceLauncherLayoutList": "Seznam", + "resourceLauncherShowLabels": "Zobrazit název", + "resourceLauncherShowSiteTags": "Zobrazit značky lokality", + "resourceLauncherShowRecents": "Zobrazit nedávné", + "resourceLauncherDeleteView": "Smazat pohled", + "resourceLauncherViewAsAdmin": "Zobrazit jako administrátor", + "resourceLauncherResourceDetailsDescription": "Podívejte se na podrobnosti o tomto zdroji.", + "resourceLauncherUnlabeled": "Bez nálepky", + "resourceLauncherNoSite": "Žádná lokalita", + "resourceLauncherNoResourcesInGroup": "V této skupině nejsou žádné zdroje", + "resourceLauncherEmptyStateTitle": "Žádné dostupné zdroje", + "resourceLauncherEmptyStateDescription": "Zatím nemáte přístup k žádným zdrojům. Kontaktujte svého administrátora, abyste požádali o přístup.", + "resourceLauncherEmptyStateNoResultsTitle": "Nebyl nalezen žádný zdroj", + "resourceLauncherEmptyStateNoResultsDescription": "Žádný zdroj neodpovídá vašemu aktuálnímu vyhledávání nebo filtrům. Zkuste je upravit, abyste našli to, co hledáte.", + "resourceLauncherEmptyStateNoResultsWithQuery": "Žádné zdroje neodpovídají \"{query}\". Zkuste upravit vyhledávání nebo vymazat filtry, abyste viděli všechny zdroje.", + "resourceLauncherCopiedToClipboard": "Zkopírováno do schránky", + "resourceLauncherCopiedAccessDescription": "Přístup ke zdroji byl zkopírován do vaší schránky.", + "resourceLauncherViewNamePlaceholder": "Název pohledu", + "resourceLauncherViewNameLabel": "Název pohledu", + "resourceLauncherViewSaved": "Pohled uložen", + "resourceLauncherViewSavedDescription": "Váš spouštěcí pohled byl uložen.", + "resourceLauncherViewSaveFailed": "Nepodařilo se uložit pohled", + "resourceLauncherViewSaveFailedDescription": "Nepodařilo se uložit spouštěcí pohled. Prosím zkuste to znovu.", + "resourceLauncherViewDeleted": "Pohled smazán", + "resourceLauncherViewDeletedDescription": "Spouštěcí pohled byl smazán.", + "resourceLauncherViewDeleteFailed": "Nepodařilo se smazat pohled", + "resourceLauncherViewDeleteFailedDescription": "Nepodařilo se smazat spouštěcí pohled. Prosím zkuste to znovu.", "memberPortalPrevious": "Předchozí", "memberPortalNext": "Následující", "httpSettings": "Nastavení HTTP", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----ZAČÁTEK SOUKROMÉHO KLÍČE OPENSSH-----", "sshPrivateKeyRequired": "Je vyžadován soukromý klíč", "vncTitle": "VNC", - "vncSignInDescription": "Zadejte své heslo VNC pro připojení", + "vncSignInDescription": "Zadejte své VNC přihlašovací údaje pro připojení", + "vncUsernameOptional": "Uživatelské jméno (nepovinné)", "vncPasswordOptional": "Heslo (nepovinné)", "vncNoResourceTarget": "Není k dispozici žádný cíl zdroje", "vncFailedToLoadNovnc": "Nepodařilo se načíst noVNC", diff --git a/messages/da-DK.json b/messages/da-DK.json index b9db7f323..81a1177df 100644 --- a/messages/da-DK.json +++ b/messages/da-DK.json @@ -123,6 +123,16 @@ "siteUpdated": "Site opdateret", "siteUpdatedDescription": "Sitet er blevet opdateret.", "siteGeneralDescription": "Konfigurer de generelle indstillinger for dette site", + "siteRestartTitle": "Genstart Site", + "siteRestartDescription": "Genstart WireGuard-tunnelen for dette site. Dette vil kortvarigt afbryde forbindelsen.", + "siteRestartBody": "Brug dette, hvis site-tunnelen ikke fungerer korrekt, og du vil tvinge en genforbindelse uden at genstarte værten.", + "siteRestartButton": "Genstart Site", + "siteRestartDialogMessage": "Er du sikker på, at du vil genstarte WireGuard-tunnelen for {name}? Sitet vil kortvarigt miste forbindelsen.", + "siteRestartWarning": "Sitet vil kortvarigt afbryde forbindelse, mens tunnelen genstarter.", + "siteRestarted": "Site genstartet", + "siteRestartedDescription": "WireGuard-tunnelen er blevet genstartet.", + "siteErrorRestart": "Kunne ikke genstarte site", + "siteErrorRestartDescription": "En fejl opstod, mens sitet blev genstartet.", "siteSettingDescription": "Konfigurer indstillingerne for sitet", "siteResourcesTab": "Ressourcer", "siteResourcesNoneOnSite": "Dette site har endnu ingen offentlige eller private ressourcer.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Brug blueprint", "actionListBlueprints": "Vis blueprints", "actionGetBlueprint": "Hent blueprint", + "actionCreateOrgWideLauncherView": "Opret org-dækkende launcher-visning", "setupToken": "Opsætningstoken", "setupTokenDescription": "Indtast opsætningstoken fra serverkonsollen.", "setupTokenRequired": "Opsætningstoken er nødvendig", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Subnet", "addressDescription": "Den interne adressen til klienten. Skal falle inden for organisationens subnet.", "selectSites": "Vælg sites", + "selectLabels": "Vælg etiketter", "sitesDescription": "Klienten vil have forbindelse til de valgte områdene", "clientInstallOlm": "Installer Olm", "clientInstallOlmDescription": "Få Olm til at køre på dit system", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Websted", "selectSite": "Vælg site...", "multiSitesSelectorSitesCount": "{count, plural, one {# sted} other {# steder}}", + "labelsSelectorLabelsCount": "{count, plural, one {# etiket} other {# etiketter}}", "noSitesFound": "Ingen sites fundet.", "createInternalResourceDialogProtocol": "Protokol", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Eksterne noder", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Sikkerhedsnøgle", + "remoteExitNodeNetworkingTitle": "Netværksindstillinger", + "remoteExitNodeNetworkingDescription": "Konfigurér, hvordan denne fjerne exit-node skal dirigere trafik, og hvilke sites der foretrækkes at oprette forbindelse gennem den. Avancerede funktioner til brug med backhaul-netværkskonfigurationer.", + "remoteExitNodeNetworkingSave": "Gem indstillinger", + "remoteExitNodeNetworkingSaveSuccessTitle": "Netværksindstillinger gemt", + "remoteExitNodeNetworkingSaveSuccessDescription": "Netværksindstillinger er blevet opdateret.", + "remoteExitNodeNetworkingSaveError": "Kunne ikke gemme netværksindstillinger", + "remoteExitNodeNetworkingSubnetsTitle": "Fjern Subnets", + "remoteExitNodeNetworkingSubnetsDescription": "Definér de CIDR-områder, som denne fjerne exit-node vil dirigere trafik til. Indtast en gyldig CIDR (f.eks. 10.0.0.0/8) og tryk Enter for at tilføje.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Tilføj et CIDR-område (f.eks. 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Kunne ikke indlæse subnets", + "remoteExitNodeNetworkingLabelsTitle": "Præference Etiketter", + "remoteExitNodeNetworkingLabelsDescription": "Sites med disse etiketter vil blive tvunget til at oprette forbindelse gennem denne fjerne exit-node.", + "remoteExitNodeNetworkingLabelsButtonText": "Vælg etiketter...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Søg efter etiketter...", + "remoteExitNodeNetworkingLabelsLoadError": "Kunne ikke indlæse etiketter", "remoteExitNodeCreate": { "title": "Opret ekstern node", "description": "Opret en ny selvhostet ekstern relay- og proxyservernode", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Google OAuth2/OIDC udbyder", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC leverandør", "subnet": "Subnet", + "utilitySubnet": "Forsynings subnet", "subnetDescription": "Subnettet for denne organisations netværkskonfiguration.", "customDomain": "Brugerdefineret domæne", "authPage": "Autentiseringssider", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "E-mailwhitelist", "memberPortalResourceDisabled": "Ressource deaktiveret", "memberPortalShowingResources": "Viser {start}-{end} af {total} ressourcer", + "resourceLauncherTitle": "Ressource Starter", + "resourceLauncherDescription": "Se ressource detaljer og start dem fra ét sted", + "resourceLauncherSearchPlaceholder": "Søg i alle sites...", + "resourceLauncherDefaultView": "Standard", + "resourceLauncherSaveView": "Gem Visning", + "resourceLauncherSaveToCurrentView": "Gem til nuværende visning", + "resourceLauncherResetView": "Nulstil Visning", + "resourceLauncherSaveAsNewView": "Gem som Ny Visning", + "resourceLauncherSaveAsNewViewDescription": "Giv denne visning et navn for at gemme dine nuværende filtre og layout.", + "resourceLauncherSaveForEveryone": "Gem for Alle", + "resourceLauncherSaveForEveryoneDescription": "Del denne visning med alle organisationsmedlemmer. Når den er ikke markeret, er visningen kun synlig for dig.", + "resourceLauncherMakePersonal": "Gør Personlig", + "resourceLauncherFilter": "Filter", + "resourceLauncherSort": "Sortér", + "resourceLauncherSortAscending": "Sortér stigende", + "resourceLauncherSortDescending": "Sortér faldende", + "resourceLauncherSettings": "Indstillinger", + "resourceLauncherGroupBy": "Gruppér Efter", + "resourceLauncherGroupBySite": "Websted", + "resourceLauncherGroupByLabel": "Etikett", + "resourceLauncherLayout": "Layout", + "resourceLauncherLayoutGrid": "Gitter", + "resourceLauncherLayoutList": "Liste", + "resourceLauncherShowLabels": "Vis Etiketter", + "resourceLauncherShowSiteTags": "Vis Site Tags", + "resourceLauncherShowRecents": "Vis Seneste", + "resourceLauncherDeleteView": "Slet Visning", + "resourceLauncherViewAsAdmin": "Vis som Admin", + "resourceLauncherResourceDetailsDescription": "Se detaljer for denne ressource.", + "resourceLauncherUnlabeled": "Uden Etiket", + "resourceLauncherNoSite": "Ingen Site", + "resourceLauncherNoResourcesInGroup": "Ingen ressourcer i denne gruppe", + "resourceLauncherEmptyStateTitle": "Ingen ressourcer tilgængelige", + "resourceLauncherEmptyStateDescription": "Du har endnu ikke adgang til nogen ressourcer. Kontakt din administrator for at anmode om adgang.", + "resourceLauncherEmptyStateNoResultsTitle": "Ingen ressourcer fundet", + "resourceLauncherEmptyStateNoResultsDescription": "Ingen ressourcer matcher din nuværende søgning eller filtre. Prøv at justere dem for at finde det, du leder efter.", + "resourceLauncherEmptyStateNoResultsWithQuery": "Ingen ressourcer matcher \"{query}\". Prøv at justere din søgning eller rydde filtre for at se alle ressourcer.", + "resourceLauncherCopiedToClipboard": "Kopieret til udklipsholderen", + "resourceLauncherCopiedAccessDescription": "Ressourcetilgangen er kopieret til din udklipsholder.", + "resourceLauncherViewNamePlaceholder": "Vis navn", + "resourceLauncherViewNameLabel": "Vis Navn", + "resourceLauncherViewSaved": "Vis Gemt", + "resourceLauncherViewSavedDescription": "Din launcher-visning er blevet gemt.", + "resourceLauncherViewSaveFailed": "Kunne ikke gemme visning", + "resourceLauncherViewSaveFailedDescription": "Kunne ikke gemme launcher-visningen. Prøv venligst igen.", + "resourceLauncherViewDeleted": "Visning slået væk", + "resourceLauncherViewDeletedDescription": "Launcher-visningen er blevet slettet.", + "resourceLauncherViewDeleteFailed": "Kunne ikke slette visning", + "resourceLauncherViewDeleteFailedDescription": "Kan ikke slette launcher-visningen. Prøv venligst igen.", "memberPortalPrevious": "Forrige", "memberPortalNext": "Næste", "httpSettings": "HTTP Indstillinger", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BEGYNN OPENSSH PRIVAT NØGLE-----", "sshPrivateKeyRequired": "Privat nøgle er påkrævet", "vncTitle": "VNC", - "vncSignInDescription": "Indtast VNC-adgangskoden for at oprette forbindelse til", + "vncSignInDescription": "Indtast dine VNC-legitimationsoplysninger for at oprette forbindelse", + "vncUsernameOptional": "Brugernavn (valgfrit)", "vncPasswordOptional": "Adgangskode (valgfrit)", "vncNoResourceTarget": "Intet ressourcemål tilgængeligt", "vncFailedToLoadNovnc": "Kunne ikke indlæse noVNC", diff --git a/messages/de-DE.json b/messages/de-DE.json index 38e8fafc4..ad39fda09 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -123,6 +123,16 @@ "siteUpdated": "Standort aktualisiert", "siteUpdatedDescription": "Der Standort wurde aktualisiert.", "siteGeneralDescription": "Allgemeine Einstellungen für diesen Standort konfigurieren", + "siteRestartTitle": "Standort neu starten", + "siteRestartDescription": "Starten Sie den WireGuard-Tunnel für diesen Standort neu. Dies wird die Konnektivität kurzzeitig unterbrechen.", + "siteRestartBody": "Verwenden Sie dies, wenn der Standort-Tunnel nicht ordnungsgemäß funktioniert und Sie eine erneute Verbindung erzwingen möchten, ohne den Host neu zu starten.", + "siteRestartButton": "Standort neu starten", + "siteRestartDialogMessage": "Sind Sie sicher, dass Sie den WireGuard-Tunnel für {name} neu starten möchten? Der Standort wird kurzzeitig die Konnektivität verlieren.", + "siteRestartWarning": "Der Standort wird kurzzeitig getrennt, während der Tunnel neu gestartet wird.", + "siteRestarted": "Standort neu gestartet", + "siteRestartedDescription": "Der WireGuard-Tunnel wurde neu gestartet.", + "siteErrorRestart": "Fehler beim Neustart des Standorts", + "siteErrorRestartDescription": "Ein Fehler ist aufgetreten, während der Standort neu gestartet wurde.", "siteSettingDescription": "Standorteinstellungen konfigurieren", "siteResourcesTab": "Ressourcen", "siteResourcesNoneOnSite": "Dieser Standort hat noch keine öffentlichen oder privaten Ressourcen", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Blueprint anwenden", "actionListBlueprints": "Blaupausen anzeigen", "actionGetBlueprint": "Erhalte Blaupause", + "actionCreateOrgWideLauncherView": "Organisationen-Weiter-Startansicht erstellen", "setupToken": "Setup-Token", "setupTokenDescription": "Geben Sie das Setup-Token von der Serverkonsole ein.", "setupTokenRequired": "Setup-Token ist erforderlich", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Subnetz", "addressDescription": "Die interne Adresse des Clients. Muss in das Subnetz der Organisation fallen.", "selectSites": "Standorte auswählen", + "selectLabels": "Etiketten auswählen", "sitesDescription": "Der Client wird zu den ausgewählten Standorten eine Verbindung haben.", "clientInstallOlm": "Olm installieren", "clientInstallOlmDescription": "Olm auf Ihrem System zum Laufen bringen", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Standort", "selectSite": "Standort auswählen...", "multiSitesSelectorSitesCount": "{count, plural, one {# Standort} other {# Standorte}}", + "labelsSelectorLabelsCount": "{count, plural, one {# Etikett} other {# Etiketten}}", "noSitesFound": "Keine Standorte gefunden.", "createInternalResourceDialogProtocol": "Protokoll", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Entfernte Knoten", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Geheimnis", + "remoteExitNodeNetworkingTitle": "Netzwerkeinstellungen", + "remoteExitNodeNetworkingDescription": "Konfigurieren Sie, wie dieser Remote Exit Node den Datenverkehr leitet und welche Standorte bevorzugt über ihn verbinden. Erweiterte Funktionen zur Verwendung mit Backhaul-Netzwerkkonfigurationen.", + "remoteExitNodeNetworkingSave": "Einstellungen speichern", + "remoteExitNodeNetworkingSaveSuccessTitle": "Netzwerkeinstellungen gespeichert", + "remoteExitNodeNetworkingSaveSuccessDescription": "Netzwerkeinstellungen wurden erfolgreich aktualisiert.", + "remoteExitNodeNetworkingSaveError": "Fehler beim Speichern der Netzwerkeinstellungen", + "remoteExitNodeNetworkingSubnetsTitle": "Remote-Subnetze", + "remoteExitNodeNetworkingSubnetsDescription": "Definieren Sie die CIDR-Bereiche, an die dieser Remote Exit Node den Datenverkehr weiterleitet. Geben Sie einen gültigen CIDR (z. B. 10.0.0.0/8) ein und drücken Sie die Eingabetaste, um hinzuzufügen.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Fügen Sie einen CIDR-Bereich hinzu (z.B. 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Fehler beim Laden der Subnetze", + "remoteExitNodeNetworkingLabelsTitle": "Präferenzetiketten", + "remoteExitNodeNetworkingLabelsDescription": "Standorte mit diesen Etiketten werden gezwungen, über diesen Remote Exit Node zu verbinden.", + "remoteExitNodeNetworkingLabelsButtonText": "Etiketten auswählen...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Etiketten suchen...", + "remoteExitNodeNetworkingLabelsLoadError": "Fehler beim Laden der Etiketten", "remoteExitNodeCreate": { "title": "Erstelle Remote Node", "description": "Erstelle einen neues selbst gehostetes Relay und ihre Proxyserver Nodes", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Google OAuth2/OIDC Provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Subnetz", + "utilitySubnet": "Nutzsubnetz", "subnetDescription": "Das Subnetz für die Netzwerkkonfiguration dieser Organisation.", "customDomain": "Eigene Domain", "authPage": "Authentifizierungs-Seiten", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "E-Mail-Whitelist", "memberPortalResourceDisabled": "Ressource deaktiviert", "memberPortalShowingResources": "Zeige {start}-{end} von {total} Ressourcen", + "resourceLauncherTitle": "Ressourcenstarter", + "resourceLauncherDescription": "Sehen Sie sich Ressourcendetails an und starten Sie sie von einem Ort aus", + "resourceLauncherSearchPlaceholder": "Alle Standorte durchsuchen...", + "resourceLauncherDefaultView": "Standard", + "resourceLauncherSaveView": "Ansicht speichern", + "resourceLauncherSaveToCurrentView": "In aktueller Ansicht speichern", + "resourceLauncherResetView": "Ansicht zurücksetzen", + "resourceLauncherSaveAsNewView": "Als neue Ansicht speichern", + "resourceLauncherSaveAsNewViewDescription": "Geben Sie dieser Ansicht einen Namen, um Ihre aktuellen Filter und das Layout zu speichern.", + "resourceLauncherSaveForEveryone": "Für alle speichern", + "resourceLauncherSaveForEveryoneDescription": "Teilen Sie diese Ansicht mit allen Organisationsmitgliedern. Wenn nicht aktiviert, ist die Ansicht nur für Sie sichtbar.", + "resourceLauncherMakePersonal": "Persönlich machen", + "resourceLauncherFilter": "Filter", + "resourceLauncherSort": "Sortieren", + "resourceLauncherSortAscending": "Aufsteigend sortieren", + "resourceLauncherSortDescending": "Absteigend sortieren", + "resourceLauncherSettings": "Einstellungen", + "resourceLauncherGroupBy": "Gruppieren nach", + "resourceLauncherGroupBySite": "Standort", + "resourceLauncherGroupByLabel": "Etikett", + "resourceLauncherLayout": "Layout", + "resourceLauncherLayoutGrid": "Raster", + "resourceLauncherLayoutList": "Liste", + "resourceLauncherShowLabels": "Etiketten anzeigen", + "resourceLauncherShowSiteTags": "Standort-Tags anzeigen", + "resourceLauncherShowRecents": "Kürzlich anzeigen", + "resourceLauncherDeleteView": "Ansicht löschen", + "resourceLauncherViewAsAdmin": "Ansicht als Administrator anzeigen", + "resourceLauncherResourceDetailsDescription": "Anzeigen von Details zu dieser Ressource.", + "resourceLauncherUnlabeled": "Nicht etikettiert", + "resourceLauncherNoSite": "Kein Standort", + "resourceLauncherNoResourcesInGroup": "Keine Ressourcen in dieser Gruppe", + "resourceLauncherEmptyStateTitle": "Keine Ressourcen verfügbar", + "resourceLauncherEmptyStateDescription": "Sie haben noch keinen Zugriff auf Ressourcen. Kontaktieren Sie Ihren Administrator, um Zugriff anzufordern.", + "resourceLauncherEmptyStateNoResultsTitle": "Keine Ressourcen gefunden", + "resourceLauncherEmptyStateNoResultsDescription": "Keine Ressourcen entsprechen Ihrer aktuellen Suche oder den Filtern. Versuchen Sie, diese anzupassen, um zu finden, wonach Sie suchen.", + "resourceLauncherEmptyStateNoResultsWithQuery": "Keine Ressourcen entsprechen \"{query}\". Versuchen Sie, Ihre Suche anzupassen oder die Filter zu löschen, um alle Ressourcen anzuzeigen.", + "resourceLauncherCopiedToClipboard": "In die Zwischenablage kopiert", + "resourceLauncherCopiedAccessDescription": "Der Ressourcenzugriff wurde in Ihre Zwischenablage kopiert.", + "resourceLauncherViewNamePlaceholder": "Ansichtsname", + "resourceLauncherViewNameLabel": "Ansichtsname", + "resourceLauncherViewSaved": "Ansicht gespeichert", + "resourceLauncherViewSavedDescription": "Ihre Startansicht wurde gespeichert.", + "resourceLauncherViewSaveFailed": "Fehler beim Speichern der Ansicht", + "resourceLauncherViewSaveFailedDescription": "Die Startansicht konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.", + "resourceLauncherViewDeleted": "Ansicht gelöscht", + "resourceLauncherViewDeletedDescription": "Die Startansicht wurde gelöscht.", + "resourceLauncherViewDeleteFailed": "Fehler beim Löschen der Ansicht", + "resourceLauncherViewDeleteFailedDescription": "Die Startansicht konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.", "memberPortalPrevious": "Vorherige", "memberPortalNext": "Nächste", "httpSettings": "HTTP-Einstellungen", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyRequired": "Privater Schlüssel ist erforderlich", "vncTitle": "VNC", - "vncSignInDescription": "Geben Sie Ihr VNC-Passwort ein, um sich zu verbinden", + "vncSignInDescription": "Geben Sie Ihre VNC-Zugangsdaten ein, um sich zu verbinden", + "vncUsernameOptional": "Benutzername (optional)", "vncPasswordOptional": "Passwort (optional)", "vncNoResourceTarget": "Kein Ressourcen-Ziel verfügbar", "vncFailedToLoadNovnc": "Fehler beim Laden von noVNC", diff --git a/messages/en-US.json b/messages/en-US.json index c7964f8be..0129c1159 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -123,6 +123,16 @@ "siteUpdated": "Site updated", "siteUpdatedDescription": "The site has been updated.", "siteGeneralDescription": "Configure the general settings for this site", + "siteRestartTitle": "Restart Site", + "siteRestartDescription": "Restart the WireGuard tunnel for this site. This will briefly interrupt connectivity.", + "siteRestartBody": "Use this if the site tunnel is not functioning correctly and you want to force a reconnect without restarting the host.", + "siteRestartButton": "Restart Site", + "siteRestartDialogMessage": "Are you sure you want to restart the WireGuard tunnel for {name}? The site will briefly lose connectivity.", + "siteRestartWarning": "The site will briefly disconnect while the tunnel restarts.", + "siteRestarted": "Site restarted", + "siteRestartedDescription": "The WireGuard tunnel has been restarted.", + "siteErrorRestart": "Failed to restart site", + "siteErrorRestartDescription": "An error occurred while restarting the site.", "siteSettingDescription": "Configure the settings on the site", "siteResourcesTab": "Resources", "siteResourcesNoneOnSite": "This site has no public or private resources yet.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Apply Blueprint", "actionListBlueprints": "List Blueprints", "actionGetBlueprint": "Get Blueprint", + "actionCreateOrgWideLauncherView": "Create Org-Wide Launcher View", "setupToken": "Setup Token", "setupTokenDescription": "Enter the setup token from the server console.", "setupTokenRequired": "Setup token is required", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Subnet", "addressDescription": "The internal address of the client. Must fall within the organization's subnet.", "selectSites": "Select sites", + "selectLabels": "Select labels", "sitesDescription": "The client will have connectivity to the selected sites", "clientInstallOlm": "Install Machine Client", "clientInstallOlmDescription": "Install the machine client for your system", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Site", "selectSite": "Select site...", "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", + "labelsSelectorLabelsCount": "{count, plural, one {# label} other {# labels}}", "noSitesFound": "No sites found.", "createInternalResourceDialogProtocol": "Protocol", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Remote Nodes", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Secret", + "remoteExitNodeNetworkingTitle": "Network Settings", + "remoteExitNodeNetworkingDescription": "Configure how this remote exit node routes traffic and which sites prefer to connect through it. Advanced features to be used with backhaul networking configurations.", + "remoteExitNodeNetworkingSave": "Save Settings", + "remoteExitNodeNetworkingSaveSuccessTitle": "Network settings saved", + "remoteExitNodeNetworkingSaveSuccessDescription": "Network settings have been updated successfully.", + "remoteExitNodeNetworkingSaveError": "Failed to save network settings", + "remoteExitNodeNetworkingSubnetsTitle": "Remote Subnets", + "remoteExitNodeNetworkingSubnetsDescription": "Define the CIDR ranges that this remote exit node will route traffic to. Type a valid CIDR (e.g. 10.0.0.0/8) and press Enter to add.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Add a CIDR range (e.g. 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Failed to load subnets", + "remoteExitNodeNetworkingLabelsTitle": "Preference Labels", + "remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will be enforced to connect through this remote exit node.", + "remoteExitNodeNetworkingLabelsButtonText": "Select labels...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Search labels...", + "remoteExitNodeNetworkingLabelsLoadError": "Failed to load labels", "remoteExitNodeCreate": { "title": "Create Remote Node", "description": "Create a new self-hosted remote relay and proxy server node", @@ -3542,6 +3570,55 @@ "memberPortalEmailWhitelist": "Email Whitelist", "memberPortalResourceDisabled": "Resource Disabled", "memberPortalShowingResources": "Showing {start}-{end} of {total} resources", + "resourceLauncherTitle": "Resource Launcher", + "resourceLauncherDescription": "View resource details and launch them from one place", + "resourceLauncherSearchPlaceholder": "Search all sites...", + "resourceLauncherDefaultView": "Default", + "resourceLauncherSaveView": "Save View", + "resourceLauncherSaveToCurrentView": "Save to Current View", + "resourceLauncherResetView": "Reset View", + "resourceLauncherSaveAsNewView": "Save as New View", + "resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.", + "resourceLauncherSaveForEveryone": "Save for Everyone", + "resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.", + "resourceLauncherMakePersonal": "Make Personal", + "resourceLauncherFilter": "Filter", + "resourceLauncherSort": "Sort", + "resourceLauncherSortAscending": "Sort ascending", + "resourceLauncherSortDescending": "Sort descending", + "resourceLauncherSettings": "Settings", + "resourceLauncherGroupBy": "Group By", + "resourceLauncherGroupBySite": "Site", + "resourceLauncherGroupByLabel": "Label", + "resourceLauncherLayout": "Layout", + "resourceLauncherLayoutGrid": "Grid", + "resourceLauncherLayoutList": "List", + "resourceLauncherShowLabels": "Show Labels", + "resourceLauncherShowSiteTags": "Show Site Tags", + "resourceLauncherShowRecents": "Show Recents", + "resourceLauncherDeleteView": "Delete View", + "resourceLauncherViewAsAdmin": "View as Admin", + "resourceLauncherResourceDetailsDescription": "View details for this resource.", + "resourceLauncherUnlabeled": "Unlabeled", + "resourceLauncherNoSite": "No Site", + "resourceLauncherNoResourcesInGroup": "No resources in this group", + "resourceLauncherEmptyStateTitle": "No Resources Available", + "resourceLauncherEmptyStateDescription": "You don't have access to any resources yet. Contact your administrator to request access.", + "resourceLauncherEmptyStateNoResultsTitle": "No Resources Found", + "resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.", + "resourceLauncherEmptyStateNoResultsWithQuery": "No resources match \"{query}\". Try adjusting your search or clearing filters to see all resources.", + "resourceLauncherCopiedToClipboard": "Copied to clipboard", + "resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.", + "resourceLauncherViewNamePlaceholder": "View name", + "resourceLauncherViewNameLabel": "View Name", + "resourceLauncherViewSaved": "View saved", + "resourceLauncherViewSavedDescription": "Your launcher view has been saved.", + "resourceLauncherViewSaveFailed": "Failed to save view", + "resourceLauncherViewSaveFailedDescription": "Could not save the launcher view. Please try again.", + "resourceLauncherViewDeleted": "View deleted", + "resourceLauncherViewDeletedDescription": "The launcher view has been deleted.", + "resourceLauncherViewDeleteFailed": "Failed to delete view", + "resourceLauncherViewDeleteFailedDescription": "Could not delete the launcher view. Please try again.", "memberPortalPrevious": "Previous", "memberPortalNext": "Next", "httpSettings": "HTTP Settings", @@ -3577,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyRequired": "Private key is required", "vncTitle": "VNC", - "vncSignInDescription": "Enter your VNC password to connect", + "vncSignInDescription": "Enter your VNC credentials to connect", + "vncUsernameOptional": "Username (optional)", "vncPasswordOptional": "Password (optional)", "vncNoResourceTarget": "No resource target is available", "vncFailedToLoadNovnc": "Failed to load noVNC", diff --git a/messages/es-ES.json b/messages/es-ES.json index 7895b15e5..6903dac34 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -123,6 +123,16 @@ "siteUpdated": "Sitio actualizado", "siteUpdatedDescription": "El sitio ha sido actualizado.", "siteGeneralDescription": "Configurar la configuración general de este sitio", + "siteRestartTitle": "Reiniciar Sitio", + "siteRestartDescription": "Reinicia el túnel WireGuard para este sitio. Esto interrumpirá brevemente la conectividad.", + "siteRestartBody": "Utiliza esto si el túnel del sitio no está funcionando correctamente y quieres forzar una reconexión sin reiniciar el host.", + "siteRestartButton": "Reiniciar Sitio", + "siteRestartDialogMessage": "¿Estás seguro de que deseas reiniciar el túnel WireGuard para {name}? El sitio perderá conectividad brevemente.", + "siteRestartWarning": "El sitio se desconectará brevemente mientras se reinicia el túnel.", + "siteRestarted": "Sitio reiniciado", + "siteRestartedDescription": "El túnel WireGuard ha sido reiniciado.", + "siteErrorRestart": "Error al reiniciar el sitio", + "siteErrorRestartDescription": "Se ha producido un error al reiniciar el sitio.", "siteSettingDescription": "Configurar los ajustes en el sitio", "siteResourcesTab": "Recursos", "siteResourcesNoneOnSite": "Este sitio aún no tiene recursos públicos o privados.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Aplicar plano", "actionListBlueprints": "Listar blueprints", "actionGetBlueprint": "Obtener blueprint", + "actionCreateOrgWideLauncherView": "Crear Vista de Lanzador para toda la Organización", "setupToken": "Configuración de token", "setupTokenDescription": "Ingrese el token de configuración desde la consola del servidor.", "setupTokenRequired": "Se requiere el token de configuración", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Subred", "addressDescription": "La dirección interna del cliente. Debe estar dentro de la subred de la organización.", "selectSites": "Seleccionar sitios", + "selectLabels": "Seleccionar etiquetas", "sitesDescription": "El cliente tendrá conectividad con los sitios seleccionados", "clientInstallOlm": "Instalar Olm", "clientInstallOlmDescription": "Obtén Olm funcionando en tu sistema", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Sitio", "selectSite": "Seleccionar sitio...", "multiSitesSelectorSitesCount": "{count, plural, one {# sitio} other {# sitios}}", + "labelsSelectorLabelsCount": "{count, plural, one {# etiqueta} other {# etiquetas}}", "noSitesFound": "Sitios no encontrados.", "createInternalResourceDialogProtocol": "Protocolo", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Nodos remotos", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Secreto", + "remoteExitNodeNetworkingTitle": "Ajustes de Red", + "remoteExitNodeNetworkingDescription": "Configura cómo este nodo de salida remoto dirige el tráfico y qué sitios prefieren conectarse a través de él. Características avanzadas para usar con configuraciones de red de retroceso.", + "remoteExitNodeNetworkingSave": "Guardar Ajustes", + "remoteExitNodeNetworkingSaveSuccessTitle": "Ajustes de red guardados", + "remoteExitNodeNetworkingSaveSuccessDescription": "Los ajustes de red han sido actualizados exitosamente.", + "remoteExitNodeNetworkingSaveError": "Error al guardar los ajustes de red", + "remoteExitNodeNetworkingSubnetsTitle": "Subredes Remotas", + "remoteExitNodeNetworkingSubnetsDescription": "Define los rangos CIDR a los que este nodo de salida remoto dirigirá el tráfico. Escribe un CIDR válido (e.g. 10.0.0.0/8) y presiona Enter para añadir.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Añadir un rango CIDR (e.g. 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Error al cargar las subredes", + "remoteExitNodeNetworkingLabelsTitle": "Etiquetas de Preferencias", + "remoteExitNodeNetworkingLabelsDescription": "Los sitios con estas etiquetas se verán obligados a conectarse a través de este nodo de salida remoto.", + "remoteExitNodeNetworkingLabelsButtonText": "Seleccionar etiquetas...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Buscar etiquetas...", + "remoteExitNodeNetworkingLabelsLoadError": "Error al cargar las etiquetas", "remoteExitNodeCreate": { "title": "Crear nodo remoto", "description": "Crea un nuevo nodo de retransmisión y proxy server autogestionado", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Proveedor OAuth2/OIDC de Google", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Subred", + "utilitySubnet": "Subred de Utilidad", "subnetDescription": "La subred para la configuración de red de esta organización.", "customDomain": "Dominio personalizado", "authPage": "Páginas de autenticación", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "Lista Blanca de Correo", "memberPortalResourceDisabled": "Recurso Deshabilitado", "memberPortalShowingResources": "Mostrando {start}-{end} de {total} recursos", + "resourceLauncherTitle": "Lanzador de Recursos", + "resourceLauncherDescription": "Ve los detalles de los recursos y lánzalos desde un solo lugar", + "resourceLauncherSearchPlaceholder": "Buscar en todos los sitios...", + "resourceLauncherDefaultView": "Predeterminado", + "resourceLauncherSaveView": "Guardar Vista", + "resourceLauncherSaveToCurrentView": "Guardar en la Vista Actual", + "resourceLauncherResetView": "Restablecer Vista", + "resourceLauncherSaveAsNewView": "Guardar como Nueva Vista", + "resourceLauncherSaveAsNewViewDescription": "Ponle un nombre a esta vista para guardar tus filtros y diseño actuales.", + "resourceLauncherSaveForEveryone": "Guardar para Todos", + "resourceLauncherSaveForEveryoneDescription": "Comparte esta vista con todos los miembros de la organización. Si está desmarcado, la vista solo es visible para ti.", + "resourceLauncherMakePersonal": "Hacer Personal", + "resourceLauncherFilter": "Filtro", + "resourceLauncherSort": "Ordenar", + "resourceLauncherSortAscending": "Ordenar Ascendente", + "resourceLauncherSortDescending": "Ordenar Descendente", + "resourceLauncherSettings": "Ajustes", + "resourceLauncherGroupBy": "Agrupar Por", + "resourceLauncherGroupBySite": "Sitio", + "resourceLauncherGroupByLabel": "Etiqueta", + "resourceLauncherLayout": "Disposición", + "resourceLauncherLayoutGrid": "Cuadrícula", + "resourceLauncherLayoutList": "Lista", + "resourceLauncherShowLabels": "Mostrar Etiquetas", + "resourceLauncherShowSiteTags": "Mostrar Etiquetas del Sitio", + "resourceLauncherShowRecents": "Mostrar Recientes", + "resourceLauncherDeleteView": "Eliminar Vista", + "resourceLauncherViewAsAdmin": "Ver como Administrador", + "resourceLauncherResourceDetailsDescription": "Ver detalles de este recurso.", + "resourceLauncherUnlabeled": "Sin Etiqueta", + "resourceLauncherNoSite": "Sin Sitio", + "resourceLauncherNoResourcesInGroup": "No hay recursos en este grupo", + "resourceLauncherEmptyStateTitle": "No hay Recursos Disponibles", + "resourceLauncherEmptyStateDescription": "Todavía no tienes acceso a ningún recurso. Contacta a tu administrador para solicitar acceso.", + "resourceLauncherEmptyStateNoResultsTitle": "No se Encontraron Recursos", + "resourceLauncherEmptyStateNoResultsDescription": "No hay recursos que coincidan con tu búsqueda o filtros actuales. Intenta ajustarlos para encontrar lo que buscas.", + "resourceLauncherEmptyStateNoResultsWithQuery": "No hay recursos que coincidan con \"{query}\". Intenta ajustar tu búsqueda o borrar filtros para ver todos los recursos.", + "resourceLauncherCopiedToClipboard": "Copiado al portapapeles", + "resourceLauncherCopiedAccessDescription": "El acceso al recurso ha sido copiado a tu portapapeles.", + "resourceLauncherViewNamePlaceholder": "Nombre de la Vista", + "resourceLauncherViewNameLabel": "Nombre de la Vista", + "resourceLauncherViewSaved": "Vista guardada", + "resourceLauncherViewSavedDescription": "Tu vista del lanzador ha sido guardada.", + "resourceLauncherViewSaveFailed": "Error al guardar la vista", + "resourceLauncherViewSaveFailedDescription": "No se pudo guardar la vista del lanzador. Por favor, intenta de nuevo.", + "resourceLauncherViewDeleted": "Vista eliminada", + "resourceLauncherViewDeletedDescription": "La vista del lanzador ha sido eliminada.", + "resourceLauncherViewDeleteFailed": "Error al eliminar la vista", + "resourceLauncherViewDeleteFailedDescription": "No se pudo eliminar la vista del lanzador. Por favor, intenta de nuevo.", "memberPortalPrevious": "Anterior", "memberPortalNext": "Siguiente", "httpSettings": "Configuración HTTP", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----COMIENZO DE LA CLAVE PRIVADA OPENSSH-----", "sshPrivateKeyRequired": "Se requiere clave privada", "vncTitle": "VNC", - "vncSignInDescription": "Introduce tu contraseña VNC para conectar", + "vncSignInDescription": "Introduce tus credenciales VNC para conectarte", + "vncUsernameOptional": "Nombre de usuario (opcional)", "vncPasswordOptional": "Contraseña (opcional)", "vncNoResourceTarget": "No hay objetivo de recurso disponible", "vncFailedToLoadNovnc": "Error al cargar noVNC", diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 14a4a5365..f3262c3ef 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -123,6 +123,16 @@ "siteUpdated": "Nœud mis à jour", "siteUpdatedDescription": "Le nœud a été mis à jour.", "siteGeneralDescription": "Configurer les paramètres par défaut de ce nœud", + "siteRestartTitle": "Redémarrer Site", + "siteRestartDescription": "Redémarrer le tunnel WireGuard pour ce site. Cela interrompra brièvement la connectivité.", + "siteRestartBody": "Utilisez cela si le tunnel du site ne fonctionne pas correctement et que vous souhaitez forcer une reconnexion sans redémarrer l'hôte.", + "siteRestartButton": "Redémarrer Site", + "siteRestartDialogMessage": "Êtes-vous sûr de vouloir redémarrer le tunnel WireGuard pour {name}? Le site perdra brièvement sa connectivité.", + "siteRestartWarning": "Le site sera brièvement déconnecté pendant le redémarrage du tunnel.", + "siteRestarted": "Site redémarré", + "siteRestartedDescription": "Le tunnel WireGuard a été redémarré.", + "siteErrorRestart": "Échec du redémarrage du site", + "siteErrorRestartDescription": "Une erreur s'est produite lors du redémarrage du site.", "siteSettingDescription": "Configurer les paramètres du site", "siteResourcesTab": "Ressources", "siteResourcesNoneOnSite": "Ce site n'a pas encore de ressources publiques ou privées.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Appliquer la Config", "actionListBlueprints": "Lister les plans", "actionGetBlueprint": "Obtenez un plan", + "actionCreateOrgWideLauncherView": "Créer une vue de lancement au niveau de l'organisation", "setupToken": "Jeton de configuration", "setupTokenDescription": "Entrez le jeton de configuration depuis la console du serveur.", "setupTokenRequired": "Le jeton de configuration est requis.", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Sous-réseau", "addressDescription": "L'adresse interne du client. Doit être dans le sous-réseau de l'organisation.", "selectSites": "Sélectionner des sites", + "selectLabels": "Sélectionner des étiquettes", "sitesDescription": "Le client aura une connectivité vers les sites sélectionnés", "clientInstallOlm": "Installer Olm", "clientInstallOlmDescription": "Faites fonctionner Olm sur votre système", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Site", "selectSite": "Sélectionner un site...", "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", + "labelsSelectorLabelsCount": "{count, plural, one {# étiquette} other {# étiquettes}}", "noSitesFound": "Aucun site trouvé.", "createInternalResourceDialogProtocol": "Protocole", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Nœuds distants", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Clé secrète", + "remoteExitNodeNetworkingTitle": "Paramètres du réseau", + "remoteExitNodeNetworkingDescription": "Configurez comment ce nœud de sortie distant acheminera le trafic et quels sites préfèrent se connecter via ce dernier. Fonctions avancées à utiliser avec les configurations réseau de retour.", + "remoteExitNodeNetworkingSave": "Enregistrer les paramètres", + "remoteExitNodeNetworkingSaveSuccessTitle": "Paramètres du réseau enregistrés", + "remoteExitNodeNetworkingSaveSuccessDescription": "Les paramètres du réseau ont été mis à jour avec succès.", + "remoteExitNodeNetworkingSaveError": "Échec de l'enregistrement des paramètres du réseau", + "remoteExitNodeNetworkingSubnetsTitle": "Sous-réseaux distants", + "remoteExitNodeNetworkingSubnetsDescription": "Définissez les plages CIDR que ce nœud de sortie distant acheminera. Saisissez un CIDR valide (par exemple 10.0.0.0/8) et appuyez sur Entrée pour ajouter.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Ajouter une plage CIDR (par exemple 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Échec du chargement des sous-réseaux", + "remoteExitNodeNetworkingLabelsTitle": "Étiquettes de préférences", + "remoteExitNodeNetworkingLabelsDescription": "Les sites avec ces étiquettes devront se connecter via ce nœud de sortie distant.", + "remoteExitNodeNetworkingLabelsButtonText": "Sélectionner des étiquettes...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Chercher des étiquettes...", + "remoteExitNodeNetworkingLabelsLoadError": "Échec du chargement des étiquettes", "remoteExitNodeCreate": { "title": "Créer un nœud distant", "description": "Créez un nouveau nœud de relais et de serveur proxy distant auto-hébergé", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Fournisseur Google OAuth2/OIDC", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Sous-réseau", + "utilitySubnet": "Routeur utilitaire", "subnetDescription": "Le sous-réseau de la configuration réseau de cette organisation.", "customDomain": "Domaine personnalisé", "authPage": "Pages d'authentification", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "Liste blanche des e-mails", "memberPortalResourceDisabled": "Ressource désactivée", "memberPortalShowingResources": "Affichage de {start}-{end} sur {total} ressources", + "resourceLauncherTitle": "Lanceur de ressources", + "resourceLauncherDescription": "Afficher les détails des ressources et les lancer depuis un seul endroit", + "resourceLauncherSearchPlaceholder": "Rechercher sur tous les sites...", + "resourceLauncherDefaultView": "Par défaut", + "resourceLauncherSaveView": "Enregistrer la vue", + "resourceLauncherSaveToCurrentView": "Enregistrer dans la vue actuelle", + "resourceLauncherResetView": "Réinitialiser la vue", + "resourceLauncherSaveAsNewView": "Enregistrer comme nouvelle vue", + "resourceLauncherSaveAsNewViewDescription": "Donnez un nom à cette vue pour enregistrer vos filtres et mise en page actuels.", + "resourceLauncherSaveForEveryone": "Enregistrer pour tout le monde", + "resourceLauncherSaveForEveryoneDescription": "Partagez cette vue avec tous les membres de l'organisation. Lorsque décochée, la vue est visible uniquement par vous.", + "resourceLauncherMakePersonal": "Rendre personnel", + "resourceLauncherFilter": "Filtrer", + "resourceLauncherSort": "Trier", + "resourceLauncherSortAscending": "Trier par ordre croissant", + "resourceLauncherSortDescending": "Trier par ordre décroissant", + "resourceLauncherSettings": "Réglages", + "resourceLauncherGroupBy": "Grouper par", + "resourceLauncherGroupBySite": "Nœud", + "resourceLauncherGroupByLabel": "Étiquette", + "resourceLauncherLayout": "Mise en page", + "resourceLauncherLayoutGrid": "Grille", + "resourceLauncherLayoutList": "Liste", + "resourceLauncherShowLabels": "Afficher les étiquettes", + "resourceLauncherShowSiteTags": "Afficher les tags de site", + "resourceLauncherShowRecents": "Afficher les récents", + "resourceLauncherDeleteView": "Supprimer la vue", + "resourceLauncherViewAsAdmin": "Voir en tant qu'admin", + "resourceLauncherResourceDetailsDescription": "Afficher les détails pour cette ressource.", + "resourceLauncherUnlabeled": "Non étiqueté", + "resourceLauncherNoSite": "Aucun nœud", + "resourceLauncherNoResourcesInGroup": "Aucune ressource dans ce groupe", + "resourceLauncherEmptyStateTitle": "Aucune ressource disponible", + "resourceLauncherEmptyStateDescription": "Vous n'avez pas encore accès à des ressources. Contactez votre administrateur pour demander l'accès.", + "resourceLauncherEmptyStateNoResultsTitle": "Aucune ressource trouvée", + "resourceLauncherEmptyStateNoResultsDescription": "Aucune ressource ne correspond à votre recherche ou filtre actuel. Essayez de les ajuster pour trouver ce que vous cherchez.", + "resourceLauncherEmptyStateNoResultsWithQuery": "Aucune ressource ne correspond à \"{query}\". Essayez d'ajuster votre recherche ou de supprimer les filtres pour voir toutes les ressources.", + "resourceLauncherCopiedToClipboard": "Copié dans le presse-papiers", + "resourceLauncherCopiedAccessDescription": "L'accès à la ressource a été copié dans votre presse-papiers.", + "resourceLauncherViewNamePlaceholder": "Nom de la vue", + "resourceLauncherViewNameLabel": "Nom de la vue", + "resourceLauncherViewSaved": "Vue enregistrée", + "resourceLauncherViewSavedDescription": "Votre vue de lancement a été enregistrée.", + "resourceLauncherViewSaveFailed": "Échec de l'enregistrement de la vue", + "resourceLauncherViewSaveFailedDescription": "Impossible d'enregistrer la vue de lancement. Veuillez réessayer.", + "resourceLauncherViewDeleted": "Vue supprimée", + "resourceLauncherViewDeletedDescription": "La vue de lancement a été supprimée.", + "resourceLauncherViewDeleteFailed": "Impossible de supprimer la vue", + "resourceLauncherViewDeleteFailedDescription": "Impossible de supprimer la vue de lancement. Veuillez réessayer.", "memberPortalPrevious": "Précédent", "memberPortalNext": "Suivant", "httpSettings": "Paramètres HTTP", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyRequired": "Une clé privée est requise", "vncTitle": "VNC", - "vncSignInDescription": "Entrez votre mot de passe VNC pour vous connecter", + "vncSignInDescription": "Entrez vos identifiants VNC pour vous connecter", + "vncUsernameOptional": "Nom d'utilisateur (optionnel)", "vncPasswordOptional": "Mot de passe (facultatif)", "vncNoResourceTarget": "Aucune cible de ressource disponible", "vncFailedToLoadNovnc": "Échec du chargement de noVNC", diff --git a/messages/it-IT.json b/messages/it-IT.json index 74066721b..04de61551 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -123,6 +123,16 @@ "siteUpdated": "Sito aggiornato", "siteUpdatedDescription": "Il sito è stato aggiornato.", "siteGeneralDescription": "Configura le impostazioni generali per questo sito", + "siteRestartTitle": "Riavvia Sito", + "siteRestartDescription": "Riavvia il tunnel WireGuard per questo sito. Questo interromperà brevemente la connettività.", + "siteRestartBody": "Usalo se il tunnel del sito non funziona correttamente e vuoi forzare un riconnessione senza riavviare l'host.", + "siteRestartButton": "Riavvia Sito", + "siteRestartDialogMessage": "Sei sicuro di voler riavviare il tunnel WireGuard per {name}? Il sito perderà brevemente la connettività.", + "siteRestartWarning": "Il sito si disconnette brevemente mentre il tunnel si riavvia.", + "siteRestarted": "Sito riavviato", + "siteRestartedDescription": "Il tunnel WireGuard è stato riavviato.", + "siteErrorRestart": "Impossibile riavviare il sito", + "siteErrorRestartDescription": "Si è verificato un errore durante il riavvio del sito.", "siteSettingDescription": "Configura le impostazioni del sito", "siteResourcesTab": "Risorse", "siteResourcesNoneOnSite": "Questo sito non ha ancora risorse pubbliche o private.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Applica Progetto", "actionListBlueprints": "Elenco Blueprints", "actionGetBlueprint": "Ottieni Blueprint", + "actionCreateOrgWideLauncherView": "Crea Visualizzazione Lanscia Org-Wide", "setupToken": "Configura Token", "setupTokenDescription": "Inserisci il token di configurazione dalla console del server.", "setupTokenRequired": "Il token di configurazione è richiesto", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Sottorete", "addressDescription": "L'indirizzo interno del client. Deve rientrare nella sottorete dell'organizzazione.", "selectSites": "Seleziona siti", + "selectLabels": "Seleziona etichette", "sitesDescription": "Il cliente avrà connettività ai siti selezionati", "clientInstallOlm": "Installa Olm", "clientInstallOlmDescription": "Avvia Olm sul tuo sistema", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Sito", "selectSite": "Seleziona sito...", "multiSitesSelectorSitesCount": "{count, plural, one {# sito} other {# siti}}", + "labelsSelectorLabelsCount": "{count, plural, one {# etichetta} other {# etichette}}", "noSitesFound": "Nessun sito trovato.", "createInternalResourceDialogProtocol": "Protocollo", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Nodi Remoti", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Segreto", + "remoteExitNodeNetworkingTitle": "Impostazioni di Rete", + "remoteExitNodeNetworkingDescription": "Configura come questo nodo di uscita remoto indirizza il traffico e quali siti preferiscono connettersi tramite esso. Caratteristiche avanzate da utilizzare con le configurazioni di rete backhaul.", + "remoteExitNodeNetworkingSave": "Salva Impostazioni", + "remoteExitNodeNetworkingSaveSuccessTitle": "Impostazioni di rete salvate", + "remoteExitNodeNetworkingSaveSuccessDescription": "Le impostazioni di rete sono state aggiornate con successo.", + "remoteExitNodeNetworkingSaveError": "Impossibile salvare le impostazioni di rete", + "remoteExitNodeNetworkingSubnetsTitle": "Sottoreti Remote", + "remoteExitNodeNetworkingSubnetsDescription": "Definisci gli intervalli CIDR che questo nodo di uscita remota inoltrerà il traffico. Digita un CIDR valido (ad esempio 10.0.0.0/8) e premi Invio per aggiungerlo.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Aggiungi un intervallo CIDR (ad esempio 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Caricamento sottoreti fallito", + "remoteExitNodeNetworkingLabelsTitle": "Etichette Preferenze", + "remoteExitNodeNetworkingLabelsDescription": "I siti con queste etichette saranno collegati attraverso questo nodo di uscita remoto.", + "remoteExitNodeNetworkingLabelsButtonText": "Seleziona etichette...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Cerca etichette...", + "remoteExitNodeNetworkingLabelsLoadError": "Caricamento etichette fallito", "remoteExitNodeCreate": { "title": "Crea Nodo Remoto", "description": "Crea un nuovo nodo server proxy e relay remoto ospitato in proprio", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Google OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Sottorete", + "utilitySubnet": "Sottorete di utilità", "subnetDescription": "La sottorete per la configurazione di rete di questa organizzazione.", "customDomain": "Dominio Personalizzato", "authPage": "Pagine di Autenticazione", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "Lista Autorizzazioni Email", "memberPortalResourceDisabled": "Risorsa Disabilitata", "memberPortalShowingResources": "Mostrando {start}-{end} di {total} risorse", + "resourceLauncherTitle": "Lanscia Risorse", + "resourceLauncherDescription": "Visualizza i dettagli delle risorse e lanciale da un solo posto", + "resourceLauncherSearchPlaceholder": "Cerca tutti i siti...", + "resourceLauncherDefaultView": "Predefinito", + "resourceLauncherSaveView": "Salva Visualizzazione", + "resourceLauncherSaveToCurrentView": "Salva alla Visualizzazione Corrente", + "resourceLauncherResetView": "Reimposta Visualizzazione", + "resourceLauncherSaveAsNewView": "Salva come Nuova Visualizzazione", + "resourceLauncherSaveAsNewViewDescription": "Dai un nome a questa visualizzazione per salvare i tuoi filtri e layout attuali.", + "resourceLauncherSaveForEveryone": "Salva per Tutti", + "resourceLauncherSaveForEveryoneDescription": "Condividi questa visualizzazione con tutti i membri dell'organizzazione. Quando non è selezionata, la visualizzazione è visibile solo a te.", + "resourceLauncherMakePersonal": "Rendi Personale", + "resourceLauncherFilter": "Filtro", + "resourceLauncherSort": "Ordina", + "resourceLauncherSortAscending": "Ordina in ordine crescente", + "resourceLauncherSortDescending": "Ordina in ordine decrescente", + "resourceLauncherSettings": "Impostazioni", + "resourceLauncherGroupBy": "Raggruppa per", + "resourceLauncherGroupBySite": "Sito", + "resourceLauncherGroupByLabel": "Etichetta", + "resourceLauncherLayout": "Layout", + "resourceLauncherLayoutGrid": "Griglia", + "resourceLauncherLayoutList": "Lista", + "resourceLauncherShowLabels": "Mostra Etichette", + "resourceLauncherShowSiteTags": "Mostra Tag di Sito", + "resourceLauncherShowRecents": "Mostra Recenti", + "resourceLauncherDeleteView": "Elimina Visualizzazione", + "resourceLauncherViewAsAdmin": "Visualizza come Admin", + "resourceLauncherResourceDetailsDescription": "Visualizza i dettagli per questa risorsa.", + "resourceLauncherUnlabeled": "Non Etichettato", + "resourceLauncherNoSite": "Nessun Sito", + "resourceLauncherNoResourcesInGroup": "Nessuna risorsa in questo gruppo", + "resourceLauncherEmptyStateTitle": "Non ci sono risorse disponibili", + "resourceLauncherEmptyStateDescription": "Non hai ancora accesso a nessuna risorsa. Contatta il tuo amministratore per richiedere l'accesso.", + "resourceLauncherEmptyStateNoResultsTitle": "Nessuna risorsa trovata", + "resourceLauncherEmptyStateNoResultsDescription": "Nessuna risorsa corrisponde alla tua ricerca o ai tuoi filtri attuali. Prova a modificarli per trovare ciò che stai cercando.", + "resourceLauncherEmptyStateNoResultsWithQuery": "Nessuna risorsa corrisponde a \"{query}\". Prova a modificare la tua ricerca o a cancellare i filtri per vedere tutte le risorse.", + "resourceLauncherCopiedToClipboard": "Copiato negli appunti", + "resourceLauncherCopiedAccessDescription": "L'accesso alla risorsa è stato copiato nei tuoi appunti.", + "resourceLauncherViewNamePlaceholder": "Nome Visualizzazione", + "resourceLauncherViewNameLabel": "Nome Visualizzazione", + "resourceLauncherViewSaved": "Visualizzazione salvata", + "resourceLauncherViewSavedDescription": "La tua visualizzazione del lanscia è stata salvata.", + "resourceLauncherViewSaveFailed": "Impossibile salvare la visualizzazione", + "resourceLauncherViewSaveFailedDescription": "Impossibile salvare la visualizzazione del lanscia. Per favore riprova.", + "resourceLauncherViewDeleted": "Visualizzazione eliminata", + "resourceLauncherViewDeletedDescription": "La visualizzazione del lanscia è stata eliminata.", + "resourceLauncherViewDeleteFailed": "Impossibile eliminare la visualizzazione", + "resourceLauncherViewDeleteFailedDescription": "Non è stato possibile eliminare la visualizzazione del lanscia. Per favore riprova.", "memberPortalPrevious": "Precedente", "memberPortalNext": "Successivo", "httpSettings": "Impostazioni HTTP", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyRequired": "È richiesta una chiave privata", "vncTitle": "VNC", - "vncSignInDescription": "Inserisci la tua password VNC per connetterti", + "vncSignInDescription": "Inserisci le tue credenziali VNC per connetterti", + "vncUsernameOptional": "Nome utente (facoltativo)", "vncPasswordOptional": "Password (opzionale)", "vncNoResourceTarget": "Nessun bersaglio di risorsa disponibile", "vncFailedToLoadNovnc": "Impossibile caricare noVNC", diff --git a/messages/ko-KR.json b/messages/ko-KR.json index a53e76d18..85cc96816 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -123,6 +123,16 @@ "siteUpdated": "사이트가 업데이트되었습니다", "siteUpdatedDescription": "사이트가 업데이트되었습니다.", "siteGeneralDescription": "이 사이트에 대한 일반 설정을 구성하세요.", + "siteRestartTitle": "사이트 다시 시작", + "siteRestartDescription": "이 사이트의 WireGuard 터널을 다시 시작합니다. 일시적으로 연결이 중단될 수 있습니다.", + "siteRestartBody": "사이트 터널이 제대로 작동하지 않을 경우, 호스트를 재시작하지 않고 다시 연결을 강제하려면 이 옵션을 사용하세요.", + "siteRestartButton": "사이트 다시 시작", + "siteRestartDialogMessage": "{name}의 WireGuard 터널을 재시작하시겠습니까? 이 작업으로 인해 사이트의 연결이 일시적으로 중단될 수 있습니다.", + "siteRestartWarning": "터널을 재시작하는 동안 사이트가 일시적으로 연결이 끊깁니다.", + "siteRestarted": "사이트가 재시작되었습니다", + "siteRestartedDescription": "WireGuard 터널이 재시작되었습니다.", + "siteErrorRestart": "사이트 재시작 실패", + "siteErrorRestartDescription": "사이트를 재시작하는 중 오류가 발생했습니다.", "siteSettingDescription": "사이트에서 설정을 구성하세요.", "siteResourcesTab": "리소스", "siteResourcesNoneOnSite": "이 사이트에는 아직 공용 또는 개인 리소스가 없습니다.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "청사진 적용", "actionListBlueprints": "청사진 목록", "actionGetBlueprint": "청사진 가져오기", + "actionCreateOrgWideLauncherView": "조직 전체 런처 보기 생성", "setupToken": "설정 토큰", "setupTokenDescription": "서버 콘솔에서 설정 토큰 입력.", "setupTokenRequired": "설정 토큰이 필요합니다", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "서브넷", "addressDescription": "클라이언트의 내부 주소. 조직의 서브넷 내에 있어야 합니다.", "selectSites": "사이트 선택", + "selectLabels": "레이블 선택", "sitesDescription": "클라이언트는 선택한 사이트에 연결됩니다.", "clientInstallOlm": "Olm 설치", "clientInstallOlmDescription": "시스템에서 Olm을 실행하기", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "사이트", "selectSite": "사이트 선택...", "multiSitesSelectorSitesCount": "{count, plural, other {# 사이트}}", + "labelsSelectorLabelsCount": "{count, plural, one {# 레이블} other {# 레이블}}", "noSitesFound": "사이트를 찾을 수 없습니다.", "createInternalResourceDialogProtocol": "프로토콜", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "원격 노드", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "비밀", + "remoteExitNodeNetworkingTitle": "네트워크 설정", + "remoteExitNodeNetworkingDescription": "이 원격 출구 노드의 트래픽 라우팅 방법과 어떤 사이트가 이를 통해 연결하는지 구성합니다. 백홀 네트워킹 구성을 사용한 고급 기능입니다.", + "remoteExitNodeNetworkingSave": "설정 저장", + "remoteExitNodeNetworkingSaveSuccessTitle": "네트워크 설정이 저장되었습니다", + "remoteExitNodeNetworkingSaveSuccessDescription": "네트워크 설정이 성공적으로 업데이트되었습니다.", + "remoteExitNodeNetworkingSaveError": "네트워크 설정 저장 실패", + "remoteExitNodeNetworkingSubnetsTitle": "원격 서브넷", + "remoteExitNodeNetworkingSubnetsDescription": "이 원격 출구 노드가 트래픽을 라우팅할 CIDR 범위를 정의합니다. 유효한 CIDR을 입력하고 Enter를 눌러 추가하세요 (예: 10.0.0.0/8).", + "remoteExitNodeNetworkingSubnetsPlaceholder": "CIDR 범위 추가 (예: 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "서브넷 로드 실패", + "remoteExitNodeNetworkingLabelsTitle": "우선순위 레이블", + "remoteExitNodeNetworkingLabelsDescription": "이 레이블이 있는 사이트는 이 원격 출구 노드를 통해 연결됩니다.", + "remoteExitNodeNetworkingLabelsButtonText": "레이블 선택...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "레이블 검색...", + "remoteExitNodeNetworkingLabelsLoadError": "레이블 로드 실패", "remoteExitNodeCreate": { "title": "원격 노드 생성", "description": "새로운 자체 호스팅 원격 중계 및 프록시 서버 노드를 생성하십시오.", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Google OAuth2/OIDC 공급자", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC 공급자", "subnet": "서브넷", + "utilitySubnet": "유틸리티 서브넷", "subnetDescription": "이 조직의 네트워크 구성에 대한 서브넷입니다.", "customDomain": "사용자 정의 도메인", "authPage": "인증 페이지", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "이메일 화이트리스트", "memberPortalResourceDisabled": "리소스 비활성화됨", "memberPortalShowingResources": "{start}-{end} 중 {total}개의 리소스를 표시 중", + "resourceLauncherTitle": "리소스 런처", + "resourceLauncherDescription": "리소스 세부 정보를 보고 한 곳에서 실행하세요", + "resourceLauncherSearchPlaceholder": "모든 사이트 검색...", + "resourceLauncherDefaultView": "기본값", + "resourceLauncherSaveView": "보기를 저장", + "resourceLauncherSaveToCurrentView": "현재 보기로 저장", + "resourceLauncherResetView": "보기를 재설정", + "resourceLauncherSaveAsNewView": "새 보기로 저장", + "resourceLauncherSaveAsNewViewDescription": "현재 필터와 레이아웃을 저장할 이름을 입력하세요.", + "resourceLauncherSaveForEveryone": "모두에게 저장", + "resourceLauncherSaveForEveryoneDescription": "이 보기를 모든 조직 구성원과 공유합니다. 체크 해제하면 해당 뷰는 사용자에게만 표시됩니다.", + "resourceLauncherMakePersonal": "개인적으로 만들기", + "resourceLauncherFilter": "필터", + "resourceLauncherSort": "정렬", + "resourceLauncherSortAscending": "오름차순 정렬", + "resourceLauncherSortDescending": "내림차순 정렬", + "resourceLauncherSettings": "설정", + "resourceLauncherGroupBy": "그룹화 기준", + "resourceLauncherGroupBySite": "사이트", + "resourceLauncherGroupByLabel": "레이블", + "resourceLauncherLayout": "레이아웃", + "resourceLauncherLayoutGrid": "그리드", + "resourceLauncherLayoutList": "목록", + "resourceLauncherShowLabels": "레이블 표시", + "resourceLauncherShowSiteTags": "사이트 태그 표시", + "resourceLauncherShowRecents": "최근 항목 표시", + "resourceLauncherDeleteView": "보기 삭제", + "resourceLauncherViewAsAdmin": "관리자로 보기", + "resourceLauncherResourceDetailsDescription": "이 리소스의 세부정보를 봅니다.", + "resourceLauncherUnlabeled": "레이블 없음", + "resourceLauncherNoSite": "사이트 없음", + "resourceLauncherNoResourcesInGroup": "이 그룹에는 리소스가 없습니다", + "resourceLauncherEmptyStateTitle": "사용 가능한 리소스 없음", + "resourceLauncherEmptyStateDescription": "아직 리소스에 대한 액세스 권한이 없습니다. 액세스를 요청하려면 관리자에게 문의하세요.", + "resourceLauncherEmptyStateNoResultsTitle": "리소스를 찾을 수 없음", + "resourceLauncherEmptyStateNoResultsDescription": "현재 검색이나 필터에 맞는 리소스가 없습니다. 필터를 조정하여 찾으려는 항목을 확인해보세요.", + "resourceLauncherEmptyStateNoResultsWithQuery": "\"{query}\"와 일치하는 리소스가 없습니다. 검색을 조정하거나 필터를 지워서 모든 리소스를 확인해보세요.", + "resourceLauncherCopiedToClipboard": "클립보드에 복사됨", + "resourceLauncherCopiedAccessDescription": "리소스 액세스가 클립보드에 복사되었습니다.", + "resourceLauncherViewNamePlaceholder": "보기 이름", + "resourceLauncherViewNameLabel": "뷰 이름", + "resourceLauncherViewSaved": "보기 저장됨", + "resourceLauncherViewSavedDescription": "런처 뷰가 저장되었습니다.", + "resourceLauncherViewSaveFailed": "뷰 저장 실패", + "resourceLauncherViewSaveFailedDescription": "런처 뷰를 저장할 수 없습니다. 다시 시도하세요.", + "resourceLauncherViewDeleted": "보기 삭제됨", + "resourceLauncherViewDeletedDescription": "런처 뷰가 삭제되었습니다.", + "resourceLauncherViewDeleteFailed": "뷰 삭제 실패", + "resourceLauncherViewDeleteFailedDescription": "런처 뷰를 삭제할 수 없습니다. 다시 시도하세요.", "memberPortalPrevious": "이전", "memberPortalNext": "다음", "httpSettings": "HTTP 설정", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyRequired": "프라이빗 키가 필요합니다", "vncTitle": "VNC", - "vncSignInDescription": "연결하려면 VNC 비밀번호를 입력하세요", + "vncSignInDescription": "연결하기 위해 VNC 자격 증명을 입력하세요", + "vncUsernameOptional": "사용자 이름 (선택 사항)", "vncPasswordOptional": "비밀번호 (선택 사항)", "vncNoResourceTarget": "사용할 수 있는 리소스 대상이 없습니다", "vncFailedToLoadNovnc": "noVNC 로드를 실패했습니다", diff --git a/messages/nb-NO.json b/messages/nb-NO.json index f26499819..ec65f9307 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -123,6 +123,16 @@ "siteUpdated": "Område oppdatert", "siteUpdatedDescription": "Området har blitt oppdatert.", "siteGeneralDescription": "Konfigurer de generelle innstillingene for dette området", + "siteRestartTitle": "Start område på nytt", + "siteRestartDescription": "Start WireGuard-tunnelen for dette området på nytt. Dette vil midlertidig avbryte tilkoblingen.", + "siteRestartBody": "Bruk dette hvis områdetunnelen ikke fungerer riktig og du vil tvinge en ny tilkobling uten å starte verten på nytt.", + "siteRestartButton": "Start område på nytt", + "siteRestartDialogMessage": "Er du sikker på at du vil starte WireGuard-tunnelen for {name} på nytt? Området vil midlertidig miste tilkoblingen.", + "siteRestartWarning": "Området vil kobles kort fra mens tunnelen starter om.", + "siteRestarted": "Område startet på nytt", + "siteRestartedDescription": "WireGuard-tunnelen er startet på nytt.", + "siteErrorRestart": "Kan ikke starte område på nytt", + "siteErrorRestartDescription": "En feil oppstod ved omstart av området.", "siteSettingDescription": "Konfigurere innstillingene på nettstedet", "siteResourcesTab": "Ressurser", "siteResourcesNoneOnSite": "Dette nettstedet har ingen offentlige eller private ressurser enda.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Bruk blåkopi", "actionListBlueprints": "List opp blåkopier", "actionGetBlueprint": "Hent blåkopi", + "actionCreateOrgWideLauncherView": "Opprett lanseringsvisning for hele organisasjonen", "setupToken": "Oppsetttoken", "setupTokenDescription": "Skriv inn oppsetttoken fra serverkonsollen.", "setupTokenRequired": "Oppsetttoken er nødvendig", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Subnett", "addressDescription": "Den interne adressen til klienten. Må falle innenfor organisasjonens undernett.", "selectSites": "Velg områder", + "selectLabels": "Velg etiketter", "sitesDescription": "Klienten vil ha tilkobling til de valgte områdene", "clientInstallOlm": "Installer Olm", "clientInstallOlmDescription": "Få Olm til å kjøre på systemet ditt", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Område", "selectSite": "Velg område...", "multiSitesSelectorSitesCount": "{count, plural, one {# sted} other {# steder}}", + "labelsSelectorLabelsCount": "{count, plural, one {en etikett} other {# etiketter}}", "noSitesFound": "Ingen områder funnet.", "createInternalResourceDialogProtocol": "Protokoll", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Eksterne Noder", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Sikkerhetsnøkkel", + "remoteExitNodeNetworkingTitle": "Nettverksinnstillinger", + "remoteExitNodeNetworkingDescription": "Konfigurer hvordan denne fjerne utgangsnoden ruter trafikk og hvilke områder som foretrekker å koble gjennom den. Avanserte funksjoner for å brukes med bakhalstilkoplingskonfigurasjoner.", + "remoteExitNodeNetworkingSave": "Lagre innstillinger", + "remoteExitNodeNetworkingSaveSuccessTitle": "Nettverksinnstillinger lagret", + "remoteExitNodeNetworkingSaveSuccessDescription": "Nettverksinnstillingene er oppdatert.", + "remoteExitNodeNetworkingSaveError": "Klarte ikke å lagre nettverksinnstillinger", + "remoteExitNodeNetworkingSubnetsTitle": "Fjern-subnett", + "remoteExitNodeNetworkingSubnetsDescription": "Definer CIDR-områdene som denne fjernutgangsnoden vil rute trafikk til. Skriv inn en gyldig CIDR (f.eks. 10.0.0.0/8) og trykk Enter for å legge til.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Legg til et CIDR-område (f.eks. 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Feil ved lasting av subnett", + "remoteExitNodeNetworkingLabelsTitle": "Preferanseetiketter", + "remoteExitNodeNetworkingLabelsDescription": "Områder med disse etikettene vil bli tvunget til å koble gjennom denne fjerne utgangsnoden.", + "remoteExitNodeNetworkingLabelsButtonText": "Velg etiketter...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Søk etiketter...", + "remoteExitNodeNetworkingLabelsLoadError": "Feil ved lasting av etiketter", "remoteExitNodeCreate": { "title": "Opprett ekstern node", "description": "Opprett en ny egendrift ekstern relé- og proxyservernode", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Google OAuth2/OIDC leverandør", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Subnett", + "utilitySubnet": "Nyttesubnett", "subnetDescription": "Undernettverket for denne organisasjonens nettverkskonfigurasjon.", "customDomain": "Egendefinert domene", "authPage": "Autentiseringssider", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "E-post-hviteliste", "memberPortalResourceDisabled": "Ressurs deaktivert", "memberPortalShowingResources": "Viser {start}-{end} av {total} ressurser", + "resourceLauncherTitle": "Ressurslansering", + "resourceLauncherDescription": "Vis ressursdetaljer og start dem fra ett sted", + "resourceLauncherSearchPlaceholder": "Søk i alle områder...", + "resourceLauncherDefaultView": "Standard", + "resourceLauncherSaveView": "Lagre visning", + "resourceLauncherSaveToCurrentView": "Lagre til nåværende visning", + "resourceLauncherResetView": "Tilbakestill visning", + "resourceLauncherSaveAsNewView": "Lagre som ny visning", + "resourceLauncherSaveAsNewViewDescription": "Gi denne visningen et navn for å lagre dine nåværende filtre og oppsett.", + "resourceLauncherSaveForEveryone": "Lagre for alle", + "resourceLauncherSaveForEveryoneDescription": "Del denne visningen med alle organisasjonsmedlemmer. Når avkrysset, er visningen synlig bare for deg.", + "resourceLauncherMakePersonal": "Gjør personlig", + "resourceLauncherFilter": "Filter", + "resourceLauncherSort": "Sorter", + "resourceLauncherSortAscending": "Sorter stigende", + "resourceLauncherSortDescending": "Sorter synkende", + "resourceLauncherSettings": "Innstillinger", + "resourceLauncherGroupBy": "Grupper etter", + "resourceLauncherGroupBySite": "Område", + "resourceLauncherGroupByLabel": "Etikett", + "resourceLauncherLayout": "Oppsett", + "resourceLauncherLayoutGrid": "Rutenett", + "resourceLauncherLayoutList": "Liste", + "resourceLauncherShowLabels": "Vis etiketter", + "resourceLauncherShowSiteTags": "Vis områdestikkord", + "resourceLauncherShowRecents": "Vis nylige", + "resourceLauncherDeleteView": "Slett visning", + "resourceLauncherViewAsAdmin": "Vis som administrator", + "resourceLauncherResourceDetailsDescription": "Vis detaljer for denne ressursen.", + "resourceLauncherUnlabeled": "Umerket", + "resourceLauncherNoSite": "Ingen område", + "resourceLauncherNoResourcesInGroup": "Ingen ressurser i denne gruppen", + "resourceLauncherEmptyStateTitle": "Ingen tilgjengelige ressurser", + "resourceLauncherEmptyStateDescription": "Du har ennå ikke tilgang til noen ressurser. Kontakt administratoren din for å be om tilgang.", + "resourceLauncherEmptyStateNoResultsTitle": "Ingen ressurser funnet", + "resourceLauncherEmptyStateNoResultsDescription": "Ingen ressurser matcher dine nåværende søk eller filtre. Prøv å justere dem for å finne det du leter etter.", + "resourceLauncherEmptyStateNoResultsWithQuery": "Ingen ressurser samsvarer med \"{query}\". Prøv å justere søket eller fjern filtrene for å se alle ressursene.", + "resourceLauncherCopiedToClipboard": "Kopiert til utklippstavlen", + "resourceLauncherCopiedAccessDescription": "Ressurstilgang er kopiert til utklippstavlen din.", + "resourceLauncherViewNamePlaceholder": "Visningsnavn", + "resourceLauncherViewNameLabel": "Visningsnavn", + "resourceLauncherViewSaved": "Visning lagret", + "resourceLauncherViewSavedDescription": "Lanseringsvisningen din er lagret.", + "resourceLauncherViewSaveFailed": "Feilet å lagre visning", + "resourceLauncherViewSaveFailedDescription": "Kunne ikke lagre lanseringsvisningen. Vennligst prøv igjen.", + "resourceLauncherViewDeleted": "Visning slettet", + "resourceLauncherViewDeletedDescription": "Lanseringsvisningen er slettet.", + "resourceLauncherViewDeleteFailed": "Klarte ikke å slette visning", + "resourceLauncherViewDeleteFailedDescription": "Kunne ikke slette lanseringsvisningen. Vennligst prøv igjen.", "memberPortalPrevious": "Forrige", "memberPortalNext": "Neste", "httpSettings": "HTTP Innstillinger", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BEGYNN OPENSSH PRIVAT NØKKEL-----", "sshPrivateKeyRequired": "Privat nøkkel er påkrevd", "vncTitle": "VNC", - "vncSignInDescription": "Skriv inn VNC-passordet for å koble til", + "vncSignInDescription": "Skriv inn VNC-kredentialene dine for å koble til", + "vncUsernameOptional": "Brukernavn (valgfritt)", "vncPasswordOptional": "Passord (valgfritt)", "vncNoResourceTarget": "Ingen ressursemål tilgjengelig", "vncFailedToLoadNovnc": "Klarte ikke å laste noVNC", diff --git a/messages/nl-NL.json b/messages/nl-NL.json index a483c147e..702ff0325 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -123,6 +123,16 @@ "siteUpdated": "Site bijgewerkt", "siteUpdatedDescription": "De site is bijgewerkt.", "siteGeneralDescription": "Algemene instellingen voor deze site configureren", + "siteRestartTitle": "Herstart Site", + "siteRestartDescription": "Herstart de WireGuard-tunnel voor deze site. Dit zal de connectiviteit kort onderbreken.", + "siteRestartBody": "Gebruik dit als de sitetunnel niet correct functioneert en je wilt een herverbinding forceren zonder de host opnieuw op te starten.", + "siteRestartButton": "Herstart Site", + "siteRestartDialogMessage": "Weet u zeker dat u de WireGuard-tunnel voor {name} wilt herstarten? De site zal tijdelijk geen connectiviteit hebben.", + "siteRestartWarning": "De site zal kort worden losgekoppeld terwijl de tunnel opnieuw wordt gestart.", + "siteRestarted": "Site herstart", + "siteRestartedDescription": "De WireGuard-tunnel is opnieuw gestart.", + "siteErrorRestart": "Site herstarten mislukt", + "siteErrorRestartDescription": "Er is een fout opgetreden tijdens het herstarten van de site.", "siteSettingDescription": "Configureer de instellingen van de site", "siteResourcesTab": "Bronnen", "siteResourcesNoneOnSite": "Deze site heeft nog geen openbare of privébronnen.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Blauwdruk toepassen", "actionListBlueprints": "Lijst blauwdrukken", "actionGetBlueprint": "Krijg Blauwdruk", + "actionCreateOrgWideLauncherView": "Maak Organisatiebrede Launcher Weergave", "setupToken": "Instel Token", "setupTokenDescription": "Voer het setup-token in vanaf de serverconsole.", "setupTokenRequired": "Setup-token is vereist", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Subnet", "addressDescription": "Het interne adres van de klant. Moet binnen het subnetwerk van de organisatie vallen.", "selectSites": "Selecteer sites", + "selectLabels": "Selecteer labels", "sitesDescription": "De client heeft connectiviteit met de geselecteerde sites", "clientInstallOlm": "Installeer Olm", "clientInstallOlmDescription": "Laat Olm draaien op uw systeem", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Site", "selectSite": "Selecteer site...", "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", + "labelsSelectorLabelsCount": "{count, plural, one {# label} other {# labels}}", "noSitesFound": "Geen sites gevonden.", "createInternalResourceDialogProtocol": "Protocol", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Externe knooppunten", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Geheim", + "remoteExitNodeNetworkingTitle": "Netwerkinstellingen", + "remoteExitNodeNetworkingDescription": "Configureer hoe dit externe exit-knooppunt verkeer routeert en welke sites de voorkeur hebben om er doorheen te verbinden. Geavanceerde functies te gebruiken met backhaul-netwerkconfiguraties.", + "remoteExitNodeNetworkingSave": "Instellingen opslaan", + "remoteExitNodeNetworkingSaveSuccessTitle": "Netwerkinstellingen opgeslagen", + "remoteExitNodeNetworkingSaveSuccessDescription": "Netwerkinstellingen zijn succesvol bijgewerkt.", + "remoteExitNodeNetworkingSaveError": "Kon netwerkinstellingen niet opslaan", + "remoteExitNodeNetworkingSubnetsTitle": "Externe Subnets", + "remoteExitNodeNetworkingSubnetsDescription": "Definieer de CIDR-bereiken waarnaar dit externe exit-knooppunt verkeer zal routeren. Voer een geldige CIDR in (bijv. 10.0.0.0/8) en druk op Enter om toe te voegen.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Voeg een CIDR-bereik toe (bijv. 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Kon subnets niet laden", + "remoteExitNodeNetworkingLabelsTitle": "Voorkeurslabels", + "remoteExitNodeNetworkingLabelsDescription": "Sites met deze labels worden verplicht om verbinding te maken via dit externe exit-knooppunt.", + "remoteExitNodeNetworkingLabelsButtonText": "Selecteer labels...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Labels zoeken...", + "remoteExitNodeNetworkingLabelsLoadError": "Kon labels niet laden", "remoteExitNodeCreate": { "title": "Externe knoop aanmaken", "description": "Maak een nieuwe zelf-gehoste externe relais- en proxyservermodule", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Google OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Subnet", + "utilitySubnet": "Hulpmiddel Subnet", "subnetDescription": "Het subnet van de netwerkconfiguratie van deze organisatie.", "customDomain": "Aangepast domein", "authPage": "Authenticatiepagina's", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "E-mail whitelist", "memberPortalResourceDisabled": "Bron Uitgeschakeld", "memberPortalShowingResources": "Toont {start}-{end} van {total} bronnen", + "resourceLauncherTitle": "Bron Launcher", + "resourceLauncherDescription": "Bekijk brongegevens en start ze vanaf één plek", + "resourceLauncherSearchPlaceholder": "Zoek alle sites...", + "resourceLauncherDefaultView": "Standaard", + "resourceLauncherSaveView": "Weergave Opslaan", + "resourceLauncherSaveToCurrentView": "Opslaan naar huidige weergave", + "resourceLauncherResetView": "Weergave Herstellen", + "resourceLauncherSaveAsNewView": "Opslaan als Nieuwe Weergave", + "resourceLauncherSaveAsNewViewDescription": "Geef deze weergave een naam om je huidige filters en indeling op te slaan.", + "resourceLauncherSaveForEveryone": "Opslaan voor Iedereen", + "resourceLauncherSaveForEveryoneDescription": "Deel deze weergave met alle organisatieleden. Als dit niet is aangevinkt, is de weergave alleen zichtbaar voor jou.", + "resourceLauncherMakePersonal": "Persoonlijk Maken", + "resourceLauncherFilter": "Filter", + "resourceLauncherSort": "Sorteren", + "resourceLauncherSortAscending": "Oplopend sorteren", + "resourceLauncherSortDescending": "Aflopend sorteren", + "resourceLauncherSettings": "Instellingen", + "resourceLauncherGroupBy": "Groep Op", + "resourceLauncherGroupBySite": "Site", + "resourceLauncherGroupByLabel": "Label", + "resourceLauncherLayout": "Lay-out", + "resourceLauncherLayoutGrid": "Raster", + "resourceLauncherLayoutList": "Lijst", + "resourceLauncherShowLabels": "Labels Weergeven", + "resourceLauncherShowSiteTags": "Site Tags Weergeven", + "resourceLauncherShowRecents": "Recente Weergeven", + "resourceLauncherDeleteView": "Weergave Verwijderen", + "resourceLauncherViewAsAdmin": "Bekijk als Admin", + "resourceLauncherResourceDetailsDescription": "Bekijk details voor deze bron.", + "resourceLauncherUnlabeled": "Geen label", + "resourceLauncherNoSite": "Geen Site", + "resourceLauncherNoResourcesInGroup": "Geen bronnen in deze groep", + "resourceLauncherEmptyStateTitle": "Geen Bronnen Beschikbaar", + "resourceLauncherEmptyStateDescription": "Je hebt nog geen toegang tot bronnen. Neem contact op met je beheerder om toegang aan te vragen.", + "resourceLauncherEmptyStateNoResultsTitle": "Geen Bronnen Gevonden", + "resourceLauncherEmptyStateNoResultsDescription": "Geen bronnen komen overeen met je huidige zoekopdracht of filters. Probeer ze aan te passen om te vinden wat je zoekt.", + "resourceLauncherEmptyStateNoResultsWithQuery": "Geen bronnen komen overeen met \"{query}\". Probeer je zoekopdracht aan te passen of filters te wissen om alle bronnen te zien.", + "resourceLauncherCopiedToClipboard": "Gekopieerd naar klembord", + "resourceLauncherCopiedAccessDescription": "Toegang tot bron is gekopieerd naar je klembord.", + "resourceLauncherViewNamePlaceholder": "Weergavenaam", + "resourceLauncherViewNameLabel": "Weergavenaam", + "resourceLauncherViewSaved": "Weergave opgeslagen", + "resourceLauncherViewSavedDescription": "Je launcher-weergave is opgeslagen.", + "resourceLauncherViewSaveFailed": "Kon weergave niet opslaan", + "resourceLauncherViewSaveFailedDescription": "Kon de launcher-weergave niet opslaan. Probeer het opnieuw.", + "resourceLauncherViewDeleted": "Weergave verwijderd", + "resourceLauncherViewDeletedDescription": "De launcher-weergave is verwijderd.", + "resourceLauncherViewDeleteFailed": "Kon weergave niet verwijderen", + "resourceLauncherViewDeleteFailedDescription": "Kon de launcher-weergave niet verwijderen. Probeer het opnieuw.", "memberPortalPrevious": "Vorige", "memberPortalNext": "Volgende", "httpSettings": "HTTP-instellingen", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyRequired": "Privésleutel is vereist", "vncTitle": "VNC", - "vncSignInDescription": "Voer uw VNC-wachtwoord in om verbinding te maken", + "vncSignInDescription": "Voer uw VNC-referenties in om verbinding te maken", + "vncUsernameOptional": "Gebruikersnaam (optioneel)", "vncPasswordOptional": "Wachtwoord (optioneel)", "vncNoResourceTarget": "Geen bron doelwit beschikbaar", "vncFailedToLoadNovnc": "Laden van noVNC mislukt", diff --git a/messages/pl-PL.json b/messages/pl-PL.json index bfe29df7d..d6c67ac20 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -123,6 +123,16 @@ "siteUpdated": "Strona zaktualizowana", "siteUpdatedDescription": "Strona została zaktualizowana.", "siteGeneralDescription": "Skonfiguruj ustawienia ogólne dla tej witryny", + "siteRestartTitle": "Restartuj Stronę", + "siteRestartDescription": "Uruchom ponownie tunel WireGuard dla tej strony. Spowoduje to tymczasowe przerwanie łączności.", + "siteRestartBody": "Użyj tego, jeśli tunel strony nie działa prawidłowo i chcesz wymusić ponowne połączenie bez ponownego uruchamiania hosta.", + "siteRestartButton": "Restartuj Stronę", + "siteRestartDialogMessage": "Czy na pewno chcesz uruchomić ponownie tunel WireGuard dla {name}? Strona tymczasowo straci łączność.", + "siteRestartWarning": "Strona tymczasowo rozłączy się podczas ponownego uruchamiania tunelu.", + "siteRestarted": "Strona zrestartowana", + "siteRestartedDescription": "Tunel WireGuard został ponownie uruchomiony.", + "siteErrorRestart": "Nie udało się zrestartować strony", + "siteErrorRestartDescription": "Wystąpił błąd podczas ponownego uruchamiania strony.", "siteSettingDescription": "Skonfiguruj ustawienia na stronie", "siteResourcesTab": "Zasoby", "siteResourcesNoneOnSite": "Ta strona nie ma jeszcze żadnych zasobów publicznych ani prywatnych.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Zastosuj schemat", "actionListBlueprints": "Lista planów", "actionGetBlueprint": "Pobierz plan", + "actionCreateOrgWideLauncherView": "Utwórz Widok Uruchamiacza dla Całej Organizacji", "setupToken": "Skonfiguruj token", "setupTokenDescription": "Wprowadź token konfiguracji z konsoli serwera.", "setupTokenRequired": "Wymagany jest token konfiguracji", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Podsieć", "addressDescription": "Adres wewnętrzny klienta. Musi mieścić się w podsieci organizacji.", "selectSites": "Wybierz witryny", + "selectLabels": "Wybierz etykiety", "sitesDescription": "Klient będzie miał łączność z wybranymi witrynami", "clientInstallOlm": "Zainstaluj Olm", "clientInstallOlmDescription": "Uruchom Olm na swoim systemie", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Witryna", "selectSite": "Wybierz stronę...", "multiSitesSelectorSitesCount": "{count, plural, one {# witryna} few {# witryny} many {# witryn} other {# witryn}}", + "labelsSelectorLabelsCount": "{count, plural, one {# etykieta} few {# etykiety} many {# etykiet} other {# etykiet}}", "noSitesFound": "Nie znaleziono stron.", "createInternalResourceDialogProtocol": "Protokół", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Zdalne węzły", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Sekret", + "remoteExitNodeNetworkingTitle": "Ustawienia sieciowe", + "remoteExitNodeNetworkingDescription": "Skonfiguruj, jak ten zdalny węzeł wyjściowy przekierowuje ruch i które strony preferują połączenie przez niego. Zaawansowane funkcje do użycia z konfiguracją sieci backhaul.", + "remoteExitNodeNetworkingSave": "Zapisz ustawienia", + "remoteExitNodeNetworkingSaveSuccessTitle": "Ustawienia sieciowe zapisane", + "remoteExitNodeNetworkingSaveSuccessDescription": "Ustawienia sieciowe zostały pomyślnie zaktualizowane.", + "remoteExitNodeNetworkingSaveError": "Nie udało się zapisać ustawień sieciowych", + "remoteExitNodeNetworkingSubnetsTitle": "Zdalne Podsieci", + "remoteExitNodeNetworkingSubnetsDescription": "Zdefiniuj zakresy CIDR, które ten zdalny węzeł wyjściowy przekieruje ruch do. Wpisz prawidłowy CIDR (np. 10.0.0.0/8) i naciśnij Enter, aby dodać.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Dodaj zakres CIDR (np. 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Nie udało się załadować podsieci", + "remoteExitNodeNetworkingLabelsTitle": "Etykiety preferencji", + "remoteExitNodeNetworkingLabelsDescription": "Strony z tymi etykietami będą zmuszone do połączenia się przez ten zdalny węzeł wyjściowy.", + "remoteExitNodeNetworkingLabelsButtonText": "Wybierz etykiety...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Szukaj etykiet...", + "remoteExitNodeNetworkingLabelsLoadError": "Nie udało się załadować etykiet", "remoteExitNodeCreate": { "title": "Utwórz zdalny węzeł", "description": "Utwórz nowy, samodzielnie hostowany węzeł przekaźnika zdalnego i serwera proxy", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Dostawca Google OAuth2/OIDC", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Podsieć", + "utilitySubnet": "Użyteczna podsieć", "subnetDescription": "Podsieć dla konfiguracji sieci tej organizacji.", "customDomain": "Niestandardowa domena", "authPage": "Strony uwierzytelniania", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "Biała lista e-mail", "memberPortalResourceDisabled": "Zasób wyłączony", "memberPortalShowingResources": "Wyświetlanie zasobów od {start} do {end} z {total}", + "resourceLauncherTitle": "Uruchamiacz Zasobów", + "resourceLauncherDescription": "Przeglądaj szczegóły zasobów i uruchamiaj je z jednego miejsca", + "resourceLauncherSearchPlaceholder": "Szukaj we wszystkich stronach...", + "resourceLauncherDefaultView": "Domyślny", + "resourceLauncherSaveView": "Zapisz Widok", + "resourceLauncherSaveToCurrentView": "Zapisz do bieżącego widoku", + "resourceLauncherResetView": "Resetuj Widok", + "resourceLauncherSaveAsNewView": "Zapisz jako Nowy Widok", + "resourceLauncherSaveAsNewViewDescription": "Nadaj nazwę temu widokowi, aby zapisać swoje bieżące filtry i układ.", + "resourceLauncherSaveForEveryone": "Zapisz dla wszystkich", + "resourceLauncherSaveForEveryoneDescription": "Udostępnij ten widok wszystkim członkom organizacji. Gdy jest niezaznaczone, widok jest widoczny tylko dla Ciebie.", + "resourceLauncherMakePersonal": "Zrób osobisty", + "resourceLauncherFilter": "Filtr", + "resourceLauncherSort": "Sortuj", + "resourceLauncherSortAscending": "Sortuj rosnąco", + "resourceLauncherSortDescending": "Sortuj malejąco", + "resourceLauncherSettings": "Ustawienia", + "resourceLauncherGroupBy": "Grupuj według", + "resourceLauncherGroupBySite": "Witryna", + "resourceLauncherGroupByLabel": "Etykieta", + "resourceLauncherLayout": "Układ", + "resourceLauncherLayoutGrid": "Siatka", + "resourceLauncherLayoutList": "Lista", + "resourceLauncherShowLabels": "Pokaż etykiety", + "resourceLauncherShowSiteTags": "Pokaż tagi stron", + "resourceLauncherShowRecents": "Pokaż ostatnie", + "resourceLauncherDeleteView": "Usuń Widok", + "resourceLauncherViewAsAdmin": "Przeglądaj jako Administrator", + "resourceLauncherResourceDetailsDescription": "Pokaż szczegóły tego zasobu.", + "resourceLauncherUnlabeled": "Bez etykiety", + "resourceLauncherNoSite": "Brak strony", + "resourceLauncherNoResourcesInGroup": "W tej grupie nie ma zasobów", + "resourceLauncherEmptyStateTitle": "Brak dostępnych zasobów", + "resourceLauncherEmptyStateDescription": "Jeszcze nie masz dostępu do żadnych zasobów. Skontaktuj się z administratorem, aby poprosić o dostęp.", + "resourceLauncherEmptyStateNoResultsTitle": "Nie znaleziono zasobów", + "resourceLauncherEmptyStateNoResultsDescription": "Żadne zasoby nie spełniają twojego bieżącego wyszukiwania lub filtrów. Spróbuj je dostosować, aby znaleźć to, czego szukasz.", + "resourceLauncherEmptyStateNoResultsWithQuery": "Żadne zasoby nie odpowiadają \"{query}\". Spróbuj dostosować swoje wyszukiwanie lub usunąć filtry, aby zobaczyć wszystkie zasoby.", + "resourceLauncherCopiedToClipboard": "Skopiowano do schowka", + "resourceLauncherCopiedAccessDescription": "Dostęp do zasobu został skopiowany do schowka.", + "resourceLauncherViewNamePlaceholder": "Nazwa widoku", + "resourceLauncherViewNameLabel": "Nazwa Widoku", + "resourceLauncherViewSaved": "Widok zapisany", + "resourceLauncherViewSavedDescription": "Twój widok uruchamiacza został zapisany.", + "resourceLauncherViewSaveFailed": "Nie udało się zapisać widoku", + "resourceLauncherViewSaveFailedDescription": "Nie można zapisać widoku uruchamiacza. Proszę spróbować ponownie.", + "resourceLauncherViewDeleted": "Widok usunięty", + "resourceLauncherViewDeletedDescription": "Widok uruchamiacza został usunięty.", + "resourceLauncherViewDeleteFailed": "Nie udało się usunąć widoku", + "resourceLauncherViewDeleteFailedDescription": "Nie można usunąć widoku uruchamiacza. Proszę spróbować ponownie.", "memberPortalPrevious": "Poprzedni", "memberPortalNext": "Następny", "httpSettings": "Ustawienia HTTP", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyRequired": "Wymagany jest klucz prywatny", "vncTitle": "VNC", - "vncSignInDescription": "Wprowadź hasło VNC, aby się połączyć", + "vncSignInDescription": "Wprowadź swoje dane uwierzytelniające VNC aby się połączyć", + "vncUsernameOptional": "Nazwa użytkownika (opcjonalnie)", "vncPasswordOptional": "Hasło (opcjonalne)", "vncNoResourceTarget": "Brak dostępnego celu zasobu", "vncFailedToLoadNovnc": "Błąd ładowania noVNC", diff --git a/messages/pt-PT.json b/messages/pt-PT.json index ff0d543fe..1f47d92b8 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -123,6 +123,16 @@ "siteUpdated": "Site atualizado", "siteUpdatedDescription": "O site foi atualizado.", "siteGeneralDescription": "Configurar as configurações gerais para este site", + "siteRestartTitle": "Reiniciar site", + "siteRestartDescription": "Reinicie o túnel WireGuard para este site. Isso interromperá brevemente a conectividade.", + "siteRestartBody": "Use isso se o túnel do site não estiver funcionando corretamente e você quiser forçar uma reconexão sem reiniciar o host.", + "siteRestartButton": "Reiniciar site", + "siteRestartDialogMessage": "Tem certeza de que deseja reiniciar o túnel WireGuard para {name}? O site perderá brevemente a conectividade.", + "siteRestartWarning": "O site será desconectado brevemente enquanto o túnel reinicia.", + "siteRestarted": "Site reiniciado", + "siteRestartedDescription": "O túnel WireGuard foi reiniciado.", + "siteErrorRestart": "Falha ao reiniciar o site", + "siteErrorRestartDescription": "Ocorreu um erro ao reiniciar o site.", "siteSettingDescription": "Configurar as configurações no site", "siteResourcesTab": "Recursos", "siteResourcesNoneOnSite": "Este site ainda não possui recursos públicos ou privados.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Aplicar Diagrama", "actionListBlueprints": "Listar Modelos", "actionGetBlueprint": "Obter Modelo", + "actionCreateOrgWideLauncherView": "Criar Visualização do Lançador para Toda a Organização", "setupToken": "Configuração do Token", "setupTokenDescription": "Digite o token de configuração do console do servidor.", "setupTokenRequired": "Token de configuração é necessário", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Sub-rede", "addressDescription": "O endereço interno do cliente. Deve estar dentro da sub-rede da organização.", "selectSites": "Selecionar sites", + "selectLabels": "Selecionar etiquetas", "sitesDescription": "O cliente terá conectividade com os sites selecionados", "clientInstallOlm": "Instalar Olm", "clientInstallOlmDescription": "Execute o Olm em seu sistema", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Site", "selectSite": "Selecionar site...", "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", + "labelsSelectorLabelsCount": "{count, plural, one {# rótulo} other {# rótulos}}", "noSitesFound": "Nenhum site encontrado.", "createInternalResourceDialogProtocol": "Protocolo", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Nós remotos", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Chave Secreta", + "remoteExitNodeNetworkingTitle": "Configurações de Rede", + "remoteExitNodeNetworkingDescription": "Configure como este nó de saída remoto roteia o tráfego e quais sites preferem se conectar através dele. Recursos avançados para serem usados com configurações de rede de backhaul.", + "remoteExitNodeNetworkingSave": "Guardar Configurações", + "remoteExitNodeNetworkingSaveSuccessTitle": "Configurações de rede salvas", + "remoteExitNodeNetworkingSaveSuccessDescription": "As configurações de rede foram atualizadas com sucesso.", + "remoteExitNodeNetworkingSaveError": "Falha ao guardar as configurações de rede", + "remoteExitNodeNetworkingSubnetsTitle": "Sub-redes Remotas", + "remoteExitNodeNetworkingSubnetsDescription": "Defina os intervalos de CIDR que este nó de saída remoto irá rotear o tráfego. Digite um CIDR válido (por exemplo, 10.0.0.0/8) e pressione Enter para adicionar.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Adicione um intervalo de CIDR (por exemplo, 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Falha ao carregar sub-redes", + "remoteExitNodeNetworkingLabelsTitle": "Etiquetas de Preferência", + "remoteExitNodeNetworkingLabelsDescription": "Os sites com essas etiquetas serão forçados a se conectar através deste nó de saída remoto.", + "remoteExitNodeNetworkingLabelsButtonText": "Selecionar etiquetas...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Pesquisar etiquetas...", + "remoteExitNodeNetworkingLabelsLoadError": "Falha ao carregar etiquetas", "remoteExitNodeCreate": { "title": "Criar Nó Remoto", "description": "Crie um novo nó de retransmissão e proxy servidor auto-hospedado", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Provedor Google OAuth2/OIDC", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Sub-rede", + "utilitySubnet": "Sub-rede de utilidade", "subnetDescription": "A sub-rede para a configuração de rede dessa organização.", "customDomain": "Domínio Personalizado", "authPage": "Páginas de Autenticação", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "Lista de E-mails Permitidos", "memberPortalResourceDisabled": "Recurso Desativado", "memberPortalShowingResources": "Mostrando {start}-{end} de {total} recursos", + "resourceLauncherTitle": "Lançador de Recursos", + "resourceLauncherDescription": "Veja os detalhes do recurso e lance-os de um só lugar", + "resourceLauncherSearchPlaceholder": "Procurar todos os sites...", + "resourceLauncherDefaultView": "Padrão", + "resourceLauncherSaveView": "Salvar Visualização", + "resourceLauncherSaveToCurrentView": "Salvar na Visualização Atual", + "resourceLauncherResetView": "Redefinir Visualização", + "resourceLauncherSaveAsNewView": "Salvar como Nova Visualização", + "resourceLauncherSaveAsNewViewDescription": "Dê um nome a esta visualização para salvar os filtros e layout atuais.", + "resourceLauncherSaveForEveryone": "Salvar para Todos", + "resourceLauncherSaveForEveryoneDescription": "Compartilhe esta visualização com todos os membros da organização. Quando desmarcado, a visualização é visível apenas para você.", + "resourceLauncherMakePersonal": "Tornar Pessoal", + "resourceLauncherFilter": "Filtro", + "resourceLauncherSort": "Ordenar", + "resourceLauncherSortAscending": "Ordenar ascendente", + "resourceLauncherSortDescending": "Ordenar descendente", + "resourceLauncherSettings": "Configurações", + "resourceLauncherGroupBy": "Agrupar por", + "resourceLauncherGroupBySite": "Site", + "resourceLauncherGroupByLabel": "Marcador", + "resourceLauncherLayout": "Layout", + "resourceLauncherLayoutGrid": "Grade", + "resourceLauncherLayoutList": "Lista", + "resourceLauncherShowLabels": "Mostrar Marcadores", + "resourceLauncherShowSiteTags": "Mostrar Etiquetas de Site", + "resourceLauncherShowRecents": "Mostrar Recents", + "resourceLauncherDeleteView": "Excluir Visualização", + "resourceLauncherViewAsAdmin": "Visualizar como Administrador", + "resourceLauncherResourceDetailsDescription": "Veja detalhes deste recurso.", + "resourceLauncherUnlabeled": "Sem Etiqueta", + "resourceLauncherNoSite": "Sem Site", + "resourceLauncherNoResourcesInGroup": "Nenhum recurso neste grupo", + "resourceLauncherEmptyStateTitle": "Nenhum Recurso Disponível", + "resourceLauncherEmptyStateDescription": "Você não tem acesso a nenhum recurso ainda. Entre em contato com seu administrador para solicitar acesso.", + "resourceLauncherEmptyStateNoResultsTitle": "Nenhum Recurso Encontrado", + "resourceLauncherEmptyStateNoResultsDescription": "Nenhum recurso corresponde à sua busca ou filtros atuais. Experimente ajustá-los para encontrar o que está procurando.", + "resourceLauncherEmptyStateNoResultsWithQuery": "Nenhum recurso corresponde a \"{query}\". Tente ajustar sua busca ou limpar os filtros para ver todos os recursos.", + "resourceLauncherCopiedToClipboard": "Copiado para a área de transferência", + "resourceLauncherCopiedAccessDescription": "O acesso ao recurso foi copiado para sua área de transferência.", + "resourceLauncherViewNamePlaceholder": "Nome da Visualização", + "resourceLauncherViewNameLabel": "Nome da Visualização", + "resourceLauncherViewSaved": "Visualização salva", + "resourceLauncherViewSavedDescription": "Sua visualização do lançador foi salva.", + "resourceLauncherViewSaveFailed": "Falha ao salvar visualização", + "resourceLauncherViewSaveFailedDescription": "Não foi possível salvar a visualização do lançador. Por favor, tente novamente.", + "resourceLauncherViewDeleted": "Visualização excluída", + "resourceLauncherViewDeletedDescription": "A visualização do lançador foi excluída.", + "resourceLauncherViewDeleteFailed": "Falha ao excluir visualização", + "resourceLauncherViewDeleteFailedDescription": "Não foi possível excluir a visualização do lançador. Por favor, tente novamente.", "memberPortalPrevious": "Anterior", "memberPortalNext": "Próximo", "httpSettings": "Configurações HTTP", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyRequired": "Chave privada é necessária", "vncTitle": "VNC", - "vncSignInDescription": "Digite sua senha VNC para conectar", + "vncSignInDescription": "Digite suas credenciais VNC para conectar", + "vncUsernameOptional": "Nome de usuário (opcional)", "vncPasswordOptional": "Senha (opcional)", "vncNoResourceTarget": "Nenhum alvo de recurso disponível", "vncFailedToLoadNovnc": "Falha ao carregar noVNC", diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 523f3c2ef..579a49c8f 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -123,6 +123,16 @@ "siteUpdated": "Сайт обновлён", "siteUpdatedDescription": "Сайт был успешно обновлён.", "siteGeneralDescription": "Настройте общие параметры для этого сайта", + "siteRestartTitle": "Перезагрузить сайт", + "siteRestartDescription": "Перезапустите туннель WireGuard для этого сайта. Это кратковременно прервет соединение.", + "siteRestartBody": "Используйте это, если туннель сайта не работает должным образом и вам нужно принудительно переподключиться без перезапуска хоста.", + "siteRestartButton": "Перезагрузить сайт", + "siteRestartDialogMessage": "Вы уверены, что хотите перезапустить туннель WireGuard для {name}? Сайт кратковременно потеряет соединение.", + "siteRestartWarning": "Сайт кратковременно отключится во время перезапуска туннеля.", + "siteRestarted": "Сайт перезапущен", + "siteRestartedDescription": "Туннель WireGuard был перезапущен.", + "siteErrorRestart": "Не удалось перезапустить сайт", + "siteErrorRestartDescription": "Произошла ошибка во время перезапуска сайта.", "siteSettingDescription": "Настройка параметров на сайте", "siteResourcesTab": "Ресурсы", "siteResourcesNoneOnSite": "На этом сайте пока нет публичных или частных ресурсов.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Применить чертёж", "actionListBlueprints": "Список чертежей", "actionGetBlueprint": "Получить чертёж", + "actionCreateOrgWideLauncherView": "Создать вид запуска на уровне организации", "setupToken": "Код настройки", "setupTokenDescription": "Введите токен настройки из консоли сервера.", "setupTokenRequired": "Токен настройки обязателен", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Подсеть", "addressDescription": "Внутренний адрес клиента. Должен находиться в подсети организации.", "selectSites": "Выберите сайты", + "selectLabels": "Выберите метки", "sitesDescription": "Клиент будет иметь подключение к выбранным сайтам", "clientInstallOlm": "Установить Olm", "clientInstallOlmDescription": "Запустите Olm на вашей системе", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Сайт", "selectSite": "Выберите сайт...", "multiSitesSelectorSitesCount": "{count, plural, one {# сайт} few {# сайта} many {# сайтов} other {# сайтов}}", + "labelsSelectorLabelsCount": "{count, plural, one {# метка} few {# метки} many {# меток} other {# меток}}", "noSitesFound": "Сайты не найдены.", "createInternalResourceDialogProtocol": "Протокол", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Удаленные узлы", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Секретный ключ", + "remoteExitNodeNetworkingTitle": "Настройки сети", + "remoteExitNodeNetworkingDescription": "Настройте, как этот удаленный узел выхода маршрутизирует трафик и какие сайты предпочитают подключаться через него. Расширенные функции для использования с конфигурациями магистральной сети.", + "remoteExitNodeNetworkingSave": "Сохранить настройки", + "remoteExitNodeNetworkingSaveSuccessTitle": "Сетевые настройки сохранены", + "remoteExitNodeNetworkingSaveSuccessDescription": "Сетевые настройки были успешно обновлены.", + "remoteExitNodeNetworkingSaveError": "Не удалось сохранить сетевые настройки", + "remoteExitNodeNetworkingSubnetsTitle": "Удалённые подсети", + "remoteExitNodeNetworkingSubnetsDescription": "Определите диапазоны CIDR, которые этот удаленный узел выхода будет использовать для маршрутизации трафика. Введите действительный CIDR (например, 10.0.0.0/8) и нажмите Enter, чтобы добавить.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Добавить диапазон CIDR (например, 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Не удалось загрузить подсети", + "remoteExitNodeNetworkingLabelsTitle": "Этикетки предпочтений", + "remoteExitNodeNetworkingLabelsDescription": "Сайты с этими метками будут обязаны подключаться через этот удаленный узел выхода.", + "remoteExitNodeNetworkingLabelsButtonText": "Выберите метки...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Поиск меток...", + "remoteExitNodeNetworkingLabelsLoadError": "Не удалось загрузить метки", "remoteExitNodeCreate": { "title": "Создать удалённый узел", "description": "Создайте новый самостоятельный удалённый ретранслятор и узел прокси-сервера", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Google OAuth2/OIDC провайдер", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Подсеть", + "utilitySubnet": "Утилита подсети", "subnetDescription": "Подсеть для конфигурации сети этой организации.", "customDomain": "Пользовательский домен", "authPage": "Страницы аутентификации", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "Белый список email", "memberPortalResourceDisabled": "Ресурс отключён", "memberPortalShowingResources": "Показаны {start}-{end} из {total} ресурсов", + "resourceLauncherTitle": "Запуск ресурса", + "resourceLauncherDescription": "Просмотр деталей ресурса и запуск их из одного места", + "resourceLauncherSearchPlaceholder": "Поиск всех сайтов...", + "resourceLauncherDefaultView": "По умолчанию", + "resourceLauncherSaveView": "Сохранить вид", + "resourceLauncherSaveToCurrentView": "Сохранить в текущий вид", + "resourceLauncherResetView": "Сбросить вид", + "resourceLauncherSaveAsNewView": "Сохранить как новый вид", + "resourceLauncherSaveAsNewViewDescription": "Дайте этому виду имя, чтобы сохранить текущие фильтры и макет.", + "resourceLauncherSaveForEveryone": "Сохранить для всех", + "resourceLauncherSaveForEveryoneDescription": "Поделитесь этим видом со всеми членами организации. Если не отмечено, видимость только для вас.", + "resourceLauncherMakePersonal": "Сделать личным", + "resourceLauncherFilter": "Фильтр", + "resourceLauncherSort": "Сортировать", + "resourceLauncherSortAscending": "Сортировать по возрастанию", + "resourceLauncherSortDescending": "Сортировать по убыванию", + "resourceLauncherSettings": "Настройки", + "resourceLauncherGroupBy": "Группировать по", + "resourceLauncherGroupBySite": "Сайт", + "resourceLauncherGroupByLabel": "Метка", + "resourceLauncherLayout": "Макет", + "resourceLauncherLayoutGrid": "Сетка", + "resourceLauncherLayoutList": "Список", + "resourceLauncherShowLabels": "Показать метки", + "resourceLauncherShowSiteTags": "Показать теги сайта", + "resourceLauncherShowRecents": "Показать недавно", + "resourceLauncherDeleteView": "Удалить вид", + "resourceLauncherViewAsAdmin": "Просмотр как администратор", + "resourceLauncherResourceDetailsDescription": "Просмотр деталей этого ресурса.", + "resourceLauncherUnlabeled": "Без меток", + "resourceLauncherNoSite": "Без сайта", + "resourceLauncherNoResourcesInGroup": "Нет ресурсов в данной группе", + "resourceLauncherEmptyStateTitle": "Нет доступных ресурсов", + "resourceLauncherEmptyStateDescription": "У вас пока нет доступа ни к одному ресурсу. Обратитесь к администратору, чтобы запросить доступ.", + "resourceLauncherEmptyStateNoResultsTitle": "Ресурсы не найдены", + "resourceLauncherEmptyStateNoResultsDescription": "Ни один ресурс не соответствует вашему текущему поисковому запросу или фильтрам. Попробуйте их изменить, чтобы найти нужное.", + "resourceLauncherEmptyStateNoResultsWithQuery": "Ни один ресурс не соответствует \"{query}\". Попробуйте изменить параметры поиска или очистить фильтры, чтобы увидеть все ресурсы.", + "resourceLauncherCopiedToClipboard": "Скопировано в буфер обмена", + "resourceLauncherCopiedAccessDescription": "Доступ к ресурсу был скопирован в ваш буфер обмена.", + "resourceLauncherViewNamePlaceholder": "Имя вида", + "resourceLauncherViewNameLabel": "Имя вида", + "resourceLauncherViewSaved": "Вид сохранён", + "resourceLauncherViewSavedDescription": "Ваш вид запуска был сохранён.", + "resourceLauncherViewSaveFailed": "Не удалось сохранить вид", + "resourceLauncherViewSaveFailedDescription": "Не удалось сохранить вид. Пожалуйста, попробуйте еще раз.", + "resourceLauncherViewDeleted": "Вид удалён", + "resourceLauncherViewDeletedDescription": "Вид запуска был удалён.", + "resourceLauncherViewDeleteFailed": "Не удалось удалить вид", + "resourceLauncherViewDeleteFailedDescription": "Не удалось удалить вид. Пожалуйста, попробуйте еще раз.", "memberPortalPrevious": "Предыдущий", "memberPortalNext": "Следующий", "httpSettings": "Настройки HTTP", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----НАЧАЛО ЛИЧНОГО КЛЮЧА OPENSSH-----", "sshPrivateKeyRequired": "Требуется личный ключ", "vncTitle": "VNC", - "vncSignInDescription": "Введите пароль VNC для подключения", + "vncSignInDescription": "Введите ваши учетные данные VNC для подключения", + "vncUsernameOptional": "Имя пользователя (необязательно)", "vncPasswordOptional": "Пароль (необязательно)", "vncNoResourceTarget": "Отсутствует целевой ресурс", "vncFailedToLoadNovnc": "Не удалось загрузить noVNC", diff --git a/messages/tr-TR.json b/messages/tr-TR.json index db5f8158a..549bd205e 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -123,6 +123,16 @@ "siteUpdated": "Site güncellendi", "siteUpdatedDescription": "Site güncellendi.", "siteGeneralDescription": "Bu site için genel ayarları yapılandırın", + "siteRestartTitle": "Siteyi Yeniden Başlat", + "siteRestartDescription": "Bu site için WireGuard tünelini yeniden başlatın. Bu, bağlantıyı kısa süreliğine keser.", + "siteRestartBody": "Site tüneli düzgün çalışmadığında ve ana bilgisayarı yeniden başlatmadan bağlantıyı yeniden sağlamak istiyorsanız bunu kullanın.", + "siteRestartButton": "Siteyi Yeniden Başlat", + "siteRestartDialogMessage": "{name} için WireGuard tünelini yeniden başlatmak istediğinizden emin misiniz? Site kısa süreliğine bağlantıyı kaybedecektir.", + "siteRestartWarning": "Tünel yeniden başlatılırken site kısa süreliğine kesintiye uğrar.", + "siteRestarted": "Site yeniden başlatıldı", + "siteRestartedDescription": "WireGuard tüneli yeniden başlatıldı.", + "siteErrorRestart": "Sitenin yeniden başlatılması başarısız oldu", + "siteErrorRestartDescription": "Site yeniden başlatılırken bir hata oluştu.", "siteSettingDescription": "Sitenizdeki ayarları yapılandırın", "siteResourcesTab": "Kaynaklar", "siteResourcesNoneOnSite": "Bu sitede henüz genel veya özel kaynak yok.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Planı Uygula", "actionListBlueprints": "Plan Listesini Görüntüle", "actionGetBlueprint": "Planı Elde Et", + "actionCreateOrgWideLauncherView": "Kuruluş Genelinde Başlatıcı Görünümü Oluşturma", "setupToken": "Kurulum Simgesi", "setupTokenDescription": "Sunucu konsolundan kurulum simgesini girin.", "setupTokenRequired": "Kurulum simgesi gerekli", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Alt ağ", "addressDescription": "İstemcinin dahili adresi. Organizasyon alt ağı içinde olmalıdır.", "selectSites": "Siteleri seçin", + "selectLabels": "Etiketleri seçin", "sitesDescription": "Müşteri seçilen sitelere bağlantı kuracaktır", "clientInstallOlm": "Olm Yükle", "clientInstallOlmDescription": "Sisteminizde Olm çalıştırın", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Site", "selectSite": "Site seç...", "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# siteler}}", + "labelsSelectorLabelsCount": "{count, plural, one {# etiket} other {# etiketler}}", "noSitesFound": "Site bulunamadı.", "createInternalResourceDialogProtocol": "Protokol", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Uzak Düğümler", "remoteExitNodeId": "Kimlik", "remoteExitNodeSecretKey": "Gizli", + "remoteExitNodeNetworkingTitle": "Ağ Ayarları", + "remoteExitNodeNetworkingDescription": "Bu uzak çıkış düğümünün trafiği nasıl yönlendireceğini ve hangi sitelerin bu üzerinden bağlanmayı tercih edeceğini yapılandırın. Gelişmiş özellikler geri bağlantı ağ konfigürasyonları ile kullanılmalıdır.", + "remoteExitNodeNetworkingSave": "Ayarları Kaydet", + "remoteExitNodeNetworkingSaveSuccessTitle": "Ağ ayarları kaydedildi", + "remoteExitNodeNetworkingSaveSuccessDescription": "Ağ ayarları başarıyla güncellendi.", + "remoteExitNodeNetworkingSaveError": "Ağ ayarları kaydedilemedi", + "remoteExitNodeNetworkingSubnetsTitle": "Uzak Alt Ağlar", + "remoteExitNodeNetworkingSubnetsDescription": "Bu uzak çıkış düğümünün trafiği taşıyacağı CIDR aralıklarını tanımlayın. Geçerli bir CIDR (örneğin, 10.0.0.0/8) yazın ve eklemek için Enter tuşuna basın.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Bir CIDR aralığı ekle (örneğin, 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Alt ağlar yüklenemedi", + "remoteExitNodeNetworkingLabelsTitle": "Tercih Etiketleri", + "remoteExitNodeNetworkingLabelsDescription": "Bu etiketlere sahip siteler, bu uzak çıkış düğümü üzerinden bağlantı kurmaya zorlanacaktır.", + "remoteExitNodeNetworkingLabelsButtonText": "Etiketleri seç...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Etiketleri ara...", + "remoteExitNodeNetworkingLabelsLoadError": "Etiketler yüklenemedi", "remoteExitNodeCreate": { "title": "Uzak Düğüm Oluştur", "description": "Yeni bir kendine misafir uzaktan ileti ve ara sunucu düğümü oluşturun", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Google OAuth2/OIDC sağlayıcısı", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC sağlayıcısı", "subnet": "Alt ağ", + "utilitySubnet": "Yardımcı Alt Ağ", "subnetDescription": "Bu organizasyonun ağ yapılandırması için alt ağ.", "customDomain": "Özel Alan", "authPage": "Kimlik Sayfaları", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "E-posta Beyaz Listesi", "memberPortalResourceDisabled": "Kaynak Devre Dışı", "memberPortalShowingResources": "{total} kaynaktan {start}-{end} gösteriliyor", + "resourceLauncherTitle": "Kaynak Başlatıcı", + "resourceLauncherDescription": "Kaynağın detaylarını görüntüleyin ve tek bir yerden başlatın", + "resourceLauncherSearchPlaceholder": "Tüm siteleri ara...", + "resourceLauncherDefaultView": "Varsayılan", + "resourceLauncherSaveView": "Görünümü Kaydet", + "resourceLauncherSaveToCurrentView": "Mevcut Görünüme Kaydet", + "resourceLauncherResetView": "Görünümü Sıfırla", + "resourceLauncherSaveAsNewView": "Yeni Görünüm Olarak Kaydet", + "resourceLauncherSaveAsNewViewDescription": "Geçerli filtrelerinizi ve düzeninizi kaydetmek için bu görünüme bir ad verin.", + "resourceLauncherSaveForEveryone": "Herkes İçin Kaydet", + "resourceLauncherSaveForEveryoneDescription": "Bu görünümü tüm kuruluş üyeleriyle paylaşın. İşaretli değilse, görünüm yalnızca size görünür olur.", + "resourceLauncherMakePersonal": "Kişisel Yap", + "resourceLauncherFilter": "Filtre", + "resourceLauncherSort": "Sıralama", + "resourceLauncherSortAscending": "Artan sırala", + "resourceLauncherSortDescending": "Azalan sırala", + "resourceLauncherSettings": "Ayarlar", + "resourceLauncherGroupBy": "Grupla", + "resourceLauncherGroupBySite": "Site", + "resourceLauncherGroupByLabel": "Etiket", + "resourceLauncherLayout": "Düzen", + "resourceLauncherLayoutGrid": "Izgara", + "resourceLauncherLayoutList": "Liste", + "resourceLauncherShowLabels": "Etiketleri Göster", + "resourceLauncherShowSiteTags": "Site Etiketlerini Göster", + "resourceLauncherShowRecents": "Son Eklenenleri Göster", + "resourceLauncherDeleteView": "Görünümü Sil", + "resourceLauncherViewAsAdmin": "Yönetici Olarak Görüntüle", + "resourceLauncherResourceDetailsDescription": "Bu kaynağın detaylarını görüntüleyin.", + "resourceLauncherUnlabeled": "Etiketsiz", + "resourceLauncherNoSite": "Site Yok", + "resourceLauncherNoResourcesInGroup": "Bu grupta kaynak yok", + "resourceLauncherEmptyStateTitle": "Kullanılabilir Kaynak Yok", + "resourceLauncherEmptyStateDescription": "Henüz hiçbir kaynağa erişiminiz yok. Erişim istemek için yöneticinizle iletişime geçin.", + "resourceLauncherEmptyStateNoResultsTitle": "Kaynak Bulunamadı", + "resourceLauncherEmptyStateNoResultsDescription": "Mevcut arama veya filtrelerinizle eşleşen kaynak yok. Aradığınızı bulmak için ayarları değiştirmeyi deneyin.", + "resourceLauncherEmptyStateNoResultsWithQuery": "\"{query}\" ile eşleşen kaynak yok. Tüm kaynakları görmek için aramayı düzenlemeyi veya filtreleri temizlemeyi deneyin.", + "resourceLauncherCopiedToClipboard": "Panoya kopyalandı", + "resourceLauncherCopiedAccessDescription": "Kaynağa erişim panonuza kopyalandı.", + "resourceLauncherViewNamePlaceholder": "Görünüm adı", + "resourceLauncherViewNameLabel": "Görünüm Adı", + "resourceLauncherViewSaved": "Görünüm kaydedildi", + "resourceLauncherViewSavedDescription": "Başlatıcı görünümünüz kaydedildi.", + "resourceLauncherViewSaveFailed": "Görünüm kaydedilemedi", + "resourceLauncherViewSaveFailedDescription": "Başlatıcı görünümü kaydedilemedi. Lütfen yeniden deneyin.", + "resourceLauncherViewDeleted": "Görünüm silindi", + "resourceLauncherViewDeletedDescription": "Başlatıcı görünüm silindi.", + "resourceLauncherViewDeleteFailed": "Görünüm silinemedi", + "resourceLauncherViewDeleteFailedDescription": "Başlatıcı görünümü silinemedi. Lütfen tekrar deneyin.", "memberPortalPrevious": "Önceki", "memberPortalNext": "Sonraki", "httpSettings": "HTTP Ayarları", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BAŞLANGIÇ OPENSSH ÖZEL ANAHTARI-----", "sshPrivateKeyRequired": "Özel anahtar gereklidir", "vncTitle": "VNC", - "vncSignInDescription": "Bağlanmak için VNC parolanızı girin", + "vncSignInDescription": "Bağlanmak için VNC kimlik bilgilerinizi girin", + "vncUsernameOptional": "Kullanıcı Adı (isteğe bağlı)", "vncPasswordOptional": "Parola (isteğe bağlı)", "vncNoResourceTarget": "Kaynak hedefi mevcut değil", "vncFailedToLoadNovnc": "NoVNC yüklenemedi", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index d4f124f96..bac29a695 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -123,6 +123,16 @@ "siteUpdated": "站点已更新", "siteUpdatedDescription": "网站已更新。", "siteGeneralDescription": "配置此站点的常规设置", + "siteRestartTitle": "重启站点", + "siteRestartDescription": "重启此站点的WireGuard隧道。此操作将暂时中断连接。", + "siteRestartBody": "如果站点隧道无法正常工作,并且您希望在不重启主机的情况下强制重新连接,请使用此选项。", + "siteRestartButton": "重启站点", + "siteRestartDialogMessage": "确定要重启{name}的WireGuard隧道吗?站点将暂时断开连接。", + "siteRestartWarning": "隧道重启时,站点将暂时断开连接。", + "siteRestarted": "站点已重启", + "siteRestartedDescription": "WireGuard隧道已重启。", + "siteErrorRestart": "重启站点失败", + "siteErrorRestartDescription": "重启站点时发生错误。", "siteSettingDescription": "配置站点设置", "siteResourcesTab": "资源", "siteResourcesNoneOnSite": "此站点尚无公开或私人资源。", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "应用蓝图", "actionListBlueprints": "列表蓝图", "actionGetBlueprint": "获取蓝图", + "actionCreateOrgWideLauncherView": "创建组织范围的启动器视图", "setupToken": "设置令牌", "setupTokenDescription": "从服务器控制台输入设置令牌。", "setupTokenRequired": "需要设置令牌", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "子网", "addressDescription": "客户的内部地址。必须属于组织的子网。", "selectSites": "选择站点", + "selectLabels": "选择标签", "sitesDescription": "客户端将与所选站点进行连接", "clientInstallOlm": "安装 Olm", "clientInstallOlmDescription": "在您的系统上运行 Olm", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "站点", "selectSite": "选择站点...", "multiSitesSelectorSitesCount": "{count, plural, other {# 个网站}}", + "labelsSelectorLabelsCount": "{count, plural, other {# 标签}}", "noSitesFound": "未找到站点。", "createInternalResourceDialogProtocol": "协议", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "远程节点", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "密钥", + "remoteExitNodeNetworkingTitle": "网络设置", + "remoteExitNodeNetworkingDescription": "配置此远程出口节点如何路由流量以及哪些站点优先通过其连接。高级功能可用于回程网络配置。", + "remoteExitNodeNetworkingSave": "保存设置", + "remoteExitNodeNetworkingSaveSuccessTitle": "网络设置已保存", + "remoteExitNodeNetworkingSaveSuccessDescription": "网络设置已成功更新。", + "remoteExitNodeNetworkingSaveError": "保存网络设置失败", + "remoteExitNodeNetworkingSubnetsTitle": "远程子网", + "remoteExitNodeNetworkingSubnetsDescription": "定义此远程出口节点将路由流量的CIDR范围。输入有效的CIDR(例如10.0.0.0/8)并按Enter键添加。", + "remoteExitNodeNetworkingSubnetsPlaceholder": "添加CIDR范围(例如10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "无法加载子网", + "remoteExitNodeNetworkingLabelsTitle": "首选标签", + "remoteExitNodeNetworkingLabelsDescription": "拥有这些标签的站点将强制通过此远程出口节点连接。", + "remoteExitNodeNetworkingLabelsButtonText": "选择标签……", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "搜索标签……", + "remoteExitNodeNetworkingLabelsLoadError": "无法加载标签", "remoteExitNodeCreate": { "title": "创建远程节点", "description": "创建一个新的自托管远程中继和代理服务器节点", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Google OAuth2/OIDC 提供商", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "子网", + "utilitySubnet": "实用程序子网", "subnetDescription": "此组织网络配置的子网。", "customDomain": "自定义域", "authPage": "身份验证页面", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "电子邮件白名单", "memberPortalResourceDisabled": "资源已禁用", "memberPortalShowingResources": "显示 {start}-{end} 共 {total} 个资源", + "resourceLauncherTitle": "资源启动器", + "resourceLauncherDescription": "查看资源详情并从一个地方启动它们", + "resourceLauncherSearchPlaceholder": "搜索所有站点……", + "resourceLauncherDefaultView": "默认", + "resourceLauncherSaveView": "保存视图", + "resourceLauncherSaveToCurrentView": "保存至当前视图", + "resourceLauncherResetView": "重置视图", + "resourceLauncherSaveAsNewView": "另存为新视图", + "resourceLauncherSaveAsNewViewDescription": "为此视图命名,以便保存您当前的过滤器和布局。", + "resourceLauncherSaveForEveryone": "为所有人保存", + "resourceLauncherSaveForEveryoneDescription": "与所有组织成员共享此视图。如果未选中,此视图仅对您可见。", + "resourceLauncherMakePersonal": "创建个人", + "resourceLauncherFilter": "筛选", + "resourceLauncherSort": "排序", + "resourceLauncherSortAscending": "按升序排序", + "resourceLauncherSortDescending": "按降序排序", + "resourceLauncherSettings": "设置", + "resourceLauncherGroupBy": "按组", + "resourceLauncherGroupBySite": "站点", + "resourceLauncherGroupByLabel": "标签", + "resourceLauncherLayout": "布局", + "resourceLauncherLayoutGrid": "网格", + "resourceLauncherLayoutList": "列表", + "resourceLauncherShowLabels": "显示标签", + "resourceLauncherShowSiteTags": "显示站点标签", + "resourceLauncherShowRecents": "显示最近使用", + "resourceLauncherDeleteView": "删除视图", + "resourceLauncherViewAsAdmin": "以管理员身份查看", + "resourceLauncherResourceDetailsDescription": "查看此资源的详细信息。", + "resourceLauncherUnlabeled": "未标记", + "resourceLauncherNoSite": "无站点", + "resourceLauncherNoResourcesInGroup": "此组中没有资源", + "resourceLauncherEmptyStateTitle": "没有可用资源", + "resourceLauncherEmptyStateDescription": "您还没有访问任何资源。请联系您的管理员以请求访问。", + "resourceLauncherEmptyStateNoResultsTitle": "未找到资源", + "resourceLauncherEmptyStateNoResultsDescription": "没有资源与您当前的搜索或过滤器匹配。尝试调整它们以找到您想要的内容。", + "resourceLauncherEmptyStateNoResultsWithQuery": "没有资源匹配\"{query}\"。尝试调整您的搜索或清除过滤器以查看所有资源。", + "resourceLauncherCopiedToClipboard": "已复制到剪贴板", + "resourceLauncherCopiedAccessDescription": "资源访问权限已复制到剪贴板。", + "resourceLauncherViewNamePlaceholder": "查看名称", + "resourceLauncherViewNameLabel": "查看名称", + "resourceLauncherViewSaved": "视图已保存", + "resourceLauncherViewSavedDescription": "您的启动器视图已保存。", + "resourceLauncherViewSaveFailed": "保存视图失败", + "resourceLauncherViewSaveFailedDescription": "无法保存启动器视图。请再试一次。", + "resourceLauncherViewDeleted": "视图已删除", + "resourceLauncherViewDeletedDescription": "启动器视图已删除。", + "resourceLauncherViewDeleteFailed": "删除视图失败", + "resourceLauncherViewDeleteFailedDescription": "无法删除启动器视图。请再试一次。", "memberPortalPrevious": "上一页", "memberPortalNext": "下一页", "httpSettings": "HTTP 设置", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyRequired": "需要私钥", "vncTitle": "VNC", - "vncSignInDescription": "输入您的 VNC 密码以连接", + "vncSignInDescription": "输入您的VNC凭据以连接", + "vncUsernameOptional": "用户名(可选)", "vncPasswordOptional": "密码 (可选)", "vncNoResourceTarget": "没有可用的资源目标", "vncFailedToLoadNovnc": "加载 noVNC 失败", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 71fb33156..e47f94d74 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -21,6 +21,7 @@ export enum ActionsEnum { getSite = "getSite", listSites = "listSites", updateSite = "updateSite", + restartSite = "restartSite", resetSiteBandwidth = "resetSiteBandwidth", reGenerateSecret = "reGenerateSecret", createResource = "createResource", @@ -178,7 +179,8 @@ export enum ActionsEnum { setResourcePolicyPincode = "setResourcePolicyPincode", setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth", setResourcePolicyWhitelist = "setResourcePolicyWhitelist", - setResourcePolicyRules = "setResourcePolicyRules" + setResourcePolicyRules = "setResourcePolicyRules", + createOrgWideLauncherView = "createOrgWideLauncherView" } export async function checkUserActionPermission( diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index ae73b97ac..cbe8a4039 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -2,6 +2,7 @@ import { pgTable, serial, varchar, + unique, boolean, integer, bigint, @@ -19,12 +20,13 @@ import { roles, users, exitNodes, - sessions, - clients, resources, siteResources, targetHealthCheck, - sites + sites, + clients, + sessions, + labels } from "./schema"; export const certificates = pgTable("certificates", { @@ -197,6 +199,42 @@ export const remoteExitNodes = pgTable("remoteExitNode", { }) }); +export const remoteExitNodeResources = pgTable("remoteExitNodeResources", { + remoteExitNodeResourceId: serial("remoteExitNodeResourceId").primaryKey(), + remoteExitNodeId: varchar("remoteExitNodeId") + .notNull() + .references(() => remoteExitNodes.remoteExitNodeId, { + onDelete: "cascade" + }), + destination: varchar("destination").notNull() // a cidr range +}); + +export const remoteExitNodePreferenceLabels = pgTable( + // this controls what sites are enforced to connect to this node + "remoteExitNodePreferenceLabels", + { + remoteExitNodePreferenceLabelId: serial( + "remoteExitNodePreferenceLabelId" + ).primaryKey(), + remoteExitNodeId: varchar("remoteExitNodeId") + .references(() => remoteExitNodes.remoteExitNodeId, { + onDelete: "cascade" + }) + .notNull(), + labelId: integer("labelId") + .references(() => labels.labelId, { + onDelete: "cascade" + }) + .notNull() + }, + (t) => [ + unique("remote_exit_node_preference_label_uniq").on( + t.remoteExitNodeId, + t.labelId + ) + ] +); + export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", { sessionId: varchar("id").primaryKey(), remoteExitNodeId: varchar("remoteExitNodeId") diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 1b48aa520..683c572b6 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -25,7 +25,8 @@ export const domains = pgTable("domains", { certResolver: varchar("certResolver"), customCertResolver: varchar("customCertResolver"), preferWildcardCert: boolean("preferWildcardCert"), - errorMessage: text("errorMessage") + errorMessage: text("errorMessage"), + lastCheckedAt: integer("lastCheckedAt") }); export const dnsRecords = pgTable("dnsRecords", { @@ -128,7 +129,8 @@ export const sites = pgTable( t.exitNodeId, t.type, t.siteId - ) + ), + index("idx_sites_orgid_niceid").on(t.orgId, t.niceId) ] ); @@ -203,7 +205,9 @@ export const resources = pgTable( (t) => [ index("idx_resources_fulldomain") .on(t.fullDomain) - .where(sql`${t.fullDomain} IS NOT NULL`) + .where(sql`${t.fullDomain} IS NOT NULL`), + index("idx_resources_niceid").on(t.niceId), + index("idx_resources_orgid_niceid").on(t.orgId, t.niceId) ] ); @@ -218,6 +222,20 @@ export const labels = pgTable("labels", { .notNull() }); +export const launcherViews = pgTable("launcherViews", { + viewId: serial("viewId").primaryKey(), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + userId: varchar("userId").references(() => users.userId, { + onDelete: "cascade" + }), + name: varchar("name").notNull(), + config: text("config").notNull(), + createdAt: varchar("createdAt").notNull(), + updatedAt: varchar("updatedAt").notNull() +}); + export const siteLabels = pgTable( "siteLabels", { @@ -384,63 +402,77 @@ export const exitNodes = pgTable("exitNodes", { region: varchar("region") }); -export const siteResources = pgTable("siteResources", { - // this is for the clients - siteResourceId: serial("siteResourceId").primaryKey(), - orgId: varchar("orgId") - .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }), - networkId: integer("networkId").references(() => networks.networkId, { - onDelete: "set null" - }), - defaultNetworkId: integer("defaultNetworkId").references( - () => networks.networkId, - { - onDelete: "restrict" - } - ), - niceId: varchar("niceId").notNull(), - name: varchar("name").notNull(), - ssl: boolean("ssl").notNull().default(false), - mode: varchar("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http" - scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode - proxyPort: integer("proxyPort"), // only for port mode - destinationPort: integer("destinationPort"), // only for port mode - destination: varchar("destination"), // ip, cidr, hostname; validate against the mode - enabled: boolean("enabled").notNull().default(true), - alias: varchar("alias"), - aliasAddress: varchar("aliasAddress"), - tcpPortRangeString: varchar("tcpPortRangeString").notNull().default("*"), - udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"), - disableIcmp: boolean("disableIcmp").notNull().default(false), - authDaemonPort: integer("authDaemonPort").default(22123), - pamMode: varchar("pamMode", { length: 32 }) - .$type<"passthrough" | "push">() - .default("passthrough"), - authDaemonMode: varchar("authDaemonMode", { length: 32 }) - .$type<"site" | "remote" | "native">() - .default("site"), - domainId: varchar("domainId").references(() => domains.domainId, { - onDelete: "set null" - }), - subdomain: varchar("subdomain"), - fullDomain: varchar("fullDomain") -}); +export const siteResources = pgTable( + "siteResources", + { + // this is for the clients + siteResourceId: serial("siteResourceId").primaryKey(), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + networkId: integer("networkId").references(() => networks.networkId, { + onDelete: "set null" + }), + defaultNetworkId: integer("defaultNetworkId").references( + () => networks.networkId, + { + onDelete: "restrict" + } + ), + niceId: varchar("niceId").notNull(), + name: varchar("name").notNull(), + ssl: boolean("ssl").notNull().default(false), + mode: varchar("mode") + .$type<"host" | "cidr" | "http" | "ssh">() + .notNull(), // "host" | "cidr" | "http" + scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode + proxyPort: integer("proxyPort"), // only for port mode + destinationPort: integer("destinationPort"), // only for port mode + destination: varchar("destination"), // ip, cidr, hostname; validate against the mode + enabled: boolean("enabled").notNull().default(true), + alias: varchar("alias"), + aliasAddress: varchar("aliasAddress"), + tcpPortRangeString: varchar("tcpPortRangeString") + .notNull() + .default("*"), + udpPortRangeString: varchar("udpPortRangeString") + .notNull() + .default("*"), + disableIcmp: boolean("disableIcmp").notNull().default(false), + authDaemonPort: integer("authDaemonPort").default(22123), + pamMode: varchar("pamMode", { length: 32 }) + .$type<"passthrough" | "push">() + .default("passthrough"), + authDaemonMode: varchar("authDaemonMode", { length: 32 }) + .$type<"site" | "remote" | "native">() + .default("site"), + domainId: varchar("domainId").references(() => domains.domainId, { + onDelete: "set null" + }), + subdomain: varchar("subdomain"), + fullDomain: varchar("fullDomain") + }, + (t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)] +); -export const networks = pgTable("networks", { - networkId: serial("networkId").primaryKey(), - niceId: text("niceId"), - name: text("name"), - scope: varchar("scope") - .$type<"global" | "resource">() - .notNull() - .default("global"), - orgId: varchar("orgId") - .references(() => orgs.orgId, { - onDelete: "cascade" - }) - .notNull() -}); +export const networks = pgTable( + "networks", + { + networkId: serial("networkId").primaryKey(), + niceId: text("niceId"), + name: text("name"), + scope: varchar("scope") + .$type<"global" | "resource">() + .notNull() + .default("global"), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull() + }, + (t) => [index("idx_networks_orgid").on(t.orgId)] +); export const siteNetworks = pgTable( "siteNetworks", @@ -986,28 +1018,32 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", { value: varchar("value").notNull() }); -export const resourcePolicies = pgTable("resourcePolicies", { - resourcePolicyId: serial("resourcePolicyId").primaryKey(), - sso: boolean("sso").notNull().default(true), - applyRules: boolean("applyRules").notNull().default(false), - scope: varchar("scope") - .$type<"global" | "resource">() - .notNull() - .default("global"), - emailWhitelistEnabled: boolean("emailWhitelistEnabled") - .notNull() - .default(false), - idpId: integer("idpId").references(() => idp.idpId, { - onDelete: "set null" - }), - niceId: text("niceId").notNull(), - name: varchar("name").notNull(), - orgId: varchar("orgId") - .references(() => orgs.orgId, { - onDelete: "cascade" - }) - .notNull() -}); +export const resourcePolicies = pgTable( + "resourcePolicies", + { + resourcePolicyId: serial("resourcePolicyId").primaryKey(), + sso: boolean("sso").notNull().default(true), + applyRules: boolean("applyRules").notNull().default(false), + scope: varchar("scope") + .$type<"global" | "resource">() + .notNull() + .default("global"), + emailWhitelistEnabled: boolean("emailWhitelistEnabled") + .notNull() + .default(false), + idpId: integer("idpId").references(() => idp.idpId, { + onDelete: "set null" + }), + niceId: text("niceId").notNull(), + name: varchar("name").notNull(), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull() + }, + (t) => [index("idx_resourcepolicies_orgid_niceid").on(t.orgId, t.niceId)] +); export const supporterKey = pgTable("supporterKey", { keyId: serial("keyId").primaryKey(), @@ -1131,7 +1167,10 @@ export const clients = pgTable( "pending" | "approved" | "denied" >() }, - (t) => [index("idx_clients_userid").on(t.userId)] + (t) => [ + index("idx_clients_userid").on(t.userId), + index("idx_clients_orgid_niceid").on(t.orgId, t.niceId) + ] ); export const clientSitesAssociationsCache = pgTable( @@ -1550,6 +1589,7 @@ export type RoundTripMessageTracker = InferSelectModel< export type Network = InferSelectModel; export type StatusHistory = InferSelectModel; export type Label = InferSelectModel; +export type LauncherView = InferSelectModel; export type ResourcePolicy = InferSelectModel; export type RolePolicy = InferSelectModel; export type UserPolicy = InferSelectModel; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index ae7360780..b75836e29 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -12,6 +12,7 @@ import { clients, domains, exitNodes, + labels, orgs, resources, roles, @@ -21,9 +22,6 @@ import { targetHealthCheck, users } from "./schema"; -import { serial, varchar } from "drizzle-orm/mysql-core"; -import { pgTable } from "drizzle-orm/pg-core"; -import { bigint } from "zod"; export const certificates = sqliteTable("certificates", { certId: integer("certId").primaryKey({ autoIncrement: true }), @@ -195,6 +193,44 @@ export const remoteExitNodes = sqliteTable("remoteExitNode", { }) }); +export const remoteExitNodeResources = sqliteTable("remoteExitNodeResources", { + remoteExitNodeResourceId: integer("remoteExitNodeResourceId").primaryKey({ + autoIncrement: true + }), + remoteExitNodeId: text("remoteExitNodeId") + .notNull() + .references(() => remoteExitNodes.remoteExitNodeId, { + onDelete: "cascade" + }), + destination: text("destination").notNull() // a cidr range +}); + +export const remoteExitNodePreferenceLabels = sqliteTable( + // this controls what sites are enforced to connect to this node + "remoteExitNodePreferenceLabels", + { + remoteExitNodePreferenceLabelId: integer( + "remoteExitNodePreferenceLabelId" + ).primaryKey({ autoIncrement: true }), + remoteExitNodeId: text("remoteExitNodeId") + .references(() => remoteExitNodes.remoteExitNodeId, { + onDelete: "cascade" + }) + .notNull(), + labelId: integer("labelId") + .references(() => labels.labelId, { + onDelete: "cascade" + }) + .notNull() + }, + (t) => [ + uniqueIndex("remote_exit_node_preference_label_uniq").on( + t.remoteExitNodeId, + t.labelId + ) + ] +); + export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", { sessionId: text("id").primaryKey(), remoteExitNodeId: text("remoteExitNodeId") diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 0c4a143f5..28797bcd9 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -20,8 +20,10 @@ export const domains = sqliteTable("domains", { failed: integer("failed", { mode: "boolean" }).notNull().default(false), tries: integer("tries").notNull().default(0), certResolver: text("certResolver"), + customCertResolver: text("customCertResolver"), preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }), - errorMessage: text("errorMessage") + errorMessage: text("errorMessage"), + lastCheckedAt: integer("lastCheckedAt") }); export const dnsRecords = sqliteTable("dnsRecords", { @@ -221,6 +223,20 @@ export const labels = sqliteTable("labels", { .notNull() }); +export const launcherViews = sqliteTable("launcherViews", { + viewId: integer("viewId").primaryKey({ autoIncrement: true }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + userId: text("userId").references(() => users.userId, { + onDelete: "cascade" + }), + name: text("name").notNull(), + config: text("config").notNull(), + createdAt: text("createdAt").notNull(), + updatedAt: text("updatedAt").notNull() +}); + export const siteLabels = sqliteTable( "siteLabels", { @@ -1549,6 +1565,7 @@ export type RoundTripMessageTracker = InferSelectModel< >; export type StatusHistory = InferSelectModel; export type Label = InferSelectModel; +export type LauncherView = InferSelectModel; export type ResourcePolicy = InferSelectModel; export type ResourcePolicyPincode = InferSelectModel< typeof resourcePolicyPincode diff --git a/server/lib/billing/features.ts b/server/lib/billing/features.ts index 6063b470f..eff042ebf 100644 --- a/server/lib/billing/features.ts +++ b/server/lib/billing/features.ts @@ -1,28 +1,33 @@ -export enum FeatureId { +export enum LimitId { USERS = "users", SITES = "sites", EGRESS_DATA_MB = "egressDataMb", DOMAINS = "domains", REMOTE_EXIT_NODES = "remoteExitNodes", - ORGINIZATIONS = "organizations", + ORGANIZATIONS = "organizations", + PUBLIC_RESOURCES = "publicResources", + PRIVATE_RESOURCES = "privateResources", + MACHINE_CLIENTS = "machineClients", TIER1 = "tier1" } -export async function getFeatureDisplayName(featureId: FeatureId): Promise { +export async function getFeatureDisplayName( + featureId: LimitId +): Promise { switch (featureId) { - case FeatureId.USERS: + case LimitId.USERS: return "Users"; - case FeatureId.SITES: + case LimitId.SITES: return "Sites"; - case FeatureId.EGRESS_DATA_MB: + case LimitId.EGRESS_DATA_MB: return "Egress Data (MB)"; - case FeatureId.DOMAINS: + case LimitId.DOMAINS: return "Domains"; - case FeatureId.REMOTE_EXIT_NODES: + case LimitId.REMOTE_EXIT_NODES: return "Remote Exit Nodes"; - case FeatureId.ORGINIZATIONS: + case LimitId.ORGANIZATIONS: return "Organizations"; - case FeatureId.TIER1: + case LimitId.TIER1: return "Home Lab"; default: return featureId; @@ -30,15 +35,16 @@ export async function getFeatureDisplayName(featureId: FeatureId): Promise> = { // right now we are not charging for any data +export const FeatureMeterIds: Partial> = { + // right now we are not charging for any data // [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW" }; -export const FeatureMeterIdsSandbox: Partial> = { +export const FeatureMeterIdsSandbox: Partial> = { // [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ" }; -export function getFeatureMeterId(featureId: FeatureId): string | undefined { +export function getFeatureMeterId(featureId: LimitId): string | undefined { if ( process.env.ENVIRONMENT == "prod" && process.env.SANDBOX_MODE !== "true" @@ -49,22 +55,20 @@ export function getFeatureMeterId(featureId: FeatureId): string | undefined { } } -export function getFeatureIdByMetricId( - metricId: string -): FeatureId | undefined { - return (Object.entries(FeatureMeterIds) as [FeatureId, string][]).find( +export function getFeatureIdByMetricId(metricId: string): LimitId | undefined { + return (Object.entries(FeatureMeterIds) as [LimitId, string][]).find( ([_, v]) => v === metricId )?.[0]; } -export type FeaturePriceSet = Partial>; +export type FeaturePriceSet = Partial>; export const tier1FeaturePriceSet: FeaturePriceSet = { - [FeatureId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G" + [LimitId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G" }; export const tier1FeaturePriceSetSandbox: FeaturePriceSet = { - [FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT" + [LimitId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT" }; export function getTier1FeaturePriceSet(): FeaturePriceSet { @@ -79,11 +83,11 @@ export function getTier1FeaturePriceSet(): FeaturePriceSet { } export const tier2FeaturePriceSet: FeaturePriceSet = { - [FeatureId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN" + [LimitId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN" }; export const tier2FeaturePriceSetSandbox: FeaturePriceSet = { - [FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR" + [LimitId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR" }; export function getTier2FeaturePriceSet(): FeaturePriceSet { @@ -98,11 +102,11 @@ export function getTier2FeaturePriceSet(): FeaturePriceSet { } export const tier3FeaturePriceSet: FeaturePriceSet = { - [FeatureId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv" + [LimitId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv" }; export const tier3FeaturePriceSetSandbox: FeaturePriceSet = { - [FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs" + [LimitId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs" }; export function getTier3FeaturePriceSet(): FeaturePriceSet { @@ -116,7 +120,7 @@ export function getTier3FeaturePriceSet(): FeaturePriceSet { } } -export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined { +export function getFeatureIdByPriceId(priceId: string): LimitId | undefined { // Check all feature price sets const allPriceSets = [ getTier1FeaturePriceSet(), @@ -125,7 +129,7 @@ export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined { ]; for (const priceSet of allPriceSets) { - const entry = (Object.entries(priceSet) as [FeatureId, string][]).find( + const entry = (Object.entries(priceSet) as [LimitId, string][]).find( ([_, price]) => price === priceId ); if (entry) { diff --git a/server/lib/billing/getLineItems.ts b/server/lib/billing/getLineItems.ts index d386e5e96..5df5fb8a8 100644 --- a/server/lib/billing/getLineItems.ts +++ b/server/lib/billing/getLineItems.ts @@ -1,19 +1,19 @@ import Stripe from "stripe"; -import { FeatureId, FeaturePriceSet } from "./features"; +import { LimitId, FeaturePriceSet } from "./features"; import { usageService } from "./usageService"; export async function getLineItems( featurePriceSet: FeaturePriceSet, - orgId: string, + orgId: string ): Promise { - const users = await usageService.getUsage(orgId, FeatureId.USERS); + const users = await usageService.getUsage(orgId, LimitId.USERS); return Object.entries(featurePriceSet).map(([featureId, priceId]) => { let quantity: number | undefined; - if (featureId === FeatureId.USERS) { + if (featureId === LimitId.USERS) { quantity = users?.instantaneousValue || 1; - } else if (featureId === FeatureId.TIER1) { + } else if (featureId === LimitId.TIER1) { quantity = 1; } diff --git a/server/lib/billing/limitSet.ts b/server/lib/billing/limitSet.ts index e45ae637d..87b1a9c17 100644 --- a/server/lib/billing/limitSet.ts +++ b/server/lib/billing/limitSet.ts @@ -1,70 +1,82 @@ -import { FeatureId } from "./features"; +import { LimitId } from "./features"; export type LimitSet = Partial<{ - [key in FeatureId]: { + [key in LimitId]: { value: number | null; // null indicates no limit description?: string; }; }>; export const freeLimitSet: LimitSet = { - [FeatureId.SITES]: { value: 5, description: "Basic limit" }, - [FeatureId.USERS]: { value: 5, description: "Basic limit" }, - [FeatureId.DOMAINS]: { value: 5, description: "Basic limit" }, - [FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" }, - [FeatureId.ORGINIZATIONS]: { value: 1, description: "Basic limit" }, + [LimitId.SITES]: { value: 5, description: "Basic limit" }, + [LimitId.USERS]: { value: 5, description: "Basic limit" }, + [LimitId.DOMAINS]: { value: 5, description: "Basic limit" }, + [LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" }, + [LimitId.ORGANIZATIONS]: { value: 1, description: "Basic limit" }, + [LimitId.PUBLIC_RESOURCES]: { value: 15, description: "Basic limit" }, + [LimitId.PRIVATE_RESOURCES]: { value: 15, description: "Basic limit" }, + [LimitId.MACHINE_CLIENTS]: { value: 5, description: "Basic limit" } }; export const tier1LimitSet: LimitSet = { - [FeatureId.USERS]: { value: 7, description: "Home limit" }, - [FeatureId.SITES]: { value: 10, description: "Home limit" }, - [FeatureId.DOMAINS]: { value: 10, description: "Home limit" }, - [FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" }, - [FeatureId.ORGINIZATIONS]: { value: 1, description: "Home limit" }, + [LimitId.USERS]: { value: 7, description: "Home limit" }, + [LimitId.SITES]: { value: 10, description: "Home limit" }, + [LimitId.DOMAINS]: { value: 10, description: "Home limit" }, + [LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" }, + [LimitId.ORGANIZATIONS]: { value: 1, description: "Home limit" }, + [LimitId.PUBLIC_RESOURCES]: { value: 30, description: "Home limit" }, + [LimitId.PRIVATE_RESOURCES]: { value: 30, description: "Home limit" }, + [LimitId.MACHINE_CLIENTS]: { value: 10, description: "Home limit" } }; export const tier2LimitSet: LimitSet = { - [FeatureId.USERS]: { + [LimitId.USERS]: { value: 50, description: "Team limit" }, - [FeatureId.SITES]: { + [LimitId.SITES]: { value: 50, description: "Team limit" }, - [FeatureId.DOMAINS]: { + [LimitId.DOMAINS]: { value: 50, description: "Team limit" }, - [FeatureId.REMOTE_EXIT_NODES]: { + [LimitId.REMOTE_EXIT_NODES]: { value: 3, description: "Team limit" }, - [FeatureId.ORGINIZATIONS]: { + [LimitId.ORGANIZATIONS]: { value: 1, description: "Team limit" - } + }, + [LimitId.PUBLIC_RESOURCES]: { value: 150, description: "Team limit" }, + [LimitId.PRIVATE_RESOURCES]: { value: 150, description: "Team limit" }, + [LimitId.MACHINE_CLIENTS]: { value: 25, description: "Team limit" } }; export const tier3LimitSet: LimitSet = { - [FeatureId.USERS]: { + [LimitId.USERS]: { value: 250, description: "Business limit" }, - [FeatureId.SITES]: { + [LimitId.SITES]: { value: 250, description: "Business limit" }, - [FeatureId.DOMAINS]: { + [LimitId.DOMAINS]: { value: 100, description: "Business limit" }, - [FeatureId.REMOTE_EXIT_NODES]: { + [LimitId.REMOTE_EXIT_NODES]: { value: 20, description: "Business limit" }, - [FeatureId.ORGINIZATIONS]: { + [LimitId.ORGANIZATIONS]: { value: 5, description: "Business limit" }, + [LimitId.PUBLIC_RESOURCES]: { value: 750, description: "Business limit" }, + [LimitId.PRIVATE_RESOURCES]: { value: 750, description: "Business limit" }, + [LimitId.MACHINE_CLIENTS]: { value: 100, description: "Business limit" } }; diff --git a/server/lib/billing/limitsService.ts b/server/lib/billing/limitsService.ts index f364d6e00..e08b0fe14 100644 --- a/server/lib/billing/limitsService.ts +++ b/server/lib/billing/limitsService.ts @@ -1,7 +1,7 @@ import { db, limits } from "@server/db"; import { and, eq } from "drizzle-orm"; import { LimitSet } from "./limitSet"; -import { FeatureId } from "./features"; +import { LimitId } from "./features"; import logger from "@server/logger"; class LimitService { @@ -38,7 +38,7 @@ class LimitService { async getOrgLimit( orgId: string, - featureId: FeatureId + featureId: LimitId ): Promise { const limitId = `${orgId}-${featureId}`; const [limit] = await db diff --git a/server/lib/billing/usageService.ts b/server/lib/billing/usageService.ts index dd32a09ad..413bdf9a3 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -9,7 +9,7 @@ import { Transaction, orgs } from "@server/db"; -import { FeatureId, getFeatureMeterId } from "./features"; +import { LimitId, getFeatureMeterId } from "./features"; import logger from "@server/logger"; import { build } from "@server/build"; import { regionalCache as cache } from "#dynamic/lib/cache"; @@ -37,7 +37,7 @@ export class UsageService { public async add( orgId: string, - featureId: FeatureId, + featureId: LimitId, value: number, transaction: any = null ): Promise { @@ -114,7 +114,7 @@ export class UsageService { private async internalAddUsage( orgId: string, // here the orgId is the billing org already resolved by getBillingOrg in updateCount - featureId: FeatureId, + featureId: LimitId, value: number, trx: Transaction ): Promise { @@ -163,7 +163,7 @@ export class UsageService { async updateCount( orgId: string, - featureId: FeatureId, + featureId: LimitId, value?: number, customerId?: string ): Promise { @@ -227,7 +227,7 @@ export class UsageService { private async getCustomerId( orgId: string, - featureId: FeatureId + featureId: LimitId ): Promise { const orgIdToUse = await this.getBillingOrg(orgId); @@ -269,18 +269,19 @@ export class UsageService { public async getUsage( orgId: string, - featureId: FeatureId, + featureId: LimitId, trx: Transaction | typeof db = db ): Promise { if (noop()) { return null; } - const orgIdToUse = await this.getBillingOrg(orgId, trx); - - const usageId = `${orgIdToUse}-${featureId}`; - + let orgIdToUse = orgId; try { + orgIdToUse = await this.getBillingOrg(orgId, trx); + + const usageId = `${orgIdToUse}-${featureId}`; + const [result] = await trx .select() .from(usage) @@ -340,8 +341,12 @@ export class UsageService { `Failed to get usage for ${orgIdToUse}/${featureId}:`, error ); - throw error; + if (process.env.NODE_ENV !== "development") { + throw error; + } } + + return null; } public async getBillingOrg( @@ -376,7 +381,7 @@ export class UsageService { public async checkLimitSet( orgId: string, - featureId?: FeatureId, + featureId?: LimitId, usage?: Usage, trx: Transaction | typeof db = db ): Promise { @@ -384,13 +389,13 @@ export class UsageService { return false; } - const orgIdToUse = await this.getBillingOrg(orgId, trx); - // This method should check the current usage against the limits set for the organization // and kick out all of the sites on the org let hasExceededLimits = false; - + let orgIdToUse = orgId; try { + orgIdToUse = await this.getBillingOrg(orgId, trx); + let orgLimits: Limit[] = []; if (featureId) { // Get all limits set for this organization @@ -424,7 +429,7 @@ export class UsageService { } else { currentUsage = await this.getUsage( orgIdToUse, - limit.featureId as FeatureId, + limit.featureId as LimitId, trx ); } diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index 493831131..c62dd4735 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -8,7 +8,7 @@ import { userSiteResources, clientSiteResources } from "@server/db"; -import { Config, ConfigSchema } from "./types"; +import { Config, ConfigSchema, isTargetsOnlyResource } from "./types"; import { PublicResourcesResults, updatePublicResources @@ -34,6 +34,12 @@ import { rebuildClientAssociationsFromSiteResource, waitForSiteResourceRebuildIdle } from "../rebuildClientAssociations"; +import { build } from "@server/build"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import next from "next"; +import { LimitId } from "../billing"; +import { usageService } from "../billing/usageService"; type ApplyBlueprintArgs = { orgId: string; @@ -64,6 +70,7 @@ export async function applyBlueprint({ let publicResourcesResults: PublicResourcesResults = []; let privateResourcesResults: ClientResourcesResults = []; + await db.transaction(async (trx) => { await updateResourcePolicies(orgId, config, trx); @@ -172,7 +179,9 @@ export async function applyBlueprint({ } catch (err) { blueprintSucceeded = false; blueprintMessage = `Blueprint applied with errors: ${err}`; - logger.error(blueprintMessage); + logger.debug( + `Org ${orgId} blueprint apply issues: ${blueprintMessage}` + ); error = err; } diff --git a/server/lib/blueprints/privateResources.ts b/server/lib/blueprints/privateResources.ts index 8d00701f1..74a1f618f 100644 --- a/server/lib/blueprints/privateResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -25,6 +25,12 @@ import { getNextAvailableAliasAddress } from "../ip"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "../billing/tierMatrix"; +import { build } from "@server/build"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import next from "next"; +import { LimitId } from "../billing"; +import { usageService } from "../billing/usageService"; async function getDomainForSiteResource( siteResourceId: number | undefined, @@ -413,6 +419,34 @@ export async function updatePrivateResources( oldSites: existingSiteIds }); } else { + // create a brand new resource + + if (build == "saas") { + const usage = await usageService.getUsage( + orgId, + LimitId.PRIVATE_RESOURCES + ); + if (!usage) { + throw new Error( + `Usage data not found for org ${orgId} and limit ${LimitId.PRIVATE_RESOURCES}` + ); + } + const rejectResource = await usageService.checkLimitSet( + orgId, + + LimitId.PRIVATE_RESOURCES, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } // We need to add one to know if we are violating the limit + ); + if (rejectResource) { + throw new Error( + "Private resource limit exceeded. Please upgrade your plan." + ); + } + } + let aliasAddress: string | null = null; let releaseAliasLock: (() => Promise) | null = null; if ( @@ -609,6 +643,8 @@ export async function updatePrivateResources( `Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}` ); + await usageService.add(orgId, LimitId.PRIVATE_RESOURCES, 1, trx); + results.push({ newSiteResource: newResource, newSites: allSites, diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index 1bbe0a4f1..92d6239fa 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -51,6 +51,11 @@ import { build } from "@server/build"; import { encrypt } from "@server/lib/crypto"; import { generateId } from "@server/auth/sessions/app"; import serverConfig from "@server/lib/config"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import next from "next"; +import { LimitId } from "../billing"; +import { usageService } from "../billing/usageService"; export type PublicResourcesResults = { proxyResource: Resource; @@ -1005,6 +1010,33 @@ export async function updatePublicResources( logger.debug(`Updated resource ${existingResource.resourceId}`); } else { // create a brand new resource + + if (build == "saas") { + const usage = await usageService.getUsage( + orgId, + LimitId.PUBLIC_RESOURCES + ); + if (!usage) { + throw new Error( + `Usage data not found for org ${orgId} and limit ${LimitId.PUBLIC_RESOURCES}` + ); + } + const rejectResource = await usageService.checkLimitSet( + orgId, + + LimitId.PUBLIC_RESOURCES, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } // We need to add one to know if we are violating the limit + ); + if (rejectResource) { + throw new Error( + "Public resource limit exceeded. Please upgrade your plan." + ); + } + } + let domain; if ( ["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "") @@ -1294,6 +1326,8 @@ export async function updatePublicResources( await createTarget(newResource.resourceId, targetData); } + await usageService.add(orgId, LimitId.PUBLIC_RESOURCES, 1, trx); + logger.debug(`Created resource ${newResource.resourceId}`); } diff --git a/server/lib/deleteOrg.ts b/server/lib/deleteOrg.ts index 065f216a1..20c8a3e23 100644 --- a/server/lib/deleteOrg.ts +++ b/server/lib/deleteOrg.ts @@ -24,7 +24,7 @@ import { deletePeer } from "@server/routers/gerbil/peers"; import { OlmErrorCodes } from "@server/routers/olm/error"; import { sendTerminateClient } from "@server/routers/client/terminate"; import { usageService } from "./billing/usageService"; -import { FeatureId } from "./billing"; +import { LimitId } from "./billing"; export type DeleteOrgByIdResult = { deletedNewtIds: string[]; @@ -140,7 +140,9 @@ export async function deleteOrgById( .select({ count: count() }) .from(orgDomains) .where(eq(orgDomains.domainId, domainId)); - logger.info(`Found ${orgCount.count} orgs using domain ${domainId}`); + logger.info( + `Found ${orgCount.count} orgs using domain ${domainId}` + ); if (orgCount.count === 1) { domainIdsToDelete.push(domainId); } @@ -152,7 +154,7 @@ export async function deleteOrgById( .where(inArray(domains.domainId, domainIdsToDelete)); } - await usageService.add(orgId, FeatureId.ORGINIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here + await usageService.add(orgId, LimitId.ORGANIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here await trx.delete(orgs).where(eq(orgs.orgId, orgId)); @@ -199,22 +201,22 @@ export async function deleteOrgById( if (org.billingOrgId) { usageService.updateCount( org.billingOrgId, - FeatureId.DOMAINS, + LimitId.DOMAINS, domainCount ?? 0 ); usageService.updateCount( org.billingOrgId, - FeatureId.SITES, + LimitId.SITES, siteCount ?? 0 ); usageService.updateCount( org.billingOrgId, - FeatureId.USERS, + LimitId.USERS, userCount ?? 0 ); usageService.updateCount( org.billingOrgId, - FeatureId.REMOTE_EXIT_NODES, + LimitId.REMOTE_EXIT_NODES, remoteExitNodeCount ?? 0 ); } diff --git a/server/lib/exitNodes/exitNodes.ts b/server/lib/exitNodes/exitNodes.ts index fb32f4f72..f405a1114 100644 --- a/server/lib/exitNodes/exitNodes.ts +++ b/server/lib/exitNodes/exitNodes.ts @@ -19,7 +19,11 @@ export async function verifyExitNodeOrgAccess( export async function listExitNodes( orgId: string, filterOnline = false, - noCloud = false + noCloud = false, + // Accepted for parity with the enterprise implementation (used there for + // site-label filtering of remote exit nodes). The OSS build has no remote + // exit nodes, so it is unused here. + siteId?: number ) { // TODO: pick which nodes to send and ping better than just all of them that are not remote const allExitNodes = await db diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 6da8bf887..56576282a 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -333,7 +333,7 @@ export async function getNextAvailableClientSubnet( if (!acquired) { throw new Error(`Failed to acquire lock: ${lockKey}`); } - const release = () => lockManager.releaseLock(lockKey); + const release = () => lockManager.releaseLock(lockKey, acquired); try { const [org] = await transaction @@ -395,7 +395,7 @@ export async function getNextAvailableAliasAddress( if (!acquired) { throw new Error(`Failed to acquire lock: ${lockKey}`); } - const release = () => lockManager.releaseLock(lockKey); + const release = () => lockManager.releaseLock(lockKey, acquired); try { const [org] = await trx @@ -463,7 +463,7 @@ export async function getNextAvailableOrgSubnet(): Promise<{ if (!acquired) { throw new Error(`Failed to acquire lock: ${lockKey}`); } - const release = () => lockManager.releaseLock(lockKey); + const release = () => lockManager.releaseLock(lockKey, acquired); try { const existingAddresses = await db diff --git a/server/lib/lock.ts b/server/lib/lock.ts index 3cd1b8704..2f6fad673 100644 --- a/server/lib/lock.ts +++ b/server/lib/lock.ts @@ -1,3 +1,5 @@ +import { randomUUID } from "crypto"; + const instanceId = `local-${Math.random().toString(36).slice(2)}-${Date.now()}`; type LocalLockRecord = { @@ -15,58 +17,60 @@ export class LockManager { } } - private getLocalOwnerToken(): string { - return `${instanceId}:`; - } - /** - * Acquire a distributed lock using Redis SET with NX and PX options + * Acquire a local in-process lock using an optimistic Map-based check. * @param lockKey - Unique identifier for the lock * @param ttlMs - Time to live in milliseconds - * @returns Promise - true if lock acquired, false otherwise + * @returns Promise - a token identifying this specific acquisition + * (truthy) on success, or null if the lock could not be acquired. */ async acquireLock( lockKey: string, ttlMs: number = 30000, maxRetries: number = 3, retryDelayMs: number = 100 - ): Promise { + ): Promise { for (let attempt = 0; attempt < maxRetries; attempt++) { this.clearExpiredLocalLock(lockKey); const existing = localLocks.get(lockKey); if (!existing) { + const token = `${instanceId}:${randomUUID()}`; localLocks.set(lockKey, { - owner: this.getLocalOwnerToken(), + owner: token, expiresAt: Date.now() + ttlMs }); - return true; - } - - if (existing.owner === this.getLocalOwnerToken()) { - existing.expiresAt = Date.now() + ttlMs; - localLocks.set(lockKey, existing); - return true; + return token; } + // The lock is currently held -- possibly by a different, unrelated + // caller in this same process. We intentionally do NOT treat + // same-process holders as automatically reentrant here: two + // independent logical operations (e.g. two different API requests) + // running concurrently in the same process must not both believe + // they hold the lock, or their writes under it can interleave + // unguarded. Just retry with backoff like any other contended lock. if (attempt < maxRetries - 1) { const delay = retryDelayMs * Math.pow(2, attempt); await new Promise((resolve) => setTimeout(resolve, delay)); } } - return false; + return null; } /** - * Release a lock using Lua script to ensure atomicity + * Release a lock previously acquired via acquireLock/acquireLockWithRetry. * @param lockKey - Unique identifier for the lock + * @param token - the exact token returned by the acquisition being released. + * Required so a caller whose TTL already expired can't delete a + * different, currently-active holder's lock. */ - async releaseLock(lockKey: string): Promise { + async releaseLock(lockKey: string, token: string): Promise { this.clearExpiredLocalLock(lockKey); const existing = localLocks.get(lockKey); - if (existing && existing.owner === this.getLocalOwnerToken()) { + if (existing && existing.owner === token) { localLocks.delete(lockKey); } } @@ -100,23 +104,29 @@ export class LockManager { const ttl = Math.max(0, existing.expiresAt - Date.now()); return { exists: true, - ownedByMe: existing.owner === this.getLocalOwnerToken(), + ownedByMe: existing.owner.startsWith(`${instanceId}:`), ttl, owner: existing.owner.split(":")[0] }; } /** - * Extend the TTL of an existing lock owned by this worker + * Extend the TTL of an existing lock, provided the token matches the + * acquisition currently holding it. * @param lockKey - Unique identifier for the lock * @param ttlMs - New TTL in milliseconds + * @param token - the token returned by the acquisition being extended * @returns Promise - true if extended successfully */ - async extendLock(lockKey: string, ttlMs: number): Promise { + async extendLock( + lockKey: string, + ttlMs: number, + token: string + ): Promise { this.clearExpiredLocalLock(lockKey); const existing = localLocks.get(lockKey); - if (!existing || existing.owner !== this.getLocalOwnerToken()) { + if (!existing || existing.owner !== token) { return false; } @@ -131,14 +141,14 @@ export class LockManager { * @param ttlMs - Time to live in milliseconds * @param maxRetries - Maximum number of retry attempts * @param baseDelayMs - Base delay between retries in milliseconds - * @returns Promise - true if lock acquired + * @returns Promise - token if acquired, null otherwise */ async acquireLockWithRetry( lockKey: string, ttlMs: number = 30000, maxRetries: number = 5, baseDelayMs: number = 100 - ): Promise { + ): Promise { for (let attempt = 0; attempt <= maxRetries; attempt++) { const acquired = await this.acquireLock( lockKey, @@ -148,7 +158,7 @@ export class LockManager { ); if (acquired) { - return true; + return acquired; } if (attempt < maxRetries) { @@ -158,7 +168,7 @@ export class LockManager { } } - return false; + return null; } /** @@ -173,16 +183,16 @@ export class LockManager { fn: () => Promise, ttlMs: number = 30000 ): Promise { - const acquired = await this.acquireLock(lockKey, ttlMs); + const token = await this.acquireLock(lockKey, ttlMs); - if (!acquired) { + if (!token) { throw new Error(`Failed to acquire lock: ${lockKey}`); } try { return await fn(); } finally { - await this.releaseLock(lockKey); + await this.releaseLock(lockKey, token); } } @@ -204,7 +214,7 @@ export class LockManager { let locksOwnedByMe = 0; for (const value of localLocks.values()) { - if (value.owner === this.getLocalOwnerToken()) { + if (value.owner.startsWith(`${instanceId}:`)) { locksOwnedByMe++; } } diff --git a/server/lib/orgRebuildCounter.ts b/server/lib/orgRebuildCounter.ts new file mode 100644 index 000000000..40edd0aa0 --- /dev/null +++ b/server/lib/orgRebuildCounter.ts @@ -0,0 +1,24 @@ +export const ORG_REBUILD_CONCURRENCY_LIMIT = 10; + +const orgActiveRebuilds = new Map(); + +export async function incrementOrgRebuildCount(orgId: string): Promise { + orgActiveRebuilds.set(orgId, (orgActiveRebuilds.get(orgId) ?? 0) + 1); +} + +export async function decrementOrgRebuildCount(orgId: string): Promise { + const current = orgActiveRebuilds.get(orgId) ?? 0; + if (current <= 1) { + orgActiveRebuilds.delete(orgId); + } else { + orgActiveRebuilds.set(orgId, current - 1); + } +} + +export async function getOrgActiveRebuildCount(orgId: string): Promise { + return orgActiveRebuilds.get(orgId) ?? 0; +} + +export async function checkOrgRebuildRateLimit(orgId: string): Promise { + return (orgActiveRebuilds.get(orgId) ?? 0) >= ORG_REBUILD_CONCURRENCY_LIMIT; +} diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 1f675e81e..150001662 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -45,11 +45,23 @@ import { } from "@server/routers/client/targets"; import { lockManager } from "#dynamic/lib/lock"; import { rebuildQueue } from "#dynamic/lib/rebuildQueue"; +import { + checkOrgRebuildRateLimit, + decrementOrgRebuildCount, + incrementOrgRebuildCount, + ORG_REBUILD_CONCURRENCY_LIMIT +} from "#dynamic/lib/orgRebuildCounter"; + +export { ORG_REBUILD_CONCURRENCY_LIMIT }; // TTL for rebuild-association locks. These functions can fan out into many // peer/proxy updates, so give them a generous window. const REBUILD_ASSOCIATIONS_LOCK_TTL_MS = 120000; +export async function isOrgRebuildRateLimited(orgId: string): Promise { + return checkOrgRebuildRateLimit(orgId); +} + const REBUILD_IDLE_POLL_INTERVAL_MS = 300; const REBUILD_IDLE_DEFAULT_TIMEOUT_MS = 130_000; // slightly longer than lock TTL const REBUILD_IDLE_HANDLER_TIMEOUT_MS = 5_000; @@ -271,6 +283,7 @@ export async function getClientSiteResourceAccess( export async function rebuildClientAssociationsFromSiteResource( siteResource: SiteResource ) { + await incrementOrgRebuildCount(siteResource.orgId); try { return await lockManager.withLock( `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, @@ -292,6 +305,8 @@ export async function rebuildClientAssociationsFromSiteResource( return { mergedAllClients: [] }; } throw err; + } finally { + await decrementOrgRebuildCount(siteResource.orgId); } } @@ -1638,8 +1653,9 @@ export async function handleMessagingForUpdatedSiteResource( export async function rebuildClientAssociationsFromClient( client: Client ): Promise { - const trx = primaryDb; + await incrementOrgRebuildCount(client.orgId); try { + const trx = primaryDb; return await lockManager.withLock( `rebuild-client-associations:client:${client.clientId}`, () => rebuildClientAssociationsFromClientImpl(client, trx), @@ -1660,6 +1676,8 @@ export async function rebuildClientAssociationsFromClient( return; } throw err; + } finally { + await decrementOrgRebuildCount(client.orgId); } } diff --git a/server/lib/rebuildQueue.ts b/server/lib/rebuildQueue.ts index 84dce9641..07f2cb003 100644 --- a/server/lib/rebuildQueue.ts +++ b/server/lib/rebuildQueue.ts @@ -1,3 +1,5 @@ +import logger from "@server/logger"; + export type RebuildJobType = "site-resource" | "client"; export interface RebuildJob { @@ -16,12 +18,104 @@ export interface RebuildQueueManager { isQueued(job: RebuildJob): Promise; } -class NoopRebuildQueue implements RebuildQueueManager { - async enqueue(_job: RebuildJob): Promise {} - startProcessing(_handlers: RebuildJobHandlers): void {} - async isQueued(_job: RebuildJob): Promise { - return false; +// In-process FIFO used when there is no Redis to back a distributed queue +// (OSS build, or Redis unavailable). A job that loses the per-resource +// rebuild lock race lands here instead of being silently dropped, and gets +// retried shortly after against fresh DB state. +const POLL_INTERVAL_MS = 500; +const BATCH_SIZE = 5; + +function dedupeKey(job: RebuildJob): string { + return `${job.type}:${job.id}`; +} + +class InMemoryRebuildQueue implements RebuildQueueManager { + private queue: RebuildJob[] = []; + private queuedSet = new Set(); + private processing = false; + private processingStarted = false; + private handlers: RebuildJobHandlers | null = null; + + async isQueued(job: RebuildJob): Promise { + return this.queuedSet.has(dedupeKey(job)); + } + + async enqueue(job: RebuildJob): Promise { + const key = dedupeKey(job); + if (this.queuedSet.has(key)) { + logger.debug( + `Rebuild queue: skipped duplicate queued job ${job.type}:${job.id}` + ); + return; + } + this.queuedSet.add(key); + this.queue.push(job); + logger.debug( + `Rebuild queue: enqueued ${job.type}:${job.id} (queue position: tail)` + ); + } + + startProcessing(handlers: RebuildJobHandlers): void { + if (this.processingStarted) return; + this.processingStarted = true; + this.handlers = handlers; + + setInterval(() => { + this.tryProcessBatch().catch((err) => { + logger.error( + "Rebuild queue: unhandled error in process loop:", + err + ); + }); + }, POLL_INTERVAL_MS); + + logger.info("Rebuild queue processor started (in-memory)"); + } + + private async tryProcessBatch(): Promise { + if (this.processing || !this.handlers || this.queue.length === 0) { + return; + } + + this.processing = true; + try { + for (let i = 0; i < BATCH_SIZE; i++) { + const job = this.queue.shift(); + if (!job) break; // queue drained + + // Remove from the dedupe set once dequeued so the same job + // can be re-queued while this one is in progress. + this.queuedSet.delete(dedupeKey(job)); + + logger.debug( + `Rebuild queue: processing ${job.type}:${job.id}` + ); + + try { + if (job.type === "site-resource") { + await this.handlers.onSiteResource(job.id); + } else if (job.type === "client") { + await this.handlers.onClient(job.id); + } else { + logger.warn( + `Rebuild queue: unknown job type "${(job as any).type}", discarding` + ); + } + + logger.debug( + `Rebuild queue: completed ${job.type}:${job.id}` + ); + } catch (err) { + logger.error( + `Rebuild queue: job ${job.type}:${job.id} threw an error:`, + err + ); + } + } + } finally { + this.processing = false; + } } } -export const rebuildQueue: RebuildQueueManager = new NoopRebuildQueue(); +export const rebuildQueue: RebuildQueueManager = new InMemoryRebuildQueue(); diff --git a/server/lib/userOrg.ts b/server/lib/userOrg.ts index 809266b73..4bff40c13 100644 --- a/server/lib/userOrg.ts +++ b/server/lib/userOrg.ts @@ -14,7 +14,7 @@ import { } from "@server/db"; import { eq, and, inArray, ne, exists } from "drizzle-orm"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; export async function assignUserToOrg( org: Org, @@ -61,7 +61,7 @@ export async function assignUserToOrg( ); if (orgsInBillingDomainThatTheUserIsStillIn.length === 0) { - await usageService.add(org.orgId, FeatureId.USERS, 1, trx); + await usageService.add(org.orgId, LimitId.USERS, 1, trx); } } } @@ -157,7 +157,7 @@ export async function removeUserFromOrg( ); if (orgsInBillingDomainThatTheUserIsStillIn.length === 0) { - await usageService.add(org.orgId, FeatureId.USERS, -1, trx); + await usageService.add(org.orgId, LimitId.USERS, -1, trx); } } } diff --git a/server/private/lib/exitNodes/exitNodes.ts b/server/private/lib/exitNodes/exitNodes.ts index f6417dae2..1f9517725 100644 --- a/server/private/lib/exitNodes/exitNodes.ts +++ b/server/private/lib/exitNodes/exitNodes.ts @@ -18,12 +18,15 @@ import { resources, targets, sites, + siteLabels, + remoteExitNodes, + remoteExitNodePreferenceLabels, targetHealthCheck, Transaction } from "@server/db"; import logger from "@server/logger"; import { ExitNodePingResult } from "@server/routers/newt"; -import { eq, and, or, ne, isNull } from "drizzle-orm"; +import { eq, and, or, ne, isNull, inArray } from "drizzle-orm"; import axios from "axios"; import config from "../config"; @@ -150,7 +153,8 @@ export async function verifyExitNodeOrgAccess( export async function listExitNodes( orgId: string, filterOnline = false, - noCloud = false + noCloud = false, + siteId?: number ) { const allExitNodes = await db .select({ @@ -237,7 +241,7 @@ export async function listExitNodes( // }) // ); - const remoteExitNodes = allExitNodes.filter( + let remoteExitNodesList = allExitNodes.filter( (node) => node.type === "remoteExitNode" && (!filterOnline || node.online) ); @@ -246,9 +250,82 @@ export async function listExitNodes( node.type === "gerbil" && (!filterOnline || node.online) && !noCloud ); + // Apply label-based filtering to remote exit nodes if siteId is provided + if (siteId !== undefined && remoteExitNodesList.length > 0) { + // Get the site's labels + const siteLabelRows = await db + .select({ labelId: siteLabels.labelId }) + .from(siteLabels) + .where(eq(siteLabels.siteId, siteId)); + const siteLabelIds = new Set(siteLabelRows.map((r) => r.labelId)); + + // Get the remoteExitNode records for these exit nodes so we have the remoteExitNodeId + const exitNodeIds = remoteExitNodesList.map((n) => n.exitNodeId); + const remoteNodeRows = await db + .select({ + exitNodeId: remoteExitNodes.exitNodeId, + remoteExitNodeId: remoteExitNodes.remoteExitNodeId + }) + .from(remoteExitNodes) + .where(inArray(remoteExitNodes.exitNodeId, exitNodeIds)); + + const exitNodeIdToRemoteId = new Map( + remoteNodeRows + .filter((r) => r.exitNodeId !== null) + .map((r) => [r.exitNodeId!, r.remoteExitNodeId]) + ); + + // Get preference labels for all remote exit nodes + const remoteExitNodeIds = remoteNodeRows.map((r) => r.remoteExitNodeId); + const prefLabelRows = + remoteExitNodeIds.length > 0 + ? await db + .select({ + remoteExitNodeId: + remoteExitNodePreferenceLabels.remoteExitNodeId, + labelId: remoteExitNodePreferenceLabels.labelId + }) + .from(remoteExitNodePreferenceLabels) + .where( + inArray( + remoteExitNodePreferenceLabels.remoteExitNodeId, + remoteExitNodeIds + ) + ) + : []; + + // Build a map of remoteExitNodeId -> Set of labelIds + const prefLabelsMap = new Map>(); + for (const row of prefLabelRows) { + if (!prefLabelsMap.has(row.remoteExitNodeId)) { + prefLabelsMap.set(row.remoteExitNodeId, new Set()); + } + prefLabelsMap.get(row.remoteExitNodeId)!.add(row.labelId); + } + + // Filter: include node if it has no preference labels, or if site shares at least one label + const filtered = remoteExitNodesList.filter((node) => { + const remoteId = exitNodeIdToRemoteId.get(node.exitNodeId); + if (!remoteId) return true; // no remoteExitNode record, don't filter + const prefLabels = prefLabelsMap.get(remoteId); + if (!prefLabels || prefLabels.size === 0) return true; // no preference labels, include + // include only if site has at least one matching label + for (const labelId of siteLabelIds) { + if (prefLabels.has(labelId)) return true; + } + return false; + }); + + // Only apply the filtered list if at least one remote node remains; + // otherwise fall through to the gerbil fallback below + if (filtered.length > 0 || remoteExitNodesList.length === 0) { + remoteExitNodesList = filtered; + } + } + // THIS PROVIDES THE FALL const exitNodesList = - remoteExitNodes.length > 0 ? remoteExitNodes : gerbilExitNodes; + remoteExitNodesList.length > 0 ? remoteExitNodesList : gerbilExitNodes; return exitNodesList; } diff --git a/server/private/lib/lock.ts b/server/private/lib/lock.ts index 26577b2b0..94b27a337 100644 --- a/server/private/lib/lock.ts +++ b/server/private/lib/lock.ts @@ -32,55 +32,59 @@ export class LockManager { } } - private getLocalOwnerToken(): string { - return `${instanceId}:`; - } - /** * Acquire a distributed lock using Redis SET with NX and PX options * @param lockKey - Unique identifier for the lock * @param ttlMs - Time to live in milliseconds - * @returns Promise - true if lock acquired, false otherwise + * @returns Promise - a token identifying this specific acquisition + * (truthy) on success, or null if the lock could not be acquired. */ async acquireLock( lockKey: string, ttlMs: number = 30000, maxRetries: number = 3, retryDelayMs: number = 100 - ): Promise { + ): Promise { if (!redis || !redis.status || redis.status !== "ready") { for (let attempt = 0; attempt < maxRetries; attempt++) { this.clearExpiredLocalLock(lockKey); const existing = localLocks.get(lockKey); if (!existing) { + const token = `${instanceId}:${uuidv4()}`; localLocks.set(lockKey, { - owner: this.getLocalOwnerToken(), + owner: token, expiresAt: Date.now() + ttlMs }); - return true; - } - - if (existing.owner === this.getLocalOwnerToken()) { - existing.expiresAt = Date.now() + ttlMs; - localLocks.set(lockKey, existing); - return true; + return token; } + // Do not treat a same-process holder as automatically + // reentrant -- see the note in the Redis branch below. if (attempt < maxRetries - 1) { const delay = retryDelayMs * Math.pow(2, attempt); await new Promise((resolve) => setTimeout(resolve, delay)); } } - return false; + return null; } - const lockValue = `${instanceId}:${Date.now()}`; const redisKey = `lock:${lockKey}`; for (let attempt = 0; attempt < maxRetries; attempt++) { try { + // Every acquisition attempt gets its own unique token, even + // within the same process. Two independent logical operations + // (e.g. two different API requests handled by the same server) + // racing for this key must never both believe they hold the + // lock -- if we treated "existing value starts with my + // instanceId" as reentrant success, a second unrelated caller + // on this process could barge in while the first is still + // mid-flight, and their writes under the lock would interleave + // unguarded. + const lockValue = `${instanceId}:${uuidv4()}`; + // Use SET with NX (only set if not exists) and PX (expire in milliseconds) // This is atomic and handles both setting and expiration const result = await redis.set( @@ -93,19 +97,7 @@ export class LockManager { if (result === "OK") { logger.debug(`Lock acquired: ${lockKey} by ${instanceId}`); - return true; - } - - // Check if the existing lock is from this worker (reentrant behavior) - const existingValue = await redis.get(redisKey); - if ( - existingValue && - existingValue.startsWith(`${instanceId}:`) - ) { - // Extend the lock TTL since it's the same worker - await redis.pexpire(redisKey, ttlMs); - logger.debug(`Lock extended: ${lockKey} by ${instanceId}`); - return true; + return lockValue; } // If this isn't our last attempt, wait before retrying with exponential backoff @@ -132,18 +124,23 @@ export class LockManager { logger.debug( `Failed to acquire lock ${lockKey} after ${maxRetries} attempts` ); - return false; + return null; } /** - * Release a lock using Lua script to ensure atomicity + * Release a lock previously acquired via acquireLock/acquireLockWithRetry, + * using a Lua script to ensure we only delete it if it still matches the + * exact token from that acquisition (not just "owned by this process") -- + * this ensures a caller whose TTL already expired can't delete a + * different, currently-active holder's lock. * @param lockKey - Unique identifier for the lock + * @param token - the exact token returned by the acquisition being released */ - async releaseLock(lockKey: string): Promise { + async releaseLock(lockKey: string, token: string): Promise { if (!redis || !redis.status || redis.status !== "ready") { this.clearExpiredLocalLock(lockKey); const existing = localLocks.get(lockKey); - if (existing && existing.owner === this.getLocalOwnerToken()) { + if (existing && existing.owner === token) { localLocks.delete(lockKey); } return; @@ -151,13 +148,12 @@ export class LockManager { const redisKey = `lock:${lockKey}`; - // Lua script to ensure we only delete the lock if it belongs to this worker const luaScript = ` local key = KEYS[1] - local worker_prefix = ARGV[1] + local expected_value = ARGV[1] local current_value = redis.call('GET', key) - if current_value and string.find(current_value, worker_prefix, 1, true) == 1 then + if current_value and current_value == expected_value then return redis.call('DEL', key) else return 0 @@ -169,16 +165,14 @@ export class LockManager { luaScript, 1, redisKey, - `${instanceId}:` + token )) as number; if (result === 1) { logger.debug(`Lock released: ${lockKey} by ${instanceId}`); } else { logger.warn( - `Lock not released - not owned by worker: ${lockKey} by ${ - instanceId - }` + `Lock not released - token did not match current holder: ${lockKey} (attempted by ${instanceId})` ); } } catch (error) { @@ -230,7 +224,7 @@ export class LockManager { const ttl = Math.max(0, existing.expiresAt - Date.now()); return { exists: true, - ownedByMe: existing.owner === this.getLocalOwnerToken(), + ownedByMe: existing.owner.startsWith(`${instanceId}:`), ttl, owner: existing.owner.split(":")[0] }; @@ -261,17 +255,23 @@ export class LockManager { } /** - * Extend the TTL of an existing lock owned by this worker + * Extend the TTL of an existing lock, provided the token matches the + * acquisition currently holding it. * @param lockKey - Unique identifier for the lock * @param ttlMs - New TTL in milliseconds + * @param token - the token returned by the acquisition being extended * @returns Promise - true if extended successfully */ - async extendLock(lockKey: string, ttlMs: number): Promise { + async extendLock( + lockKey: string, + ttlMs: number, + token: string + ): Promise { if (!redis || !redis.status || redis.status !== "ready") { this.clearExpiredLocalLock(lockKey); const existing = localLocks.get(lockKey); - if (!existing || existing.owner !== this.getLocalOwnerToken()) { + if (!existing || existing.owner !== token) { return false; } @@ -282,14 +282,13 @@ export class LockManager { const redisKey = `lock:${lockKey}`; - // Lua script to extend TTL only if lock is owned by this worker const luaScript = ` local key = KEYS[1] - local worker_prefix = ARGV[1] + local expected_value = ARGV[1] local ttl = tonumber(ARGV[2]) local current_value = redis.call('GET', key) - if current_value and string.find(current_value, worker_prefix, 1, true) == 1 then + if current_value and current_value == expected_value then return redis.call('PEXPIRE', key, ttl) else return 0 @@ -301,7 +300,7 @@ export class LockManager { luaScript, 1, redisKey, - `${instanceId}:`, + token, ttlMs.toString() )) as number; @@ -324,14 +323,14 @@ export class LockManager { * @param ttlMs - Time to live in milliseconds * @param maxRetries - Maximum number of retry attempts * @param baseDelayMs - Base delay between retries in milliseconds - * @returns Promise - true if lock acquired + * @returns Promise - token if acquired, null otherwise */ async acquireLockWithRetry( lockKey: string, ttlMs: number = 30000, maxRetries: number = 5, baseDelayMs: number = 100 - ): Promise { + ): Promise { for (let attempt = 0; attempt <= maxRetries; attempt++) { const acquired = await this.acquireLock( lockKey, @@ -341,7 +340,7 @@ export class LockManager { ); if (acquired) { - return true; + return acquired; } if (attempt < maxRetries) { @@ -355,7 +354,7 @@ export class LockManager { logger.warn( `Failed to acquire lock ${lockKey} after ${maxRetries + 1} attempts` ); - return false; + return null; } /** @@ -370,16 +369,16 @@ export class LockManager { fn: () => Promise, ttlMs: number = 30000 ): Promise { - const acquired = await this.acquireLock(lockKey, ttlMs); + const token = await this.acquireLock(lockKey, ttlMs); - if (!acquired) { + if (!token) { throw new Error(`Failed to acquire lock: ${lockKey}`); } try { return await fn(); } finally { - await this.releaseLock(lockKey); + await this.releaseLock(lockKey, token); } } @@ -402,7 +401,7 @@ export class LockManager { let locksOwnedByMe = 0; for (const value of localLocks.values()) { - if (value.owner === this.getLocalOwnerToken()) { + if (value.owner.startsWith(`${instanceId}:`)) { locksOwnedByMe++; } } diff --git a/server/private/lib/orgRebuildCounter.ts b/server/private/lib/orgRebuildCounter.ts new file mode 100644 index 000000000..58d954f66 --- /dev/null +++ b/server/private/lib/orgRebuildCounter.ts @@ -0,0 +1,105 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { redis } from "#private/lib/redis"; +import logger from "@server/logger"; + +export const ORG_REBUILD_CONCURRENCY_LIMIT = 5; + +// Safety-net TTL: slightly longer than the rebuild lock TTL (120 s). If a +// server process dies while holding a rebuild, this ensures the counter key +// eventually expires rather than staying inflated forever. +const ORG_REBUILD_COUNT_TTL_MS = 180000; +const KEY_PREFIX = "rebuild-org-count:"; + +// In-memory fallback used when Redis is unavailable. +const localFallback = new Map(); + +function isRedisReady(): boolean { + return !!(redis && redis.status === "ready"); +} + +export async function incrementOrgRebuildCount(orgId: string): Promise { + if (!isRedisReady()) { + localFallback.set(orgId, (localFallback.get(orgId) ?? 0) + 1); + return; + } + try { + const key = `${KEY_PREFIX}${orgId}`; + await redis!.incr(key); + // Always refresh the TTL so the key doesn't expire while rebuilds are + // still in progress. The TTL is purely a crash-recovery safety net. + await redis!.pexpire(key, ORG_REBUILD_COUNT_TTL_MS); + } catch (err) { + logger.warn( + `orgRebuildCounter: Redis increment failed for org ${orgId}, falling back to local:`, + err + ); + localFallback.set(orgId, (localFallback.get(orgId) ?? 0) + 1); + } +} + +export async function decrementOrgRebuildCount(orgId: string): Promise { + if (!isRedisReady()) { + const current = localFallback.get(orgId) ?? 0; + if (current <= 1) { + localFallback.delete(orgId); + } else { + localFallback.set(orgId, current - 1); + } + return; + } + try { + const key = `${KEY_PREFIX}${orgId}`; + const count = await redis!.decr(key); + if (count <= 0) { + await redis!.del(key); + } + } catch (err) { + logger.warn( + `orgRebuildCounter: Redis decrement failed for org ${orgId}, falling back to local:`, + err + ); + const current = localFallback.get(orgId) ?? 0; + if (current <= 1) { + localFallback.delete(orgId); + } else { + localFallback.set(orgId, current - 1); + } + } +} + +export async function getOrgActiveRebuildCount(orgId: string): Promise { + if (!isRedisReady()) { + return localFallback.get(orgId) ?? 0; + } + try { + const key = `${KEY_PREFIX}${orgId}`; + const val = await redis!.get(key); + return val ? parseInt(val, 10) : 0; + } catch (err) { + logger.warn( + `orgRebuildCounter: Redis get failed for org ${orgId}, falling back to local:`, + err + ); + return localFallback.get(orgId) ?? 0; + } +} + +export async function checkOrgRebuildRateLimit( + orgId: string +): Promise { + return ( + (await getOrgActiveRebuildCount(orgId)) >= ORG_REBUILD_CONCURRENCY_LIMIT + ); +} diff --git a/server/private/lib/rateLimit.ts b/server/private/lib/rateLimit.ts index a8cf3c01c..32aa6419c 100644 --- a/server/private/lib/rateLimit.ts +++ b/server/private/lib/rateLimit.ts @@ -12,7 +12,7 @@ */ import logger from "@server/logger"; -import redisManager from "#private/lib/redis"; +import { regionalRedisManager as redisManager } from "#private/lib/redis"; import { build } from "@server/build"; // Rate limiting configuration @@ -152,10 +152,9 @@ export class RateLimitService { ); // Set TTL using the client directly - this prevents the key from persisting forever - if (redisManager.getClient()) { - await redisManager - .getClient() - .expire(globalKey, RATE_LIMIT_WINDOW + 10); + const writeClient = redisManager.getClient(); + if (writeClient) { + await writeClient.expire(globalKey, RATE_LIMIT_WINDOW + 10); } // Update tracking @@ -204,10 +203,12 @@ export class RateLimitService { ); // Set TTL using the client directly - this prevents the key from persisting forever - if (redisManager.getClient()) { - await redisManager - .getClient() - .expire(messageTypeKey, RATE_LIMIT_WINDOW + 10); + const writeClient = redisManager.getClient(); + if (writeClient) { + await writeClient.expire( + messageTypeKey, + RATE_LIMIT_WINDOW + 10 + ); } // Update tracking @@ -487,16 +488,13 @@ export class RateLimitService { await redisManager.del(globalKey); // Get all message type keys for this client and delete them - const client = redisManager.getClient(); - if (client) { - const messageTypeKeys = await client.keys( - `ratelimit:${clientId}:*` + const messageTypeKeys = await redisManager.keys( + `ratelimit:${clientId}:*` + ); + if (messageTypeKeys.length > 0) { + await Promise.all( + messageTypeKeys.map((key) => redisManager.del(key)) ); - if (messageTypeKeys.length > 0) { - await Promise.all( - messageTypeKeys.map((key) => redisManager.del(key)) - ); - } } } } diff --git a/server/private/lib/redis.ts b/server/private/lib/redis.ts index 7c3d67836..bda2e6204 100644 --- a/server/private/lib/redis.ts +++ b/server/private/lib/redis.ts @@ -1000,6 +1000,45 @@ class RegionalRedisManager { } } + public getClient(): Redis | null { + return this.writeClient; + } + + public async hget(key: string, field: string): Promise { + if (!this.isRedisEnabled() || !this.readClient) return null; + try { + return await this.readClient.hget(key, field); + } catch (error) { + logger.error("Regional Redis HGET error:", error); + return null; + } + } + + public async hset( + key: string, + field: string, + value: string + ): Promise { + if (!this.isRedisEnabled() || !this.writeClient) return false; + try { + await this.writeClient.hset(key, field, value); + return true; + } catch (error) { + logger.error("Regional Redis HSET error:", error); + return false; + } + } + + public async hgetall(key: string): Promise> { + if (!this.isRedisEnabled() || !this.readClient) return {}; + try { + return await this.readClient.hgetall(key); + } catch (error) { + logger.error("Regional Redis HGETALL error:", error); + return {}; + } + } + public async disconnect(): Promise { try { if (this.writeClient) { diff --git a/server/private/routers/auditLogs/index.ts b/server/private/routers/auditLogs/index.ts index aacd37635..5fffa2b86 100644 --- a/server/private/routers/auditLogs/index.ts +++ b/server/private/routers/auditLogs/index.ts @@ -17,3 +17,4 @@ export * from "./queryAccessAuditLog"; export * from "./exportAccessAuditLog"; export * from "./queryConnectionAuditLog"; export * from "./exportConnectionAuditLog"; +export * from "./logAccessAuditAttempt"; diff --git a/server/private/routers/auditLogs/logAccessAuditAttempt.ts b/server/private/routers/auditLogs/logAccessAuditAttempt.ts new file mode 100644 index 000000000..5b0fb9c92 --- /dev/null +++ b/server/private/routers/auditLogs/logAccessAuditAttempt.ts @@ -0,0 +1,95 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { logAccessAudit } from "#private/lib/logAccessAudit"; + +export const logAccessAuditAttemptSchema = z.object({ + resourceId: z.number().int().positive(), + action: z.boolean(), + type: z.enum(["login", "ssh", "vnc", "rdp"]) +}); + +export const logAccessAuditAttemptParams = z.object({ + orgId: z.string() +}); + +export async function logAccessAuditAttempt( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = logAccessAuditAttemptSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error) + ) + ); + } + const parsedParams = logAccessAuditAttemptParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const { orgId } = parsedParams.data; + const { resourceId, action, type } = parsedBody.data; + + const username = req.user?.username; + const userId = req.user?.userId; + + await logAccessAudit({ + orgId: orgId, + resourceId: resourceId, + action: action, + ...(username && userId + ? { + user: { + username, + userId + } + } + : {}), + type: type, + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "Access audit attempt logged successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/auditLogs/queryAccessAuditLog.ts b/server/private/routers/auditLogs/queryAccessAuditLog.ts index 0feca4154..9e819db64 100644 --- a/server/private/routers/auditLogs/queryAccessAuditLog.ts +++ b/server/private/routers/auditLogs/queryAccessAuditLog.ts @@ -22,7 +22,7 @@ import { import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; -import { eq, gt, lt, and, count, desc, inArray, isNull } from "drizzle-orm"; +import { eq, gt, lt, and, count, desc, inArray, isNull, or } from "drizzle-orm"; import { OpenAPITags } from "@server/openApi"; import { z } from "zod"; import createHttpError from "http-errors"; @@ -120,7 +120,10 @@ function getWhere(data: Q) { lt(accessAuditLog.timestamp, data.timeEnd), eq(accessAuditLog.orgId, data.orgId), data.resourceId - ? eq(accessAuditLog.resourceId, data.resourceId) + ? or( + eq(accessAuditLog.resourceId, data.resourceId), + eq(accessAuditLog.siteResourceId, data.resourceId) + ) : undefined, data.actor ? eq(accessAuditLog.actor, data.actor) : undefined, data.actorType @@ -233,7 +236,6 @@ async function enrichWithResourceDetails( const details = siteResourceMap.get(log.siteResourceId); return { ...log, - resourceId: log.siteResourceId, resourceName: details?.name ?? null, resourceNiceId: details?.niceId ?? null }; diff --git a/server/private/routers/billing/changeTier.ts b/server/private/routers/billing/changeTier.ts index d82cbfeea..e532e88fd 100644 --- a/server/private/routers/billing/changeTier.ts +++ b/server/private/routers/billing/changeTier.ts @@ -25,7 +25,7 @@ import { getTier1FeaturePriceSet, getTier3FeaturePriceSet, getTier2FeaturePriceSet, - FeatureId, + LimitId, type FeaturePriceSet } from "@server/lib/billing"; import { getLineItems } from "@server/lib/billing/getLineItems"; @@ -214,7 +214,7 @@ export async function changeTier( } // Map to the corresponding feature in the new tier - const newPriceId = targetPriceSet[FeatureId.USERS]; + const newPriceId = targetPriceSet[LimitId.USERS]; if (newPriceId) { return { diff --git a/server/private/routers/billing/getOrgUsage.ts b/server/private/routers/billing/getOrgUsage.ts index 718559654..6b61b84de 100644 --- a/server/private/routers/billing/getOrgUsage.ts +++ b/server/private/routers/billing/getOrgUsage.ts @@ -24,7 +24,7 @@ import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { Limit, limits, Usage, usage } from "@server/db"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; import { GetOrgUsageResponse } from "@server/routers/billing/types"; const getOrgSchema = z.strictObject({ @@ -93,16 +93,16 @@ export async function getOrgUsage( // Get usage for org const usageData = []; - const sites = await usageService.getUsage(orgId, FeatureId.SITES); - const users = await usageService.getUsage(orgId, FeatureId.USERS); - const domains = await usageService.getUsage(orgId, FeatureId.DOMAINS); + const sites = await usageService.getUsage(orgId, LimitId.SITES); + const users = await usageService.getUsage(orgId, LimitId.USERS); + const domains = await usageService.getUsage(orgId, LimitId.DOMAINS); const remoteExitNodes = await usageService.getUsage( orgId, - FeatureId.REMOTE_EXIT_NODES + LimitId.REMOTE_EXIT_NODES ); const organizations = await usageService.getUsage( orgId, - FeatureId.ORGINIZATIONS + LimitId.ORGANIZATIONS ); // const egressData = await usageService.getUsage( // orgId, diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 3cecd2ebb..c6ee3de55 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -329,6 +329,44 @@ authenticated.delete( remoteExitNode.deleteRemoteExitNode ); +authenticated.get( + "/org/:orgId/remote-exit-node/:remoteExitNodeId/resources", + verifyValidLicense, + verifyOrgAccess, + verifyRemoteExitNodeAccess, + verifyUserHasAction(ActionsEnum.getRemoteExitNode), + remoteExitNode.listRemoteExitNodeResources +); + +authenticated.post( + "/org/:orgId/remote-exit-node/:remoteExitNodeId/resources", + verifyValidLicense, + verifyOrgAccess, + verifyRemoteExitNodeAccess, + verifyUserHasAction(ActionsEnum.updateRemoteExitNode), + logActionAudit(ActionsEnum.updateRemoteExitNode), + remoteExitNode.setRemoteExitNodeResources +); + +authenticated.get( + "/org/:orgId/remote-exit-node/:remoteExitNodeId/preference-labels", + verifyValidLicense, + verifyOrgAccess, + verifyRemoteExitNodeAccess, + verifyUserHasAction(ActionsEnum.getRemoteExitNode), + remoteExitNode.listRemoteExitNodePreferenceLabels +); + +authenticated.post( + "/org/:orgId/remote-exit-node/:remoteExitNodeId/preference-labels", + verifyValidLicense, + verifyOrgAccess, + verifyRemoteExitNodeAccess, + verifyUserHasAction(ActionsEnum.updateRemoteExitNode), + logActionAudit(ActionsEnum.updateRemoteExitNode), + remoteExitNode.setRemoteExitNodePreferenceLabels +); + authenticated.put( "/org/:orgId/login-page", verifyValidLicense, @@ -495,29 +533,31 @@ authRouter.post( auth.transferSession ); -authenticated.post( - "/license/activate", - verifyUserIsServerAdmin, - license.activateLicense -); +if (build !== "saas") { + authenticated.post( + "/license/activate", + verifyUserIsServerAdmin, + license.activateLicense + ); -authenticated.get( - "/license/keys", - verifyUserIsServerAdmin, - license.listLicenseKeys -); + authenticated.get( + "/license/keys", + verifyUserIsServerAdmin, + license.listLicenseKeys + ); -authenticated.delete( - "/license/:licenseKey", - verifyUserIsServerAdmin, - license.deleteLicenseKey -); + authenticated.delete( + "/license/:licenseKey", + verifyUserIsServerAdmin, + license.deleteLicenseKey + ); -authenticated.post( - "/license/recheck", - verifyUserIsServerAdmin, - license.recheckStatus -); + authenticated.post( + "/license/recheck", + verifyUserIsServerAdmin, + license.recheckStatus + ); +} authenticated.get( "/org/:orgId/logs/action", @@ -878,3 +918,9 @@ authenticated.post( verifyClientAccess, client.rebuildClientAssociationsCacheRoute ); + +authenticated.post( + "/org/:orgId/logs/access/attempt", + verifyOrgAccess, + logs.logAccessAuditAttempt +); diff --git a/server/private/routers/org/sendTrialNotification.ts b/server/private/routers/org/sendTrialNotification.ts index 233010064..d3210530a 100644 --- a/server/private/routers/org/sendTrialNotification.ts +++ b/server/private/routers/org/sendTrialNotification.ts @@ -215,7 +215,7 @@ export async function sendTrialNotification( if (resetLimits) { // this will only fire if they have not upgraded yet because when upgrading we delete the trial - await handleSubscriptionLifesycle(orgId, "cancled"); + await handleSubscriptionLifesycle(orgId, "canceled"); logger.debug( `Trial ended for org ${orgId}, limits reset to free tier` ); diff --git a/server/private/routers/orgIdp/unassociateOrgIdp.ts b/server/private/routers/orgIdp/unassociateOrgIdp.ts index 0b5c1ed51..d9c0bfc8f 100644 --- a/server/private/routers/orgIdp/unassociateOrgIdp.ts +++ b/server/private/routers/orgIdp/unassociateOrgIdp.ts @@ -23,6 +23,7 @@ import { and, eq, sql } from "drizzle-orm"; import { removeUserFromOrg } from "@server/lib/userOrg"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { OpenAPITags, registry } from "@server/openApi"; +import { isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations"; const paramsSchema = z .object({ @@ -90,6 +91,15 @@ export async function unassociateOrgIdp( ); } + if (await isOrgRebuildRateLimited(org.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + const orgUsersFromIdp = await db .select({ userId: userOrgs.userId, diff --git a/server/private/routers/remoteExitNode/createRemoteExitNode.ts b/server/private/routers/remoteExitNode/createRemoteExitNode.ts index d7e889222..bb16f228d 100644 --- a/server/private/routers/remoteExitNode/createRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/createRemoteExitNode.ts @@ -35,7 +35,7 @@ import logger from "@server/logger"; import { and, eq, inArray, ne } from "drizzle-orm"; import { getNextAvailableSubnet } from "@server/lib/exitNodes"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; import { CreateRemoteExitNodeResponse } from "@server/routers/remoteExitNode/types"; export const paramsSchema = z.object({ @@ -79,7 +79,10 @@ export async function createRemoteExitNode( const { remoteExitNodeId, secret } = parsedBody.data; - if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { + if ( + req.user && + (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0) + ) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); @@ -87,13 +90,13 @@ export async function createRemoteExitNode( const usage = await usageService.getUsage( orgId, - FeatureId.REMOTE_EXIT_NODES + LimitId.REMOTE_EXIT_NODES ); if (usage) { const rejectRemoteExitNodes = await usageService.checkLimitSet( orgId, - FeatureId.REMOTE_EXIT_NODES, + LimitId.REMOTE_EXIT_NODES, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 @@ -264,7 +267,7 @@ export async function createRemoteExitNode( if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) { await usageService.add( orgId, - FeatureId.REMOTE_EXIT_NODES, + LimitId.REMOTE_EXIT_NODES, 1, trx ); diff --git a/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts b/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts index e86476a5a..6fd871896 100644 --- a/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts @@ -22,7 +22,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; const paramsSchema = z.strictObject({ orgId: z.string().min(1), @@ -117,7 +117,7 @@ export async function deleteRemoteExitNode( if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) { await usageService.add( orgId, - FeatureId.REMOTE_EXIT_NODES, + LimitId.REMOTE_EXIT_NODES, -1, trx ); diff --git a/server/private/routers/remoteExitNode/index.ts b/server/private/routers/remoteExitNode/index.ts index 953ccba88..244ef17de 100644 --- a/server/private/routers/remoteExitNode/index.ts +++ b/server/private/routers/remoteExitNode/index.ts @@ -23,3 +23,7 @@ export * from "./pickRemoteExitNodeDefaults"; export * from "./quickStartRemoteExitNode"; export * from "./offlineChecker"; export * from "./exitNodeReconnectScheduler"; +export * from "./listRemoteExitNodeResources"; +export * from "./setRemoteExitNodeResources"; +export * from "./listRemoteExitNodePreferenceLabels"; +export * from "./setRemoteExitNodePreferenceLabels"; diff --git a/server/private/routers/remoteExitNode/listRemoteExitNodePreferenceLabels.ts b/server/private/routers/remoteExitNode/listRemoteExitNodePreferenceLabels.ts new file mode 100644 index 000000000..a03e02257 --- /dev/null +++ b/server/private/routers/remoteExitNode/listRemoteExitNodePreferenceLabels.ts @@ -0,0 +1,102 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { NextFunction, Request, Response } from "express"; +import { z } from "zod"; +import { + db, + labels, + remoteExitNodePreferenceLabels, + remoteExitNodes +} from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { ListRemoteExitNodePreferenceLabelsResponse } from "@server/routers/remoteExitNode"; + +const paramsSchema = z.strictObject({ + orgId: z.string().min(1), + remoteExitNodeId: z.string().min(1) +}); + +export async function listRemoteExitNodePreferenceLabels( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { remoteExitNodeId } = parsedParams.data; + + const [remoteExitNode] = await db + .select() + .from(remoteExitNodes) + .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)) + .limit(1); + + if (!remoteExitNode) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Remote exit node with ID ${remoteExitNodeId} not found` + ) + ); + } + + const rows = await db + .select({ + remoteExitNodePreferenceLabelId: + remoteExitNodePreferenceLabels.remoteExitNodePreferenceLabelId, + labelId: remoteExitNodePreferenceLabels.labelId, + name: labels.name, + color: labels.color + }) + .from(remoteExitNodePreferenceLabels) + .innerJoin( + labels, + eq(labels.labelId, remoteExitNodePreferenceLabels.labelId) + ) + .where( + eq( + remoteExitNodePreferenceLabels.remoteExitNodeId, + remoteExitNodeId + ) + ); + + return response(res, { + data: { labels: rows }, + success: true, + error: false, + message: + "Remote exit node preference labels retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/remoteExitNode/listRemoteExitNodeResources.ts b/server/private/routers/remoteExitNode/listRemoteExitNodeResources.ts new file mode 100644 index 000000000..a51b4bed6 --- /dev/null +++ b/server/private/routers/remoteExitNode/listRemoteExitNodeResources.ts @@ -0,0 +1,83 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { NextFunction, Request, Response } from "express"; +import { z } from "zod"; +import { db, remoteExitNodeResources, remoteExitNodes } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { ListRemoteExitNodeResourcesResponse } from "@server/routers/remoteExitNode/types"; + +const paramsSchema = z.strictObject({ + orgId: z.string().min(1), + remoteExitNodeId: z.string().min(1) +}); + +export async function listRemoteExitNodeResources( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { remoteExitNodeId } = parsedParams.data; + + const [remoteExitNode] = await db + .select() + .from(remoteExitNodes) + .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)) + .limit(1); + + if (!remoteExitNode) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Remote exit node with ID ${remoteExitNodeId} not found` + ) + ); + } + + const resources = await db + .select() + .from(remoteExitNodeResources) + .where( + eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId) + ); + + return response(res, { + data: { resources }, + success: true, + error: false, + message: "Remote exit node resources retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/remoteExitNode/setRemoteExitNodePreferenceLabels.ts b/server/private/routers/remoteExitNode/setRemoteExitNodePreferenceLabels.ts new file mode 100644 index 000000000..4c300d589 --- /dev/null +++ b/server/private/routers/remoteExitNode/setRemoteExitNodePreferenceLabels.ts @@ -0,0 +1,160 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { NextFunction, Request, Response } from "express"; +import { z } from "zod"; +import { + db, + labels, + remoteExitNodePreferenceLabels, + remoteExitNodes +} from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { SetRemoteExitNodePreferenceLabelsResponse } from "@server/routers/remoteExitNode"; + +const paramsSchema = z.strictObject({ + orgId: z.string().min(1), + remoteExitNodeId: z.string().min(1) +}); + +const bodySchema = z.strictObject({ + labelIds: z.array(z.number().int().positive()) +}); + +export type SetRemoteExitNodePreferenceLabelsBody = z.infer; + +export async function setRemoteExitNodePreferenceLabels( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, remoteExitNodeId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { labelIds } = parsedBody.data; + + const [remoteExitNode] = await db + .select() + .from(remoteExitNodes) + .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)) + .limit(1); + + if (!remoteExitNode) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Remote exit node with ID ${remoteExitNodeId} not found` + ) + ); + } + + // Validate all provided labelIds belong to this org + if (labelIds.length > 0) { + const existingLabels = await db + .select({ labelId: labels.labelId }) + .from(labels) + .where( + and( + eq(labels.orgId, orgId), + inArray(labels.labelId, labelIds) + ) + ); + + if (existingLabels.length !== labelIds.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One or more label IDs are invalid or do not belong to this organization" + ) + ); + } + } + + // Replace all preference labels atomically + await db + .delete(remoteExitNodePreferenceLabels) + .where( + eq( + remoteExitNodePreferenceLabels.remoteExitNodeId, + remoteExitNodeId + ) + ); + + if (labelIds.length > 0) { + await db.insert(remoteExitNodePreferenceLabels).values( + labelIds.map((labelId) => ({ + remoteExitNodeId, + labelId + })) + ); + } + + const rows = await db + .select({ + remoteExitNodePreferenceLabelId: + remoteExitNodePreferenceLabels.remoteExitNodePreferenceLabelId, + labelId: remoteExitNodePreferenceLabels.labelId, + name: labels.name, + color: labels.color + }) + .from(remoteExitNodePreferenceLabels) + .innerJoin( + labels, + eq(labels.labelId, remoteExitNodePreferenceLabels.labelId) + ) + .where( + eq( + remoteExitNodePreferenceLabels.remoteExitNodeId, + remoteExitNodeId + ) + ); + + return response(res, { + data: { labels: rows }, + success: true, + error: false, + message: "Remote exit node preference labels updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts b/server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts new file mode 100644 index 000000000..a5f9c1478 --- /dev/null +++ b/server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts @@ -0,0 +1,153 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { NextFunction, Request, Response } from "express"; +import { z } from "zod"; +import { + db, + newts, + remoteExitNodeResources, + remoteExitNodes, + sites +} from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { sendToClientsBatch } from "#private/routers/ws"; +import { canCompress } from "@server/lib/clientVersionChecks"; +import { SetRemoteExitNodeResourcesResponse } from "@server/routers/remoteExitNode"; + +const paramsSchema = z.strictObject({ + orgId: z.string().min(1), + remoteExitNodeId: z.string().min(1) +}); + +const cidrRegex = + /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))$|^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$/; + +const bodySchema = z.strictObject({ + destinations: z.array( + z.string().regex(cidrRegex, "Must be a valid CIDR range") + ) +}); + +export type SetRemoteExitNodeResourcesBody = z.infer; + +export async function setRemoteExitNodeResources( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { remoteExitNodeId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { destinations } = parsedBody.data; + + const [remoteExitNode] = await db + .select() + .from(remoteExitNodes) + .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)) + .limit(1); + + if (!remoteExitNode) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Remote exit node with ID ${remoteExitNodeId} not found` + ) + ); + } + + // Replace all resources atomically + await db + .delete(remoteExitNodeResources) + .where( + eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId) + ); + + if (destinations.length > 0) { + await db.insert(remoteExitNodeResources).values( + destinations.map((destination) => ({ + remoteExitNodeId, + destination + })) + ); + } + + const resources = await db + .select() + .from(remoteExitNodeResources) + .where( + eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId) + ); + + // Notify all newts connected to this remote exit node's exit node + if (remoteExitNode.exitNodeId) { + const connectedNewts = await db + .select({ newtId: newts.newtId, version: newts.version }) + .from(newts) + .innerJoin(sites, eq(newts.siteId, sites.siteId)) + .where(eq(sites.exitNodeId, remoteExitNode.exitNodeId)); + + await sendToClientsBatch( + connectedNewts.map(({ newtId, version }) => ({ + clientId: newtId, + message: { + type: "newt/wg/subnets/update", + data: { subnets: destinations } + }, + options: { + incrementConfigVersion: true, + compress: canCompress(version, "newt") + } + })) + ); + } + + return response(res, { + data: { resources }, + success: true, + error: false, + message: "Remote exit node resources updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/user/addUserRole.ts b/server/private/routers/user/addUserRole.ts index ce5a6dd50..2065984a3 100644 --- a/server/private/routers/user/addUserRole.ts +++ b/server/private/routers/user/addUserRole.ts @@ -23,7 +23,10 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromClient, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; const addUserRoleParamsSchema = z.strictObject({ userId: z.string(), @@ -128,6 +131,15 @@ export async function addUserRole( ); } + if (await isOrgRebuildRateLimited(role.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + let newUserRole: { userId: string; orgId: string; diff --git a/server/private/routers/user/setUserOrgRoles.ts b/server/private/routers/user/setUserOrgRoles.ts index ef6bc1b4f..b583233d1 100644 --- a/server/private/routers/user/setUserOrgRoles.ts +++ b/server/private/routers/user/setUserOrgRoles.ts @@ -21,7 +21,10 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromClient, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; const setUserOrgRolesParamsSchema = z.strictObject({ orgId: z.string(), @@ -87,6 +90,15 @@ export async function setUserOrgRoles( ); } + if (await isOrgRebuildRateLimited(orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + const orgRoles = await db .select({ roleId: roles.roleId, isAdmin: roles.isAdmin }) .from(roles) diff --git a/server/private/routers/ws/ws.ts b/server/private/routers/ws/ws.ts index 5e38c709e..f014b0e57 100644 --- a/server/private/routers/ws/ws.ts +++ b/server/private/routers/ws/ws.ts @@ -26,7 +26,7 @@ import { } from "@server/db"; import { eq } from "drizzle-orm"; import { db } from "@server/db"; -import { recordPing } from "@server/routers/newt/pingAccumulator"; +import { recordSitePing } from "@server/routers/newt/pingAccumulator"; import { validateNewtSessionToken } from "@server/auth/sessions/newt"; import { validateOlmSessionToken } from "@server/auth/sessions/olm"; import logger from "@server/logger"; @@ -1063,7 +1063,7 @@ const setupConnection = async ( // pending pings in a single batched UPDATE every ~10s, which // prevents connection pool exhaustion under load (especially // with cross-region latency to the database). - recordPing(newtClient.siteId); + recordSitePing(newtClient.siteId); }); } diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts index b8168ef1e..15ca1e87e 100644 --- a/server/routers/auditLogs/types.ts +++ b/server/routers/auditLogs/types.ts @@ -68,6 +68,7 @@ export type QueryAccessAuditLogResponse = { actorType: string | null; actorId: string | null; resourceId: number | null; + siteResourceId: number | null; resourceName: string | null; resourceNiceId: string | null; ip: string | null; diff --git a/server/routers/auth/deleteMyAccount.ts b/server/routers/auth/deleteMyAccount.ts index d45486946..0a1f74f3e 100644 --- a/server/routers/auth/deleteMyAccount.ts +++ b/server/routers/auth/deleteMyAccount.ts @@ -20,7 +20,7 @@ import { getOrgTierData } from "#dynamic/lib/billing"; import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg"; import { UserType } from "@server/types/UserTypes"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; const deleteMyAccountBody = z.strictObject({ password: z.string().optional(), @@ -220,7 +220,7 @@ export async function deleteMyAccount( await trx.delete(users).where(eq(users.userId, userId)); // loop through the other orgs and decrement the count for (const userOrg of otherOrgsTheUserWasIn) { - await usageService.add(userOrg.orgId, FeatureId.USERS, -1, trx); + await usageService.add(userOrg.orgId, LimitId.USERS, -1, trx); } }); diff --git a/server/routers/client/createClient.ts b/server/routers/client/createClient.ts index bef47245d..11c7c9ec2 100644 --- a/server/routers/client/createClient.ts +++ b/server/routers/client/createClient.ts @@ -24,9 +24,14 @@ import { isIpInCidr } from "@server/lib/ip"; import { listExitNodes } from "#dynamic/lib/exitNodes"; import { generateId } from "@server/auth/sessions/app"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromClient, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; import { getUniqueClientName } from "@server/db/names"; import { build } from "@server/build"; +import { LimitId } from "@server/lib/billing"; +import { usageService } from "@server/lib/billing/usageService"; const createClientParamsSchema = z.strictObject({ orgId: z.string() @@ -125,6 +130,38 @@ export async function createClient( ); } + if (build == "saas") { + const usage = await usageService.getUsage( + orgId, + LimitId.MACHINE_CLIENTS + ); + if (!usage) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No usage data found for this organization" + ) + ); + } + const rejectClient = await usageService.checkLimitSet( + orgId, + + LimitId.MACHINE_CLIENTS, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } // We need to add one to know if we are violating the limit + ); + if (rejectClient) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Machine client limit exceeded. Please upgrade your plan." + ) + ); + } + } + const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); if (!org) { @@ -154,6 +191,15 @@ export async function createClient( ); } + if (await isOrgRebuildRateLimited(orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org // make sure the subnet is unique @@ -277,6 +323,8 @@ export async function createClient( clientId: newClient.clientId, dateCreated: moment().toISOString() }); + + await usageService.add(orgId, LimitId.MACHINE_CLIENTS, 1, trx); }); if (newClient) { @@ -291,7 +339,7 @@ export async function createClient( data: newClient, success: true, error: false, - message: "Site created successfully", + message: "Client created successfully", status: HttpCode.CREATED }); } catch (error) { diff --git a/server/routers/client/createUserClient.ts b/server/routers/client/createUserClient.ts index 3c7d85018..70027e4a5 100644 --- a/server/routers/client/createUserClient.ts +++ b/server/routers/client/createUserClient.ts @@ -21,7 +21,10 @@ import { isValidIP } from "@server/lib/validators"; import { isIpInCidr } from "@server/lib/ip"; import { listExitNodes } from "#dynamic/lib/exitNodes"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromClient, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; import { getUniqueClientName } from "@server/db/names"; const paramsSchema = z @@ -146,6 +149,15 @@ export async function createUserClient( ); } + if (await isOrgRebuildRateLimited(orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org // make sure the subnet is unique diff --git a/server/routers/client/deleteClient.ts b/server/routers/client/deleteClient.ts index 62765c2c1..f09d6432e 100644 --- a/server/routers/client/deleteClient.ts +++ b/server/routers/client/deleteClient.ts @@ -9,9 +9,14 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromClient, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; import { sendTerminateClient } from "./terminate"; import { OlmErrorCodes } from "../olm/error"; +import { LimitId } from "@server/lib/billing/features"; +import { usageService } from "@server/lib/billing/usageService"; const deleteClientSchema = z.strictObject({ clientId: z.coerce.number().int().positive() @@ -76,6 +81,15 @@ export async function deleteClient( ); } + if (await isOrgRebuildRateLimited(client.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + // Only allow deletion of machine clients (clients without userId) if (client.userId) { return next( @@ -106,6 +120,13 @@ export async function deleteClient( if (!client.userId && client.olmId) { await trx.delete(olms).where(eq(olms.olmId, client.olmId)); } + + await usageService.add( + deletedClient.orgId, + LimitId.MACHINE_CLIENTS, + -1, + trx + ); }); if (deletedClient) { diff --git a/server/routers/client/rebuildClientAssociationsCacheRoute.ts b/server/routers/client/rebuildClientAssociationsCacheRoute.ts index 86cb5c485..ca149fa97 100644 --- a/server/routers/client/rebuildClientAssociationsCacheRoute.ts +++ b/server/routers/client/rebuildClientAssociationsCacheRoute.ts @@ -9,7 +9,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { rebuildClientAssociationsFromClient, isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations"; const paramsSchema = z.strictObject({ clientId: z.string().transform(Number).pipe(z.int().positive()) @@ -60,6 +60,15 @@ export async function rebuildClientAssociationsCacheRoute( ); } + if (await isOrgRebuildRateLimited(client.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + rebuildClientAssociationsFromClient(client).catch((e) => { logger.error( `Failed to rebuild client associations for client ${clientId}: ${e}` diff --git a/server/routers/domain/createOrgDomain.ts b/server/routers/domain/createOrgDomain.ts index ceb61b25f..8370b5465 100644 --- a/server/routers/domain/createOrgDomain.ts +++ b/server/routers/domain/createOrgDomain.ts @@ -17,7 +17,7 @@ import { subdomainSchema } from "@server/lib/schemas"; import { generateId } from "@server/auth/sessions/app"; import { eq, and } from "drizzle-orm"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; import { isSecondLevelDomain, isValidDomain } from "@server/lib/validators"; import { build } from "@server/build"; import config from "@server/lib/config"; @@ -120,7 +120,7 @@ export async function createOrgDomain( } if (build == "saas") { - const usage = await usageService.getUsage(orgId, FeatureId.DOMAINS); + const usage = await usageService.getUsage(orgId, LimitId.DOMAINS); if (!usage) { return next( createHttpError( @@ -132,7 +132,7 @@ export async function createOrgDomain( const rejectDomains = await usageService.checkLimitSet( orgId, - FeatureId.DOMAINS, + LimitId.DOMAINS, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 @@ -346,7 +346,7 @@ export async function createOrgDomain( await trx.insert(dnsRecords).values(recordsToInsert); } - await usageService.add(orgId, FeatureId.DOMAINS, 1, trx); + await usageService.add(orgId, LimitId.DOMAINS, 1, trx); }); if (!returned) { diff --git a/server/routers/domain/deleteOrgDomain.ts b/server/routers/domain/deleteOrgDomain.ts index 4c347668e..3f443eac5 100644 --- a/server/routers/domain/deleteOrgDomain.ts +++ b/server/routers/domain/deleteOrgDomain.ts @@ -8,7 +8,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { and, eq } from "drizzle-orm"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; const paramsSchema = z.strictObject({ domainId: z.string(), @@ -77,7 +77,7 @@ export async function deleteAccountDomain( await trx.delete(domains).where(eq(domains.domainId, domainId)); - await usageService.add(orgId, FeatureId.DOMAINS, -1, trx); + await usageService.add(orgId, LimitId.DOMAINS, -1, trx); }); return response(res, { diff --git a/server/routers/external.ts b/server/routers/external.ts index 960c00249..5d770c064 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -17,6 +17,7 @@ import * as idp from "./idp"; import * as blueprints from "./blueprints"; import * as apiKeys from "./apiKeys"; import * as logs from "./auditLogs"; +import * as launcher from "./launcher"; import * as newt from "./newt"; import * as olm from "./olm"; import * as serverInfo from "./serverInfo"; @@ -254,6 +255,14 @@ authenticated.delete( site.deleteSite ); +authenticated.post( + "/site/:siteId/restart", + verifySiteAccess, + verifyUserHasAction(ActionsEnum.restartSite), + logActionAudit(ActionsEnum.restartSite), + site.restartSite +); + // TODO: BREAK OUT THESE ACTIONS SO THEY ARE NOT ALL "getSite" authenticated.get( "/site/:siteId/docker/status", @@ -455,6 +464,54 @@ authenticated.get( resource.getUserResources ); +authenticated.get( + "/org/:orgId/launcher/groups", + verifyOrgAccess, + launcher.listLauncherGroups +); + +authenticated.get( + "/org/:orgId/launcher/resources", + verifyOrgAccess, + launcher.listLauncherResources +); + +authenticated.get( + "/org/:orgId/launcher/sites", + verifyOrgAccess, + launcher.listLauncherSites +); + +authenticated.get( + "/org/:orgId/launcher/labels", + verifyOrgAccess, + launcher.listLauncherLabels +); + +authenticated.get( + "/org/:orgId/launcher/views", + verifyOrgAccess, + launcher.listLauncherViews +); + +authenticated.post( + "/org/:orgId/launcher/views", + verifyOrgAccess, + launcher.createLauncherView +); + +authenticated.put( + "/org/:orgId/launcher/views/:viewId", + verifyOrgAccess, + launcher.updateLauncherView +); + +authenticated.delete( + "/org/:orgId/launcher/views/:viewId", + verifyOrgAccess, + launcher.deleteLauncherView +); + authenticated.get( "/org/:orgId/user-resource-aliases", verifyOrgAccess, @@ -910,19 +967,6 @@ unauthenticated.post( ); unauthenticated.get("/my-device", verifySessionMiddleware, user.myDevice); -authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers); -authenticated.get("/user/:userId", verifyUserIsServerAdmin, user.adminGetUser); -authenticated.post( - "/user/:userId/generate-password-reset-code", - verifyUserIsServerAdmin, - user.adminGeneratePasswordResetCode -); -authenticated.delete( - "/user/:userId", - verifyUserIsServerAdmin, - user.adminRemoveUser -); - authenticated.put( "/org/:orgId/user", verifyOrgAccess, @@ -945,12 +989,6 @@ authenticated.post( authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); authenticated.get("/org/:orgId/user/:userId/check", org.checkOrgUserAccess); -authenticated.post( - "/user/:userId/2fa", - verifyUserIsServerAdmin, - user.updateUser2FA -); - authenticated.get( "/org/:orgId/users", verifyOrgAccess, @@ -1033,85 +1071,112 @@ authenticated.post( olm.recoverOlmWithFingerprint ); -authenticated.put( - "/idp/oidc", - verifyUserIsServerAdmin, - // verifyUserHasAction(ActionsEnum.createIdp), - idp.createOidcIdp -); +if (build !== "saas") { + authenticated.put( + "/idp/oidc", + verifyUserIsServerAdmin, + // verifyUserHasAction(ActionsEnum.createIdp), + idp.createOidcIdp + ); -authenticated.post( - "/idp/:idpId/oidc", - verifyUserIsServerAdmin, - idp.updateOidcIdp -); + authenticated.post( + "/idp/:idpId/oidc", + verifyUserIsServerAdmin, + idp.updateOidcIdp + ); -authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp); + authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp); -authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); + authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); -authenticated.put( - "/idp/:idpId/org/:orgId", - verifyUserIsServerAdmin, - idp.createIdpOrgPolicy -); + authenticated.put( + "/idp/:idpId/org/:orgId", + verifyUserIsServerAdmin, + idp.createIdpOrgPolicy + ); -authenticated.post( - "/idp/:idpId/org/:orgId", - verifyUserIsServerAdmin, - idp.updateIdpOrgPolicy -); + authenticated.post( + "/idp/:idpId/org/:orgId", + verifyUserIsServerAdmin, + idp.updateIdpOrgPolicy + ); -authenticated.delete( - "/idp/:idpId/org/:orgId", - verifyUserIsServerAdmin, - idp.deleteIdpOrgPolicy -); + authenticated.delete( + "/idp/:idpId/org/:orgId", + verifyUserIsServerAdmin, + idp.deleteIdpOrgPolicy + ); -authenticated.get( - "/idp/:idpId/org", - verifyUserIsServerAdmin, - idp.listIdpOrgPolicies -); + authenticated.get( + "/idp/:idpId/org", + verifyUserIsServerAdmin, + idp.listIdpOrgPolicies + ); + + authenticated.get( + `/api-key/:apiKeyId`, + verifyUserIsServerAdmin, + apiKeys.getApiKey + ); + + authenticated.put( + `/api-key`, + verifyUserIsServerAdmin, + apiKeys.createRootApiKey + ); + + authenticated.delete( + `/api-key/:apiKeyId`, + verifyUserIsServerAdmin, + apiKeys.deleteApiKey + ); + + authenticated.get( + `/api-keys`, + verifyUserIsServerAdmin, + apiKeys.listRootApiKeys + ); + + authenticated.get( + `/api-key/:apiKeyId/actions`, + verifyUserIsServerAdmin, + apiKeys.listApiKeyActions + ); + + authenticated.post( + `/api-key/:apiKeyId/actions`, + verifyUserIsServerAdmin, + apiKeys.setApiKeyActions + ); + + authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers); + + authenticated.get( + "/user/:userId", + verifyUserIsServerAdmin, + user.adminGetUser + ); + + authenticated.post( + "/user/:userId/generate-password-reset-code", + verifyUserIsServerAdmin, + user.adminGeneratePasswordResetCode + ); + + authenticated.delete( + "/user/:userId", + verifyUserIsServerAdmin, + user.adminRemoveUser + ); + + authenticated.post( + "/user/:userId/2fa", + verifyUserIsServerAdmin, + user.updateUser2FA + ); +} authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids -authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); - -authenticated.get( - `/api-key/:apiKeyId`, - verifyUserIsServerAdmin, - apiKeys.getApiKey -); - -authenticated.put( - `/api-key`, - verifyUserIsServerAdmin, - apiKeys.createRootApiKey -); - -authenticated.delete( - `/api-key/:apiKeyId`, - verifyUserIsServerAdmin, - apiKeys.deleteApiKey -); - -authenticated.get( - `/api-keys`, - verifyUserIsServerAdmin, - apiKeys.listRootApiKeys -); - -authenticated.get( - `/api-key/:apiKeyId/actions`, - verifyUserIsServerAdmin, - apiKeys.listApiKeyActions -); - -authenticated.post( - `/api-key/:apiKeyId/actions`, - verifyUserIsServerAdmin, - apiKeys.setApiKeyActions -); authenticated.get( `/org/:orgId/api-keys`, diff --git a/server/routers/gerbil/receiveBandwidth.ts b/server/routers/gerbil/receiveBandwidth.ts index eacf3dad4..aacf7f843 100644 --- a/server/routers/gerbil/receiveBandwidth.ts +++ b/server/routers/gerbil/receiveBandwidth.ts @@ -6,7 +6,7 @@ import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing/features"; +import { LimitId } from "@server/lib/billing/features"; import { checkExitNodeOrg } from "#dynamic/lib/exitNodes"; import { build } from "@server/build"; @@ -171,8 +171,9 @@ export async function flushSiteBandwidthToDb(): Promise { } // PostgreSQL: batch UPDATE … FROM (VALUES …) - single round-trip per chunk. - const valuesList = chunk.map(([publicKey, { bytesIn, bytesOut }]) => - sql`(${publicKey}::text, ${bytesIn}::real, ${bytesOut}::real)` + const valuesList = chunk.map( + ([publicKey, { bytesIn, bytesOut }]) => + sql`(${publicKey}::text, ${bytesIn}::real, ${bytesOut}::real)` ); const valuesClause = sql.join(valuesList, sql`, `); return dbQueryRows<{ orgId: string; pubKey: string }>(sql` @@ -228,7 +229,7 @@ export async function flushSiteBandwidthToDb(): Promise { const totalBandwidth = orgUsageMap.get(orgId)!; const bandwidthUsage = await usageService.add( orgId, - FeatureId.EGRESS_DATA_MB, + LimitId.EGRESS_DATA_MB, totalBandwidth ); if (bandwidthUsage) { @@ -236,7 +237,7 @@ export async function flushSiteBandwidthToDb(): Promise { usageService .checkLimitSet( orgId, - FeatureId.EGRESS_DATA_MB, + LimitId.EGRESS_DATA_MB, bandwidthUsage ) .catch((error: any) => { @@ -247,10 +248,7 @@ export async function flushSiteBandwidthToDb(): Promise { }); } } catch (error) { - logger.error( - `Error processing usage for org ${orgId}:`, - error - ); + logger.error(`Error processing usage for org ${orgId}:`, error); // Continue with other orgs. } } diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index f6cb656b3..57b16964d 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -31,7 +31,7 @@ import { } from "@server/auth/sessions/app"; import { decrypt } from "@server/lib/crypto"; import { UserType } from "@server/types/UserTypes"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; import { usageService } from "@server/lib/billing/usageService"; import { build } from "@server/build"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; @@ -645,7 +645,7 @@ export async function validateOidcCallback( for (const orgCount of orgUserCounts) { await usageService.updateCount( orgCount.orgId, - FeatureId.USERS, + LimitId.USERS, orgCount.userCount ); } diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 93857e1db..d124263d5 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -159,6 +159,7 @@ authenticated.get( verifyApiKeyOrgAccess, resource.getUserResources ); + // Site Resource endpoints authenticated.put( "/org/:orgId/site-resource", diff --git a/server/routers/launcher/createLauncherView.ts b/server/routers/launcher/createLauncherView.ts new file mode 100644 index 000000000..593bad304 --- /dev/null +++ b/server/routers/launcher/createLauncherView.ts @@ -0,0 +1,101 @@ +import { db, launcherViews } from "@server/db"; +import { response } from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import moment from "moment"; +import { fromZodError } from "zod-validation-error"; +import { z } from "zod"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { launcherViewConfigSchema } from "./types"; + +const createLauncherViewBodySchema = z.strictObject({ + name: z.string().min(1).max(128), + config: launcherViewConfigSchema, + orgWide: z.boolean().optional().default(false) +}); + +export async function createLauncherView( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = req.userOrgId; + const userId = req.user!.userId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const parsed = createLauncherViewBodySchema.safeParse(req.body); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + if (parsed.data.orgWide) { + const canCreateOrgWide = await checkUserActionPermission( + ActionsEnum.createOrgWideLauncherView, + req + ); + if (!canCreateOrgWide) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission perform this action" + ) + ); + } + } + + const now = moment().toISOString(); + const [created] = await db + .insert(launcherViews) + .values({ + orgId, + userId: parsed.data.orgWide ? null : userId, + name: parsed.data.name, + config: JSON.stringify(parsed.data.config), + createdAt: now, + updatedAt: now + }) + .returning(); + + return response(res, { + data: { + viewId: created.viewId, + orgId: created.orgId, + userId: created.userId, + name: created.name, + config: launcherViewConfigSchema.parse( + JSON.parse(created.config) + ), + createdAt: created.createdAt, + updatedAt: created.updatedAt, + isOrgWide: created.userId == null + }, + success: true, + error: false, + message: "Launcher view created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error creating launcher view:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/deleteLauncherView.ts b/server/routers/launcher/deleteLauncherView.ts new file mode 100644 index 000000000..beaf1b433 --- /dev/null +++ b/server/routers/launcher/deleteLauncherView.ts @@ -0,0 +1,86 @@ +import { db, launcherViews } from "@server/db"; +import { response } from "@server/lib/response"; +import { getFirstString } from "@server/lib/requestParams"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; + +export async function deleteLauncherView( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = req.userOrgId; + const userId = req.user!.userId; + const viewId = Number.parseInt( + getFirstString(req.params.viewId) ?? "", + 10 + ); + + if (!orgId || !Number.isFinite(viewId)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid request parameters" + ) + ); + } + + const [existing] = await db + .select() + .from(launcherViews) + .where( + and( + eq(launcherViews.viewId, viewId), + eq(launcherViews.orgId, orgId) + ) + ) + .limit(1); + + if (!existing) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Launcher view not found") + ); + } + + const isPersonalView = existing.userId === userId; + const isOrgWideView = existing.userId == null; + const canManageOrgWide = await checkUserActionPermission( + ActionsEnum.createOrgWideLauncherView, + req + ); + + if (!isPersonalView && !(isOrgWideView && canManageOrgWide)) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have permission to delete this view" + ) + ); + } + + await db.delete(launcherViews).where(eq(launcherViews.viewId, viewId)); + + return response(res, { + data: null, + success: true, + error: false, + message: "Launcher view deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error deleting launcher view:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/formatLauncherAccess.ts b/server/routers/launcher/formatLauncherAccess.ts new file mode 100644 index 000000000..c12767ce1 --- /dev/null +++ b/server/routers/launcher/formatLauncherAccess.ts @@ -0,0 +1,172 @@ +import { formatEndpoint, parseEndpoint } from "@server/lib/ip"; + +export type SiteResourceDestinationInput = { + mode: "host" | "cidr" | "http" | "ssh"; + destination: string | null; + destinationPort: number | null; + scheme: "http" | "https" | null; +}; + +export function resolveHttpHttpsDisplayPort( + mode: "http", + destinationPort: number | null +): number { + if (destinationPort != null) { + return destinationPort; + } + return 80; +} + +export function formatSiteResourceDestinationDisplay( + row: SiteResourceDestinationInput +): string { + if (!row.destination) { + return ""; + } + const { mode, destination, destinationPort, scheme } = row; + if (mode !== "http") { + return destination; + } + const port = resolveHttpHttpsDisplayPort(mode, destinationPort); + const downstreamScheme = scheme ?? "http"; + const hostPart = + destination.includes(":") && !destination.startsWith("[") + ? `[${destination}]` + : destination; + return `${downstreamScheme}://${hostPart}:${port}`; +} + +export type PublicResourceAccessInput = { + mode: string; + fullDomain: string | null; + ssl: boolean; + proxyPort: number | null; + wildcard: boolean; + exitNodeEndpoint?: string | null; +}; + +export type SiteResourceAccessInput = { + mode: string; + destination: string | null; + destinationPort: number | null; + scheme: "http" | "https" | null; + ssl: boolean; + fullDomain: string | null; + alias: string | null; + aliasAddress: string | null; +}; + +export type LauncherAccessFields = { + accessDisplay: string; + accessCopyValue: string; + accessUrl: string | null; +}; + +function formatTcpUdpResourceAccess( + exitNodeEndpoint: string | null | undefined, + proxyPort: number | null +): LauncherAccessFields { + if (proxyPort == null) { + return { + accessDisplay: "", + accessCopyValue: "", + accessUrl: null + }; + } + + if (!exitNodeEndpoint?.trim()) { + const port = proxyPort.toString(); + return { + accessDisplay: port, + accessCopyValue: port, + accessUrl: null + }; + } + + const parsed = parseEndpoint(exitNodeEndpoint); + const host = parsed?.ip ?? exitNodeEndpoint.trim(); + const access = formatEndpoint(host, proxyPort); + + return { + accessDisplay: access, + accessCopyValue: access, + accessUrl: null + }; +} + +export function formatPublicResourceAccess( + resource: PublicResourceAccessInput +): LauncherAccessFields { + const browserModes = ["http", "ssh", "rdp", "vnc"]; + if (!browserModes.includes(resource.mode)) { + return formatTcpUdpResourceAccess( + resource.exitNodeEndpoint, + resource.proxyPort + ); + } + + if (!resource.fullDomain) { + return { + accessDisplay: "", + accessCopyValue: "", + accessUrl: null + }; + } + + const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; + return { + accessDisplay: url, + accessCopyValue: url, + accessUrl: resource.wildcard ? null : url + }; +} + +export function formatSiteResourceAccess( + resource: SiteResourceAccessInput +): LauncherAccessFields { + if (resource.alias) { + return { + accessDisplay: resource.alias, + accessCopyValue: resource.alias, + accessUrl: null + }; + } + + if (resource.mode === "http" && resource.fullDomain) { + const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; + return { + accessDisplay: url, + accessCopyValue: url, + accessUrl: url + }; + } + + const destination = formatSiteResourceDestinationDisplay({ + mode: resource.mode as SiteResourceDestinationInput["mode"], + destination: resource.destination, + destinationPort: resource.destinationPort, + scheme: resource.scheme + }); + + if (destination) { + return { + accessDisplay: destination, + accessCopyValue: destination, + accessUrl: resource.mode === "http" ? destination : null + }; + } + + if (resource.aliasAddress) { + return { + accessDisplay: resource.aliasAddress, + accessCopyValue: resource.aliasAddress, + accessUrl: null + }; + } + + return { + accessDisplay: "", + accessCopyValue: "", + accessUrl: null + }; +} diff --git a/server/routers/launcher/index.ts b/server/routers/launcher/index.ts new file mode 100644 index 000000000..939bf74bc --- /dev/null +++ b/server/routers/launcher/index.ts @@ -0,0 +1,9 @@ +export * from "./types"; +export { listLauncherGroups } from "./listLauncherGroups"; +export { listLauncherResources } from "./listLauncherResources"; +export { listLauncherSites } from "./listLauncherSites"; +export { listLauncherLabels } from "./listLauncherLabels"; +export { listLauncherViews } from "./listLauncherViews"; +export { createLauncherView } from "./createLauncherView"; +export { updateLauncherView } from "./updateLauncherView"; +export { deleteLauncherView } from "./deleteLauncherView"; diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts new file mode 100644 index 000000000..8fe5e8ef8 --- /dev/null +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -0,0 +1,1436 @@ +import { db } from "@server/db"; +import { + exitNodes, + labels, + launcherViews, + resourceLabels, + resources, + rolePolicies, + roleResources, + roles, + roleSiteResources, + siteNetworks, + siteResourceLabels, + siteResources, + sites, + targets, + userOrgRoles, + userPolicies, + userResources, + userSiteResources +} from "@server/db"; +import { regionalCache as cache } from "#dynamic/lib/cache"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { + and, + asc, + countDistinct, + eq, + inArray, + isNull, + like, + or, + sql +} from "drizzle-orm"; +import { + formatPublicResourceAccess, + formatSiteResourceAccess +} from "./formatLauncherAccess"; +import { + LAUNCHER_NO_SITE_GROUP_KEY, + LAUNCHER_UNLABELED_GROUP_KEY, + type LauncherFilterListQuery, + type LauncherGroup, + type LauncherLabel, + type LauncherListQuery, + type LauncherResource, + type LauncherSiteInfo, + parseIdListParam +} from "./types"; + +const effectiveResourcePolicyId = sql< + number | null +>`coalesce(${resources.resourcePolicyId}, ${resources.defaultResourcePolicyId})`; + +export type AccessibleIds = { + resourceIds: number[]; + siteResourceIds: number[]; +}; + +const LAUNCHER_ACCESSIBLE_IDS_TTL_SEC = 60; + +function launcherAccessibleIdsCacheKey( + orgId: string, + userId: string, + roleIds: number[] +) { + const rolesKey = [...roleIds].sort((a, b) => a - b).join(","); + return `launcherAccessibleIds:${orgId}:${userId}:${rolesKey}`; +} + +async function resolveAccessibleIdsUncached( + orgId: string, + userId: string, + userRoleIds: number[] +): Promise { + const [ + directResources, + roleResourceResults, + directPolicyResourceResults, + rolePolicyResourceResults, + directSiteResourceResults, + roleSiteResourceResults + ] = await Promise.all([ + db + .select({ resourceId: userResources.resourceId }) + .from(userResources) + .innerJoin( + resources, + eq(userResources.resourceId, resources.resourceId) + ) + .where( + and( + eq(userResources.userId, userId), + eq(resources.orgId, orgId) + ) + ), + userRoleIds.length > 0 + ? db + .select({ resourceId: roleResources.resourceId }) + .from(roleResources) + .innerJoin( + resources, + eq(roleResources.resourceId, resources.resourceId) + ) + .where( + and( + inArray(roleResources.roleId, userRoleIds), + eq(resources.orgId, orgId) + ) + ) + : Promise.resolve([]), + db + .select({ resourceId: resources.resourceId }) + .from(resources) + .innerJoin( + userPolicies, + eq(effectiveResourcePolicyId, userPolicies.resourcePolicyId) + ) + .where( + and(eq(userPolicies.userId, userId), eq(resources.orgId, orgId)) + ), + userRoleIds.length > 0 + ? db + .select({ resourceId: resources.resourceId }) + .from(resources) + .innerJoin( + rolePolicies, + eq( + effectiveResourcePolicyId, + rolePolicies.resourcePolicyId + ) + ) + .where( + and( + inArray(rolePolicies.roleId, userRoleIds), + eq(resources.orgId, orgId) + ) + ) + : Promise.resolve([]), + db + .select({ siteResourceId: userSiteResources.siteResourceId }) + .from(userSiteResources) + .where(eq(userSiteResources.userId, userId)), + userRoleIds.length > 0 + ? db + .select({ + siteResourceId: roleSiteResources.siteResourceId + }) + .from(roleSiteResources) + .where(inArray(roleSiteResources.roleId, userRoleIds)) + : Promise.resolve([]) + ]); + + return { + resourceIds: Array.from( + new Set([ + ...directResources.map((r) => r.resourceId), + ...roleResourceResults.map((r) => r.resourceId), + ...directPolicyResourceResults.map((r) => r.resourceId), + ...rolePolicyResourceResults.map((r) => r.resourceId) + ]) + ), + siteResourceIds: Array.from( + new Set([ + ...directSiteResourceResults.map((r) => r.siteResourceId), + ...roleSiteResourceResults.map((r) => r.siteResourceId) + ]) + ) + }; +} + +export async function resolveAccessibleIds( + orgId: string, + userId: string, + userRoleIds: number[] +): Promise { + const cacheKey = launcherAccessibleIdsCacheKey(orgId, userId, userRoleIds); + const cached = await cache.get(cacheKey); + if (cached) { + return cached; + } + + const result = await resolveAccessibleIdsUncached( + orgId, + userId, + userRoleIds + ); + await cache.set(cacheKey, result, LAUNCHER_ACCESSIBLE_IDS_TTL_SEC); + return result; +} + +function searchPattern(query: string) { + return `%${query.trim()}%`; +} + +function buildSearchConditionForPublic( + query: string, + labelsFeatureEnabled: boolean +) { + if (!query.trim()) { + return undefined; + } + const pattern = searchPattern(query.toLowerCase()); + const queryList = [ + like(sql`LOWER(${resources.name})`, pattern), + like(sql`LOWER(${resources.fullDomain})`, pattern), + like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern) + ]; + + if (labelsFeatureEnabled) { + queryList.push( + inArray( + resources.resourceId, + db + .select({ id: resourceLabels.resourceId }) + .from(resourceLabels) + .innerJoin( + labels, + eq(labels.labelId, resourceLabels.labelId) + ) + .where(like(sql`LOWER(${labels.name})`, pattern)) + ) + ); + } + + return or(...queryList); +} + +function buildSearchConditionForSiteResource( + query: string, + labelsFeatureEnabled: boolean +) { + if (!query.trim()) { + return undefined; + } + const pattern = searchPattern(query.toLowerCase()); + const queryList = [ + like(sql`LOWER(${siteResources.name})`, pattern), + like(sql`LOWER(${siteResources.destination})`, pattern), + like(sql`LOWER(${siteResources.alias})`, pattern), + like(sql`LOWER(${siteResources.fullDomain})`, pattern), + like(sql`LOWER(${siteResources.aliasAddress})`, pattern) + ]; + + if (labelsFeatureEnabled) { + queryList.push( + inArray( + siteResources.siteResourceId, + db + .select({ id: siteResourceLabels.siteResourceId }) + .from(siteResourceLabels) + .innerJoin( + labels, + eq(labels.labelId, siteResourceLabels.labelId) + ) + .where(like(sql`LOWER(${labels.name})`, pattern)) + ) + ); + } + + return or(...queryList); +} + +async function labelsEnabled(orgId: string): Promise { + return isLicensedOrSubscribed(orgId, tierMatrix.labels); +} + +async function fetchLabelsForResources( + orgId: string, + resourceIds: number[], + siteResourceIds: number[] +): Promise<{ + byResourceId: Map; + bySiteResourceId: Map; +}> { + const byResourceId = new Map(); + const bySiteResourceId = new Map(); + + if (!(await labelsEnabled(orgId))) { + return { byResourceId, bySiteResourceId }; + } + + const [resourceLabelRows, siteResourceLabelRows] = await Promise.all([ + resourceIds.length === 0 + ? Promise.resolve([]) + : db + .select({ + resourceId: resourceLabels.resourceId, + labelId: labels.labelId, + name: labels.name, + color: labels.color + }) + .from(resourceLabels) + .innerJoin(labels, eq(resourceLabels.labelId, labels.labelId)) + .where(inArray(resourceLabels.resourceId, resourceIds)) + .orderBy(asc(resourceLabels.resourceLabelId)), + siteResourceIds.length === 0 + ? Promise.resolve([]) + : db + .select({ + siteResourceId: siteResourceLabels.siteResourceId, + labelId: labels.labelId, + name: labels.name, + color: labels.color + }) + .from(siteResourceLabels) + .innerJoin( + labels, + eq(siteResourceLabels.labelId, labels.labelId) + ) + .where( + inArray( + siteResourceLabels.siteResourceId, + siteResourceIds + ) + ) + .orderBy(asc(siteResourceLabels.siteResourceLabelId)) + ]); + + for (const row of resourceLabelRows) { + const list = byResourceId.get(row.resourceId) ?? []; + list.push({ + labelId: row.labelId, + name: row.name, + color: row.color + }); + byResourceId.set(row.resourceId, list); + } + + for (const row of siteResourceLabelRows) { + const list = bySiteResourceId.get(row.siteResourceId) ?? []; + list.push({ + labelId: row.labelId, + name: row.name, + color: row.color + }); + bySiteResourceId.set(row.siteResourceId, list); + } + + return { byResourceId, bySiteResourceId }; +} + +type SiteGroupRow = { + siteId: number; + name: string; + type: string; + online: boolean; + itemCount: number; +}; + +async function listSiteGroups( + orgId: string, + accessible: AccessibleIds, + query: LauncherListQuery +): Promise<{ groups: LauncherGroup[]; total: number }> { + const siteFilterIds = parseIdListParam(query.siteIds); + const labelFilterIds = parseIdListParam(query.labelIds); + const labelsFeatureEnabled = await labelsEnabled(orgId); + const searchPublic = buildSearchConditionForPublic( + query.query, + labelsFeatureEnabled + ); + const searchSite = buildSearchConditionForSiteResource( + query.query, + labelsFeatureEnabled + ); + const siteCountMap = new Map(); + + if (accessible.resourceIds.length > 0) { + const publicConditions = [ + inArray(resources.resourceId, accessible.resourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true) + ]; + if (searchPublic) { + publicConditions.push(searchPublic); + } + if (siteFilterIds.length > 0) { + publicConditions.push(inArray(targets.siteId, siteFilterIds)); + } + + let publicQuery = db + .select({ + siteId: sites.siteId, + name: sites.name, + type: sites.type, + online: sites.online, + itemCount: countDistinct(resources.resourceId) + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .innerJoin(sites, eq(targets.siteId, sites.siteId)); + + if (labelFilterIds.length > 0) { + publicQuery = publicQuery.innerJoin( + resourceLabels, + eq(resourceLabels.resourceId, resources.resourceId) + ); + publicConditions.push( + inArray(resourceLabels.labelId, labelFilterIds) + ); + } + + const publicRows = await publicQuery + .where(and(...publicConditions)) + .groupBy(sites.siteId, sites.name, sites.type, sites.online); + + for (const row of publicRows) { + const existing = siteCountMap.get(row.siteId); + if (existing) { + existing.itemCount += Number(row.itemCount); + } else { + siteCountMap.set(row.siteId, { + siteId: row.siteId, + name: row.name, + type: row.type, + online: row.online, + itemCount: Number(row.itemCount) + }); + } + } + } + + if (accessible.siteResourceIds.length > 0) { + const siteConditions = [ + inArray(siteResources.siteResourceId, accessible.siteResourceIds), + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true) + ]; + if (searchSite) { + siteConditions.push(searchSite); + } + if (siteFilterIds.length > 0) { + siteConditions.push(inArray(sites.siteId, siteFilterIds)); + } + + let siteResourceQuery = db + .select({ + siteId: sites.siteId, + name: sites.name, + type: sites.type, + online: sites.online, + itemCount: countDistinct(siteResources.siteResourceId) + }) + .from(siteResources) + .innerJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)); + + if (labelFilterIds.length > 0) { + siteResourceQuery = siteResourceQuery.innerJoin( + siteResourceLabels, + eq( + siteResourceLabels.siteResourceId, + siteResources.siteResourceId + ) + ); + siteConditions.push( + inArray(siteResourceLabels.labelId, labelFilterIds) + ); + } + + const siteRows = await siteResourceQuery + .where(and(...siteConditions)) + .groupBy(sites.siteId, sites.name, sites.type, sites.online); + + for (const row of siteRows) { + const existing = siteCountMap.get(row.siteId); + if (existing) { + existing.itemCount += Number(row.itemCount); + } else { + siteCountMap.set(row.siteId, { + siteId: row.siteId, + name: row.name, + type: row.type, + online: row.online, + itemCount: Number(row.itemCount) + }); + } + } + } + + let noSiteCount = 0; + + if (accessible.resourceIds.length > 0 && siteFilterIds.length === 0) { + const noSitePublicConditions = [ + inArray(resources.resourceId, accessible.resourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true) + ]; + if (searchPublic) { + noSitePublicConditions.push(searchPublic); + } + + let noSitePublicQuery = db + .select({ + itemCount: countDistinct(resources.resourceId) + }) + .from(resources) + .leftJoin(targets, eq(targets.resourceId, resources.resourceId)); + + if (labelFilterIds.length > 0) { + noSitePublicQuery = noSitePublicQuery.innerJoin( + resourceLabels, + eq(resourceLabels.resourceId, resources.resourceId) + ); + noSitePublicConditions.push( + inArray(resourceLabels.labelId, labelFilterIds) + ); + } + + const [noSitePublicRow] = await noSitePublicQuery.where( + and(...noSitePublicConditions, isNull(targets.targetId)) + ); + + noSiteCount += Number(noSitePublicRow?.itemCount ?? 0); + } + + if (accessible.siteResourceIds.length > 0 && siteFilterIds.length === 0) { + const noSiteSiteConditions = [ + inArray(siteResources.siteResourceId, accessible.siteResourceIds), + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true) + ]; + if (searchSite) { + noSiteSiteConditions.push(searchSite); + } + + let noSiteSiteQuery = db + .select({ + itemCount: countDistinct(siteResources.siteResourceId) + }) + .from(siteResources) + .leftJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .leftJoin(sites, eq(siteNetworks.siteId, sites.siteId)); + + if (labelFilterIds.length > 0) { + noSiteSiteQuery = noSiteSiteQuery.innerJoin( + siteResourceLabels, + eq( + siteResourceLabels.siteResourceId, + siteResources.siteResourceId + ) + ); + noSiteSiteConditions.push( + inArray(siteResourceLabels.labelId, labelFilterIds) + ); + } + + const [noSiteSiteRow] = await noSiteSiteQuery.where( + and(...noSiteSiteConditions, isNull(sites.siteId)) + ); + + noSiteCount += Number(noSiteSiteRow?.itemCount ?? 0); + } + + let groups: LauncherGroup[] = Array.from(siteCountMap.values()).map( + (row) => ({ + groupKey: String(row.siteId), + name: row.name, + groupType: "site" as const, + itemCount: row.itemCount, + siteType: row.type, + siteOnline: row.online + }) + ); + + if (noSiteCount > 0 && siteFilterIds.length === 0) { + groups.push({ + groupKey: LAUNCHER_NO_SITE_GROUP_KEY, + name: "No Site", + groupType: "site", + itemCount: noSiteCount + }); + } + + groups.sort((a, b) => { + const cmp = a.name.localeCompare(b.name, undefined, { + sensitivity: "base" + }); + return query.order === "desc" ? -cmp : cmp; + }); + + const total = groups.length; + const offset = (query.page - 1) * query.pageSize; + return { + groups: groups.slice(offset, offset + query.pageSize), + total + }; +} + +async function listLabelGroups( + orgId: string, + accessible: AccessibleIds, + query: LauncherListQuery +): Promise<{ groups: LauncherGroup[]; total: number }> { + const siteFilterIds = parseIdListParam(query.siteIds); + const labelFilterIds = parseIdListParam(query.labelIds); + const labelCountMap = new Map< + number, + { labelId: number; name: string; color: string; itemCount: number } + >(); + let unlabeledCount = 0; + + if (!(await labelsEnabled(orgId))) { + return { groups: [], total: 0 }; + } + + const matchesLabelFilters = (labelId: number) => + labelFilterIds.length === 0 || labelFilterIds.includes(labelId); + + if (accessible.resourceIds.length > 0) { + const publicConditions = [ + inArray(resources.resourceId, accessible.resourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true) + ]; + const searchPublic = buildSearchConditionForPublic(query.query, true); + if (searchPublic) { + publicConditions.push(searchPublic); + } + if (siteFilterIds.length > 0) { + publicConditions.push(inArray(targets.siteId, siteFilterIds)); + } + + const labeledPublic = await db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color, + itemCount: countDistinct(resources.resourceId) + }) + .from(resourceLabels) + .innerJoin(labels, eq(resourceLabels.labelId, labels.labelId)) + .innerJoin( + resources, + eq(resourceLabels.resourceId, resources.resourceId) + ) + .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) + .where(and(...publicConditions, eq(labels.orgId, orgId))) + .groupBy(labels.labelId, labels.name, labels.color); + + for (const row of labeledPublic) { + if (!matchesLabelFilters(row.labelId)) { + continue; + } + const existing = labelCountMap.get(row.labelId); + if (existing) { + existing.itemCount += Number(row.itemCount); + } else { + labelCountMap.set(row.labelId, { + labelId: row.labelId, + name: row.name, + color: row.color, + itemCount: Number(row.itemCount) + }); + } + } + + const labeledPublicIds = await db + .select({ resourceId: resourceLabels.resourceId }) + .from(resourceLabels) + .innerJoin( + resources, + eq(resourceLabels.resourceId, resources.resourceId) + ) + .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) + .where(and(...publicConditions)); + + const labeledSet = new Set(labeledPublicIds.map((r) => r.resourceId)); + unlabeledCount += accessible.resourceIds.filter( + (id) => !labeledSet.has(id) + ).length; + } + + if (accessible.siteResourceIds.length > 0) { + const siteConditions = [ + inArray(siteResources.siteResourceId, accessible.siteResourceIds), + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true) + ]; + const searchSite = buildSearchConditionForSiteResource( + query.query, + true + ); + if (searchSite) { + siteConditions.push(searchSite); + } + if (siteFilterIds.length > 0) { + siteConditions.push(inArray(sites.siteId, siteFilterIds)); + } + + const labeledSite = await db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color, + itemCount: countDistinct(siteResources.siteResourceId) + }) + .from(siteResourceLabels) + .innerJoin(labels, eq(siteResourceLabels.labelId, labels.labelId)) + .innerJoin( + siteResources, + eq( + siteResourceLabels.siteResourceId, + siteResources.siteResourceId + ) + ) + .leftJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .leftJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .where(and(...siteConditions, eq(labels.orgId, orgId))) + .groupBy(labels.labelId, labels.name, labels.color); + + for (const row of labeledSite) { + if (!matchesLabelFilters(row.labelId)) { + continue; + } + const existing = labelCountMap.get(row.labelId); + if (existing) { + existing.itemCount += Number(row.itemCount); + } else { + labelCountMap.set(row.labelId, { + labelId: row.labelId, + name: row.name, + color: row.color, + itemCount: Number(row.itemCount) + }); + } + } + + const labeledSiteIds = await db + .select({ siteResourceId: siteResourceLabels.siteResourceId }) + .from(siteResourceLabels) + .innerJoin( + siteResources, + eq( + siteResourceLabels.siteResourceId, + siteResources.siteResourceId + ) + ) + .leftJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .leftJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .where(and(...siteConditions)); + + const labeledSet = new Set(labeledSiteIds.map((r) => r.siteResourceId)); + unlabeledCount += accessible.siteResourceIds.filter( + (id) => !labeledSet.has(id) + ).length; + } + + let groups: LauncherGroup[] = Array.from(labelCountMap.values()).map( + (row) => ({ + groupKey: String(row.labelId), + name: row.name, + groupType: "label" as const, + itemCount: row.itemCount, + labelColor: row.color + }) + ); + + if (unlabeledCount > 0 && labelFilterIds.length === 0) { + groups.push({ + groupKey: LAUNCHER_UNLABELED_GROUP_KEY, + name: "Unlabeled", + groupType: "label", + itemCount: unlabeledCount, + labelColor: "#a1a1aa" + }); + } + + groups.sort((a, b) => { + const cmp = a.name.localeCompare(b.name, undefined, { + sensitivity: "base" + }); + return query.order === "desc" ? -cmp : cmp; + }); + + const total = groups.length; + const offset = (query.page - 1) * query.pageSize; + return { + groups: groups.slice(offset, offset + query.pageSize), + total + }; +} + +export async function listLauncherGroupsForUser( + orgId: string, + userId: string, + userRoleIds: number[], + query: LauncherListQuery +): Promise<{ groups: LauncherGroup[]; total: number }> { + const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); + + if (query.groupBy === "label") { + return listLabelGroups(orgId, accessible, query); + } + + return listSiteGroups(orgId, accessible, query); +} + +async function mapPublicResources( + orgId: string, + resourceIds: number[], + labelMaps: Awaited>, + siteIdFilter?: number +): Promise { + if (resourceIds.length === 0) { + return []; + } + + const rows = await db + .select({ + resourceId: resources.resourceId, + niceId: resources.niceId, + name: resources.name, + mode: resources.mode, + fullDomain: resources.fullDomain, + ssl: resources.ssl, + proxyPort: resources.proxyPort, + wildcard: resources.wildcard, + enabled: resources.enabled, + siteId: sites.siteId, + siteName: sites.name, + siteType: sites.type, + siteOnline: sites.online, + exitNodeEndpoint: exitNodes.endpoint + }) + .from(resources) + .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) + .leftJoin(sites, eq(targets.siteId, sites.siteId)) + .leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId)) + .where( + and( + inArray(resources.resourceId, resourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true), + siteIdFilter != null + ? eq(sites.siteId, siteIdFilter) + : undefined + ) + ); + + const seen = new Set(); + const result: LauncherResource[] = []; + + for (const row of rows) { + const key = `public:${row.resourceId}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + + const access = formatPublicResourceAccess({ + mode: row.mode, + fullDomain: row.fullDomain, + ssl: row.ssl, + proxyPort: row.proxyPort, + wildcard: row.wildcard, + exitNodeEndpoint: row.exitNodeEndpoint + }); + + result.push({ + launcherResourceKey: key, + resourceType: "public", + resourceId: row.resourceId, + niceId: row.niceId, + name: row.name, + ...access, + iconUrl: null, + enabled: row.enabled, + mode: row.mode, + labels: labelMaps.byResourceId.get(row.resourceId) ?? [], + site: + row.siteId != null + ? { + siteId: row.siteId, + name: row.siteName!, + type: row.siteType!, + online: row.siteOnline ?? undefined + } + : undefined + }); + } + + return result; +} + +async function mapSiteResources( + orgId: string, + siteResourceIds: number[], + labelMaps: Awaited>, + siteIdFilter?: number +): Promise { + if (siteResourceIds.length === 0) { + return []; + } + + const rows = await db + .select({ + siteResourceId: siteResources.siteResourceId, + niceId: siteResources.niceId, + name: siteResources.name, + mode: siteResources.mode, + destination: siteResources.destination, + destinationPort: siteResources.destinationPort, + scheme: siteResources.scheme, + ssl: siteResources.ssl, + fullDomain: siteResources.fullDomain, + alias: siteResources.alias, + aliasAddress: siteResources.aliasAddress, + enabled: siteResources.enabled, + siteId: sites.siteId, + siteName: sites.name, + siteType: sites.type, + siteOnline: sites.online + }) + .from(siteResources) + .leftJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .leftJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .where( + and( + inArray(siteResources.siteResourceId, siteResourceIds), + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true), + siteIdFilter != null + ? eq(sites.siteId, siteIdFilter) + : undefined + ) + ); + + const seen = new Set(); + const result: LauncherResource[] = []; + + for (const row of rows) { + const key = `site:${row.siteResourceId}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + + const access = formatSiteResourceAccess({ + mode: row.mode, + destination: row.destination, + destinationPort: row.destinationPort, + scheme: row.scheme, + ssl: row.ssl, + fullDomain: row.fullDomain, + alias: row.alias, + aliasAddress: row.aliasAddress + }); + + result.push({ + launcherResourceKey: key, + resourceType: "site", + resourceId: row.siteResourceId, + siteResourceId: row.siteResourceId, + niceId: row.niceId, + name: row.name, + ...access, + iconUrl: null, + enabled: row.enabled, + mode: row.mode, + labels: labelMaps.bySiteResourceId.get(row.siteResourceId) ?? [], + site: + row.siteId != null + ? { + siteId: row.siteId, + name: row.siteName!, + type: row.siteType!, + online: row.siteOnline ?? undefined + } + : undefined + }); + } + + return result; +} + +function filterResourcesBySite( + items: LauncherResource[], + groupKey: string +): LauncherResource[] { + if (groupKey === LAUNCHER_NO_SITE_GROUP_KEY) { + return items.filter((item) => !item.site); + } + const siteId = Number.parseInt(groupKey, 10); + if (!Number.isFinite(siteId)) { + return items; + } + return items.filter((item) => item.site?.siteId === siteId); +} + +function filterResourcesByLabel( + items: LauncherResource[], + groupKey: string +): LauncherResource[] { + if (groupKey === LAUNCHER_UNLABELED_GROUP_KEY) { + return items.filter((item) => item.labels.length === 0); + } + const labelId = Number.parseInt(groupKey, 10); + return items.filter((item) => + item.labels.some((label) => label.labelId === labelId) + ); +} + +function filterResourcesBySearch( + items: LauncherResource[], + query: string +): LauncherResource[] { + if (!query.trim()) { + return items; + } + const pattern = query.trim().toLowerCase(); + return items.filter( + (item) => + item.name.toLowerCase().includes(pattern) || + item.accessDisplay.toLowerCase().includes(pattern) || + item.accessCopyValue.toLowerCase().includes(pattern) || + item.labels.some((label) => + label.name.toLowerCase().includes(pattern) + ) || + item.site?.name.toLowerCase().includes(pattern) + ); +} + +function sortLauncherResources( + items: LauncherResource[], + order: "asc" | "desc" +): LauncherResource[] { + return [...items].sort((a, b) => { + const cmp = a.name.localeCompare(b.name, undefined, { + sensitivity: "base" + }); + return order === "desc" ? -cmp : cmp; + }); +} + +export async function listLauncherResourcesForUser( + orgId: string, + userId: string, + userRoleIds: number[], + query: LauncherListQuery & { groupKey: string } +): Promise<{ resources: LauncherResource[]; total: number }> { + const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); + + const siteFilterIds = parseIdListParam(query.siteIds); + const labelFilterIds = parseIdListParam(query.labelIds); + + let filteredResourceIds = accessible.resourceIds; + let filteredSiteResourceIds = accessible.siteResourceIds; + + if (siteFilterIds.length > 0 && accessible.resourceIds.length > 0) { + const publicOnSites = await db + .select({ resourceId: resources.resourceId }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .where( + and( + inArray(resources.resourceId, accessible.resourceIds), + inArray(targets.siteId, siteFilterIds) + ) + ); + filteredResourceIds = publicOnSites.map((r) => r.resourceId); + } + + if (siteFilterIds.length > 0 && accessible.siteResourceIds.length > 0) { + const privateOnSites = await db + .select({ siteResourceId: siteResources.siteResourceId }) + .from(siteResources) + .innerJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .where( + and( + inArray( + siteResources.siteResourceId, + accessible.siteResourceIds + ), + inArray(siteNetworks.siteId, siteFilterIds) + ) + ); + filteredSiteResourceIds = privateOnSites.map((r) => r.siteResourceId); + } + + if (labelFilterIds.length > 0) { + if (filteredResourceIds.length > 0) { + const withLabels = await db + .select({ resourceId: resourceLabels.resourceId }) + .from(resourceLabels) + .where( + and( + inArray(resourceLabels.resourceId, filteredResourceIds), + inArray(resourceLabels.labelId, labelFilterIds) + ) + ); + filteredResourceIds = withLabels.map((r) => r.resourceId); + } + if (filteredSiteResourceIds.length > 0) { + const withLabels = await db + .select({ siteResourceId: siteResourceLabels.siteResourceId }) + .from(siteResourceLabels) + .where( + and( + inArray( + siteResourceLabels.siteResourceId, + filteredSiteResourceIds + ), + inArray(siteResourceLabels.labelId, labelFilterIds) + ) + ); + filteredSiteResourceIds = withLabels.map((r) => r.siteResourceId); + } + } + + const labelMaps = await fetchLabelsForResources( + orgId, + filteredResourceIds, + filteredSiteResourceIds + ); + + const parsedSiteId = + query.groupBy === "site" && + query.groupKey !== LAUNCHER_NO_SITE_GROUP_KEY + ? Number.parseInt(query.groupKey, 10) + : Number.NaN; + const siteIdFilter = Number.isFinite(parsedSiteId) + ? parsedSiteId + : undefined; + + const [publicItems, siteItems] = await Promise.all([ + mapPublicResources( + orgId, + filteredResourceIds, + labelMaps, + Number.isFinite(siteIdFilter) ? siteIdFilter : undefined + ), + mapSiteResources( + orgId, + filteredSiteResourceIds, + labelMaps, + Number.isFinite(siteIdFilter) ? siteIdFilter : undefined + ) + ]); + + let items = [...publicItems, ...siteItems]; + items = filterResourcesBySearch(items, query.query); + + if (query.groupBy === "label") { + items = filterResourcesByLabel(items, query.groupKey); + } else if (query.groupBy === "site") { + items = filterResourcesBySite(items, query.groupKey); + } + + items = sortLauncherResources(items, query.order); + + const total = items.length; + const offset = (query.page - 1) * query.pageSize; + return { + resources: items.slice(offset, offset + query.pageSize), + total + }; +} + +function buildSiteNameSearchCondition(query: string) { + if (!query.trim()) { + return undefined; + } + const pattern = searchPattern(query.toLowerCase()); + return or( + like(sql`LOWER(${sites.name})`, pattern), + like(sql`LOWER(${sites.niceId})`, pattern) + ); +} + +function buildLabelNameSearchCondition(query: string) { + if (!query.trim()) { + return undefined; + } + const pattern = searchPattern(query.toLowerCase()); + return like(sql`LOWER(${labels.name})`, pattern); +} + +async function collectAccessibleSites( + orgId: string, + accessible: AccessibleIds, + siteNameSearch?: ReturnType +): Promise> { + const siteCountMap = new Map(); + + if (accessible.resourceIds.length > 0) { + const publicConditions = [ + inArray(resources.resourceId, accessible.resourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true) + ]; + if (siteNameSearch) { + publicConditions.push(siteNameSearch); + } + + const publicRows = await db + .select({ + siteId: sites.siteId, + name: sites.name, + type: sites.type, + online: sites.online, + itemCount: countDistinct(resources.resourceId) + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .innerJoin(sites, eq(targets.siteId, sites.siteId)) + .where(and(...publicConditions)) + .groupBy(sites.siteId, sites.name, sites.type, sites.online); + + for (const row of publicRows) { + const existing = siteCountMap.get(row.siteId); + if (existing) { + existing.itemCount += Number(row.itemCount); + } else { + siteCountMap.set(row.siteId, { + siteId: row.siteId, + name: row.name, + type: row.type, + online: row.online, + itemCount: Number(row.itemCount) + }); + } + } + } + + if (accessible.siteResourceIds.length > 0) { + const siteConditions = [ + inArray(siteResources.siteResourceId, accessible.siteResourceIds), + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true) + ]; + if (siteNameSearch) { + siteConditions.push(siteNameSearch); + } + + const siteRows = await db + .select({ + siteId: sites.siteId, + name: sites.name, + type: sites.type, + online: sites.online, + itemCount: countDistinct(siteResources.siteResourceId) + }) + .from(siteResources) + .innerJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .where(and(...siteConditions)) + .groupBy(sites.siteId, sites.name, sites.type, sites.online); + + for (const row of siteRows) { + const existing = siteCountMap.get(row.siteId); + if (existing) { + existing.itemCount += Number(row.itemCount); + } else { + siteCountMap.set(row.siteId, { + siteId: row.siteId, + name: row.name, + type: row.type, + online: row.online, + itemCount: Number(row.itemCount) + }); + } + } + } + + return siteCountMap; +} + +async function collectAccessibleLabels( + orgId: string, + accessible: AccessibleIds, + labelNameSearch?: ReturnType +): Promise> { + const labelMap = new Map(); + + if (!(await labelsEnabled(orgId))) { + return labelMap; + } + + if (accessible.resourceIds.length > 0) { + const publicConditions = [ + inArray(resources.resourceId, accessible.resourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true), + eq(labels.orgId, orgId) + ]; + if (labelNameSearch) { + publicConditions.push(labelNameSearch); + } + + const labeledPublic = await db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color + }) + .from(resourceLabels) + .innerJoin(labels, eq(resourceLabels.labelId, labels.labelId)) + .innerJoin( + resources, + eq(resourceLabels.resourceId, resources.resourceId) + ) + .where(and(...publicConditions)) + .groupBy(labels.labelId, labels.name, labels.color); + + for (const row of labeledPublic) { + labelMap.set(row.labelId, { + labelId: row.labelId, + name: row.name, + color: row.color + }); + } + } + + if (accessible.siteResourceIds.length > 0) { + const siteConditions = [ + inArray(siteResources.siteResourceId, accessible.siteResourceIds), + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true), + eq(labels.orgId, orgId) + ]; + if (labelNameSearch) { + siteConditions.push(labelNameSearch); + } + + const labeledSite = await db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color + }) + .from(siteResourceLabels) + .innerJoin(labels, eq(siteResourceLabels.labelId, labels.labelId)) + .innerJoin( + siteResources, + eq( + siteResourceLabels.siteResourceId, + siteResources.siteResourceId + ) + ) + .where(and(...siteConditions)) + .groupBy(labels.labelId, labels.name, labels.color); + + for (const row of labeledSite) { + labelMap.set(row.labelId, { + labelId: row.labelId, + name: row.name, + color: row.color + }); + } + } + + return labelMap; +} + +export async function listAccessibleLauncherSitesForUser( + orgId: string, + userId: string, + userRoleIds: number[], + query: LauncherFilterListQuery +): Promise<{ sites: LauncherSiteInfo[]; total: number }> { + const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); + const siteNameSearch = buildSiteNameSearchCondition(query.query); + const siteCountMap = await collectAccessibleSites( + orgId, + accessible, + siteNameSearch + ); + + const sites: LauncherSiteInfo[] = Array.from(siteCountMap.values()) + .map((row) => ({ + siteId: row.siteId, + name: row.name, + type: row.type, + online: row.online + })) + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) + ); + + const total = sites.length; + const offset = (query.page - 1) * query.pageSize; + return { + sites: sites.slice(offset, offset + query.pageSize), + total + }; +} + +export async function listAccessibleLauncherLabelsForUser( + orgId: string, + userId: string, + userRoleIds: number[], + query: LauncherFilterListQuery +): Promise<{ labels: LauncherLabel[]; total: number }> { + const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); + const labelNameSearch = buildLabelNameSearchCondition(query.query); + const labelMap = await collectAccessibleLabels( + orgId, + accessible, + labelNameSearch + ); + + const labelsList = Array.from(labelMap.values()).sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) + ); + + const total = labelsList.length; + const offset = (query.page - 1) * query.pageSize; + return { + labels: labelsList.slice(offset, offset + query.pageSize), + total + }; +} diff --git a/server/routers/launcher/listLauncherGroups.ts b/server/routers/launcher/listLauncherGroups.ts new file mode 100644 index 000000000..a57e5864a --- /dev/null +++ b/server/routers/launcher/listLauncherGroups.ts @@ -0,0 +1,67 @@ +import { response } from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { fromZodError } from "zod-validation-error"; +import { listLauncherGroupsForUser } from "./launcherResourceAccess"; +import { launcherListQuerySchema } from "./types"; + +export async function listLauncherGroups( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = req.userOrgId; + const userId = req.user!.userId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const parsed = launcherListQuerySchema.safeParse(req.query); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const { groups, total } = await listLauncherGroupsForUser( + orgId, + userId, + req.userOrgRoleIds ?? [], + parsed.data + ); + + return response(res, { + data: { + groups, + pagination: { + total, + page: parsed.data.page, + pageSize: parsed.data.pageSize + } + }, + success: true, + error: false, + message: "Launcher groups retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error listing launcher groups:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/listLauncherLabels.ts b/server/routers/launcher/listLauncherLabels.ts new file mode 100644 index 000000000..4bb25c965 --- /dev/null +++ b/server/routers/launcher/listLauncherLabels.ts @@ -0,0 +1,67 @@ +import { response } from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { fromZodError } from "zod-validation-error"; +import { listAccessibleLauncherLabelsForUser } from "./launcherResourceAccess"; +import { launcherFilterListQuerySchema } from "./types"; + +export async function listLauncherLabels( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = req.userOrgId; + const userId = req.user!.userId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const parsed = launcherFilterListQuerySchema.safeParse(req.query); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const { labels, total } = await listAccessibleLauncherLabelsForUser( + orgId, + userId, + req.userOrgRoleIds ?? [], + parsed.data + ); + + return response(res, { + data: { + labels, + pagination: { + total, + page: parsed.data.page, + pageSize: parsed.data.pageSize + } + }, + success: true, + error: false, + message: "Launcher labels retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error listing launcher labels:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/listLauncherResources.ts b/server/routers/launcher/listLauncherResources.ts new file mode 100644 index 000000000..94f18b864 --- /dev/null +++ b/server/routers/launcher/listLauncherResources.ts @@ -0,0 +1,72 @@ +import { response } from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { fromZodError } from "zod-validation-error"; +import { z } from "zod"; +import { listLauncherResourcesForUser } from "./launcherResourceAccess"; +import { launcherListQuerySchema } from "./types"; + +const listLauncherResourcesQuerySchema = launcherListQuerySchema.extend({ + groupKey: z.string().min(1) +}); + +export async function listLauncherResources( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = req.userOrgId; + const userId = req.user!.userId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const parsed = listLauncherResourcesQuerySchema.safeParse(req.query); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const { resources, total } = await listLauncherResourcesForUser( + orgId, + userId, + req.userOrgRoleIds ?? [], + parsed.data + ); + + return response(res, { + data: { + resources, + pagination: { + total, + page: parsed.data.page, + pageSize: parsed.data.pageSize + } + }, + success: true, + error: false, + message: "Launcher resources retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error listing launcher resources:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/listLauncherSites.ts b/server/routers/launcher/listLauncherSites.ts new file mode 100644 index 000000000..8755f6d5a --- /dev/null +++ b/server/routers/launcher/listLauncherSites.ts @@ -0,0 +1,67 @@ +import { response } from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { fromZodError } from "zod-validation-error"; +import { listAccessibleLauncherSitesForUser } from "./launcherResourceAccess"; +import { launcherFilterListQuerySchema } from "./types"; + +export async function listLauncherSites( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = req.userOrgId; + const userId = req.user!.userId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const parsed = launcherFilterListQuerySchema.safeParse(req.query); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const { sites, total } = await listAccessibleLauncherSitesForUser( + orgId, + userId, + req.userOrgRoleIds ?? [], + parsed.data + ); + + return response(res, { + data: { + sites, + pagination: { + total, + page: parsed.data.page, + pageSize: parsed.data.pageSize + } + }, + success: true, + error: false, + message: "Launcher sites retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error listing launcher sites:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/listLauncherViews.ts b/server/routers/launcher/listLauncherViews.ts new file mode 100644 index 000000000..e176bae46 --- /dev/null +++ b/server/routers/launcher/listLauncherViews.ts @@ -0,0 +1,73 @@ +import { db, launcherViews } from "@server/db"; +import { response } from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq, isNull, or } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { launcherViewConfigSchema, type LauncherViewRecord } from "./types"; + +function mapViewRow( + row: typeof launcherViews.$inferSelect +): LauncherViewRecord { + return { + viewId: row.viewId, + orgId: row.orgId, + userId: row.userId, + name: row.name, + config: launcherViewConfigSchema.parse(JSON.parse(row.config)), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + isOrgWide: row.userId == null + }; +} + +export async function listLauncherViews( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = req.userOrgId; + const userId = req.user!.userId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const rows = await db + .select() + .from(launcherViews) + .where( + and( + eq(launcherViews.orgId, orgId), + or( + eq(launcherViews.userId, userId), + isNull(launcherViews.userId) + ) + ) + ); + + return response(res, { + data: { + views: rows.map(mapViewRow) + }, + success: true, + error: false, + message: "Launcher views retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error listing launcher views:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts new file mode 100644 index 000000000..0a252d74d --- /dev/null +++ b/server/routers/launcher/types.ts @@ -0,0 +1,165 @@ +import { z } from "zod"; + +export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled"; +export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site"; + +export const launcherViewConfigSchema = z.object({ + groupBy: z.enum(["site", "label"]).default("site"), + layout: z.enum(["grid", "list"]).default("grid"), + sortBy: z.literal("name").default("name"), + order: z.enum(["asc", "desc"]).default("asc"), + showLabels: z.boolean().default(true), + showSiteTags: z.boolean().default(true), + showRecents: z.boolean().default(false).optional(), + siteIds: z.array(z.number()).default([]), + labelIds: z.array(z.number()).default([]), + query: z.string().default("") +}); + +export type LauncherViewConfig = z.infer; + +export const defaultLauncherViewConfig: LauncherViewConfig = + launcherViewConfigSchema.parse({}); + +export type LauncherLabel = { + labelId: number; + name: string; + color: string; +}; + +export type LauncherSiteInfo = { + siteId: number; + name: string; + type: string; + online?: boolean; +}; + +export type LauncherResource = { + launcherResourceKey: string; + resourceType: "public" | "site"; + resourceId: number; + siteResourceId?: number; + niceId: string; + name: string; + accessDisplay: string; + accessCopyValue: string; + accessUrl: string | null; + iconUrl: string | null; + enabled: boolean; + mode: string; + labels: LauncherLabel[]; + site?: LauncherSiteInfo; +}; + +export type LauncherGroup = { + groupKey: string; + name: string; + groupType: "site" | "label"; + itemCount: number; + siteType?: string; + siteOnline?: boolean; + labelColor?: string; +}; + +export type ListLauncherGroupsResponse = { + groups: LauncherGroup[]; + pagination: { + total: number; + page: number; + pageSize: number; + }; +}; + +export type ListLauncherResourcesResponse = { + resources: LauncherResource[]; + pagination: { + total: number; + page: number; + pageSize: number; + }; +}; + +export type LauncherViewRecord = { + viewId: number; + orgId: string; + userId: string | null; + name: string; + config: LauncherViewConfig; + createdAt: string; + updatedAt: string; + isOrgWide: boolean; +}; + +export type ListLauncherViewsResponse = { + views: LauncherViewRecord[]; +}; + +export const launcherFilterListQuerySchema = z.strictObject({ + pageSize: z.coerce + .number() + .int() + .positive() + .optional() + .catch(500) + .default(500), + page: z.coerce.number().int().min(1).optional().catch(1).default(1), + query: z.string().optional().default("") +}); + +export type LauncherFilterListQuery = z.infer< + typeof launcherFilterListQuerySchema +>; + +export type ListLauncherSitesResponse = { + sites: LauncherSiteInfo[]; + pagination: { + total: number; + page: number; + pageSize: number; + }; +}; + +export type ListLauncherLabelsResponse = { + labels: LauncherLabel[]; + pagination: { + total: number; + page: number; + pageSize: number; + }; +}; + +export const launcherListQuerySchema = z.strictObject({ + pageSize: z.coerce + .number() + .int() + .positive() + .optional() + .catch(20) + .default(20), + page: z.coerce.number().int().min(1).optional().catch(1).default(1), + query: z.string().optional().default(""), + groupBy: z.enum(["site", "label"]).optional().default("site"), + groupKey: z.string().optional(), + siteIds: z.string().optional(), + labelIds: z.string().optional(), + sort_by: z.literal("name").optional().default("name"), + order: z.enum(["asc", "desc"]).optional().default("asc") +}); + +export type LauncherListQuery = z.infer; + +export function parseIdListParam(value: string | undefined): number[] { + if (!value?.trim()) { + return []; + } + return value + .split(",") + .map((part) => Number.parseInt(part.trim(), 10)) + .filter((id) => Number.isFinite(id)); +} + +export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const; + +export type LauncherViewSelection = + | { type: "default" } + | { type: "saved"; viewId: number }; diff --git a/server/routers/launcher/updateLauncherView.ts b/server/routers/launcher/updateLauncherView.ts new file mode 100644 index 000000000..9450da153 --- /dev/null +++ b/server/routers/launcher/updateLauncherView.ts @@ -0,0 +1,157 @@ +import { db, launcherViews } from "@server/db"; +import { response } from "@server/lib/response"; +import { getFirstString } from "@server/lib/requestParams"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import moment from "moment"; +import { fromZodError } from "zod-validation-error"; +import { z } from "zod"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { launcherViewConfigSchema } from "./types"; + +const updateLauncherViewBodySchema = z.strictObject({ + name: z.string().min(1).max(128).optional(), + config: launcherViewConfigSchema.optional(), + orgWide: z.boolean().optional() +}); + +export async function updateLauncherView( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = req.userOrgId; + const userId = req.user!.userId; + const viewId = Number.parseInt( + getFirstString(req.params.viewId) ?? "", + 10 + ); + + if (!orgId || !Number.isFinite(viewId)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid request parameters" + ) + ); + } + + const parsed = updateLauncherViewBodySchema.safeParse(req.body); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const [existing] = await db + .select() + .from(launcherViews) + .where( + and( + eq(launcherViews.viewId, viewId), + eq(launcherViews.orgId, orgId) + ) + ) + .limit(1); + + if (!existing) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Launcher view not found") + ); + } + + const isPersonalView = existing.userId === userId; + const isOrgWideView = existing.userId == null; + const canManageOrgWide = await checkUserActionPermission( + ActionsEnum.createOrgWideLauncherView, + req + ); + + if (!isPersonalView && !(isOrgWideView && canManageOrgWide)) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have permission to update this view" + ) + ); + } + + if (parsed.data.orgWide === true && !canManageOrgWide) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission perform this action" + ) + ); + } + + if ( + parsed.data.orgWide === false && + isOrgWideView && + !canManageOrgWide + ) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission perform this action" + ) + ); + } + + const nextUserId = + parsed.data.orgWide === true + ? null + : parsed.data.orgWide === false + ? userId + : existing.userId; + + const [updated] = await db + .update(launcherViews) + .set({ + name: parsed.data.name ?? existing.name, + config: parsed.data.config + ? JSON.stringify(parsed.data.config) + : existing.config, + userId: nextUserId, + updatedAt: moment().toISOString() + }) + .where(eq(launcherViews.viewId, viewId)) + .returning(); + + return response(res, { + data: { + viewId: updated.viewId, + orgId: updated.orgId, + userId: updated.userId, + name: updated.name, + config: launcherViewConfigSchema.parse( + JSON.parse(updated.config) + ), + createdAt: updated.createdAt, + updatedAt: updated.updatedAt, + isOrgWide: updated.userId == null + }, + success: true, + error: false, + message: "Launcher view updated successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error updating launcher view:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index 154ab38cb..5083a6c56 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -5,6 +5,7 @@ import { db, ExitNode, networks, + remoteExitNodeResources, resources, Site, siteNetworks, @@ -223,7 +224,8 @@ export async function buildClientConfigurationForNewtClient( export async function buildTargetConfigurationForNewtClient( siteId: number, - version?: string | null + version?: string | null, + remoteExitNodeId?: string ) { // Get all enabled targets with their resource mode information const allTargets = await db @@ -379,10 +381,24 @@ export async function buildTargetConfigurationForNewtClient( }; }); + let remoteExitNodeSubnets: string[] = []; + if (remoteExitNodeId) { + const remoteNodeResources = await db + .select() + .from(remoteExitNodeResources) + .where( + eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId) + ); + + // filter through these and provide the subnets + remoteExitNodeSubnets = remoteNodeResources.map((r) => r.destination); + } + return { validHealthCheckTargets, tcpTargets, udpTargets, - browserGatewayTargets + browserGatewayTargets, + remoteExitNodeSubnets }; } diff --git a/server/routers/newt/handleNewtPingMessage.ts b/server/routers/newt/handleNewtPingMessage.ts index c56c5f6d4..86489bd67 100644 --- a/server/routers/newt/handleNewtPingMessage.ts +++ b/server/routers/newt/handleNewtPingMessage.ts @@ -5,7 +5,7 @@ import { Newt } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; import { sendNewtSyncMessage } from "./sync"; -import { recordPing } from "./pingAccumulator"; +import { recordSitePing } from "./pingAccumulator"; /** * Handles ping messages from newt clients. @@ -35,7 +35,7 @@ export const handleNewtPingMessage: MessageHandler = async (context) => { // batched UPDATE instead of one query per ping. This prevents // connection pool exhaustion under load, especially with // cross-region latency to the database. - recordPing(newt.siteId); + recordSitePing(newt.siteId); // Check config version and sync if stale. const configVersion = await getClientConfigVersion(newt.newtId); @@ -49,22 +49,20 @@ export const handleNewtPingMessage: MessageHandler = async (context) => { `Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})` ); - // TODO: IMPLEMENT THE SYNC ON THE NEWT SIDE AND COMMENT THIS BACK IN + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, newt.siteId)) + .limit(1); - // const [site] = await db - // .select() - // .from(sites) - // .where(eq(sites.siteId, newt.siteId)) - // .limit(1); + if (!site) { + logger.warn( + `Newt ping message: site with ID ${newt.siteId} not found` + ); + return; + } - // if (!site) { - // logger.warn( - // `Newt ping message: site with ID ${newt.siteId} not found` - // ); - // return; - // } - - // await sendNewtSyncMessage(newt, site); + await sendNewtSyncMessage(newt, site); } return { diff --git a/server/routers/newt/handleNewtPingRequestMessage.ts b/server/routers/newt/handleNewtPingRequestMessage.ts index 8f6df4bec..f239dc4de 100644 --- a/server/routers/newt/handleNewtPingRequestMessage.ts +++ b/server/routers/newt/handleNewtPingRequestMessage.ts @@ -38,7 +38,8 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => { const exitNodesList = await listExitNodes( site.orgId, true, - noCloud || false + noCloud || false, + newt.siteId ); // filter for only the online ones let lastExitNodeId = null; diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index bd4aaacb3..0dc8380c8 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -1,4 +1,4 @@ -import { db, ExitNode, newts, Transaction } from "@server/db"; +import { db, ExitNode, newts, remoteExitNodes, Transaction } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { exitNodes, Newt, sites } from "@server/db"; import { eq } from "drizzle-orm"; @@ -196,12 +196,29 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { .where(eq(newts.newtId, newt.newtId)); } + let remoteExitNodeId: string | undefined; + if (exitNode.type == "remoteExitNode") { + // get the remote exit node ID associated with this exit node + const [remoteExitNode] = await db + .select() + .from(remoteExitNodes) + .where(eq(remoteExitNodes.exitNodeId, exitNode.exitNodeId)) + .limit(1); + + remoteExitNodeId = remoteExitNode?.remoteExitNodeId; + } + const { tcpTargets, udpTargets, validHealthCheckTargets, - browserGatewayTargets - } = await buildTargetConfigurationForNewtClient(siteId, newtVersion); + browserGatewayTargets, + remoteExitNodeSubnets + } = await buildTargetConfigurationForNewtClient( + siteId, + newtVersion, + remoteExitNodeId // this is for the remote node resources + ); logger.debug( `Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}` @@ -222,6 +239,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { }, healthCheckTargets: validHealthCheckTargets, browserGatewayTargets: browserGatewayTargets, + remoteExitNodeSubnets: remoteExitNodeSubnets, chainId: chainId } }, diff --git a/server/routers/newt/pingAccumulator.ts b/server/routers/newt/pingAccumulator.ts index 5351c6723..be9cd4972 100644 --- a/server/routers/newt/pingAccumulator.ts +++ b/server/routers/newt/pingAccumulator.ts @@ -57,9 +57,6 @@ export function recordSitePing(siteId: number): void { pendingSitePings.set(siteId, now); } -/** @deprecated Use `recordSitePing` instead. Alias kept for existing call-sites. */ -export const recordPing = recordSitePing; - /** * Record a ping for an OLM client. Batches the `clients` table update * (`online`, `lastPing`, `archived`) and, when `olmArchived` is true, diff --git a/server/routers/newt/registerNewt.ts b/server/routers/newt/registerNewt.ts index 3adcfb467..1fbfab191 100644 --- a/server/routers/newt/registerNewt.ts +++ b/server/routers/newt/registerNewt.ts @@ -25,7 +25,7 @@ import { getUniqueSiteName } from "@server/db/names"; import moment from "moment"; import { build } from "@server/build"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; import { INSPECT_MAX_BYTES } from "buffer"; import { getNextAvailableClientSubnet } from "@server/lib/ip"; @@ -169,7 +169,7 @@ export async function registerNewt( // SaaS billing check if (build == "saas") { - const usage = await usageService.getUsage(orgId, FeatureId.SITES); + const usage = await usageService.getUsage(orgId, LimitId.SITES); if (!usage) { return next( createHttpError( @@ -180,7 +180,7 @@ export async function registerNewt( } const rejectSites = await usageService.checkLimitSet( orgId, - FeatureId.SITES, + LimitId.SITES, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 @@ -274,7 +274,7 @@ export async function registerNewt( ) ); - await usageService.add(orgId, FeatureId.SITES, 1, trx); + await usageService.add(orgId, LimitId.SITES, 1, trx); }); } finally { await releaseSubnetLock(); diff --git a/server/routers/newt/sync.ts b/server/routers/newt/sync.ts index b8f152bec..6c3ab0a0f 100644 --- a/server/routers/newt/sync.ts +++ b/server/routers/newt/sync.ts @@ -9,45 +9,45 @@ import { import { canCompress } from "@server/lib/clientVersionChecks"; export async function sendNewtSyncMessage(newt: Newt, site: Site) { - const { - tcpTargets, - udpTargets, - validHealthCheckTargets, - browserGatewayTargets - } = await buildTargetConfigurationForNewtClient(site.siteId); - - let exitNode: ExitNode | undefined; - if (site.exitNodeId) { - [exitNode] = await db - .select() - .from(exitNodes) - .where(eq(exitNodes.exitNodeId, site.exitNodeId)) - .limit(1); - } - const { peers, targets } = await buildClientConfigurationForNewtClient( - site, - exitNode - ); - - await sendToClient( - newt.newtId, - { - type: "newt/sync", - data: { - proxyTargets: { - udp: udpTargets, - tcp: tcpTargets - }, - healthCheckTargets: validHealthCheckTargets, - peers: peers, - clientTargets: targets, - browserGatewayTargets: browserGatewayTargets - } - }, - { - compress: canCompress(newt.version, "newt") - } - ).catch((error) => { - logger.warn(`Error sending newt sync message:`, error); - }); + // const { + // tcpTargets, + // udpTargets, + // validHealthCheckTargets, + // browserGatewayTargets, + // remoteExitNodeSubnets + // } = await buildTargetConfigurationForNewtClient(site.siteId); + // let exitNode: ExitNode | undefined; + // if (site.exitNodeId) { + // [exitNode] = await db + // .select() + // .from(exitNodes) + // .where(eq(exitNodes.exitNodeId, site.exitNodeId)) + // .limit(1); + // } + // const { peers, targets } = await buildClientConfigurationForNewtClient( + // site, + // exitNode + // ); + // await sendToClient( + // newt.newtId, + // { + // type: "newt/sync", + // data: { + // proxyTargets: { + // udp: udpTargets, + // tcp: tcpTargets + // }, + // healthCheckTargets: validHealthCheckTargets, + // peers: peers, + // clientTargets: targets, + // browserGatewayTargets: browserGatewayTargets, + // remoteExitNodeSubnets: remoteExitNodeSubnets + // } + // }, + // { + // compress: canCompress(newt.version, "newt") + // } + // ).catch((error) => { + // logger.warn(`Error sending newt sync message:`, error); + // }); } diff --git a/server/routers/olm/deleteUserOlm.ts b/server/routers/olm/deleteUserOlm.ts index addef32d9..d2d0821e8 100644 --- a/server/routers/olm/deleteUserOlm.ts +++ b/server/routers/olm/deleteUserOlm.ts @@ -9,7 +9,10 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromClient, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; import { sendTerminateClient } from "../client/terminate"; import { OlmErrorCodes } from "./error"; @@ -64,6 +67,30 @@ export async function deleteUserOlm( const { olmId } = parsedParams.data; + // get the client first + const [client] = await db + .select() + .from(clients) + .where(eq(clients.olmId, olmId)); + + if (!client) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `No client found for olmId ${olmId}` + ) + ); + } + + if (await isOrgRebuildRateLimited(client.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + let deletedClient: Client | undefined; // Delete associated clients and the OLM in a transaction await db.transaction(async (trx) => { diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index bef993831..d386fe74e 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -197,15 +197,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { policyCheck }); - if (policyCheck?.error) { - logger.error( - `[handleOlmRegisterMessage] Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}`, - { orgId: client.orgId, clientId: client.clientId } - ); - sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId); - return; - } - if (policyCheck.policies?.passwordAge?.compliant === false) { logger.warn( `[handleOlmRegisterMessage] Olm user ${olm.userId} has non-compliant password age for org ${orgId}`, @@ -238,7 +229,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { olm.olmId ); return; - } else if (!policyCheck.allowed) { + } else if (!policyCheck.allowed || policyCheck.error) { logger.warn( `[handleOlmRegisterMessage] Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}`, { orgId: client.orgId, clientId: client.clientId } diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index 35466ebc0..f7ea3018f 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -27,7 +27,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import { isValidCIDR } from "@server/lib/validators"; import { createCustomer } from "#dynamic/lib/billing"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId, limitsService, freeLimitSet } from "@server/lib/billing"; +import { LimitId, limitsService, freeLimitSet } from "@server/lib/billing"; import { build } from "@server/build"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { doCidrsOverlap } from "@server/lib/ip"; @@ -202,7 +202,7 @@ export async function createOrg( if (build == "saas" && billingOrgIdForNewOrg) { const usage = await usageService.getUsage( billingOrgIdForNewOrg, - FeatureId.ORGINIZATIONS + LimitId.ORGANIZATIONS ); if (!usage) { return next( @@ -214,7 +214,7 @@ export async function createOrg( } const rejectOrgs = await usageService.checkLimitSet( billingOrgIdForNewOrg, - FeatureId.ORGINIZATIONS, + LimitId.ORGANIZATIONS, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 @@ -421,7 +421,7 @@ export async function createOrg( if (customerId) { await usageService.updateCount( orgId, - FeatureId.USERS, + LimitId.USERS, 1, customerId ); // Only 1 because we are creating the org @@ -431,7 +431,7 @@ export async function createOrg( if (numOrgs) { usageService.updateCount( billingOrgIdForNewOrg || orgId, - FeatureId.ORGINIZATIONS, + LimitId.ORGANIZATIONS, numOrgs ); } diff --git a/server/routers/policy/setResourcePolicyHeaderAuth.ts b/server/routers/policy/setResourcePolicyHeaderAuth.ts index 368f9b05e..6dba0b8a7 100644 --- a/server/routers/policy/setResourcePolicyHeaderAuth.ts +++ b/server/routers/policy/setResourcePolicyHeaderAuth.ts @@ -76,6 +76,15 @@ export async function setResourcePolicyHeaderAuth( const { resourcePolicyId } = parsedParams.data; const { headerAuth } = parsedBody.data; + const headerAuthHash = + headerAuth !== null + ? await hashPassword( + Buffer.from( + `${headerAuth.user}:${headerAuth.password}` + ).toString("base64") + ) + : null; + await db.transaction(async (trx) => { await trx .delete(resourcePolicyHeaderAuth) @@ -86,13 +95,7 @@ export async function setResourcePolicyHeaderAuth( ) ); - if (headerAuth !== null) { - const headerAuthHash = await hashPassword( - Buffer.from( - `${headerAuth.user}:${headerAuth.password}` - ).toString("base64") - ); - + if (headerAuth !== null && headerAuthHash !== null) { await trx.insert(resourcePolicyHeaderAuth).values({ resourcePolicyId, headerAuthHash, diff --git a/server/routers/policy/setResourcePolicyRules.ts b/server/routers/policy/setResourcePolicyRules.ts index f15c1e51a..dfcfb7cdb 100644 --- a/server/routers/policy/setResourcePolicyRules.ts +++ b/server/routers/policy/setResourcePolicyRules.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, resourcePolicyRules, resourcePolicies } from "@server/db"; -import { eq } from "drizzle-orm"; +import { and, eq, notInArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -14,6 +14,7 @@ import { import { OpenAPITags, registry } from "@server/openApi"; const ruleSchema = z.strictObject({ + ruleId: z.int().positive().optional(), action: z.enum(["ACCEPT", "DROP", "PASS"]).openapi({ type: "string", enum: ["ACCEPT", "DROP", "PASS"], @@ -121,17 +122,74 @@ export async function setResourcePolicyRules( .set({ applyRules }) .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)); - await trx - .delete(resourcePolicyRules) - .where( - eq(resourcePolicyRules.resourcePolicyId, resourcePolicyId) - ); + const incomingRuleIds = rules + .map((r) => r.ruleId) + .filter((id): id is number => id !== undefined); - if (rules.length > 0) { + // Delete rules that are no longer in the incoming list + if (incomingRuleIds.length > 0) { + await trx + .delete(resourcePolicyRules) + .where( + and( + eq( + resourcePolicyRules.resourcePolicyId, + resourcePolicyId + ), + notInArray( + resourcePolicyRules.ruleId, + incomingRuleIds + ) + ) + ); + } else { + await trx + .delete(resourcePolicyRules) + .where( + eq( + resourcePolicyRules.resourcePolicyId, + resourcePolicyId + ) + ); + } + + // Update existing rules (those with a ruleId) + const existingRules = rules.filter( + (r): r is typeof r & { ruleId: number } => + r.ruleId !== undefined + ); + for (const rule of existingRules) { + await trx + .update(resourcePolicyRules) + .set({ + action: rule.action, + match: rule.match, + value: rule.value, + priority: rule.priority, + enabled: rule.enabled + }) + .where( + and( + eq(resourcePolicyRules.ruleId, rule.ruleId), + eq( + resourcePolicyRules.resourcePolicyId, + resourcePolicyId + ) + ) + ); + } + + // Insert new rules (those without a ruleId) + const newRules = rules.filter((r) => r.ruleId === undefined); + if (newRules.length > 0) { await trx.insert(resourcePolicyRules).values( - rules.map((rule) => ({ + newRules.map((rule) => ({ resourcePolicyId, - ...rule + action: rule.action, + match: rule.match, + value: rule.value, + priority: rule.priority, + enabled: rule.enabled })) ); } diff --git a/server/routers/remoteExitNode/index.ts b/server/routers/remoteExitNode/index.ts new file mode 100644 index 000000000..eea524d65 --- /dev/null +++ b/server/routers/remoteExitNode/index.ts @@ -0,0 +1 @@ +export * from "./types"; diff --git a/server/routers/remoteExitNode/types.ts b/server/routers/remoteExitNode/types.ts index 9984b1b4f..3290832a8 100644 --- a/server/routers/remoteExitNode/types.ts +++ b/server/routers/remoteExitNode/types.ts @@ -43,3 +43,37 @@ export type GetRemoteExitNodeResponse = { online: boolean; type: string | null; }; + +export type ListRemoteExitNodeResourcesResponse = { + resources: { + remoteExitNodeResourceId: number; + remoteExitNodeId: string; + destination: string; + }[]; +}; + +export type SetRemoteExitNodeResourcesResponse = { + resources: { + remoteExitNodeResourceId: number; + remoteExitNodeId: string; + destination: string; + }[]; +}; + +export type ListRemoteExitNodePreferenceLabelsResponse = { + labels: { + remoteExitNodePreferenceLabelId: number; + labelId: number; + name: string; + color: string; + }[]; +}; + +export type SetRemoteExitNodePreferenceLabelsResponse = { + labels: { + remoteExitNodePreferenceLabelId: number; + labelId: number; + name: string; + color: string; + }[]; +}; diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index b9547bfbf..81c5950fb 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -36,6 +36,8 @@ import { getUniqueResourceName, getUniqueResourcePolicyName } from "@server/db/names"; +import { usageService } from "@server/lib/billing/usageService"; +import { LimitId } from "@server/lib/billing"; const createResourceParamsSchema = z.strictObject({ orgId: z.string() @@ -235,6 +237,38 @@ export async function createResource( req.body.mode = resolvedMode.mode; } + if (build == "saas") { + const usage = await usageService.getUsage( + orgId, + LimitId.PUBLIC_RESOURCES + ); + if (!usage) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No usage data found for this organization" + ) + ); + } + const rejectResource = await usageService.checkLimitSet( + orgId, + + LimitId.PUBLIC_RESOURCES, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } // We need to add one to know if we are violating the limit + ); + if (rejectResource) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Public resource limit exceeded. Please upgrade your plan." + ) + ); + } + } + if (typeof req.body.proxyPort === "number") { if ( !config.getRawConfig().flags?.allow_raw_resources && @@ -503,6 +537,8 @@ async function createHttpResource( } resource = newResource[0]; + + await usageService.add(orgId, LimitId.PUBLIC_RESOURCES, 1, trx); }); if (!resource) { @@ -631,6 +667,8 @@ async function createRawResource( } resource = newResource[0]; + + await usageService.add(orgId, LimitId.PUBLIC_RESOURCES, 1, trx); }); if (!resource) { diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index 766b25b04..d80817360 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -11,6 +11,8 @@ import { performDeleteResource, runResourceDeleteSideEffects } from "@server/lib/deleteResource"; +import { LimitId } from "@server/lib/billing"; +import { usageService } from "@server/lib/billing/usageService"; const deleteResourceSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() @@ -64,6 +66,14 @@ export async function deleteResource( await db.transaction(async (trx) => { deleteResult = await performDeleteResource(resourceId, trx); + if (deleteResult?.deletedResource?.orgId) { + await usageService.add( + deleteResult?.deletedResource?.orgId, + LimitId.PUBLIC_RESOURCES, + -1, + trx + ); + } }); if (!deleteResult) { diff --git a/server/routers/resource/listUserResourceAliases.ts b/server/routers/resource/listUserResourceAliases.ts index 75dc91166..dab8afc23 100644 --- a/server/routers/resource/listUserResourceAliases.ts +++ b/server/routers/resource/listUserResourceAliases.ts @@ -5,8 +5,12 @@ import { userSiteResources, roleSiteResources, userOrgRoles, - userOrgs + userOrgs, + labels, + siteResourceLabels } from "@server/db"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { and, eq, inArray, asc, isNotNull, ne, or } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; @@ -19,13 +23,33 @@ import { regionalCache as cache } from "#dynamic/lib/cache"; const USER_RESOURCE_ALIASES_CACHE_TTL_SEC = 60; +const labelFilterQuerySchema = z + .preprocess((val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + if (Array.isArray(val)) { + return val; + } + if (typeof val === "string") { + return val.split(","); + } + return undefined; + }, z.array(z.string())) + .optional() + .catch([]); + function userResourceAliasesCacheKey( orgId: string, userId: string, page: number, - pageSize: number + pageSize: number, + includeLabels: boolean, + labelFilter: string[] ) { - return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}`; + const labelsKey = + labelFilter.length > 0 ? labelFilter.slice().sort().join(",") : "all"; + return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}:${includeLabels ? "labels" : "plain"}:${labelsKey}`; } const listUserResourceAliasesParamsSchema = z.strictObject({ @@ -56,43 +80,35 @@ const listUserResourceAliasesQuerySchema = z.strictObject({ type: "integer", default: 1, description: "Page number to retrieve" - }) + }), + includeLabels: z + .enum(["true", "false"]) + .optional() + .default("false") + .transform((val) => val === "true") + .openapi({ + type: "boolean", + default: false, + description: + "When true, include label names for each alias in the items field" + }), + labels: labelFilterQuerySchema.openapi({ + type: "array", + description: + "Filter by resource labels. A resource matches when it has any of the given labels (OR)." + }) }); +export type UserResourceAliasItem = { + alias: string; + labels: string[]; +}; + export type ListUserResourceAliasesResponse = PaginatedResponse<{ aliases: string[]; + items?: UserResourceAliasItem[]; }>; -// registry.registerPath({ -// method: "get", -// path: "/org/{orgId}/user-resource-aliases", -// description: -// "List private (host-mode) site resource aliases the authenticated user can access in the organization, paginated.", -// tags: [OpenAPITags.PrivateResource], -// request: { -// params: z.object({ -// orgId: z.string() -// }), -// query: listUserResourceAliasesQuerySchema -// }, -// responses: { -// 200: { -// description: "Successful response", -// content: { -// "application/json": { -// schema: z.object({ -// data: z.record(z.string(), z.any()).nullable(), -// success: z.boolean(), -// error: z.boolean(), -// message: z.string(), -// status: z.number() -// }) -// } -// } -// } -// } -// }); - export async function listUserResourceAliases( req: Request, res: Response, @@ -110,7 +126,12 @@ export async function listUserResourceAliases( ) ); } - const { page, pageSize } = parsedQuery.data; + const { + page, + pageSize, + includeLabels, + labels: labelFilter + } = parsedQuery.data; const parsedParams = listUserResourceAliasesParamsSchema.safeParse( req.params @@ -149,7 +170,9 @@ export async function listUserResourceAliases( orgId, userId, page, - pageSize + pageSize, + includeLabels, + labelFilter ?? [] ); const cachedData: ListUserResourceAliasesResponse | undefined = await cache.get(cacheKey); @@ -204,6 +227,7 @@ export async function listUserResourceAliases( if (accessibleSiteResourceIds.length === 0) { const data: ListUserResourceAliasesResponse = { aliases: [], + ...(includeLabels ? { items: [] } : {}), pagination: { total: 0, pageSize, @@ -224,18 +248,44 @@ export async function listUserResourceAliases( }); } - const whereClause = and( + const isLabelFeatureEnabled = await isLicensedOrSubscribed( + orgId, + tierMatrix.labels + ); + + const whereConditions = [ eq(siteResources.orgId, orgId), eq(siteResources.enabled, true), or(eq(siteResources.mode, "host"), eq(siteResources.mode, "ssh")), isNotNull(siteResources.alias), ne(siteResources.alias, ""), inArray(siteResources.siteResourceId, accessibleSiteResourceIds) - ); + ]; + + if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) { + whereConditions.push( + inArray( + siteResources.siteResourceId, + db + .select({ id: siteResourceLabels.siteResourceId }) + .from(siteResourceLabels) + .innerJoin( + labels, + eq(labels.labelId, siteResourceLabels.labelId) + ) + .where(inArray(labels.name, labelFilter)) + ) + ); + } + + const whereClause = and(...whereConditions); const baseSelect = () => db - .select({ alias: siteResources.alias }) + .select({ + alias: siteResources.alias, + siteResourceId: siteResources.siteResourceId + }) .from(siteResources) .where(whereClause); @@ -251,8 +301,46 @@ export async function listUserResourceAliases( const aliases = rows.map((r) => r.alias as string); + let items: UserResourceAliasItem[] | undefined; + if (includeLabels) { + const siteResourceIdList = rows.map((r) => r.siteResourceId); + + let labelsForSiteResources: Array<{ + name: string; + siteResourceId: number; + }> = []; + + if (isLabelFeatureEnabled && siteResourceIdList.length > 0) { + labelsForSiteResources = await db + .select({ + name: labels.name, + siteResourceId: siteResourceLabels.siteResourceId + }) + .from(labels) + .innerJoin( + siteResourceLabels, + eq(siteResourceLabels.labelId, labels.labelId) + ) + .where( + inArray( + siteResourceLabels.siteResourceId, + siteResourceIdList + ) + ) + .orderBy(asc(siteResourceLabels.siteResourceLabelId)); + } + + items = rows.map((row) => ({ + alias: row.alias as string, + labels: labelsForSiteResources + .filter((l) => l.siteResourceId === row.siteResourceId) + .map((l) => l.name) + })); + } + const data: ListUserResourceAliasesResponse = { aliases, + ...(items !== undefined ? { items } : {}), pagination: { total: totalCount, pageSize, diff --git a/server/routers/resource/setResourceHeaderAuth.ts b/server/routers/resource/setResourceHeaderAuth.ts index e19d47c3b..6c5ee1788 100644 --- a/server/routers/resource/setResourceHeaderAuth.ts +++ b/server/routers/resource/setResourceHeaderAuth.ts @@ -107,6 +107,13 @@ export async function setResourceHeaderAuth( resource.resourcePolicyId === null && resource.defaultResourcePolicyId !== null; + const headerAuthHash = + user && password && extendedCompatibility !== null + ? await hashPassword( + Buffer.from(`${user}:${password}`).toString("base64") + ) + : null; + await db.transaction(async (trx) => { if (isInlinePolicy) { const policyId = resource.defaultResourcePolicyId!; @@ -116,11 +123,7 @@ export async function setResourceHeaderAuth( eq(resourcePolicyHeaderAuth.resourcePolicyId, policyId) ); - if (user && password && extendedCompatibility !== null) { - const headerAuthHash = await hashPassword( - Buffer.from(`${user}:${password}`).toString("base64") - ); - + if (headerAuthHash !== null && extendedCompatibility !== null) { await trx.insert(resourcePolicyHeaderAuth).values({ resourcePolicyId: policyId, headerAuthHash, @@ -140,11 +143,7 @@ export async function setResourceHeaderAuth( ) ); - if (user && password && extendedCompatibility !== null) { - const headerAuthHash = await hashPassword( - Buffer.from(`${user}:${password}`).toString("base64") - ); - + if (headerAuthHash !== null && extendedCompatibility !== null) { await Promise.all([ trx .insert(resourceHeaderAuth) diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index cc67f7b27..bddf5b251 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -19,7 +19,7 @@ import { getNextAvailableClientSubnet, isIpInCidr } from "@server/lib/ip"; import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes"; import { build } from "@server/build"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; import { generateId } from "@server/auth/sessions/app"; const createSiteParamsSchema = z.strictObject({ @@ -160,7 +160,7 @@ export async function createSite( } if (build == "saas") { - const usage = await usageService.getUsage(orgId, FeatureId.SITES); + const usage = await usageService.getUsage(orgId, LimitId.SITES); if (!usage) { return next( createHttpError( @@ -172,7 +172,7 @@ export async function createSite( const rejectSites = await usageService.checkLimitSet( orgId, - FeatureId.SITES, + LimitId.SITES, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 @@ -519,7 +519,7 @@ export async function createSite( }); } - await usageService.add(orgId, FeatureId.SITES, 1, trx); + await usageService.add(orgId, LimitId.SITES, 1, trx); }); } finally { await releaseSubnetLock?.(); diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 300c570d8..602032ffb 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -13,7 +13,7 @@ import { sendToClient } from "#dynamic/routers/ws"; import { OpenAPITags, registry } from "@server/openApi"; import { cleanupSiteAssociations } from "@server/lib/rebuildClientAssociations"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import { deleteAssociatedResourcesForSite, @@ -177,7 +177,7 @@ export async function deleteSite( } await trx.delete(sites).where(eq(sites.siteId, siteId)); - await usageService.add(site.orgId, FeatureId.SITES, -1, trx); + await usageService.add(site.orgId, LimitId.SITES, -1, trx); }); if (deleteResources) { diff --git a/server/routers/site/index.ts b/server/routers/site/index.ts index 00fdeda91..292c57971 100644 --- a/server/routers/site/index.ts +++ b/server/routers/site/index.ts @@ -7,3 +7,4 @@ export * from "./listSites"; export * from "./listSiteRoles"; export * from "./pickSiteDefaults"; export * from "./socketIntegration"; +export * from "./restartSite"; diff --git a/server/routers/site/restartSite.ts b/server/routers/site/restartSite.ts new file mode 100644 index 000000000..9ae6ffe21 --- /dev/null +++ b/server/routers/site/restartSite.ts @@ -0,0 +1,114 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, newts } from "@server/db"; +import { sites } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { sendToClient } from "#dynamic/routers/ws"; + +const updateSiteParamsSchema = z.strictObject({ + siteId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "post", + path: "/site/{siteId}/restart", + description: "Restart a site.", + tags: [OpenAPITags.Site], + request: { + params: updateSiteParamsSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function restartSite( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = updateSiteParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteId } = parsedParams.data; + + const [existingSite] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!existingSite) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${siteId} not found` + ) + ); + } + + // get the newt + + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); + + if (!newt) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Newt for site with ID ${siteId} not found` + ) + ); + } + + logger.info(`Restarting site ${siteId}...`); + + await sendToClient(newt.newtId, { + type: "newt/wg/restart", + data: {} + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "Site restarted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/siteResource/addClientToSiteResource.ts b/server/routers/siteResource/addClientToSiteResource.ts index 4a6dd141e..5e81cf8f7 100644 --- a/server/routers/siteResource/addClientToSiteResource.ts +++ b/server/routers/siteResource/addClientToSiteResource.ts @@ -8,7 +8,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromSiteResource, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; const addClientToSiteResourceBodySchema = z .object({ @@ -128,6 +131,15 @@ export async function addClientToSiteResource( ); } + if (await isOrgRebuildRateLimited(siteResource.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + // Check if client already exists in site resource const existingEntry = await db .select() diff --git a/server/routers/siteResource/addRoleToSiteResource.ts b/server/routers/siteResource/addRoleToSiteResource.ts index 05186c351..d3dd164cc 100644 --- a/server/routers/siteResource/addRoleToSiteResource.ts +++ b/server/routers/siteResource/addRoleToSiteResource.ts @@ -9,7 +9,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromSiteResource, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; const addRoleToSiteResourceBodySchema = z .object({ @@ -104,6 +107,15 @@ export async function addRoleToSiteResource( ); } + if (await isOrgRebuildRateLimited(siteResource.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + // verify the role exists and belongs to the same org const [role] = await db .select() diff --git a/server/routers/siteResource/addUserToSiteResource.ts b/server/routers/siteResource/addUserToSiteResource.ts index c35357993..0a93868ae 100644 --- a/server/routers/siteResource/addUserToSiteResource.ts +++ b/server/routers/siteResource/addUserToSiteResource.ts @@ -9,7 +9,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromSiteResource, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; const addUserToSiteResourceBodySchema = z .object({ @@ -104,6 +107,15 @@ export async function addUserToSiteResource( ); } + if (await isOrgRebuildRateLimited(siteResource.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + // Check if user already exists in site resource const existingEntry = await db .select() diff --git a/server/routers/siteResource/batchAddClientToSiteResources.ts b/server/routers/siteResource/batchAddClientToSiteResources.ts index 1ebb3359d..3eb282310 100644 --- a/server/routers/siteResource/batchAddClientToSiteResources.ts +++ b/server/routers/siteResource/batchAddClientToSiteResources.ts @@ -15,7 +15,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, and, inArray } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromClient, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; const batchAddClientToSiteResourcesParamsSchema = z .object({ @@ -186,6 +189,15 @@ export async function batchAddClientToSiteResources( ); } + if (await isOrgRebuildRateLimited(client.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + if (client.userId !== null) { return next( createHttpError( diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index d0d018f84..d7adb5c03 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -21,7 +21,10 @@ import { } from "@server/lib/ip"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromSiteResource, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -34,6 +37,8 @@ import { fromError } from "zod-validation-error"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { build } from "@server/build"; +import { usageService } from "@server/lib/billing/usageService"; +import { LimitId } from "@server/lib/billing"; const createSiteResourceParamsSchema = z.strictObject({ orgId: z.string() @@ -291,6 +296,38 @@ export async function createSiteResource( siteIds.push(siteId); } + if (build == "saas") { + const usage = await usageService.getUsage( + orgId, + LimitId.PRIVATE_RESOURCES + ); + if (!usage) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No usage data found for this organization" + ) + ); + } + const rejectResource = await usageService.checkLimitSet( + orgId, + + LimitId.PRIVATE_RESOURCES, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } // We need to add one to know if we are violating the limit + ); + if (rejectResource) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Private resource limit exceeded. Please upgrade your plan." + ) + ); + } + } + if (mode == "http") { const hasHttpFeature = await isLicensedOrSubscribed( orgId, @@ -339,6 +376,15 @@ export async function createSiteResource( ); } + if (await isOrgRebuildRateLimited(org.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + // Only check if destination is an IP address const isIp = z .union([z.ipv4(), z.ipv6()]) @@ -593,6 +639,13 @@ export async function createSiteResource( ); } } + + await usageService.add( + orgId, + LimitId.PRIVATE_RESOURCES, + 1, + trx + ); }); } finally { await releaseAliasLock?.(); diff --git a/server/routers/siteResource/deleteSiteResource.ts b/server/routers/siteResource/deleteSiteResource.ts index cddeb490b..63479c399 100644 --- a/server/routers/siteResource/deleteSiteResource.ts +++ b/server/routers/siteResource/deleteSiteResource.ts @@ -12,6 +12,8 @@ import { performDeleteSiteResource, runSiteResourceDeleteSideEffects } from "@server/lib/deleteSiteResource"; +import { LimitId } from "@server/lib/billing"; +import { usageService } from "@server/lib/billing/usageService"; const deleteSiteResourceParamsSchema = z.strictObject({ siteResourceId: z.coerce.number().int().positive() @@ -86,6 +88,14 @@ export async function deleteSiteResource( siteResourceId, trx ); + if (removedSiteResource?.orgId) { + await usageService.add( + removedSiteResource?.orgId, + LimitId.PRIVATE_RESOURCES, + -1, + trx + ); + } }); if (!removedSiteResource) { diff --git a/server/routers/siteResource/removeClientFromSiteResource.ts b/server/routers/siteResource/removeClientFromSiteResource.ts index c53e214cd..e955dd5c6 100644 --- a/server/routers/siteResource/removeClientFromSiteResource.ts +++ b/server/routers/siteResource/removeClientFromSiteResource.ts @@ -8,7 +8,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromSiteResource, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; const removeClientFromSiteResourceBodySchema = z .object({ @@ -106,6 +109,14 @@ export async function removeClientFromSiteResource( ); } + if (await isOrgRebuildRateLimited(siteResource.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } // Check if client exists and has a userId const [client] = await db .select() diff --git a/server/routers/siteResource/removeRoleFromSiteResource.ts b/server/routers/siteResource/removeRoleFromSiteResource.ts index 6dd978e24..509088597 100644 --- a/server/routers/siteResource/removeRoleFromSiteResource.ts +++ b/server/routers/siteResource/removeRoleFromSiteResource.ts @@ -9,7 +9,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromSiteResource, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; const removeRoleFromSiteResourceBodySchema = z .object({ @@ -106,6 +109,15 @@ export async function removeRoleFromSiteResource( ); } + if (await isOrgRebuildRateLimited(siteResource.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + // Check if the role is an admin role const [roleToCheck] = await db .select() diff --git a/server/routers/siteResource/removeUserFromSiteResource.ts b/server/routers/siteResource/removeUserFromSiteResource.ts index 67e6ac960..3d8e1fd97 100644 --- a/server/routers/siteResource/removeUserFromSiteResource.ts +++ b/server/routers/siteResource/removeUserFromSiteResource.ts @@ -9,7 +9,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromSiteResource, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; const removeUserFromSiteResourceBodySchema = z .object({ @@ -106,6 +109,15 @@ export async function removeUserFromSiteResource( ); } + if (await isOrgRebuildRateLimited(siteResource.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + // Check if user exists in site resource const existingEntry = await db .select() diff --git a/server/routers/siteResource/setSiteResourceClients.ts b/server/routers/siteResource/setSiteResourceClients.ts index a4bc5b69e..59dff6ef9 100644 --- a/server/routers/siteResource/setSiteResourceClients.ts +++ b/server/routers/siteResource/setSiteResourceClients.ts @@ -8,7 +8,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, inArray } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromSiteResource, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; const setSiteResourceClientsBodySchema = z .object({ @@ -107,6 +110,15 @@ export async function setSiteResourceClients( ); } + if (await isOrgRebuildRateLimited(siteResource.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + // Check if any clients have a userId (associated with a user) if (clientIds.length > 0) { const clientsWithUsers = await db diff --git a/server/routers/siteResource/setSiteResourceRoles.ts b/server/routers/siteResource/setSiteResourceRoles.ts index cad6da53b..277ef437e 100644 --- a/server/routers/siteResource/setSiteResourceRoles.ts +++ b/server/routers/siteResource/setSiteResourceRoles.ts @@ -9,7 +9,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, and, ne, inArray } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromSiteResource, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; const setSiteResourceRolesBodySchema = z .object({ @@ -108,6 +111,15 @@ export async function setSiteResourceRoles( ); } + if (await isOrgRebuildRateLimited(siteResource.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + // Check if any of the roleIds are admin roles const rolesToCheck = await db .select() diff --git a/server/routers/siteResource/setSiteResourceUsers.ts b/server/routers/siteResource/setSiteResourceUsers.ts index cde5b4e66..4a423bf06 100644 --- a/server/routers/siteResource/setSiteResourceUsers.ts +++ b/server/routers/siteResource/setSiteResourceUsers.ts @@ -9,7 +9,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromSiteResource, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; import { error } from "node:console"; const setSiteResourceUsersBodySchema = z @@ -109,6 +112,15 @@ export async function setSiteResourceUsers( ); } + if (await isOrgRebuildRateLimited(siteResource.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + await db.transaction(async (trx) => { await trx .delete(userSiteResources) diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 434163f6f..22159f1c6 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -19,6 +19,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import { isIpInCidr, portRangeStringSchema } from "@server/lib/ip"; import { handleMessagingForUpdatedSiteResource, + isOrgRebuildRateLimited, rebuildClientAssociationsFromSiteResource, waitForSiteResourceRebuildIdle } from "@server/lib/rebuildClientAssociations"; @@ -345,6 +346,15 @@ export async function updateSiteResource( ); } + if (await isOrgRebuildRateLimited(org.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + // Verify the site exists and belongs to the org const sitesToAssign = await db .select() diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index ef7ddcdbd..c912fea0e 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -17,10 +17,11 @@ import { fromError } from "zod-validation-error"; import { checkValidInvite } from "@server/auth/checkValidInvite"; import { verifySession } from "@server/auth/sessions/verifySession"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { build } from "@server/build"; import { assignUserToOrg } from "@server/lib/userOrg"; +import { isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations"; const acceptInviteBodySchema = z.strictObject({ token: z.string(), @@ -103,7 +104,7 @@ export async function acceptInvite( if (build == "saas") { const usage = await usageService.getUsage( existingInvite.orgId, - FeatureId.USERS + LimitId.USERS ); if (!usage) { return next( @@ -116,7 +117,7 @@ export async function acceptInvite( const rejectUsers = await usageService.checkLimitSet( existingInvite.orgId, - FeatureId.USERS, + LimitId.USERS, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 @@ -147,6 +148,15 @@ export async function acceptInvite( ); } + if (await isOrgRebuildRateLimited(org.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + const inviteRoleRows = await db .select({ roleId: userInviteRoles.roleId }) .from(userInviteRoles) diff --git a/server/routers/user/addUserRoleLegacy.ts b/server/routers/user/addUserRoleLegacy.ts index b3ff55a06..b7df41bf3 100644 --- a/server/routers/user/addUserRoleLegacy.ts +++ b/server/routers/user/addUserRoleLegacy.ts @@ -10,7 +10,10 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromClient, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; /** Legacy path param order: /role/:roleId/add/:userId */ const addUserRoleLegacyParamsSchema = z.strictObject({ @@ -127,6 +130,15 @@ export async function addUserRoleLegacy( ); } + if (await isOrgRebuildRateLimited(role.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + let orgClientsToRebuild: Client[] = []; await db.transaction(async (trx) => { diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index c6f25e085..d80d85999 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -12,13 +12,14 @@ import { and, eq, inArray } from "drizzle-orm"; import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db"; import { generateId } from "@server/auth/sessions/app"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; import { build } from "@server/build"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { assignUserToOrg } from "@server/lib/userOrg"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -122,7 +123,7 @@ export async function createOrgUser( } = parsedBody.data; if (build == "saas") { - const usage = await usageService.getUsage(orgId, FeatureId.USERS); + const usage = await usageService.getUsage(orgId, LimitId.USERS); if (!usage) { return next( createHttpError( @@ -134,7 +135,7 @@ export async function createOrgUser( const rejectUsers = await usageService.checkLimitSet( orgId, - FeatureId.USERS, + LimitId.USERS, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 @@ -229,6 +230,15 @@ export async function createOrgUser( ); } + if (await isOrgRebuildRateLimited(org.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + const [idpRes] = await db .select() .from(idp) diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index c4feb820f..7445ee910 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -25,7 +25,7 @@ import SendInviteLink from "@server/emails/templates/SendInviteLink"; import { OpenAPITags, registry } from "@server/openApi"; import { UserType } from "@server/types/UserTypes"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { build } from "@server/build"; import cache from "#dynamic/lib/cache"; @@ -73,7 +73,6 @@ const InviteUserResponseDataSchema = z.object({ expiresAt: z.number() }); - registry.registerPath({ method: "post", path: "/org/{orgId}/create-invite", @@ -94,7 +93,9 @@ registry.registerPath({ description: "Successful response", content: { "application/json": { - schema: createApiResponseSchema(InviteUserResponseDataSchema) + schema: createApiResponseSchema( + InviteUserResponseDataSchema + ) } } } @@ -181,7 +182,7 @@ export async function inviteUser( } if (build == "saas") { - const usage = await usageService.getUsage(orgId, FeatureId.USERS); + const usage = await usageService.getUsage(orgId, LimitId.USERS); if (!usage) { return next( createHttpError( @@ -192,7 +193,7 @@ export async function inviteUser( } const rejectUsers = await usageService.checkLimitSet( orgId, - FeatureId.USERS, + LimitId.USERS, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 diff --git a/server/routers/user/removeUserOrg.ts b/server/routers/user/removeUserOrg.ts index 902aeed84..abaf6b3e6 100644 --- a/server/routers/user/removeUserOrg.ts +++ b/server/routers/user/removeUserOrg.ts @@ -1,29 +1,17 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { - db, - orgs, - resources, - siteResources, - sites, - UserOrg, - userSiteResources, - primaryDb -} from "@server/db"; -import { userOrgs, userResources, users, userSites } from "@server/db"; -import { and, count, eq, exists, inArray } from "drizzle-orm"; +import { db, orgs } from "@server/db"; +import { userOrgs } from "@server/db"; +import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; -import { build } from "@server/build"; -import { UserType } from "@server/types/UserTypes"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { removeUserFromOrg } from "@server/lib/userOrg"; +import { isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations"; const removeUserSchema = z.strictObject({ userId: z.string(), @@ -93,6 +81,15 @@ export async function removeUserOrg( ); } + if (await isOrgRebuildRateLimited(orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + const [org] = await db .select() .from(orgs) diff --git a/server/routers/ws/ws.ts b/server/routers/ws/ws.ts index 4ce337a20..dad29c4f0 100644 --- a/server/routers/ws/ws.ts +++ b/server/routers/ws/ws.ts @@ -14,7 +14,7 @@ import { } from "@server/db"; import { eq } from "drizzle-orm"; import { db } from "@server/db"; -import { recordPing } from "@server/routers/newt/pingAccumulator"; +import { recordSitePing } from "@server/routers/newt/pingAccumulator"; import { validateNewtSessionToken } from "@server/auth/sessions/newt"; import { validateOlmSessionToken } from "@server/auth/sessions/olm"; import { messageHandlers } from "./messageHandlers"; @@ -424,7 +424,7 @@ const setupConnection = async ( // pending pings in a single batched UPDATE every ~10s, which // prevents connection pool exhaustion under load (especially // with cross-region latency to the database). - recordPing(newtClient.siteId); + recordSitePing(newtClient.siteId); }); } diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index bc68ba3aa..8662678d2 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -45,6 +45,7 @@ import m39 from "./scriptsSqlite/1.18.3"; import m40 from "./scriptsSqlite/1.18.4"; import m41 from "./scriptsSqlite/1.19.0"; import m42 from "./scriptsSqlite/1.19.1"; +import m43 from "./scriptsSqlite/1.19.5"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -87,7 +88,8 @@ const migrations = [ { version: "1.18.3", run: m39 }, { version: "1.18.4", run: m40 }, { version: "1.19.0", run: m41 }, - { version: "1.19.1", run: m42 } + { version: "1.19.1", run: m42 }, + { version: "1.19.5", run: m43 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsSqlite/1.19.5.ts b/server/setup/scriptsSqlite/1.19.5.ts new file mode 100644 index 000000000..b9fe43ecf --- /dev/null +++ b/server/setup/scriptsSqlite/1.19.5.ts @@ -0,0 +1,106 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.19.5"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + + db.transaction(() => { + // The 1.19.0 migration added policyPasswordId/policyPincodeId/policyWhitelistId to + // resourceSessions via inline REFERENCES clauses with no ON DELETE action, unlike the + // Postgres migration which correctly used "ON DELETE cascade". SQLite can't alter an + // existing foreign key, so rebuild the table to match resourceSessions in schema.ts. + db.prepare( + ` + CREATE TABLE 'resourceSessions_new' ( + 'id' text PRIMARY KEY NOT NULL, + 'resourceId' integer NOT NULL, + 'expiresAt' integer NOT NULL, + 'sessionLength' integer NOT NULL, + 'doNotExtend' integer DEFAULT false NOT NULL, + 'isRequestToken' integer, + 'userSessionId' text, + 'passwordId' integer, + 'pincodeId' integer, + 'whitelistId' integer, + 'accessTokenId' text, + 'policyPasswordId' integer, + 'policyPincodeId' integer, + 'policyWhitelistId' integer, + 'issuedAt' integer, + FOREIGN KEY ('resourceId') REFERENCES 'resources'('resourceId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('userSessionId') REFERENCES 'session'('id') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('passwordId') REFERENCES 'resourcePassword'('passwordId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('pincodeId') REFERENCES 'resourcePincode'('pincodeId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('whitelistId') REFERENCES 'resourceWhitelist'('id') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('accessTokenId') REFERENCES 'resourceAccessToken'('accessTokenId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('policyPasswordId') REFERENCES 'resourcePolicyPassword'('passwordId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('policyPincodeId') REFERENCES 'resourcePolicyPincode'('pincodeId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('policyWhitelistId') REFERENCES 'resourcePolicyWhitelist'('id') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + + db.prepare( + ` + INSERT INTO 'resourceSessions_new' ( + "id", + "resourceId", + "expiresAt", + "sessionLength", + "doNotExtend", + "isRequestToken", + "userSessionId", + "passwordId", + "pincodeId", + "whitelistId", + "accessTokenId", + "policyPasswordId", + "policyPincodeId", + "policyWhitelistId", + "issuedAt" + ) + SELECT + "id", + "resourceId", + "expiresAt", + "sessionLength", + "doNotExtend", + "isRequestToken", + "userSessionId", + "passwordId", + "pincodeId", + "whitelistId", + "accessTokenId", + "policyPasswordId", + "policyPincodeId", + "policyWhitelistId", + "issuedAt" + FROM 'resourceSessions'; + ` + ).run(); + + db.prepare(`DROP TABLE 'resourceSessions';`).run(); + db.prepare( + `ALTER TABLE 'resourceSessions_new' RENAME TO 'resourceSessions';` + ).run(); + })(); + + db.pragma("foreign_keys = ON"); + + console.log("Migrated database"); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/src/app/[orgId]/loading.tsx b/src/app/[orgId]/loading.tsx new file mode 100644 index 000000000..0f7ea0d31 --- /dev/null +++ b/src/app/[orgId]/loading.tsx @@ -0,0 +1,9 @@ +import { Loader2 } from "lucide-react"; + +export default function OrgPageLoading() { + return ( +
+ +
+ ); +} diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index fc806acc1..7abb1b317 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -1,9 +1,9 @@ import { Layout } from "@app/components/Layout"; -import MemberResourcesPortal from "@app/components/MemberResourcesPortal"; +import ResourceLauncher from "@app/components/resource-launcher/ResourceLauncher"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; +import { fetchLauncherPageData } from "@app/lib/launcherServerData"; import { verifySession } from "@app/lib/auth/verifySession"; -import { pullEnv } from "@app/lib/pullEnv"; import UserProvider from "@app/providers/UserProvider"; import { ListUserOrgsResponse } from "@server/routers/org"; import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; @@ -13,12 +13,14 @@ import { cache } from "react"; type OrgPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; +export const dynamic = "force-dynamic"; + export default async function OrgPage(props: OrgPageProps) { const params = await props.params; const orgId = params.orgId; - const env = pullEnv(); if (!orgId) { redirect(`/`); @@ -40,12 +42,6 @@ export default async function OrgPage(props: OrgPageProps) { overview = res.data.data; } catch (e) {} - // If user is admin or owner, redirect to settings - if (overview?.isAdmin || overview?.isOwner) { - redirect(`/${orgId}/settings`); - } - - // For non-admin users, show the member resources portal let orgs: ListUserOrgsResponse["orgs"] = []; try { const getOrgs = cache(async () => @@ -60,10 +56,39 @@ export default async function OrgPage(props: OrgPageProps) { } } catch (e) {} + const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner); + + const searchParams = new URLSearchParams(await props.searchParams); + const launcherData = overview + ? await fetchLauncherPageData( + orgId, + searchParams, + await authCookieHeader() + ) + : null; + return ( - - {overview && } + + {overview && launcherData ? ( + + ) : null} ); diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index e4abdb561..d526a17f4 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -58,7 +58,7 @@ import { tier2LimitSet, tier3LimitSet } from "@server/lib/billing/limitSet"; -import { FeatureId } from "@server/lib/billing/features"; +import { LimitId } from "@server/lib/billing/features"; import TrialBillingBanner from "@app/components/TrialBillingBanner"; // Plan tier definitions matching the mockup @@ -161,32 +161,32 @@ const tierLimits: Record< } > = { basic: { - users: freeLimitSet[FeatureId.USERS]?.value ?? 0, - sites: freeLimitSet[FeatureId.SITES]?.value ?? 0, - domains: freeLimitSet[FeatureId.DOMAINS]?.value ?? 0, - remoteNodes: freeLimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0, - organizations: freeLimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0 + users: freeLimitSet[LimitId.USERS]?.value ?? 0, + sites: freeLimitSet[LimitId.SITES]?.value ?? 0, + domains: freeLimitSet[LimitId.DOMAINS]?.value ?? 0, + remoteNodes: freeLimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0, + organizations: freeLimitSet[LimitId.ORGANIZATIONS]?.value ?? 0 }, tier1: { - users: tier1LimitSet[FeatureId.USERS]?.value ?? 0, - sites: tier1LimitSet[FeatureId.SITES]?.value ?? 0, - domains: tier1LimitSet[FeatureId.DOMAINS]?.value ?? 0, - remoteNodes: tier1LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0, - organizations: tier1LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0 + users: tier1LimitSet[LimitId.USERS]?.value ?? 0, + sites: tier1LimitSet[LimitId.SITES]?.value ?? 0, + domains: tier1LimitSet[LimitId.DOMAINS]?.value ?? 0, + remoteNodes: tier1LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0, + organizations: tier1LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0 }, tier2: { - users: tier2LimitSet[FeatureId.USERS]?.value ?? 0, - sites: tier2LimitSet[FeatureId.SITES]?.value ?? 0, - domains: tier2LimitSet[FeatureId.DOMAINS]?.value ?? 0, - remoteNodes: tier2LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0, - organizations: tier2LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0 + users: tier2LimitSet[LimitId.USERS]?.value ?? 0, + sites: tier2LimitSet[LimitId.SITES]?.value ?? 0, + domains: tier2LimitSet[LimitId.DOMAINS]?.value ?? 0, + remoteNodes: tier2LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0, + organizations: tier2LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0 }, tier3: { - users: tier3LimitSet[FeatureId.USERS]?.value ?? 0, - sites: tier3LimitSet[FeatureId.SITES]?.value ?? 0, - domains: tier3LimitSet[FeatureId.DOMAINS]?.value ?? 0, - remoteNodes: tier3LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0, - organizations: tier3LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0 + users: tier3LimitSet[LimitId.USERS]?.value ?? 0, + sites: tier3LimitSet[LimitId.SITES]?.value ?? 0, + domains: tier3LimitSet[LimitId.DOMAINS]?.value ?? 0, + remoteNodes: tier3LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0, + organizations: tier3LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0 }, enterprise: { users: 0, // Custom for enterprise diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx index ec1dea837..b58403050 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx @@ -37,6 +37,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { { title: t("credentials"), href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/credentials" + }, + { + title: "Networking", + href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/networking" } ]; diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx new file mode 100644 index 000000000..e0d03f771 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx @@ -0,0 +1,271 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + SettingsContainer, + SettingsFormCell, + SettingsFormGrid, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { Label } from "@app/components/ui/label"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { useParams } from "next/navigation"; +import { AxiosResponse } from "axios"; +import { useRemoteExitNodeContext } from "@app/hooks/useRemoteExitNodeContext"; +import { TagInput, type Tag } from "@app/components/tags/tag-input"; +import { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input"; +import type { TagValue } from "@app/components/multi-select/multi-select-content"; +import { orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import { useDebounce } from "use-debounce"; +import type { ListRemoteExitNodeResourcesResponse } from "@server/routers/remoteExitNode"; +import type { SetRemoteExitNodeResourcesResponse } from "@server/routers/remoteExitNode"; +import type { ListRemoteExitNodePreferenceLabelsResponse } from "@server/routers/remoteExitNode"; +import type { SetRemoteExitNodePreferenceLabelsResponse } from "@server/routers/remoteExitNode"; +import { useTranslations } from "next-intl"; +import { ExternalLink } from "lucide-react"; + +const cidrRegex = + /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))$|^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$/; + +export default function NetworkingPage() { + const t = useTranslations(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId } = useParams<{ + orgId: string; + remoteExitNodeId: string; + }>(); + const { remoteExitNode } = useRemoteExitNodeContext(); + + // Subnets state + const [subnets, setSubnets] = useState([]); + const [activeTagIndex, setActiveTagIndex] = useState(null); + const [loadingSubnets, setLoadingSubnets] = useState(true); + + // Labels state + const [selectedLabels, setSelectedLabels] = useState([]); + const [labelSearchQuery, setLabelSearchQuery] = useState(""); + const [loadingLabels, setLoadingLabels] = useState(true); + + const [saving, setSaving] = useState(false); + + const [debouncedLabelQuery] = useDebounce(labelSearchQuery, 150); + + const { data: availableLabels = [] } = useQuery( + orgQueries.labels({ orgId, query: debouncedLabelQuery, perPage: 10 }) + ); + + const labelsShown = useMemo(() => { + const base: TagValue[] = availableLabels.map((l) => ({ + id: l.labelId.toString(), + text: l.name, + color: l.color + })); + if (debouncedLabelQuery.trim().length === 0) { + for (const sel of selectedLabels) { + if (!base.find((b) => b.id === sel.id)) { + base.unshift(sel); + } + } + } + return base; + }, [availableLabels, selectedLabels, debouncedLabelQuery]); + + useEffect(() => { + async function loadSubnets() { + try { + const res = await api.get< + AxiosResponse + >( + `/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/resources` + ); + setSubnets( + res.data.data.resources.map((r) => ({ + id: r.destination, + text: r.destination + })) + ); + } catch (error) { + toast({ + variant: "destructive", + title: t("error"), + description: + formatAxiosError(error) || + t("remoteExitNodeNetworkingSubnetsLoadError") + }); + } finally { + setLoadingSubnets(false); + } + } + + async function loadLabels() { + try { + const res = await api.get< + AxiosResponse + >( + `/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/preference-labels` + ); + setSelectedLabels( + res.data.data.labels.map((l) => ({ + id: l.labelId.toString(), + text: l.name, + color: l.color + })) + ); + } catch (error) { + toast({ + variant: "destructive", + title: t("error"), + description: + formatAxiosError(error) || + t("remoteExitNodeNetworkingLabelsLoadError") + }); + } finally { + setLoadingLabels(false); + } + } + + loadSubnets(); + loadLabels(); + }, [remoteExitNode.remoteExitNodeId]); + + const handleSave = async () => { + setSaving(true); + try { + await Promise.all([ + api.post>( + `/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/resources`, + { destinations: subnets.map((s) => s.text) } + ), + api.post< + AxiosResponse + >( + `/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/preference-labels`, + { labelIds: selectedLabels.map((l) => parseInt(l.id)) } + ) + ]); + toast({ + title: t("remoteExitNodeNetworkingSaveSuccessTitle"), + description: t("remoteExitNodeNetworkingSaveSuccessDescription") + }); + } catch (error) { + toast({ + variant: "destructive", + title: t("error"), + description: + formatAxiosError(error) || + t("remoteExitNodeNetworkingSaveError") + }); + } finally { + setSaving(false); + } + }; + + return ( + + + + + {t("remoteExitNodeNetworkingTitle")} + + + {t("remoteExitNodeNetworkingDescription")} + + {t("learnMore")} + + + + + + + + +
+ + + cidrRegex.test(tag.trim()) + } + activeTagIndex={activeTagIndex} + setActiveTagIndex={setActiveTagIndex} + disabled={loadingSubnets} + allowDuplicates={false} + size="sm" + inlineTags={true} + /> +

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

+
+
+ +
+ + +

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

+
+
+
+
+
+ + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx index a368ec687..4df0f0797 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx @@ -10,6 +10,6 @@ export default async function RemoteExitNodePage(props: { }) { const params = await props.params; redirect( - `/${params.orgId}/settings/remote-exit-nodes/${params.remoteExitNodeId}/credentials` + `/${params.orgId}/settings/remote-exit-nodes/${params.remoteExitNodeId}/networking` ); } diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx index 0e68c3791..13c62a3e7 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx @@ -35,8 +35,6 @@ import { toast } from "@app/hooks/useToast"; import { AxiosResponse } from "axios"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { InfoIcon } from "lucide-react"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { StrategySelect } from "@app/components/StrategySelect"; diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index fc0660ebb..6ac2a30e8 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -342,7 +342,7 @@ export default function GeneralPage() { return ( { const typeLabel = - row.original.type === "ssh" - ? "SSH" + row.original.type === "ssh" || + row.original.type === "rdp" || + row.original.type === "vnc" + ? row.original.type.toUpperCase() : row.original.type.charAt(0).toUpperCase() + row.original.type.slice(1); return {typeLabel || "-"}; @@ -513,7 +517,15 @@ export default function GeneralPage() { function generateSampleAccessLogs(): QueryAccessAuditLogResponse["log"] { const locations = ["US", "DE", "GB", "FR", "JP", "CA", "AU"]; - const types = ["password", "pincode", "login", "whitelistedEmail", "ssh"]; + const types = [ + "password", + "pincode", + "login", + "whitelistedEmail", + "ssh", + "rdp", + "vnc" + ]; const actors = [ "alice@example.com", "bob@example.com", @@ -538,6 +550,7 @@ function generateSampleAccessLogs(): QueryAccessAuditLogResponse["log"] { actor, actorId: actor ? `user-${i}` : null, resourceId: Math.floor(Math.random() * 5) + 1, + siteResourceId: null, resourceNiceId: `resource-${(i % 3) + 1}`, resourceName: `Resource ${(i % 3) + 1}`, ip: `${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`, diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 5f35ee4cd..15d900ee8 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -13,6 +13,7 @@ import { Layout } from "@app/components/Layout"; import { adminNavSections } from "../navigation"; import { pullEnv } from "@app/lib/pullEnv"; import SubscriptionStatusProvider from "@app/providers/SubscriptionStatusProvider"; +import { build } from "@server/build"; export const dynamic = "force-dynamic"; @@ -29,6 +30,11 @@ export default async function AdminLayout(props: LayoutProps) { const getUser = cache(verifySession); const user = await getUser(); + // Disable the admin page on saas + if (build == "saas") { + redirect(`/`); + } + const env = pullEnv(); if (!user || !user.serverAdmin) { diff --git a/src/app/page.tsx b/src/app/page.tsx index 188089bda..7f0f05b57 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -107,7 +107,15 @@ export default async function Page(props: { } if (targetOrgId) { - return ; + const targetOrg = orgs.find((org) => org.orgId === targetOrgId); + return ( + + ); } return ( diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index 1c7d77eb9..668a42cec 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -42,6 +42,8 @@ import { loadEncryptedLocalStorage, saveEncryptedLocalStorage } from "@app/lib/secureLocalStorage"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; declare module "react" { namespace JSX { @@ -96,6 +98,7 @@ export default function RdpClient({ primaryColor?: string | null; }) { const t = useTranslations(); + const api = createApiClient(useEnvContext()); const STORAGE_KEY = "pangolin_rdp_credentials"; const resourceName = target?.name?.trim() || null; @@ -311,6 +314,11 @@ export default function RdpClient({ values, target.authToken ); + void api.post(`/org/${target.orgId}/logs/access/attempt`, { + resourceId: target.resourceId, + action: true, + type: "rdp" + }); setConnecting(false); setShowLogin(false); userInteraction.setVisibility(true); @@ -320,6 +328,11 @@ export default function RdpClient({ fileTransferRef.current = null; setShowLogin(true); } catch (err) { + void api.post(`/org/${target.orgId}/logs/access/attempt`, { + resourceId: target.resourceId, + action: false, + type: "rdp" + }); setConnecting(false); setShowLogin(true); if (isIronError(err)) { diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx index 7a2bcd425..8fc0423e7 100644 --- a/src/app/ssh/SshClient.tsx +++ b/src/app/ssh/SshClient.tsx @@ -36,6 +36,8 @@ import { loadEncryptedLocalStorage, saveEncryptedLocalStorage } from "@app/lib/secureLocalStorage"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; type AuthTab = "password" | "privateKey"; @@ -73,6 +75,7 @@ export default function SshClient({ }) { const STORAGE_KEY = "pangolin_ssh_credentials"; const t = useTranslations(); + const api = createApiClient(useEnvContext()); const resourceName = target?.name?.trim() || null; const passwordTabSchema = z.object({ @@ -263,6 +266,17 @@ export default function SshClient({ let authConfirmed = false; let authErrorShown = false; let socketOpened = false; + let auditLogged = false; + + const logAudit = (action: boolean) => { + if (auditLogged || !target) return; + auditLogged = true; + void api.post(`/org/${target.orgId}/logs/access/attempt`, { + resourceId: target.resourceId, + action, + type: "ssh" + }); + }; ws.onopen = () => { socketOpened = true; @@ -294,6 +308,7 @@ export default function SshClient({ if (msg.type === "data" && msg.data) { if (!authConfirmed) { authConfirmed = true; + logAudit(true); setConnecting(false); setConnected(true); } @@ -301,6 +316,7 @@ export default function SshClient({ } else if (msg.type === "error") { if (!authConfirmed) { authErrorShown = true; + logAudit(false); setConnecting(false); setConnectError( msg.error ?? t("sshErrorAuthFailed") @@ -323,6 +339,7 @@ export default function SshClient({ evt.data.text().then((text) => { if (!authConfirmed) { authConfirmed = true; + logAudit(true); setConnecting(false); setConnected(true); } @@ -332,6 +349,7 @@ export default function SshClient({ }; ws.onerror = () => { + logAudit(false); setConnecting(false); setConnected(false); setConnectError(t("sshErrorWebSocket")); @@ -355,6 +373,7 @@ export default function SshClient({ ); } if (!authConfirmed && !authErrorShown) { + logAudit(false); setConnectError(t("sshErrorConnectionClosed")); } }; diff --git a/src/app/vnc/VncClient.tsx b/src/app/vnc/VncClient.tsx index ac50bb587..b549cc547 100644 --- a/src/app/vnc/VncClient.tsx +++ b/src/app/vnc/VncClient.tsx @@ -33,12 +33,16 @@ import { loadEncryptedLocalStorage, saveEncryptedLocalStorage } from "@app/lib/secureLocalStorage"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; type VncCredentialsForm = { + username: string; password: string; }; const DEFAULT_VNC_CREDENTIALS: VncCredentialsForm = { + username: "", password: "" }; @@ -52,10 +56,12 @@ export default function VncClient({ primaryColor?: string | null; }) { const t = useTranslations(); + const api = createApiClient(useEnvContext()); const STORAGE_KEY = "pangolin_vnc_credentials"; const resourceName = target?.name?.trim() || null; const formSchema = z.object({ + username: z.string(), password: z.string() }); @@ -165,8 +171,11 @@ export default function VncClient({ screenRef.current.innerHTML = ""; const options: Record = {}; - if (values.password) { - options.credentials = { password: values.password }; + if (values.username || values.password) { + options.credentials = { + username: values.username, + password: values.password + }; } let rfb: any; @@ -179,6 +188,7 @@ export default function VncClient({ } let authConfirmed = false; + let auditLogged = false; rfb.scaleViewport = true; rfb.resizeSession = true; @@ -190,6 +200,12 @@ export default function VncClient({ target.authToken ); authConfirmed = true; + auditLogged = true; + void api.post(`/org/${target.orgId}/logs/access/attempt`, { + resourceId: target.resourceId, + action: true, + type: "vnc" + }); setConnecting(false); setConnected(true); }); @@ -201,6 +217,17 @@ export default function VncClient({ setConnecting(false); setConnected(false); if (!authConfirmed && !e.detail.clean) { + if (!auditLogged) { + auditLogged = true; + void api.post( + `/org/${target.orgId}/logs/access/attempt`, + { + resourceId: target.resourceId, + action: false, + type: "vnc" + } + ); + } setConnectError(t("sshErrorConnectionClosed")); } } @@ -209,6 +236,12 @@ export default function VncClient({ rfb.addEventListener( "securityfailure", (e: { detail: { status: number; reason?: string } }) => { + auditLogged = true; + void api.post(`/org/${target.orgId}/logs/access/attempt`, { + resourceId: target.resourceId, + action: false, + type: "vnc" + }); disconnect(); setConnectError( e.detail.reason ?? @@ -265,6 +298,25 @@ export default function VncClient({ onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" > + ( + + + {t("vncUsernameOptional")} + + + + + + + )} + /> (selectedValues); const t = useTranslations(); - const [labelSearchQuery, setlabelsSearchQuery] = useState(""); - const [debouncedQuery] = useDebounce(labelSearchQuery, 150); - const { data: labels = [] } = useQuery( orgQueries.labels({ orgId, - query: debouncedQuery, perPage: 500 }) ); @@ -152,53 +139,17 @@ export function LabelColumnFilterButton({ className={dataTableFilterPopoverContentClassName} align="start" > - - - - {t("labelsNotFound")} - - {draftValues.length > 0 && ( - { - setDraftValues([]); - }} - className="text-muted-foreground" - > - {t("accessFilterClear")} - - )} - {labels.map((label) => ( - { - toggle(label.name); - }} - className="flex items-center gap-2" - > - -
- {label.name} - - ))} - - - + draftSet.has(label.name)} + onToggle={(label) => { + toggle(label.name); + }} + showClear={draftValues.length > 0} + onClear={() => { + setDraftValues([]); + }} + />
diff --git a/src/components/LabelsFilterSelector.tsx b/src/components/LabelsFilterSelector.tsx new file mode 100644 index 000000000..3c9410e43 --- /dev/null +++ b/src/components/LabelsFilterSelector.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { launcherQueries, orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { useDebounce } from "use-debounce"; +import { Checkbox } from "./ui/checkbox"; + +export type LabelFilterOption = { + labelId: number; + name: string; + color: string; +}; + +type LabelsFilterSelectorProps = { + orgId: string; + isSelected: (label: LabelFilterOption) => boolean; + onToggle: (label: LabelFilterOption) => void; + onClear?: () => void; + showClear?: boolean; + scope?: "org" | "launcher"; +}; + +export function LabelsFilterSelector({ + orgId, + isSelected, + onToggle, + onClear, + showClear = false, + scope = "org" +}: LabelsFilterSelectorProps) { + const t = useTranslations(); + const [labelSearchQuery, setlabelsSearchQuery] = useState(""); + const [debouncedQuery] = useDebounce(labelSearchQuery, 150); + + const orgLabelsQuery = useQuery({ + ...orgQueries.labels({ + orgId, + query: debouncedQuery, + perPage: 500 + }), + enabled: scope === "org" + }); + const launcherLabelsQuery = useQuery({ + ...launcherQueries.labels({ + orgId, + query: debouncedQuery, + perPage: 500 + }), + enabled: scope === "launcher" + }); + const labels = + scope === "launcher" + ? (launcherLabelsQuery.data ?? []) + : (orgLabelsQuery.data ?? []); + + return ( + + + + {t("labelsNotFound")} + + {showClear && onClear && ( + + {t("accessFilterClear")} + + )} + {labels.map((label) => ( + { + onToggle(label); + }} + className="flex items-center gap-2" + > + +
+ {label.name} + + ))} + + + + ); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index dd0ef3d2f..32d4d620c 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -16,6 +16,8 @@ interface LayoutProps { showHeader?: boolean; showTopBar?: boolean; defaultSidebarCollapsed?: boolean; + launcherMode?: boolean; + showViewAsAdmin?: boolean; } export async function Layout({ @@ -26,7 +28,9 @@ export async function Layout({ showSidebar = true, showHeader = true, showTopBar = true, - defaultSidebarCollapsed = false + defaultSidebarCollapsed = false, + launcherMode = false, + showViewAsAdmin = false }: LayoutProps) { const allCookies = await cookies(); const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value; @@ -64,11 +68,21 @@ export async function Layout({ navItems={navItems} showSidebar={showSidebar} showTopBar={showTopBar} + launcherMode={launcherMode} + showViewAsAdmin={showViewAsAdmin} /> )} {/* Desktop header */} - {showHeader && } + {showHeader && ( + + )} {/* Main content */}
diff --git a/src/components/LayoutHeader.tsx b/src/components/LayoutHeader.tsx index 29850f115..434963882 100644 --- a/src/components/LayoutHeader.tsx +++ b/src/components/LayoutHeader.tsx @@ -8,16 +8,31 @@ import { useTheme } from "next-themes"; import BrandingLogo from "./BrandingLogo"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import { LauncherOrgSelector } from "@app/components/resource-launcher/LauncherOrgSelector"; +import { Button } from "@app/components/ui/button"; +import { useTranslations } from "next-intl"; -interface LayoutHeaderProps { +type LayoutHeaderProps = { showTopBar: boolean; -} + launcherMode?: boolean; + orgId?: string; + orgs?: ListUserOrgsResponse["orgs"]; + showViewAsAdmin?: boolean; +}; -export function LayoutHeader({ showTopBar }: LayoutHeaderProps) { +export function LayoutHeader({ + showTopBar, + launcherMode = false, + orgId, + orgs, + showViewAsAdmin = false +}: LayoutHeaderProps) { const { theme } = useTheme(); const [path, setPath] = useState(""); const { env } = useEnvContext(); const { isUnlocked } = useLicenseStatusContext(); + const t = useTranslations(); const logoWidth = isUnlocked() ? env.branding.logo?.navbar?.width || 98 @@ -53,16 +68,38 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
-
- +
+ - {/* {build === "saas" && ( - Cloud Beta - )} */} + {launcherMode ? ( + <> + + {showViewAsAdmin && orgId ? ( + + ) : null} + + ) : null}
{showTopBar && ( diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx index 13efdd564..b549d1f2e 100644 --- a/src/components/LayoutMobileMenu.tsx +++ b/src/components/LayoutMobileMenu.tsx @@ -6,7 +6,7 @@ import { OrgSelector } from "@app/components/OrgSelector"; import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; import { Button } from "@app/components/ui/button"; -import { ArrowRight, Menu, Server } from "lucide-react"; +import { Menu, Server, Settings, SquareMousePointer } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useUserContext } from "@app/hooks/useUserContext"; @@ -29,6 +29,8 @@ interface LayoutMobileMenuProps { navItems: SidebarNavSection[]; showSidebar: boolean; showTopBar: boolean; + launcherMode?: boolean; + showViewAsAdmin?: boolean; } export function LayoutMobileMenu({ @@ -36,19 +38,33 @@ export function LayoutMobileMenu({ orgs, navItems, showSidebar, - showTopBar + showTopBar, + launcherMode = false, + showViewAsAdmin = false }: LayoutMobileMenuProps) { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const pathname = usePathname(); const isAdminPage = pathname?.startsWith("/admin"); const { user } = useUserContext(); const t = useTranslations(); + const showMobileNav = showSidebar || launcherMode; + const currentOrg = orgs?.find((org) => org.orgId === orgId); + const isSettingsPage = Boolean( + orgId && pathname?.includes(`/${orgId}/settings`) + ); + const canViewResourceLauncher = Boolean( + currentOrg?.isAdmin || currentOrg?.isOwner + ); + + const mobileNavLinkClassName = cn( + "flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md px-3 py-1.5" + ); return (
- {showSidebar && ( + {showMobileNav && (
{t("navbarDescription")} -
-
- -
-
-
-
- {!isAdminPage && - user.serverAdmin && ( + {launcherMode ? ( + <> +
+
+ +
+
+ {showViewAsAdmin && orgId ? ( +
setIsMobileMenuOpen( false @@ -94,25 +110,95 @@ export function LayoutMobileMenu({ } > - + {t( - "serverAdmin" + "resourceLauncherViewAsAdmin" )}
- )} - - setIsMobileMenuOpen(false) - } - /> -
-
-
+
+ ) : null} + + ) : ( + <> +
+
+ +
+
+
+
+ {!isAdminPage && + isSettingsPage && + canViewResourceLauncher && + orgId && ( +
+ + setIsMobileMenuOpen( + false + ) + } + > + + + + + {t( + "resourceLauncherTitle" + )} + + +
+ )} + {!isAdminPage && + user.serverAdmin && ( +
+ + setIsMobileMenuOpen( + false + ) + } + > + + + + + {t( + "serverAdmin" + )} + + +
+ )} + + setIsMobileMenuOpen( + false + ) + } + /> +
+
+
+ + )}
diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index a7c3a141f..a1a7bc412 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -18,7 +18,13 @@ import { approvalQueries } from "@app/lib/queries"; import { build } from "@server/build"; import { useQuery } from "@tanstack/react-query"; import { ListUserOrgsResponse } from "@server/routers/org"; -import { ArrowRight, ExternalLink, PanelRightOpen, Server } from "lucide-react"; +import { + ArrowRight, + ExternalLink, + PanelRightOpen, + Server, + SquareMousePointer +} from "lucide-react"; import { useTranslations } from "next-intl"; import dynamic from "next/dynamic"; import Link from "next/link"; @@ -130,6 +136,13 @@ export function LayoutSidebar({ const showTrial = build === "saas" && Boolean(orgId) && subscriptionContext?.isTrial; + const isSettingsPage = Boolean( + orgId && pathname?.includes(`/${orgId}/settings`) + ); + const canViewResourceLauncher = Boolean( + currentOrg?.isAdmin || currentOrg?.isOwner + ); + return (
+ {!isAdminPage && + isSettingsPage && + canViewResourceLauncher && + orgId && ( +
+ + + + + {!isSidebarCollapsed && ( + + {t("resourceLauncherTitle")} + + )} + +
+ )} {!isAdminPage && user.serverAdmin && (
{ - const [faviconError, setFaviconError] = useState(false); - const [faviconLoaded, setFaviconLoaded] = useState(false); - - // Extract domain for favicon URL - const cleanDomain = domain.replace(/^https?:\/\//, "").split("/")[0]; - const faviconUrl = `https://www.google.com/s2/favicons?domain=${cleanDomain}&sz=32`; - - const handleFaviconLoad = () => { - setFaviconLoaded(true); - setFaviconError(false); - }; - - const handleFaviconError = () => { - setFaviconError(true); - setFaviconLoaded(false); - }; - - if (faviconError || !enabled) { - return ( - - ); - } - - return ( -
- {!faviconLoaded && ( -
- )} - {`${cleanDomain} -
- ); -}; - -// Resource Info component -const ResourceInfo = ({ resource }: { resource: Resource }) => { - const t = useTranslations(); - const hasAuthMethods = - resource.sso || - resource.password || - resource.pincode || - resource.whitelist; - - const hasAnyInfo = - Boolean(resource.siteName) || - Boolean(hasAuthMethods) || - !resource.enabled; - - if (!hasAnyInfo) return null; - - const infoContent = ( -
- {/* Site Information */} - {resource.siteName && ( -
-
- {t("site")} -
-
- - {resource.siteName} -
-
- )} - - {/* Authentication Methods */} - {hasAuthMethods && ( -
-
- {t("memberPortalAuthMethods")} -
-
- {resource.sso && ( -
-
- -
- - {t("memberPortalSso")} - -
- )} - {resource.password && ( -
-
- -
- - {t("memberPortalPasswordProtected")} - -
- )} - {resource.pincode && ( -
-
- -
- - {t("memberPortalPinCode")} - -
- )} - {resource.whitelist && ( -
-
- -
- - {t("memberPortalEmailWhitelist")} - -
- )} -
-
- )} - - {/* Resource Status - if disabled */} - {!resource.enabled && ( -
-
- - - {t("memberPortalResourceDisabled")} - -
-
- )} -
- ); - - return {infoContent}; -}; - -// Pagination component -const PaginationControls = ({ - currentPage, - totalPages, - onPageChange, - totalItems, - itemsPerPage -}: { - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; - totalItems: number; - itemsPerPage: number; -}) => { - const t = useTranslations(); - const startItem = (currentPage - 1) * itemsPerPage + 1; - const endItem = Math.min(currentPage * itemsPerPage, totalItems); - - if (totalPages <= 1) return null; - - return ( -
-
- {t("memberPortalShowingResources", { - start: startItem, - end: endItem, - total: totalItems - })} -
- -
- - -
- {Array.from({ length: totalPages }, (_, i) => i + 1).map( - (page) => { - // Show first page, last page, current page, and 2 pages around current - const showPage = - page === 1 || - page === totalPages || - Math.abs(page - currentPage) <= 1; - - const showEllipsis = - (page === 2 && currentPage > 4) || - (page === totalPages - 1 && - currentPage < totalPages - 3); - - if (!showPage && !showEllipsis) return null; - - if (showEllipsis) { - return ( - - ... - - ); - } - - return ( - - ); - } - )} -
- - -
-
- ); -}; - -// Loading skeleton component -const ResourceCardSkeleton = () => ( - - -
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-); - -export default function MemberResourcesPortal({ - orgId -}: MemberResourcesPortalProps) { - const t = useTranslations(); - const { env } = useEnvContext(); - const api = createApiClient({ env }); - const { toast } = useToast(); - - const [resources, setResources] = useState([]); - const [siteResources, setSiteResources] = useState([]); - const [filteredResources, setFilteredResources] = useState([]); - const [filteredSiteResources, setFilteredSiteResources] = useState< - SiteResource[] - >([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); - const [sortBy, setSortBy] = useState("name-asc"); - const [refreshing, setRefreshing] = useState(false); - - // Pagination state - const [currentPage, setCurrentPage] = useState(1); - const itemsPerPage = 12; // 3x4 grid on desktop - - const fetchUserResources = async (isRefresh = false) => { - try { - if (isRefresh) { - setRefreshing(true); - } else { - setLoading(true); - } - setError(null); - - const response = await api.get( - `/org/${orgId}/user-resources` - ); - - if (response.data.success) { - setResources(response.data.data.resources); - setSiteResources(response.data.data.siteResources || []); - setFilteredResources(response.data.data.resources); - setFilteredSiteResources( - response.data.data.siteResources || [] - ); - } else { - setError(t("memberPortalFailedToLoad")); - } - } catch (err) { - console.error("Error fetching user resources:", err); - setError(t("memberPortalFailedToLoadDescription")); - } finally { - setLoading(false); - setRefreshing(false); - } - }; - - useEffect(() => { - fetchUserResources(); - }, [orgId, api]); - - // Filter and sort resources - useEffect(() => { - const filtered = resources.filter( - (resource) => - resource.name - .toLowerCase() - .includes(searchQuery.toLowerCase()) || - resource.domain - .toLowerCase() - .includes(searchQuery.toLowerCase()) - ); - - // Sort resources - filtered.sort((a, b) => { - switch (sortBy) { - case "name-asc": - return a.name.localeCompare(b.name); - case "name-desc": - return b.name.localeCompare(a.name); - case "domain-asc": - return a.domain.localeCompare(b.domain); - case "domain-desc": - return b.domain.localeCompare(a.domain); - case "status-enabled": - // Enabled first, then protected vs unprotected - if (a.enabled !== b.enabled) return b.enabled ? 1 : -1; - return b.protected ? 1 : -1; - case "status-disabled": - // Disabled first, then unprotected vs protected - if (a.enabled !== b.enabled) return a.enabled ? 1 : -1; - return a.protected ? 1 : -1; - default: - return a.name.localeCompare(b.name); - } - }); - - setFilteredResources(filtered); - - // Filter and sort site resources - const filteredSites = siteResources.filter( - (resource) => - resource.name - .toLowerCase() - .includes(searchQuery.toLowerCase()) || - resource.destination - .toLowerCase() - .includes(searchQuery.toLowerCase()) - ); - - // Sort site resources - filteredSites.sort((a, b) => { - switch (sortBy) { - case "name-asc": - return a.name.localeCompare(b.name); - case "name-desc": - return b.name.localeCompare(a.name); - case "domain-asc": - case "domain-desc": - // Sort by destination for site resources - const destCompare = - sortBy === "domain-asc" - ? a.destination.localeCompare(b.destination) - : b.destination.localeCompare(a.destination); - return destCompare; - case "status-enabled": - return b.enabled ? 1 : -1; - case "status-disabled": - return a.enabled ? 1 : -1; - default: - return a.name.localeCompare(b.name); - } - }); - - setFilteredSiteResources(filteredSites); - - // Reset to first page when search/sort changes - setCurrentPage(1); - }, [resources, siteResources, searchQuery, sortBy]); - - // Calculate pagination - const totalItems = filteredResources.length + filteredSiteResources.length; - const totalPages = Math.ceil(totalItems / itemsPerPage); - const startIndex = (currentPage - 1) * itemsPerPage; - const paginatedResources = filteredResources.slice( - startIndex, - startIndex + itemsPerPage - ); - const remainingSlots = itemsPerPage - paginatedResources.length; - const paginatedSiteResources = - remainingSlots > 0 - ? filteredSiteResources.slice( - Math.max(0, startIndex - filteredResources.length), - Math.max(0, startIndex - filteredResources.length) + - remainingSlots - ) - : []; - - const handleOpenResource = (resource: Resource) => { - // Open the resource in a new tab - window.open(resource.domain, "_blank"); - }; - - const handleRefresh = () => { - fetchUserResources(true); - }; - - const handleRetry = () => { - fetchUserResources(); - }; - - const handlePageChange = (page: number) => { - setCurrentPage(page); - // Scroll to top when page changes - window.scrollTo({ top: 0, behavior: "smooth" }); - }; - - if (loading) { - return ( -
- - - {/* Search and Sort Controls - Skeleton */} -
-
-
-
-
-
-
-
- - {/* Loading Skeletons */} -
- {Array.from({ length: 12 }).map((_, index) => ( - - ))} -
-
- ); - } - - if (error) { - return ( -
- - - -
- -
-

- {t("memberPortalUnableToLoad")} -

-

- {error} -

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

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

-

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

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

- - {t("memberPortalPublicResources")} -

-

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

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

- { - resource.name - } -

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

- - {t("memberPortalPrivateResources")} -

-

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

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

- { - siteResource.name - } -

-
-
-
-
- -
- - {siteResource.mode.toUpperCase()} - - -
-
- {t( - "memberPortalResourceDetails" - )} -
-
- - {t( - "memberPortalMode" - )} - : - - - {siteResource.mode.toUpperCase()} - -
- {siteResource.destination && ( -
- - {t( - "memberPortalDestination" - )} - : - - - { - siteResource.destination - } - -
- )} - {siteResource.alias && ( -
- - {t( - "memberPortalAlias" - )} - : - - - { - siteResource.alias - } - -
- )} -
- - {t( - "status" - )} - : - - - {siteResource.enabled - ? t( - "enabled" - ) - : t( - "disabled" - )} - -
-
-
-
-
- -
- {siteResource.mode === "http" && - siteResource.fullDomain ? ( - /* HTTP mode - show as clickable link */ - - ) : siteResource.alias ? ( - /* Alias as primary */ -
-
- {siteResource.alias} -
- -
- ) : siteResource.destination ? ( - /* Destination as primary when no alias */ -
-
- { - siteResource.destination - } -
- -
- ) : ( - /* niceId fallback when no alias and no destination */ -
-
- { - siteResource.niceId - } -
- -
- )} -
-
- -
- {siteResource.mode === "http" && - siteResource.fullDomain ? ( - - ) : null} -
- - {t( - "memberPortalRequiresClientConnection" - )} -
-
-
- ))} -
- - )} - - {/* Pagination Controls */} - - - )} -
- ); -} diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index 2a4d63e85..ff854a1a8 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -186,11 +186,11 @@ export default function PrivateResourcesTable({ }); }); } catch (e) { - console.error(t("resourceErrorDelete"), e); + console.error(t("resourceErrorDelte"), e); toast({ variant: "destructive", title: t("resourceErrorDelte"), - description: formatAxiosError(e, t("v")) + description: formatAxiosError(e, t("resourceErrorDelte")) }); } }; diff --git a/src/components/RedirectToOrg.tsx b/src/components/RedirectToOrg.tsx index e647ee7a1..02ad97773 100644 --- a/src/components/RedirectToOrg.tsx +++ b/src/components/RedirectToOrg.tsx @@ -6,20 +6,29 @@ import { getInternalRedirectTarget } from "@app/lib/internalRedirect"; type RedirectToOrgProps = { targetOrgId: string; + isAdminOrOwner?: boolean; }; -export default function RedirectToOrg({ targetOrgId }: RedirectToOrgProps) { +export default function RedirectToOrg({ + targetOrgId, + isAdminOrOwner = false +}: RedirectToOrgProps) { const router = useRouter(); useEffect(() => { try { const target = - getInternalRedirectTarget(targetOrgId) ?? `/${targetOrgId}`; + getInternalRedirectTarget(targetOrgId) ?? + (isAdminOrOwner + ? `/${targetOrgId}/settings` + : `/${targetOrgId}`); router.replace(target); } catch { - router.replace(`/${targetOrgId}`); + router.replace( + isAdminOrOwner ? `/${targetOrgId}/settings` : `/${targetOrgId}` + ); } - }, [targetOrgId, router]); + }, [targetOrgId, isAdminOrOwner, router]); return null; } diff --git a/src/components/ResourceInfoBox.tsx b/src/components/ResourceInfoBox.tsx index f25342263..f459d2c38 100644 --- a/src/components/ResourceInfoBox.tsx +++ b/src/components/ResourceInfoBox.tsx @@ -90,7 +90,11 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { - {resource.ssl ? "HTTPS" : "HTTP"} + {resource.mode == "http" + ? resource.ssl + ? "HTTPS" + : "HTTP" + : resource.mode?.toUpperCase()} diff --git a/src/components/SidePanel.tsx b/src/components/SidePanel.tsx new file mode 100644 index 000000000..969eebcfc --- /dev/null +++ b/src/components/SidePanel.tsx @@ -0,0 +1,164 @@ +"use client"; + +import * as React from "react"; + +import { useMediaQuery } from "@app/hooks/useMediaQuery"; +import { cn } from "@app/lib/cn"; +import { + Sheet, + SheetClose, + SheetDescription, + SheetFooter, + SheetHeader, + SheetOverlay, + SheetPortal, + SheetTitle, + SheetTrigger +} from "./ui/sheet"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; + +type BaseProps = { + children: React.ReactNode; +}; + +type RootSidePanelProps = BaseProps & { + open?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +type SidePanelProps = { + className?: string; + asChild?: true; + children?: React.ReactNode; +}; + +const desktop = "(min-width: 768px)"; + +const SidePanel = ({ children, ...props }: RootSidePanelProps) => { + return {children}; +}; + +const SidePanelTrigger = ({ + className, + children, + ...props +}: SidePanelProps) => { + return ( + + {children} + + ); +}; + +const SidePanelClose = ({ className, children, ...props }: SidePanelProps) => { + return ( + + {children} + + ); +}; + +const SidePanelContent = ({ + className, + children, + ...props +}: SidePanelProps) => { + const isDesktop = useMediaQuery(desktop); + + return ( + + + e.preventDefault()} + > + {children} + + + ); +}; + +const SidePanelDescription = ({ + className, + children, + ...props +}: SidePanelProps) => { + return ( + + {children} + + ); +}; + +const SidePanelHeader = ({ className, children, ...props }: SidePanelProps) => { + return ( + + {children} + + ); +}; + +const SidePanelTitle = ({ className, children, ...props }: SidePanelProps) => { + return ( + + {children} + + ); +}; + +const SidePanelBody = ({ className, children, ...props }: SidePanelProps) => { + return ( +
+
{children}
+
+
+ ); +}; + +const SidePanelFooter = ({ className, children, ...props }: SidePanelProps) => { + return ( + + {children} + + ); +}; + +export { + SidePanel, + SidePanelBody, + SidePanelClose, + SidePanelContent, + SidePanelDescription, + SidePanelFooter, + SidePanelHeader, + SidePanelTitle, + SidePanelTrigger +}; diff --git a/src/components/SiteInfoCard.tsx b/src/components/SiteInfoCard.tsx index a5e639c28..8089e71b2 100644 --- a/src/components/SiteInfoCard.tsx +++ b/src/components/SiteInfoCard.tsx @@ -54,7 +54,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) { {t("publicIpEndpoint")} {formatPublicEndpoint(site.endpoint)}  - + {site.countryCode && countryCodeToFlagEmoji(site.countryCode)} diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 3dc7a56da..efcf83e72 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -107,6 +107,7 @@ export default function SitesTable({ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [deleteWithResources, setDeleteWithResources] = useState(false); const [selectedSite, setSelectedSite] = useState(null); + const [restartingSite, setRestartingSite] = useState(null); const [resourcesDialogSite, setResourcesDialogSite] = useState(null); const [isRefreshing, startTransition] = useTransition(); @@ -159,6 +160,24 @@ export default function SitesTable({ }); } + async function restartSite(siteId: number) { + try { + await api.post(`/site/${siteId}/restart`); + toast({ + title: t("siteRestarted"), + description: t("siteRestartedDescription") + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("siteErrorRestart"), + description: formatAxiosError(e, t("siteErrorRestartDescription")) + }); + } finally { + setRestartingSite(null); + } + } + function deleteSite(siteId: number, withResources: boolean) { startTransition(async () => { await api @@ -526,6 +545,20 @@ export default function SitesTable({ + {siteRow.type === "newt" && ( + <> + + setRestartingSite(siteRow) + } + > + + {t("siteRestartButton")} + + + + + )} { setSelectedSite(siteRow); @@ -654,6 +687,28 @@ export default function SitesTable({ + {restartingSite && ( + { + if (!val) setRestartingSite(null); + }} + dialog={ +

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

+ } + buttonText={t("siteRestartButton")} + onConfirm={() => restartSite(restartingSite.id)} + string={restartingSite.name} + warningText={t("siteRestartWarning")} + title={t("siteRestartTitle")} + /> + )} + {selectedSite && ( void; }; +export function formatLabelsSelectorLabel( + selectedLabels: SelectedLabel[], + t: (key: string, values?: { count: number }) => string +): string { + if (selectedLabels.length === 0) { + return t("selectLabels"); + } + if (selectedLabels.length === 1) { + return selectedLabels[0]!.name; + } + return t("labelsSelectorLabelsCount", { + count: selectedLabels.length + }); +} + export const LABEL_COLORS = { red: "#ff6467", green: "#05df72", diff --git a/src/components/multi-select/multi-select-content.tsx b/src/components/multi-select/multi-select-content.tsx index 15b23827f..659e16d5c 100644 --- a/src/components/multi-select/multi-select-content.tsx +++ b/src/components/multi-select/multi-select-content.tsx @@ -12,7 +12,12 @@ import { CheckIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { Checkbox } from "../ui/checkbox"; -export type TagValue = { text: string; id: string; isAdmin?: boolean }; +export type TagValue = { + text: string; + id: string; + isAdmin?: boolean; + color?: string; +}; export type MultiSelectTagsProps = { emptyPlaceholder?: string; @@ -77,6 +82,14 @@ export function MultiSelectContent({ aria-hidden tabIndex={-1} /> + {option.color && ( + + )} {`${option.text}`} ); diff --git a/src/components/multi-select/multi-select-tag-input.tsx b/src/components/multi-select/multi-select-tag-input.tsx index bde1a9b05..dc75311c2 100644 --- a/src/components/multi-select/multi-select-tag-input.tsx +++ b/src/components/multi-select/multi-select-tag-input.tsx @@ -66,7 +66,17 @@ export function MultiSelectTagInput({ )} onClick={(e) => e.stopPropagation()} > - {option.text} + {option.color && ( + + )} + + {option.text} + {isLocked ? ( diff --git a/src/components/multi-site-selector.tsx b/src/components/multi-site-selector.tsx index acb8b7dd9..fe81dc697 100644 --- a/src/components/multi-site-selector.tsx +++ b/src/components/multi-site-selector.tsx @@ -1,4 +1,4 @@ -import { orgQueries } from "@app/lib/queries"; +import { launcherQueries, orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import { @@ -19,6 +19,7 @@ export type MultiSitesSelectorProps = { selectedSites: Selectedsite[]; onSelectionChange: (sites: Selectedsite[]) => void; filterTypes?: string[]; + scope?: "org" | "launcher"; }; export function formatMultiSitesSelectorLabel( @@ -40,19 +41,33 @@ export function MultiSitesSelector({ orgId, selectedSites, onSelectionChange, - filterTypes + filterTypes, + scope = "org" }: MultiSitesSelectorProps) { const t = useTranslations(); const [siteSearchQuery, setSiteSearchQuery] = useState(""); const [debouncedQuery] = useDebounce(siteSearchQuery, 150); - const { data: sites = [] } = useQuery( - orgQueries.sites({ + const orgSitesQuery = useQuery({ + ...orgQueries.sites({ orgId, query: debouncedQuery, perPage: 10 - }) - ); + }), + enabled: scope === "org" + }); + const launcherSitesQuery = useQuery({ + ...launcherQueries.sites({ + orgId, + query: debouncedQuery, + perPage: 500 + }), + enabled: scope === "launcher" + }); + const sites = + scope === "launcher" + ? (launcherSitesQuery.data ?? []) + : (orgSitesQuery.data ?? []); const sitesShown = useMemo(() => { const base = filterTypes diff --git a/src/components/resource-launcher/LauncherCopyIcon.tsx b/src/components/resource-launcher/LauncherCopyIcon.tsx new file mode 100644 index 000000000..bfaf21d04 --- /dev/null +++ b/src/components/resource-launcher/LauncherCopyIcon.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { cn } from "@app/lib/cn"; +import { Check, Copy } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; + +type LauncherCopyIconProps = { + text: string; + className?: string; +}; + +export function LauncherCopyIcon({ text, className }: LauncherCopyIconProps) { + const t = useTranslations(); + const [copied, setCopied] = useState(false); + + if (!text) { + return null; + } + + return ( + + ); +} diff --git a/src/components/resource-launcher/LauncherEmptyState.tsx b/src/components/resource-launcher/LauncherEmptyState.tsx new file mode 100644 index 000000000..193ffae78 --- /dev/null +++ b/src/components/resource-launcher/LauncherEmptyState.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { cn } from "@app/lib/cn"; +import { LayoutGrid, SearchX } from "lucide-react"; +import { useTranslations } from "next-intl"; + +type LauncherEmptyStateVariant = "empty" | "noResults"; + +type LauncherEmptyStateProps = { + variant: LauncherEmptyStateVariant; + layout: "grid" | "list"; + query?: string; + onClearFilters?: () => void; +}; + +function GhostResourceGrid() { + return ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} + +function GhostResourceList() { + return ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} + +export function LauncherEmptyState({ + variant, + layout, + query, + onClearFilters +}: LauncherEmptyStateProps) { + const t = useTranslations(); + const isNoResults = variant === "noResults"; + const Icon = isNoResults ? SearchX : LayoutGrid; + const trimmedQuery = query?.trim(); + + return ( +
+
+ {layout === "grid" ? ( + + ) : ( + + )} +
+
+
+ +
+
+

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

+

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

+
+ {isNoResults && onClearFilters ? ( + + ) : null} +
+
+ ); +} diff --git a/src/components/resource-launcher/LauncherFilterPopover.tsx b/src/components/resource-launcher/LauncherFilterPopover.tsx new file mode 100644 index 000000000..5a0e425f9 --- /dev/null +++ b/src/components/resource-launcher/LauncherFilterPopover.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { + formatMultiSitesSelectorLabel, + MultiSitesSelector +} from "@app/components/multi-site-selector"; +import { + formatLabelsSelectorLabel, + LABEL_COLORS, + type SelectedLabel +} from "@app/components/labels-selector"; +import { LabelsFilterSelector } from "@app/components/LabelsFilterSelector"; +import { Button } from "@app/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { cn } from "@app/lib/cn"; +import { launcherQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { ChevronsUpDown, Funnel } from "lucide-react"; +import { useMemo, useState } from "react"; +import type { Selectedsite } from "@app/components/site-selector"; + +type LauncherFilterPopoverProps = { + orgId: string; + selectedSites: Selectedsite[]; + selectedLabels: SelectedLabel[]; + onSitesChange: (sites: Selectedsite[]) => void; + onLabelsChange: (labels: SelectedLabel[]) => void; +}; + +export function LauncherFilterPopover({ + orgId, + selectedSites, + selectedLabels, + onSitesChange, + onLabelsChange +}: LauncherFilterPopoverProps) { + const t = useTranslations(); + const [sitesOpen, setSitesOpen] = useState(false); + const [labelsOpen, setLabelsOpen] = useState(false); + + const { data: labels = [] } = useQuery( + launcherQueries.labels({ + orgId, + perPage: 500 + }) + ); + + const { data: sites = [] } = useQuery( + launcherQueries.sites({ + orgId, + perPage: 500 + }) + ); + + const resolvedSelectedSites: Selectedsite[] = useMemo( + () => + selectedSites.map((selected) => { + const found = sites.find( + (site) => site.siteId === selected.siteId + ); + return found + ? { + siteId: found.siteId, + name: found.name, + type: found.type, + online: found.online + } + : selected; + }), + [sites, selectedSites] + ); + + const selectedLabelIds = useMemo( + () => new Set(selectedLabels.map((label) => label.labelId)), + [selectedLabels] + ); + + const resolvedSelectedLabels: SelectedLabel[] = useMemo( + () => + selectedLabels.map((selected) => { + const found = labels.find( + (label) => label.labelId === selected.labelId + ); + return ( + found ?? { + ...selected, + color: selected.color || LABEL_COLORS.gray + } + ); + }), + [labels, selectedLabels] + ); + + return ( + + + + + +
+
+

{t("sites")}

+ + + + + + + + +
+
+

{t("labels")}

+ + + + + + + selectedLabelIds.has(label.labelId) + } + onToggle={(label) => { + if ( + selectedLabelIds.has(label.labelId) + ) { + onLabelsChange( + selectedLabels.filter( + (item) => + item.labelId !== + label.labelId + ) + ); + } else { + onLabelsChange([ + ...selectedLabels, + label + ]); + } + }} + showClear={selectedLabels.length > 0} + onClear={() => { + onLabelsChange([]); + }} + /> + + +
+
+
+
+ ); +} diff --git a/src/components/resource-launcher/LauncherGroupList.tsx b/src/components/resource-launcher/LauncherGroupList.tsx new file mode 100644 index 000000000..c7fe6b905 --- /dev/null +++ b/src/components/resource-launcher/LauncherGroupList.tsx @@ -0,0 +1,146 @@ +"use client"; + +import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; +import { launcherQueries } from "@app/lib/queries"; +import type { + LauncherGroup, + LauncherResource, + LauncherViewConfig +} from "@server/routers/launcher/types"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { useEffect, useMemo, useRef } from "react"; +import { LauncherEmptyState } from "./LauncherEmptyState"; +import { LauncherGroupSection } from "./LauncherGroupSection"; + +type LauncherGroupListProps = { + orgId: string; + activeViewId: LauncherActiveViewId; + config: LauncherViewConfig; + initialGroups: LauncherGroup[]; + groupsPagination: { + total: number; + page: number; + pageSize: number; + }; + onClearFilters?: () => void; + onResourceSelect?: (resource: LauncherResource) => void; +}; + +function hasActiveLauncherFilters(config: LauncherViewConfig): boolean { + return ( + config.query.trim().length > 0 || + config.siteIds.length > 0 || + config.labelIds.length > 0 + ); +} + +export function LauncherGroupList({ + orgId, + activeViewId, + config, + initialGroups, + groupsPagination, + onClearFilters, + onResourceSelect +}: LauncherGroupListProps) { + const loadMoreRef = useRef(null); + + const groupFilters = useMemo( + () => ({ + query: config.query, + groupBy: config.groupBy, + siteIds: config.siteIds, + labelIds: config.labelIds, + sort_by: config.sortBy, + order: config.order, + pageSize: 20 + }), + [ + config.groupBy, + config.labelIds, + config.order, + config.query, + config.siteIds, + config.sortBy + ] + ); + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } = + useInfiniteQuery({ + ...launcherQueries.groups(orgId, groupFilters), + initialData: { + pages: [ + { + groups: initialGroups, + pagination: groupsPagination + } + ], + pageParams: [1] + }, + refetchOnMount: false + }); + + const groups = data?.pages.flatMap((page) => page.groups) ?? []; + + useEffect(() => { + const node = loadMoreRef.current; + if (!node || !hasNextPage) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && !isFetchingNextPage) { + void fetchNextPage(); + } + }, + { rootMargin: "200px" } + ); + + observer.observe(node); + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + + if (groups.length === 0) { + if (isFetching) { + return ( +
+ +
+ ); + } + + return ( + + ); + } + + return ( +
+ {groups.map((group) => ( + + ))} +
+ {isFetchingNextPage ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/src/components/resource-launcher/LauncherGroupSection.tsx b/src/components/resource-launcher/LauncherGroupSection.tsx new file mode 100644 index 000000000..49b648032 --- /dev/null +++ b/src/components/resource-launcher/LauncherGroupSection.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { + Collapsible, + CollapsibleContent +} from "@app/components/ui/collapsible"; +import { cn } from "@app/lib/cn"; +import { + readLauncherGroupOpen, + writeLauncherGroupOpen, + type LauncherActiveViewId +} from "@app/lib/launcherLocalStorage"; +import { launcherQueries } from "@app/lib/queries"; +import type { + LauncherGroup, + LauncherResource, + LauncherViewConfig +} from "@server/routers/launcher/types"; +import { + LAUNCHER_NO_SITE_GROUP_KEY, + LAUNCHER_UNLABELED_GROUP_KEY +} from "@server/routers/launcher/types"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useRef, useState } from "react"; +import { LauncherGroupTrigger } from "./LauncherGroupTrigger"; +import { LauncherResourceGrid } from "./LauncherResourceGrid"; +import { LauncherResourceList } from "./LauncherResourceList"; + +type LauncherGroupSectionProps = { + orgId: string; + activeViewId: LauncherActiveViewId; + group: LauncherGroup; + config: LauncherViewConfig; + initialResources?: LauncherResource[]; + initialResourcesPagination?: { + total: number; + page: number; + pageSize: number; + }; + defaultOpen?: boolean; + onResourceSelect?: (resource: LauncherResource) => void; +}; + +export function LauncherGroupSection({ + orgId, + activeViewId, + group, + config, + initialResources, + initialResourcesPagination, + defaultOpen = true, + onResourceSelect +}: LauncherGroupSectionProps) { + const t = useTranslations(); + const loadMoreRef = useRef(null); + const [isOpen, setIsOpen] = useState(() => + readLauncherGroupOpen( + orgId, + activeViewId, + config.groupBy, + group.groupKey, + defaultOpen + ) + ); + + useEffect(() => { + setIsOpen( + readLauncherGroupOpen( + orgId, + activeViewId, + config.groupBy, + group.groupKey, + defaultOpen + ) + ); + }, [activeViewId, config.groupBy, defaultOpen, group.groupKey, orgId]); + + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + writeLauncherGroupOpen( + orgId, + activeViewId, + config.groupBy, + group.groupKey, + open + ); + }; + + const hasInitialResources = initialResources !== undefined; + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + useInfiniteQuery({ + ...launcherQueries.resources(orgId, { + query: config.query, + groupBy: config.groupBy, + groupKey: group.groupKey, + siteIds: config.siteIds, + labelIds: config.labelIds, + sort_by: config.sortBy, + order: config.order, + pageSize: 20 + }), + enabled: isOpen, + refetchOnMount: false, + ...(hasInitialResources + ? { + initialData: { + pages: [ + { + resources: initialResources, + pagination: initialResourcesPagination ?? { + total: initialResources.length, + page: 1, + pageSize: 20 + } + } + ], + pageParams: [1] + } + } + : {}) + }); + + const resources = data?.pages.flatMap((page) => page.resources) ?? []; + const showInitialLoader = isLoading && resources.length === 0; + + useEffect(() => { + const node = loadMoreRef.current; + if (!node || !hasNextPage || !isOpen) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && !isFetchingNextPage) { + void fetchNextPage(); + } + }, + { rootMargin: "200px" } + ); + + observer.observe(node); + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage, isOpen]); + + const groupTitle = + group.groupKey === LAUNCHER_UNLABELED_GROUP_KEY + ? t("resourceLauncherUnlabeled") + : group.groupKey === LAUNCHER_NO_SITE_GROUP_KEY + ? t("resourceLauncherNoSite") + : group.name; + + return ( + + + + + {showInitialLoader ? ( +
+ +
+ ) : resources.length === 0 ? ( +

+ {t("resourceLauncherNoResourcesInGroup")} +

+ ) : config.layout === "grid" ? ( + + ) : ( + + )} +
+ {isFetchingNextPage ? ( +
+ +
+ ) : null} + + + ); +} diff --git a/src/components/resource-launcher/LauncherGroupTrigger.tsx b/src/components/resource-launcher/LauncherGroupTrigger.tsx new file mode 100644 index 000000000..a43536fd5 --- /dev/null +++ b/src/components/resource-launcher/LauncherGroupTrigger.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { CollapsibleTrigger } from "@app/components/ui/collapsible"; +import type { LauncherGroup } from "@server/routers/launcher/types"; +import { ChevronDown, ChevronLeft } from "lucide-react"; + +type LauncherGroupTriggerProps = { + group: LauncherGroup; + title: string; + isOpen: boolean; +}; + +function LauncherGroupStatusDot({ group }: { group: LauncherGroup }) { + if (group.groupType === "label") { + return ( + + ); + } + + if (group.groupType === "site") { + if ( + (group.siteType === "newt" || group.siteType === "wireguard") && + typeof group.siteOnline === "boolean" + ) { + return ( + + ); + } + + return ; + } + + return null; +} + +export function LauncherGroupTrigger({ + group, + title, + isOpen +}: LauncherGroupTriggerProps) { + return ( + + {group.groupType === "site" || group.groupType === "label" ? ( + + ) : null} + + + {title} ({group.itemCount}) + + {isOpen ? ( + + ) : ( + + )} + + + ); +} diff --git a/src/components/resource-launcher/LauncherLabelsRow.tsx b/src/components/resource-launcher/LauncherLabelsRow.tsx new file mode 100644 index 000000000..e2c913ae9 --- /dev/null +++ b/src/components/resource-launcher/LauncherLabelsRow.tsx @@ -0,0 +1,175 @@ +"use client"; + +import type { LauncherLabel } from "@server/routers/launcher/types"; +import { LabelBadge } from "@app/components/label-badge"; +import { LabelOverflowBadge } from "@app/components/label-overflow-badge"; +import { cn } from "@app/lib/cn"; +import { useLayoutEffect, useRef, useState } from "react"; + +const MAX_LABEL_ROWS = 2; +const SINGLE_ROW_MAX_LABELS = 5; + +type LauncherLabelsRowProps = { + labels: LauncherLabel[]; + className?: string; + variant?: "wrap" | "single-row"; +}; + +function countFlexRows(container: HTMLElement): number { + const rowTops = new Set(); + + for (const child of container.children) { + const element = child as HTMLElement; + if (element.style.display === "none") { + continue; + } + rowTops.add(element.offsetTop); + } + + return rowTops.size; +} + +export function LauncherLabelsRow({ + labels, + className, + variant = "wrap" +}: LauncherLabelsRowProps) { + const containerRef = useRef(null); + const measureRef = useRef(null); + const [visibleCount, setVisibleCount] = useState(labels.length); + + const labelKey = labels.map((label) => label.labelId).join(","); + + useLayoutEffect(() => { + if (variant === "single-row") { + return; + } + + const container = containerRef.current; + const measure = measureRef.current; + if (!container || !measure || labels.length === 0) { + return; + } + + const recompute = () => { + const width = container.clientWidth; + if (width <= 0) { + setVisibleCount(labels.length); + return; + } + + measure.style.width = `${width}px`; + + const labelNodes = measure.querySelectorAll( + "[data-measure-label]" + ); + const overflowNode = measure.querySelector( + "[data-measure-overflow]" + ); + + const fits = (visible: number) => { + labelNodes.forEach((node, index) => { + node.style.display = index < visible ? "" : "none"; + }); + + if (overflowNode) { + const overflowCount = labels.length - visible; + if (overflowCount > 0) { + overflowNode.style.display = ""; + } else { + overflowNode.style.display = "none"; + } + } + + return countFlexRows(measure) <= MAX_LABEL_ROWS; + }; + + let best = 0; + for (let visible = labels.length; visible >= 0; visible--) { + if (fits(visible)) { + best = visible; + break; + } + } + + setVisibleCount(best); + }; + + recompute(); + + const observer = new ResizeObserver(recompute); + observer.observe(container); + + return () => observer.disconnect(); + }, [labelKey, labels, variant]); + + if (labels.length === 0) { + return null; + } + + const resolvedVisibleCount = + variant === "single-row" + ? Math.min(labels.length, SINGLE_ROW_MAX_LABELS) + : visibleCount; + const visibleLabels = labels.slice(0, resolvedVisibleCount); + const overflowLabels = labels.slice(resolvedVisibleCount); + + return ( +
+
+ {visibleLabels.map((label) => ( + + ))} + {overflowLabels.length > 0 ? ( + ({ + color: label.color, + name: label.name + }))} + displayOnly + className="shrink-0" + /> + ) : null} +
+ + {variant === "wrap" ? ( +
+ {labels.map((label) => ( + + + + ))} + + + +
+ ) : null} +
+ ); +} diff --git a/src/components/resource-launcher/LauncherOrgSelector.tsx b/src/components/resource-launcher/LauncherOrgSelector.tsx new file mode 100644 index 000000000..795e21927 --- /dev/null +++ b/src/components/resource-launcher/LauncherOrgSelector.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { cn } from "@app/lib/cn"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import { Check, ChevronDown, ChevronsUpDown } from "lucide-react"; +import { usePathname, useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@app/components/ui/button"; + +type LauncherOrgSelectorProps = { + orgId?: string; + orgs?: ListUserOrgsResponse["orgs"]; +}; + +export function LauncherOrgSelector({ orgId, orgs }: LauncherOrgSelectorProps) { + const [open, setOpen] = useState(false); + const router = useRouter(); + const pathname = usePathname(); + const t = useTranslations(); + + const selectedOrg = orgs?.find((org) => org.orgId === orgId); + + const sortedOrgs = useMemo(() => { + if (!orgs?.length) { + return orgs ?? []; + } + return [...orgs].sort((a, b) => { + const aPrimary = Boolean(a.isPrimaryOrg); + const bPrimary = Boolean(b.isPrimaryOrg); + if (aPrimary && !bPrimary) { + return -1; + } + if (!aPrimary && bPrimary) { + return 1; + } + return 0; + }); + }, [orgs]); + + return ( + + + + + + + + + {t("orgNotFound2")} + + {sortedOrgs.map((org) => ( + { + setOpen(false); + const newPath = pathname.includes( + "/settings/" + ) + ? pathname.replace( + /^\/[^/]+/, + `/${org.orgId}` + ) + : `/${org.orgId}`; + router.push(newPath); + }} + > +
+ + {org.name} + + + {org.orgId} + +
+ +
+ ))} +
+
+
+
+
+ ); +} diff --git a/src/components/resource-launcher/LauncherRefreshButton.tsx b/src/components/resource-launcher/LauncherRefreshButton.tsx new file mode 100644 index 000000000..854128736 --- /dev/null +++ b/src/components/resource-launcher/LauncherRefreshButton.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { useTranslations } from "next-intl"; +import { RefreshCw } from "lucide-react"; + +type LauncherRefreshButtonProps = { + onRefresh: () => void; + isRefreshing: boolean; +}; + +export function LauncherRefreshButton({ + onRefresh, + isRefreshing +}: LauncherRefreshButtonProps) { + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/components/resource-launcher/LauncherResourceAccess.tsx b/src/components/resource-launcher/LauncherResourceAccess.tsx new file mode 100644 index 000000000..81a867e17 --- /dev/null +++ b/src/components/resource-launcher/LauncherResourceAccess.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess"; +import Link from "next/link"; +import { LauncherCopyIcon } from "./LauncherCopyIcon"; + +type LauncherResourceAccessProps = { + accessDisplay: string; + accessCopyValue: string; + accessUrl?: string | null; + variant: "grid" | "list"; +}; + +export function LauncherResourceAccess({ + accessDisplay, + accessCopyValue, + accessUrl, + variant +}: LauncherResourceAccessProps) { + if (!accessDisplay) { + return null; + } + + const href = accessUrl ?? undefined; + const canLink = href && isSafeUrlForLink(href); + const copyValue = canLink ? href : accessCopyValue; + + if (variant === "list") { + return ( +
+ {canLink ? ( + + {accessDisplay} + + ) : ( + + {accessDisplay} + + )} + +
+ ); + } + + return ( +
+ {canLink ? ( + + {accessDisplay} + + ) : ( + + {accessDisplay} + + )} + +
+ ); +} diff --git a/src/components/resource-launcher/LauncherResourceCard.tsx b/src/components/resource-launcher/LauncherResourceCard.tsx new file mode 100644 index 000000000..ac891e84b --- /dev/null +++ b/src/components/resource-launcher/LauncherResourceCard.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { cn } from "@app/lib/cn"; +import type { LauncherResource } from "@server/routers/launcher/types"; +import { LauncherLabelsRow } from "./LauncherLabelsRow"; +import { LauncherResourceAccess } from "./LauncherResourceAccess"; +import { LauncherResourceIcon } from "./LauncherResourceIcon"; +import { getLauncherResourceSelectProps } from "./useLauncherResourceAction"; + +type LauncherResourceCardProps = { + resource: LauncherResource; + showLabels: boolean; + onSelect?: () => void; +}; + +export function LauncherResourceCard({ + resource, + showLabels, + onSelect +}: LauncherResourceCardProps) { + const hasIcon = Boolean(resource.iconUrl); + const clickProps = onSelect + ? getLauncherResourceSelectProps(onSelect) + : null; + + return ( +
+
+ {hasIcon ? ( + + ) : null} + +
+
+ {resource.name} +
+ +
+
+ + {showLabels && resource.labels.length > 0 ? ( + + ) : null} +
+ ); +} diff --git a/src/components/resource-launcher/LauncherResourceGrid.tsx b/src/components/resource-launcher/LauncherResourceGrid.tsx new file mode 100644 index 000000000..0e69bee04 --- /dev/null +++ b/src/components/resource-launcher/LauncherResourceGrid.tsx @@ -0,0 +1,33 @@ +"use client"; + +import type { LauncherResource } from "@server/routers/launcher/types"; +import { LauncherResourceCard } from "./LauncherResourceCard"; + +type LauncherResourceGridProps = { + resources: LauncherResource[]; + showLabels: boolean; + onResourceSelect?: (resource: LauncherResource) => void; +}; + +export function LauncherResourceGrid({ + resources, + showLabels, + onResourceSelect +}: LauncherResourceGridProps) { + return ( +
+ {resources.map((resource) => ( + onResourceSelect(resource) + : undefined + } + /> + ))} +
+ ); +} diff --git a/src/components/resource-launcher/LauncherResourceIcon.tsx b/src/components/resource-launcher/LauncherResourceIcon.tsx new file mode 100644 index 000000000..a4abbd63f --- /dev/null +++ b/src/components/resource-launcher/LauncherResourceIcon.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { cn } from "@app/lib/cn"; + +type LauncherResourceIconProps = { + iconUrl?: string | null; + name: string; + className?: string; + variant?: "grid" | "list"; +}; + +export function LauncherResourceIcon({ + iconUrl, + name, + className, + variant = "grid" +}: LauncherResourceIconProps) { + const dimension = variant === "list" ? "size-5" : "size-10"; + + if (iconUrl) { + return ( + {name} + ); + } + + if (variant === "list") { + return ( +
+ - +
+ ); + } + + return null; +} diff --git a/src/components/resource-launcher/LauncherResourceList.tsx b/src/components/resource-launcher/LauncherResourceList.tsx new file mode 100644 index 000000000..555d112a3 --- /dev/null +++ b/src/components/resource-launcher/LauncherResourceList.tsx @@ -0,0 +1,36 @@ +"use client"; + +import type { LauncherResource } from "@server/routers/launcher/types"; +import { LauncherResourceRow } from "./LauncherResourceRow"; + +type LauncherResourceListProps = { + resources: LauncherResource[]; + showLabels: boolean; + onResourceSelect?: (resource: LauncherResource) => void; +}; + +export function LauncherResourceList({ + resources, + showLabels, + onResourceSelect +}: LauncherResourceListProps) { + return ( +
+
+ {resources.map((resource, index) => ( + onResourceSelect(resource) + : undefined + } + /> + ))} +
+
+ ); +} diff --git a/src/components/resource-launcher/LauncherResourcePanel.tsx b/src/components/resource-launcher/LauncherResourcePanel.tsx new file mode 100644 index 000000000..1bb61bd9e --- /dev/null +++ b/src/components/resource-launcher/LauncherResourcePanel.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { + SidePanel, + SidePanelBody, + SidePanelContent, + SidePanelDescription, + SidePanelFooter, + SidePanelHeader, + SidePanelTitle +} from "@app/components/SidePanel"; +import { Button } from "@app/components/ui/button"; +import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref"; +import type { LauncherResource } from "@server/routers/launcher/types"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; + +type LauncherResourcePanelProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + resource: LauncherResource | null; + orgId: string; + isAdmin: boolean; +}; + +export function LauncherResourcePanel({ + open, + onOpenChange, + resource, + orgId, + isAdmin +}: LauncherResourcePanelProps) { + const t = useTranslations(); + + return ( + + + + {resource?.name ?? ""} + + {t("resourceLauncherResourceDetailsDescription")} + + + + + + {isAdmin && resource ? ( + + ) : null} + + + + ); +} diff --git a/src/components/resource-launcher/LauncherResourceRow.tsx b/src/components/resource-launcher/LauncherResourceRow.tsx new file mode 100644 index 000000000..eca882c56 --- /dev/null +++ b/src/components/resource-launcher/LauncherResourceRow.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { cn } from "@app/lib/cn"; +import type { LauncherResource } from "@server/routers/launcher/types"; +import { LauncherLabelsRow } from "./LauncherLabelsRow"; +import { LauncherResourceAccess } from "./LauncherResourceAccess"; +import { LauncherResourceIcon } from "./LauncherResourceIcon"; +import { getLauncherResourceSelectProps } from "./useLauncherResourceAction"; + +type LauncherResourceRowProps = { + resource: LauncherResource; + showLabels: boolean; + isLast?: boolean; + onSelect?: () => void; +}; + +export function LauncherResourceRow({ + resource, + showLabels, + isLast = false, + onSelect +}: LauncherResourceRowProps) { + const hasTags = showLabels && resource.labels.length > 0; + const clickProps = onSelect + ? getLauncherResourceSelectProps(onSelect) + : null; + + return ( +
+ + + + {resource.name} + + + + + {hasTags ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/src/components/resource-launcher/LauncherSettingsMenu.tsx b/src/components/resource-launcher/LauncherSettingsMenu.tsx new file mode 100644 index 000000000..41143cd10 --- /dev/null +++ b/src/components/resource-launcher/LauncherSettingsMenu.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { Label } from "@app/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { Switch } from "@app/components/ui/switch"; +import type { LauncherViewConfig } from "@server/routers/launcher/types"; +import { useTranslations } from "next-intl"; +import { Settings } from "lucide-react"; + +type LauncherSettingsMenuProps = { + config: LauncherViewConfig; + isDefaultView: boolean; + onConfigChange: (patch: Partial) => void; + onDeleteView: () => void; +}; + +export function LauncherSettingsMenu({ + config, + isDefaultView, + onConfigChange, + onDeleteView +}: LauncherSettingsMenuProps) { + const t = useTranslations(); + + return ( + + + + + +
+
+

+ {t("resourceLauncherGroupBy")} +

+ +
+ +
+

+ {t("resourceLauncherLayout")} +

+ +
+ +
+
+ + + onConfigChange({ showLabels: checked }) + } + /> +
+
+ + {!isDefaultView ? ( + + ) : null} +
+
+
+ ); +} diff --git a/src/components/resource-launcher/LauncherSortButton.tsx b/src/components/resource-launcher/LauncherSortButton.tsx new file mode 100644 index 000000000..d79c3c6c2 --- /dev/null +++ b/src/components/resource-launcher/LauncherSortButton.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { useTranslations } from "next-intl"; +import { ArrowDown01, ArrowUp10 } from "lucide-react"; + +type LauncherSortButtonProps = { + order: "asc" | "desc"; + onToggle: () => void; +}; + +export function LauncherSortButton({ + order, + onToggle +}: LauncherSortButtonProps) { + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/components/resource-launcher/LauncherViewTabs.tsx b/src/components/resource-launcher/LauncherViewTabs.tsx new file mode 100644 index 000000000..98d8f7289 --- /dev/null +++ b/src/components/resource-launcher/LauncherViewTabs.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { useTranslations } from "next-intl"; +import { ChevronDown } from "lucide-react"; +import { cn } from "@app/lib/cn"; + +type LauncherViewTabsProps = { + activeViewId: number | "default"; + savedViews: Array<{ viewId: number; name: string }>; + onSelectView: (viewId: number | "default") => void; +}; + +export function LauncherViewTabs({ + activeViewId, + savedViews, + onSelectView +}: LauncherViewTabsProps) { + const t = useTranslations(); + + const viewOptions: Array<{ + value: number | "default"; + label: string; + }> = [ + { value: "default", label: t("resourceLauncherDefaultView") }, + ...savedViews.map((view) => ({ + value: view.viewId, + label: view.name + })) + ]; + + return ( +
+ {viewOptions.map((option) => { + const isSelected = activeViewId === option.value; + return ( + + ); + })} +
+ ); +} + +type LauncherSaveViewMenuProps = { + isDefaultView: boolean; + isAdmin: boolean; + isOrgWideView: boolean; + hasUnsavedChanges: boolean; + onSaveToCurrent: () => void; + onSaveAsNew: () => void; + onSaveForEveryone: () => void; + onMakePersonal: () => void; + onResetView: () => void; +}; + +export function LauncherSaveViewMenu({ + isDefaultView, + isAdmin, + isOrgWideView, + hasUnsavedChanges, + onSaveToCurrent, + onSaveAsNew, + onSaveForEveryone, + onMakePersonal, + onResetView +}: LauncherSaveViewMenuProps) { + const t = useTranslations(); + + return ( + + + + + + {hasUnsavedChanges ? ( + <> + + {t("resourceLauncherResetView")} + + + + ) : null} + {!isDefaultView && (isAdmin || !isOrgWideView) ? ( + + {t("resourceLauncherSaveToCurrentView")} + + ) : null} + + {t("resourceLauncherSaveAsNewView")} + + {isAdmin && !isDefaultView && !isOrgWideView ? ( + + {t("resourceLauncherSaveForEveryone")} + + ) : null} + {isAdmin && !isDefaultView && isOrgWideView ? ( + + {t("resourceLauncherMakePersonal")} + + ) : null} + + + ); +} diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx new file mode 100644 index 000000000..be5620d25 --- /dev/null +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -0,0 +1,577 @@ +"use client"; + +import { + Credenza, + CredenzaBody, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Button } from "@app/components/ui/button"; +import { CheckboxWithLabel } from "@app/components/ui/checkbox"; +import { Input } from "@app/components/ui/input"; +import { Label } from "@app/components/ui/label"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { + readLauncherLastView, + writeLauncherLastView, + type LauncherActiveViewId +} from "@app/lib/launcherLocalStorage"; +import { + buildLauncherPath, + getLauncherUrlBaseConfig, + isLauncherConfigEqual, + parseLauncherUrlState, + serializeLauncherUrlState +} from "@app/lib/launcherUrlState"; +import { useToast } from "@app/hooks/useToast"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import type { + LauncherGroup, + LauncherViewConfig, + LauncherViewRecord +} from "@server/routers/launcher/types"; +import { useMutation } from "@tanstack/react-query"; +import { Search } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useTransition +} from "react"; +import { useDebouncedCallback } from "use-debounce"; +import type { Selectedsite } from "@app/components/site-selector"; +import type { SelectedLabel } from "@app/components/labels-selector"; +import { useMediaQuery } from "@app/hooks/useMediaQuery"; +import { cn } from "@app/lib/cn"; +import { LauncherFilterPopover } from "./LauncherFilterPopover"; +import { LauncherGroupList } from "./LauncherGroupList"; +import { LauncherRefreshButton } from "./LauncherRefreshButton"; +import { LauncherSettingsMenu } from "./LauncherSettingsMenu"; +import { LauncherSortButton } from "./LauncherSortButton"; +import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; + +type ResourceLauncherProps = { + orgId: string; + isAdmin: boolean; + views: LauncherViewRecord[]; + activeViewId: LauncherActiveViewId; + config: LauncherViewConfig; + savedConfig: LauncherViewConfig; + groups: LauncherGroup[]; + groupsPagination: { + total: number; + page: number; + pageSize: number; + }; +}; + +export default function ResourceLauncher({ + orgId, + isAdmin, + views, + activeViewId, + config, + savedConfig, + groups, + groupsPagination +}: ResourceLauncherProps) { + const t = useTranslations(); + const { toast } = useToast(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const router = useRouter(); + const { navigate, isNavigating, searchParams } = useNavigationContext(); + const [isRefreshing, startRefreshTransition] = useTransition(); + const hasRestoredLastView = useRef(false); + + const [searchInputResetKey, setSearchInputResetKey] = useState(0); + const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const [newViewName, setNewViewName] = useState(""); + const [saveOrgWide, setSaveOrgWide] = useState(false); + + const isDesktop = useMediaQuery("(min-width: 768px)"); + + const configRef = useRef(config); + configRef.current = config; + const searchInputRef = useRef(config.query); + const activeViewIdRef = useRef(activeViewId); + activeViewIdRef.current = activeViewId; + + useEffect(() => { + if (hasRestoredLastView.current) { + return; + } + hasRestoredLastView.current = true; + + const parsed = parseLauncherUrlState(searchParams); + if (parsed.hasAnyLauncherParams) { + return; + } + + const lastView = readLauncherLastView(orgId); + if (lastView === null || lastView === activeViewId) { + return; + } + + const isValid = + lastView === "default" || + views.some((view) => view.viewId === lastView); + if (!isValid) { + return; + } + + const baseConfig = getLauncherUrlBaseConfig(lastView, views); + const params = serializeLauncherUrlState({ + viewId: lastView, + config: baseConfig + }); + navigate({ searchParams: params, replace: true }); + }, [activeViewId, navigate, orgId, searchParams, views]); + + const navigateToConfig = useCallback( + (viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => { + const params = serializeLauncherUrlState({ + viewId, + config: nextConfig + }); + navigate({ searchParams: params }); + }, + [navigate] + ); + + const debouncedNavigateSearch = useDebouncedCallback( + (viewId: LauncherActiveViewId, query: string) => { + navigateToConfig(viewId, { ...configRef.current, query }); + }, + 300 + ); + + const selectView = useCallback( + (viewId: LauncherActiveViewId) => { + writeLauncherLastView(orgId, viewId); + const baseConfig = getLauncherUrlBaseConfig(viewId, views); + navigateToConfig(viewId, baseConfig); + }, + [navigateToConfig, orgId, views] + ); + + const activeSavedView = useMemo( + () => + activeViewId === "default" + ? null + : views.find((view) => view.viewId === activeViewId), + [activeViewId, views] + ); + + const isDefaultView = activeViewId === "default"; + const isOrgWideView = Boolean(activeSavedView?.isOrgWide); + const hasUnsavedChanges = !isLauncherConfigEqual(config, savedConfig); + + const selectedSites: Selectedsite[] = useMemo( + () => + config.siteIds.map((siteId) => ({ + siteId, + name: String(siteId), + type: "newt" + })), + [config.siteIds] + ); + + const selectedLabels: SelectedLabel[] = useMemo( + () => + config.labelIds.map((labelId) => ({ + labelId, + name: String(labelId), + color: "#a1a1aa" + })), + [config.labelIds] + ); + + const createViewMutation = useMutation({ + mutationFn: async (payload: { + name: string; + config: LauncherViewConfig; + orgWide: boolean; + }) => { + const res = await api.post(`/org/${orgId}/launcher/views`, payload); + return res.data.data as LauncherViewRecord; + }, + onSuccess: (view) => { + writeLauncherLastView(orgId, view.viewId); + const params = serializeLauncherUrlState({ + viewId: view.viewId, + config: view.config + }); + navigate({ searchParams: params, replace: true }); + router.refresh(); + setSaveDialogOpen(false); + setNewViewName(""); + toast({ + title: t("resourceLauncherViewSaved"), + description: t("resourceLauncherViewSavedDescription") + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + title: t("resourceLauncherViewSaveFailed"), + description: formatAxiosError( + error, + t("resourceLauncherViewSaveFailedDescription") + ) + }); + } + }); + + const updateViewMutation = useMutation({ + mutationFn: async (payload: { + viewId: number; + name?: string; + config?: LauncherViewConfig; + orgWide?: boolean; + }) => { + const { viewId, ...body } = payload; + const res = await api.put( + `/org/${orgId}/launcher/views/${viewId}`, + body + ); + return res.data.data as LauncherViewRecord; + }, + onSuccess: (view) => { + const params = serializeLauncherUrlState({ + viewId: view.viewId, + config: view.config + }); + navigate({ searchParams: params, replace: true }); + router.refresh(); + toast({ + title: t("resourceLauncherViewSaved"), + description: t("resourceLauncherViewSavedDescription") + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + title: t("resourceLauncherViewSaveFailed"), + description: formatAxiosError( + error, + t("resourceLauncherViewSaveFailedDescription") + ) + }); + } + }); + + const deleteViewMutation = useMutation({ + mutationFn: async (viewId: number) => { + await api.delete(`/org/${orgId}/launcher/views/${viewId}`); + }, + onSuccess: () => { + writeLauncherLastView(orgId, "default"); + const params = serializeLauncherUrlState({ + viewId: "default", + config: getLauncherUrlBaseConfig("default", views) + }); + navigate({ searchParams: params, replace: true }); + router.refresh(); + toast({ + title: t("resourceLauncherViewDeleted"), + description: t("resourceLauncherViewDeletedDescription") + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + title: t("resourceLauncherViewDeleteFailed"), + description: formatAxiosError( + error, + t("resourceLauncherViewDeleteFailedDescription") + ) + }); + } + }); + + const applyConfigPatch = useCallback( + (patch: Partial) => { + const nextConfig = { + ...configRef.current, + ...patch, + query: searchInputRef.current + }; + navigateToConfig(activeViewIdRef.current, nextConfig); + }, + [navigateToConfig] + ); + + const handleClearFilters = useCallback(() => { + searchInputRef.current = ""; + setSearchInputResetKey((key) => key + 1); + navigateToConfig(activeViewIdRef.current, { + ...configRef.current, + query: "", + siteIds: [], + labelIds: [] + }); + }, [navigateToConfig]); + + const handleResetView = useCallback(() => { + searchInputRef.current = savedConfig.query; + setSearchInputResetKey((key) => key + 1); + navigateToConfig(activeViewIdRef.current, savedConfig); + }, [navigateToConfig, savedConfig]); + + const refreshData = () => { + startRefreshTransition(async () => { + try { + router.refresh(); + } catch { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); + }; + + const handleSaveToCurrent = () => { + if (isDefaultView || (isOrgWideView && !isAdmin)) { + return; + } + updateViewMutation.mutate({ + viewId: activeViewId, + config + }); + }; + + const handleSaveAsNew = () => { + setSaveOrgWide(false); + setNewViewName(""); + setSaveDialogOpen(true); + }; + + const handleSaveForEveryone = () => { + if (isDefaultView) { + return; + } + updateViewMutation.mutate({ + viewId: activeViewId, + orgWide: true + }); + }; + + const handleMakePersonal = () => { + if (isDefaultView) { + return; + } + updateViewMutation.mutate({ + viewId: activeViewId, + orgWide: false + }); + }; + + const handleCreateView = () => { + if (!newViewName.trim()) { + return; + } + createViewMutation.mutate({ + name: newViewName.trim(), + config, + orgWide: saveOrgWide && isAdmin + }); + }; + + const savedViewTabs = views.map((view) => ({ + viewId: view.viewId, + name: view.name + })); + + const renderToolbarSearch = (searchClassName: string) => ( +
+ + { + const value = event.currentTarget.value; + searchInputRef.current = value; + debouncedNavigateSearch(activeViewIdRef.current, value); + }} + placeholder={t("resourceLauncherSearchPlaceholder")} + className="pl-8" + type="search" + /> +
+ ); + + const renderToolbarActions = () => ( + <> + + + applyConfigPatch({ + siteIds: sites.map((site) => site.siteId) + }) + } + onLabelsChange={(labels) => + applyConfigPatch({ + labelIds: labels.map((label) => label.labelId) + }) + } + /> + + applyConfigPatch({ + order: config.order === "asc" ? "desc" : "asc" + }) + } + /> + { + if (!isDefaultView) { + deleteViewMutation.mutate(activeViewId); + } + }} + /> + + + ); + + const renderToolbarViews = () => ( + + ); + + return ( +
+ + + {isDesktop ? ( +
+ {renderToolbarSearch("w-64")} +
+ {renderToolbarViews()} +
+
+ {renderToolbarActions()} +
+
+ ) : ( +
+
+ {renderToolbarActions()} +
+ {renderToolbarSearch("w-full")} +
+ {renderToolbarViews()} +
+
+ )} + + + + + + + + {t("resourceLauncherSaveAsNewView")} + + + {t("resourceLauncherSaveAsNewViewDescription")} + + + +
+ + + setNewViewName(event.target.value) + } + /> +
+ {isAdmin ? ( +
+ + setSaveOrgWide(checked === true) + } + /> +

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

+
+ ) : null} +
+ + + + +
+
+
+ ); +} diff --git a/src/components/resource-launcher/useLauncherResourceAction.ts b/src/components/resource-launcher/useLauncherResourceAction.ts new file mode 100644 index 000000000..4c7d081dd --- /dev/null +++ b/src/components/resource-launcher/useLauncherResourceAction.ts @@ -0,0 +1,127 @@ +"use client"; + +import { useToast } from "@app/hooks/useToast"; +import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess"; +import { useTranslations } from "next-intl"; +import { useCallback, type KeyboardEvent, type MouseEvent } from "react"; + +type LauncherResourceActionInput = { + accessUrl?: string | null; + accessCopyValue: string; +}; + +export function useLauncherResourceAction({ + accessUrl, + accessCopyValue +}: LauncherResourceActionInput) { + const { toast } = useToast(); + const t = useTranslations(); + + const href = accessUrl ?? undefined; + const canLink = Boolean(href && isSafeUrlForLink(href)); + const isClickable = canLink || Boolean(accessCopyValue); + + const handleAction = useCallback(() => { + if (canLink && href) { + window.open(href, "_blank", "noopener,noreferrer"); + return; + } + + if (!accessCopyValue) { + return; + } + + void navigator.clipboard.writeText(accessCopyValue).then(() => { + toast({ + title: t("resourceLauncherCopiedToClipboard"), + description: t("resourceLauncherCopiedAccessDescription"), + duration: 2000 + }); + }); + }, [accessCopyValue, canLink, href, t, toast]); + + return { handleAction, isClickable }; +} + +export function isLauncherResourceInteractiveTarget( + target: EventTarget | null, + container?: EventTarget | null +): boolean { + if (!(target instanceof Element)) { + return false; + } + + const interactive = target.closest( + "a, button, [role='button'], input, textarea, select" + ); + + if (!interactive) { + return false; + } + + if (container instanceof Element && interactive === container) { + return false; + } + + return true; +} + +function handleLauncherResourceClick( + event: MouseEvent, + handleAction: () => void +) { + if ( + isLauncherResourceInteractiveTarget(event.target, event.currentTarget) + ) { + return; + } + + handleAction(); +} + +export function getLauncherResourceSelectProps(onSelect: () => void) { + return { + onClick: (event: MouseEvent) => { + if ( + isLauncherResourceInteractiveTarget( + event.target, + event.currentTarget + ) + ) { + return; + } + + onSelect(); + }, + className: "cursor-pointer", + role: "button" as const, + tabIndex: 0, + onKeyDown: (event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect(); + } + } + }; +} + +export function getLauncherResourceClickProps( + handleAction: () => void, + isClickable: boolean +) { + return { + onClick: (event: MouseEvent) => + handleLauncherResourceClick(event, handleAction), + className: isClickable ? "cursor-pointer" : undefined, + role: isClickable ? ("button" as const) : undefined, + tabIndex: isClickable ? 0 : undefined, + onKeyDown: isClickable + ? (event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleAction(); + } + } + : undefined + }; +} diff --git a/src/components/resource-policy/PolicyAccessRulesSection.tsx b/src/components/resource-policy/PolicyAccessRulesSection.tsx index 7a88cab0b..bd735f9b5 100644 --- a/src/components/resource-policy/PolicyAccessRulesSection.tsx +++ b/src/components/resource-policy/PolicyAccessRulesSection.tsx @@ -340,7 +340,8 @@ function PolicyAccessRulesSectionEdit({ ? rules.filter((rule) => !rule.fromPolicy) : rules; const rulesPayload = rulesToValidate.map( - ({ action, match, value, priority, enabled }) => ({ + ({ ruleId, action, match, value, priority, enabled, new: isNew }) => ({ + ...(isNew ? {} : { ruleId }), action, match, value, diff --git a/src/lib/launcherLocalStorage.ts b/src/lib/launcherLocalStorage.ts new file mode 100644 index 000000000..6e1fb60a5 --- /dev/null +++ b/src/lib/launcherLocalStorage.ts @@ -0,0 +1,98 @@ +export type LauncherActiveViewId = number | "default"; + +const LAST_VIEW_PREFIX = "pangolin:launcher:last-view:"; +const GROUP_OPEN_PREFIX = "pangolin:launcher:group-open:"; + +function lastViewKey(orgId: string) { + return `${LAST_VIEW_PREFIX}${orgId}`; +} + +function groupOpenKey( + orgId: string, + viewId: LauncherActiveViewId, + groupBy: "site" | "label" +) { + return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`; +} + +function readJson(key: string, fallback: T): T { + if (typeof window === "undefined") { + return fallback; + } + + try { + const raw = window.localStorage.getItem(key); + return raw ? (JSON.parse(raw) as T) : fallback; + } catch (error) { + console.warn(`Error reading localStorage key "${key}":`, error); + return fallback; + } +} + +function writeJson(key: string, value: unknown) { + if (typeof window === "undefined") { + return; + } + + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.warn(`Error writing localStorage key "${key}":`, error); + } +} + +export function readLauncherLastView( + orgId: string +): LauncherActiveViewId | null { + const value = readJson( + lastViewKey(orgId), + null + ); + if (value === "default" || typeof value === "number") { + return value; + } + return null; +} + +export function writeLauncherLastView( + orgId: string, + viewId: LauncherActiveViewId +) { + writeJson(lastViewKey(orgId), viewId); +} + +export function readLauncherGroupOpenState( + orgId: string, + viewId: LauncherActiveViewId, + groupBy: "site" | "label" +): Record { + return readJson>( + groupOpenKey(orgId, viewId, groupBy), + {} + ); +} + +export function readLauncherGroupOpen( + orgId: string, + viewId: LauncherActiveViewId, + groupBy: "site" | "label", + groupKey: string, + defaultOpen: boolean +): boolean { + const state = readLauncherGroupOpenState(orgId, viewId, groupBy); + return groupKey in state ? state[groupKey] : defaultOpen; +} + +export function writeLauncherGroupOpen( + orgId: string, + viewId: LauncherActiveViewId, + groupBy: "site" | "label", + groupKey: string, + isOpen: boolean +) { + const state = readLauncherGroupOpenState(orgId, viewId, groupBy); + writeJson(groupOpenKey(orgId, viewId, groupBy), { + ...state, + [groupKey]: isOpen + }); +} diff --git a/src/lib/launcherResourceAccess.ts b/src/lib/launcherResourceAccess.ts new file mode 100644 index 000000000..d7dd888ac --- /dev/null +++ b/src/lib/launcherResourceAccess.ts @@ -0,0 +1,123 @@ +import { + formatSiteResourceDestinationDisplay, + type SiteResourceDestinationInput +} from "./formatSiteResourceAccess"; + +export { + formatSiteResourceDestinationDisplay, + resolveHttpHttpsDisplayPort, + type SiteResourceDestinationInput +} from "./formatSiteResourceAccess"; + +export type PublicResourceAccessInput = { + mode: string; + fullDomain: string | null; + ssl: boolean; + proxyPort: number | null; + wildcard: boolean; +}; + +export type SiteResourceAccessInput = { + mode: string; + destination: string | null; + destinationPort: number | null; + scheme: "http" | "https" | null; + ssl: boolean; + fullDomain: string | null; + alias: string | null; + aliasAddress: string | null; +}; + +export type LauncherAccessFields = { + accessDisplay: string; + accessCopyValue: string; + accessUrl: string | null; +}; + +export function formatPublicResourceAccess( + resource: PublicResourceAccessInput +): LauncherAccessFields { + const browserModes = ["http", "ssh", "rdp", "vnc"]; + if (!browserModes.includes(resource.mode)) { + const port = resource.proxyPort?.toString() ?? ""; + return { + accessDisplay: port, + accessCopyValue: port, + accessUrl: null + }; + } + + if (!resource.fullDomain) { + return { + accessDisplay: "", + accessCopyValue: "", + accessUrl: null + }; + } + + const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; + return { + accessDisplay: url, + accessCopyValue: url, + accessUrl: resource.wildcard ? null : url + }; +} + +export function formatSiteResourceAccess( + resource: SiteResourceAccessInput +): LauncherAccessFields { + if (resource.alias) { + return { + accessDisplay: resource.alias, + accessCopyValue: resource.alias, + accessUrl: null + }; + } + + if (resource.mode === "http" && resource.fullDomain) { + const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; + return { + accessDisplay: url, + accessCopyValue: url, + accessUrl: url + }; + } + + const destination = formatSiteResourceDestinationDisplay({ + mode: resource.mode as SiteResourceDestinationInput["mode"], + destination: resource.destination, + destinationPort: resource.destinationPort, + scheme: resource.scheme + }); + + if (destination) { + return { + accessDisplay: destination, + accessCopyValue: destination, + accessUrl: resource.mode === "http" ? destination : null + }; + } + + if (resource.aliasAddress) { + return { + accessDisplay: resource.aliasAddress, + accessCopyValue: resource.aliasAddress, + accessUrl: null + }; + } + + return { + accessDisplay: "", + accessCopyValue: "", + accessUrl: null + }; +} + +export function isSafeUrlForLink(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} diff --git a/src/lib/launcherResourceAdminHref.ts b/src/lib/launcherResourceAdminHref.ts new file mode 100644 index 000000000..db7da151e --- /dev/null +++ b/src/lib/launcherResourceAdminHref.ts @@ -0,0 +1,17 @@ +import type { LauncherResource } from "@server/routers/launcher/types"; + +export function getLauncherResourceAdminHref( + orgId: string, + resource: LauncherResource +): string { + if (resource.resourceType === "public") { + return `/${orgId}/settings/resources/public/${resource.niceId}/general`; + } + + const qs = new URLSearchParams({ query: resource.niceId }); + if (resource.site?.siteId != null) { + qs.set("siteId", String(resource.site.siteId)); + } + + return `/${orgId}/settings/resources/private?${qs.toString()}`; +} diff --git a/src/lib/launcherSearchParams.ts b/src/lib/launcherSearchParams.ts new file mode 100644 index 000000000..acaccd06f --- /dev/null +++ b/src/lib/launcherSearchParams.ts @@ -0,0 +1,43 @@ +import type { LauncherListQuery } from "@server/routers/launcher/types"; + +export type LauncherQueryFilters = { + query?: string; + groupBy?: LauncherListQuery["groupBy"]; + groupKey?: string; + siteIds?: number[]; + labelIds?: number[]; + sort_by?: LauncherListQuery["sort_by"]; + order?: LauncherListQuery["order"]; + pageSize?: number; +}; + +export function buildLauncherSearchParams( + filters: LauncherQueryFilters, + page: number +) { + const sp = new URLSearchParams(); + sp.set("page", String(page)); + sp.set("pageSize", String(filters.pageSize ?? 20)); + if (filters.query) { + sp.set("query", filters.query); + } + if (filters.groupBy) { + sp.set("groupBy", filters.groupBy); + } + if (filters.groupKey) { + sp.set("groupKey", filters.groupKey); + } + if (filters.siteIds?.length) { + sp.set("siteIds", filters.siteIds.join(",")); + } + if (filters.labelIds?.length) { + sp.set("labelIds", filters.labelIds.join(",")); + } + if (filters.sort_by) { + sp.set("sort_by", filters.sort_by); + } + if (filters.order) { + sp.set("order", filters.order); + } + return sp; +} diff --git a/src/lib/launcherServerData.ts b/src/lib/launcherServerData.ts new file mode 100644 index 000000000..2c542c5fc --- /dev/null +++ b/src/lib/launcherServerData.ts @@ -0,0 +1,82 @@ +import { internal } from "@app/lib/api"; +import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; +import { resolveLauncherStateFromUrl } from "@app/lib/launcherUrlState"; +import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; +import type { + LauncherGroup, + LauncherViewConfig, + LauncherViewRecord, + ListLauncherGroupsResponse, + ListLauncherViewsResponse +} from "@server/routers/launcher/types"; +import { AxiosResponse } from "axios"; + +export type LauncherPageData = { + views: LauncherViewRecord[]; + activeViewId: LauncherActiveViewId; + config: LauncherViewConfig; + savedConfig: LauncherViewConfig; + groups: LauncherGroup[]; + groupsPagination: { + total: number; + page: number; + pageSize: number; + }; +}; + +export async function fetchLauncherPageData( + orgId: string, + searchParams: URLSearchParams, + cookieHeader: Awaited< + ReturnType + > +): Promise { + let views: LauncherViewRecord[] = []; + try { + const viewsRes = await internal.get< + AxiosResponse + >(`/org/${orgId}/launcher/views`, cookieHeader); + views = viewsRes.data.data.views; + } catch (e) {} + + const { activeViewId, config, savedConfig } = resolveLauncherStateFromUrl( + searchParams, + views, + null + ); + + const groupFilters = { + query: config.query, + groupBy: config.groupBy, + siteIds: config.siteIds, + labelIds: config.labelIds, + sort_by: config.sortBy, + order: config.order, + pageSize: 20 + }; + + let groups: LauncherGroup[] = []; + let groupsPagination: LauncherPageData["groupsPagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; + + try { + const sp = buildLauncherSearchParams(groupFilters, 1); + const groupsRes = await internal.get< + AxiosResponse + >(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader); + groups = groupsRes.data.data.groups; + groupsPagination = groupsRes.data.data.pagination; + } catch (e) {} + + return { + views, + activeViewId, + config, + savedConfig, + groups, + groupsPagination + }; +} diff --git a/src/lib/launcherUrlState.ts b/src/lib/launcherUrlState.ts new file mode 100644 index 000000000..cfe8e26c4 --- /dev/null +++ b/src/lib/launcherUrlState.ts @@ -0,0 +1,278 @@ +import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; +import { + defaultLauncherViewConfig, + parseIdListParam, + type LauncherViewConfig, + type LauncherViewRecord +} from "@server/routers/launcher/types"; +import { z } from "zod"; + +const launcherUrlBooleanSchema = z + .enum(["0", "1"]) + .transform((value) => value === "1"); + +export type LauncherUrlConfigOverrides = Partial< + Pick< + LauncherViewConfig, + | "groupBy" + | "layout" + | "order" + | "showLabels" + | "siteIds" + | "labelIds" + | "query" + > +>; + +export type ParsedLauncherUrlState = { + viewId: LauncherActiveViewId | null; + configOverrides: LauncherUrlConfigOverrides; + hasAnyLauncherParams: boolean; +}; + +export type ResolvedLauncherState = { + activeViewId: LauncherActiveViewId; + config: LauncherViewConfig; + savedConfig: LauncherViewConfig; +}; + +const LAUNCHER_CONFIG_PARAM_KEYS = [ + "query", + "groupBy", + "layout", + "order", + "showLabels", + "siteIds", + "labelIds" +] as const; + +const LAUNCHER_URL_PARAM_KEYS = [ + "view", + ...LAUNCHER_CONFIG_PARAM_KEYS +] as const; + +export function hasLauncherConfigParams(searchParams: URLSearchParams) { + return LAUNCHER_CONFIG_PARAM_KEYS.some((key) => searchParams.has(key)); +} + +export function isLauncherConfigEqual( + a: LauncherViewConfig, + b: LauncherViewConfig +) { + return JSON.stringify(a) === JSON.stringify(b); +} + +export function getLauncherUrlBaseConfig( + viewId: LauncherActiveViewId, + views: LauncherViewRecord[] +): LauncherViewConfig { + if (viewId === "default") { + return defaultLauncherViewConfig; + } + + const savedView = views.find((view) => view.viewId === viewId); + return savedView?.config ?? defaultLauncherViewConfig; +} + +export function resolveLauncherConfig( + baseConfig: LauncherViewConfig, + overrides: LauncherUrlConfigOverrides +): LauncherViewConfig { + return { + ...baseConfig, + ...overrides, + sortBy: "name" + }; +} + +function parseViewParam(value: string | null): LauncherActiveViewId | null { + if (value === null) { + return null; + } + + if (value === "default") { + return "default"; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + return "default"; + } + + return parsed; +} + +function parseConfigOverrides( + searchParams: URLSearchParams +): LauncherUrlConfigOverrides { + const overrides: LauncherUrlConfigOverrides = {}; + + const query = searchParams.get("query"); + if (query !== null) { + overrides.query = query; + } + + const groupBy = searchParams.get("groupBy"); + if (groupBy === "site" || groupBy === "label") { + overrides.groupBy = groupBy; + } + + const layout = searchParams.get("layout"); + if (layout === "grid" || layout === "list") { + overrides.layout = layout; + } + + const order = searchParams.get("order"); + if (order === "asc" || order === "desc") { + overrides.order = order; + } + + const showLabels = searchParams.get("showLabels"); + if (showLabels !== null) { + const parsed = launcherUrlBooleanSchema.safeParse(showLabels); + if (parsed.success) { + overrides.showLabels = parsed.data; + } + } + + const siteIds = searchParams.get("siteIds"); + if (siteIds !== null) { + overrides.siteIds = parseIdListParam(siteIds); + } + + const labelIds = searchParams.get("labelIds"); + if (labelIds !== null) { + overrides.labelIds = parseIdListParam(labelIds); + } + + return overrides; +} + +export function parseLauncherUrlState( + searchParams: URLSearchParams +): ParsedLauncherUrlState { + const hasAnyLauncherParams = LAUNCHER_URL_PARAM_KEYS.some((key) => + searchParams.has(key) + ); + + return { + viewId: parseViewParam(searchParams.get("view")), + configOverrides: parseConfigOverrides(searchParams), + hasAnyLauncherParams + }; +} + +function isValidActiveViewId( + viewId: LauncherActiveViewId, + views: LauncherViewRecord[] +) { + return viewId === "default" || views.some((view) => view.viewId === viewId); +} + +export function resolveLauncherStateFromUrl( + searchParams: URLSearchParams, + views: LauncherViewRecord[], + fallbackViewId: LauncherActiveViewId | null +): ResolvedLauncherState { + const parsed = parseLauncherUrlState(searchParams); + + let activeViewId: LauncherActiveViewId = "default"; + + if (parsed.viewId !== null) { + activeViewId = isValidActiveViewId(parsed.viewId, views) + ? parsed.viewId + : "default"; + } else if (!parsed.hasAnyLauncherParams && fallbackViewId !== null) { + activeViewId = isValidActiveViewId(fallbackViewId, views) + ? fallbackViewId + : "default"; + } + + const savedConfig = getLauncherUrlBaseConfig(activeViewId, views); + + let config: LauncherViewConfig; + if (hasLauncherConfigParams(searchParams)) { + config = resolveLauncherConfig( + defaultLauncherViewConfig, + parsed.configOverrides + ); + } else if (activeViewId !== "default") { + config = savedConfig; + } else { + config = defaultLauncherViewConfig; + } + + return { + activeViewId, + config, + savedConfig + }; +} + +function idListsEqual(a: number[], b: number[]) { + if (a.length !== b.length) { + return false; + } + + return a.every((value, index) => value === b[index]); +} + +export function serializeLauncherUrlState({ + viewId, + config +}: { + viewId: LauncherActiveViewId; + config: LauncherViewConfig; +}): URLSearchParams { + const baseConfig = defaultLauncherViewConfig; + const params = new URLSearchParams(); + + if (viewId !== "default") { + params.set("view", String(viewId)); + } + + if (config.query !== baseConfig.query && config.query) { + params.set("query", config.query); + } else if (config.query !== baseConfig.query && !config.query) { + params.set("query", ""); + } + + if (config.groupBy !== baseConfig.groupBy) { + params.set("groupBy", config.groupBy); + } + + if (config.layout !== baseConfig.layout) { + params.set("layout", config.layout); + } + + if (config.order !== baseConfig.order) { + params.set("order", config.order); + } + + if (config.showLabels !== baseConfig.showLabels) { + params.set("showLabels", config.showLabels ? "1" : "0"); + } + + if (!idListsEqual(config.siteIds, baseConfig.siteIds)) { + if (config.siteIds.length > 0) { + params.set("siteIds", config.siteIds.join(",")); + } else { + params.set("siteIds", ""); + } + } + + if (!idListsEqual(config.labelIds, baseConfig.labelIds)) { + if (config.labelIds.length > 0) { + params.set("labelIds", config.labelIds.join(",")); + } else { + params.set("labelIds", ""); + } + } + + return params; +} + +export function buildLauncherPath(orgId: string, params: URLSearchParams) { + const query = params.toString(); + return query ? `/${orgId}?${query}` : `/${orgId}`; +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index b8a50a908..14fa1d3da 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -46,6 +46,20 @@ import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; import { StatusHistoryResponse } from "@server/lib/statusHistory"; import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; import type { GetResourcePolicyResponse } from "@server/routers/policy"; +import type { + ListLauncherGroupsResponse, + ListLauncherLabelsResponse, + ListLauncherResourcesResponse, + ListLauncherSitesResponse, + ListLauncherViewsResponse, + LauncherListQuery, + LauncherViewConfig +} from "@server/routers/launcher/types"; +import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams"; +import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; + +export type { LauncherQueryFilters } from "@app/lib/launcherSearchParams"; +export { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; export type ProductUpdate = { link: string | null; @@ -1166,3 +1180,123 @@ export const domainQueries = { refetchInterval: durationToMs(10, "seconds") }) }; + +export const launcherQueries = { + views: (orgId: string) => + queryOptions({ + queryKey: ["ORG", orgId, "LAUNCHER", "VIEWS"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/launcher/views`, { signal }); + return res.data.data.views; + } + }), + sites: ({ + orgId, + query, + perPage = 500 + }: { + orgId: string; + query?: string; + perPage?: number; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "LAUNCHER", + "SITES", + { query, perPage } + ] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + pageSize: perPage.toString() + }); + + if (query?.trim()) { + sp.set("query", query); + } + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/launcher/sites?${sp.toString()}`, { signal }); + return res.data.data.sites; + } + }), + labels: ({ + orgId, + query, + perPage = 500 + }: { + orgId: string; + query?: string; + perPage?: number; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "LAUNCHER", + "LABELS", + { query, perPage } + ] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + pageSize: perPage.toString() + }); + + if (query?.trim()) { + sp.set("query", query); + } + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/launcher/labels?${sp.toString()}`, { + signal + }); + return res.data.data.labels; + } + }), + groups: (orgId: string, filters: LauncherQueryFilters) => + infiniteQueryOptions({ + queryKey: ["ORG", orgId, "LAUNCHER", "GROUPS", filters] as const, + queryFn: async ({ pageParam = 1, signal, meta }) => { + const sp = buildLauncherSearchParams(filters, pageParam); + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/launcher/groups?${sp.toString()}`, { signal }); + return res.data.data; + }, + initialPageParam: 1, + placeholderData: keepPreviousData, + getNextPageParam: (lastPage) => { + const { page, pageSize, total } = lastPage.pagination; + const nextPage = page + 1; + return page * pageSize < total ? nextPage : undefined; + } + }), + resources: ( + orgId: string, + filters: LauncherQueryFilters & { groupKey: string } + ) => + infiniteQueryOptions({ + queryKey: ["ORG", orgId, "LAUNCHER", "RESOURCES", filters] as const, + queryFn: async ({ pageParam = 1, signal, meta }) => { + const sp = buildLauncherSearchParams(filters, pageParam); + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/launcher/resources?${sp.toString()}`, { + signal + }); + return res.data.data; + }, + initialPageParam: 1, + placeholderData: keepPreviousData, + getNextPageParam: (lastPage) => { + const { page, pageSize, total } = lastPage.pagination; + const nextPage = page + 1; + return page * pageSize < total ? nextPage : undefined; + } + }) +};