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 ( +
+ formRef.current?.requestSubmit()} + /> + + ); +} 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; +}