Compare commits

...

36 Commits

Author SHA1 Message Date
Owen Schwartz 362981ad19 Merge pull request #2498 from fosrl/crowdin_dev
New Crowdin updates
2026-02-17 21:57:14 -08:00
Owen Schwartz fa4f7e4ac2 New translations en-us.json (Norwegian Bokmal) 2026-02-17 21:56:24 -08:00
Owen Schwartz c6bca4e2ab New translations en-us.json (Chinese Simplified) 2026-02-17 21:56:23 -08:00
Owen Schwartz e28b361e05 New translations en-us.json (Turkish) 2026-02-17 21:56:22 -08:00
Owen Schwartz a18691011b New translations en-us.json (Russian) 2026-02-17 21:56:20 -08:00
Owen Schwartz c4a6403cba New translations en-us.json (Portuguese) 2026-02-17 21:56:19 -08:00
Owen Schwartz 1851bf941a New translations en-us.json (Polish) 2026-02-17 21:56:18 -08:00
Owen Schwartz b7ab3c2e92 New translations en-us.json (Dutch) 2026-02-17 21:56:16 -08:00
Owen Schwartz ce1ad032ba New translations en-us.json (Korean) 2026-02-17 21:56:15 -08:00
Owen Schwartz 8446c68e1b New translations en-us.json (Italian) 2026-02-17 21:56:14 -08:00
Owen Schwartz 40ed388b0f New translations en-us.json (German) 2026-02-17 21:56:12 -08:00
Owen Schwartz ce1693aa2f New translations en-us.json (Czech) 2026-02-17 21:56:11 -08:00
Owen Schwartz 11d16a1552 New translations en-us.json (Bulgarian) 2026-02-17 21:56:10 -08:00
Owen Schwartz 0ac54a2c88 New translations en-us.json (Spanish) 2026-02-17 21:56:08 -08:00
Owen Schwartz b7d8b32123 New translations en-us.json (French) 2026-02-17 21:56:07 -08:00
miloschwartz 7ad76f5683 allow type hyphen in orgID 2026-02-17 21:54:55 -08:00
Owen e2f78ba476 Merge branch 'main' of github.com:fosrl/pangolin into dev 2026-02-17 21:06:16 -08:00
Owen 5d92190d50 Merge branch 'cloud-multi-org' into dev 2026-02-17 21:01:44 -08:00
Owen 2b0d6de986 Handle feature lifecycle for multiple orgs 2026-02-17 21:00:48 -08:00
Owen 057f82a561 Fix some cosmetics 2026-02-17 20:46:02 -08:00
Owen 719d2a5ffe Count everything when deleting the org 2026-02-17 20:39:47 -08:00
miloschwartz d4bff9d5cb clean orgId and fix primary badge 2026-02-17 20:35:36 -08:00
Owen 19fcc1f93b Set org limit 2026-02-17 20:18:50 -08:00
miloschwartz d45ea127c2 use billing org id in get subscription status 2026-02-17 20:07:29 -08:00
Owen f591cf8601 Look to the right org to test is subscribed 2026-02-17 20:06:58 -08:00
Owen 6661a76aa8 Update member resources page and testing new org counts 2026-02-17 20:01:43 -08:00
miloschwartz a2ed22bfcc use add/remove helper functions in auto (de)provision 2026-02-17 17:50:41 -08:00
Owen e370f8891a Also update in the assign 2026-02-17 17:34:57 -08:00
miloschwartz 8a83e32c42 add send email verification opt out 2026-02-17 17:33:35 -08:00
Owen 831eb6325c Centralize user functions 2026-02-17 17:31:41 -08:00
Owen 4d6240c987 Handle new usage tracking with multi org 2026-02-17 17:10:05 -08:00
miloschwartz 79cf7c84dc support delete org and preserve path on switch 2026-02-17 16:45:15 -08:00
Owen b71f582329 Use the billing org id when updating and checking usage 2026-02-17 15:09:42 -08:00
miloschwartz b8c3cc751a support creating multiple orgs in saas 2026-02-17 14:37:46 -08:00
miloschwartz 9eacefb155 support delete account 2026-02-14 22:44:30 -08:00
Owen 843b13ed57 Try to fix cicd 2026-02-13 15:00:17 -08:00
58 changed files with 1984 additions and 1129 deletions
+4 -35
View File
@@ -525,41 +525,10 @@ jobs:
VERIFIED_INDEX_KEYLESS=false
fi
# If index verification fails, attempt to verify child platform manifests
if [ "${VERIFIED_INDEX}" != "true" ] || [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then
echo "Index verification not available; attempting child manifest verification for ${BASE_IMAGE}:${IMAGE_TAG}"
CHILD_VERIFIED=false
for ARCH in arm64 amd64; do
CHILD_TAG="${IMAGE_TAG}-${ARCH}"
echo "Resolving child digest for ${BASE_IMAGE}:${CHILD_TAG}"
CHILD_DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${CHILD_TAG} | jq -r '.Digest' || true)"
if [ -n "${CHILD_DIGEST}" ] && [ "${CHILD_DIGEST}" != "null" ]; then
CHILD_REF="${BASE_IMAGE}@${CHILD_DIGEST}"
echo "==> cosign verify (public key) child ${CHILD_REF}"
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${CHILD_REF}' -o text"; then
CHILD_VERIFIED=true
echo "Public key verification succeeded for child ${CHILD_REF}"
else
echo "Public key verification failed for child ${CHILD_REF}"
fi
echo "==> cosign verify (keyless policy) child ${CHILD_REF}"
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${CHILD_REF}' -o text"; then
CHILD_VERIFIED=true
echo "Keyless verification succeeded for child ${CHILD_REF}"
else
echo "Keyless verification failed for child ${CHILD_REF}"
fi
else
echo "No child digest found for ${BASE_IMAGE}:${CHILD_TAG}; skipping"
fi
done
if [ "${CHILD_VERIFIED}" != "true" ]; then
echo "Failed to verify index and no child manifests verified for ${BASE_IMAGE}:${IMAGE_TAG}"
exit 1
fi
# Check if verification succeeded
if [ "${VERIFIED_INDEX}" != "true" ] && [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then
echo "⚠️ WARNING: Verification not available for ${BASE_IMAGE}:${IMAGE_TAG}"
echo "This may be due to registry propagation delays. Continuing anyway."
fi
) || TAG_FAILED=true
+23 -1
View File
@@ -201,6 +201,7 @@
"protocolSelect": "Изберете протокол",
"resourcePortNumber": "Номер на порт",
"resourcePortNumberDescription": "Външен номер на порт за прокси заявки.",
"back": "Назад",
"cancel": "Отмяна",
"resourceConfig": "Конфигурационни фрагменти",
"resourceConfigDescription": "Копирайте и поставете тези конфигурационни отрязъци, за да настроите TCP/UDP ресурса",
@@ -246,6 +247,17 @@
"orgErrorDeleteMessage": "Възникна грешка при изтриването на организацията.",
"orgDeleted": "Организацията е изтрита",
"orgDeletedMessage": "Организацията и нейните данни са изтрити.",
"deleteAccount": "Изтриване на профил",
"deleteAccountDescription": "Перманентно изтрийте своя профил, всички организации, които притежавате, и всички данни в тези организации. Това не може да бъде отменено.",
"deleteAccountButton": "Изтриване на профил",
"deleteAccountConfirmTitle": "Изтрий профила",
"deleteAccountConfirmMessage": "Това ще изтрие перманентно вашия профил, всички организации, които притежавате, и всички данни в тези организации. Това не може да бъде отменено.",
"deleteAccountConfirmString": "изтриване на профил",
"deleteAccountSuccess": "Профилът е изтрит",
"deleteAccountSuccessMessage": "Вашият профил е изтрит.",
"deleteAccountError": "Неуспешно изтриване на профил",
"deleteAccountPreviewAccount": "Вашият профил",
"deleteAccountPreviewOrgs": "Организации, които притежавате (и всички техни данни)",
"orgMissing": "Липсва идентификатор на организация",
"orgMissingMessage": "Невъзможност за регенериране на покана без идентификатор на организация.",
"accessUsersManage": "Управление на потребители",
@@ -461,6 +473,8 @@
"filterByApprovalState": "Филтрирайте по състояние на одобрение",
"approvalListEmpty": "Няма одобрения",
"approvalState": "Състояние на одобрение",
"approvalLoadMore": "Заредете още",
"loadingApprovals": "Зарежда се одобрение",
"approve": "Одобряване",
"approved": "Одобрен",
"denied": "Отказан",
@@ -1017,6 +1031,7 @@
"pangolinSetup": "Настройка - Pangolin",
"orgNameRequired": "Името на организацията е задължително",
"orgIdRequired": "ID на организацията е задължително",
"orgIdMaxLength": "ID на организация трябва да бъде най-много 32 символа",
"orgErrorCreate": "Възникна грешка при създаване на организация",
"pageNotFound": "Страницата не е намерена",
"pageNotFoundDescription": "О, не! Страницата, която търсите, не съществува.",
@@ -1169,7 +1184,8 @@
"actionViewLogs": "Преглед на дневници",
"noneSelected": "Нищо не е избрано",
"orgNotFound2": "Няма намерени организации.",
"searchProgress": "Търсене...",
"searchPlaceholder": "Търсене...",
"emptySearchOptions": "Няма намерени опции",
"create": "Създаване",
"orgs": "Организации",
"loginError": "Възникна неочаквана грешка. Моля, опитайте отново.",
@@ -1251,6 +1267,7 @@
"sidebarLogAndAnalytics": "Лог & Анализи",
"sidebarBluePrints": "Чертежи",
"sidebarOrganization": "Организация",
"sidebarBillingAndLicenses": "Фактуриране & Лицензи",
"sidebarLogsAnalytics": "Анализи",
"blueprints": "Чертежи",
"blueprintsDescription": "Прилагайте декларативни конфигурации и преглеждайте предишни изпълнения",
@@ -1412,6 +1429,7 @@
"billingSites": "Сайтове",
"billingUsers": "Потребители",
"billingDomains": "Домейни",
"billingOrganizations": "Организации",
"billingRemoteExitNodes": "Дистанционни възли",
"billingNoLimitConfigured": "Няма конфигуриран лимит",
"billingEstimatedPeriod": "Очакван период на фактуриране",
@@ -1454,6 +1472,7 @@
"failed": "Неуспешно",
"createNewOrgDescription": "Създайте нова организация",
"organization": "Организация",
"primary": "Основно",
"port": "Порт",
"securityKeyManage": "Управление на ключове за защита",
"securityKeyDescription": "Добавяне или премахване на ключове за защита за удостоверяване без парола",
@@ -1916,6 +1935,9 @@
"authPageBrandingQuestionRemove": "Сигурни ли сте, че искате да премахнете брандинга за страниците за автентификация?",
"authPageBrandingDeleteConfirm": "Потвърждение на изтриване на брандинга.",
"brandingLogoURL": "URL адрес на логото.",
"brandingLogoURLOrPath": "URL или Път към лого",
"brandingLogoPathDescription": "Въведете URL или локален път.",
"brandingLogoURLDescription": "Въведете публично достъпен URL към вашето лого изображение.",
"brandingPrimaryColor": "Основен цвят.",
"brandingLogoWidth": "Ширина (px).",
"brandingLogoHeight": "Височина (px).",
+23 -1
View File
@@ -201,6 +201,7 @@
"protocolSelect": "Vybrat protokol",
"resourcePortNumber": "Číslo portu",
"resourcePortNumberDescription": "Externí port k požadavkům proxy serveru.",
"back": "Zpět",
"cancel": "Zrušit",
"resourceConfig": "Konfigurační snippety",
"resourceConfigDescription": "Zkopírujte a vložte tyto konfigurační textové bloky pro nastavení TCP/UDP zdroje",
@@ -246,6 +247,17 @@
"orgErrorDeleteMessage": "Došlo k chybě při odstraňování organizace.",
"orgDeleted": "Organizace odstraněna",
"orgDeletedMessage": "Organizace a její data byla smazána.",
"deleteAccount": "Odstranit účet",
"deleteAccountDescription": "Trvale smazat svůj účet, všechny organizace, které vlastníte, a všechna data těchto organizací. Tuto akci nelze vrátit zpět.",
"deleteAccountButton": "Odstranit účet",
"deleteAccountConfirmTitle": "Odstranit účet",
"deleteAccountConfirmMessage": "Toto trvale vymaže váš účet, všechny organizace, které vlastníte, a všechna data v rámci těchto organizací. Tuto akci nelze vrátit zpět.",
"deleteAccountConfirmString": "smazat účet",
"deleteAccountSuccess": "Účet odstraněn",
"deleteAccountSuccessMessage": "Váš účet byl odstraněn.",
"deleteAccountError": "Nepodařilo se odstranit účet",
"deleteAccountPreviewAccount": "Váš účet",
"deleteAccountPreviewOrgs": "Organizace, které vlastníte (a všechny jejich údaje)",
"orgMissing": "Chybí ID organizace",
"orgMissingMessage": "Nelze obnovit pozvánku bez ID organizace.",
"accessUsersManage": "Spravovat uživatele",
@@ -461,6 +473,8 @@
"filterByApprovalState": "Filtrovat podle státu schválení",
"approvalListEmpty": "Žádná schválení",
"approvalState": "Země schválení",
"approvalLoadMore": "Načíst více",
"loadingApprovals": "Načítání schválení",
"approve": "Schválit",
"approved": "Schváleno",
"denied": "Zamítnuto",
@@ -1017,6 +1031,7 @@
"pangolinSetup": "Setup - Pangolin",
"orgNameRequired": "Je vyžadován název organizace",
"orgIdRequired": "Je vyžadováno ID organizace",
"orgIdMaxLength": "ID organizace musí mít nejvýše 32 znaků",
"orgErrorCreate": "Při vytváření org došlo k chybě",
"pageNotFound": "Stránka nenalezena",
"pageNotFoundDescription": "Jejda! Stránka, kterou hledáte, neexistuje.",
@@ -1169,7 +1184,8 @@
"actionViewLogs": "Zobrazit logy",
"noneSelected": "Není vybráno",
"orgNotFound2": "Nebyly nalezeny žádné organizace.",
"searchProgress": "Hledat...",
"searchPlaceholder": "Hledat...",
"emptySearchOptions": "Nebyly nalezeny žádné možnosti",
"create": "Vytvořit",
"orgs": "Organizace",
"loginError": "Došlo k neočekávané chybě. Zkuste to prosím znovu.",
@@ -1251,6 +1267,7 @@
"sidebarLogAndAnalytics": "Log & Analytics",
"sidebarBluePrints": "Plány",
"sidebarOrganization": "Organizace",
"sidebarBillingAndLicenses": "Fakturace a licence",
"sidebarLogsAnalytics": "Analytici",
"blueprints": "Plány",
"blueprintsDescription": "Použít deklarativní konfigurace a zobrazit předchozí běhy",
@@ -1412,6 +1429,7 @@
"billingSites": "Stránky",
"billingUsers": "Uživatelé",
"billingDomains": "Domény",
"billingOrganizations": "Tělo",
"billingRemoteExitNodes": "Vzdálené uzly",
"billingNoLimitConfigured": "Žádný limit nenastaven",
"billingEstimatedPeriod": "Odhadované období fakturace",
@@ -1454,6 +1472,7 @@
"failed": "Selhalo",
"createNewOrgDescription": "Vytvořit novou organizaci",
"organization": "Organizace",
"primary": "Primární",
"port": "Přístav",
"securityKeyManage": "Správa bezpečnostních klíčů",
"securityKeyDescription": "Přidat nebo odebrat bezpečnostní klíče pro bezheslou autentizaci",
@@ -1916,6 +1935,9 @@
"authPageBrandingQuestionRemove": "Jste si jisti, že chcete odstranit branding autentizačních stránek?",
"authPageBrandingDeleteConfirm": "Potvrzení odstranění brandingu",
"brandingLogoURL": "URL loga",
"brandingLogoURLOrPath": "URL nebo cesta k logu",
"brandingLogoPathDescription": "Zadejte URL nebo místní cestu.",
"brandingLogoURLDescription": "Zadejte veřejně přístupnou adresu URL vašeho loga.",
"brandingPrimaryColor": "Primární barva",
"brandingLogoWidth": "Šířka (px)",
"brandingLogoHeight": "Výška (px)",
+23 -1
View File
@@ -201,6 +201,7 @@
"protocolSelect": "Wählen Sie ein Protokoll",
"resourcePortNumber": "Portnummer",
"resourcePortNumberDescription": "Die externe Portnummer für Proxy-Anfragen.",
"back": "Zurück",
"cancel": "Abbrechen",
"resourceConfig": "Konfiguration Snippets",
"resourceConfigDescription": "Kopieren und fügen Sie diese Konfigurations-Snippets ein, um die TCP/UDP Ressource einzurichten",
@@ -246,6 +247,17 @@
"orgErrorDeleteMessage": "Beim Löschen der Organisation ist ein Fehler aufgetreten.",
"orgDeleted": "Organisation gelöscht",
"orgDeletedMessage": "Die Organisation und ihre Daten wurden gelöscht.",
"deleteAccount": "Konto löschen",
"deleteAccountDescription": "Lösche dein Konto, alle Organisationen, die du besitzt, und alle Daten innerhalb dieser Organisationen. Dies kann nicht rückgängig gemacht werden.",
"deleteAccountButton": "Konto löschen",
"deleteAccountConfirmTitle": "Konto löschen",
"deleteAccountConfirmMessage": "Dies wird Ihr Konto dauerhaft löschen, alle Organisationen, die Sie besitzen, und alle Daten innerhalb dieser Organisationen. Dies kann nicht rückgängig gemacht werden.",
"deleteAccountConfirmString": "Konto löschen",
"deleteAccountSuccess": "Konto gelöscht",
"deleteAccountSuccessMessage": "Ihr Konto wurde gelöscht.",
"deleteAccountError": "Konto konnte nicht gelöscht werden",
"deleteAccountPreviewAccount": "Ihr Konto",
"deleteAccountPreviewOrgs": "Organisationen, die Sie besitzen (und ihre Daten)",
"orgMissing": "Organisations-ID fehlt",
"orgMissingMessage": "Einladung kann ohne Organisations-ID nicht neu generiert werden.",
"accessUsersManage": "Benutzer verwalten",
@@ -461,6 +473,8 @@
"filterByApprovalState": "Filtern nach Genehmigungsstatus",
"approvalListEmpty": "Keine Genehmigungen",
"approvalState": "Genehmigungsstatus",
"approvalLoadMore": "Mehr laden",
"loadingApprovals": "Genehmigungen werden geladen",
"approve": "Bestätigen",
"approved": "Genehmigt",
"denied": "Verweigert",
@@ -1017,6 +1031,7 @@
"pangolinSetup": "Einrichtung - Pangolin",
"orgNameRequired": "Organisationsname ist erforderlich",
"orgIdRequired": "Organisations-ID ist erforderlich",
"orgIdMaxLength": "Organisations-ID darf höchstens 32 Zeichen lang sein",
"orgErrorCreate": "Beim Erstellen der Organisation ist ein Fehler aufgetreten",
"pageNotFound": "Seite nicht gefunden",
"pageNotFoundDescription": "Hoppla! Die gesuchte Seite existiert nicht.",
@@ -1169,7 +1184,8 @@
"actionViewLogs": "Logs anzeigen",
"noneSelected": "Keine ausgewählt",
"orgNotFound2": "Keine Organisationen gefunden.",
"searchProgress": "Suche...",
"searchPlaceholder": "Suche...",
"emptySearchOptions": "Keine Optionen gefunden",
"create": "Erstellen",
"orgs": "Organisationen",
"loginError": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut.",
@@ -1251,6 +1267,7 @@
"sidebarLogAndAnalytics": "Log & Analytik",
"sidebarBluePrints": "Blaupausen",
"sidebarOrganization": "Organisation",
"sidebarBillingAndLicenses": "Abrechnung & Lizenzen",
"sidebarLogsAnalytics": "Analytik",
"blueprints": "Blaupausen",
"blueprintsDescription": "Deklarative Konfigurationen anwenden und vorherige Abläufe anzeigen",
@@ -1412,6 +1429,7 @@
"billingSites": "Seiten",
"billingUsers": "Benutzergeräte",
"billingDomains": "Domänen",
"billingOrganizations": "Orden",
"billingRemoteExitNodes": "Entfernte Knoten",
"billingNoLimitConfigured": "Kein Limit konfiguriert",
"billingEstimatedPeriod": "Geschätzter Abrechnungszeitraum",
@@ -1454,6 +1472,7 @@
"failed": "Fehlgeschlagen",
"createNewOrgDescription": "Eine neue Organisation erstellen",
"organization": "Organisation",
"primary": "Primär",
"port": "Port",
"securityKeyManage": "Sicherheitsschlüssel verwalten",
"securityKeyDescription": "Sicherheitsschlüssel für passwortlose Authentifizierung hinzufügen oder entfernen",
@@ -1916,6 +1935,9 @@
"authPageBrandingQuestionRemove": "Sind Sie sicher, dass Sie das Branding für Authentifizierungsseiten entfernen möchten?",
"authPageBrandingDeleteConfirm": "Branding löschen bestätigen",
"brandingLogoURL": "Logo URL",
"brandingLogoURLOrPath": "Logo-URL oder Pfad",
"brandingLogoPathDescription": "Geben Sie eine URL oder einen lokalen Pfad ein.",
"brandingLogoURLDescription": "Geben Sie eine öffentlich zugängliche URL zu Ihrem Logobild ein.",
"brandingPrimaryColor": "Primär-Farbe",
"brandingLogoWidth": "Breite (px)",
"brandingLogoHeight": "Höhe (px)",
+4
View File
@@ -1031,6 +1031,7 @@
"pangolinSetup": "Setup - Pangolin",
"orgNameRequired": "Organization name is required",
"orgIdRequired": "Organization ID is required",
"orgIdMaxLength": "Organization ID must be at most 32 characters",
"orgErrorCreate": "An error occurred while creating org",
"pageNotFound": "Page Not Found",
"pageNotFoundDescription": "Oops! The page you're looking for doesn't exist.",
@@ -1266,6 +1267,7 @@
"sidebarLogAndAnalytics": "Log & Analytics",
"sidebarBluePrints": "Blueprints",
"sidebarOrganization": "Organization",
"sidebarBillingAndLicenses": "Billing & Licenses",
"sidebarLogsAnalytics": "Analytics",
"blueprints": "Blueprints",
"blueprintsDescription": "Apply declarative configurations and view previous runs",
@@ -1427,6 +1429,7 @@
"billingSites": "Sites",
"billingUsers": "Users",
"billingDomains": "Domains",
"billingOrganizations": "Orgs",
"billingRemoteExitNodes": "Remote Nodes",
"billingNoLimitConfigured": "No limit configured",
"billingEstimatedPeriod": "Estimated Billing Period",
@@ -1469,6 +1472,7 @@
"failed": "Failed",
"createNewOrgDescription": "Create a new organization",
"organization": "Organization",
"primary": "Primary",
"port": "Port",
"securityKeyManage": "Manage Security Keys",
"securityKeyDescription": "Add or remove security keys for passwordless authentication",
+23 -1
View File
@@ -201,6 +201,7 @@
"protocolSelect": "Seleccionar un protocolo",
"resourcePortNumber": "Número de puerto",
"resourcePortNumberDescription": "El número de puerto externo a las solicitudes de proxy.",
"back": "Atrás",
"cancel": "Cancelar",
"resourceConfig": "Fragmentos de configuración",
"resourceConfigDescription": "Copia y pega estos fragmentos de configuración para configurar el recurso TCP/UDP",
@@ -246,6 +247,17 @@
"orgErrorDeleteMessage": "Se ha producido un error al eliminar la organización.",
"orgDeleted": "Organización eliminada",
"orgDeletedMessage": "La organización y sus datos han sido eliminados.",
"deleteAccount": "Eliminar cuenta",
"deleteAccountDescription": "Elimina permanentemente tu cuenta, todas las organizaciones que posees y todos los datos dentro de esas organizaciones. Esto no se puede deshacer.",
"deleteAccountButton": "Eliminar cuenta",
"deleteAccountConfirmTitle": "Eliminar cuenta",
"deleteAccountConfirmMessage": "Esto borrará permanentemente tu cuenta, todas las organizaciones que posees y todos los datos dentro de esas organizaciones. Esto no se puede deshacer.",
"deleteAccountConfirmString": "eliminar cuenta",
"deleteAccountSuccess": "Cuenta eliminada",
"deleteAccountSuccessMessage": "Tu cuenta ha sido eliminada.",
"deleteAccountError": "Error al eliminar la cuenta",
"deleteAccountPreviewAccount": "Tu cuenta",
"deleteAccountPreviewOrgs": "Organizaciones que tienes (y todos sus datos)",
"orgMissing": "Falta el ID de la organización",
"orgMissingMessage": "No se puede regenerar la invitación sin el ID de la organización.",
"accessUsersManage": "Administrar usuarios",
@@ -461,6 +473,8 @@
"filterByApprovalState": "Filtrar por estado de aprobación",
"approvalListEmpty": "No hay aprobaciones",
"approvalState": "Estado de aprobación",
"approvalLoadMore": "Cargar más",
"loadingApprovals": "Cargando aprobaciones",
"approve": "Aprobar",
"approved": "Aprobado",
"denied": "Denegado",
@@ -1017,6 +1031,7 @@
"pangolinSetup": "Configuración - Pangolin",
"orgNameRequired": "El nombre de la organización es obligatorio",
"orgIdRequired": "El ID de la organización es obligatorio",
"orgIdMaxLength": "El ID de la organización debe tener como máximo 32 caracteres",
"orgErrorCreate": "Se ha producido un error al crear el org",
"pageNotFound": "Página no encontrada",
"pageNotFoundDescription": "¡Vaya! La página que estás buscando no existe.",
@@ -1169,7 +1184,8 @@
"actionViewLogs": "Ver registros",
"noneSelected": "Ninguno seleccionado",
"orgNotFound2": "No se encontraron organizaciones.",
"searchProgress": "Buscar...",
"searchPlaceholder": "Buscar...",
"emptySearchOptions": "No se encontraron opciones",
"create": "Crear",
"orgs": "Organizaciones",
"loginError": "Ocurrió un error inesperado. Por favor, inténtelo de nuevo.",
@@ -1251,6 +1267,7 @@
"sidebarLogAndAnalytics": "Registro y análisis",
"sidebarBluePrints": "Planos",
"sidebarOrganization": "Organización",
"sidebarBillingAndLicenses": "Facturación y licencias",
"sidebarLogsAnalytics": "Analíticas",
"blueprints": "Planos",
"blueprintsDescription": "Aplicar configuraciones declarativas y ver ejecuciones anteriores",
@@ -1412,6 +1429,7 @@
"billingSites": "Sitios",
"billingUsers": "Usuarios",
"billingDomains": "Dominios",
"billingOrganizations": "Orgánico",
"billingRemoteExitNodes": "Nodos remotos",
"billingNoLimitConfigured": "No se ha configurado ningún límite",
"billingEstimatedPeriod": "Período de facturación estimado",
@@ -1454,6 +1472,7 @@
"failed": "Fallido",
"createNewOrgDescription": "Crear una nueva organización",
"organization": "Organización",
"primary": "Principal",
"port": "Puerto",
"securityKeyManage": "Gestionar llaves de seguridad",
"securityKeyDescription": "Agregar o eliminar llaves de seguridad para autenticación sin contraseña",
@@ -1916,6 +1935,9 @@
"authPageBrandingQuestionRemove": "¿Está seguro de que desea eliminar la marca de las páginas de autenticación?",
"authPageBrandingDeleteConfirm": "Confirmar eliminación de la marca",
"brandingLogoURL": "URL del logotipo",
"brandingLogoURLOrPath": "URL o ruta de Logo",
"brandingLogoPathDescription": "Introduzca una URL o una ruta local.",
"brandingLogoURLDescription": "Introduzca una URL de acceso público a su imagen de logotipo.",
"brandingPrimaryColor": "Color primario",
"brandingLogoWidth": "Ancho (px)",
"brandingLogoHeight": "Altura (px)",
+23 -1
View File
@@ -201,6 +201,7 @@
"protocolSelect": "Choisir un protocole",
"resourcePortNumber": "Numéro de port",
"resourcePortNumberDescription": "Le numéro de port externe pour les requêtes de proxy.",
"back": "Précédent",
"cancel": "Abandonner",
"resourceConfig": "Snippets de configuration",
"resourceConfigDescription": "Copiez et collez ces extraits de configuration pour configurer la ressource TCP/UDP",
@@ -246,6 +247,17 @@
"orgErrorDeleteMessage": "Une erreur s'est produite lors de la suppression de l'organisation.",
"orgDeleted": "Organisation supprimée",
"orgDeletedMessage": "L'organisation et ses données ont été supprimées.",
"deleteAccount": "Supprimer le compte",
"deleteAccountDescription": "Supprimer définitivement votre compte, toutes les organisations que vous possédez et toutes les données au sein de ces organisations. Cela ne peut pas être annulé.",
"deleteAccountButton": "Supprimer le compte",
"deleteAccountConfirmTitle": "Supprimer le compte",
"deleteAccountConfirmMessage": "Cela effacera définitivement votre compte, toutes les organisations que vous possédez et toutes les données au sein de ces organisations. Cela ne peut pas être annulé.",
"deleteAccountConfirmString": "supprimer le compte",
"deleteAccountSuccess": "Compte supprimé",
"deleteAccountSuccessMessage": "Votre compte a été supprimé.",
"deleteAccountError": "Échec de la suppression du compte",
"deleteAccountPreviewAccount": "Votre Compte",
"deleteAccountPreviewOrgs": "Organisations que vous possédez (et toutes leurs données)",
"orgMissing": "ID d'organisation manquant",
"orgMissingMessage": "Impossible de régénérer l'invitation sans un ID d'organisation.",
"accessUsersManage": "Gérer les utilisateurs",
@@ -461,6 +473,8 @@
"filterByApprovalState": "Filtrer par État d'Approbation",
"approvalListEmpty": "Aucune approbation",
"approvalState": "État d'approbation",
"approvalLoadMore": "Charger plus",
"loadingApprovals": "Chargement des approbations",
"approve": "Approuver",
"approved": "Approuvé",
"denied": "Refusé",
@@ -1017,6 +1031,7 @@
"pangolinSetup": "Configuration - Pangolin",
"orgNameRequired": "Le nom de l'organisation est requis",
"orgIdRequired": "L'ID de l'organisation est requis",
"orgIdMaxLength": "L'identifiant de l'organisation doit comporter au plus 32 caractères",
"orgErrorCreate": "Une erreur s'est produite lors de la création de l'organisation",
"pageNotFound": "Page non trouvée",
"pageNotFoundDescription": "Oups! La page que vous recherchez n'existe pas.",
@@ -1169,7 +1184,8 @@
"actionViewLogs": "Voir les logs",
"noneSelected": "Aucune sélection",
"orgNotFound2": "Aucune organisation trouvée.",
"searchProgress": "Rechercher...",
"searchPlaceholder": "Recherche...",
"emptySearchOptions": "Aucune option trouvée",
"create": "Créer",
"orgs": "Organisations",
"loginError": "Une erreur inattendue s'est produite. Veuillez réessayer.",
@@ -1251,6 +1267,7 @@
"sidebarLogAndAnalytics": "Journaux & Analytiques",
"sidebarBluePrints": "Configs",
"sidebarOrganization": "Organisation",
"sidebarBillingAndLicenses": "Facturation & Licences",
"sidebarLogsAnalytics": "Analyses",
"blueprints": "Configs",
"blueprintsDescription": "Appliquer les configurations déclaratives et afficher les exécutions précédentes",
@@ -1412,6 +1429,7 @@
"billingSites": "Nœuds",
"billingUsers": "Utilisateurs",
"billingDomains": "Domaines",
"billingOrganizations": "Organes",
"billingRemoteExitNodes": "Nœuds distants",
"billingNoLimitConfigured": "Aucune limite configurée",
"billingEstimatedPeriod": "Période de facturation estimée",
@@ -1454,6 +1472,7 @@
"failed": "Échec",
"createNewOrgDescription": "Créer une nouvelle organisation",
"organization": "Organisation",
"primary": "Primaire",
"port": "Port",
"securityKeyManage": "Gérer les clés de sécurité",
"securityKeyDescription": "Ajouter ou supprimer des clés de sécurité pour l'authentification sans mot de passe",
@@ -1916,6 +1935,9 @@
"authPageBrandingQuestionRemove": "Êtes-vous sûr de vouloir supprimer la marque des pages d'authentification ?",
"authPageBrandingDeleteConfirm": "Confirmer la suppression de la marque",
"brandingLogoURL": "URL du logo",
"brandingLogoURLOrPath": "URL du logo ou du chemin d'accès",
"brandingLogoPathDescription": "Entrez une URL ou un chemin local.",
"brandingLogoURLDescription": "Entrez une URL accessible au public à votre image de logo.",
"brandingPrimaryColor": "Couleur principale",
"brandingLogoWidth": "Largeur (px)",
"brandingLogoHeight": "Hauteur (px)",
+23 -1
View File
@@ -201,6 +201,7 @@
"protocolSelect": "Seleziona un protocollo",
"resourcePortNumber": "Numero Porta",
"resourcePortNumberDescription": "Il numero di porta esterna per le richieste di proxy.",
"back": "Indietro",
"cancel": "Annulla",
"resourceConfig": "Snippet Di Configurazione",
"resourceConfigDescription": "Copia e incolla questi snippet di configurazione per configurare la risorsa TCP/UDP",
@@ -246,6 +247,17 @@
"orgErrorDeleteMessage": "Si è verificato un errore durante l'eliminazione dell'organizzazione.",
"orgDeleted": "Organizzazione eliminata",
"orgDeletedMessage": "L'organizzazione e i suoi dati sono stati eliminati.",
"deleteAccount": "Elimina Account",
"deleteAccountDescription": "Elimina definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questo non può essere annullato.",
"deleteAccountButton": "Elimina Account",
"deleteAccountConfirmTitle": "Elimina Account",
"deleteAccountConfirmMessage": "Questo cancellerà definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questo non può essere annullato.",
"deleteAccountConfirmString": "elimina account",
"deleteAccountSuccess": "Account Eliminato",
"deleteAccountSuccessMessage": "Il tuo account è stato eliminato.",
"deleteAccountError": "Impossibile eliminare l'account",
"deleteAccountPreviewAccount": "Il Tuo Account",
"deleteAccountPreviewOrgs": "Organizzazioni che possiedi (e tutti i loro dati)",
"orgMissing": "ID Organizzazione Mancante",
"orgMissingMessage": "Impossibile rigenerare l'invito senza un ID organizzazione.",
"accessUsersManage": "Gestisci Utenti",
@@ -461,6 +473,8 @@
"filterByApprovalState": "Filtra Per Stato Di Approvazione",
"approvalListEmpty": "Nessuna approvazione",
"approvalState": "Stato Di Approvazione",
"approvalLoadMore": "Carica altro",
"loadingApprovals": "Caricamento Approvazioni",
"approve": "Approva",
"approved": "Approvato",
"denied": "Negato",
@@ -1017,6 +1031,7 @@
"pangolinSetup": "Configurazione - Pangolin",
"orgNameRequired": "Il nome dell'organizzazione è obbligatorio",
"orgIdRequired": "L'ID dell'organizzazione è obbligatorio",
"orgIdMaxLength": "L'ID dell'organizzazione deve contenere al massimo 32 caratteri",
"orgErrorCreate": "Si è verificato un errore durante la creazione dell'organizzazione",
"pageNotFound": "Pagina Non Trovata",
"pageNotFoundDescription": "Oops! La pagina che stai cercando non esiste.",
@@ -1169,7 +1184,8 @@
"actionViewLogs": "Visualizza Log",
"noneSelected": "Nessuna selezione",
"orgNotFound2": "Nessuna organizzazione trovata.",
"searchProgress": "Ricerca...",
"searchPlaceholder": "Cerca...",
"emptySearchOptions": "Nessuna opzione trovata",
"create": "Crea",
"orgs": "Organizzazioni",
"loginError": "Si è verificato un errore imprevisto. Riprova.",
@@ -1251,6 +1267,7 @@
"sidebarLogAndAnalytics": "Log & Analytics",
"sidebarBluePrints": "Progetti",
"sidebarOrganization": "Organizzazione",
"sidebarBillingAndLicenses": "Fatturazione E Licenze",
"sidebarLogsAnalytics": "Analisi",
"blueprints": "Progetti",
"blueprintsDescription": "Applica le configurazioni dichiarative e visualizza le partite precedenti",
@@ -1412,6 +1429,7 @@
"billingSites": "Siti",
"billingUsers": "Utenti",
"billingDomains": "Domini",
"billingOrganizations": "Organi",
"billingRemoteExitNodes": "Nodi Remoti",
"billingNoLimitConfigured": "Nessun limite configurato",
"billingEstimatedPeriod": "Periodo di Fatturazione Stimato",
@@ -1454,6 +1472,7 @@
"failed": "Fallito",
"createNewOrgDescription": "Crea una nuova organizzazione",
"organization": "Organizzazione",
"primary": "Principale",
"port": "Porta",
"securityKeyManage": "Gestisci chiavi di sicurezza",
"securityKeyDescription": "Aggiungi o rimuovi chiavi di sicurezza per l'autenticazione senza password",
@@ -1916,6 +1935,9 @@
"authPageBrandingQuestionRemove": "Sei sicuro di voler rimuovere il branding per le pagine di autenticazione?",
"authPageBrandingDeleteConfirm": "Conferma Eliminazione Branding",
"brandingLogoURL": "URL Logo",
"brandingLogoURLOrPath": "URL o percorso del logo",
"brandingLogoPathDescription": "Inserisci un URL o un percorso locale.",
"brandingLogoURLDescription": "Inserisci un URL accessibile al pubblico per la tua immagine del logo.",
"brandingPrimaryColor": "Colore Primario",
"brandingLogoWidth": "Larghezza (px)",
"brandingLogoHeight": "Altezza (px)",
+23 -1
View File
@@ -201,6 +201,7 @@
"protocolSelect": "프로토콜 선택",
"resourcePortNumber": "포트 번호",
"resourcePortNumberDescription": "요청을 프록시하기 위한 외부 포트 번호입니다.",
"back": "뒤로",
"cancel": "취소",
"resourceConfig": "구성 스니펫",
"resourceConfigDescription": "TCP/UDP 리소스를 설정하기 위해 이 구성 스니펫을 복사하여 붙여넣습니다.",
@@ -246,6 +247,17 @@
"orgErrorDeleteMessage": "조직을 삭제하는 중 오류가 발생했습니다.",
"orgDeleted": "조직이 삭제되었습니다.",
"orgDeletedMessage": "조직과 그 데이터가 삭제되었습니다.",
"deleteAccount": "계정 삭제",
"deleteAccountDescription": "계정, 소유한 모든 조직 및 조직 내의 모든 데이터를 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.",
"deleteAccountButton": "계정 삭제",
"deleteAccountConfirmTitle": "계정 삭제",
"deleteAccountConfirmMessage": "이 작업은 귀하의 계정, 소유한 모든 조직 및 조직 내 모든 데이터를 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.",
"deleteAccountConfirmString": "계정 삭제",
"deleteAccountSuccess": "계정 삭제됨",
"deleteAccountSuccessMessage": "계정이 삭제되었습니다.",
"deleteAccountError": "계정 삭제 실패",
"deleteAccountPreviewAccount": "귀하의 계정",
"deleteAccountPreviewOrgs": "귀하가 소유한 조직(포함된 모든 데이터)",
"orgMissing": "조직 ID가 누락되었습니다",
"orgMissingMessage": "조직 ID 없이 초대장을 재생성할 수 없습니다.",
"accessUsersManage": "사용자 관리",
@@ -461,6 +473,8 @@
"filterByApprovalState": "승인 상태로 필터링",
"approvalListEmpty": "승인이 없습니다.",
"approvalState": "승인 상태",
"approvalLoadMore": "더 불러오기",
"loadingApprovals": "승인 불러오는 중",
"approve": "승인",
"approved": "승인됨",
"denied": "거부됨",
@@ -1017,6 +1031,7 @@
"pangolinSetup": "설정 - 판골린",
"orgNameRequired": "조직 이름은 필수입니다.",
"orgIdRequired": "조직 ID가 필요합니다",
"orgIdMaxLength": "조직 ID는 최대 32자 이내여야 합니다",
"orgErrorCreate": "조직 생성 중 오류가 발생했습니다.",
"pageNotFound": "페이지를 찾을 수 없습니다",
"pageNotFoundDescription": "앗! 찾고 있는 페이지가 존재하지 않습니다.",
@@ -1169,7 +1184,8 @@
"actionViewLogs": "로그 보기",
"noneSelected": "선택된 항목 없음",
"orgNotFound2": "조직이 없습니다.",
"searchProgress": "검색...",
"searchPlaceholder": "검색...",
"emptySearchOptions": "옵션이 없습니다",
"create": "생성",
"orgs": "조직",
"loginError": "예기치 않은 오류가 발생했습니다. 다시 시도해주세요.",
@@ -1251,6 +1267,7 @@
"sidebarLogAndAnalytics": "로그 & 통계",
"sidebarBluePrints": "청사진",
"sidebarOrganization": "조직",
"sidebarBillingAndLicenses": "결제 및 라이선스",
"sidebarLogsAnalytics": "분석",
"blueprints": "청사진",
"blueprintsDescription": "선언적 구성을 적용하고 이전 실행을 봅니다",
@@ -1412,6 +1429,7 @@
"billingSites": "사이트",
"billingUsers": "사용자",
"billingDomains": "도메인",
"billingOrganizations": "조직",
"billingRemoteExitNodes": "원격 노드",
"billingNoLimitConfigured": "구성된 한도가 없습니다.",
"billingEstimatedPeriod": "예상 청구 기간",
@@ -1454,6 +1472,7 @@
"failed": "실패",
"createNewOrgDescription": "새 조직 생성",
"organization": "조직",
"primary": "기본",
"port": "포트",
"securityKeyManage": "보안 키 관리",
"securityKeyDescription": "비밀번호 없는 인증을 위해 보안 키를 추가하거나 제거합니다.",
@@ -1916,6 +1935,9 @@
"authPageBrandingQuestionRemove": "인증 페이지의 브랜딩을 제거하시겠습니까?",
"authPageBrandingDeleteConfirm": "브랜딩 삭제 확인",
"brandingLogoURL": "로고 URL",
"brandingLogoURLOrPath": "로고 URL 또는 경로",
"brandingLogoPathDescription": "URL 또는 로컬 경로를 입력하세요.",
"brandingLogoURLDescription": "로고 이미지에 대한 공용 URL을 입력하십시오.",
"brandingPrimaryColor": "기본 색상",
"brandingLogoWidth": "너비(px)",
"brandingLogoHeight": "높이(px)",
+23 -1
View File
@@ -201,6 +201,7 @@
"protocolSelect": "Velg en protokoll",
"resourcePortNumber": "Portnummer",
"resourcePortNumberDescription": "Det eksterne portnummeret for proxy forespørsler.",
"back": "Tilbake",
"cancel": "Avbryt",
"resourceConfig": "Konfigurasjonsutdrag",
"resourceConfigDescription": "Kopier og lim inn disse konfigurasjons-øyeblikkene for å sette opp TCP/UDP ressursen",
@@ -246,6 +247,17 @@
"orgErrorDeleteMessage": "Det oppsto en feil under sletting av organisasjonen.",
"orgDeleted": "Organisasjon slettet",
"orgDeletedMessage": "Organisasjonen og tilhørende data er slettet.",
"deleteAccount": "Slett konto",
"deleteAccountDescription": "Slett kontoen din permanent, alle organisasjoner du eier, og alle data i disse organisasjonene. Dette kan ikke angres.",
"deleteAccountButton": "Slett konto",
"deleteAccountConfirmTitle": "Slett konto",
"deleteAccountConfirmMessage": "Dette vil slette kontoen din, alle organisasjoner du eier og alle data i disse organisasjonene. Dette kan ikke gjøres om.",
"deleteAccountConfirmString": "Slett konto",
"deleteAccountSuccess": "Kontoen er slettet",
"deleteAccountSuccessMessage": "Kontoen din er slettet.",
"deleteAccountError": "Kunne ikke slette konto",
"deleteAccountPreviewAccount": "Din konto",
"deleteAccountPreviewOrgs": "Organisasjoner du eier (og alle deres data)",
"orgMissing": "Organisasjons-ID Mangler",
"orgMissingMessage": "Kan ikke regenerere invitasjon uten en organisasjons-ID.",
"accessUsersManage": "Administrer brukere",
@@ -461,6 +473,8 @@
"filterByApprovalState": "Filtrer etter godkjenningsstatus",
"approvalListEmpty": "Ingen godkjenninger",
"approvalState": "Godkjennings tilstand",
"approvalLoadMore": "Last mer",
"loadingApprovals": "Laster inn godkjenninger",
"approve": "Godkjenn",
"approved": "Godkjent",
"denied": "Avvist",
@@ -1017,6 +1031,7 @@
"pangolinSetup": "Oppsett - Pangolin",
"orgNameRequired": "Organisasjonsnavn er påkrevd",
"orgIdRequired": "Organisasjons-ID er påkrevd",
"orgIdMaxLength": "Organisasjons-ID må maksimalt være 32 tegn",
"orgErrorCreate": "En feil oppstod under oppretting av organisasjon",
"pageNotFound": "Siden ble ikke funnet",
"pageNotFoundDescription": "Oops! Siden du leter etter finnes ikke.",
@@ -1169,7 +1184,8 @@
"actionViewLogs": "Vis logger",
"noneSelected": "Ingen valgt",
"orgNotFound2": "Ingen organisasjoner funnet.",
"searchProgress": "Søker...",
"searchPlaceholder": "Søk...",
"emptySearchOptions": "Ingen valg funnet",
"create": "Opprett",
"orgs": "Organisasjoner",
"loginError": "En uventet feil oppstod. Vennligst prøv igjen.",
@@ -1251,6 +1267,7 @@
"sidebarLogAndAnalytics": "Logg og analyser",
"sidebarBluePrints": "Tegninger",
"sidebarOrganization": "Organisasjon",
"sidebarBillingAndLicenses": "Fakturering & lisenser",
"sidebarLogsAnalytics": "Analyser",
"blueprints": "Tegninger",
"blueprintsDescription": "Bruk deklarative konfigurasjoner og vis tidligere kjøringer",
@@ -1412,6 +1429,7 @@
"billingSites": "Områder",
"billingUsers": "Brukere",
"billingDomains": "Domener",
"billingOrganizations": "Orger",
"billingRemoteExitNodes": "Eksterne Noder",
"billingNoLimitConfigured": "Ingen grense konfigurert",
"billingEstimatedPeriod": "Estimert faktureringsperiode",
@@ -1454,6 +1472,7 @@
"failed": "Mislyktes",
"createNewOrgDescription": "Opprett en ny organisasjon",
"organization": "Organisasjon",
"primary": "Primær",
"port": "Port",
"securityKeyManage": "Administrer sikkerhetsnøkler",
"securityKeyDescription": "Legg til eller fjern sikkerhetsnøkler for passordløs autentisering",
@@ -1916,6 +1935,9 @@
"authPageBrandingQuestionRemove": "Er du sikker på at du vil fjerne merkevarebyggingen for autentiseringssider?",
"authPageBrandingDeleteConfirm": "Bekreft sletting av merkevarebygging",
"brandingLogoURL": "Logo URL",
"brandingLogoURLOrPath": "Logoen URL eller sti",
"brandingLogoPathDescription": "Skriv inn en URL eller en lokal bane.",
"brandingLogoURLDescription": "Skriv inn en offentlig tilgjengelig nettadresse til din logobilde.",
"brandingPrimaryColor": "Primærfarge",
"brandingLogoWidth": "Bredde (px)",
"brandingLogoHeight": "Høyde (px)",
+23 -1
View File
@@ -201,6 +201,7 @@
"protocolSelect": "Selecteer een protocol",
"resourcePortNumber": "Nummer van poort",
"resourcePortNumberDescription": "Het externe poortnummer naar proxyverzoeken.",
"back": "Achterzijde",
"cancel": "Annuleren",
"resourceConfig": "Configuratie tekstbouwstenen",
"resourceConfigDescription": "Kopieer en plak deze configuratie-snippets om de TCP/UDP-bron in te stellen",
@@ -246,6 +247,17 @@
"orgErrorDeleteMessage": "Er is een fout opgetreden tijdens het verwijderen van de organisatie.",
"orgDeleted": "Organisatie verwijderd",
"orgDeletedMessage": "De organisatie en haar gegevens zijn verwijderd.",
"deleteAccount": "Verwijder account",
"deleteAccountDescription": "Verwijdert permanent uw account, alle organisaties die u bezit, en alle gegevens binnen deze organisaties. Dit kan niet ongedaan worden gemaakt.",
"deleteAccountButton": "Verwijder account",
"deleteAccountConfirmTitle": "Verwijder account",
"deleteAccountConfirmMessage": "Dit zal uw account permanent wissen, alle organisaties die u bezit, en alle gegevens binnen deze organisaties. Dit kan niet ongedaan worden gemaakt.",
"deleteAccountConfirmString": "verwijder account",
"deleteAccountSuccess": "Account verwijderd",
"deleteAccountSuccessMessage": "Uw account is verwijderd.",
"deleteAccountError": "Kan account niet verwijderen",
"deleteAccountPreviewAccount": "Uw account",
"deleteAccountPreviewOrgs": "Organisaties die je bezit (en al hun gegevens)",
"orgMissing": "Organisatie-ID ontbreekt",
"orgMissingMessage": "Niet in staat om de uitnodiging te regenereren zonder organisatie-ID.",
"accessUsersManage": "Gebruikers beheren",
@@ -461,6 +473,8 @@
"filterByApprovalState": "Filter op goedkeuringsstatus",
"approvalListEmpty": "Geen goedkeuringen",
"approvalState": "Goedkeuring status",
"approvalLoadMore": "Meer laden",
"loadingApprovals": "Goedkeuringen laden",
"approve": "Goedkeuren",
"approved": "Goedgekeurd",
"denied": "Geweigerd",
@@ -1017,6 +1031,7 @@
"pangolinSetup": "Instellen - Pangolin",
"orgNameRequired": "Organisatienaam is vereist",
"orgIdRequired": "Organisatie-ID is vereist",
"orgIdMaxLength": "Organisatie-ID mag maximaal 32 tekens lang zijn",
"orgErrorCreate": "Fout opgetreden tijdens het aanmaken org",
"pageNotFound": "Pagina niet gevonden",
"pageNotFoundDescription": "Oeps! De pagina die je zoekt bestaat niet.",
@@ -1169,7 +1184,8 @@
"actionViewLogs": "Logboeken bekijken",
"noneSelected": "Niet geselecteerd",
"orgNotFound2": "Geen organisaties gevonden.",
"searchProgress": "Zoeken...",
"searchPlaceholder": "Zoeken...",
"emptySearchOptions": "Geen opties gevonden",
"create": "Aanmaken",
"orgs": "Organisaties",
"loginError": "Er is een onverwachte fout opgetreden. Probeer het opnieuw.",
@@ -1251,6 +1267,7 @@
"sidebarLogAndAnalytics": "Log & Analytics",
"sidebarBluePrints": "Blauwdrukken",
"sidebarOrganization": "Organisatie",
"sidebarBillingAndLicenses": "Facturatie & Licenties",
"sidebarLogsAnalytics": "Analyses",
"blueprints": "Blauwdrukken",
"blueprintsDescription": "Gebruik declaratieve configuraties en bekijk vorige uitvoeringen.",
@@ -1412,6 +1429,7 @@
"billingSites": "Sites",
"billingUsers": "Gebruikers",
"billingDomains": "Domeinen",
"billingOrganizations": "Ordenen",
"billingRemoteExitNodes": "Externe knooppunten",
"billingNoLimitConfigured": "Geen limiet ingesteld",
"billingEstimatedPeriod": "Geschatte Facturatie Periode",
@@ -1454,6 +1472,7 @@
"failed": "Mislukt",
"createNewOrgDescription": "Maak een nieuwe organisatie",
"organization": "Organisatie",
"primary": "Primair",
"port": "Poort",
"securityKeyManage": "Beveiligingssleutels beheren",
"securityKeyDescription": "Voeg beveiligingssleutels toe of verwijder ze voor wachtwoordloze authenticatie",
@@ -1916,6 +1935,9 @@
"authPageBrandingQuestionRemove": "Weet u zeker dat u de branding voor Auth-pagina's wilt verwijderen?",
"authPageBrandingDeleteConfirm": "Bevestig verwijder Branding",
"brandingLogoURL": "Het logo-URL",
"brandingLogoURLOrPath": "Logo URL of pad",
"brandingLogoPathDescription": "Voer een URL of een lokaal pad in.",
"brandingLogoURLDescription": "Voer een openbaar toegankelijke URL in voor uw logo afbeelding.",
"brandingPrimaryColor": "Primaire kleur",
"brandingLogoWidth": "Breedte (px)",
"brandingLogoHeight": "Hoogte (px)",
+23 -1
View File
@@ -201,6 +201,7 @@
"protocolSelect": "Wybierz protokół",
"resourcePortNumber": "Numer portu",
"resourcePortNumberDescription": "Numer portu zewnętrznego do żądań proxy.",
"back": "Powrót",
"cancel": "Anuluj",
"resourceConfig": "Snippety konfiguracji",
"resourceConfigDescription": "Skopiuj i wklej te fragmenty konfiguracji, aby skonfigurować zasób TCP/UDP",
@@ -246,6 +247,17 @@
"orgErrorDeleteMessage": "Wystąpił błąd podczas usuwania organizacji.",
"orgDeleted": "Organizacja usunięta",
"orgDeletedMessage": "Organizacja i jej dane zostały usunięte.",
"deleteAccount": "Usuń konto",
"deleteAccountDescription": "Trwale usuń swoje konto, wszystkie organizacje, które posiadasz, oraz wszystkie dane w ramach tych organizacji. Tej operacji nie można cofnąć.",
"deleteAccountButton": "Usuń konto",
"deleteAccountConfirmTitle": "Usuń konto",
"deleteAccountConfirmMessage": "Spowoduje to trwałe usunięcie konta, wszystkich organizacji, które posiadasz, oraz wszystkich danych w tych organizacjach. Tej operacji nie można cofnąć.",
"deleteAccountConfirmString": "usuń konto",
"deleteAccountSuccess": "Konto usunięte",
"deleteAccountSuccessMessage": "Twoje konto zostało usunięte.",
"deleteAccountError": "Nie udało się usunąć konta",
"deleteAccountPreviewAccount": "Twoje konto",
"deleteAccountPreviewOrgs": "Organizacje, które jesteś właścicielem (i wszystkie ich dane)",
"orgMissing": "Brak ID organizacji",
"orgMissingMessage": "Nie można ponownie wygenerować zaproszenia bez ID organizacji.",
"accessUsersManage": "Zarządzaj użytkownikami",
@@ -461,6 +473,8 @@
"filterByApprovalState": "Filtruj według państwa zatwierdzenia",
"approvalListEmpty": "Brak zatwierdzeń",
"approvalState": "Państwo zatwierdzające",
"approvalLoadMore": "Załaduj więcej",
"loadingApprovals": "Wczytywanie zatwierdzeń",
"approve": "Zatwierdź",
"approved": "Zatwierdzone",
"denied": "Odmowa",
@@ -1017,6 +1031,7 @@
"pangolinSetup": "Konfiguracja - Pangolin",
"orgNameRequired": "Nazwa organizacji jest wymagana",
"orgIdRequired": "ID organizacji jest wymagane",
"orgIdMaxLength": "Identyfikator organizacji musi mieć co najwyżej 32 znaki",
"orgErrorCreate": "Wystąpił błąd podczas tworzenia organizacji",
"pageNotFound": "Nie znaleziono strony",
"pageNotFoundDescription": "Ups! Strona, której szukasz, nie istnieje.",
@@ -1169,7 +1184,8 @@
"actionViewLogs": "Zobacz dzienniki",
"noneSelected": "Nie wybrano",
"orgNotFound2": "Nie znaleziono organizacji.",
"searchProgress": "Szukaj...",
"searchPlaceholder": "Szukaj...",
"emptySearchOptions": "Nie znaleziono opcji",
"create": "Utwórz",
"orgs": "Organizacje",
"loginError": "Wystąpił nieoczekiwany błąd. Spróbuj ponownie.",
@@ -1251,6 +1267,7 @@
"sidebarLogAndAnalytics": "Dziennik & Analityka",
"sidebarBluePrints": "Schematy",
"sidebarOrganization": "Organizacja",
"sidebarBillingAndLicenses": "Płatność i licencje",
"sidebarLogsAnalytics": "Analityka",
"blueprints": "Schematy",
"blueprintsDescription": "Zastosuj konfiguracje deklaracyjne i wyświetl poprzednie operacje",
@@ -1412,6 +1429,7 @@
"billingSites": "Witryny",
"billingUsers": "Użytkownicy",
"billingDomains": "Domeny",
"billingOrganizations": "O masie całkowitej pojazdu przekraczającej 5 ton, ale nieprzekraczającej 5 ton",
"billingRemoteExitNodes": "Zdalne węzły",
"billingNoLimitConfigured": "Nie skonfigurowano limitu",
"billingEstimatedPeriod": "Szacowany Okres Rozliczeniowy",
@@ -1454,6 +1472,7 @@
"failed": "Niepowodzenie",
"createNewOrgDescription": "Utwórz nową organizację",
"organization": "Organizacja",
"primary": "Podstawowy",
"port": "Port",
"securityKeyManage": "Zarządzaj kluczami bezpieczeństwa",
"securityKeyDescription": "Dodaj lub usuń klucze bezpieczeństwa do uwierzytelniania bez hasła",
@@ -1916,6 +1935,9 @@
"authPageBrandingQuestionRemove": "Czy na pewno chcesz usunąć branding dla stron uwierzytelniania?",
"authPageBrandingDeleteConfirm": "Potwierdź usunięcie brandingu",
"brandingLogoURL": "URL logo",
"brandingLogoURLOrPath": "Adres URL logo lub ścieżka",
"brandingLogoPathDescription": "Wprowadź adres URL lub ścieżkę lokalną.",
"brandingLogoURLDescription": "Wprowadź publicznie dostępny adres URL do obrazu logo.",
"brandingPrimaryColor": "Główny kolor",
"brandingLogoWidth": "Szerokość (piksele)",
"brandingLogoHeight": "Wysokość (piksele)",
+23 -1
View File
@@ -201,6 +201,7 @@
"protocolSelect": "Selecione um protocolo",
"resourcePortNumber": "Número da Porta",
"resourcePortNumberDescription": "O número da porta externa para requisições de proxy.",
"back": "Anterior",
"cancel": "cancelar",
"resourceConfig": "Snippets de Configuração",
"resourceConfigDescription": "Copie e cole estes snippets de configuração para configurar o recurso TCP/UDP",
@@ -246,6 +247,17 @@
"orgErrorDeleteMessage": "Ocorreu um erro ao apagar a organização.",
"orgDeleted": "Organização excluída",
"orgDeletedMessage": "A organização e seus dados foram excluídos.",
"deleteAccount": "Excluir Conta",
"deleteAccountDescription": "Exclua permanentemente sua conta, todas as organizações que você possui e todos os dados nessas organizações. Isso não pode ser desfeito.",
"deleteAccountButton": "Excluir Conta",
"deleteAccountConfirmTitle": "Excluir Conta",
"deleteAccountConfirmMessage": "Isto limpará permanentemente sua conta, todas as organizações que você possui e todos os dados dentro dessas organizações. Isso não pode ser desfeito.",
"deleteAccountConfirmString": "excluir conta",
"deleteAccountSuccess": "Conta excluída",
"deleteAccountSuccessMessage": "Sua conta foi excluída.",
"deleteAccountError": "Falha ao excluir conta",
"deleteAccountPreviewAccount": "Sua conta",
"deleteAccountPreviewOrgs": "Organizações que você possui (e todos os dados deles)",
"orgMissing": "ID da Organização Ausente",
"orgMissingMessage": "Não é possível regenerar o convite sem um ID de organização.",
"accessUsersManage": "Gerir Utilizadores",
@@ -461,6 +473,8 @@
"filterByApprovalState": "Filtrar por estado de aprovação",
"approvalListEmpty": "Sem aprovações",
"approvalState": "Estado de aprovação",
"approvalLoadMore": "Carregue mais",
"loadingApprovals": "Carregando aprovações",
"approve": "Aprovar",
"approved": "Aceito",
"denied": "Negado",
@@ -1017,6 +1031,7 @@
"pangolinSetup": "Configuração - Pangolin",
"orgNameRequired": "O nome da organização é obrigatório",
"orgIdRequired": "O ID da organização é obrigatório",
"orgIdMaxLength": "ID da organização deve ter no máximo 32 caracteres",
"orgErrorCreate": "Ocorreu um erro ao criar a organização",
"pageNotFound": "Página Não Encontrada",
"pageNotFoundDescription": "Ops! A página que você está procurando não existe.",
@@ -1169,7 +1184,8 @@
"actionViewLogs": "Visualizar registros",
"noneSelected": "Nenhum selecionado",
"orgNotFound2": "Nenhuma organização encontrada.",
"searchProgress": "Pesquisar...",
"searchPlaceholder": "Buscar...",
"emptySearchOptions": "Nenhuma opção encontrada",
"create": "Criar",
"orgs": "Organizações",
"loginError": "Ocorreu um erro inesperado. Por favor, tente novamente.",
@@ -1251,6 +1267,7 @@
"sidebarLogAndAnalytics": "Registo & Análise",
"sidebarBluePrints": "Diagramas",
"sidebarOrganization": "Organização",
"sidebarBillingAndLicenses": "Faturamento e Licenças",
"sidebarLogsAnalytics": "Análises",
"blueprints": "Diagramas",
"blueprintsDescription": "Aplicar configurações declarativas e ver execuções anteriores",
@@ -1412,6 +1429,7 @@
"billingSites": "sites",
"billingUsers": "Utilizadores",
"billingDomains": "Domínios",
"billingOrganizations": "Órgãos",
"billingRemoteExitNodes": "Nós remotos",
"billingNoLimitConfigured": "Nenhum limite configurado",
"billingEstimatedPeriod": "Período Estimado de Cobrança",
@@ -1454,6 +1472,7 @@
"failed": "Falhou",
"createNewOrgDescription": "Crie uma nova organização",
"organization": "Organização",
"primary": "Primário",
"port": "Porta",
"securityKeyManage": "Gerir chaves de segurança",
"securityKeyDescription": "Adicionar ou remover chaves de segurança para autenticação sem senha",
@@ -1916,6 +1935,9 @@
"authPageBrandingQuestionRemove": "Tem certeza de que deseja remover a marcação das Páginas de Autenticação?",
"authPageBrandingDeleteConfirm": "Confirmar Exclusão de Marca",
"brandingLogoURL": "URL do Logo",
"brandingLogoURLOrPath": "URL ou caminho do logotipo",
"brandingLogoPathDescription": "Insira uma URL ou um caminho local.",
"brandingLogoURLDescription": "Digite uma URL publicamente acessível para a sua imagem do logotipo.",
"brandingPrimaryColor": "Cor Primária",
"brandingLogoWidth": "Largura (px)",
"brandingLogoHeight": "Altura (px)",
+23 -1
View File
@@ -201,6 +201,7 @@
"protocolSelect": "Выберите протокол",
"resourcePortNumber": "Номер порта",
"resourcePortNumberDescription": "Внешний номер порта для проксирования запросов.",
"back": "Назад",
"cancel": "Отмена",
"resourceConfig": "Фрагменты конфигурации",
"resourceConfigDescription": "Скопируйте и вставьте эти сниппеты для настройки TCP/UDP ресурса",
@@ -246,6 +247,17 @@
"orgErrorDeleteMessage": "Произошла ошибка при удалении организации.",
"orgDeleted": "Организация удалена",
"orgDeletedMessage": "Организация и её данные были удалены.",
"deleteAccount": "Удалить аккаунт",
"deleteAccountDescription": "Окончательно удалить учетную запись, все организации, которые вы владеете, и все данные этих организаций не могут быть отменены.",
"deleteAccountButton": "Удалить аккаунт",
"deleteAccountConfirmTitle": "Удалить аккаунт",
"deleteAccountConfirmMessage": "Это очистит ваш аккаунт, все организации, которым вы владеете, и все данные этих организаций не могут быть отменены.",
"deleteAccountConfirmString": "удалить аккаунт",
"deleteAccountSuccess": "Учетная запись удалена",
"deleteAccountSuccessMessage": "Ваша учетная запись удалена.",
"deleteAccountError": "Не удалось удалить аккаунт",
"deleteAccountPreviewAccount": "Ваша учетная запись",
"deleteAccountPreviewOrgs": "Организации, которые вы владеете (и все их данные)",
"orgMissing": "Отсутствует ID организации",
"orgMissingMessage": "Невозможно восстановить приглашение без ID организации.",
"accessUsersManage": "Управление пользователями",
@@ -461,6 +473,8 @@
"filterByApprovalState": "Фильтр по состоянию утверждения",
"approvalListEmpty": "Нет утверждений",
"approvalState": "Состояние одобрения",
"approvalLoadMore": "Загрузить еще",
"loadingApprovals": "Загрузка утверждений",
"approve": "Одобрить",
"approved": "Одобрено",
"denied": "Отказано",
@@ -1017,6 +1031,7 @@
"pangolinSetup": "Настройка - Pangolin",
"orgNameRequired": "Название организации обязательно",
"orgIdRequired": "ID организации обязателен",
"orgIdMaxLength": "ID организации должен быть не более 32 символов",
"orgErrorCreate": "Произошла ошибка при создании организации",
"pageNotFound": "Страница не найдена",
"pageNotFoundDescription": "Упс! Страница, которую вы ищете, не существует.",
@@ -1169,7 +1184,8 @@
"actionViewLogs": "Просмотр журналов",
"noneSelected": "Ничего не выбрано",
"orgNotFound2": "Организации не найдены.",
"searchProgress": "Поиск...",
"searchPlaceholder": "Поиск...",
"emptySearchOptions": "Опции не найдены",
"create": "Создать",
"orgs": "Организации",
"loginError": "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз.",
@@ -1251,6 +1267,7 @@
"sidebarLogAndAnalytics": "Журнал и аналитика",
"sidebarBluePrints": "Чертежи",
"sidebarOrganization": "Организация",
"sidebarBillingAndLicenses": "Биллинг и лицензии",
"sidebarLogsAnalytics": "Статистика",
"blueprints": "Чертежи",
"blueprintsDescription": "Применить декларирующие конфигурации и просмотреть предыдущие запуски",
@@ -1412,6 +1429,7 @@
"billingSites": "Сайты",
"billingUsers": "Пользователи",
"billingDomains": "Домены",
"billingOrganizations": "Орги",
"billingRemoteExitNodes": "Удаленные узлы",
"billingNoLimitConfigured": "Лимит не установлен",
"billingEstimatedPeriod": "Предполагаемый период выставления счетов",
@@ -1454,6 +1472,7 @@
"failed": "Ошибка",
"createNewOrgDescription": "Создать новую организацию",
"organization": "Организация",
"primary": "Первичный",
"port": "Порт",
"securityKeyManage": "Управление ключами безопасности",
"securityKeyDescription": "Добавить или удалить ключи безопасности для аутентификации без пароля",
@@ -1916,6 +1935,9 @@
"authPageBrandingQuestionRemove": "Вы уверены, что хотите удалить брендирование для страниц аутентификации?",
"authPageBrandingDeleteConfirm": "Подтвердить удаление брендирования",
"brandingLogoURL": "URL логотипа",
"brandingLogoURLOrPath": "URL логотипа или путь",
"brandingLogoPathDescription": "Введите URL или локальный путь.",
"brandingLogoURLDescription": "Введите публичный URL для изображения вашего логотипа.",
"brandingPrimaryColor": "Основной цвет",
"brandingLogoWidth": "Ширина (px)",
"brandingLogoHeight": "Высота (px)",
+23 -1
View File
@@ -201,6 +201,7 @@
"protocolSelect": "Bir protokol seçin",
"resourcePortNumber": "Port Numarası",
"resourcePortNumberDescription": "Vekil istekler için harici port numarası.",
"back": "Geri",
"cancel": "İptal",
"resourceConfig": "Yapılandırma Parçaları",
"resourceConfigDescription": "TCP/UDP kaynağınızı kurmak için bu yapılandırma parçalarını kopyalayıp yapıştırın",
@@ -246,6 +247,17 @@
"orgErrorDeleteMessage": "Organizasyon silinirken bir hata oluştu.",
"orgDeleted": "Organizasyon silindi",
"orgDeletedMessage": "Organizasyon ve verileri silindi.",
"deleteAccount": "Hesabı Sil",
"deleteAccountDescription": "Hesabınızı, sahip olduğunuz tüm organizasyonları ve bu organizasyonlardaki tüm verileri kalıcı olarak silin. Bu geri alınamaz.",
"deleteAccountButton": "Hesabı Sil",
"deleteAccountConfirmTitle": "Hesabı Sil",
"deleteAccountConfirmMessage": "Bu işlem, hesabınızı, sahip olduğunuz tüm organizasyonları ve bu organizasyonlardaki tüm verileri kalıcı olarak silecektir. Bu geri alınamaz.",
"deleteAccountConfirmString": "hesabı sil",
"deleteAccountSuccess": "Hesap Silindi",
"deleteAccountSuccessMessage": "Hesabınız silindi.",
"deleteAccountError": "Hesabı silme başarısız oldu",
"deleteAccountPreviewAccount": "Hesabınız",
"deleteAccountPreviewOrgs": "Sahip olduğunuz organizasyonlar (ve tüm verileri)",
"orgMissing": "Organizasyon Kimliği Eksik",
"orgMissingMessage": "Organizasyon kimliği olmadan daveti yeniden oluşturmanız mümkün değildir.",
"accessUsersManage": "Kullanıcıları Yönet",
@@ -461,6 +473,8 @@
"filterByApprovalState": "Onay Durumuna Göre Filtrele",
"approvalListEmpty": "Onay yok",
"approvalState": "Onay Durumu",
"approvalLoadMore": "Daha fazla yükle",
"loadingApprovals": "Onaylar Yükleniyor",
"approve": "Onayla",
"approved": "Onaylandı",
"denied": "Reddedildi",
@@ -1017,6 +1031,7 @@
"pangolinSetup": "Kurulum - Pangolin",
"orgNameRequired": "Kuruluş adı gereklidir",
"orgIdRequired": "Kuruluş ID gereklidir",
"orgIdMaxLength": "Organizasyon kimliği en fazla 32 karakter olmalıdır",
"orgErrorCreate": "Kuruluş oluşturulurken bir hata oluştu",
"pageNotFound": "Sayfa Bulunamadı",
"pageNotFoundDescription": "Oops! Aradığınız sayfa mevcut değil.",
@@ -1169,7 +1184,8 @@
"actionViewLogs": "Kayıtları Görüntüle",
"noneSelected": "Hiçbiri seçili değil",
"orgNotFound2": "Hiçbir organizasyon bulunamadı.",
"searchProgress": "Ara...",
"searchPlaceholder": "Ara...",
"emptySearchOptions": "Seçenek bulunamadı",
"create": "Oluştur",
"orgs": "Organizasyonlar",
"loginError": "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.",
@@ -1251,6 +1267,7 @@
"sidebarLogAndAnalytics": "Kayıt & Analiz",
"sidebarBluePrints": "Planlar",
"sidebarOrganization": "Organizasyon",
"sidebarBillingAndLicenses": "Faturalandırma & Lisanslar",
"sidebarLogsAnalytics": "Analitik",
"blueprints": "Planlar",
"blueprintsDescription": "Deklaratif yapılandırmaları uygulayın ve önceki çalışmaları görüntüleyin",
@@ -1412,6 +1429,7 @@
"billingSites": "Siteler",
"billingUsers": "Kullanıcılar",
"billingDomains": "Alan Adları",
"billingOrganizations": "Organizasyonlar",
"billingRemoteExitNodes": "Uzak Düğümler",
"billingNoLimitConfigured": "Hiçbir limit yapılandırılmadı",
"billingEstimatedPeriod": "Tahmini Fatura Dönemi",
@@ -1454,6 +1472,7 @@
"failed": "Başarısız",
"createNewOrgDescription": "Yeni bir organizasyon oluşturun",
"organization": "Kuruluş",
"primary": "Birincil",
"port": "Bağlantı Noktası",
"securityKeyManage": "Güvenlik Anahtarlarını Yönet",
"securityKeyDescription": "Şifresiz kimlik doğrulama için güvenlik anahtarları ekleyin veya kaldırın",
@@ -1916,6 +1935,9 @@
"authPageBrandingQuestionRemove": "Kimlik Sayfaları için markayı kaldırmak istediğinizden emin misiniz?",
"authPageBrandingDeleteConfirm": "Markayı Silmeyi Onayla",
"brandingLogoURL": "Logo URL",
"brandingLogoURLOrPath": "Logo URL veya Yol",
"brandingLogoPathDescription": "Bir URL veya yerel bir yol girin.",
"brandingLogoURLDescription": "Logo resminiz için genel olarak erişilebilir bir URL girin.",
"brandingPrimaryColor": "Ana Renk",
"brandingLogoWidth": "Genişlik (px)",
"brandingLogoHeight": "Yükseklik (px)",
+23 -1
View File
@@ -201,6 +201,7 @@
"protocolSelect": "选择协议",
"resourcePortNumber": "端口号",
"resourcePortNumberDescription": "代理请求的外部端口号。",
"back": "后退",
"cancel": "取消",
"resourceConfig": "配置片段",
"resourceConfigDescription": "复制并粘贴这些配置片段以设置 TCP/UDP 资源",
@@ -246,6 +247,17 @@
"orgErrorDeleteMessage": "删除组织时出错。",
"orgDeleted": "组织已删除",
"orgDeletedMessage": "组织及其数据已被删除。",
"deleteAccount": "删除帐户",
"deleteAccountDescription": "永久删除您的帐户、您拥有的所有组织以及这些组织中的所有数据。此操作无法撤消。",
"deleteAccountButton": "删除帐户",
"deleteAccountConfirmTitle": "删除帐户",
"deleteAccountConfirmMessage": "这将永久擦除您的帐户、您拥有的所有组织以及这些组织中的所有数据。这不能撤消。",
"deleteAccountConfirmString": "删除帐户",
"deleteAccountSuccess": "账户已删除",
"deleteAccountSuccessMessage": "您的帐户已被删除。",
"deleteAccountError": "删除帐户失败",
"deleteAccountPreviewAccount": "您的帐户",
"deleteAccountPreviewOrgs": "您拥有的组织 (和所有数据)",
"orgMissing": "缺少组织 ID",
"orgMissingMessage": "没有组织ID,无法重新生成邀请。",
"accessUsersManage": "管理用户",
@@ -461,6 +473,8 @@
"filterByApprovalState": "按批准状态过滤",
"approvalListEmpty": "无批准",
"approvalState": "审批状态",
"approvalLoadMore": "加载更多",
"loadingApprovals": "正在加载批准",
"approve": "批准",
"approved": "已批准",
"denied": "被拒绝",
@@ -1017,6 +1031,7 @@
"pangolinSetup": "认证 - Pangolin",
"orgNameRequired": "组织名称是必需的",
"orgIdRequired": "组织ID是必需的",
"orgIdMaxLength": "组织 ID 必须至少 32 个字符",
"orgErrorCreate": "创建组织时出错",
"pageNotFound": "找不到页面",
"pageNotFoundDescription": "哎呀!您正在查找的页面不存在。",
@@ -1169,7 +1184,8 @@
"actionViewLogs": "查看日志",
"noneSelected": "未选择",
"orgNotFound2": "未找到组织。",
"searchProgress": "搜索...",
"searchPlaceholder": "搜索...",
"emptySearchOptions": "未找到选项",
"create": "创建",
"orgs": "组织",
"loginError": "发生意外错误。请重试。",
@@ -1251,6 +1267,7 @@
"sidebarLogAndAnalytics": "日志与分析",
"sidebarBluePrints": "蓝图",
"sidebarOrganization": "组织",
"sidebarBillingAndLicenses": "帐单和许可证",
"sidebarLogsAnalytics": "分析",
"blueprints": "蓝图",
"blueprintsDescription": "应用声明配置并查看先前运行的",
@@ -1412,6 +1429,7 @@
"billingSites": "站点",
"billingUsers": "用户",
"billingDomains": "域",
"billingOrganizations": "球队",
"billingRemoteExitNodes": "远程节点",
"billingNoLimitConfigured": "未配置限制",
"billingEstimatedPeriod": "估计结算周期",
@@ -1454,6 +1472,7 @@
"failed": "失败",
"createNewOrgDescription": "创建一个新组织",
"organization": "组织",
"primary": "主要的",
"port": "端口",
"securityKeyManage": "管理安全密钥",
"securityKeyDescription": "添加或删除用于无密码认证的安全密钥",
@@ -1916,6 +1935,9 @@
"authPageBrandingQuestionRemove": "您确定要移除授权页面的品牌吗?",
"authPageBrandingDeleteConfirm": "确认删除品牌",
"brandingLogoURL": "Logo URL",
"brandingLogoURLOrPath": "徽标URL或路径",
"brandingLogoPathDescription": "输入网址或本地路径。",
"brandingLogoURLDescription": "请在您的徽标图片中输入一个可公开访问的 URL。",
"brandingPrimaryColor": "主要颜色",
"brandingLogoWidth": "宽度(px",
"brandingLogoHeight": "高度(px",
+3 -1
View File
@@ -55,7 +55,9 @@ export const orgs = pgTable("orgs", {
.notNull()
.default(0),
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
sshCaPublicKey: text("sshCaPublicKey") // SSH CA public key (OpenSSH format)
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
isBillingOrg: boolean("isBillingOrg"),
billingOrgId: varchar("billingOrgId")
});
export const orgDomains = pgTable("orgDomains", {
+3 -1
View File
@@ -47,7 +47,9 @@ export const orgs = sqliteTable("orgs", {
.notNull()
.default(0),
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
sshCaPublicKey: text("sshCaPublicKey") // SSH CA public key (OpenSSH format)
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
billingOrgId: text("billingOrgId")
});
export const userDomains = sqliteTable("userDomains", {
+3
View File
@@ -4,6 +4,7 @@ export enum FeatureId {
EGRESS_DATA_MB = "egressDataMb",
DOMAINS = "domains",
REMOTE_EXIT_NODES = "remoteExitNodes",
ORGINIZATIONS = "organizations",
TIER1 = "tier1"
}
@@ -19,6 +20,8 @@ export async function getFeatureDisplayName(featureId: FeatureId): Promise<strin
return "Domains";
case FeatureId.REMOTE_EXIT_NODES:
return "Remote Exit Nodes";
case FeatureId.ORGINIZATIONS:
return "Organizations";
case FeatureId.TIER1:
return "Home Lab";
default:
+10 -7
View File
@@ -7,18 +7,12 @@ export type LimitSet = Partial<{
};
}>;
export const sandboxLimitSet: LimitSet = {
[FeatureId.USERS]: { value: 1, description: "Sandbox limit" },
[FeatureId.SITES]: { value: 1, description: "Sandbox limit" },
[FeatureId.DOMAINS]: { value: 0, description: "Sandbox limit" },
[FeatureId.REMOTE_EXIT_NODES]: { value: 0, description: "Sandbox limit" },
};
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" },
};
export const tier1LimitSet: LimitSet = {
@@ -26,6 +20,7 @@ export const tier1LimitSet: LimitSet = {
[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" },
};
export const tier2LimitSet: LimitSet = {
@@ -45,6 +40,10 @@ export const tier2LimitSet: LimitSet = {
value: 3,
description: "Team limit"
},
[FeatureId.ORGINIZATIONS]: {
value: 1,
description: "Team limit"
}
};
export const tier3LimitSet: LimitSet = {
@@ -64,4 +63,8 @@ export const tier3LimitSet: LimitSet = {
value: 20,
description: "Business limit"
},
[FeatureId.ORGINIZATIONS]: {
value: 5,
description: "Business limit"
},
};
+72 -203
View File
@@ -1,34 +1,19 @@
import { eq, sql, and } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import {
db,
usage,
customers,
sites,
newts,
limits,
Usage,
Limit,
Transaction
Transaction,
orgs
} from "@server/db";
import { FeatureId, getFeatureMeterId } from "./features";
import logger from "@server/logger";
import { sendToClient } from "#dynamic/routers/ws";
import { build } from "@server/build";
import { s3Client } from "@server/lib/s3";
import cache from "@server/lib/cache";
interface StripeEvent {
identifier?: string;
timestamp: number;
event_name: string;
payload: {
value: number;
stripe_customer_id: string;
};
}
export function noop() {
if (build !== "saas") {
return true;
@@ -37,41 +22,11 @@ export function noop() {
}
export class UsageService {
private bucketName: string | undefined;
private events: StripeEvent[] = [];
private lastUploadTime: number = Date.now();
private isUploading: boolean = false;
constructor() {
if (noop()) {
return;
}
// this.bucketName = process.env.S3_BUCKET || undefined;
// // Periodically check and upload events
// setInterval(() => {
// this.checkAndUploadEvents().catch((err) => {
// logger.error("Error in periodic event upload:", err);
// });
// }, 30000); // every 30 seconds
// // Handle graceful shutdown on SIGTERM
// process.on("SIGTERM", async () => {
// logger.info(
// "SIGTERM received, uploading events before shutdown..."
// );
// await this.forceUpload();
// logger.info("Events uploaded, proceeding with shutdown");
// });
// // Handle SIGINT as well (Ctrl+C)
// process.on("SIGINT", async () => {
// logger.info("SIGINT received, uploading events before shutdown...");
// await this.forceUpload();
// logger.info("Events uploaded, proceeding with shutdown");
// process.exit(0);
// });
}
/**
@@ -91,6 +46,8 @@ export class UsageService {
return null;
}
let orgIdToUse = await this.getBillingOrg(orgId, transaction);
// Truncate value to 11 decimal places
value = this.truncateValue(value);
@@ -100,20 +57,10 @@ export class UsageService {
while (attempt <= maxRetries) {
try {
// Get subscription data for this org (with caching)
const customerId = await this.getCustomerId(orgId, featureId);
if (!customerId) {
logger.warn(
`No subscription data found for org ${orgId} and feature ${featureId}`
);
return null;
}
let usage;
if (transaction) {
usage = await this.internalAddUsage(
orgId,
orgIdToUse,
featureId,
value,
transaction
@@ -121,7 +68,7 @@ export class UsageService {
} else {
await db.transaction(async (trx) => {
usage = await this.internalAddUsage(
orgId,
orgIdToUse,
featureId,
value,
trx
@@ -129,11 +76,6 @@ export class UsageService {
});
}
// Log event for Stripe
// if (privateConfig.getRawPrivateConfig().flags.usage_reporting) {
// await this.logStripeEvent(featureId, value, customerId);
// }
return usage || null;
} catch (error: any) {
// Check if this is a deadlock error
@@ -150,7 +92,7 @@ export class UsageService {
const delay = baseDelay + jitter;
logger.warn(
`Deadlock detected for ${orgId}/${featureId}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`
`Deadlock detected for ${orgIdToUse}/${featureId}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`
);
await new Promise((resolve) => setTimeout(resolve, delay));
@@ -158,7 +100,7 @@ export class UsageService {
}
logger.error(
`Failed to add usage for ${orgId}/${featureId} after ${attempt} attempts:`,
`Failed to add usage for ${orgIdToUse}/${featureId} after ${attempt} attempts:`,
error
);
break;
@@ -169,7 +111,7 @@ export class UsageService {
}
private async internalAddUsage(
orgId: string,
orgId: string, // here the orgId is the billing org already resolved by getBillingOrg in updateCount
featureId: FeatureId,
value: number,
trx: Transaction
@@ -188,17 +130,22 @@ export class UsageService {
featureId,
orgId,
meterId,
latestValue: value,
instantaneousValue: value || 0,
latestValue: value || 0,
updatedAt: Math.floor(Date.now() / 1000)
})
.onConflictDoUpdate({
target: usage.usageId,
set: {
latestValue: sql`${usage.latestValue} + ${value}`
instantaneousValue: sql`COALESCE(${usage.instantaneousValue}, 0) + ${value}`
}
})
.returning();
logger.debug(
`Added usage for org ${orgId} feature ${featureId}: +${value}, new instantaneousValue: ${returnUsage.instantaneousValue}`
);
return returnUsage;
}
@@ -221,18 +168,10 @@ export class UsageService {
if (noop()) {
return;
}
try {
if (!customerId) {
customerId =
(await this.getCustomerId(orgId, featureId)) || undefined;
if (!customerId) {
logger.warn(
`No subscription data found for org ${orgId} and feature ${featureId}`
);
return;
}
}
let orgIdToUse = await this.getBillingOrg(orgId);
try {
// Truncate value to 11 decimal places if provided
if (value !== undefined && value !== null) {
value = this.truncateValue(value);
@@ -242,7 +181,7 @@ export class UsageService {
await db.transaction(async (trx) => {
// Get existing meter record
const usageId = `${orgId}-${featureId}`;
const usageId = `${orgIdToUse}-${featureId}`;
// Get current usage record
[currentUsage] = await trx
.select()
@@ -264,7 +203,7 @@ export class UsageService {
await trx.insert(usage).values({
usageId,
featureId,
orgId,
orgId: orgIdToUse,
meterId,
instantaneousValue: value || 0,
latestValue: value || 0,
@@ -278,7 +217,7 @@ export class UsageService {
// }
} catch (error) {
logger.error(
`Failed to update count usage for ${orgId}/${featureId}:`,
`Failed to update count usage for ${orgIdToUse}/${featureId}:`,
error
);
}
@@ -288,7 +227,9 @@ export class UsageService {
orgId: string,
featureId: FeatureId
): Promise<string | null> {
const cacheKey = `customer_${orgId}_${featureId}`;
let orgIdToUse = await this.getBillingOrg(orgId);
const cacheKey = `customer_${orgIdToUse}_${featureId}`;
const cached = cache.get<string>(cacheKey);
if (cached) {
@@ -302,7 +243,7 @@ export class UsageService {
customerId: customers.customerId
})
.from(customers)
.where(eq(customers.orgId, orgId))
.where(eq(customers.orgId, orgIdToUse))
.limit(1);
if (!customer) {
@@ -317,112 +258,13 @@ export class UsageService {
return customerId;
} catch (error) {
logger.error(
`Failed to get subscription data for ${orgId}/${featureId}:`,
`Failed to get subscription data for ${orgIdToUse}/${featureId}:`,
error
);
return null;
}
}
private async logStripeEvent(
featureId: FeatureId,
value: number,
customerId: string
): Promise<void> {
// Truncate value to 11 decimal places before sending to Stripe
const truncatedValue = this.truncateValue(value);
const event: StripeEvent = {
identifier: uuidv4(),
timestamp: Math.floor(new Date().getTime() / 1000),
event_name: featureId,
payload: {
value: truncatedValue,
stripe_customer_id: customerId
}
};
this.addEventToMemory(event);
await this.checkAndUploadEvents();
}
private addEventToMemory(event: StripeEvent): void {
if (!this.bucketName) {
logger.warn(
"S3 bucket name is not configured, skipping event storage."
);
return;
}
this.events.push(event);
}
private async checkAndUploadEvents(): Promise<void> {
const now = Date.now();
const timeSinceLastUpload = now - this.lastUploadTime;
// Check if at least 1 minute has passed since last upload
if (timeSinceLastUpload >= 60000 && this.events.length > 0) {
await this.uploadEventsToS3();
}
}
private async uploadEventsToS3(): Promise<void> {
if (!this.bucketName) {
logger.warn(
"S3 bucket name is not configured, skipping S3 upload."
);
return;
}
if (this.events.length === 0) {
return;
}
// Check if already uploading
if (this.isUploading) {
logger.debug("Already uploading events, skipping");
return;
}
this.isUploading = true;
try {
// Take a snapshot of current events and clear the array
const eventsToUpload = [...this.events];
this.events = [];
this.lastUploadTime = Date.now();
const fileName = this.generateEventFileName();
const fileContent = JSON.stringify(eventsToUpload, null, 2);
// Upload to S3
const uploadCommand = new PutObjectCommand({
Bucket: this.bucketName,
Key: fileName,
Body: fileContent,
ContentType: "application/json"
});
await s3Client.send(uploadCommand);
logger.info(
`Uploaded ${fileName} to S3 with ${eventsToUpload.length} events`
);
} catch (error) {
logger.error("Failed to upload events to S3:", error);
// Note: Events are lost if upload fails. In a production system,
// you might want to add the events back to the array or implement retry logic
} finally {
this.isUploading = false;
}
}
private generateEventFileName(): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const uuid = uuidv4().substring(0, 8);
return `events-${timestamp}-${uuid}.json`;
}
public async getUsage(
orgId: string,
featureId: FeatureId,
@@ -432,7 +274,9 @@ export class UsageService {
return null;
}
const usageId = `${orgId}-${featureId}`;
let orgIdToUse = await this.getBillingOrg(orgId, trx);
const usageId = `${orgIdToUse}-${featureId}`;
try {
const [result] = await trx
@@ -444,7 +288,7 @@ export class UsageService {
if (!result) {
// Lets create one if it doesn't exist using upsert to handle race conditions
logger.info(
`Creating new usage record for ${orgId}/${featureId}`
`Creating new usage record for ${orgIdToUse}/${featureId}`
);
const meterId = getFeatureMeterId(featureId);
@@ -454,7 +298,7 @@ export class UsageService {
.values({
usageId,
featureId,
orgId,
orgId: orgIdToUse,
meterId,
latestValue: 0,
updatedAt: Math.floor(Date.now() / 1000)
@@ -476,7 +320,7 @@ export class UsageService {
} catch (insertError) {
// Fallback: try to fetch existing record in case of any insert issues
logger.warn(
`Insert failed for ${orgId}/${featureId}, attempting to fetch existing record:`,
`Insert failed for ${orgIdToUse}/${featureId}, attempting to fetch existing record:`,
insertError
);
const [existingUsage] = await trx
@@ -491,19 +335,41 @@ export class UsageService {
return result;
} catch (error) {
logger.error(
`Failed to get usage for ${orgId}/${featureId}:`,
`Failed to get usage for ${orgIdToUse}/${featureId}:`,
error
);
throw error;
}
}
public async forceUpload(): Promise<void> {
if (this.events.length > 0) {
// Force upload regardless of time
this.lastUploadTime = 0; // Reset to force upload
await this.uploadEventsToS3();
public async getBillingOrg(
orgId: string,
trx: Transaction | typeof db = db
): Promise<string> {
let orgIdToUse = orgId;
// get the org
const [org] = await trx
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org) {
throw new Error(`Organization with ID ${orgId} not found`);
}
if (!org.isBillingOrg) {
if (org.billingOrgId) {
orgIdToUse = org.billingOrgId;
} else {
throw new Error(
`Organization ${orgId} is not a billing org and does not have a billingOrgId set`
);
}
}
return orgIdToUse;
}
public async checkLimitSet(
@@ -515,6 +381,9 @@ export class UsageService {
if (noop()) {
return false;
}
let 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;
@@ -528,7 +397,7 @@ export class UsageService {
.from(limits)
.where(
and(
eq(limits.orgId, orgId),
eq(limits.orgId, orgIdToUse),
eq(limits.featureId, featureId)
)
);
@@ -537,11 +406,11 @@ export class UsageService {
orgLimits = await trx
.select()
.from(limits)
.where(eq(limits.orgId, orgId));
.where(eq(limits.orgId, orgIdToUse));
}
if (orgLimits.length === 0) {
logger.debug(`No limits set for org ${orgId}`);
logger.debug(`No limits set for org ${orgIdToUse}`);
return false;
}
@@ -552,7 +421,7 @@ export class UsageService {
currentUsage = usage;
} else {
currentUsage = await this.getUsage(
orgId,
orgIdToUse,
limit.featureId as FeatureId,
trx
);
@@ -563,10 +432,10 @@ export class UsageService {
currentUsage?.latestValue ||
0;
logger.debug(
`Current usage for org ${orgId} on feature ${limit.featureId}: ${usageValue}`
`Current usage for org ${orgIdToUse} on feature ${limit.featureId}: ${usageValue}`
);
logger.debug(
`Limit for org ${orgId} on feature ${limit.featureId}: ${limit.value}`
`Limit for org ${orgIdToUse} on feature ${limit.featureId}: ${limit.value}`
);
if (
currentUsage &&
@@ -574,7 +443,7 @@ export class UsageService {
usageValue > limit.value
) {
logger.debug(
`Org ${orgId} has exceeded limit for ${limit.featureId}: ` +
`Org ${orgIdToUse} has exceeded limit for ${limit.featureId}: ` +
`${usageValue} > ${limit.value}`
);
hasExceededLimits = true;
@@ -582,7 +451,7 @@ export class UsageService {
}
}
} catch (error) {
logger.error(`Error checking limits for org ${orgId}:`, error);
logger.error(`Error checking limits for org ${orgIdToUse}:`, error);
}
return hasExceededLimits;
-206
View File
@@ -1,206 +0,0 @@
import { isValidCIDR } from "@server/lib/validators";
import { getNextAvailableOrgSubnet } from "@server/lib/ip";
import {
actions,
apiKeyOrg,
apiKeys,
db,
domains,
Org,
orgDomains,
orgs,
roleActions,
roles,
userOrgs
} from "@server/db";
import { eq } from "drizzle-orm";
import { defaultRoleAllowedActions } from "@server/routers/role";
import { FeatureId, limitsService, sandboxLimitSet } from "@server/lib/billing";
import { createCustomer } from "#dynamic/lib/billing";
import { usageService } from "@server/lib/billing/usageService";
import config from "@server/lib/config";
import { generateCA } from "@server/private/lib/sshCA";
import { encrypt } from "@server/lib/crypto";
export async function createUserAccountOrg(
userId: string,
userEmail: string
): Promise<{
success: boolean;
org?: {
orgId: string;
name: string;
subnet: string;
};
error?: string;
}> {
// const subnet = await getNextAvailableOrgSubnet();
const orgId = "org_" + userId;
const name = `${userEmail}'s Organization`;
// if (!isValidCIDR(subnet)) {
// return {
// success: false,
// error: "Invalid subnet format. Please provide a valid CIDR notation."
// };
// }
// // make sure the subnet is unique
// const subnetExists = await db
// .select()
// .from(orgs)
// .where(eq(orgs.subnet, subnet))
// .limit(1);
// if (subnetExists.length > 0) {
// return { success: false, error: `Subnet ${subnet} already exists` };
// }
// make sure the orgId is unique
const orgExists = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (orgExists.length > 0) {
return {
success: false,
error: `Organization with ID ${orgId} already exists`
};
}
let error = "";
let org: Org | null = null;
await db.transaction(async (trx) => {
const allDomains = await trx
.select()
.from(domains)
.where(eq(domains.configManaged, true));
const utilitySubnet = config.getRawConfig().orgs.utility_subnet_group;
// Generate SSH CA keys for the org
// const ca = generateCA(`${orgId}-ca`);
// const encryptionKey = config.getRawConfig().server.secret!;
// const encryptedCaPrivateKey = encrypt(ca.privateKeyPem, encryptionKey);
const newOrg = await trx
.insert(orgs)
.values({
orgId,
name,
// subnet
subnet: "100.90.128.0/24", // TODO: this should not be hardcoded - or can it be the same in all orgs?
utilitySubnet: utilitySubnet,
createdAt: new Date().toISOString(),
// sshCaPrivateKey: encryptedCaPrivateKey,
// sshCaPublicKey: ca.publicKeyOpenSSH
})
.returning();
if (newOrg.length === 0) {
error = "Failed to create organization";
trx.rollback();
return;
}
org = newOrg[0];
// Create admin role within the same transaction
const [insertedRole] = await trx
.insert(roles)
.values({
orgId: newOrg[0].orgId,
isAdmin: true,
name: "Admin",
description: "Admin role with the most permissions"
})
.returning({ roleId: roles.roleId });
if (!insertedRole || !insertedRole.roleId) {
error = "Failed to create Admin role";
trx.rollback();
return;
}
const roleId = insertedRole.roleId;
// Get all actions and create role actions
const actionIds = await trx.select().from(actions).execute();
if (actionIds.length > 0) {
await trx.insert(roleActions).values(
actionIds.map((action) => ({
roleId,
actionId: action.actionId,
orgId: newOrg[0].orgId
}))
);
}
if (allDomains.length) {
await trx.insert(orgDomains).values(
allDomains.map((domain) => ({
orgId: newOrg[0].orgId,
domainId: domain.domainId
}))
);
}
await trx.insert(userOrgs).values({
userId,
orgId: newOrg[0].orgId,
roleId: roleId,
isOwner: true
});
const memberRole = await trx
.insert(roles)
.values({
name: "Member",
description: "Members can only view resources",
orgId
})
.returning();
await trx.insert(roleActions).values(
defaultRoleAllowedActions.map((action) => ({
roleId: memberRole[0].roleId,
actionId: action,
orgId
}))
);
});
await limitsService.applyLimitSetToOrg(orgId, sandboxLimitSet);
if (!org) {
return { success: false, error: "Failed to create org" };
}
if (error) {
return {
success: false,
error: `Failed to create org: ${error}`
};
}
// make sure we have the stripe customer
const customerId = await createCustomer(orgId, userEmail);
if (customerId) {
await usageService.updateCount(orgId, FeatureId.USERS, 1, customerId); // Only 1 because we are crating the org
}
return {
org: {
orgId,
name,
// subnet
subnet: "100.90.128.0/24"
},
success: true
};
}
+78 -2
View File
@@ -4,14 +4,18 @@ import {
clientSitesAssociationsCache,
db,
domains,
exitNodeOrgs,
exitNodes,
olms,
orgDomains,
orgs,
remoteExitNodes,
resources,
sites
sites,
userOrgs
} from "@server/db";
import { newts, newtSessions } from "@server/db";
import { eq, and, inArray, sql } from "drizzle-orm";
import { eq, and, inArray, sql, count, countDistinct } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
@@ -19,6 +23,8 @@ import { sendToClient } from "#dynamic/routers/ws";
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";
export type DeleteOrgByIdResult = {
deletedNewtIds: string[];
@@ -60,6 +66,11 @@ export async function deleteOrgById(
const deletedNewtIds: string[] = [];
const olmsToTerminate: string[] = [];
let domainCount: number | null = null;
let siteCount: number | null = null;
let userCount: number | null = null;
let remoteExitNodeCount: number | null = null;
await db.transaction(async (trx) => {
for (const site of orgSites) {
if (site.pubKey) {
@@ -137,9 +148,74 @@ export async function deleteOrgById(
.where(inArray(domains.domainId, domainIdsToDelete));
}
await trx.delete(resources).where(eq(resources.orgId, orgId));
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 trx.delete(orgs).where(eq(orgs.orgId, orgId));
if (org.billingOrgId) {
const billingOrgs = await trx
.select()
.from(orgs)
.where(eq(orgs.billingOrgId, org.billingOrgId));
if (billingOrgs.length > 0) {
const billingOrgIds = billingOrgs.map((org) => org.orgId);
const [domainCountRes] = await trx
.select({ count: count() })
.from(orgDomains)
.where(inArray(orgDomains.orgId, billingOrgIds));
domainCount = domainCountRes.count;
const [siteCountRes] = await trx
.select({ count: count() })
.from(sites)
.where(inArray(sites.orgId, billingOrgIds));
siteCount = siteCountRes.count;
const [userCountRes] = await trx
.select({ count: countDistinct(userOrgs.userId) })
.from(userOrgs)
.where(inArray(userOrgs.orgId, billingOrgIds));
userCount = userCountRes.count;
const [remoteExitNodeCountRes] = await trx
.select({ count: countDistinct(exitNodeOrgs.exitNodeId) })
.from(exitNodeOrgs)
.where(inArray(exitNodeOrgs.orgId, billingOrgIds));
remoteExitNodeCount = remoteExitNodeCountRes.count;
}
}
});
if (org.billingOrgId) {
usageService.updateCount(
org.billingOrgId,
FeatureId.DOMAINS,
domainCount ?? 0
);
usageService.updateCount(
org.billingOrgId,
FeatureId.SITES,
siteCount ?? 0
);
usageService.updateCount(
org.billingOrgId,
FeatureId.USERS,
userCount ?? 0
);
usageService.updateCount(
org.billingOrgId,
FeatureId.REMOTE_EXIT_NODES,
remoteExitNodeCount ?? 0
);
}
return { deletedNewtIds, olmsToTerminate };
}
+142
View File
@@ -0,0 +1,142 @@
import {
db,
Org,
orgs,
resources,
siteResources,
sites,
Transaction,
UserOrg,
userOrgs,
userResources,
userSiteResources,
userSites
} 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";
export async function assignUserToOrg(
org: Org,
values: typeof userOrgs.$inferInsert,
trx: Transaction | typeof db = db
) {
const [userOrg] = await trx.insert(userOrgs).values(values).returning();
// calculate if the user is in any other of the orgs before we count it as an add to the billing org
if (org.billingOrgId) {
const otherBillingOrgs = await trx
.select()
.from(orgs)
.where(
and(
eq(orgs.billingOrgId, org.billingOrgId),
ne(orgs.orgId, org.orgId)
)
);
const billingOrgIds = otherBillingOrgs.map((o) => o.orgId);
const orgsInBillingDomainThatTheUserIsStillIn = await trx
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, userOrg.userId),
inArray(userOrgs.orgId, billingOrgIds)
)
);
if (orgsInBillingDomainThatTheUserIsStillIn.length === 0) {
await usageService.add(org.orgId, FeatureId.USERS, 1, trx);
}
}
}
export async function removeUserFromOrg(
org: Org,
userId: string,
trx: Transaction | typeof db = db
) {
await trx
.delete(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId)));
await trx.delete(userResources).where(
and(
eq(userResources.userId, userId),
exists(
trx
.select()
.from(resources)
.where(
and(
eq(resources.resourceId, userResources.resourceId),
eq(resources.orgId, org.orgId)
)
)
)
)
);
await trx.delete(userSiteResources).where(
and(
eq(userSiteResources.userId, userId),
exists(
trx
.select()
.from(siteResources)
.where(
and(
eq(
siteResources.siteResourceId,
userSiteResources.siteResourceId
),
eq(siteResources.orgId, org.orgId)
)
)
)
)
);
await trx.delete(userSites).where(
and(
eq(userSites.userId, userId),
exists(
db
.select()
.from(sites)
.where(
and(
eq(sites.siteId, userSites.siteId),
eq(sites.orgId, org.orgId)
)
)
)
)
);
// calculate if the user is in any other of the orgs before we count it as an remove to the billing org
if (org.billingOrgId) {
const billingOrgs = await trx
.select()
.from(orgs)
.where(eq(orgs.billingOrgId, org.billingOrgId));
const billingOrgIds = billingOrgs.map((o) => o.orgId);
const orgsInBillingDomainThatTheUserIsStillIn = await trx
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, userId),
inArray(userOrgs.orgId, billingOrgIds)
)
);
if (orgsInBillingDomainThatTheUserIsStillIn.length === 0) {
await usageService.add(org.orgId, FeatureId.USERS, -1, trx);
}
}
}
+49 -25
View File
@@ -12,7 +12,8 @@
*/
import { build } from "@server/build";
import { db, customers, subscriptions } from "@server/db";
import { db, customers, subscriptions, orgs } from "@server/db";
import logger from "@server/logger";
import { Tier } from "@server/types/Tiers";
import { eq, and, ne } from "drizzle-orm";
@@ -27,37 +28,60 @@ export async function getOrgTierData(
}
try {
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org) {
return { tier, active };
}
let orgIdToUse = org.orgId;
if (!org.isBillingOrg) {
if (!org.billingOrgId) {
logger.warn(
`Org ${orgId} is not a billing org and does not have a billingOrgId`
);
return { tier, active };
}
orgIdToUse = org.billingOrgId;
}
// Get customer for org
const [customer] = await db
.select()
.from(customers)
.where(eq(customers.orgId, orgId))
.where(eq(customers.orgId, orgIdToUse))
.limit(1);
if (customer) {
// Query for active subscriptions that are not license type
const [subscription] = await db
.select()
.from(subscriptions)
.where(
and(
eq(subscriptions.customerId, customer.customerId),
eq(subscriptions.status, "active"),
ne(subscriptions.type, "license")
)
)
.limit(1);
if (!customer) {
return { tier, active };
}
if (subscription) {
// Validate that subscription.type is one of the expected tier values
if (
subscription.type === "tier1" ||
subscription.type === "tier2" ||
subscription.type === "tier3"
) {
tier = subscription.type;
active = true;
}
// Query for active subscriptions that are not license type
const [subscription] = await db
.select()
.from(subscriptions)
.where(
and(
eq(subscriptions.customerId, customer.customerId),
eq(subscriptions.status, "active"),
ne(subscriptions.type, "license")
)
)
.limit(1);
if (subscription) {
// Validate that subscription.type is one of the expected tier values
if (
subscription.type === "tier1" ||
subscription.type === "tier2" ||
subscription.type === "tier3"
) {
tier = subscription.type;
active = true;
}
}
} catch (error) {
@@ -15,7 +15,18 @@ import { SubscriptionType } from "./hooks/getSubType";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { Tier } from "@server/types/Tiers";
import logger from "@server/logger";
import { db, idp, idpOrg, loginPage, loginPageBranding, loginPageBrandingOrg, loginPageOrg, orgs, resources, roles } from "@server/db";
import {
db,
idp,
idpOrg,
loginPage,
loginPageBranding,
loginPageBrandingOrg,
loginPageOrg,
orgs,
resources,
roles
} from "@server/db";
import { eq } from "drizzle-orm";
/**
@@ -59,10 +70,7 @@ async function capRetentionDays(
}
// Get current org settings
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId));
if (!org) {
logger.warn(`Org ${orgId} not found when capping retention days`);
@@ -110,18 +118,13 @@ async function capRetentionDays(
// Apply updates if needed
if (needsUpdate) {
await db
.update(orgs)
.set(updates)
.where(eq(orgs.orgId, orgId));
await db.update(orgs).set(updates).where(eq(orgs.orgId, orgId));
logger.info(
`Successfully capped retention days for org ${orgId} to max ${maxRetentionDays} days`
);
} else {
logger.debug(
`No retention day capping needed for org ${orgId}`
);
logger.debug(`No retention day capping needed for org ${orgId}`);
}
}
@@ -134,6 +137,35 @@ export async function handleTierChange(
`Handling tier change for org ${orgId}: ${previousTier || "none"} -> ${newTier || "free"}`
);
// Get all orgs that have this orgId as their billingOrgId
const associatedOrgs = await db
.select()
.from(orgs)
.where(eq(orgs.billingOrgId, orgId));
logger.info(
`Found ${associatedOrgs.length} org(s) associated with billing org ${orgId}`
);
// Loop over all associated orgs and apply tier changes
for (const org of associatedOrgs) {
await handleTierChangeForOrg(org.orgId, newTier, previousTier);
}
logger.info(
`Completed tier change handling for all orgs associated with billing org ${orgId}`
);
}
async function handleTierChangeForOrg(
orgId: string,
newTier: SubscriptionType | null,
previousTier?: SubscriptionType | null
): Promise<void> {
logger.info(
`Handling tier change for org ${orgId}: ${previousTier || "none"} -> ${newTier || "free"}`
);
// License subscriptions are handled separately and don't use the tier matrix
if (newTier === "license") {
logger.debug(
@@ -314,9 +346,7 @@ async function disableLoginPageDomain(orgId: string): Promise<void> {
);
if (existingLoginPage) {
await db
.delete(loginPageOrg)
.where(eq(loginPageOrg.orgId, orgId));
await db.delete(loginPageOrg).where(eq(loginPageOrg.orgId, orgId));
await db
.delete(loginPage)
@@ -112,11 +112,13 @@ export async function getOrgSubscriptionsData(
throw new Error(`Not found`);
}
const billingOrgId = org[0].billingOrgId || org[0].orgId;
// Get customer for org
const customer = await db
.select()
.from(customers)
.where(eq(customers.orgId, orgId))
.where(eq(customers.orgId, billingOrgId))
.limit(1);
const subscriptionsWithItems: Array<{
+12 -5
View File
@@ -85,10 +85,14 @@ export async function getOrgUsage(
orgId,
FeatureId.REMOTE_EXIT_NODES
);
const egressData = await usageService.getUsage(
const organizations = await usageService.getUsage(
orgId,
FeatureId.EGRESS_DATA_MB
FeatureId.ORGINIZATIONS
);
// const egressData = await usageService.getUsage(
// orgId,
// FeatureId.EGRESS_DATA_MB
// );
if (sites) {
usageData.push(sites);
@@ -96,15 +100,18 @@ export async function getOrgUsage(
if (users) {
usageData.push(users);
}
if (egressData) {
usageData.push(egressData);
}
// if (egressData) {
// usageData.push(egressData);
// }
if (domains) {
usageData.push(domains);
}
if (remoteExitNodes) {
usageData.push(remoteExitNodes);
}
if (organizations) {
usageData.push(organizations);
}
const orgLimits = await db
.select()
@@ -12,7 +12,14 @@
*/
import { NextFunction, Request, Response } from "express";
import { db, exitNodes, exitNodeOrgs, ExitNode, ExitNodeOrg } from "@server/db";
import {
db,
exitNodes,
exitNodeOrgs,
ExitNode,
ExitNodeOrg,
orgs
} from "@server/db";
import HttpCode from "@server/types/HttpCode";
import { z } from "zod";
import { remoteExitNodes } from "@server/db";
@@ -25,7 +32,7 @@ import { createRemoteExitNodeSession } from "#private/auth/sessions/remoteExitNo
import { fromError } from "zod-validation-error";
import { hashPassword, verifyPassword } from "@server/auth/password";
import logger from "@server/logger";
import { and, eq } from "drizzle-orm";
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";
@@ -169,7 +176,17 @@ export async function createRemoteExitNode(
);
}
let numExitNodeOrgs: ExitNodeOrg[] | undefined;
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Organization not found")
);
}
await db.transaction(async (trx) => {
if (!existingExitNode) {
@@ -217,19 +234,43 @@ export async function createRemoteExitNode(
});
}
numExitNodeOrgs = await trx
.select()
.from(exitNodeOrgs)
.where(eq(exitNodeOrgs.orgId, orgId));
});
// calculate if the node is in any other of the orgs before we count it as an add to the billing org
if (org.billingOrgId) {
const otherBillingOrgs = await trx
.select()
.from(orgs)
.where(
and(
eq(orgs.billingOrgId, org.billingOrgId),
ne(orgs.orgId, orgId)
)
);
if (numExitNodeOrgs) {
await usageService.updateCount(
orgId,
FeatureId.REMOTE_EXIT_NODES,
numExitNodeOrgs.length
);
}
const billingOrgIds = otherBillingOrgs.map((o) => o.orgId);
const orgsInBillingDomainThatTheNodeIsStillIn = await trx
.select()
.from(exitNodeOrgs)
.where(
and(
eq(
exitNodeOrgs.exitNodeId,
existingExitNode.exitNodeId
),
inArray(exitNodeOrgs.orgId, billingOrgIds)
)
);
if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) {
await usageService.add(
orgId,
FeatureId.REMOTE_EXIT_NODES,
1,
trx
);
}
}
});
const token = generateSessionToken();
await createRemoteExitNodeSession(token, remoteExitNodeId);
@@ -13,9 +13,9 @@
import { NextFunction, Request, Response } from "express";
import { z } from "zod";
import { db, ExitNodeOrg, exitNodeOrgs, exitNodes } from "@server/db";
import { db, ExitNodeOrg, exitNodeOrgs, exitNodes, orgs } from "@server/db";
import { remoteExitNodes } from "@server/db";
import { and, count, eq } from "drizzle-orm";
import { and, count, eq, inArray } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -50,7 +50,8 @@ export async function deleteRemoteExitNode(
const [remoteExitNode] = await db
.select()
.from(remoteExitNodes)
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId));
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId))
.limit(1);
if (!remoteExitNode) {
return next(
@@ -70,7 +71,17 @@ export async function deleteRemoteExitNode(
);
}
let numExitNodeOrgs: ExitNodeOrg[] | undefined;
const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId));
if (!org) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Org with ID ${orgId} not found`
)
);
}
await db.transaction(async (trx) => {
await trx
.delete(exitNodeOrgs)
@@ -81,38 +92,39 @@ export async function deleteRemoteExitNode(
)
);
const [remainingExitNodeOrgs] = await trx
.select({ count: count() })
.from(exitNodeOrgs)
.where(eq(exitNodeOrgs.exitNodeId, remoteExitNode.exitNodeId!));
// calculate if the user is in any other of the orgs before we count it as an remove to the billing org
if (org.billingOrgId) {
const otherBillingOrgs = await trx
.select()
.from(orgs)
.where(eq(orgs.billingOrgId, org.billingOrgId));
if (remainingExitNodeOrgs.count === 0) {
await trx
.delete(remoteExitNodes)
const billingOrgIds = otherBillingOrgs.map((o) => o.orgId);
const orgsInBillingDomainThatTheNodeIsStillIn = await trx
.select()
.from(exitNodeOrgs)
.where(
eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)
and(
eq(
exitNodeOrgs.exitNodeId,
remoteExitNode.exitNodeId!
),
inArray(exitNodeOrgs.orgId, billingOrgIds)
)
);
await trx
.delete(exitNodes)
.where(
eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId!)
if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) {
await usageService.add(
orgId,
FeatureId.REMOTE_EXIT_NODES,
-1,
trx
);
}
}
numExitNodeOrgs = await trx
.select()
.from(exitNodeOrgs)
.where(eq(exitNodeOrgs.orgId, orgId));
});
if (numExitNodeOrgs) {
await usageService.updateCount(
orgId,
FeatureId.REMOTE_EXIT_NODES,
numExitNodeOrgs.length
);
}
return response(res, {
data: null,
success: true,
+24 -10
View File
@@ -15,6 +15,8 @@ import {
import { verifyPassword } from "@server/auth/password";
import { verifyTotpCode } from "@server/auth/totp";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { build } from "@server/build";
import { getOrgTierData } from "#dynamic/lib/billing";
import {
deleteOrgById,
sendTerminationMessages
@@ -40,11 +42,6 @@ export type DeleteMyAccountSuccessResponse = {
success: true;
};
/**
* Self-service account deletion (saas only). Returns preview when no password;
* requires password and optional 2FA code to perform deletion. Uses shared
* deleteOrgById for each owned org (delete-my-account may delete multiple orgs).
*/
export async function deleteMyAccount(
req: Request,
res: Response,
@@ -91,18 +88,35 @@ export async function deleteMyAccount(
const ownedOrgsRows = await db
.select({
orgId: userOrgs.orgId
orgId: userOrgs.orgId,
isOwner: userOrgs.isOwner,
isBillingOrg: orgs.isBillingOrg
})
.from(userOrgs)
.innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId))
.where(
and(
eq(userOrgs.userId, userId),
eq(userOrgs.isOwner, true)
)
and(eq(userOrgs.userId, userId), eq(userOrgs.isOwner, true))
);
const orgIds = ownedOrgsRows.map((r) => r.orgId);
if (build === "saas" && orgIds.length > 0) {
const primaryOrgId = ownedOrgsRows.find(
(r) => r.isBillingOrg && r.isOwner
)?.orgId;
if (primaryOrgId) {
const { tier, active } = await getOrgTierData(primaryOrgId);
if (active && tier) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"You must cancel your subscription before deleting your account"
)
);
}
}
}
if (!password) {
const orgsWithNames =
orgIds.length > 0
+15 -26
View File
@@ -1,7 +1,7 @@
import { NextFunction, Request, Response } from "express";
import { db, users } from "@server/db";
import HttpCode from "@server/types/HttpCode";
import { z } from "zod";
import { email, z } from "zod";
import { fromError } from "zod-validation-error";
import createHttpError from "http-errors";
import response from "@server/lib/response";
@@ -21,7 +21,6 @@ import { hashPassword } from "@server/auth/password";
import { checkValidInvite } from "@server/auth/checkValidInvite";
import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes";
import { createUserAccountOrg } from "@server/lib/createUserAccountOrg";
import { build } from "@server/build";
import resend, { AudienceIds, moveEmailToAudience } from "#dynamic/lib/resend";
@@ -31,7 +30,8 @@ export const signupBodySchema = z.object({
inviteToken: z.string().optional(),
inviteId: z.string().optional(),
termsAcceptedTimestamp: z.string().nullable().optional(),
marketingEmailConsent: z.boolean().optional()
marketingEmailConsent: z.boolean().optional(),
skipVerificationEmail: z.boolean().optional()
});
export type SignUpBody = z.infer<typeof signupBodySchema>;
@@ -62,7 +62,8 @@ export async function signup(
inviteToken,
inviteId,
termsAcceptedTimestamp,
marketingEmailConsent
marketingEmailConsent,
skipVerificationEmail
} = parsedBody.data;
const passwordHash = await hashPassword(password);
@@ -198,26 +199,6 @@ export async function signup(
// orgId: null,
// });
if (build == "saas") {
const { success, error, org } = await createUserAccountOrg(
userId,
email
);
if (!success) {
if (error) {
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, error)
);
}
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create user account and organization"
)
);
}
}
const token = generateSessionToken();
const sess = await createSession(token, userId);
const isSecure = req.protocol === "https";
@@ -235,7 +216,13 @@ export async function signup(
}
if (config.getRawConfig().flags?.require_email_verification) {
sendEmailVerificationCode(email, userId);
if (!skipVerificationEmail) {
sendEmailVerificationCode(email, userId);
} else {
logger.debug(
`User ${email} opted out of verification email during signup.`
);
}
return response<SignUpResponse>(res, {
data: {
@@ -243,7 +230,9 @@ export async function signup(
},
success: true,
error: false,
message: `User created successfully. We sent an email to ${email} with a verification code.`,
message: skipVerificationEmail
? "User created successfully. Please verify your email."
: `User created successfully. We sent an email to ${email} with a verification code.`,
status: HttpCode.OK
});
}
+1 -13
View File
@@ -148,7 +148,6 @@ export async function createOrgDomain(
}
}
let numOrgDomains: OrgDomains[] | undefined;
let aRecords: CreateDomainResponse["aRecords"];
let cnameRecords: CreateDomainResponse["cnameRecords"];
let txtRecords: CreateDomainResponse["txtRecords"];
@@ -347,20 +346,9 @@ export async function createOrgDomain(
await trx.insert(dnsRecords).values(recordsToInsert);
}
numOrgDomains = await trx
.select()
.from(orgDomains)
.where(eq(orgDomains.orgId, orgId));
await usageService.add(orgId, FeatureId.DOMAINS, 1, trx);
});
if (numOrgDomains) {
await usageService.updateCount(
orgId,
FeatureId.DOMAINS,
numOrgDomains.length
);
}
if (!returned) {
return next(
createHttpError(
+1 -14
View File
@@ -36,8 +36,6 @@ export async function deleteAccountDomain(
}
const { domainId, orgId } = parsed.data;
let numOrgDomains: OrgDomains[] | undefined;
await db.transaction(async (trx) => {
const [existing] = await trx
.select()
@@ -79,20 +77,9 @@ export async function deleteAccountDomain(
await trx.delete(domains).where(eq(domains.domainId, domainId));
numOrgDomains = await trx
.select()
.from(orgDomains)
.where(eq(orgDomains.orgId, orgId));
await usageService.add(orgId, FeatureId.DOMAINS, -1, trx);
});
if (numOrgDomains) {
await usageService.updateCount(
orgId,
FeatureId.DOMAINS,
numOrgDomains.length
);
}
return response<DeleteAccountDomainResponse>(res, {
data: { success: true },
success: true,
+10 -13
View File
@@ -65,9 +65,8 @@ authenticated.use(verifySessionUserMiddleware);
authenticated.get("/pick-org-defaults", org.pickOrgDefaults);
authenticated.get("/org/checkId", org.checkId);
if (build === "oss" || build === "enterprise") {
authenticated.put("/org", getUserOrgs, org.createOrg);
}
authenticated.put("/org", getUserOrgs, org.createOrg);
authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs);
authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);
@@ -87,16 +86,14 @@ authenticated.post(
org.updateOrg
);
if (build !== "saas") {
authenticated.delete(
"/org/:orgId",
verifyOrgAccess,
verifyUserIsOrgOwner,
verifyUserHasAction(ActionsEnum.deleteOrg),
logActionAudit(ActionsEnum.deleteOrg),
org.deleteOrg
);
}
authenticated.delete(
"/org/:orgId",
verifyOrgAccess,
verifyUserIsOrgOwner,
verifyUserHasAction(ActionsEnum.deleteOrg),
logActionAudit(ActionsEnum.deleteOrg),
org.deleteOrg
);
authenticated.put(
"/org/:orgId/site",
+52 -22
View File
@@ -36,6 +36,10 @@ import { build } from "@server/build";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import {
assignUserToOrg,
removeUserFromOrg
} from "@server/lib/userOrg";
const ensureTrailingSlash = (url: string): string => {
return url;
@@ -436,6 +440,7 @@ export async function validateOidcCallback(
}
}
// These are the orgs that the user should be provisioned into based on the IdP mappings and the token claims
logger.debug("User org info", { userOrgInfo });
let existingUserId = existingUser?.userId;
@@ -454,15 +459,32 @@ export async function validateOidcCallback(
);
if (!existingUserOrgs.length) {
// delete all auto -provisioned user orgs
await db
.delete(userOrgs)
// delete all auto-provisioned user orgs
const autoProvisionedUserOrgs = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, existingUser.userId),
eq(userOrgs.autoProvisioned, true)
)
);
const orgIdsToRemove = autoProvisionedUserOrgs.map(
(uo) => uo.orgId
);
if (orgIdsToRemove.length > 0) {
const orgsToRemove = await db
.select()
.from(orgs)
.where(inArray(orgs.orgId, orgIdsToRemove));
for (const org of orgsToRemove) {
await removeUserFromOrg(
org,
existingUser.userId,
db
);
}
}
await calculateUserClientsForOrgs(existingUser.userId);
@@ -484,7 +506,7 @@ export async function validateOidcCallback(
}
}
const orgUserCounts: { orgId: string; userCount: number }[] = [];
const orgUserCounts: { orgId: string; userCount: number }[] = [];
// sync the user with the orgs and roles
await db.transaction(async (trx) => {
@@ -538,15 +560,14 @@ export async function validateOidcCallback(
);
if (orgsToDelete.length > 0) {
await trx.delete(userOrgs).where(
and(
eq(userOrgs.userId, userId!),
inArray(
userOrgs.orgId,
orgsToDelete.map((org) => org.orgId)
)
)
);
const orgIdsToRemove = orgsToDelete.map((org) => org.orgId);
const fullOrgsToRemove = await trx
.select()
.from(orgs)
.where(inArray(orgs.orgId, orgIdsToRemove));
for (const org of fullOrgsToRemove) {
await removeUserFromOrg(org, userId!, trx);
}
}
// Update roles for existing auto-provisioned orgs where the role has changed
@@ -587,15 +608,24 @@ export async function validateOidcCallback(
);
if (orgsToAdd.length > 0) {
await trx.insert(userOrgs).values(
orgsToAdd.map((org) => ({
userId: userId!,
orgId: org.orgId,
roleId: org.roleId,
autoProvisioned: true,
dateCreated: new Date().toISOString()
}))
);
for (const org of orgsToAdd) {
const [fullOrg] = await trx
.select()
.from(orgs)
.where(eq(orgs.orgId, org.orgId));
if (fullOrg) {
await assignUserToOrg(
fullOrg,
{
orgId: org.orgId,
userId: userId!,
roleId: org.roleId,
autoProvisioned: true,
},
trx
);
}
}
}
// Loop through all the orgs and get the total number of users from the userOrgs table
+110 -7
View File
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { eq } from "drizzle-orm";
import { and, count, eq } from "drizzle-orm";
import {
domains,
Org,
@@ -24,15 +24,24 @@ 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 } from "@server/lib/billing";
import { FeatureId, limitsService, freeLimitSet } from "@server/lib/billing";
import { build } from "@server/build";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { doCidrsOverlap } from "@server/lib/ip";
import { generateCA } from "@server/private/lib/sshCA";
import { encrypt } from "@server/lib/crypto";
const validOrgIdRegex = /^[a-z0-9_]+(-[a-z0-9_]+)*$/;
const createOrgSchema = z.strictObject({
orgId: z.string(),
orgId: z
.string()
.min(1, "Organization ID is required")
.max(32, "Organization ID must be at most 32 characters")
.refine((val) => validOrgIdRegex.test(val), {
message:
"Organization ID must contain only lowercase letters, numbers, underscores, and single hyphens (no leading, trailing, or consecutive hyphens)"
}),
name: z.string().min(1).max(255),
subnet: z
// .union([z.cidrv4(), z.cidrv6()])
@@ -110,6 +119,7 @@ export async function createOrg(
// )
// );
// }
//
// make sure the orgId is unique
const orgExists = await db
@@ -136,8 +146,71 @@ export async function createOrg(
);
}
let isFirstOrg: boolean | null = null;
let billingOrgIdForNewOrg: string | null = null;
if (build === "saas" && req.user) {
const ownedOrgs = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, req.user.userId),
eq(userOrgs.isOwner, true)
)
);
if (ownedOrgs.length === 0) {
isFirstOrg = true;
} else {
isFirstOrg = false;
const [billingOrg] = await db
.select({ orgId: orgs.orgId })
.from(orgs)
.innerJoin(userOrgs, eq(orgs.orgId, userOrgs.orgId))
.where(
and(
eq(userOrgs.userId, req.user.userId),
eq(userOrgs.isOwner, true),
eq(orgs.isBillingOrg, true)
)
)
.limit(1);
if (billingOrg) {
billingOrgIdForNewOrg = billingOrg.orgId;
}
}
}
if (build == "saas" && billingOrgIdForNewOrg) {
const usage = await usageService.getUsage(billingOrgIdForNewOrg, FeatureId.ORGINIZATIONS);
if (!usage) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"No usage data found for this organization"
)
);
}
const rejectOrgs = await usageService.checkLimitSet(
billingOrgIdForNewOrg,
FeatureId.ORGINIZATIONS,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
} // We need to add one to know if we are violating the limit
);
if (rejectOrgs) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Organization limit exceeded. Please upgrade your plan."
)
);
}
}
let error = "";
let org: Org | null = null;
let numOrgs: number | null = null;
await db.transaction(async (trx) => {
const allDomains = await trx
@@ -145,11 +218,21 @@ export async function createOrg(
.from(domains)
.where(eq(domains.configManaged, true));
// // Generate SSH CA keys for the org
// Generate SSH CA keys for the org
// const ca = generateCA(`${orgId}-ca`);
// const encryptionKey = config.getRawConfig().server.secret!;
// const encryptedCaPrivateKey = encrypt(ca.privateKeyPem, encryptionKey);
const saasBillingFields =
build === "saas" && req.user && isFirstOrg !== null
? isFirstOrg
? { isBillingOrg: true as const, billingOrgId: orgId } // if this is the first org, it becomes the billing org for itself
: {
isBillingOrg: false as const,
billingOrgId: billingOrgIdForNewOrg
}
: {};
const newOrg = await trx
.insert(orgs)
.values({
@@ -159,7 +242,8 @@ export async function createOrg(
utilitySubnet,
createdAt: new Date().toISOString(),
// sshCaPrivateKey: encryptedCaPrivateKey,
// sshCaPublicKey: ca.publicKeyOpenSSH
// sshCaPublicKey: ca.publicKeyOpenSSH,
...saasBillingFields
})
.returning();
@@ -261,6 +345,17 @@ export async function createOrg(
);
await calculateUserClientsForOrgs(ownerUserId, trx);
if (billingOrgIdForNewOrg) {
const [numOrgsResult] = await trx
.select({ count: count() })
.from(orgs)
.where(eq(orgs.billingOrgId, billingOrgIdForNewOrg)); // all the billable orgs including the primary org that is the billing org itself
numOrgs = numOrgsResult.count;
} else {
numOrgs = 1; // we only have one org if there is no billing org found out
}
});
if (!org) {
@@ -276,8 +371,8 @@ export async function createOrg(
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, error));
}
if (build == "saas") {
// make sure we have the stripe customer
if (build === "saas" && isFirstOrg === true) {
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
const customerId = await createCustomer(orgId, req.user?.email);
if (customerId) {
await usageService.updateCount(
@@ -289,6 +384,14 @@ export async function createOrg(
}
}
if (numOrgs) {
usageService.updateCount(
billingOrgIdForNewOrg || orgId,
FeatureId.ORGINIZATIONS,
numOrgs
);
}
return response(res, {
data: org,
success: true,
+44
View File
@@ -7,6 +7,8 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg";
import { db, userOrgs, orgs } from "@server/db";
import { eq, and } from "drizzle-orm";
const deleteOrgSchema = z.strictObject({
orgId: z.string()
@@ -41,6 +43,48 @@ export async function deleteOrg(
);
}
const { orgId } = parsedParams.data;
const [data] = await db
.select()
.from(userOrgs)
.innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId))
.where(
and(
eq(userOrgs.orgId, orgId),
eq(userOrgs.userId, req.user!.userId)
)
);
const org = data?.orgs;
const userOrg = data?.userOrgs;
if (!org || !userOrg) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Organization with ID ${orgId} not found`
)
);
}
if (!userOrg.isOwner) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Only organization owners can delete the organization"
)
);
}
if (org.isBillingOrg) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Cannot delete a primary organization"
)
);
}
const result = await deleteOrgById(orgId);
sendTerminationMessages(result);
return response(res, {
+8 -1
View File
@@ -40,7 +40,11 @@ const listOrgsSchema = z.object({
// responses: {}
// });
type ResponseOrg = Org & { isOwner?: boolean; isAdmin?: boolean };
type ResponseOrg = Org & {
isOwner?: boolean;
isAdmin?: boolean;
isPrimaryOrg?: boolean;
};
export type ListUserOrgsResponse = {
orgs: ResponseOrg[];
@@ -132,6 +136,9 @@ export async function listUserOrgs(
if (val.roles && val.roles.isAdmin) {
res.isAdmin = val.roles.isAdmin;
}
if (val.userOrgs?.isOwner && val.orgs?.isBillingOrg) {
res.isPrimaryOrg = val.orgs.isBillingOrg;
}
return res;
});
+99 -14
View File
@@ -8,7 +8,10 @@ import {
userOrgs,
resourcePassword,
resourcePincode,
resourceWhitelist
resourceWhitelist,
siteResources,
userSiteResources,
roleSiteResources
} from "@server/db";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
@@ -57,9 +60,21 @@ export async function getUserResources(
.from(roleResources)
.where(eq(roleResources.roleId, userRoleId));
const [directResources, roleResourceResults] = await Promise.all([
const directSiteResourcesQuery = db
.select({ siteResourceId: userSiteResources.siteResourceId })
.from(userSiteResources)
.where(eq(userSiteResources.userId, userId));
const roleSiteResourcesQuery = db
.select({ siteResourceId: roleSiteResources.siteResourceId })
.from(roleSiteResources)
.where(eq(roleSiteResources.roleId, userRoleId));
const [directResources, roleResourceResults, directSiteResourceResults, roleSiteResourceResults] = await Promise.all([
directResourcesQuery,
roleResourcesQuery
roleResourcesQuery,
directSiteResourcesQuery,
roleSiteResourcesQuery
]);
// Combine all accessible resource IDs
@@ -68,18 +83,25 @@ export async function getUserResources(
...roleResourceResults.map((r) => r.resourceId)
];
if (accessibleResourceIds.length === 0) {
return response(res, {
data: { resources: [] },
success: true,
error: false,
message: "No resources found",
status: HttpCode.OK
});
}
// Combine all accessible site resource IDs
const accessibleSiteResourceIds = [
...directSiteResourceResults.map((r) => r.siteResourceId),
...roleSiteResourceResults.map((r) => r.siteResourceId)
];
// Get resource details for accessible resources
const resourcesData = await db
let resourcesData: Array<{
resourceId: number;
name: string;
fullDomain: string | null;
ssl: boolean;
enabled: boolean;
sso: boolean;
protocol: string;
emailWhitelistEnabled: boolean;
}> = [];
if (accessibleResourceIds.length > 0) {
resourcesData = await db
.select({
resourceId: resources.resourceId,
name: resources.name,
@@ -98,6 +120,40 @@ export async function getUserResources(
eq(resources.enabled, true)
)
);
}
// Get site resource details for accessible site resources
let siteResourcesData: Array<{
siteResourceId: number;
name: string;
destination: string;
mode: string;
protocol: string | null;
enabled: boolean;
alias: string | null;
aliasAddress: string | null;
}> = [];
if (accessibleSiteResourceIds.length > 0) {
siteResourcesData = await db
.select({
siteResourceId: siteResources.siteResourceId,
name: siteResources.name,
destination: siteResources.destination,
mode: siteResources.mode,
protocol: siteResources.protocol,
enabled: siteResources.enabled,
alias: siteResources.alias,
aliasAddress: siteResources.aliasAddress
})
.from(siteResources)
.where(
and(
inArray(siteResources.siteResourceId, accessibleSiteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true)
)
);
}
// Check for password, pincode, and whitelist protection for each resource
const resourcesWithAuth = await Promise.all(
@@ -161,8 +217,26 @@ export async function getUserResources(
})
);
// Format site resources
const siteResourcesFormatted = siteResourcesData.map((siteResource) => {
return {
siteResourceId: siteResource.siteResourceId,
name: siteResource.name,
destination: siteResource.destination,
mode: siteResource.mode,
protocol: siteResource.protocol,
enabled: siteResource.enabled,
alias: siteResource.alias,
aliasAddress: siteResource.aliasAddress,
type: 'site' as const
};
});
return response(res, {
data: { resources: resourcesWithAuth },
data: {
resources: resourcesWithAuth,
siteResources: siteResourcesFormatted
},
success: true,
error: false,
message: "User resources retrieved successfully",
@@ -190,5 +264,16 @@ export type GetUserResourcesResponse = {
protected: boolean;
protocol: string;
}>;
siteResources: Array<{
siteResourceId: number;
name: string;
destination: string;
mode: string;
protocol: string | null;
enabled: boolean;
alias: string | null;
aliasAddress: string | null;
type: 'site';
}>;
};
};
+2 -14
View File
@@ -6,7 +6,7 @@ import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { eq, and } from "drizzle-orm";
import { eq, and, count } from "drizzle-orm";
import { getUniqueSiteName } from "../../db/names";
import { addPeer } from "../gerbil/peers";
import { fromError } from "zod-validation-error";
@@ -288,7 +288,6 @@ export async function createSite(
const niceId = await getUniqueSiteName(orgId);
let newSite: Site | undefined;
let numSites: Site[] | undefined;
await db.transaction(async (trx) => {
if (type == "newt") {
[newSite] = await trx
@@ -443,20 +442,9 @@ export async function createSite(
});
}
numSites = await trx
.select()
.from(sites)
.where(eq(sites.orgId, orgId));
await usageService.add(orgId, FeatureId.SITES, 1, trx);
});
if (numSites) {
await usageService.updateCount(
orgId,
FeatureId.SITES,
numSites.length
);
}
if (!newSite) {
return next(
createHttpError(
+1 -12
View File
@@ -64,7 +64,6 @@ export async function deleteSite(
}
let deletedNewtId: string | null = null;
let numSites: Site[] | undefined;
await db.transaction(async (trx) => {
if (site.type == "wireguard") {
@@ -103,19 +102,9 @@ export async function deleteSite(
await trx.delete(sites).where(eq(sites.siteId, siteId));
numSites = await trx
.select()
.from(sites)
.where(eq(sites.orgId, site.orgId));
await usageService.add(site.orgId, FeatureId.SITES, -1, trx);
});
if (numSites) {
await usageService.updateCount(
site.orgId,
FeatureId.SITES,
numSites.length
);
}
// Send termination message outside of transaction to prevent blocking
if (deletedNewtId) {
const payload = {
+28 -24
View File
@@ -1,8 +1,8 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, UserOrg } from "@server/db";
import { db, orgs, UserOrg } from "@server/db";
import { roles, userInvites, userOrgs, users } from "@server/db";
import { eq } from "drizzle-orm";
import { eq, and, inArray, ne } 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 { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { build } from "@server/build";
import { assignUserToOrg } from "@server/lib/userOrg";
const acceptInviteBodySchema = z.strictObject({
token: z.string(),
@@ -125,8 +126,22 @@ export async function acceptInvite(
}
}
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, existingInvite.orgId))
.limit(1);
if (!org) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Organization does not exist. Please contact an admin."
)
);
}
let roleId: number;
let totalUsers: UserOrg[] | undefined;
// get the role to make sure it exists
const existingRole = await db
.select()
@@ -146,12 +161,15 @@ export async function acceptInvite(
}
await db.transaction(async (trx) => {
// add the user to the org
await trx.insert(userOrgs).values({
userId: existingUser[0].userId,
orgId: existingInvite.orgId,
roleId: existingInvite.roleId
});
await assignUserToOrg(
org,
{
userId: existingUser[0].userId,
orgId: existingInvite.orgId,
roleId: existingInvite.roleId
},
trx
);
// delete the invite
await trx
@@ -160,25 +178,11 @@ export async function acceptInvite(
await calculateUserClientsForOrgs(existingUser[0].userId, trx);
// Get the total number of users in the org now
totalUsers = await trx
.select()
.from(userOrgs)
.where(eq(userOrgs.orgId, existingInvite.orgId));
logger.debug(
`User ${existingUser[0].userId} accepted invite to org ${existingInvite.orgId}. Total users in org: ${totalUsers.length}`
`User ${existingUser[0].userId} accepted invite to org ${existingInvite.orgId}`
);
});
if (totalUsers) {
await usageService.updateCount(
existingInvite.orgId,
FeatureId.USERS,
totalUsers.length
);
}
return response<AcceptInviteResponse>(res, {
data: { accepted: true, orgId: existingInvite.orgId },
success: true,
+26 -32
View File
@@ -6,8 +6,8 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { db, UserOrg } from "@server/db";
import { and, eq } from "drizzle-orm";
import { db, orgs, UserOrg } from "@server/db";
import { and, eq, inArray, ne } 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";
@@ -16,6 +16,7 @@ import { build } from "@server/build";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { assignUserToOrg } from "@server/lib/userOrg";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
@@ -151,6 +152,21 @@ export async function createOrgUser(
);
}
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Organization not found"
)
);
}
const [idpRes] = await db
.select()
.from(idp)
@@ -172,8 +188,6 @@ export async function createOrgUser(
);
}
let orgUsers: UserOrg[] | undefined;
await db.transaction(async (trx) => {
const [existingUser] = await trx
.select()
@@ -207,15 +221,12 @@ export async function createOrgUser(
);
}
await trx
.insert(userOrgs)
.values({
orgId,
userId: existingUser.userId,
roleId: role.roleId,
autoProvisioned: false
})
.returning();
await assignUserToOrg(org, {
orgId,
userId: existingUser.userId,
roleId: role.roleId,
autoProvisioned: false
}, trx);
} else {
userId = generateId(15);
@@ -233,33 +244,16 @@ export async function createOrgUser(
})
.returning();
await trx
.insert(userOrgs)
.values({
await assignUserToOrg(org, {
orgId,
userId: newUser.userId,
roleId: role.roleId,
autoProvisioned: false
})
.returning();
}, trx);
}
// List all of the users in the org
orgUsers = await trx
.select()
.from(userOrgs)
.where(eq(userOrgs.orgId, orgId));
await calculateUserClientsForOrgs(userId, trx);
});
if (orgUsers) {
await usageService.updateCount(
orgId,
FeatureId.USERS,
orgUsers.length
);
}
} else {
return next(
createHttpError(HttpCode.BAD_REQUEST, "User type is required")
+26 -61
View File
@@ -1,8 +1,16 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resources, sites, UserOrg } from "@server/db";
import {
db,
orgs,
resources,
siteResources,
sites,
UserOrg,
userSiteResources
} from "@server/db";
import { userOrgs, userResources, users, userSites } from "@server/db";
import { and, count, eq, exists } from "drizzle-orm";
import { and, count, eq, exists, inArray } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -14,6 +22,7 @@ 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";
const removeUserSchema = z.strictObject({
userId: z.string(),
@@ -50,16 +59,16 @@ export async function removeUserOrg(
const { userId, orgId } = parsedParams.data;
// get the user first
const user = await db
const [user] = await db
.select()
.from(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)));
if (!user || user.length === 0) {
if (!user) {
return next(createHttpError(HttpCode.NOT_FOUND, "User not found"));
}
if (user[0].isOwner) {
if (user.isOwner) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@@ -68,56 +77,20 @@ export async function removeUserOrg(
);
}
let userCount: UserOrg[] | undefined;
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Organization not found")
);
}
await db.transaction(async (trx) => {
await trx
.delete(userOrgs)
.where(
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
);
await db.delete(userResources).where(
and(
eq(userResources.userId, userId),
exists(
db
.select()
.from(resources)
.where(
and(
eq(
resources.resourceId,
userResources.resourceId
),
eq(resources.orgId, orgId)
)
)
)
)
);
await db.delete(userSites).where(
and(
eq(userSites.userId, userId),
exists(
db
.select()
.from(sites)
.where(
and(
eq(sites.siteId, userSites.siteId),
eq(sites.orgId, orgId)
)
)
)
)
);
userCount = await trx
.select()
.from(userOrgs)
.where(eq(userOrgs.orgId, orgId));
await removeUserFromOrg(org, userId, trx);
// if (build === "saas") {
// const [rootUser] = await trx
@@ -139,14 +112,6 @@ export async function removeUserOrg(
await calculateUserClientsForOrgs(userId, trx);
});
if (userCount) {
await usageService.updateCount(
orgId,
FeatureId.USERS,
userCount.length
);
}
return response(res, {
data: null,
success: true,
@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import { build } from "@server/build";
type BillingSettingsProps = {
children: React.ReactNode;
@@ -17,6 +18,9 @@ export default async function BillingSettingsPage({
params
}: BillingSettingsProps) {
const { orgId } = await params;
if (build !== "saas") {
redirect(`/${orgId}/settings`);
}
const user = await verifySession();
@@ -40,6 +44,10 @@ export default async function BillingSettingsPage({
redirect(`/${orgId}`);
}
if (!(org?.org?.isBillingOrg && orgUser?.isOwner)) {
redirect(`/${orgId}`);
}
const t = await getTranslations();
return (
@@ -110,37 +110,42 @@ const planOptions: PlanOption[] = [
// Tier limits mapping derived from limit sets
const tierLimits: Record<
Tier | "basic",
{ users: number; sites: number; domains: number; remoteNodes: number }
{ users: number; sites: number; domains: number; remoteNodes: number; organizations: number }
> = {
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
remoteNodes: freeLimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
organizations: freeLimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
},
tier1: {
users: tier1LimitSet[FeatureId.USERS]?.value ?? 0,
sites: tier1LimitSet[FeatureId.SITES]?.value ?? 0,
domains: tier1LimitSet[FeatureId.DOMAINS]?.value ?? 0,
remoteNodes: tier1LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
remoteNodes: tier1LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
organizations: tier1LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
},
tier2: {
users: tier2LimitSet[FeatureId.USERS]?.value ?? 0,
sites: tier2LimitSet[FeatureId.SITES]?.value ?? 0,
domains: tier2LimitSet[FeatureId.DOMAINS]?.value ?? 0,
remoteNodes: tier2LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
remoteNodes: tier2LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
organizations: tier2LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
},
tier3: {
users: tier3LimitSet[FeatureId.USERS]?.value ?? 0,
sites: tier3LimitSet[FeatureId.SITES]?.value ?? 0,
domains: tier3LimitSet[FeatureId.DOMAINS]?.value ?? 0,
remoteNodes: tier3LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
remoteNodes: tier3LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
organizations: tier3LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
},
enterprise: {
users: 0, // Custom for enterprise
sites: 0, // Custom for enterprise
domains: 0, // Custom for enterprise
remoteNodes: 0 // Custom for enterprise
remoteNodes: 0, // Custom for enterprise
organizations: 0 // Custom for enterprise
}
};
@@ -179,6 +184,7 @@ export default function BillingPage() {
const SITES = "sites";
const DOMAINS = "domains";
const REMOTE_EXIT_NODES = "remoteExitNodes";
const ORGINIZATIONS = "organizations";
// Confirmation dialog state
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
@@ -619,6 +625,16 @@ export default function BillingPage() {
});
}
// Check organizations
const organizationsUsage = getUsageValue(ORGINIZATIONS);
if (limits.organizations > 0 && organizationsUsage > limits.organizations) {
violations.push({
feature: "Organizations",
currentUsage: organizationsUsage,
newLimit: limits.organizations
});
}
return violations;
};
@@ -752,7 +768,7 @@ export default function BillingPage() {
<div className="text-sm text-muted-foreground mb-3">
{t("billingMaximumLimits") || "Maximum Limits"}
</div>
<InfoSections cols={4}>
<InfoSections cols={5}>
<InfoSection>
<InfoSectionTitle className="flex items-center gap-1 text-xs">
{t("billingUsers") || "Users"}
@@ -855,6 +871,41 @@ export default function BillingPage() {
)}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle className="flex items-center gap-1 text-xs">
{t("billingOrganizations") ||
"Organizations"}
</InfoSectionTitle>
<InfoSectionContent className="text-sm">
{isOverLimit(ORGINIZATIONS) ? (
<Tooltip>
<TooltipTrigger className="flex items-center gap-1">
<AlertTriangle className="h-3 w-3 text-orange-400" />
<span className={cn(
"text-orange-600 dark:text-orange-400 font-medium"
)}>
{getLimitValue(ORGINIZATIONS) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(ORGINIZATIONS) !==
null && "orgs"}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(ORGINIZATIONS), limit: getLimitValue(ORGINIZATIONS) ?? 0 }) || `Current usage (${getUsageValue(ORGINIZATIONS)}) exceeds limit (${getLimitValue(ORGINIZATIONS)})`}</p>
</TooltipContent>
</Tooltip>
) : (
<>
{getLimitValue(ORGINIZATIONS) ??
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(ORGINIZATIONS) !==
null && "orgs"}
</>
)}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle className="flex items-center gap-1 text-xs">
{t("billingRemoteNodes") ||
@@ -872,7 +923,7 @@ export default function BillingPage() {
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(REMOTE_EXIT_NODES) !==
null && "remote nodes"}
null && "nodes"}
</span>
</TooltipTrigger>
<TooltipContent>
@@ -885,7 +936,7 @@ export default function BillingPage() {
t("billingUnlimited") ??
"∞"}{" "}
{getLimitValue(REMOTE_EXIT_NODES) !==
null && "remote nodes"}
null && "nodes"}
</>
)}
</InfoSectionContent>
@@ -1016,6 +1067,17 @@ export default function BillingPage() {
"Domains"}
</span>
</div>
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-600" />
<span>
{
tierLimits[pendingTier.tier]
.organizations
}{" "}
{t("billingOrganizations") ||
"Organizations"}
</span>
</div>
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-600" />
<span>
@@ -4,6 +4,8 @@ import { redirect } from "next/navigation";
import { cache } from "react";
import { getTranslations } from "next-intl/server";
import { build } from "@server/build";
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
type LicensesSettingsProps = {
children: React.ReactNode;
@@ -27,6 +29,26 @@ export default async function LicensesSetingsLayoutProps({
redirect(`/`);
}
let orgUser = null;
try {
const res = await getCachedOrgUser(orgId, user.userId);
orgUser = res.data.data;
} catch {
redirect(`/${orgId}`);
}
let org = null;
try {
const res = await getCachedOrg(orgId);
org = res.data.data;
} catch {
redirect(`/${orgId}`);
}
if (!org?.org?.isBillingOrg || !orgUser?.isOwner) {
redirect(`/${orgId}`);
}
const t = await getTranslations();
return (
+2 -6
View File
@@ -3,11 +3,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Button } from "@app/components/ui/button";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
import {
useState,
useTransition,
useActionState
} from "react";
import { useState, useTransition, useActionState } from "react";
import {
Form,
FormControl,
@@ -54,7 +50,7 @@ export default function GeneralPage() {
return (
<SettingsContainer>
<GeneralSectionForm org={org.org} />
{build !== "saas" && <DeleteForm org={org.org} />}
{!org.org.isBillingOrg && <DeleteForm org={org.org} />}
</SettingsContainer>
);
}
+5 -1
View File
@@ -77,12 +77,16 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
}
} catch (e) {}
const primaryOrg = orgs.find((o) => o.orgId === params.orgId)?.isPrimaryOrg;
return (
<UserProvider user={user}>
<Layout
orgId={params.orgId}
orgs={orgs}
navItems={orgNavSections(env)}
navItems={orgNavSections(env, {
isPrimaryOrg: primaryOrg
})}
>
{children}
</Layout>
+5
View File
@@ -15,6 +15,7 @@ export default async function Page(props: {
redirect: string | undefined;
email: string | undefined;
fromSmartLogin: string | undefined;
skipVerificationEmail: string | undefined;
}>;
}) {
const searchParams = await props.searchParams;
@@ -75,6 +76,10 @@ export default async function Page(props: {
inviteId={inviteId}
emailParam={searchParams.email}
fromSmartLogin={searchParams.fromSmartLogin === "true"}
skipVerificationEmail={
searchParams.skipVerificationEmail === "true" ||
searchParams.skipVerificationEmail === "1"
}
/>
<p className="text-center text-muted-foreground mt-4">
+20 -13
View File
@@ -31,6 +31,10 @@ export type SidebarNavSection = {
items: SidebarNavItem[];
};
export type OrgNavSectionsOptions = {
isPrimaryOrg?: boolean;
};
// Merged from 'user-management-and-resources' branch
export const orgLangingNavItems: SidebarNavItem[] = [
{
@@ -40,7 +44,10 @@ export const orgLangingNavItems: SidebarNavItem[] = [
}
];
export const orgNavSections = (env?: Env): SidebarNavSection[] => [
export const orgNavSections = (
env?: Env,
options?: OrgNavSectionsOptions
): SidebarNavSection[] => [
{
heading: "sidebarGeneral",
items: [
@@ -214,28 +221,28 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
title: "sidebarSettings",
href: "/{orgId}/settings/general",
icon: <Settings className="size-4 flex-none" />
},
...(build == "saas"
? [
}
]
},
...(build == "saas" && options?.isPrimaryOrg
? [
{
heading: "sidebarBillingAndLicenses",
items: [
{
title: "sidebarBilling",
href: "/{orgId}/settings/billing",
icon: <CreditCard className="size-4 flex-none" />
}
]
: []),
...(build == "saas"
? [
},
{
title: "sidebarEnterpriseLicenses",
href: "/{orgId}/settings/license",
icon: <TicketCheck className="size-4 flex-none" />
}
]
: [])
]
}
}
]
: [])
];
export const adminNavSections = (env?: Env): SidebarNavSection[] => [
+9 -1
View File
@@ -73,7 +73,7 @@ export default async function Page(props: {
if (!orgs.length) {
if (!env.flags.disableUserCreateOrg || user.serverAdmin) {
redirect("/setup");
redirect("/setup?firstOrg");
}
}
@@ -86,6 +86,14 @@ export default async function Page(props: {
targetOrgId = lastOrgCookie;
} else {
let ownedOrg = orgs.find((org) => org.isOwner);
let primaryOrg = orgs.find((org) => org.isPrimaryOrg);
if (!ownedOrg) {
if (primaryOrg) {
ownedOrg = primaryOrg;
} else {
ownedOrg = orgs[0];
}
}
if (!ownedOrg) {
ownedOrg = orgs[0];
}
+272 -258
View File
@@ -4,19 +4,14 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "@app/hooks/useToast";
import { useCallback, useEffect, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@app/components/ui/card";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useUserContext } from "@app/hooks/useUserContext";
import { build } from "@server/build";
import { Separator } from "@/components/ui/separator";
import { z } from "zod";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
@@ -35,7 +30,7 @@ import {
CollapsibleContent,
CollapsibleTrigger
} from "@app/components/ui/collapsible";
import { ChevronsUpDown } from "lucide-react";
import { ArrowRight, ChevronsUpDown } from "lucide-react";
import { cn } from "@app/lib/cn";
type Step = "org" | "site" | "resources";
@@ -45,6 +40,7 @@ export default function StepperForm() {
const [orgIdTaken, setOrgIdTaken] = useState(false);
const t = useTranslations();
const { env } = useEnvContext();
const { user } = useUserContext();
const [loading, setLoading] = useState(false);
const [isChecked, setIsChecked] = useState(false);
@@ -54,7 +50,10 @@ export default function StepperForm() {
const orgSchema = z.object({
orgName: z.string().min(1, { message: t("orgNameRequired") }),
orgId: z.string().min(1, { message: t("orgIdRequired") }),
orgId: z
.string()
.min(1, { message: t("orgIdRequired") })
.max(32, { message: t("orgIdMaxLength") }),
subnet: z.string().min(1, { message: t("subnetRequired") }),
utilitySubnet: z.string().min(1, { message: t("subnetRequired") })
});
@@ -71,12 +70,27 @@ export default function StepperForm() {
const api = createApiClient(useEnvContext());
const router = useRouter();
const searchParams = useSearchParams();
const isFirstOrg = searchParams.get("firstOrg") != null;
// Fetch default subnet on component mount
useEffect(() => {
fetchDefaultSubnet();
}, []);
// Prefill org name and id when build is saas and firstOrg query param is set
useEffect(() => {
if (build !== "saas" || !user || !isFirstOrg) return;
const orgName = user.email
? `${user.email}'s Organization`
: "My Organization";
const orgId = `org_${user.userId}`;
orgForm.setValue("orgName", orgName);
orgForm.setValue("orgId", orgId);
debouncedCheckOrgIdAvailability(orgId);
}, []);
const fetchDefaultSubnet = async () => {
try {
const res = await api.get(`/pick-org-defaults`);
@@ -129,6 +143,15 @@ export default function StepperForm() {
.replace(/^-+|-+$/g, "");
};
const sanitizeOrgId = (value: string) => {
return value
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9_-]/g, "")
.replace(/-+/g, "-")
.slice(0, 32);
};
async function orgSubmit(values: z.infer<typeof orgSchema>) {
if (orgIdTaken) {
return;
@@ -161,263 +184,254 @@ export default function StepperForm() {
}
return (
<>
<Card>
<CardHeader>
<CardTitle>{t("setupNewOrg")}</CardTitle>
<CardDescription>{t("setupCreate")}</CardDescription>
</CardHeader>
<CardContent>
<section className="space-y-6">
<div className="flex justify-between mb-2">
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
currentStep === "org"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
1
</div>
<span
className={`text-sm font-medium ${
currentStep === "org"
? "text-primary"
: "text-muted-foreground"
}`}
>
{t("setupCreateOrg")}
</span>
</div>
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
currentStep === "site"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
2
</div>
<span
className={`text-sm font-medium ${
currentStep === "site"
? "text-primary"
: "text-muted-foreground"
}`}
>
{t("siteCreate")}
</span>
</div>
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
currentStep === "resources"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
3
</div>
<span
className={`text-sm font-medium ${
currentStep === "resources"
? "text-primary"
: "text-muted-foreground"
}`}
>
{t("setupCreateResources")}
</span>
</div>
</div>
<section className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{t("setupNewOrg")}
</h1>
<p className="text-muted-foreground text-sm mt-1">
{t("setupCreate")}
</p>
</div>
<div className="flex justify-between mb-2">
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
currentStep === "org"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
1
</div>
<span
className={`text-sm font-medium ${
currentStep === "org"
? "text-primary"
: "text-muted-foreground"
}`}
>
{t("setupCreateOrg")}
</span>
</div>
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
currentStep === "site"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
2
</div>
<span
className={`text-sm font-medium ${
currentStep === "site"
? "text-primary"
: "text-muted-foreground"
}`}
>
{t("siteCreate")}
</span>
</div>
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
currentStep === "resources"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
3
</div>
<span
className={`text-sm font-medium ${
currentStep === "resources"
? "text-primary"
: "text-muted-foreground"
}`}
>
{t("setupCreateResources")}
</span>
</div>
</div>
<Separator />
<Separator />
{currentStep === "org" && (
<Form {...orgForm}>
<form
onSubmit={orgForm.handleSubmit(orgSubmit)}
className="space-y-4"
>
<FormField
control={orgForm.control}
name="orgName"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("setupOrgName")}
</FormLabel>
<FormControl>
<Input
type="text"
{...field}
onChange={(e) => {
// Prevent "/" in orgName input
const sanitizedValue =
e.target.value.replace(
/\//g,
"-"
);
const orgId =
generateId(
sanitizedValue
);
orgForm.setValue(
"orgId",
orgId
);
orgForm.setValue(
"orgName",
sanitizedValue
);
debouncedCheckOrgIdAvailability(
orgId
);
}}
value={field.value.replace(
/\//g,
"-"
)}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t("orgDisplayName")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={orgForm.control}
name="orgId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("orgId")}
</FormLabel>
<FormControl>
<Input
type="text"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"setupIdentifierMessage"
)}
</FormDescription>
</FormItem>
)}
/>
{currentStep === "org" && (
<Form {...orgForm}>
<form
onSubmit={orgForm.handleSubmit(orgSubmit)}
className="space-y-4"
>
<FormField
control={orgForm.control}
name="orgName"
render={({ field }) => (
<FormItem>
<FormLabel>{t("setupOrgName")}</FormLabel>
<FormControl>
<Input
type="text"
{...field}
onChange={(e) => {
// Prevent "/" in orgName input
const sanitizedValue =
e.target.value.replace(
/\//g,
"-"
);
const orgId =
generateId(sanitizedValue);
orgForm.setValue(
"orgId",
orgId
);
orgForm.setValue(
"orgName",
sanitizedValue
);
debouncedCheckOrgIdAvailability(
orgId
);
}}
value={field.value.replace(
/\//g,
"-"
)}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t("orgDisplayName")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={orgForm.control}
name="orgId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("orgId")}</FormLabel>
<FormControl>
<Input
type="text"
{...field}
onChange={(e) => {
const value = sanitizeOrgId(
e.target.value
);
field.onChange(value);
setOrgIdTaken(false);
if (value) {
debouncedCheckOrgIdAvailability(
value
);
}
}}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t("setupIdentifierMessage")}
</FormDescription>
</FormItem>
)}
/>
<Collapsible
open={isAdvancedOpen}
onOpenChange={setIsAdvancedOpen}
className="space-y-2"
<Collapsible
open={isAdvancedOpen}
onOpenChange={setIsAdvancedOpen}
className="space-y-2"
>
<div className="flex items-center justify-between space-x-4">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="text"
size="sm"
className="p-0 flex items-center justify-between w-full"
>
<div className="flex items-center justify-between space-x-4">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="text"
size="sm"
className="p-0 flex items-center justify-between w-full"
>
<h4 className="text-sm">
{t("advancedSettings")}
</h4>
<div>
<ChevronsUpDown className="h-4 w-4" />
<span className="sr-only">
{t("toggle")}
</span>
</div>
</Button>
</CollapsibleTrigger>
<h4 className="text-sm">
{t("advancedSettings")}
</h4>
<div>
<ChevronsUpDown className="h-4 w-4" />
<span className="sr-only">
{t("toggle")}
</span>
</div>
<CollapsibleContent className="space-y-4">
<FormField
control={orgForm.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"setupSubnetAdvanced"
)}
</FormLabel>
<FormControl>
<Input
type="text"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"setupSubnetDescription"
)}
</FormDescription>
</FormItem>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="space-y-4">
<FormField
control={orgForm.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("setupSubnetAdvanced")}
</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
<FormDescription>
{t("setupSubnetDescription")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={orgForm.control}
name="utilitySubnet"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("setupUtilitySubnet")}
</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
<FormDescription>
{t(
"setupUtilitySubnetDescription"
)}
/>
</FormDescription>
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
<FormField
control={orgForm.control}
name="utilitySubnet"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"setupUtilitySubnet"
)}
</FormLabel>
<FormControl>
<Input
type="text"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"setupUtilitySubnetDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
{orgIdTaken && !orgCreated ? (
<Alert variant="destructive">
<AlertDescription>
{t("setupErrorIdentifier")}
</AlertDescription>
</Alert>
) : null}
{orgIdTaken && !orgCreated ? (
<Alert variant="destructive">
<AlertDescription>
{t("setupErrorIdentifier")}
</AlertDescription>
</Alert>
) : null}
{/* Error Alert removed, errors now shown as toast */}
{/* Error Alert removed, errors now shown as toast */}
<div className="flex justify-end">
<Button
type="submit"
loading={loading}
disabled={loading || orgIdTaken}
>
{t("setupCreateOrg")}
</Button>
</div>
</form>
</Form>
)}
</section>
</CardContent>
</Card>
</>
<div className="flex justify-end">
<Button
type="submit"
loading={loading}
disabled={loading || orgIdTaken}
>
{t("setupCreateOrg")}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</form>
</Form>
)}
</section>
);
}
+3 -1
View File
@@ -189,10 +189,12 @@ export function LayoutSidebar({
<div className="w-full border-t border-border" />
<div className="p-4 pt-1 flex flex-col shrink-0">
{canShowProductUpdates && (
{canShowProductUpdates ? (
<div className="mb-3">
<ProductUpdates isCollapsed={isSidebarCollapsed} />
</div>
) : (
<div className="mb-3"></div>
)}
{build === "enterprise" && (
+232 -7
View File
@@ -58,6 +58,18 @@ type Resource = {
siteName?: string | null;
};
type SiteResource = {
siteResourceId: number;
name: string;
destination: string;
mode: string;
protocol: string | null;
enabled: boolean;
alias: string | null;
aliasAddress: string | null;
type: 'site';
};
type MemberResourcesPortalProps = {
orgId: string;
};
@@ -334,7 +346,9 @@ export default function MemberResourcesPortal({
const { toast } = useToast();
const [resources, setResources] = useState<Resource[]>([]);
const [siteResources, setSiteResources] = useState<SiteResource[]>([]);
const [filteredResources, setFilteredResources] = useState<Resource[]>([]);
const [filteredSiteResources, setFilteredSiteResources] = useState<SiteResource[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
@@ -360,7 +374,9 @@ export default function MemberResourcesPortal({
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("Failed to load resources");
}
@@ -417,17 +433,61 @@ export default function MemberResourcesPortal({
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, searchQuery, sortBy]);
}, [resources, siteResources, searchQuery, sortBy]);
// Calculate pagination
const totalPages = Math.ceil(filteredResources.length / itemsPerPage);
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
@@ -575,7 +635,7 @@ export default function MemberResourcesPortal({
</div>
{/* Resources Content */}
{filteredResources.length === 0 ? (
{filteredResources.length === 0 && filteredSiteResources.length === 0 ? (
/* Enhanced Empty State */
<Card>
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
@@ -623,9 +683,20 @@ export default function MemberResourcesPortal({
</Card>
) : (
<>
{/* Resources Grid */}
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr">
{paginatedResources.map((resource) => (
{/* Public Resources Section */}
{paginatedResources.length > 0 && (
<>
<div className="mb-4">
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Globe className="h-5 w-5" />
Public Resources
</h3>
<p className="text-sm text-muted-foreground mt-1">
Web applications and services accessible via browser
</p>
</div>
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8">
{paginatedResources.map((resource) => (
<Card key={resource.resourceId}>
<div className="p-6">
<div className="flex items-center justify-between gap-3">
@@ -702,13 +773,167 @@ export default function MemberResourcesPortal({
</Card>
))}
</div>
</>
)}
{/* Private Resources (Site Resources) Section */}
{paginatedSiteResources.length > 0 && (
<>
<div className="mb-4">
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Combine className="h-5 w-5" />
Private Resources
</h3>
<p className="text-sm text-muted-foreground mt-1">
Internal network resources accessible via client
</p>
</div>
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8">
{paginatedSiteResources.map((siteResource) => (
<Card key={siteResource.siteResourceId}>
<div className="p-6">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center min-w-0 flex-1 gap-3 overflow-hidden">
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="min-w-0 max-w-full">
<CardTitle className="text-lg font-bold text-foreground truncate group-hover:text-primary transition-colors">
{siteResource.name}
</CardTitle>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs break-words">
{siteResource.name}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex-shrink-0">
<InfoPopup>
<div className="space-y-2 text-sm">
<div className="text-xs font-medium mb-1.5">Resource Details</div>
<div>
<span className="font-medium">Mode:</span>
<span className="ml-2 text-muted-foreground capitalize">
{siteResource.mode}
</span>
</div>
{siteResource.protocol && (
<div>
<span className="font-medium">Protocol:</span>
<span className="ml-2 text-muted-foreground uppercase">
{siteResource.protocol}
</span>
</div>
)}
{siteResource.alias && (
<div>
<span className="font-medium">Alias:</span>
<span className="ml-2 text-muted-foreground">
{siteResource.alias}
</span>
</div>
)}
{siteResource.aliasAddress && (
<div>
<span className="font-medium">Alias Address:</span>
<span className="ml-2 text-muted-foreground">
{siteResource.aliasAddress}
</span>
</div>
)}
<div>
<span className="font-medium">Status:</span>
<span className={`ml-2 ${siteResource.enabled ? 'text-green-600' : 'text-red-600'}`}>
{siteResource.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
</InfoPopup>
</div>
</div>
<div className="mt-3">
{siteResource.alias ? (
<>
{/* Alias as primary */}
<div className="flex items-center gap-2 mb-1">
<div className="text-base font-semibold text-foreground text-left truncate flex-1">
{siteResource.alias}
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
onClick={() => {
navigator.clipboard.writeText(
siteResource.alias!
);
toast({
title: "Copied to clipboard",
description:
"Resource alias has been copied to your clipboard.",
duration: 2000
});
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
{/* Destination as secondary */}
<div className="text-xs text-muted-foreground truncate">
{siteResource.destination}
</div>
</>
) : (
/* Destination as primary when no alias */
<div className="flex items-center gap-2">
<div className="text-sm text-muted-foreground font-medium text-left truncate flex-1">
{siteResource.destination}
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
onClick={() => {
navigator.clipboard.writeText(
siteResource.destination
);
toast({
title: "Copied to clipboard",
description:
"Resource destination has been copied to your clipboard.",
duration: 2000
});
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
<div className="p-6 pt-0 mt-auto">
<div className="flex items-center justify-center py-2 px-4 bg-muted/50 rounded text-sm text-muted-foreground">
<Combine className="h-3.5 w-3.5 mr-2" />
Requires Client Connection
</div>
</div>
</Card>
))}
</div>
</>
)}
{/* Pagination Controls */}
<PaginationControls
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
totalItems={filteredResources.length}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
/>
</>
+36 -9
View File
@@ -20,12 +20,13 @@ import {
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import { Badge } from "@app/components/ui/badge";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
import { Check, ChevronsUpDown, Plus, Building2, Users } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
@@ -43,11 +44,23 @@ export function OrgSelector({
const { user } = useUserContext();
const [open, setOpen] = useState(false);
const router = useRouter();
const pathname = usePathname();
const { env } = useEnvContext();
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]);
const orgSelectorContent = (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@@ -124,25 +137,39 @@ export function OrgSelector({
)}
<CommandGroup heading={t("orgs")} className="py-2">
<CommandList>
{orgs?.map((org) => (
{sortedOrgs.map((org) => (
<CommandItem
key={org.orgId}
onSelect={() => {
setOpen(false);
router.push(`/${org.orgId}/settings`);
const newPath = pathname.replace(
/^\/[^/]+/,
`/${org.orgId}`
);
router.push(newPath);
}}
className="mx-2 rounded-md"
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-muted mr-3">
<Users className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex flex-col flex-1">
<span className="font-medium">
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">
{org.name}
</span>
<span className="text-xs text-muted-foreground">
{t("organization")}
</span>
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-muted-foreground font-mono truncate">
{org.orgId}
</span>
{org.isPrimaryOrg && (
<Badge
variant="outline"
className="shrink-0 text-[10px] px-1.5 py-0 font-medium ml-auto"
>
{t("primary")}
</Badge>
)}
</div>
</div>
<Check
className={cn(
+5 -2
View File
@@ -72,6 +72,7 @@ type SignupFormProps = {
inviteToken?: string;
emailParam?: string;
fromSmartLogin?: boolean;
skipVerificationEmail?: boolean;
};
const formSchema = z
@@ -103,7 +104,8 @@ export default function SignupForm({
inviteId,
inviteToken,
emailParam,
fromSmartLogin = false
fromSmartLogin = false,
skipVerificationEmail = false
}: SignupFormProps) {
const router = useRouter();
const { env } = useEnvContext();
@@ -147,7 +149,8 @@ export default function SignupForm({
inviteToken,
termsAcceptedTimestamp: termsAgreedAt,
marketingEmailConsent:
build === "saas" ? marketingEmailConsent : undefined
build === "saas" ? marketingEmailConsent : undefined,
skipVerificationEmail: skipVerificationEmail || undefined
})
.catch((e) => {
console.error(e);