diff --git a/messages/bg-BG.json b/messages/bg-BG.json
index 2d455afa8..25e192fec 100644
--- a/messages/bg-BG.json
+++ b/messages/bg-BG.json
@@ -449,8 +449,14 @@
"provisioningManage": "Осигуряване",
"provisioningDescription": "Управление на ключовете за осигуряване и преглед на чаканещите сайтове за одобрение.",
"pendingSites": "Чаканещи сайтове",
- "siteApproveSuccess": "Сайтът е одобрен успешно",
+ "siteApproveSuccess": "Сайтът и свързаните ресурси са одобрени успешно",
"siteApproveError": "Грешка при одобряването на сайта",
+ "siteReject": "Отказ на сайт",
+ "siteQuestionReject": "Сигурни ли сте, че искате да откажете този сайт?",
+ "siteMessageReject": "Това ще изтрие окончателно сайта и всички свързани ресурси, които все още са на изчакване.",
+ "siteConfirmReject": "Потвърдете отказ на сайт",
+ "siteRejectSuccess": "Сайтът беше успешно отхвърлен",
+ "siteRejectError": "Грешка при отхвърляне на сайта",
"provisioningKeys": "Ключове за осигуряване",
"searchProvisioningKeys": "Търсене на ключове за осигуряване...",
"provisioningKeysAdd": "Генериране на ключ за осигуряване",
@@ -466,8 +472,8 @@
"provisioningKeysSave": "Запазете ключа за осигуряване",
"provisioningKeysSaveDescription": "Ще можете да видите това само веднъж. Копирайте го на сигурно място.",
"provisioningKeysErrorCreate": "Грешка при създаване на ключ за осигуряване",
- "provisioningKeysList": "Нов ключ за осигуряване",
- "provisioningKeysMaxBatchSize": "Максимален размер на пакет",
+ "provisioningKeysList": "Нов ключ за разпределяне",
+ "provisioningKeysMaxBatchSize": "Максимален размер на пакета",
"provisioningKeysUnlimitedBatchSize": "Неограничен размер на партида (без лимит)",
"provisioningKeysMaxBatchUnlimited": "Неограничено",
"provisioningKeysMaxBatchSizeInvalid": "Въведете валиден максимален размер на партида (1–1,000,000).",
@@ -480,7 +486,7 @@
"provisioningKeysNeverUsed": "Никога",
"provisioningKeysEdit": "Редактиране на ключ за осигуряване",
"provisioningKeysEditDescription": "Актуализирайте максималния размер на партида и времето на изтичане за този ключ.",
- "provisioningKeysApproveNewSites": "Одобрете нови сайтове",
+ "provisioningKeysApproveNewSites": "Одобряване на нови сайтове",
"provisioningKeysApproveNewSitesDescription": "Автоматично одобряване на сайтове, които се регистрират с този ключ.",
"provisioningKeysUpdateError": "Грешка при актуализирането на ключа за осигуряване",
"provisioningKeysUpdated": "Ключът за осигуряване е актуализиран",
@@ -1397,6 +1403,7 @@
"createOrgUser": "Създаване на Организационна Потребител",
"actionUpdateOrg": "Актуализиране на организацията",
"actionRemoveInvitation": "Премахване на поканата.",
+ "actionRemoveUserRole": "Премахване на роля на потребител",
"actionUpdateUser": "Актуализиране на потребител",
"actionGetUser": "Получаване на потребител",
"actionGetOrgUser": "Вземете потребител на организация",
@@ -1419,6 +1426,8 @@
"setupTokenDescription": "Въведете конфигурационния токен от сървърната конзола.",
"setupTokenRequired": "Необходим е конфигурационен токен",
"actionUpdateSite": "Актуализиране на сайт",
+ "actionApproveSite": "Одобряване на сайт",
+ "actionRejectSite": "Отказ на сайт",
"actionResetSiteBandwidth": "Нулиране на честотната лента на организацията",
"actionListSiteRoles": "Изброяване на позволените роли за сайта",
"actionCreateResource": "Създаване на ресурс",
@@ -1516,6 +1525,7 @@
"otpAuthDescription": "Въведете кода от приложението за удостоверяване или един от вашите резервни кодове за еднократна употреба.",
"otpAuthSubmit": "Изпрати код",
"idpContinue": "Или продължете със",
+ "idpLastUsed": "Последно използвано",
"otpAuthBack": "Назад към парола",
"navbar": "Навигационно меню",
"navbarDescription": "Главно навигационно меню за приложението",
diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json
index f456ee3b9..f440a8e38 100644
--- a/messages/cs-CZ.json
+++ b/messages/cs-CZ.json
@@ -449,8 +449,14 @@
"provisioningManage": "Zajištění",
"provisioningDescription": "Spravovat klíče pro nastavení a zkontrolovat čekající stránky čekající na schválení.",
"pendingSites": "Nevyřízené weby",
- "siteApproveSuccess": "Web byl úspěšně schválen",
+ "siteApproveSuccess": "Stránka a přidružené zdroje byly úspěšně schváleny",
"siteApproveError": "Chyba při schvalování webu",
+ "siteReject": "Odmítnout stránku",
+ "siteQuestionReject": "Opravdu chcete tuto stránku odmítnout?",
+ "siteMessageReject": "Toto trvale odstraní stránku a všechny přidružené zdroje, které jsou stále nevyřízené.",
+ "siteConfirmReject": "Potvrdit odmítnutí stránky",
+ "siteRejectSuccess": "Stránka byla úspěšně odmítnuta",
+ "siteRejectError": "Chyba při odmítání stránky",
"provisioningKeys": "Poskytovací klíče",
"searchProvisioningKeys": "Hledat klíče k zajišťování...",
"provisioningKeysAdd": "Generovat zajišťovací klíč",
@@ -466,12 +472,12 @@
"provisioningKeysSave": "Uložit konfigurační klíč",
"provisioningKeysSaveDescription": "Toto můžete vidět pouze jednou. Zkopírujte ho na bezpečné místo.",
"provisioningKeysErrorCreate": "Chyba při vytváření doplňovacího klíče",
- "provisioningKeysList": "Nový klíč pro poskytování informací",
+ "provisioningKeysList": "Nový provisioning klíč",
"provisioningKeysMaxBatchSize": "Maximální velikost dávky",
"provisioningKeysUnlimitedBatchSize": "Neomezená velikost šarže (bez omezení)",
"provisioningKeysMaxBatchUnlimited": "Bez omezení",
"provisioningKeysMaxBatchSizeInvalid": "Zadejte platnou maximální velikost šarže (1–1,000,000).",
- "provisioningKeysValidUntil": "Platné do",
+ "provisioningKeysValidUntil": "Platná do",
"provisioningKeysValidUntilHint": "Ponechte prázdné, pokud vyprší platnost.",
"provisioningKeysValidUntilInvalid": "Zadejte platné datum a čas.",
"provisioningKeysNumUsed": "Časy použití",
@@ -1397,6 +1403,7 @@
"createOrgUser": "Vytvořit Org uživatele",
"actionUpdateOrg": "Aktualizovat organizaci",
"actionRemoveInvitation": "Odstranit pozvání",
+ "actionRemoveUserRole": "Odstranit uživatelskou roli",
"actionUpdateUser": "Aktualizovat uživatele",
"actionGetUser": "Získat uživatele",
"actionGetOrgUser": "Získat uživatele organizace",
@@ -1419,6 +1426,8 @@
"setupTokenDescription": "Zadejte nastavovací token z konzole serveru.",
"setupTokenRequired": "Je vyžadován token nastavení",
"actionUpdateSite": "Aktualizovat stránku",
+ "actionApproveSite": "Schválit stránku",
+ "actionRejectSite": "Odmítnout stránku",
"actionResetSiteBandwidth": "Resetovat šířku pásma organizace",
"actionListSiteRoles": "Seznam povolených rolí webu",
"actionCreateResource": "Vytvořit zdroj",
@@ -1516,6 +1525,7 @@
"otpAuthDescription": "Zadejte kód z vaší autentizační aplikace nebo jeden z vlastních záložních kódů.",
"otpAuthSubmit": "Odeslat kód",
"idpContinue": "Nebo pokračovat s",
+ "idpLastUsed": "Naposled použito",
"otpAuthBack": "Zpět na heslo",
"navbar": "Navigation Menu",
"navbarDescription": "Hlavní navigační menu aplikace",
diff --git a/messages/da-DK.json b/messages/da-DK.json
index ce08bff23..2e1376264 100644
--- a/messages/da-DK.json
+++ b/messages/da-DK.json
@@ -449,8 +449,14 @@
"provisioningManage": "Provisionering",
"provisioningDescription": "Administrer provisioneringsnøgler og gennemgå ventende sites som venter på godkendelse.",
"pendingSites": "Ventende sites",
- "siteApproveSuccess": "Med succes godkendelse af site",
+ "siteApproveSuccess": "Site og tilknyttede ressourcer godkendt med succes",
"siteApproveError": "Fejl ved godkendelse af side",
+ "siteReject": "Afvis Site",
+ "siteQuestionReject": "Er du sikker på, at du vil afvise dette site?",
+ "siteMessageReject": "Dette vil permanent slette sitet og enhver tilknyttet ressource, der stadig er i afventning.",
+ "siteConfirmReject": "Bekræft Afvis Site",
+ "siteRejectSuccess": "Site afvist med succes",
+ "siteRejectError": "Fejl ved afvisning af sitet",
"provisioningKeys": "Provisioneringsnøgler",
"searchProvisioningKeys": "Søg varer i provisioneringsnøgler...",
"provisioningKeysAdd": "Generer provisioneringsnøgle",
@@ -467,7 +473,7 @@
"provisioningKeysSaveDescription": "Du kan kun se denne én gang. Kopiér det til et sikkert sted.",
"provisioningKeysErrorCreate": "Fejl under oprettelse af provisioneringsnøgle",
"provisioningKeysList": "Ny provisioneringsnøgle",
- "provisioningKeysMaxBatchSize": "Maks størrelse på bunt",
+ "provisioningKeysMaxBatchSize": "Maksimum Batch Størrelse",
"provisioningKeysUnlimitedBatchSize": "Ubegrænset mengde bunt (ingen begrænsning)",
"provisioningKeysMaxBatchUnlimited": "Ubegrænset",
"provisioningKeysMaxBatchSizeInvalid": "Angiv en gyldig sjakkstørrelse (1–1 000.000).",
@@ -1397,6 +1403,7 @@
"createOrgUser": "Opret organisationsbruger",
"actionUpdateOrg": "Opdater organisation",
"actionRemoveInvitation": "Fjern invitation",
+ "actionRemoveUserRole": "Fjern Brugers Rolle",
"actionUpdateUser": "Opdater bruger",
"actionGetUser": "Hent bruger",
"actionGetOrgUser": "Hent organisationsbruger",
@@ -1419,6 +1426,8 @@
"setupTokenDescription": "Indtast opsætningstoken fra serverkonsollen.",
"setupTokenRequired": "Opsætningstoken er nødvendig",
"actionUpdateSite": "Opdater site",
+ "actionApproveSite": "Godkend Site",
+ "actionRejectSite": "Afvis Site",
"actionResetSiteBandwidth": "Nulstil organisationsbåndbredde",
"actionListSiteRoles": "Vis tilladte områderoller",
"actionCreateResource": "Opret ressource",
@@ -1516,6 +1525,7 @@
"otpAuthDescription": "Indtast koden fra autentiseringsappen din eller en af dine engangs reservekoder.",
"otpAuthSubmit": "Send kode",
"idpContinue": "Eller fortsæt med",
+ "idpLastUsed": "Sidst brugt",
"otpAuthBack": "Tilbage til adgangskode",
"navbar": "Navigationsmenu",
"navbarDescription": "Hovednavigasjonsmeny for applikationen",
diff --git a/messages/de-DE.json b/messages/de-DE.json
index dc7dbb808..3ceac6dc4 100644
--- a/messages/de-DE.json
+++ b/messages/de-DE.json
@@ -449,8 +449,14 @@
"provisioningManage": "Bereitstellung",
"provisioningDescription": "Bereitstellungsschlüssel verwalten und ausstehende Standorte prüfen, die noch auf Genehmigung warten.",
"pendingSites": "Ausstehende Standorte",
- "siteApproveSuccess": "Standort erfolgreich freigegeben",
+ "siteApproveSuccess": "Site und zugehörige Ressourcen erfolgreich genehmigt",
"siteApproveError": "Fehler beim Genehmigen des Standorts",
+ "siteReject": "Site ablehnen",
+ "siteQuestionReject": "Sind Sie sicher, dass Sie diese Site ablehnen möchten?",
+ "siteMessageReject": "Dies wird die Site und alle damit verbundenen, noch ausstehenden Ressourcen dauerhaft löschen.",
+ "siteConfirmReject": "Ablehnung der Site bestätigen",
+ "siteRejectSuccess": "Site erfolgreich abgelehnt",
+ "siteRejectError": "Fehler beim Ablehnen der Site",
"provisioningKeys": "Bereitstellungsschlüssel",
"searchProvisioningKeys": "Bereitstellungsschlüssel suchen...",
"provisioningKeysAdd": "Bereitstellungsschlüssel generieren",
@@ -467,7 +473,7 @@
"provisioningKeysSaveDescription": "Sie können dies nur einmal sehen. Kopieren Sie es an einen sicheren Ort.",
"provisioningKeysErrorCreate": "Fehler beim Erstellen des Bereitstellungsschlüssels",
"provisioningKeysList": "Neuer Bereitstellungsschlüssel",
- "provisioningKeysMaxBatchSize": "Max. Batch-Größe",
+ "provisioningKeysMaxBatchSize": "Maximale Batch-Größe",
"provisioningKeysUnlimitedBatchSize": "Unbegrenzte Batch-Größe (kein Limit)",
"provisioningKeysMaxBatchUnlimited": "Unbegrenzt",
"provisioningKeysMaxBatchSizeInvalid": "Geben Sie eine gültige maximale Batchgröße ein (1–1.000.000).",
@@ -480,7 +486,7 @@
"provisioningKeysNeverUsed": "Nie",
"provisioningKeysEdit": "Bereitstellungsschlüssel bearbeiten",
"provisioningKeysEditDescription": "Aktualisieren Sie die maximale Batch-Größe und Ablaufzeit für diesen Schlüssel.",
- "provisioningKeysApproveNewSites": "Neuen Standort genehmigen",
+ "provisioningKeysApproveNewSites": "Neue Sites genehmigen",
"provisioningKeysApproveNewSitesDescription": "Sites, die sich mit diesem Schlüssel registrieren, automatisch freigeben.",
"provisioningKeysUpdateError": "Fehler beim Aktualisieren des Bereitstellungsschlüssels",
"provisioningKeysUpdated": "Bereitstellungsschlüssel aktualisiert",
@@ -1397,6 +1403,7 @@
"createOrgUser": "Org Benutzer erstellen",
"actionUpdateOrg": "Organisation aktualisieren",
"actionRemoveInvitation": "Einladung entfernen",
+ "actionRemoveUserRole": "Benutzerrolle entfernen",
"actionUpdateUser": "Benutzer aktualisieren",
"actionGetUser": "Benutzer abrufen",
"actionGetOrgUser": "Organisationsbenutzer abrufen",
@@ -1419,6 +1426,8 @@
"setupTokenDescription": "Geben Sie das Setup-Token von der Serverkonsole ein.",
"setupTokenRequired": "Setup-Token ist erforderlich",
"actionUpdateSite": "Standorte aktualisieren",
+ "actionApproveSite": "Site genehmigen",
+ "actionRejectSite": "Site ablehnen",
"actionResetSiteBandwidth": "Organisations-Bandbreite zurücksetzen",
"actionListSiteRoles": "Erlaubte Standort-Rollen auflisten",
"actionCreateResource": "Ressource erstellen",
@@ -1516,6 +1525,7 @@
"otpAuthDescription": "Geben Sie den Code aus Ihrer Authenticator-App oder einen Ihrer einmaligen Backup-Codes ein.",
"otpAuthSubmit": "Code absenden",
"idpContinue": "Oder weiter mit",
+ "idpLastUsed": "Zuletzt verwendet",
"otpAuthBack": "Zurück zum Passwort",
"navbar": "Navigationsmenü",
"navbarDescription": "Hauptnavigationsmenü für die Anwendung",
diff --git a/messages/en-US.json b/messages/en-US.json
index 2a2c83b18..23195eab0 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -449,8 +449,14 @@
"provisioningManage": "Provisioning",
"provisioningDescription": "Manage provisioning keys and review pending sites awaiting approval.",
"pendingSites": "Pending Sites",
- "siteApproveSuccess": "Site approved successfully",
+ "siteApproveSuccess": "Site and associated resources approved successfully",
"siteApproveError": "Error approving site",
+ "siteReject": "Reject Site",
+ "siteQuestionReject": "Are you sure you want to reject this site?",
+ "siteMessageReject": "This will permanently delete the site and any associated resources that are still pending.",
+ "siteConfirmReject": "Confirm Reject Site",
+ "siteRejectSuccess": "Site rejected successfully",
+ "siteRejectError": "Error rejecting site",
"provisioningKeys": "Provisioning Keys",
"searchProvisioningKeys": "Search provisioning keys...",
"provisioningKeysAdd": "Generate Provisioning Key",
@@ -466,12 +472,12 @@
"provisioningKeysSave": "Save the provisioning key",
"provisioningKeysSaveDescription": "You will only be able to see this once. Copy it to a secure place.",
"provisioningKeysErrorCreate": "Error creating provisioning key",
- "provisioningKeysList": "New provisioning key",
- "provisioningKeysMaxBatchSize": "Max batch size",
+ "provisioningKeysList": "New Provisioning Key",
+ "provisioningKeysMaxBatchSize": "Max Batch Size",
"provisioningKeysUnlimitedBatchSize": "Unlimited batch size (no limit)",
"provisioningKeysMaxBatchUnlimited": "Unlimited",
"provisioningKeysMaxBatchSizeInvalid": "Enter a valid max batch size (1–1,000,000).",
- "provisioningKeysValidUntil": "Valid until",
+ "provisioningKeysValidUntil": "Valid Until",
"provisioningKeysValidUntilHint": "Leave empty for no expiration.",
"provisioningKeysValidUntilInvalid": "Enter a valid date and time.",
"provisioningKeysNumUsed": "Times used",
@@ -480,7 +486,7 @@
"provisioningKeysNeverUsed": "Never",
"provisioningKeysEdit": "Edit Provisioning Key",
"provisioningKeysEditDescription": "Update the max batch size and expiration time for this key.",
- "provisioningKeysApproveNewSites": "Approve new sites",
+ "provisioningKeysApproveNewSites": "Approve New Sites",
"provisioningKeysApproveNewSitesDescription": "Automatically approve sites that register with this key.",
"provisioningKeysUpdateError": "Error updating provisioning key",
"provisioningKeysUpdated": "Provisioning key updated",
@@ -1420,6 +1426,8 @@
"setupTokenDescription": "Enter the setup token from the server console.",
"setupTokenRequired": "Setup token is required",
"actionUpdateSite": "Update Site",
+ "actionApproveSite": "Approve Site",
+ "actionRejectSite": "Reject Site",
"actionResetSiteBandwidth": "Reset Organization Bandwidth",
"actionListSiteRoles": "List Allowed Site Roles",
"actionCreateResource": "Create Resource",
@@ -1517,6 +1525,7 @@
"otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.",
"otpAuthSubmit": "Submit Code",
"idpContinue": "Or continue with",
+ "idpLastUsed": "Last Used",
"otpAuthBack": "Back to Password",
"navbar": "Navigation Menu",
"navbarDescription": "Main navigation menu for the application",
diff --git a/messages/es-ES.json b/messages/es-ES.json
index 9bc4c83a1..d7453592a 100644
--- a/messages/es-ES.json
+++ b/messages/es-ES.json
@@ -449,8 +449,14 @@
"provisioningManage": "Aprovisionamiento",
"provisioningDescription": "Administrar las claves de aprovisionamiento y revisar los sitios pendientes de aprobación.",
"pendingSites": "Sitios pendientes",
- "siteApproveSuccess": "Sitio aprobado con éxito",
+ "siteApproveSuccess": "Sitio y recursos asociados aprobados correctamente",
"siteApproveError": "Error al aprobar el sitio",
+ "siteReject": "Rechazar Sitio",
+ "siteQuestionReject": "¿Está seguro de que desea rechazar este sitio?",
+ "siteMessageReject": "Esto eliminará permanentemente el sitio y cualquier recurso asociado que aún esté pendiente.",
+ "siteConfirmReject": "Confirmar Rechazo del Sitio",
+ "siteRejectSuccess": "Sitio rechazado correctamente",
+ "siteRejectError": "Error al rechazar el sitio",
"provisioningKeys": "Claves de aprovisionamiento",
"searchProvisioningKeys": "Buscar claves de suministro...",
"provisioningKeysAdd": "Generar clave de aprovisionamiento",
@@ -466,12 +472,12 @@
"provisioningKeysSave": "Guardar la clave de aprovisionamiento",
"provisioningKeysSaveDescription": "Sólo podrás verlo una vez. Copítalo a un lugar seguro.",
"provisioningKeysErrorCreate": "Error al crear la clave de provisioning",
- "provisioningKeysList": "Nueva clave de aprovisionamiento",
- "provisioningKeysMaxBatchSize": "Tamaño máximo de lote",
+ "provisioningKeysList": "Nueva Clave de Provisión",
+ "provisioningKeysMaxBatchSize": "Tamaño Máximo del Lote",
"provisioningKeysUnlimitedBatchSize": "Tamaño ilimitado del lote (sin límite)",
"provisioningKeysMaxBatchUnlimited": "Ilimitado",
"provisioningKeysMaxBatchSizeInvalid": "Introduzca un tamaño máximo de lote válido (1–1,000,000).",
- "provisioningKeysValidUntil": "Válido hasta",
+ "provisioningKeysValidUntil": "Válido Hasta",
"provisioningKeysValidUntilHint": "Dejar vacío para no expirar.",
"provisioningKeysValidUntilInvalid": "Introduzca una fecha y hora válidas.",
"provisioningKeysNumUsed": "Tiempos usados",
@@ -480,7 +486,7 @@
"provisioningKeysNeverUsed": "Nunca",
"provisioningKeysEdit": "Editar clave de aprovisionamiento",
"provisioningKeysEditDescription": "Actualizar el tamaño máximo de lote y el tiempo de caducidad para esta clave.",
- "provisioningKeysApproveNewSites": "Aprobar nuevos sitios",
+ "provisioningKeysApproveNewSites": "Aprobar Nuevos Sitios",
"provisioningKeysApproveNewSitesDescription": "Aprobar automáticamente los sitios que se registran con esta clave.",
"provisioningKeysUpdateError": "Error al actualizar la clave de aprovisionamiento",
"provisioningKeysUpdated": "Clave de aprovisionamiento actualizada",
@@ -1397,6 +1403,7 @@
"createOrgUser": "Crear usuario Org",
"actionUpdateOrg": "Actualizar organización",
"actionRemoveInvitation": "Eliminar invitación",
+ "actionRemoveUserRole": "Quitar Rol de Usuario",
"actionUpdateUser": "Actualizar usuario",
"actionGetUser": "Obtener usuario",
"actionGetOrgUser": "Obtener usuario de la organización",
@@ -1419,6 +1426,8 @@
"setupTokenDescription": "Ingrese el token de configuración desde la consola del servidor.",
"setupTokenRequired": "Se requiere el token de configuración",
"actionUpdateSite": "Actualizar sitio",
+ "actionApproveSite": "Aprobar Sitio",
+ "actionRejectSite": "Rechazar Sitio",
"actionResetSiteBandwidth": "Restablecer ancho de banda de la organización",
"actionListSiteRoles": "Lista de roles permitidos del sitio",
"actionCreateResource": "Crear Recurso",
@@ -1516,6 +1525,7 @@
"otpAuthDescription": "Introduzca el código de su aplicación de autenticación o uno de sus códigos de copia de seguridad de un solo uso.",
"otpAuthSubmit": "Enviar código",
"idpContinue": "O continuar con",
+ "idpLastUsed": "Último Uso",
"otpAuthBack": "Volver a la contraseña",
"navbar": "Menú de navegación",
"navbarDescription": "Menú de navegación principal para la aplicación",
diff --git a/messages/fr-FR.json b/messages/fr-FR.json
index 4084ceae8..ec24ec4d1 100644
--- a/messages/fr-FR.json
+++ b/messages/fr-FR.json
@@ -449,8 +449,14 @@
"provisioningManage": "Mise en place",
"provisioningDescription": "Gérer les clés de provisioning et examiner les sites en attente d'approbation.",
"pendingSites": "Sites en attente",
- "siteApproveSuccess": "Site approuvé avec succès",
+ "siteApproveSuccess": "Site et ressources associées approuvés avec succès",
"siteApproveError": "Erreur lors de l'approbation du site",
+ "siteReject": "Rejeter le site",
+ "siteQuestionReject": "Êtes-vous sûr de vouloir rejeter ce site ?",
+ "siteMessageReject": "Cela supprimera définitivement le site et toutes les ressources associées qui sont encore en attente.",
+ "siteConfirmReject": "Confirmer le rejet du site",
+ "siteRejectSuccess": "Site rejeté avec succès",
+ "siteRejectError": "Erreur lors du rejet du site",
"provisioningKeys": "Clés de provisionnement",
"searchProvisioningKeys": "Recherche des clés de provision...",
"provisioningKeysAdd": "Générer une clé de provisioning",
@@ -466,12 +472,12 @@
"provisioningKeysSave": "Enregistrer la clé de provisioning",
"provisioningKeysSaveDescription": "Vous ne pourrez voir cela qu'une seule fois. Copiez-le dans un endroit sécurisé.",
"provisioningKeysErrorCreate": "Erreur lors de la création de la clé de provisioning",
- "provisioningKeysList": "Nouvelle clé de provisioning",
+ "provisioningKeysList": "Nouvelle clé d'approvisionnement",
"provisioningKeysMaxBatchSize": "Taille maximale du lot",
"provisioningKeysUnlimitedBatchSize": "Taille de lot illimitée (sans limite)",
"provisioningKeysMaxBatchUnlimited": "Illimité",
"provisioningKeysMaxBatchSizeInvalid": "Entrez une taille de lot maximale valide (1–1 000 000).",
- "provisioningKeysValidUntil": "Valable jusqu'au",
+ "provisioningKeysValidUntil": "Valable jusqu'à",
"provisioningKeysValidUntilHint": "Laisser vide pour ne pas expirer.",
"provisioningKeysValidUntilInvalid": "Entrez une date et une heure valides.",
"provisioningKeysNumUsed": "Nombre de fois utilisées",
@@ -1397,6 +1403,7 @@
"createOrgUser": "Créer un utilisateur Org",
"actionUpdateOrg": "Mettre à jour l'organisation",
"actionRemoveInvitation": "Supprimer l'invitation",
+ "actionRemoveUserRole": "Supprimer le rôle d'utilisateur",
"actionUpdateUser": "Mettre à jour l'utilisateur",
"actionGetUser": "Obtenir l'utilisateur",
"actionGetOrgUser": "Obtenir l'utilisateur de l'organisation",
@@ -1419,6 +1426,8 @@
"setupTokenDescription": "Entrez le jeton de configuration depuis la console du serveur.",
"setupTokenRequired": "Le jeton de configuration est requis.",
"actionUpdateSite": "Mettre à jour un site",
+ "actionApproveSite": "Approuver le site",
+ "actionRejectSite": "Rejeter le site",
"actionResetSiteBandwidth": "Réinitialiser la bande passante de l'organisation",
"actionListSiteRoles": "Lister les rôles autorisés du site",
"actionCreateResource": "Créer une ressource",
@@ -1516,6 +1525,7 @@
"otpAuthDescription": "Entrez le code de votre application d'authentification ou l'un de vos codes de secours à usage unique.",
"otpAuthSubmit": "Soumettre le code",
"idpContinue": "Ou continuer avec",
+ "idpLastUsed": "Dernière utilisation",
"otpAuthBack": "Retour au mot de passe",
"navbar": "Menu de navigation",
"navbarDescription": "Menu de navigation principal de l'application",
diff --git a/messages/it-IT.json b/messages/it-IT.json
index ebb398334..5edde2e2e 100644
--- a/messages/it-IT.json
+++ b/messages/it-IT.json
@@ -449,8 +449,14 @@
"provisioningManage": "Accantonamento",
"provisioningDescription": "Gestire le chiavi di provisioning e rivedere i siti in attesa di approvazione.",
"pendingSites": "Siti In Attesa",
- "siteApproveSuccess": "Sito approvato con successo",
+ "siteApproveSuccess": "Sito e risorse associate approvate con successo",
"siteApproveError": "Errore nell'approvazione del sito",
+ "siteReject": "Rifiuta Sito",
+ "siteQuestionReject": "Sei sicuro di voler rifiutare questo sito?",
+ "siteMessageReject": "Questo eliminerà permanentemente il sito e tutte le risorse associate ancora in sospeso.",
+ "siteConfirmReject": "Conferma Rifiuto Sito",
+ "siteRejectSuccess": "Sito rifiutato con successo",
+ "siteRejectError": "Errore nel rifiutare il sito",
"provisioningKeys": "Chiavi Di Provvedimento",
"searchProvisioningKeys": "Cerca le chiavi di provisioning...",
"provisioningKeysAdd": "Genera Chiave di provisioning",
@@ -466,12 +472,12 @@
"provisioningKeysSave": "Salva la chiave di provisioning",
"provisioningKeysSaveDescription": "Sarai in grado di vedere solo una volta. Copiarlo in un posto sicuro.",
"provisioningKeysErrorCreate": "Errore nella creazione della chiave di provisioning",
- "provisioningKeysList": "Nuova chiave di provisioning",
- "provisioningKeysMaxBatchSize": "Dimensione massima batch",
+ "provisioningKeysList": "Nuova Chiave di Provisioning",
+ "provisioningKeysMaxBatchSize": "Dimensione Massima Batch",
"provisioningKeysUnlimitedBatchSize": "Dimensione illimitata del batch (nessun limite)",
"provisioningKeysMaxBatchUnlimited": "Illimitato",
"provisioningKeysMaxBatchSizeInvalid": "Inserisci una dimensione massima valida del batch (1–1.000.000).",
- "provisioningKeysValidUntil": "Valido fino al",
+ "provisioningKeysValidUntil": "Valido Fino a",
"provisioningKeysValidUntilHint": "Lasciare vuoto per nessuna scadenza.",
"provisioningKeysValidUntilInvalid": "Inserisci una data e ora valide.",
"provisioningKeysNumUsed": "Volte usate",
@@ -480,7 +486,7 @@
"provisioningKeysNeverUsed": "Mai",
"provisioningKeysEdit": "Modifica Chiave di provisioning",
"provisioningKeysEditDescription": "Aggiorna la dimensione massima del batch e il tempo di scadenza per questa chiave.",
- "provisioningKeysApproveNewSites": "Approva nuovi siti",
+ "provisioningKeysApproveNewSites": "Approva Nuovi Siti",
"provisioningKeysApproveNewSitesDescription": "Approvare automaticamente i siti che si registrano con questa chiave.",
"provisioningKeysUpdateError": "Errore nell'aggiornamento della chiave di provisioning",
"provisioningKeysUpdated": "Chiave di provisioning aggiornata",
@@ -1397,6 +1403,7 @@
"createOrgUser": "Crea Utente Org",
"actionUpdateOrg": "Aggiorna Organizzazione",
"actionRemoveInvitation": "Rimuovi Invito",
+ "actionRemoveUserRole": "Rimuovi Ruolo Utente",
"actionUpdateUser": "Aggiorna Utente",
"actionGetUser": "Ottieni Utente",
"actionGetOrgUser": "Ottieni Utente Organizzazione",
@@ -1419,6 +1426,8 @@
"setupTokenDescription": "Inserisci il token di configurazione dalla console del server.",
"setupTokenRequired": "Il token di configurazione è richiesto",
"actionUpdateSite": "Aggiorna Sito",
+ "actionApproveSite": "Approva Sito",
+ "actionRejectSite": "Rifiuta Sito",
"actionResetSiteBandwidth": "Reimposta Larghezza Banda Dell'Organizzazione",
"actionListSiteRoles": "Elenca Ruoli Sito Consentiti",
"actionCreateResource": "Crea Risorsa",
@@ -1516,6 +1525,7 @@
"otpAuthDescription": "Inserisci il codice dalla tua app di autenticazione o uno dei tuoi codici di backup monouso.",
"otpAuthSubmit": "Invia Codice",
"idpContinue": "O continua con",
+ "idpLastUsed": "Ultimo Utilizzo",
"otpAuthBack": "Torna alla Password",
"navbar": "Menu di Navigazione",
"navbarDescription": "Menu di navigazione principale dell'applicazione",
diff --git a/messages/ko-KR.json b/messages/ko-KR.json
index d8c929ead..67c0ee59f 100644
--- a/messages/ko-KR.json
+++ b/messages/ko-KR.json
@@ -449,8 +449,14 @@
"provisioningManage": "프로비저닝",
"provisioningDescription": "프로비저닝 키를 관리하고 승인을 기다리는 사이트를 검토합니다.",
"pendingSites": "대기중인 사이트",
- "siteApproveSuccess": "사이트가 성공적으로 승인되었습니다",
+ "siteApproveSuccess": "사이트 및 관련 리소스가 성공적으로 승인되었습니다",
"siteApproveError": "사이트 승인 오류",
+ "siteReject": "사이트 거부",
+ "siteQuestionReject": "이 사이트를 거부하시겠습니까?",
+ "siteMessageReject": "이렇게 하면 펜딩 중인 사이트 및 관련 리소스가 영구적으로 삭제됩니다.",
+ "siteConfirmReject": "사이트 거부 확인",
+ "siteRejectSuccess": "사이트가 성공적으로 거부되었습니다",
+ "siteRejectError": "사이트 거부 오류",
"provisioningKeys": "프로비저닝 키",
"searchProvisioningKeys": "프로비저닝 키 검색...",
"provisioningKeysAdd": "프로비저닝 키 생성",
@@ -480,7 +486,7 @@
"provisioningKeysNeverUsed": "절대",
"provisioningKeysEdit": "프로비저닝 키 수정",
"provisioningKeysEditDescription": "이 키의 최대 배치 크기 및 만료 시간을 업데이트하세요.",
- "provisioningKeysApproveNewSites": "새로운 사이트 승인",
+ "provisioningKeysApproveNewSites": "새 사이트 승인",
"provisioningKeysApproveNewSitesDescription": "이 키를 등록하는 사이트를 자동으로 승인합니다.",
"provisioningKeysUpdateError": "프로비저닝 키 업데이트 오류",
"provisioningKeysUpdated": "프로비저닝 키가 업데이트되었습니다",
@@ -1397,6 +1403,7 @@
"createOrgUser": "조직 사용자 생성",
"actionUpdateOrg": "조직 업데이트",
"actionRemoveInvitation": "초대 제거",
+ "actionRemoveUserRole": "사용자 역할 제거",
"actionUpdateUser": "사용자 업데이트",
"actionGetUser": "사용자 조회",
"actionGetOrgUser": "조직 사용자 가져오기",
@@ -1419,6 +1426,8 @@
"setupTokenDescription": "서버 콘솔에서 설정 토큰 입력.",
"setupTokenRequired": "설정 토큰이 필요합니다",
"actionUpdateSite": "사이트 업데이트",
+ "actionApproveSite": "사이트 승인",
+ "actionRejectSite": "사이트 거부",
"actionResetSiteBandwidth": "조직 대역폭 재설정",
"actionListSiteRoles": "허용된 사이트 역할 목록",
"actionCreateResource": "리소스 생성",
@@ -1516,6 +1525,7 @@
"otpAuthDescription": "인증 앱에서 코드를 입력하거나 단일 사용 백업 코드 중 하나를 입력하세요.",
"otpAuthSubmit": "코드 제출",
"idpContinue": "또는 계속 진행하십시오.",
+ "idpLastUsed": "마지막 사용",
"otpAuthBack": "비밀번호로 돌아가기",
"navbar": "탐색 메뉴",
"navbarDescription": "애플리케이션의 주요 탐색 메뉴",
diff --git a/messages/nb-NO.json b/messages/nb-NO.json
index 1e0d6a1a3..9bcca74db 100644
--- a/messages/nb-NO.json
+++ b/messages/nb-NO.json
@@ -449,8 +449,14 @@
"provisioningManage": "Levering",
"provisioningDescription": "Administrer foreløpig nøkler og gjennomgå ventende nettsteder som venter på godkjenning.",
"pendingSites": "Ventende nettsteder",
- "siteApproveSuccess": "Vellykket godkjenning av nettsted",
+ "siteApproveSuccess": "Område og tilknyttede ressurser godkjent",
"siteApproveError": "Feil ved godkjenning av side",
+ "siteReject": "Avvis Område",
+ "siteQuestionReject": "Er du sikker på at du vil avvise dette området?",
+ "siteMessageReject": "Dette vil permanent slette området og eventuelle tilknyttede ressurser som fortsatt venter.",
+ "siteConfirmReject": "Bekreft Avvisning av Område",
+ "siteRejectSuccess": "Område avvist",
+ "siteRejectError": "Feil ved avvisning av området",
"provisioningKeys": "Foreløpig nøkler",
"searchProvisioningKeys": "Søk varer i lagrings nøkler...",
"provisioningKeysAdd": "Generer fremvisende nøkkel",
@@ -466,12 +472,12 @@
"provisioningKeysSave": "Lagre den midlertidig nøkkelen",
"provisioningKeysSaveDescription": "Du kan bare se denne én gang. Kopier det til et sikkert sted.",
"provisioningKeysErrorCreate": "Feil under oppretting av foreløpig nøkkel",
- "provisioningKeysList": "Ny provisorisk nøkkel",
- "provisioningKeysMaxBatchSize": "Maks størrelse på bunt",
+ "provisioningKeysList": "Ny Forsyningsnøkkel",
+ "provisioningKeysMaxBatchSize": "Maks Batch Størrelse",
"provisioningKeysUnlimitedBatchSize": "Ubegrenset mengde bunt (ingen begrensning)",
"provisioningKeysMaxBatchUnlimited": "Ubegrenset",
"provisioningKeysMaxBatchSizeInvalid": "Angi en gyldig sjakkstørrelse (1–1 000.000).",
- "provisioningKeysValidUntil": "Gyldig til",
+ "provisioningKeysValidUntil": "Gyldig Til",
"provisioningKeysValidUntilHint": "La stå tomt for ingen utløp.",
"provisioningKeysValidUntilInvalid": "Angi en gyldig dato og klokkeslett.",
"provisioningKeysNumUsed": "Antall ganger brukt",
@@ -480,7 +486,7 @@
"provisioningKeysNeverUsed": "Aldri",
"provisioningKeysEdit": "Rediger bestemmelsesnøkkel",
"provisioningKeysEditDescription": "Oppdater maksimal størrelse for bunt og utløpstid for denne nøkkelen.",
- "provisioningKeysApproveNewSites": "Godkjenn nye nettsteder",
+ "provisioningKeysApproveNewSites": "Godkjenn Nye Områder",
"provisioningKeysApproveNewSitesDescription": "Godkjenn automatisk nettsteder som registrerer deg med denne nøkkelen.",
"provisioningKeysUpdateError": "Feil under oppdatering av foreløpig nøkkel",
"provisioningKeysUpdated": "Foreslå nøkkel oppdatert",
@@ -1397,6 +1403,7 @@
"createOrgUser": "Opprett Org bruker",
"actionUpdateOrg": "Oppdater organisasjon",
"actionRemoveInvitation": "Fjern invitasjon",
+ "actionRemoveUserRole": "Fjern Brukerrolle",
"actionUpdateUser": "Oppdater bruker",
"actionGetUser": "Hent bruker",
"actionGetOrgUser": "Hent organisasjonsbruker",
@@ -1419,6 +1426,8 @@
"setupTokenDescription": "Skriv inn oppsetttoken fra serverkonsollen.",
"setupTokenRequired": "Oppsetttoken er nødvendig",
"actionUpdateSite": "Oppdater område",
+ "actionApproveSite": "Godkjenn Område",
+ "actionRejectSite": "Avvis Område",
"actionResetSiteBandwidth": "Tilbakestill organisasjons-båndbredde",
"actionListSiteRoles": "List opp tillatte områderoller",
"actionCreateResource": "Opprett ressurs",
@@ -1516,6 +1525,7 @@
"otpAuthDescription": "Skriv inn koden fra autentiseringsappen din eller en av dine engangs reservekoder.",
"otpAuthSubmit": "Send inn kode",
"idpContinue": "Eller fortsett med",
+ "idpLastUsed": "Sist brukt",
"otpAuthBack": "Tilbake til passord",
"navbar": "Navigasjonsmeny",
"navbarDescription": "Hovednavigasjonsmeny for applikasjonen",
diff --git a/messages/nl-NL.json b/messages/nl-NL.json
index 506a4a5b3..9239cd81c 100644
--- a/messages/nl-NL.json
+++ b/messages/nl-NL.json
@@ -449,8 +449,14 @@
"provisioningManage": "Provisie",
"provisioningDescription": "Voorzieningssleutels beheren en sites beoordelen in afwachting van goedkeuring.",
"pendingSites": "Openstaande sites",
- "siteApproveSuccess": "Site succesvol goedgekeurd",
+ "siteApproveSuccess": "Site en bijbehorende middelen succesvol goedgekeurd",
"siteApproveError": "Fout bij goedkeuren website",
+ "siteReject": "Afwijzen Site",
+ "siteQuestionReject": "Weet u zeker dat u deze site wilt afwijzen?",
+ "siteMessageReject": "Hiermee wordt de site en alle bijbehorende middelen die nog in behandeling zijn permanent verwijderd.",
+ "siteConfirmReject": "Afwijzing van site bevestigen",
+ "siteRejectSuccess": "Site succesvol afgewezen",
+ "siteRejectError": "Fout bij het afwijzen van site",
"provisioningKeys": "Verhelderende sleutels",
"searchProvisioningKeys": "Zoek provisioningsleutels ...",
"provisioningKeysAdd": "Genereer Provisioning Sleutel",
@@ -466,7 +472,7 @@
"provisioningKeysSave": "Sla de provisioning sleutel op",
"provisioningKeysSaveDescription": "Je kunt dit slechts één keer zien. Kopieer het naar een veilige plaats.",
"provisioningKeysErrorCreate": "Fout bij aanmaken provisioning sleutel",
- "provisioningKeysList": "Nieuwe provisioning sleutel",
+ "provisioningKeysList": "Nieuwe provisioning-sleutel",
"provisioningKeysMaxBatchSize": "Maximale batchgrootte",
"provisioningKeysUnlimitedBatchSize": "Onbeperkte batchgrootte (geen limiet)",
"provisioningKeysMaxBatchUnlimited": "Onbeperkt",
@@ -480,7 +486,7 @@
"provisioningKeysNeverUsed": "Nooit",
"provisioningKeysEdit": "Wijzig Provisioning Sleutel",
"provisioningKeysEditDescription": "Werk de maximale batchgrootte en verlooptijd voor deze sleutel bij.",
- "provisioningKeysApproveNewSites": "Goedkeuren van nieuwe sites",
+ "provisioningKeysApproveNewSites": "Nieuwe sites goedkeuren",
"provisioningKeysApproveNewSitesDescription": "Automatisch sites goedkeuren die zich registreren met deze sleutel.",
"provisioningKeysUpdateError": "Fout tijdens bijwerken provisioning sleutel",
"provisioningKeysUpdated": "Provisie sleutel bijgewerkt",
@@ -1397,6 +1403,7 @@
"createOrgUser": "Org gebruiker aanmaken",
"actionUpdateOrg": "Organisatie bijwerken",
"actionRemoveInvitation": "Verwijder uitnodiging",
+ "actionRemoveUserRole": "Gebruikersrol verwijderen",
"actionUpdateUser": "Gebruiker bijwerken",
"actionGetUser": "Gebruiker ophalen",
"actionGetOrgUser": "Krijg organisatie-gebruiker",
@@ -1419,6 +1426,8 @@
"setupTokenDescription": "Voer het setup-token in vanaf de serverconsole.",
"setupTokenRequired": "Setup-token is vereist",
"actionUpdateSite": "Site bijwerken",
+ "actionApproveSite": "Verleen site goedkeuring",
+ "actionRejectSite": "Afwijzen Site",
"actionResetSiteBandwidth": "Reset organisatieschandbreedte",
"actionListSiteRoles": "Toon toegestane sitenollen",
"actionCreateResource": "Bron maken",
@@ -1516,6 +1525,7 @@
"otpAuthDescription": "Voer de code van je authenticator-app of een van je reservekopiecodes voor het eenmalig gebruik in.",
"otpAuthSubmit": "Code indienen",
"idpContinue": "Of ga verder met",
+ "idpLastUsed": "Laatst gebruikt",
"otpAuthBack": "Terug naar wachtwoord",
"navbar": "Navigatiemenu",
"navbarDescription": "Hoofd navigatie menu voor de applicatie",
diff --git a/messages/pl-PL.json b/messages/pl-PL.json
index 718579932..11df1b353 100644
--- a/messages/pl-PL.json
+++ b/messages/pl-PL.json
@@ -449,8 +449,14 @@
"provisioningManage": "Dostarczanie",
"provisioningDescription": "Zarządzaj kluczami rezerwacji i sprawdzaj oczekujące strony oczekujące na zatwierdzenie.",
"pendingSites": "Witryny oczekujące",
- "siteApproveSuccess": "Witryna została pomyślnie zatwierdzona",
+ "siteApproveSuccess": "Witryna i powiązane zasoby zostały zatwierdzone pomyślnie",
"siteApproveError": "Błąd zatwierdzania witryny",
+ "siteReject": "Odrzuć witrynę",
+ "siteQuestionReject": "Czy na pewno chcesz odrzucić tę witrynę?",
+ "siteMessageReject": "Spowoduje to trwałe usunięcie witryny i wszelkich powiązanych, nadal oczekujących zasobów.",
+ "siteConfirmReject": "Potwierdź odrzucenie witryny",
+ "siteRejectSuccess": "Witryna została odrzucona pomyślnie",
+ "siteRejectError": "Błąd podczas odrzucania witryny",
"provisioningKeys": "Klucze Zaopatrzenia",
"searchProvisioningKeys": "Szukaj kluczy zaopatrzenia...",
"provisioningKeysAdd": "Wygeneruj klucz zaopatrzenia",
@@ -466,7 +472,7 @@
"provisioningKeysSave": "Zapisz klucz zaopatrzenia",
"provisioningKeysSaveDescription": "Możesz to zobaczyć tylko raz. Skopiuj je do bezpiecznego miejsca.",
"provisioningKeysErrorCreate": "Błąd podczas tworzenia klucza zaopatrzenia",
- "provisioningKeysList": "Nowy klucz rezerwacji",
+ "provisioningKeysList": "Nowy klucz aprowizacyjny",
"provisioningKeysMaxBatchSize": "Maksymalny rozmiar partii",
"provisioningKeysUnlimitedBatchSize": "Nieograniczony rozmiar partii (bez limitu)",
"provisioningKeysMaxBatchUnlimited": "Nieograniczona",
@@ -1397,6 +1403,7 @@
"createOrgUser": "Utwórz użytkownika Org",
"actionUpdateOrg": "Aktualizuj organizację",
"actionRemoveInvitation": "Usuń zaproszenie",
+ "actionRemoveUserRole": "Usuń rolę użytkownika",
"actionUpdateUser": "Zaktualizuj użytkownika",
"actionGetUser": "Pobierz użytkownika",
"actionGetOrgUser": "Pobierz użytkownika organizacji",
@@ -1419,6 +1426,8 @@
"setupTokenDescription": "Wprowadź token konfiguracji z konsoli serwera.",
"setupTokenRequired": "Wymagany jest token konfiguracji",
"actionUpdateSite": "Aktualizuj witrynę",
+ "actionApproveSite": "Zatwierdź witrynę",
+ "actionRejectSite": "Odrzuć witrynę",
"actionResetSiteBandwidth": "Zresetuj przepustowość organizacji",
"actionListSiteRoles": "Lista dozwolonych ról witryny",
"actionCreateResource": "Utwórz zasób",
@@ -1516,6 +1525,7 @@
"otpAuthDescription": "Wprowadź kod z aplikacji uwierzytelniającej lub jeden z jednorazowych kodów zapasowych.",
"otpAuthSubmit": "Wyślij kod",
"idpContinue": "Lub kontynuuj z",
+ "idpLastUsed": "Ostatnio używany",
"otpAuthBack": "Powrót do hasła",
"navbar": "Menu nawigacyjne",
"navbarDescription": "Główne menu nawigacyjne aplikacji",
diff --git a/messages/pt-PT.json b/messages/pt-PT.json
index c0d65e0fb..4a37fb52b 100644
--- a/messages/pt-PT.json
+++ b/messages/pt-PT.json
@@ -449,8 +449,14 @@
"provisioningManage": "Provisionamento",
"provisioningDescription": "Gerenciar chaves de provisionamento e revisar sites pendentes aguardando aprovação.",
"pendingSites": "Sites pendentes",
- "siteApproveSuccess": "Site aprovado com sucesso",
+ "siteApproveSuccess": "Site e recursos associados aprovados com sucesso",
"siteApproveError": "Erro ao aprovar site",
+ "siteReject": "Rejeitar Site",
+ "siteQuestionReject": "Tem certeza de que deseja rejeitar este site?",
+ "siteMessageReject": "Isto eliminará permanentemente o site e quaisquer recursos associados que ainda estejam pendentes.",
+ "siteConfirmReject": "Confirmar Rejeição de Site",
+ "siteRejectSuccess": "Site rejeitado com sucesso",
+ "siteRejectError": "Erro ao rejeitar site",
"provisioningKeys": "Posicionando chaves",
"searchProvisioningKeys": "Pesquisar chaves de provisionamento...",
"provisioningKeysAdd": "Gerar chave de provisionamento",
@@ -466,12 +472,12 @@
"provisioningKeysSave": "Salvar a chave de provisionamento",
"provisioningKeysSaveDescription": "Você só será capaz de ver esta vez. Copiá-lo para um lugar seguro.",
"provisioningKeysErrorCreate": "Erro ao criar chave de provisionamento",
- "provisioningKeysList": "Nova chave de aprovisionamento",
- "provisioningKeysMaxBatchSize": "Tamanho máximo do lote",
+ "provisioningKeysList": "Nova Chave de Provisionamento",
+ "provisioningKeysMaxBatchSize": "Tamanho Máximo do Lote",
"provisioningKeysUnlimitedBatchSize": "Tamanho ilimitado em lote (sem limite)",
"provisioningKeysMaxBatchUnlimited": "Ilimitado",
"provisioningKeysMaxBatchSizeInvalid": "Informe um tamanho máximo válido em lote (1–1,000,000).",
- "provisioningKeysValidUntil": "Valido ate",
+ "provisioningKeysValidUntil": "Válido Até",
"provisioningKeysValidUntilHint": "Deixe em branco para nenhuma expiração.",
"provisioningKeysValidUntilInvalid": "Informe uma data e hora válidas.",
"provisioningKeysNumUsed": "Use percentual",
@@ -480,7 +486,7 @@
"provisioningKeysNeverUsed": "nunca",
"provisioningKeysEdit": "Editar chave de provisionamento",
"provisioningKeysEditDescription": "Atualizar o tamanho máximo do lote e tempo de expiração para esta chave.",
- "provisioningKeysApproveNewSites": "Aprovar novos sites",
+ "provisioningKeysApproveNewSites": "Aprovar Novos Sites",
"provisioningKeysApproveNewSitesDescription": "Aprovar automaticamente sites que se registram com esta chave.",
"provisioningKeysUpdateError": "Erro ao atualizar chave de provisionamento",
"provisioningKeysUpdated": "Chave de provisionamento atualizada",
@@ -1397,6 +1403,7 @@
"createOrgUser": "Criar utilizador Org",
"actionUpdateOrg": "Atualizar Organização",
"actionRemoveInvitation": "Remover Convite",
+ "actionRemoveUserRole": "Remover Função de Utilizador",
"actionUpdateUser": "Atualizar Usuário",
"actionGetUser": "Obter Usuário",
"actionGetOrgUser": "Obter Utilizador da Organização",
@@ -1419,6 +1426,8 @@
"setupTokenDescription": "Digite o token de configuração do console do servidor.",
"setupTokenRequired": "Token de configuração é necessário",
"actionUpdateSite": "Atualizar Site",
+ "actionApproveSite": "Aprovar Site",
+ "actionRejectSite": "Rejeitar Site",
"actionResetSiteBandwidth": "Redefinir banda da organização",
"actionListSiteRoles": "Listar Funções Permitidas do Site",
"actionCreateResource": "Criar Recurso",
@@ -1516,6 +1525,7 @@
"otpAuthDescription": "Insira o código da sua aplicação de autenticação ou um dos seus códigos de backup de uso único.",
"otpAuthSubmit": "Submeter Código",
"idpContinue": "Ou continuar com",
+ "idpLastUsed": "Última Utilização",
"otpAuthBack": "Voltar à Palavra-passe",
"navbar": "Menu de Navegação",
"navbarDescription": "Menu de navegação principal da aplicação",
diff --git a/messages/ru-RU.json b/messages/ru-RU.json
index 53bf7b015..efd5bee64 100644
--- a/messages/ru-RU.json
+++ b/messages/ru-RU.json
@@ -449,8 +449,14 @@
"provisioningManage": "Подготовка",
"provisioningDescription": "Управляйте предоставленными ключами и проверять непроверенные сайты, ожидающие утверждения.",
"pendingSites": "Ожидающие сайты",
- "siteApproveSuccess": "Сайт успешно утвержден",
+ "siteApproveSuccess": "Сайт и связанные ресурсы успешно одобрены",
"siteApproveError": "Ошибка при утверждении сайта",
+ "siteReject": "Отклонить сайт",
+ "siteQuestionReject": "Вы уверены, что хотите отклонить этот сайт?",
+ "siteMessageReject": "Этот процесс окончательно удалит сайт и все связанные с ним ресурсы, которые еще ожидают.",
+ "siteConfirmReject": "Подтвердите отклонение сайта",
+ "siteRejectSuccess": "Сайт успешно отклонен",
+ "siteRejectError": "Ошибка при отклонении сайта",
"provisioningKeys": "Ключи подготовки",
"searchProvisioningKeys": "Поиск подготовительных ключей...",
"provisioningKeysAdd": "Сгенерировать ключ подготовки",
@@ -466,8 +472,8 @@
"provisioningKeysSave": "Сохранить ключ подготовки",
"provisioningKeysSaveDescription": "Вы сможете увидеть это только один раз. Скопируйте его в безопасное место.",
"provisioningKeysErrorCreate": "Ошибка при создании ключа подготовки",
- "provisioningKeysList": "Новый подготовительный ключ",
- "provisioningKeysMaxBatchSize": "Макс. размер партии",
+ "provisioningKeysList": "Новый ключ для подготовки",
+ "provisioningKeysMaxBatchSize": "Максимальный размер партии",
"provisioningKeysUnlimitedBatchSize": "Неограниченный размер партии (без ограничений)",
"provisioningKeysMaxBatchUnlimited": "Неограниченный",
"provisioningKeysMaxBatchSizeInvalid": "Введите максимальный размер пакета (1–1,000,000).",
@@ -1397,6 +1403,7 @@
"createOrgUser": "Создать пользователя Org",
"actionUpdateOrg": "Обновить организацию",
"actionRemoveInvitation": "Удалить приглашение",
+ "actionRemoveUserRole": "Удалить роль пользователя",
"actionUpdateUser": "Обновить пользователя",
"actionGetUser": "Получить пользователя",
"actionGetOrgUser": "Получить пользователя организации",
@@ -1419,6 +1426,8 @@
"setupTokenDescription": "Введите токен настройки из консоли сервера.",
"setupTokenRequired": "Токен настройки обязателен",
"actionUpdateSite": "Обновить сайт",
+ "actionApproveSite": "Одобрить сайт",
+ "actionRejectSite": "Отклонить сайт",
"actionResetSiteBandwidth": "Сброс пропускной способности организации",
"actionListSiteRoles": "Список разрешенных ролей сайта",
"actionCreateResource": "Создать ресурс",
@@ -1516,6 +1525,7 @@
"otpAuthDescription": "Введите код из вашего приложения-аутентификатора или один из ваших одноразовых резервных кодов.",
"otpAuthSubmit": "Отправить код",
"idpContinue": "Или продолжить с",
+ "idpLastUsed": "Последнее использование",
"otpAuthBack": "Назад к паролю",
"navbar": "Навигационное меню",
"navbarDescription": "Главное навигационное меню приложения",
diff --git a/messages/tr-TR.json b/messages/tr-TR.json
index 6ab5d026d..006180cc0 100644
--- a/messages/tr-TR.json
+++ b/messages/tr-TR.json
@@ -449,8 +449,14 @@
"provisioningManage": "Tedarik",
"provisioningDescription": "Tedarik anahtarlarını yönetin ve onay bekleyen siteleri gözden geçirin.",
"pendingSites": "Bekleyen Siteler",
- "siteApproveSuccess": "Site başarıyla onaylandı",
+ "siteApproveSuccess": "Site ve ilgili kaynaklar başarıyla onaylandı",
"siteApproveError": "Site onaylanırken hata oluştu",
+ "siteReject": "Siteyi Reddet",
+ "siteQuestionReject": "Bu siteyi reddetmek istediğinizden emin misiniz?",
+ "siteMessageReject": "Bu işlem, siteyi ve hala beklemede olan herhangi bir ilgili kaynağı kalıcı olarak silecektir.",
+ "siteConfirmReject": "Site Reddetmeyi Onayla",
+ "siteRejectSuccess": "Site başarıyla reddedildi",
+ "siteRejectError": "Site reddedilirken hata oluştu",
"provisioningKeys": "Tedarik Anahtarları",
"searchProvisioningKeys": "Tedarik anahtarlarını ara...",
"provisioningKeysAdd": "Tedarik Anahtarı Üret",
@@ -466,12 +472,12 @@
"provisioningKeysSave": "Tedarik anahtarını kaydet",
"provisioningKeysSaveDescription": "Bunu yalnızca bir kez görebileceksiniz. Güvenli bir yere kopyalayın.",
"provisioningKeysErrorCreate": "Tedarik anahtarı oluşturulurken hata oluştu",
- "provisioningKeysList": "Yeni tedarik anahtarı",
- "provisioningKeysMaxBatchSize": "Maksimum toplu iş boyutu",
+ "provisioningKeysList": "Yeni Sağlama Anahtarı",
+ "provisioningKeysMaxBatchSize": "Maksimum Toplu İş Boyutu",
"provisioningKeysUnlimitedBatchSize": "Sınırsız toplu iş boyutu (sınırlama yok)",
"provisioningKeysMaxBatchUnlimited": "Sınırsız",
"provisioningKeysMaxBatchSizeInvalid": "Geçerli bir maksimum toplu iş boyutu girin (1–1,000,000).",
- "provisioningKeysValidUntil": "Geçerlilik tarihi",
+ "provisioningKeysValidUntil": "Geçerli Olma Süresi",
"provisioningKeysValidUntilHint": "Son kullanım tarihi için boş bırakın.",
"provisioningKeysValidUntilInvalid": "Geçerli bir tarih ve saat girin.",
"provisioningKeysNumUsed": "Kullanım Sayısı",
@@ -480,7 +486,7 @@
"provisioningKeysNeverUsed": "Asla",
"provisioningKeysEdit": "Tedarik Anahtarını Düzenle",
"provisioningKeysEditDescription": "Bu anahtar için maksimum toplu iş boyutunu ve son kullanma zamanını güncelleyin.",
- "provisioningKeysApproveNewSites": "Yeni siteleri onayla",
+ "provisioningKeysApproveNewSites": "Yeni Siteleri Onayla",
"provisioningKeysApproveNewSitesDescription": "Bu anahtar ile kayıt olan siteleri otomatik olarak onayla.",
"provisioningKeysUpdateError": "Tedarik anahtarı güncellenirken hata oluştu",
"provisioningKeysUpdated": "Tedarik anahtarı güncellendi",
@@ -1397,6 +1403,7 @@
"createOrgUser": "Organizasyon Kullanıcısı Oluştur",
"actionUpdateOrg": "Kuruluşu Güncelle",
"actionRemoveInvitation": "Daveti Kaldır",
+ "actionRemoveUserRole": "Kullanıcı Rolünü Kaldır",
"actionUpdateUser": "Kullanıcıyı Güncelle",
"actionGetUser": "Kullanıcıyı Getir",
"actionGetOrgUser": "Kuruluş Kullanıcısını Al",
@@ -1419,6 +1426,8 @@
"setupTokenDescription": "Sunucu konsolundan kurulum simgesini girin.",
"setupTokenRequired": "Kurulum simgesi gerekli",
"actionUpdateSite": "Siteyi Güncelle",
+ "actionApproveSite": "Siteyi Onayla",
+ "actionRejectSite": "Siteyi Reddet",
"actionResetSiteBandwidth": "Organizasyon Bant Genişliğini Sıfırla",
"actionListSiteRoles": "İzin Verilen Site Rolleri Listele",
"actionCreateResource": "Kaynak Oluştur",
@@ -1516,6 +1525,7 @@
"otpAuthDescription": "Authenticator uygulamanızdan veya tek kullanımlık yedek kodlarınızdan birini girin.",
"otpAuthSubmit": "Kodu Gönder",
"idpContinue": "Veya devam et:",
+ "idpLastUsed": "Son Kullanılan",
"otpAuthBack": "Şifreye Geri Dön",
"navbar": "Navigasyon Menüsü",
"navbarDescription": "Uygulamanın ana navigasyon menüsü",
diff --git a/messages/zh-CN.json b/messages/zh-CN.json
index 3d34fef2e..e5a680d99 100644
--- a/messages/zh-CN.json
+++ b/messages/zh-CN.json
@@ -449,8 +449,14 @@
"provisioningManage": "预配",
"provisioningDescription": "管理预配密钥,并审核待批准的站点。",
"pendingSites": "待审批站点",
- "siteApproveSuccess": "站点批准成功",
+ "siteApproveSuccess": "站点及相关资源已成功审批",
"siteApproveError": "批准站点出错",
+ "siteReject": "拒绝站点",
+ "siteQuestionReject": "您确定要拒绝此站点吗?",
+ "siteMessageReject": "这将永久删除站点及所有尚未处理的相关资源。",
+ "siteConfirmReject": "确认拒绝站点",
+ "siteRejectSuccess": "站点已被成功拒绝",
+ "siteRejectError": "拒绝站点时出错",
"provisioningKeys": "置备键",
"searchProvisioningKeys": "搜索配备密钥...",
"provisioningKeysAdd": "生成预配密钥",
@@ -466,8 +472,8 @@
"provisioningKeysSave": "保存预配键",
"provisioningKeysSaveDescription": "您只能看到一次。复制它到一个安全的地方。",
"provisioningKeysErrorCreate": "创建预配键时出错",
- "provisioningKeysList": "新建预配键",
- "provisioningKeysMaxBatchSize": "最大批量大小",
+ "provisioningKeysList": "新预配密钥",
+ "provisioningKeysMaxBatchSize": "最大批次大小",
"provisioningKeysUnlimitedBatchSize": "无限批量大小(无限制)",
"provisioningKeysMaxBatchUnlimited": "无限制",
"provisioningKeysMaxBatchSizeInvalid": "输入一个有效的最大批处理大小(1-1,000,000)。",
@@ -480,7 +486,7 @@
"provisioningKeysNeverUsed": "永不过期",
"provisioningKeysEdit": "编辑置备键",
"provisioningKeysEditDescription": "更新此密钥的最大批量大小和过期时间。",
- "provisioningKeysApproveNewSites": "批准新节点",
+ "provisioningKeysApproveNewSites": "批准新站点",
"provisioningKeysApproveNewSitesDescription": "自动批准使用此密钥注册的节点。",
"provisioningKeysUpdateError": "更新预配键时出错",
"provisioningKeysUpdated": "置备密钥已更新",
@@ -1397,6 +1403,7 @@
"createOrgUser": "创建组织用户",
"actionUpdateOrg": "更新组织",
"actionRemoveInvitation": "移除邀请",
+ "actionRemoveUserRole": "移除用户角色",
"actionUpdateUser": "更新用户",
"actionGetUser": "获取用户",
"actionGetOrgUser": "获取组织用户",
@@ -1419,6 +1426,8 @@
"setupTokenDescription": "从服务器控制台输入设置令牌。",
"setupTokenRequired": "需要设置令牌",
"actionUpdateSite": "更新站点",
+ "actionApproveSite": "批准站点",
+ "actionRejectSite": "拒绝站点",
"actionResetSiteBandwidth": "重置组织带宽",
"actionListSiteRoles": "允许站点角色列表",
"actionCreateResource": "创建资源",
@@ -1516,6 +1525,7 @@
"otpAuthDescription": "从您的身份验证程序中输入代码或您的单次备份代码。",
"otpAuthSubmit": "提交代码",
"idpContinue": "或者继续",
+ "idpLastUsed": "上次使用",
"otpAuthBack": "回到密码",
"navbar": "导航菜单",
"navbarDescription": "应用程序的主导航菜单",
diff --git a/server/auth/actions.ts b/server/auth/actions.ts
index 50c98c528..741d7a057 100644
--- a/server/auth/actions.ts
+++ b/server/auth/actions.ts
@@ -21,6 +21,7 @@ export enum ActionsEnum {
getSite = "getSite",
listSites = "listSites",
updateSite = "updateSite",
+ updateSiteApprovals = "updateSiteApprovals",
restartSite = "restartSite",
resetSiteBandwidth = "resetSiteBandwidth",
reGenerateSecret = "reGenerateSecret",
diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts
index b207b423b..f3375b0d9 100644
--- a/server/db/pg/schema/schema.ts
+++ b/server/db/pg/schema/schema.ts
@@ -200,7 +200,10 @@ export const resources = pgTable(
authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote" | "native">()
.default("site"),
- authDaemonPort: integer("authDaemonPort").default(22123)
+ authDaemonPort: integer("authDaemonPort").default(22123),
+ status: varchar("status")
+ .$type<"pending" | "approved">()
+ .default("approved")
},
(t) => [
index("idx_resources_fulldomain")
@@ -451,7 +454,10 @@ export const siteResources = pgTable(
onDelete: "set null"
}),
subdomain: varchar("subdomain"),
- fullDomain: varchar("fullDomain")
+ fullDomain: varchar("fullDomain"),
+ status: varchar("status")
+ .$type<"pending" | "approved">()
+ .default("approved")
},
(t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)]
);
diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts
index 4ec4916b5..3fcf38a41 100644
--- a/server/db/sqlite/schema/schema.ts
+++ b/server/db/sqlite/schema/schema.ts
@@ -209,7 +209,8 @@ export const resources = sqliteTable("resources", {
authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote" | "native">()
.default("site"),
- authDaemonPort: integer("authDaemonPort").default(22123)
+ authDaemonPort: integer("authDaemonPort").default(22123),
+ status: text("status").$type<"pending" | "approved">().default("approved")
});
export const labels = sqliteTable("labels", {
@@ -447,7 +448,8 @@ export const siteResources = sqliteTable("siteResources", {
onDelete: "set null"
}),
subdomain: text("subdomain"),
- fullDomain: text("fullDomain")
+ fullDomain: text("fullDomain"),
+ status: text("status").$type<"pending" | "approved">().default("approved")
});
export const networks = sqliteTable("networks", {
diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts
index c62dd4735..44646e0bf 100644
--- a/server/lib/blueprints/applyBlueprint.ts
+++ b/server/lib/blueprints/applyBlueprint.ts
@@ -34,12 +34,6 @@ import {
rebuildClientAssociationsFromSiteResource,
waitForSiteResourceRebuildIdle
} from "../rebuildClientAssociations";
-import { build } from "@server/build";
-import HttpCode from "@server/types/HttpCode";
-import createHttpError from "http-errors";
-import next from "next";
-import { LimitId } from "../billing";
-import { usageService } from "../billing/usageService";
type ApplyBlueprintArgs = {
orgId: string;
diff --git a/server/lib/blueprints/privateResources.ts b/server/lib/blueprints/privateResources.ts
index 74a1f618f..44901e8c8 100644
--- a/server/lib/blueprints/privateResources.ts
+++ b/server/lib/blueprints/privateResources.ts
@@ -26,9 +26,6 @@ import { createCertificate } from "#dynamic/routers/certificates/createCertifica
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
import { build } from "@server/build";
-import HttpCode from "@server/types/HttpCode";
-import createHttpError from "http-errors";
-import next from "next";
import { LimitId } from "../billing";
import { usageService } from "../billing/usageService";
@@ -201,17 +198,19 @@ export async function updatePrivateResources(
}
}
+ let resourceStatusFromSite: "approved" | "pending" = "approved";
if (siteId && allSites.length === 0) {
// only add if there are not provided sites
// Use the provided siteId directly, but verify it belongs to the org
const [siteSingle] = await trx
- .select({ siteId: sites.siteId })
+ .select({ siteId: sites.siteId, status: sites.status })
.from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1);
if (siteSingle) {
allSites.push(siteSingle);
}
+ resourceStatusFromSite = siteSingle.status ?? "approved";
}
if (allSites.length === 0) {
@@ -220,6 +219,13 @@ export async function updatePrivateResources(
);
}
+ const resourceEnabled =
+ resourceData.enabled == undefined || resourceData.enabled == null
+ ? true
+ : resourceStatusFromSite === "pending"
+ ? false
+ : resourceData.enabled;
+
if (existingResource) {
let domainInfo:
| { subdomain: string | null; domainId: string }
@@ -243,8 +249,7 @@ export async function updatePrivateResources(
scheme: resourceData.scheme,
destination: resourceData.destination,
destinationPort: resourceData["destination-port"],
- enabled: true, // hardcoded for now
- // enabled: resourceData.enabled ?? true,
+ enabled: resourceEnabled,
alias: resourceData.alias || null,
disableIcmp:
resourceData["disable-icmp"] ||
@@ -263,7 +268,8 @@ export async function updatePrivateResources(
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
authDaemonMode:
resourceData["auth-daemon"]?.mode || "native",
- authDaemonPort: resourceData["auth-daemon"]?.port || 22123
+ authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
+ status: resourceStatusFromSite
})
.where(
eq(
@@ -496,8 +502,7 @@ export async function updatePrivateResources(
scheme: resourceData.scheme,
destination: resourceData.destination,
destinationPort: resourceData["destination-port"],
- enabled: true, // hardcoded for now
- // enabled: resourceData.enabled ?? true,
+ enabled: resourceEnabled,
alias: resourceData.alias || null,
aliasAddress: aliasAddress,
disableIcmp:
@@ -517,7 +522,8 @@ export async function updatePrivateResources(
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
authDaemonMode:
resourceData["auth-daemon"]?.mode || "native",
- authDaemonPort: resourceData["auth-daemon"]?.port || 22123
+ authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
+ status: resourceStatusFromSite
})
.returning();
diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts
index df3b3799e..997d56e2a 100644
--- a/server/lib/blueprints/publicResources.ts
+++ b/server/lib/blueprints/publicResources.ts
@@ -25,6 +25,7 @@ import {
rolePolicies,
roleResources,
roles,
+ Site,
sites,
Target,
TargetHealthCheck,
@@ -74,19 +75,40 @@ export async function updatePublicResources(
)) {
const targetsToUpdate: Target[] = [];
const healthchecksToUpdate: TargetHealthCheck[] = [];
+
let resource: Resource;
+ let resourceStatusFromSite: "approved" | "pending" = "approved";
+ let providedSite: Partial | undefined;
+ if (siteId) {
+ // Use the provided siteId directly, but verify it belongs to the org
+ [providedSite] = await trx
+ .select({
+ siteId: sites.siteId,
+ type: sites.type,
+ status: sites.status
+ })
+ .from(sites)
+ .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
+ .limit(1);
+
+ resourceStatusFromSite = providedSite?.status ?? "approved";
+ }
async function createTarget( // reusable function to create a target
resourceId: number,
targetData: TargetData
) {
const targetSiteId = targetData.site;
- let site;
+ let site: Partial | undefined;
if (targetSiteId) {
// Look up site by niceId
[site] = await trx
- .select({ siteId: sites.siteId, type: sites.type })
+ .select({
+ siteId: sites.siteId,
+ type: sites.type,
+ status: sites.status
+ })
.from(sites)
.where(
and(
@@ -95,15 +117,9 @@ export async function updatePublicResources(
)
)
.limit(1);
- } else if (siteId) {
+ } else if (siteId && providedSite) {
// Use the provided siteId directly, but verify it belongs to the org
- [site] = await trx
- .select({ siteId: sites.siteId, type: sites.type })
- .from(sites)
- .where(
- and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
- )
- .limit(1);
+ site = providedSite;
} else {
throw new Error(`Target site is required`);
}
@@ -139,7 +155,7 @@ export async function updatePublicResources(
.insert(targets)
.values({
resourceId: resourceId,
- siteId: site.siteId,
+ siteId: site.siteId!,
ip: targetData.hostname,
mode: resourceData.mode as Target["mode"],
method: targetData.method,
@@ -172,7 +188,7 @@ export async function updatePublicResources(
.insert(targetHealthCheck)
.values({
name: `${targetData.hostname}:${targetData.port}`,
- siteId: site.siteId,
+ siteId: site.siteId!,
targetId: newTarget.targetId,
orgId: orgId,
hcEnabled: healthcheckData?.enabled || false,
@@ -230,7 +246,10 @@ export async function updatePublicResources(
const resourceEnabled =
resourceData.enabled == undefined || resourceData.enabled == null
? true
- : resourceData.enabled;
+ : resourceStatusFromSite === "pending"
+ ? false
+ : resourceData.enabled;
+
const resourceSsl =
resourceData.ssl == undefined || resourceData.ssl == null
? true
@@ -406,7 +425,8 @@ export async function updatePublicResources(
? (resourceData["proxy-protocol-version"] ??
1)
: 1,
- resourcePolicyId: sharedPolicy.resourcePolicyId
+ resourcePolicyId: sharedPolicy.resourcePolicyId,
+ status: resourceStatusFromSite
})
.where(
eq(
@@ -590,7 +610,8 @@ export async function updatePublicResources(
authDaemonPort:
resourceData["auth-daemon"]?.port || 22123,
resourcePolicyId: null,
- defaultResourcePolicyId: inlinePolicyId
+ defaultResourcePolicyId: inlinePolicyId,
+ status: resourceStatusFromSite
})
.where(
eq(
@@ -1131,6 +1152,7 @@ export async function updatePublicResources(
.values({
orgId,
niceId: resourceNiceId,
+ status: resourceStatusFromSite,
name: resourceData.name || "Unnamed Resource",
mode: resourceData.mode,
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts
index 5495a2d8e..cc9865533 100644
--- a/server/lib/blueprints/types.ts
+++ b/server/lib/blueprints/types.ts
@@ -470,7 +470,7 @@ export const PrivateResourceSchema = z
// proxyPort: z.int().positive().optional(),
"destination-port": z.int().positive().optional(),
destination: z.string().min(1).optional(),
- // enabled: z.boolean().default(true),
+ enabled: z.boolean().default(true),
"tcp-ports": portRangeStringSchema.optional().default("*"),
"udp-ports": portRangeStringSchema.optional().default("*"),
"disable-icmp": z.boolean().optional().default(false),
diff --git a/server/lib/deleteResource.ts b/server/lib/deleteResource.ts
index b2ffa0f0f..3f33c7400 100644
--- a/server/lib/deleteResource.ts
+++ b/server/lib/deleteResource.ts
@@ -14,8 +14,6 @@ import {
} from "@server/db";
import logger from "@server/logger";
import { removeTargets } from "@server/routers/newt/targets";
-import createHttpError from "http-errors";
-import HttpCode from "@server/types/HttpCode";
export type DeleteResourceResult = {
deletedResource: Resource;
@@ -117,10 +115,10 @@ export async function runResourceDeleteSideEffects(
.limit(1);
if (!site) {
- throw createHttpError(
- HttpCode.NOT_FOUND,
- `Site with ID ${target.siteId} not found`
+ logger.debug(
+ `Site with ID ${target.siteId} not found during resource delete side effects; skipping target removal`
);
+ continue;
}
if (site.pubKey && site.type === "newt") {
diff --git a/server/lib/deleteSiteAssociatedResources.ts b/server/lib/deleteSiteAssociatedResources.ts
index 61da82f58..e69585d1a 100644
--- a/server/lib/deleteSiteAssociatedResources.ts
+++ b/server/lib/deleteSiteAssociatedResources.ts
@@ -1,6 +1,7 @@
-import { and, eq, sql } from "drizzle-orm";
+import { and, eq, inArray, sql } from "drizzle-orm";
import {
db,
+ resources,
siteNetworks,
siteResources,
targets,
@@ -97,6 +98,64 @@ export function exceedsSiteAssociatedResourceDeleteLimit(
return resourceCount > MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE;
}
+export async function getPendingResourceIdsForSite(
+ siteId: number,
+ trx: Transaction | typeof db = db
+): Promise {
+ const resourceIds = await getResourceIdsForSite(siteId, trx);
+ if (resourceIds.length === 0) {
+ return [];
+ }
+
+ const rows = await trx
+ .select({ resourceId: resources.resourceId })
+ .from(resources)
+ .where(
+ and(
+ inArray(resources.resourceId, resourceIds),
+ eq(resources.status, "pending")
+ )
+ );
+
+ return rows.map((row) => row.resourceId);
+}
+
+export async function getPendingSiteResourceIdsForSite(
+ siteId: number,
+ orgId: string,
+ trx: Transaction | typeof db = db
+): Promise {
+ const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx);
+ if (siteResourceIds.length === 0) {
+ return [];
+ }
+
+ const rows = await trx
+ .select({ siteResourceId: siteResources.siteResourceId })
+ .from(siteResources)
+ .where(
+ and(
+ inArray(siteResources.siteResourceId, siteResourceIds),
+ eq(siteResources.status, "pending")
+ )
+ );
+
+ return rows.map((row) => row.siteResourceId);
+}
+
+export async function getPendingAssociatedResourceCountForSite(
+ siteId: number,
+ orgId: string,
+ trx: Transaction | typeof db = db
+): Promise {
+ const [resourceIds, siteResourceIds] = await Promise.all([
+ getPendingResourceIdsForSite(siteId, trx),
+ getPendingSiteResourceIdsForSite(siteId, orgId, trx)
+ ]);
+
+ return resourceIds.length + siteResourceIds.length;
+}
+
export async function deleteAssociatedResourcesForSite(
siteId: number,
orgId: string,
@@ -105,12 +164,32 @@ export async function deleteAssociatedResourcesForSite(
const resourceIds = await getResourceIdsForSite(siteId, trx);
const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx);
- const [resources, siteResourcesDeleted] = await Promise.all([
+ const [deletedResources, siteResourcesDeleted] = await Promise.all([
performDeleteResources(resourceIds, trx),
performDeleteSiteResources(siteResourceIds, trx)
]);
- return { resources, siteResources: siteResourcesDeleted };
+ return { resources: deletedResources, siteResources: siteResourcesDeleted };
+}
+
+export async function deletePendingAssociatedResourcesForSite(
+ siteId: number,
+ orgId: string,
+ trx: Transaction | typeof db = db
+): Promise {
+ const resourceIds = await getPendingResourceIdsForSite(siteId, trx);
+ const siteResourceIds = await getPendingSiteResourceIdsForSite(
+ siteId,
+ orgId,
+ trx
+ );
+
+ const [deletedResources, siteResourcesDeleted] = await Promise.all([
+ performDeleteResources(resourceIds, trx),
+ performDeleteSiteResources(siteResourceIds, trx)
+ ]);
+
+ return { resources: deletedResources, siteResources: siteResourcesDeleted };
}
export async function runDeleteSiteAssociatedResourcesSideEffects(
diff --git a/server/lib/ip.ts b/server/lib/ip.ts
index 56576282a..fb161df1c 100644
--- a/server/lib/ip.ts
+++ b/server/lib/ip.ts
@@ -496,6 +496,7 @@ export function generateRemoteSubnets(
): string[] {
const remoteSubnets = allSiteResources
.filter((sr) => {
+ if (!sr.enabled) return false;
if (!sr.destination) return false;
if (sr.mode === "cidr") {
@@ -530,6 +531,7 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
return allSiteResources
.filter(
(sr) =>
+ sr.enabled &&
sr.aliasAddress &&
((sr.alias && (sr.mode == "host" || sr.mode == "ssh")) ||
(sr.fullDomain && sr.mode == "http"))
@@ -662,6 +664,13 @@ export async function generateSubnetProxyTargetV2(
subnet: string | null;
}[]
): Promise {
+ if (!siteResource.enabled) {
+ logger.debug(
+ `Site resource ${siteResource.siteResourceId} is disabled, skipping target generation.`
+ );
+ return;
+ }
+
if (clients.length === 0) {
logger.debug(
`No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.`
diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts
index efb856825..eae579634 100644
--- a/server/lib/rebuildClientAssociations.ts
+++ b/server/lib/rebuildClientAssociations.ts
@@ -1561,9 +1561,19 @@ export async function handleMessagingForUpdatedSiteResource(
updatedSiteResource.udpPortRangeString ||
existingSiteResource.disableIcmp !==
updatedSiteResource.disableIcmp);
+ // Toggling enabled on/off doesn't change any of the fields above, but it
+ // does change whether targets/peer data should exist at all, so it needs
+ // to drive the same old->new diff machinery: going enabled->disabled
+ // diffs "real data" against "nothing" (a remove), and disabled->enabled
+ // diffs "nothing" against "real data" (an add). generateSubnetProxyTargetV2/
+ // generateRemoteSubnets/generateAliasConfig already return nothing for a
+ // disabled resource, so no other changes are needed here.
+ const enabledChanged =
+ existingSiteResource &&
+ existingSiteResource.enabled !== updatedSiteResource.enabled;
logger.debug(
- `handleMessagingForUpdatedSiteResource: change flags destinationChanged=${Boolean(destinationChanged)} destinationPortChanged=${Boolean(destinationPortChanged)} aliasChanged=${Boolean(aliasChanged)} fullDomainChanged=${Boolean(fullDomainChanged)} sslChanged=${Boolean(sslChanged)} portRangesChanged=${Boolean(portRangesChanged)}`
+ `handleMessagingForUpdatedSiteResource: change flags destinationChanged=${Boolean(destinationChanged)} destinationPortChanged=${Boolean(destinationPortChanged)} aliasChanged=${Boolean(aliasChanged)} fullDomainChanged=${Boolean(fullDomainChanged)} sslChanged=${Boolean(sslChanged)} portRangesChanged=${Boolean(portRangesChanged)} enabledChanged=${Boolean(enabledChanged)}`
);
// if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
@@ -1574,14 +1584,16 @@ export async function handleMessagingForUpdatedSiteResource(
fullDomainChanged ||
sslChanged ||
portRangesChanged ||
- destinationPortChanged
+ destinationPortChanged ||
+ enabledChanged
) {
const shouldUpdateTargets =
destinationChanged ||
sslChanged ||
portRangesChanged ||
fullDomainChanged ||
- destinationPortChanged;
+ destinationPortChanged ||
+ enabledChanged;
logger.debug(
`handleMessagingForUpdatedSiteResource: entering unchanged-site update path shouldUpdateTargets=${shouldUpdateTargets}`
@@ -1657,20 +1669,22 @@ export async function handleMessagingForUpdatedSiteResource(
peerDataUpdateBatch.push({
clientId: client.clientId,
siteId,
- remoteSubnets: destinationChanged
- ? {
- oldRemoteSubnets: !oldDestinationStillInUseBySite
- ? generateRemoteSubnets([
- existingSiteResource
- ])
- : [],
- newRemoteSubnets: generateRemoteSubnets([
- updatedSiteResource
- ])
- }
- : undefined,
+ remoteSubnets:
+ destinationChanged || enabledChanged
+ ? {
+ oldRemoteSubnets:
+ !oldDestinationStillInUseBySite
+ ? generateRemoteSubnets([
+ existingSiteResource
+ ])
+ : [],
+ newRemoteSubnets: generateRemoteSubnets([
+ updatedSiteResource
+ ])
+ }
+ : undefined,
aliases:
- aliasChanged || fullDomainChanged // the full domain is sent down as an alias
+ aliasChanged || fullDomainChanged || enabledChanged // the full domain is sent down as an alias
? {
oldAliases: generateAliasConfig([
existingSiteResource
diff --git a/server/routers/external.ts b/server/routers/external.ts
index 9e4c5b3f1..62ca15316 100644
--- a/server/routers/external.ts
+++ b/server/routers/external.ts
@@ -248,6 +248,22 @@ authenticated.post(
site.updateSite
);
+authenticated.post(
+ "/site/:siteId/approve",
+ verifySiteAccess,
+ verifyUserHasAction(ActionsEnum.updateSiteApprovals),
+ logActionAudit(ActionsEnum.updateSiteApprovals),
+ site.approveSite
+);
+
+authenticated.post(
+ "/site/:siteId/reject",
+ verifySiteAccess,
+ verifyUserHasAction(ActionsEnum.updateSiteApprovals),
+ logActionAudit(ActionsEnum.updateSiteApprovals),
+ site.rejectSite
+);
+
authenticated.delete(
"/site/:siteId",
verifySiteAccess,
diff --git a/server/routers/integration.ts b/server/routers/integration.ts
index 74ea20078..3abd664eb 100644
--- a/server/routers/integration.ts
+++ b/server/routers/integration.ts
@@ -138,6 +138,7 @@ authenticated.post(
logActionAudit(ActionsEnum.updateSite),
site.updateSite
);
+
authenticated.post(
"/org/:orgId/reset-bandwidth",
verifyApiKeyOrgAccess,
diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts
index ec4500262..ce94a7a50 100644
--- a/server/routers/launcher/launcherResourceAccess.ts
+++ b/server/routers/launcher/launcherResourceAccess.ts
@@ -157,7 +157,8 @@ async function resolveAccessibleIdsUncached(
.where(
and(
eq(userResources.userId, userId),
- eq(resources.orgId, orgId)
+ eq(resources.orgId, orgId),
+ eq(resources.status, "approved")
)
),
userRoleIds.length > 0
@@ -171,7 +172,8 @@ async function resolveAccessibleIdsUncached(
.where(
and(
inArray(roleResources.roleId, userRoleIds),
- eq(resources.orgId, orgId)
+ eq(resources.orgId, orgId),
+ eq(resources.status, "approved")
)
)
: Promise.resolve([]),
@@ -183,7 +185,11 @@ async function resolveAccessibleIdsUncached(
eq(effectiveResourcePolicyId, userPolicies.resourcePolicyId)
)
.where(
- and(eq(userPolicies.userId, userId), eq(resources.orgId, orgId))
+ and(
+ eq(userPolicies.userId, userId),
+ eq(resources.orgId, orgId),
+ eq(resources.status, "approved")
+ )
),
userRoleIds.length > 0
? db
@@ -199,21 +205,48 @@ async function resolveAccessibleIdsUncached(
.where(
and(
inArray(rolePolicies.roleId, userRoleIds),
- eq(resources.orgId, orgId)
+ eq(resources.orgId, orgId),
+ eq(resources.status, "approved")
)
)
: Promise.resolve([]),
db
.select({ siteResourceId: userSiteResources.siteResourceId })
.from(userSiteResources)
- .where(eq(userSiteResources.userId, userId)),
+ .innerJoin(
+ siteResources,
+ eq(
+ userSiteResources.siteResourceId,
+ siteResources.siteResourceId
+ )
+ )
+ .where(
+ and(
+ eq(userSiteResources.userId, userId),
+ eq(siteResources.orgId, orgId),
+ eq(siteResources.status, "approved")
+ )
+ ),
userRoleIds.length > 0
? db
.select({
siteResourceId: roleSiteResources.siteResourceId
})
.from(roleSiteResources)
- .where(inArray(roleSiteResources.roleId, userRoleIds))
+ .innerJoin(
+ siteResources,
+ eq(
+ roleSiteResources.siteResourceId,
+ siteResources.siteResourceId
+ )
+ )
+ .where(
+ and(
+ inArray(roleSiteResources.roleId, userRoleIds),
+ eq(siteResources.orgId, orgId),
+ eq(siteResources.status, "approved")
+ )
+ )
: Promise.resolve([])
]);
@@ -365,6 +398,7 @@ async function filterPublicResourceIdsByTextSearch(
inArray(resources.resourceId, resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true),
+ eq(resources.status, "approved"),
textMatch
)
);
@@ -402,6 +436,7 @@ async function filterSiteResourceIdsByTextSearch(
inArray(siteResources.siteResourceId, siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
+ eq(siteResources.status, "approved"),
textMatch
)
);
@@ -503,7 +538,8 @@ async function listSiteGroups(
const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId),
- eq(resources.enabled, true)
+ eq(resources.enabled, true),
+ eq(resources.status, "approved")
];
if (searchPublic) {
publicConditions.push(searchPublic);
@@ -558,7 +594,8 @@ async function listSiteGroups(
const siteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId),
- eq(siteResources.enabled, true)
+ eq(siteResources.enabled, true),
+ eq(siteResources.status, "approved")
];
if (searchSite) {
siteConditions.push(searchSite);
@@ -621,7 +658,8 @@ async function listSiteGroups(
const noSitePublicConditions = [
inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId),
- eq(resources.enabled, true)
+ eq(resources.enabled, true),
+ eq(resources.status, "approved")
];
if (searchPublic) {
noSitePublicConditions.push(searchPublic);
@@ -655,7 +693,8 @@ async function listSiteGroups(
const noSiteSiteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId),
- eq(siteResources.enabled, true)
+ eq(siteResources.enabled, true),
+ eq(siteResources.status, "approved")
];
if (searchSite) {
noSiteSiteConditions.push(searchSite);
@@ -746,7 +785,8 @@ async function listLabelGroups(
const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId),
- eq(resources.enabled, true)
+ eq(resources.enabled, true),
+ eq(resources.status, "approved")
];
const searchPublic = buildSearchConditionForPublic(query.query);
if (searchPublic) {
@@ -810,7 +850,8 @@ async function listLabelGroups(
const siteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId),
- eq(siteResources.enabled, true)
+ eq(siteResources.enabled, true),
+ eq(siteResources.status, "approved")
];
const searchSite = buildSearchConditionForSiteResource(query.query);
if (searchSite) {
@@ -997,6 +1038,7 @@ async function mapPublicResources(
inArray(resources.resourceId, resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true),
+ eq(resources.status, "approved"),
siteIdFilter != null
? eq(sites.siteId, siteIdFilter)
: undefined
@@ -1088,6 +1130,7 @@ async function mapSiteResources(
inArray(siteResources.siteResourceId, siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
+ eq(siteResources.status, "approved"),
siteIdFilter != null
? eq(sites.siteId, siteIdFilter)
: undefined
@@ -1382,7 +1425,8 @@ async function collectAccessibleSites(
const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId),
- eq(resources.enabled, true)
+ eq(resources.enabled, true),
+ eq(resources.status, "approved")
];
if (siteNameSearch) {
publicConditions.push(siteNameSearch);
@@ -1422,7 +1466,8 @@ async function collectAccessibleSites(
const siteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId),
- eq(siteResources.enabled, true)
+ eq(siteResources.enabled, true),
+ eq(siteResources.status, "approved")
];
if (siteNameSearch) {
siteConditions.push(siteNameSearch);
@@ -1476,6 +1521,7 @@ async function collectAccessibleLabels(
inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true),
+ eq(resources.status, "approved"),
eq(labels.orgId, orgId)
];
if (labelNameSearch) {
@@ -1511,6 +1557,7 @@ async function collectAccessibleLabels(
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
+ eq(siteResources.status, "approved"),
eq(labels.orgId, orgId)
];
if (labelNameSearch) {
diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts
index 5b7f211ae..a12d035a5 100644
--- a/server/routers/newt/buildConfiguration.ts
+++ b/server/routers/newt/buildConfiguration.ts
@@ -148,7 +148,12 @@ export async function buildClientConfigurationForNewtClient(
.from(siteResources)
.innerJoin(networks, eq(siteResources.networkId, networks.networkId))
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
- .where(eq(siteNetworks.siteId, siteId))
+ .where(
+ and(
+ eq(siteNetworks.siteId, siteId),
+ eq(siteResources.enabled, true)
+ )
+ )
.then((rows) => rows.map((r) => r.siteResources));
const targetsToSend: SubnetProxyTargetV2[] = [];
diff --git a/server/routers/olm/buildConfiguration.ts b/server/routers/olm/buildConfiguration.ts
index f72731c92..0266fa041 100644
--- a/server/routers/olm/buildConfiguration.ts
+++ b/server/routers/olm/buildConfiguration.ts
@@ -16,7 +16,7 @@ import {
generateRemoteSubnets
} from "@server/lib/ip";
import logger from "@server/logger";
-import { eq, inArray } from "drizzle-orm";
+import { and, eq, inArray } from "drizzle-orm";
import { addPeer, deletePeer } from "../newt/peers";
import config from "@server/lib/config";
@@ -70,7 +70,13 @@ export async function buildSiteConfigurationForOlmClient(
.innerJoin(networks, eq(siteResources.networkId, networks.networkId))
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
.where(
- eq(clientSiteResourcesAssociationsCache.clientId, client.clientId)
+ and(
+ eq(
+ clientSiteResourcesAssociationsCache.clientId,
+ client.clientId
+ ),
+ eq(siteResources.enabled, true)
+ )
);
const siteResourcesBySiteId = new Map();
diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts
index 9567b5bcd..e0baa49b1 100644
--- a/server/routers/resource/listResources.ts
+++ b/server/routers/resource/listResources.ts
@@ -138,6 +138,15 @@ const listResourcesSchema = z.strictObject({
description:
"When set, only resources that have at least one target on this site are returned"
}),
+ status: z
+ .enum(["pending", "approved"])
+ .optional()
+ .catch(undefined)
+ .openapi({
+ type: "string",
+ enum: ["pending", "approved"],
+ description: "Filter by resource status"
+ }),
labels: z
.preprocess((val) => {
if (val === undefined || val === null || val === "") {
@@ -451,6 +460,7 @@ export async function listResources(
sort_by,
order,
siteId,
+ status,
labels: labelFilter
} = parsedQuery.data;
@@ -660,6 +670,10 @@ export async function listResources(
}
}
+ if (typeof status !== "undefined") {
+ conditions.push(eq(resources.status, status));
+ }
+
if (siteId != null) {
const resourcesWithSite = db
.select({ resourceId: targets.resourceId })
diff --git a/server/routers/resource/listUserResourceAliases.ts b/server/routers/resource/listUserResourceAliases.ts
index 4ec87b8bb..98e73ad85 100644
--- a/server/routers/resource/listUserResourceAliases.ts
+++ b/server/routers/resource/listUserResourceAliases.ts
@@ -45,18 +45,19 @@ function userResourceAliasesCacheKey(
page: number,
pageSize: number,
includeLabels: boolean,
- labelFilter: string[]
+ labelFilter: string[],
+ status?: "pending" | "approved"
) {
const labelsKey =
labelFilter.length > 0 ? labelFilter.slice().sort().join(",") : "all";
- return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}:${includeLabels ? "labels" : "plain"}:${labelsKey}`;
+ return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}:${includeLabels ? "labels" : "plain"}:${labelsKey}:${status ?? "all"}`;
}
const listUserResourceAliasesParamsSchema = z.strictObject({
orgId: z.string()
});
-const listUserResourceAliasesQuerySchema = z.strictObject({
+const listUserResourceAliasesQuerySchema = z.object({
pageSize: z.coerce
.number()
.int()
@@ -96,7 +97,16 @@ const listUserResourceAliasesQuerySchema = z.strictObject({
type: "array",
description:
"Filter by resource labels. A resource matches when it has any of the given labels (OR)."
- })
+ }),
+ status: z
+ .enum(["pending", "approved"])
+ .optional()
+ .catch(undefined)
+ .openapi({
+ type: "string",
+ enum: ["pending", "approved"],
+ description: "Filter by site resource status"
+ })
});
export type UserResourceAliasItem = {
@@ -130,7 +140,8 @@ export async function listUserResourceAliases(
page,
pageSize,
includeLabels,
- labels: labelFilter
+ labels: labelFilter,
+ status
} = parsedQuery.data;
const parsedParams = listUserResourceAliasesParamsSchema.safeParse(
@@ -172,7 +183,8 @@ export async function listUserResourceAliases(
page,
pageSize,
includeLabels,
- labelFilter ?? []
+ labelFilter ?? [],
+ status
);
const cachedData: ListUserResourceAliasesResponse | undefined =
await cache.get(cacheKey);
@@ -257,6 +269,10 @@ export async function listUserResourceAliases(
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
];
+ if (typeof status !== "undefined") {
+ whereConditions.push(eq(siteResources.status, status));
+ }
+
if (labelFilter && labelFilter.length > 0) {
whereConditions.push(
inArray(
diff --git a/server/routers/site/approveSite.ts b/server/routers/site/approveSite.ts
new file mode 100644
index 000000000..19e2795ad
--- /dev/null
+++ b/server/routers/site/approveSite.ts
@@ -0,0 +1,251 @@
+import { Request, Response, NextFunction } from "express";
+import { z } from "zod";
+import {
+ db,
+ resources,
+ siteNetworks,
+ siteResources,
+ sites,
+ type Site,
+ type SiteResource
+} from "@server/db";
+import { and, eq, inArray } from "drizzle-orm";
+import response from "@server/lib/response";
+import HttpCode from "@server/types/HttpCode";
+import createHttpError from "http-errors";
+import logger from "@server/logger";
+import { fromError } from "zod-validation-error";
+import { OpenAPITags, registry } from "@server/openApi";
+import {
+ getResourceIdsForSite,
+ getSiteResourceIdsForSite
+} from "@server/lib/deleteSiteAssociatedResources";
+import {
+ handleMessagingForUpdatedSiteResource,
+ rebuildClientAssociationsFromSiteResource,
+ waitForSiteResourceRebuildIdle
+} from "@server/lib/rebuildClientAssociations";
+
+const approveSiteParamsSchema = z.strictObject({
+ siteId: z.coerce.number().int().positive()
+});
+
+registry.registerPath({
+ method: "post",
+ path: "/site/{siteId}/approve",
+ description:
+ "Approve a pending site and approve (and enable when needed) its associated resources.",
+ tags: [OpenAPITags.Site],
+ request: {
+ params: approveSiteParamsSchema
+ },
+ responses: {
+ 200: {
+ description: "Successful response",
+ content: {
+ "application/json": {
+ schema: z.object({
+ data: z.record(z.string(), z.any()).nullable(),
+ success: z.boolean(),
+ error: z.boolean(),
+ message: z.string(),
+ status: z.number()
+ })
+ }
+ }
+ }
+ }
+});
+
+type SiteResourceEnableSideEffect = {
+ existing: SiteResource;
+ updated: SiteResource;
+ siteIds: number[];
+};
+
+export async function approveSite(
+ req: Request,
+ res: Response,
+ next: NextFunction
+): Promise {
+ try {
+ const parsedParams = approveSiteParamsSchema.safeParse(req.params);
+ if (!parsedParams.success) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ fromError(parsedParams.error).toString()
+ )
+ );
+ }
+
+ const { siteId } = parsedParams.data;
+
+ const [existingSite] = await db
+ .select()
+ .from(sites)
+ .where(eq(sites.siteId, siteId))
+ .limit(1);
+
+ if (!existingSite) {
+ return next(
+ createHttpError(
+ HttpCode.NOT_FOUND,
+ `Site with ID ${siteId} not found`
+ )
+ );
+ }
+
+ if (!existingSite.orgId) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ `Site with ID ${siteId} has no organization`
+ )
+ );
+ }
+
+ const orgId = existingSite.orgId;
+ let updatedSite: Site | undefined;
+ const siteResourceEnableSideEffects: SiteResourceEnableSideEffect[] =
+ [];
+
+ await db.transaction(async (trx) => {
+ [updatedSite] = await trx
+ .update(sites)
+ .set({ status: "approved" })
+ .where(eq(sites.siteId, siteId))
+ .returning();
+
+ const resourceIds = await getResourceIdsForSite(siteId, trx);
+ const siteResourceIds = await getSiteResourceIdsForSite(
+ siteId,
+ orgId,
+ trx
+ );
+
+ if (resourceIds.length > 0) {
+ const pendingDisabledResources = await trx
+ .select({ resourceId: resources.resourceId })
+ .from(resources)
+ .where(
+ and(
+ inArray(resources.resourceId, resourceIds),
+ eq(resources.status, "pending"),
+ eq(resources.enabled, false)
+ )
+ );
+
+ await trx
+ .update(resources)
+ .set({ status: "approved" })
+ .where(inArray(resources.resourceId, resourceIds));
+
+ if (pendingDisabledResources.length > 0) {
+ await trx
+ .update(resources)
+ .set({ enabled: true })
+ .where(
+ inArray(
+ resources.resourceId,
+ pendingDisabledResources.map(
+ (r) => r.resourceId
+ )
+ )
+ );
+ }
+ }
+
+ if (siteResourceIds.length > 0) {
+ const existingSiteResources = await trx
+ .select()
+ .from(siteResources)
+ .where(
+ inArray(siteResources.siteResourceId, siteResourceIds)
+ );
+
+ const pendingDisabledSiteResources =
+ existingSiteResources.filter(
+ (sr) => sr.status === "pending" && !sr.enabled
+ );
+
+ await trx
+ .update(siteResources)
+ .set({ status: "approved" })
+ .where(
+ inArray(siteResources.siteResourceId, siteResourceIds)
+ );
+
+ if (pendingDisabledSiteResources.length > 0) {
+ const enableIds = pendingDisabledSiteResources.map(
+ (sr) => sr.siteResourceId
+ );
+
+ const updatedEnabledSiteResources = await trx
+ .update(siteResources)
+ .set({ enabled: true })
+ .where(inArray(siteResources.siteResourceId, enableIds))
+ .returning();
+
+ for (const updated of updatedEnabledSiteResources) {
+ const existing = pendingDisabledSiteResources.find(
+ (sr) => sr.siteResourceId === updated.siteResourceId
+ );
+ if (!existing || !updated.networkId) {
+ continue;
+ }
+
+ const networkSites = await trx
+ .select({ siteId: siteNetworks.siteId })
+ .from(siteNetworks)
+ .where(
+ eq(siteNetworks.networkId, updated.networkId)
+ );
+
+ siteResourceEnableSideEffects.push({
+ existing,
+ updated,
+ siteIds: networkSites.map((s) => s.siteId)
+ });
+ }
+ }
+ }
+ });
+
+ for (const sideEffect of siteResourceEnableSideEffects) {
+ rebuildClientAssociationsFromSiteResource(sideEffect.updated)
+ .then(() =>
+ waitForSiteResourceRebuildIdle(
+ sideEffect.updated.siteResourceId
+ )
+ )
+ .then(() =>
+ handleMessagingForUpdatedSiteResource(
+ sideEffect.existing,
+ sideEffect.updated,
+ sideEffect.siteIds,
+ sideEffect.siteIds
+ )
+ )
+ .catch((e) => {
+ logger.error(
+ `Failed to rebuild and handle messaging for site resource ${sideEffect.updated.siteResourceId} after site approval:`,
+ e
+ );
+ });
+ }
+
+ return response(res, {
+ data: updatedSite ?? null,
+ success: true,
+ error: false,
+ message: "Site approved successfully",
+ status: HttpCode.OK
+ });
+ } catch (error) {
+ logger.error(error);
+ return next(
+ createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
+ );
+ }
+}
diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts
index 602032ffb..6cf584d18 100644
--- a/server/routers/site/deleteSite.ts
+++ b/server/routers/site/deleteSite.ts
@@ -159,15 +159,39 @@ export async function deleteSite(
siteResources: []
};
- await db.transaction(async (trx) => {
- if (deleteResources) {
+ if (deleteResources) {
+ await db.transaction(async (trx) => {
resourceSideEffects = await deleteAssociatedResourcesForSite(
siteId,
site.orgId,
trx
);
- }
+ if (resourceSideEffects.resources.length > 0) {
+ await usageService.add(
+ site.orgId,
+ LimitId.PUBLIC_RESOURCES,
+ -resourceSideEffects.resources.length,
+ trx
+ );
+ }
+
+ if (resourceSideEffects.siteResources.length > 0) {
+ await usageService.add(
+ site.orgId,
+ LimitId.PRIVATE_RESOURCES,
+ -resourceSideEffects.siteResources.length,
+ trx
+ );
+ }
+ });
+
+ await runDeleteSiteAssociatedResourcesSideEffects(
+ resourceSideEffects
+ );
+ }
+
+ await db.transaction(async (trx) => {
if (site.type == "wireguard") {
if (site.pubKey) {
await deletePeer(site.exitNodeId!, site.pubKey);
@@ -180,12 +204,6 @@ export async function deleteSite(
await usageService.add(site.orgId, LimitId.SITES, -1, trx);
});
- if (deleteResources) {
- await runDeleteSiteAssociatedResourcesSideEffects(
- resourceSideEffects
- );
- }
-
if (deletedNewt) {
const payload = {
type: `newt/wg/terminate`,
diff --git a/server/routers/site/index.ts b/server/routers/site/index.ts
index 292c57971..c0c399789 100644
--- a/server/routers/site/index.ts
+++ b/server/routers/site/index.ts
@@ -3,6 +3,8 @@ export * from "./getStatusHistory";
export * from "./createSite";
export * from "./deleteSite";
export * from "./updateSite";
+export * from "./approveSite";
+export * from "./rejectSite";
export * from "./listSites";
export * from "./listSiteRoles";
export * from "./pickSiteDefaults";
diff --git a/server/routers/site/rejectSite.ts b/server/routers/site/rejectSite.ts
new file mode 100644
index 000000000..c671dcd79
--- /dev/null
+++ b/server/routers/site/rejectSite.ts
@@ -0,0 +1,196 @@
+import { Request, Response, NextFunction } from "express";
+import { z } from "zod";
+import { db } from "@server/db";
+import { newts, sites } from "@server/db";
+import { eq } from "drizzle-orm";
+import response from "@server/lib/response";
+import HttpCode from "@server/types/HttpCode";
+import createHttpError from "http-errors";
+import logger from "@server/logger";
+import { deletePeer } from "../gerbil/peers";
+import { fromError } from "zod-validation-error";
+import { sendToClient } from "#dynamic/routers/ws";
+import { OpenAPITags, registry } from "@server/openApi";
+import { cleanupSiteAssociations } from "@server/lib/rebuildClientAssociations";
+import { usageService } from "@server/lib/billing/usageService";
+import { LimitId } from "@server/lib/billing";
+import {
+ deletePendingAssociatedResourcesForSite,
+ exceedsSiteAssociatedResourceDeleteLimit,
+ getPendingAssociatedResourceCountForSite,
+ runDeleteSiteAssociatedResourcesSideEffects,
+ MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE,
+ type DeleteSiteAssociatedResourcesSideEffects
+} from "@server/lib/deleteSiteAssociatedResources";
+
+const rejectSiteParamsSchema = z.strictObject({
+ siteId: z.coerce.number().int().positive()
+});
+
+registry.registerPath({
+ method: "post",
+ path: "/site/{siteId}/reject",
+ description:
+ "Reject a pending site by deleting it and any associated resources that are still pending.",
+ tags: [OpenAPITags.Site],
+ request: {
+ params: rejectSiteParamsSchema
+ },
+ responses: {
+ 200: {
+ description: "Successful response",
+ content: {
+ "application/json": {
+ schema: z.object({
+ data: z.record(z.string(), z.any()).nullable(),
+ success: z.boolean(),
+ error: z.boolean(),
+ message: z.string(),
+ status: z.number()
+ })
+ }
+ }
+ }
+ }
+});
+
+export async function rejectSite(
+ req: Request,
+ res: Response,
+ next: NextFunction
+): Promise {
+ try {
+ const parsedParams = rejectSiteParamsSchema.safeParse(req.params);
+ if (!parsedParams.success) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ fromError(parsedParams.error).toString()
+ )
+ );
+ }
+
+ const { siteId } = parsedParams.data;
+
+ const [site] = await db
+ .select()
+ .from(sites)
+ .where(eq(sites.siteId, siteId))
+ .limit(1);
+
+ if (!site) {
+ return next(
+ createHttpError(
+ HttpCode.NOT_FOUND,
+ `Site with ID ${siteId} not found`
+ )
+ );
+ }
+
+ if (!site.orgId) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ `Site with ID ${siteId} has no organization`
+ )
+ );
+ }
+
+ const pendingAssociatedResourceCount =
+ await getPendingAssociatedResourceCountForSite(siteId, site.orgId);
+
+ if (
+ exceedsSiteAssociatedResourceDeleteLimit(
+ pendingAssociatedResourceCount
+ )
+ ) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ `Cannot reject site and associated pending resources when the site has more than ${MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE} pending resources`
+ )
+ );
+ }
+
+ const [deletedNewt] = await db
+ .select()
+ .from(newts)
+ .where(eq(newts.siteId, siteId))
+ .limit(1);
+
+ let resourceSideEffects: DeleteSiteAssociatedResourcesSideEffects = {
+ resources: [],
+ siteResources: []
+ };
+
+ await db.transaction(async (trx) => {
+ resourceSideEffects = await deletePendingAssociatedResourcesForSite(
+ siteId,
+ site.orgId,
+ trx
+ );
+
+ if (resourceSideEffects.resources.length > 0) {
+ await usageService.add(
+ site.orgId,
+ LimitId.PUBLIC_RESOURCES,
+ -resourceSideEffects.resources.length,
+ trx
+ );
+ }
+
+ if (resourceSideEffects.siteResources.length > 0) {
+ await usageService.add(
+ site.orgId,
+ LimitId.PRIVATE_RESOURCES,
+ -resourceSideEffects.siteResources.length,
+ trx
+ );
+ }
+ });
+
+ await runDeleteSiteAssociatedResourcesSideEffects(resourceSideEffects);
+
+ await db.transaction(async (trx) => {
+ if (site.type == "wireguard") {
+ if (site.pubKey) {
+ await deletePeer(site.exitNodeId!, site.pubKey);
+ }
+ } else if (site.type == "newt") {
+ await cleanupSiteAssociations(site, trx);
+ }
+
+ await trx.delete(sites).where(eq(sites.siteId, siteId));
+ await usageService.add(site.orgId, LimitId.SITES, -1, trx);
+ });
+
+ if (deletedNewt) {
+ const payload = {
+ type: `newt/wg/terminate`,
+ data: {}
+ };
+ sendToClient(deletedNewt.newtId, payload).catch((error) => {
+ logger.error(
+ "Failed to send termination message to newt:",
+ error
+ );
+ });
+ }
+
+ return response(res, {
+ data: null,
+ success: true,
+ error: false,
+ message: "Site rejected successfully",
+ status: HttpCode.OK
+ });
+ } catch (error) {
+ logger.error(error);
+ if (createHttpError.isHttpError(error)) {
+ return next(error);
+ }
+ return next(
+ createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
+ );
+ }
+}
diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts
index c6851a3c3..42ab8b246 100644
--- a/server/routers/site/updateSite.ts
+++ b/server/routers/site/updateSite.ts
@@ -21,7 +21,6 @@ const updateSiteBodySchema = z
name: z.string().min(1).max(255).optional(),
niceId: z.string().min(1).max(255).optional(),
dockerSocketEnabled: z.boolean().optional(),
- status: z.enum(["pending", "approved"]).optional(),
autoUpdateEnabled: z.boolean().optional(),
autoUpdateOverrideOrg: z.boolean().optional()
})
diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts
index eaf4cf130..365053cf1 100644
--- a/server/routers/siteResource/createSiteResource.ts
+++ b/server/routers/siteResource/createSiteResource.ts
@@ -56,7 +56,6 @@ const createSiteResourceSchema = z
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
destinationPort: z.int().positive().optional(),
destination: z.string().min(1).nullish(),
- enabled: z.boolean().default(true),
alias: z
.string()
.regex(
@@ -275,7 +274,6 @@ export async function createSiteResource(
scheme,
destinationPort,
destination,
- enabled,
ssl,
alias,
userIds,
@@ -539,7 +537,6 @@ export async function createSiteResource(
destination: destination, // the ssh can be null
scheme,
destinationPort,
- enabled,
alias: alias ? alias.trim() : null,
aliasAddress,
tcpPortRangeString: tcpPortRangeStringAdjusted,
diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts
index 4515e033e..2cbbc2c4e 100644
--- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts
+++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts
@@ -86,6 +86,15 @@ const listAllSiteResourcesByOrgQuerySchema = z.strictObject({
description:
"When set, only site resources associated with this site (via network) are returned"
}),
+ status: z
+ .enum(["pending", "approved"])
+ .optional()
+ .catch(undefined)
+ .openapi({
+ type: "string",
+ enum: ["pending", "approved"],
+ description: "Filter by site resource status"
+ }),
labels: z
.preprocess((val) => {
if (val === undefined || val === null || val === "") {
@@ -283,6 +292,7 @@ export async function listAllSiteResourcesByOrg(
sort_by,
order,
siteId,
+ status,
labels: labelFilter
} = parsedQuery.data;
@@ -315,6 +325,10 @@ export async function listAllSiteResourcesByOrg(
conditions.push(eq(siteResources.mode, mode));
}
+ if (typeof status !== "undefined") {
+ conditions.push(eq(siteResources.status, status));
+ }
+
if (labelFilter && labelFilter.length > 0) {
conditions.push(
inArray(
diff --git a/server/routers/siteResource/listSiteResources.ts b/server/routers/siteResource/listSiteResources.ts
index a9688a9c6..c97900bec 100644
--- a/server/routers/siteResource/listSiteResources.ts
+++ b/server/routers/siteResource/listSiteResources.ts
@@ -47,6 +47,15 @@ const listSiteResourcesQuerySchema = z.strictObject({
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
+ }),
+ status: z
+ .enum(["pending", "approved"])
+ .optional()
+ .catch(undefined)
+ .openapi({
+ type: "string",
+ enum: ["pending", "approved"],
+ description: "Filter by site resource status"
})
});
@@ -110,7 +119,7 @@ export async function listSiteResources(
}
const { siteId, orgId } = parsedParams.data;
- const { limit, offset, sort_by, order } = parsedQuery.data;
+ const { limit, offset, sort_by, order, status } = parsedQuery.data;
// Verify the site exists and belongs to the org
const site = await db
@@ -124,6 +133,15 @@ export async function listSiteResources(
}
// Get site resources by joining networks to siteResources via siteNetworks
+ const conditions = [
+ eq(siteNetworks.siteId, siteId),
+ eq(siteResources.orgId, orgId)
+ ];
+
+ if (typeof status !== "undefined") {
+ conditions.push(eq(siteResources.status, status));
+ }
+
const siteResourcesList = await db
.select()
.from(siteNetworks)
@@ -132,12 +150,7 @@ export async function listSiteResources(
siteResources,
eq(siteResources.networkId, networks.networkId)
)
- .where(
- and(
- eq(siteNetworks.siteId, siteId),
- eq(siteResources.orgId, orgId)
- )
- )
+ .where(and(...conditions))
.orderBy(
sort_by
? order === "asc"
diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts
index 173a15190..0ccf29908 100644
--- a/server/routers/siteResource/updateSiteResource.ts
+++ b/server/routers/siteResource/updateSiteResource.ts
@@ -152,6 +152,11 @@ const updateSiteResourceSchema = z
)
.refine(
(data) => {
+ // this is a partial update; only enforce destination when the
+ // caller is actually changing mode or destination
+ if (data.mode === undefined && data.destination === undefined) {
+ return true;
+ }
// destination is only optional for ssh mode with native authDaemonMode
if (data.mode === "ssh" && data.authDaemonMode === "native") {
return true;
@@ -411,8 +416,10 @@ export async function updateSiteResource(
: [];
const existingSiteIds = existingSiteNetworks.map((sn) => sn.siteId);
- let fullDomain: string | null = null;
- let finalSubdomain: string | null = null;
+ // undefined means "leave unchanged" (partial update); only nulled out
+ // when the mode is explicitly being changed away from http
+ let fullDomain: string | null | undefined = undefined;
+ let finalSubdomain: string | null | undefined = undefined;
if (domainId) {
// Validate domain and construct full domain
const domainResult = await validateAndConstructDomain(
@@ -448,6 +455,11 @@ export async function updateSiteResource(
)
);
}
+ } else if (mode !== undefined && mode !== "http") {
+ // mode is explicitly changing away from http, so the resource
+ // can no longer have a domain associated with it
+ fullDomain = null;
+ finalSubdomain = null;
}
// make sure the alias is unique within the org if provided
@@ -516,15 +528,28 @@ export async function updateSiteResource(
destination: destination,
destinationPort: destinationPort,
enabled: enabled,
- alias: alias ? alias.trim() : null,
+ alias:
+ alias !== undefined
+ ? alias
+ ? alias.trim()
+ : null
+ : mode !== undefined &&
+ mode !== "host" &&
+ mode !== "ssh"
+ ? null
+ : undefined,
tcpPortRangeString: tcpPortRangeStringAdjusted,
udpPortRangeString:
mode == "http" || mode == "ssh"
? ""
: udpPortRangeString,
disableIcmp:
- disableIcmp ||
- (mode == "http" || mode == "ssh" ? true : false),
+ mode !== undefined
+ ? disableIcmp ||
+ (mode == "http" || mode == "ssh"
+ ? true
+ : false)
+ : disableIcmp,
domainId,
subdomain: finalSubdomain,
fullDomain,
diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/access/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/access/page.tsx
index e09366329..70a6504c4 100644
--- a/src/app/[orgId]/settings/resources/private/[niceId]/access/page.tsx
+++ b/src/app/[orgId]/settings/resources/private/[niceId]/access/page.tsx
@@ -29,7 +29,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useActionState, useEffect } from "react";
import { useForm } from "react-hook-form";
-import { PrivateResourceAccessFields } from "../../PrivateResourceAccessFields";
+import { PrivateResourceAccessFields } from "@app/components/PrivateResourceAccessFields";
export default function PrivateResourceAccessPage() {
const t = useTranslations();
diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/cidr/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/cidr/page.tsx
index 8064ec883..16e603c01 100644
--- a/src/app/[orgId]/settings/resources/private/[niceId]/cidr/page.tsx
+++ b/src/app/[orgId]/settings/resources/private/[niceId]/cidr/page.tsx
@@ -20,12 +20,12 @@ import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
-import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
-import { PrivateResourceCidrDestinationField } from "../../PrivateResourceDestinationFields";
-import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
-import { buildSelectedSitesForResource } from "../../privateResourceUtils";
-import { asAnyControl, asAnySetValue } from "../../formControlUtils";
-import { useSaveSiteResource } from "../../useSaveSiteResource";
+import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
+import { PrivateResourceCidrDestinationField } from "@app/components/PrivateResourceDestinationFields";
+import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
+import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
+import { asAnyControl, asAnySetValue } from "@app/lib/formControlUtils";
+import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
export default function PrivateResourceCidrPage() {
const t = useTranslations();
diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx
index 6e1a9fdeb..8dbdb3809 100644
--- a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx
+++ b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx
@@ -16,19 +16,21 @@ import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
+ FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
+import { SwitchInput } from "@app/components/SwitchInput";
import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useActionState, useMemo } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
-import { useSaveSiteResource } from "../../useSaveSiteResource";
+import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
export default function PrivateResourceGeneralPage() {
const t = useTranslations();
@@ -41,7 +43,8 @@ export default function PrivateResourceGeneralPage() {
resolver: zodResolver(formSchema),
defaultValues: {
name: siteResource.name,
- niceId: siteResource.niceId
+ niceId: siteResource.niceId,
+ enabled: siteResource.enabled
}
});
@@ -52,7 +55,8 @@ export default function PrivateResourceGeneralPage() {
const data = form.getValues();
await save({
name: data.name,
- niceId: data.niceId
+ niceId: data.niceId,
+ enabled: data.enabled
});
}, null);
@@ -76,6 +80,42 @@ export default function PrivateResourceGeneralPage() {
id="private-resource-general-form"
>
+
+ (
+
+
+
+ form.setValue(
+ "enabled",
+ val
+ )
+ }
+ />
+
+
+ {t(
+ "disabledResourceDescription"
+ )}
+
+
+
+ )}
+ />
+
+
;
}) {
const searchParams = await props.searchParams;
- const getUser = cache(verifySession);
- const user = await getUser({ skipCheckVerifyEmail: true });
+ const user = await verifySession({ skipCheckVerifyEmail: true });
+
+ const lastUsedIdpCookie = (await cookies()).get(LAST_USED_IDP_COOKIE_NAME);
const isInvite = searchParams?.redirect?.includes("/invite");
const forceLoginParam = searchParams?.forceLogin;
@@ -85,19 +89,47 @@ export default async function Page(props: {
(build === "enterprise" && env.app.identityProviderMode === "org");
let loginIdps: LoginFormIDP[] = [];
+ let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null =
+ null;
if (!useSmartLogin) {
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
if (build === "oss" || env.app.identityProviderMode !== "org") {
- const idpsRes = await cache(
- async () =>
- await priv.get>("/idp")
- )();
+ const idpsRes =
+ await priv.get>("/idp");
loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId,
name: idp.name,
variant: idp.type
})) as LoginFormIDP[];
}
+ } else {
+ if (lastUsedIdpCookie) {
+ const lastUsedIdpSchema = z.object({
+ orgId: z.string().optional(),
+ idpId: z.number()
+ });
+ try {
+ const persistedData = lastUsedIdpSchema.parse(
+ JSON.parse(lastUsedIdpCookie.value)
+ );
+
+ const idpRes = await priv.get>(
+ `/idp/${persistedData.idpId}`
+ );
+
+ const idp = idpRes.data.data.idp;
+
+ lastUsedIdpForSmartLogin = {
+ idpId: idp.idpId,
+ name: idp.name,
+ variant: idp.type,
+ orgId: persistedData.orgId,
+ lastUsed: true
+ };
+ } catch (error) {
+ // the idp might not exist or the data is malformatted, skip this
+ }
+ }
}
const t = await getTranslations();
@@ -160,6 +192,7 @@ export default async function Page(props: {
redirect={redirectUrl}
forceLogin={forceLogin}
defaultUser={defaultUser}
+ lastUsedIdp={lastUsedIdpForSmartLogin}
orgSignIn={
!isInvite &&
(build === "saas" ||
diff --git a/src/app/auth/org/[orgId]/page.tsx b/src/app/auth/org/[orgId]/page.tsx
index 2329d5b7c..b2a225120 100644
--- a/src/app/auth/org/[orgId]/page.tsx
+++ b/src/app/auth/org/[orgId]/page.tsx
@@ -13,6 +13,8 @@ import { redirect } from "next/navigation";
import OrgLoginPage from "@app/components/OrgLoginPage";
import { pullEnv } from "@app/lib/pullEnv";
import type { Metadata } from "next";
+import { tierMatrix } from "@server/lib/billing/tierMatrix";
+import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
export const metadata: Metadata = {
title: "Organization Login"
@@ -68,15 +70,22 @@ export default async function OrgAuthPage(props: {
variant: idp.variant
})) as LoginFormIDP[];
+ const hasLoginPageBranding = await isOrgSubscribed(
+ orgId,
+ tierMatrix.loginPageBranding
+ );
+
let branding: LoadLoginPageBrandingResponse | null = null;
- try {
- const res = await priv.get<
- AxiosResponse
- >(`/login-page-branding?orgId=${orgId}`);
- if (res.status === 200) {
- branding = res.data.data;
- }
- } catch (error) {}
+ if (hasLoginPageBranding) {
+ try {
+ const res = await priv.get<
+ AxiosResponse
+ >(`/login-page-branding?orgId=${orgId}`);
+ if (res.status === 200) {
+ branding = res.data.data;
+ }
+ } catch (error) {}
+ }
return (
{
+ const wildcardMatchesRedirect = (
+ wildcardDomain: string,
+ host: string
+ ): boolean => {
if (!wildcardDomain.startsWith("*.")) return false;
const suffix = wildcardDomain.slice(1); // e.g. ".wildcard.owen.fosrl.io"
return host.endsWith(suffix) && host.length > suffix.length;
@@ -228,7 +243,7 @@ export default async function ResourceAuthPage(props: {
let loginIdps: LoginFormIDP[] = [];
if (build === "saas" || env.app.identityProviderMode === "org") {
- if (subscribed) {
+ if (hasOrgOidc) {
const idpsRes = await cache(
async () =>
await priv.get>(
@@ -271,7 +286,7 @@ export default async function ResourceAuthPage(props: {
let branding: LoadLoginPageBrandingResponse | null = null;
try {
- if (subscribed) {
+ if (hasLoginPageBranding) {
const res = await priv.get<
AxiosResponse
>(`/login-page-branding?orgId=${authInfo.orgId}`);
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 27c2c8eb3..3b3a45a04 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -5,7 +5,6 @@ import UserProvider from "@app/providers/UserProvider";
import { ListUserOrgsResponse } from "@server/routers/org";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
-import { cache } from "react";
import OrganizationLanding from "@app/components/OrganizationLanding";
import { pullEnv } from "@app/lib/pullEnv";
import { cleanRedirect } from "@app/lib/cleanRedirect";
@@ -13,7 +12,6 @@ import { Layout } from "@app/components/Layout";
import RedirectToOrg from "@app/components/RedirectToOrg";
import { InitialSetupCompleteResponse } from "@server/routers/auth";
import { cookies } from "next/headers";
-import { build } from "@server/build";
export const dynamic = "force-dynamic";
@@ -29,8 +27,7 @@ export default async function Page(props: {
const env = pullEnv();
- const getUser = cache(verifySession);
- const user = await getUser({ skipCheckVerifyEmail: true });
+ const user = await verifySession({ skipCheckVerifyEmail: true });
let complete = false;
try {
diff --git a/src/components/IdpLoginButtons.tsx b/src/components/IdpLoginButtons.tsx
index d015dce52..0851a0dd0 100644
--- a/src/components/IdpLoginButtons.tsx
+++ b/src/components/IdpLoginButtons.tsx
@@ -1,26 +1,25 @@
"use client";
-import { useEffect, useState } from "react";
-import { Button } from "@app/components/ui/button";
-import { Alert, AlertDescription } from "@app/components/ui/alert";
-import { useTranslations } from "next-intl";
+import { generateOidcUrlProxy } from "@app/actions/server";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
-import {
- generateOidcUrlProxy,
- type GenerateOidcUrlResponse
-} from "@app/actions/server";
+import { Alert, AlertDescription } from "@app/components/ui/alert";
+import { Button } from "@app/components/ui/button";
+import { cleanRedirect } from "@app/lib/cleanRedirect";
+import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
+import { setClientCookie } from "@app/lib/setClientCookie";
+import { useTranslations } from "next-intl";
import {
redirect as redirectTo,
- useParams,
+ useRouter,
useSearchParams
} from "next/navigation";
-import { useRouter } from "next/navigation";
-import { cleanRedirect } from "@app/lib/cleanRedirect";
+import { useEffect, useState, useTransition } from "react";
export type LoginFormIDP = {
idpId: number;
name: string;
variant?: string;
+ lastUsed?: boolean;
};
type IdpLoginButtonsProps = {
@@ -35,7 +34,6 @@ export default function IdpLoginButtons({
orgId
}: IdpLoginButtonsProps) {
const [error, setError] = useState(null);
- const [loading, setLoading] = useState(false);
const t = useTranslations();
const params = useSearchParams();
@@ -52,10 +50,22 @@ export default function IdpLoginButtons({
}
}, []);
+ const [loading, startTransition] = useTransition();
+
async function loginWithIdp(idpId: number) {
- setLoading(true);
setError(null);
+ setClientCookie(
+ LAST_USED_IDP_COOKIE_NAME,
+ JSON.stringify({
+ orgId,
+ idpId
+ }),
+ {
+ sameSite: "Lax"
+ }
+ );
+
let redirectToUrl: string | undefined;
try {
console.log("generating", idpId, redirect || "/", orgId);
@@ -68,7 +78,6 @@ export default function IdpLoginButtons({
if (response.error) {
setError(response.message);
- setLoading(false);
return;
}
@@ -84,7 +93,6 @@ export default function IdpLoginButtons({
"An unexpected error occurred. Please try again."
})
);
- setLoading(false);
}
if (redirectToUrl) {
@@ -124,20 +132,38 @@ export default function IdpLoginButtons({
idp.variant || idp.name.toLowerCase();
return (
-
+
+
+ {idp.lastUsed && (
+
+
+ {t("idpLastUsed")}
+
+
+ )}
+
);
})}
>
diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx
index fe3c80667..82f948ca9 100644
--- a/src/components/LoginForm.tsx
+++ b/src/components/LoginForm.tsx
@@ -30,10 +30,7 @@ import Link from "next/link";
import { GenerateOidcUrlResponse } from "@server/routers/idp";
import { Separator } from "./ui/separator";
import { useTranslations } from "next-intl";
-import {
- generateOidcUrlProxy,
- loginProxy
-} from "@app/actions/server";
+import { generateOidcUrlProxy, loginProxy } from "@app/actions/server";
import { redirect as redirectTo } from "next/navigation";
import { useEnvContext } from "@app/hooks/useEnvContext";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
@@ -41,11 +38,13 @@ import IdpTypeIcon from "@app/components/IdpTypeIcon";
import { loadReoScript } from "reodotdev";
import { build } from "@server/build";
import MfaInputForm from "@app/components/MfaInputForm";
+import { useLocalStorage } from "@app/hooks/useLocalStorage";
export type LoginFormIDP = {
idpId: number;
name: string;
variant?: string;
+ lastUsed?: boolean;
};
type LoginFormProps = {
@@ -105,7 +104,6 @@ export default function LoginForm({
}
}, []);
-
const formSchema = z.object({
email: z.string().email({ message: t("emailInvalid") }),
password: z.string().min(8, { message: t("passwordRequirementsChars") })
@@ -130,11 +128,16 @@ export default function LoginForm({
}
});
+ const [lastUsedIdpId, setLastUsedIdpId] = useLocalStorage(
+ "login:last-used-idp",
+ null
+ );
async function onSubmit(values: any) {
const { email, password } = form.getValues();
const { code } = mfaForm.getValues();
+ setLastUsedIdpId(null);
setLoading(true);
setError(null);
@@ -179,8 +182,7 @@ export default function LoginForm({
if (data.useSecurityKey) {
setError(
t("securityKeyRequired", {
- defaultValue:
- "Please use your security key to sign in."
+ defaultValue: "Please use your security key to sign in."
})
);
return;
@@ -242,6 +244,8 @@ export default function LoginForm({
async function loginWithIdp(idpId: number) {
let redirectUrl: string | undefined;
+
+ setLastUsedIdpId(idpId.toString());
try {
const data = await generateOidcUrlProxy(
idpId,
@@ -356,7 +360,6 @@ export default function LoginForm({
)}
-
{!mfaRequested && (
<>
{
- loginWithIdp(idp.idpId);
- }}
>
-
- {idp.name}
-
+
+
+ {lastUsedIdpId ===
+ idp.idpId.toString() && (
+
+
+ {t("idpLastUsed")}
+
+
+ )}
+
);
})}
>
)}
>
)}
-
);
diff --git a/src/components/LoginPasswordForm.tsx b/src/components/LoginPasswordForm.tsx
index d6e0c0b79..efbc8d727 100644
--- a/src/components/LoginPasswordForm.tsx
+++ b/src/components/LoginPasswordForm.tsx
@@ -22,6 +22,8 @@ import Link from "next/link";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import MfaInputForm from "@app/components/MfaInputForm";
+import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
+import { setClientCookie } from "@app/lib/setClientCookie";
type LoginPasswordFormProps = {
identifier: string;
@@ -82,6 +84,12 @@ export default function LoginPasswordForm({
const { password } = values;
const { code } = mfaForm.getValues();
+ // delete last used auth cookie by setting it in the past
+ setClientCookie(LAST_USED_IDP_COOKIE_NAME, JSON.stringify(null), {
+ sameSite: "Lax",
+ days: -1
+ });
+
setLoading(true);
setError(null);
diff --git a/src/components/PendingSitesTable.tsx b/src/components/PendingSitesTable.tsx
index 8cdfc424d..9e0fa0ad8 100644
--- a/src/components/PendingSitesTable.tsx
+++ b/src/components/PendingSitesTable.tsx
@@ -1,6 +1,16 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
+import {
+ Credenza,
+ CredenzaBody,
+ CredenzaContent,
+ CredenzaDescription,
+ CredenzaFooter,
+ CredenzaHeader,
+ CredenzaTitle
+} from "@app/components/Credenza";
+import SiteResourcesOverview from "@app/components/SiteResourcesOverview";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
import {
@@ -24,6 +34,7 @@ import {
ArrowUp10Icon,
ArrowUpRight,
Check,
+ ChevronDown,
ChevronsUpDownIcon,
MoreHorizontal,
X
@@ -65,8 +76,10 @@ export default function PendingSitesTable({
const [isRefreshing, startTransition] = useTransition();
const [approvingIds, setApprovingIds] = useState>(new Set());
const [rejectingIds, setRejectingIds] = useState>(new Set());
- const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+ const [isRejectModalOpen, setIsRejectModalOpen] = useState(false);
const [selectedSite, setSelectedSite] = useState(null);
+ const [resourcesDialogSite, setResourcesDialogSite] =
+ useState(null);
const api = createApiClient(useEnvContext());
const t = useTranslations();
@@ -111,7 +124,7 @@ export default function PendingSitesTable({
async function approveSite(siteId: number) {
setApprovingIds((prev) => new Set(prev).add(siteId));
try {
- await api.post(`/site/${siteId}`, { status: "approved" });
+ await api.post(`/site/${siteId}/approve`);
toast({
title: t("success"),
description: t("siteApproveSuccess"),
@@ -136,20 +149,20 @@ export default function PendingSitesTable({
async function rejectSite(siteId: number) {
setRejectingIds((prev) => new Set(prev).add(siteId));
try {
- await api.delete(`/site/${siteId}`);
+ await api.post(`/site/${siteId}/reject`);
toast({
title: t("success"),
- description: t("siteDeleted"),
+ description: t("siteRejectSuccess"),
variant: "default"
});
- setIsDeleteModalOpen(false);
+ setIsRejectModalOpen(false);
setSelectedSite(null);
router.refresh();
} catch (e) {
toast({
variant: "destructive",
- title: t("siteErrorDelete"),
- description: formatAxiosError(e, t("siteErrorDelete"))
+ title: t("siteRejectError"),
+ description: formatAxiosError(e, t("siteRejectError"))
});
} finally {
setRejectingIds((prev) => {
@@ -342,6 +355,29 @@ export default function PendingSitesTable({
}
}
},
+ {
+ id: "resources",
+ accessorKey: "resourceCount",
+ friendlyName: t("resources"),
+ header: () => {t("resources")},
+ cell: ({ row }) => {
+ const siteRow = row.original;
+ return (
+
+ );
+ }
+ },
{
accessorKey: "exitNode",
friendlyName: t("exitNode"),
@@ -445,7 +481,7 @@ export default function PendingSitesTable({
disabled={isApproving || isRejecting}
onClick={() => {
setSelectedSite(siteRow);
- setIsDeleteModalOpen(true);
+ setIsRejectModalOpen(true);
}}
>
@@ -491,25 +527,63 @@ export default function PendingSitesTable({
return (
<>
+ {
+ if (!open) setResourcesDialogSite(null);
+ }}
+ >
+
+
+ {t("siteResourcesTab")}
+
+ {t("siteResourcesDialogDescription")}
+
+
+
+ {resourcesDialogSite != null && (
+
+ )}
+
+
+
+
+
+
+
{selectedSite && (
{
- setIsDeleteModalOpen(val);
+ setIsRejectModalOpen(val);
if (!val) {
setSelectedSite(null);
}
}}
dialog={
-
{t("siteQuestionRemove")}
-
{t("siteMessageRemove")}
+
{t("siteQuestionReject")}
+
{t("siteMessageReject")}
}
- buttonText={t("siteConfirmDelete")}
+ buttonText={t("siteConfirmReject")}
onConfirm={async () => rejectSite(selectedSite.id)}
string={selectedSite.name}
- title={t("siteDelete")}
+ title={t("siteReject")}
/>
)}
= {
control: Control;
diff --git a/src/app/[orgId]/settings/resources/private/PrivateResourceSshFields.tsx b/src/components/PrivateResourceSshFields.tsx
similarity index 97%
rename from src/app/[orgId]/settings/resources/private/PrivateResourceSshFields.tsx
rename to src/components/PrivateResourceSshFields.tsx
index e61ed2292..edfdde5de 100644
--- a/src/app/[orgId]/settings/resources/private/PrivateResourceSshFields.tsx
+++ b/src/components/PrivateResourceSshFields.tsx
@@ -9,10 +9,10 @@ import {
} from "@app/components/Settings";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
-import { PrivateResourceAliasField } from "./PrivateResourceDestinationFields";
-import { PrivateResourceSitesField } from "./PrivateResourceSitesField";
-import { getSshUseMultiSiteTargetForm } from "./privateResourceUtils";
+import { PrivateResourceAliasField } from "@app/components/PrivateResourceDestinationFields";
+import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { inferSshPamMode } from "@app/lib/privateResourceForm";
+import { getSshUseMultiSiteTargetForm } from "@app/lib/privateResourceUtils";
import {
FormControl,
FormField,
diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx
index 8d6f1a014..6c2999624 100644
--- a/src/components/PrivateResourcesTable.tsx
+++ b/src/components/PrivateResourcesTable.tsx
@@ -23,6 +23,7 @@ import {
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
+import { Switch } from "@app/components/ui/switch";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
@@ -36,7 +37,9 @@ import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHr
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
+import { UpdateSiteResourceResponse } from "@server/routers/siteResource";
import type { PaginationState } from "@tanstack/react-table";
+import { AxiosResponse } from "axios";
import {
ArrowDown01Icon,
ArrowRight,
@@ -49,8 +52,17 @@ import {
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
-import { startTransition, useMemo, useState, useTransition } from "react";
+import {
+ startTransition,
+ useMemo,
+ useOptimistic,
+ useRef,
+ useState,
+ useTransition,
+ type ComponentRef
+} from "react";
import { useDebouncedCallback } from "use-debounce";
+import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
import { LabelsTableCell } from "./LabelsTableCell";
@@ -114,6 +126,11 @@ function isSafeUrlForLink(href: string): boolean {
}
}
+const booleanSearchFilterSchema = z
+ .enum(["true", "false"])
+ .optional()
+ .catch(undefined);
+
type ClientResourcesTableProps = {
internalResources: InternalResourceRow[];
orgId: string;
@@ -174,6 +191,30 @@ export default function PrivateResourcesTable({
});
};
+ async function toggleInternalResourceEnabled(
+ val: boolean,
+ resourceId: number
+ ) {
+ try {
+ await api.post>(
+ `site-resource/${resourceId}`,
+ {
+ enabled: val
+ }
+ );
+ router.refresh();
+ } catch (e) {
+ toast({
+ variant: "destructive",
+ title: t("resourcesErrorUpdate"),
+ description: formatAxiosError(
+ e,
+ t("resourcesErrorUpdateDescription")
+ )
+ });
+ }
+ }
+
const deleteInternalResource = async (
resourceId: number,
siteId: number
@@ -429,6 +470,36 @@ export default function PrivateResourcesTable({
);
}
},
+ {
+ accessorKey: "enabled",
+ friendlyName: t("enabled"),
+ header: () => (
+
+ handleFilterChange("enabled", value)
+ }
+ searchPlaceholder={t("searchPlaceholder")}
+ emptyMessage={t("emptySearchOptions")}
+ label={t("enabled")}
+ className="p-3"
+ />
+ ),
+ cell: ({ row }) => (
+
+ )
+ },
{
id: "labels",
accessorKey: "labels",
@@ -643,3 +714,39 @@ function ClientResourceLabelCell({
/>
);
}
+
+type InternalResourceEnabledFormProps = {
+ resource: InternalResourceRow;
+ onToggleInternalResourceEnabled: (
+ val: boolean,
+ resourceId: number
+ ) => Promise;
+};
+
+function InternalResourceEnabledForm({
+ resource,
+ onToggleInternalResourceEnabled
+}: InternalResourceEnabledFormProps) {
+ const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(
+ resource.enabled
+ );
+
+ const formRef = useRef>(null);
+
+ async function submitAction(formData: FormData) {
+ const newEnabled = !(formData.get("enabled") === "on");
+ setOptimisticEnabled(newEnabled);
+ await onToggleInternalResourceEnabled(newEnabled, resource.id);
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/SiteResourcesOverview.tsx b/src/components/SiteResourcesOverview.tsx
index d861a870c..7563b6d22 100644
--- a/src/components/SiteResourcesOverview.tsx
+++ b/src/components/SiteResourcesOverview.tsx
@@ -174,8 +174,8 @@ type OverviewRow = {
type OverviewColumnProps = {
title: string;
description: string;
- viewAllHref: string;
- viewAllLabel: string;
+ viewAllHref?: string;
+ viewAllLabel?: string;
emptyLabel: string;
isForbidden: boolean;
isFetching: boolean;
@@ -212,12 +212,14 @@ function OverviewColumn({
{description}
-
- {viewAllLabel}
-
+ {viewAllHref && viewAllLabel ? (
+
+ {viewAllLabel}
+
+ ) : null}
);
@@ -319,6 +321,8 @@ type SiteResourcesOverviewProps = {
initialPrivateForbidden: boolean;
/** When not under `/[orgId]/...` routes, pass org id explicitly (e.g. credenza on sites list). */
orgIdOverride?: string;
+ /** When false, hides links to the org resources tables filtered by this site. */
+ showViewAllLinks?: boolean;
};
export default function SiteResourcesOverview({
@@ -327,7 +331,8 @@ export default function SiteResourcesOverview({
initialPrivateData,
initialPublicForbidden,
initialPrivateForbidden,
- orgIdOverride
+ orgIdOverride,
+ showViewAllLinks = true
}: SiteResourcesOverviewProps) {
const t = useTranslations();
const params = useParams<{ orgId: string }>();
@@ -467,8 +472,10 @@ export default function SiteResourcesOverview({
key="public"
title={t("siteResourcesSectionPublic")}
description={t("siteResourcesSectionPublicDescription")}
- viewAllHref={publicViewAllHref}
- viewAllLabel={t("siteResourcesViewAllPublic")}
+ viewAllHref={showViewAllLinks ? publicViewAllHref : undefined}
+ viewAllLabel={
+ showViewAllLinks ? t("siteResourcesViewAllPublic") : undefined
+ }
emptyLabel={t("siteResourcesEmptyPublic")}
isForbidden={publicForbidden}
isFetching={publicQuery.isFetching}
@@ -484,8 +491,10 @@ export default function SiteResourcesOverview({
key="private"
title={t("siteResourcesSectionPrivate")}
description={t("siteResourcesSectionPrivateDescription")}
- viewAllHref={privateViewAllHref}
- viewAllLabel={t("siteResourcesViewAllPrivate")}
+ viewAllHref={showViewAllLinks ? privateViewAllHref : undefined}
+ viewAllLabel={
+ showViewAllLinks ? t("siteResourcesViewAllPrivate") : undefined
+ }
emptyLabel={t("siteResourcesEmptyPrivate")}
isForbidden={privateForbidden}
isFetching={privateQuery.isFetching}
diff --git a/src/components/SmartLoginForm.tsx b/src/components/SmartLoginForm.tsx
index dd0e131df..de1955771 100644
--- a/src/components/SmartLoginForm.tsx
+++ b/src/components/SmartLoginForm.tsx
@@ -27,6 +27,8 @@ import UserProfileCard from "@app/components/UserProfileCard";
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
import { Separator } from "@app/components/ui/separator";
import OrgSignInLink from "@app/components/OrgSignInLink";
+import type { LoginFormIDP } from "./LoginForm";
+import IdpLoginButtons from "./IdpLoginButtons";
const identifierSchema = z.object({
identifier: z.string().min(1, "Username or email is required")
@@ -53,6 +55,7 @@ type SmartLoginFormProps = {
forceLogin?: boolean;
defaultUser?: string;
orgSignIn?: OrgSignInConfig;
+ lastUsedIdp?: (LoginFormIDP & { orgId?: string }) | null;
};
type ViewState =
@@ -89,7 +92,8 @@ export default function SmartLoginForm({
redirect,
forceLogin,
defaultUser,
- orgSignIn
+ orgSignIn,
+ lastUsedIdp
}: SmartLoginFormProps) {
const router = useRouter();
const { env } = useEnvContext();
@@ -294,6 +298,15 @@ export default function SmartLoginForm({
+
+ {lastUsedIdp && (
+
+ )}
+
{
+const DEFAULT_PAID_TIERS: Tier[] = ["tier1", "tier2", "tier3", "enterprise"];
+
+export const isOrgSubscribed = cache(async (orgId: string, tiers?: Tier[]) => {
let subscribed = false;
+ const allowedTiers = tiers ?? DEFAULT_PAID_TIERS;
if (build === "enterprise") {
try {
@@ -20,7 +24,8 @@ export const isOrgSubscribed = cache(async (orgId: string) => {
try {
const subRes = await getCachedSubscription(orgId);
subscribed =
- (subRes.data.data.tier == "tier1" || subRes.data.data.tier == "tier2" || subRes.data.data.tier == "tier3" || subRes.data.data.tier == "enterprise") &&
+ !!subRes.data.data.tier &&
+ allowedTiers.includes(subRes.data.data.tier as Tier) &&
subRes.data.data.active;
} catch {}
}
diff --git a/src/lib/consts.ts b/src/lib/consts.ts
new file mode 100644
index 000000000..13f86484f
--- /dev/null
+++ b/src/lib/consts.ts
@@ -0,0 +1 @@
+export const LAST_USED_IDP_COOKIE_NAME = "p__last_used_idp";
diff --git a/src/app/[orgId]/settings/resources/private/formControlUtils.ts b/src/lib/formControlUtils.ts
similarity index 100%
rename from src/app/[orgId]/settings/resources/private/formControlUtils.ts
rename to src/lib/formControlUtils.ts
diff --git a/src/lib/loadOrgLoginPageBranding.ts b/src/lib/loadOrgLoginPageBranding.ts
index 7e549622a..02ef66295 100644
--- a/src/lib/loadOrgLoginPageBranding.ts
+++ b/src/lib/loadOrgLoginPageBranding.ts
@@ -1,6 +1,7 @@
import { priv } from "@app/lib/api";
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
import { build } from "@server/build";
+import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { LoadLoginPageBrandingResponse } from "@server/routers/loginPage/types";
import { AxiosResponse } from "axios";
@@ -11,7 +12,10 @@ export async function loadOrgLoginPageBranding(orgId: string): Promise<{
return { primaryColor: null };
}
- const subscribed = await isOrgSubscribed(orgId);
+ const subscribed = await isOrgSubscribed(
+ orgId,
+ tierMatrix.loginPageBranding
+ );
if (!subscribed) {
return { primaryColor: null };
}
diff --git a/src/lib/privateResourceForm.ts b/src/lib/privateResourceForm.ts
index d9f6fd691..3edd436e4 100644
--- a/src/lib/privateResourceForm.ts
+++ b/src/lib/privateResourceForm.ts
@@ -165,7 +165,6 @@ export function buildCreateSiteResourcePayload(
siteIds: data.siteIds,
mode: data.mode,
destination: isNativeSsh ? undefined : (data.destination ?? undefined),
- enabled: true,
...(data.mode === "http" && {
scheme: data.scheme,
ssl: data.ssl ?? false,
@@ -342,7 +341,8 @@ export function createGeneralFormSchema(t: TranslateFn) {
.string()
.min(1)
.max(255)
- .regex(/^[a-zA-Z0-9-]+$/)
+ .regex(/^[a-zA-Z0-9-]+$/),
+ enabled: z.boolean()
});
}
diff --git a/src/app/[orgId]/settings/resources/private/privateResourceUtils.ts b/src/lib/privateResourceUtils.ts
similarity index 100%
rename from src/app/[orgId]/settings/resources/private/privateResourceUtils.ts
rename to src/lib/privateResourceUtils.ts
diff --git a/src/lib/setClientCookie.ts b/src/lib/setClientCookie.ts
new file mode 100644
index 000000000..dad75b049
--- /dev/null
+++ b/src/lib/setClientCookie.ts
@@ -0,0 +1,32 @@
+/**
+ * Set a cookie on the client side in javascript code, not on the server
+ * @param name
+ * @param value
+ * @param days
+ * @param options
+ */
+export function setClientCookie(
+ name: string,
+ value: string,
+ options: {
+ days?: number;
+ path?: string;
+ secure?: boolean;
+ sameSite?: "Strict" | "Lax" | "None";
+ } = {}
+): void {
+ let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
+
+ if (options.days) {
+ const date = new Date();
+ date.setTime(date.getTime() + options.days * 864e5);
+ cookie += `; expires=${date.toUTCString()}`;
+ }
+
+ cookie += `; path=${options.path ?? "/"}`;
+
+ if (options.secure) cookie += "; Secure";
+ if (options.sameSite) cookie += `; SameSite=${options.sameSite}`;
+
+ document.cookie = cookie;
+}