diff --git a/README.md b/README.md index ab857350e..1827f2570 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@

-Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls. +Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure connectivity to infrastructure anywhere. It combines reverse-proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to private resources with NAT traversal, all with granular access control. ## Installation @@ -63,11 +63,26 @@ Pangolin is an open-source, identity-based remote access platform built on WireG Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform. +* Lightweight user-space connector runs anywhere +* Punches through any firewall +* Doesn't require open ports or a public IP +* Strict network segmentation +* WireGuard-based +* Get alerts when a device or network resource goes down + Sites ### Browser-based reverse proxy access -Expose web applications through identity and context-aware tunneled reverse proxies. Users access applications through any web browser with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet. +Expose HTTPS web applications and connect to VNC, RDP, and SSH entirely in the browser through identity and context-aware tunneled reverse proxies. Users access resources with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet. + +* Expose a web panel anywhere +* Access via any web browser +* Single sign-on across all resources +* HTTPS resources +* Remote desktop in the browser with VNC and RDP +* In-browser SSH terminal with privileged access management (PAM) +* PIN codes, passcodes, email OTP, geoblocking, allow-lists, and more Reverse proxy access @@ -75,14 +90,35 @@ Expose web applications through identity and context-aware tunneled reverse prox Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network. +* Peer-to-peer with intelligent NAT traversal +* Hosts/IPs and port ranges +* Network ranges/CIDRs +* Friendly DNS aliases for network addresses +* Privileged access management (PAM) with SSH resources +* Private HTTPS resources only accessible on the private network + Private resources ### Give users and roles access to resources -Use Pangolin's built in users or bring your own identity provider and set up role based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define. +Use Pangolin's built-in users or bring your own identity provider and set up role-based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define. + +* Bring your existing identity provider (IdP) or use Pangolin identities +* Sync users and roles from your IdP +* User- and role-based access control +* Full network audit and access logs Users from identity provider with roles +### Find and launch resources from a personalized home page + +Give users a landing page to quickly find and open the resources they can access. Resources are grouped by site or label, searchable, and filterable, with grid or list views. Saved views capture filters, grouping, and layout as personal or organization-wide defaults. + +* Single place for admins and non-admins to see accessible resources +* Create reusable views for common access patterns + +Resource Launcher + ## Download Clients Download the Pangolin client for your platform: diff --git a/install/main.go b/install/main.go index 001f09a52..13afbaca3 100644 --- a/install/main.go +++ b/install/main.go @@ -76,7 +76,7 @@ var redisFlag *bool func main() { crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt") - redisFlag = flag.Bool("redis", false, "Install Redis as cacheing solution. Required for HA. Not required for the Enterprise version.") + redisFlag = flag.Bool("redis", false, "Install Redis as caching solution. Required for HA. Not required for the Enterprise version.") flag.Parse() // print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 2d455afa8..4bf0d9ab6 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": "Създаване на ресурс", @@ -1502,6 +1511,10 @@ "actionListInvitations": "Списък с покани", "actionExportLogs": "Експортиране на дневници", "actionViewLogs": "Преглед на дневници", + "actionCreateSiteProvisioningKey": "Създаване на ключ за предоставяне на сайт", + "actionListSiteProvisioningKeys": "Списък на ключовете за предоставяне на сайт", + "actionUpdateSiteProvisioningKey": "Актуализиране на ключ за предоставяне на сайт", + "actionDeleteSiteProvisioningKey": "Изтриване на ключ за предоставяне на сайт", "noneSelected": "Нищо не е избрано", "orgNotFound2": "Няма намерени организации.", "search": "Търси…", @@ -1516,6 +1529,7 @@ "otpAuthDescription": "Въведете кода от приложението за удостоверяване или един от вашите резервни кодове за еднократна употреба.", "otpAuthSubmit": "Изпрати код", "idpContinue": "Или продължете със", + "idpLastUsed": "Последно използвано", "otpAuthBack": "Назад към парола", "navbar": "Навигационно меню", "navbarDescription": "Главно навигационно меню за приложението", @@ -3801,5 +3815,6 @@ "rdpUploadFailed": "Качването неуспешно", "rdpUnicodeKeyboardMode": "Режим на unicode клавиатура", "sessionToolbarShow": "Показване на лентата с инструменти", - "sessionToolbarHide": "Скриване на лентата с инструменти" + "sessionToolbarHide": "Скриване на лентата с инструменти", + "actionUpdateSiteApprovals": "Обновяване на одобренията на сайта" } diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index f456ee3b9..0d96a80cd 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", @@ -1502,6 +1511,10 @@ "actionListInvitations": "Seznam pozvánek", "actionExportLogs": "Exportovat protokoly", "actionViewLogs": "Zobrazit logy", + "actionCreateSiteProvisioningKey": "Vytvořit klíč pro zřízení webu", + "actionListSiteProvisioningKeys": "Seznam klíčů pro zřízení webu", + "actionUpdateSiteProvisioningKey": "Aktualizovat klíč pro zřízení webu", + "actionDeleteSiteProvisioningKey": "Smazat klíč pro zřízení webu", "noneSelected": "Není vybráno", "orgNotFound2": "Nebyly nalezeny žádné organizace.", "search": "Vyhledávání…", @@ -1516,6 +1529,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", @@ -3801,5 +3815,6 @@ "rdpUploadFailed": "Nahrání selhalo", "rdpUnicodeKeyboardMode": "Režim Unicode klávesnice", "sessionToolbarShow": "Zobrazit panel nástrojů", - "sessionToolbarHide": "Skrýt panel nástrojů" + "sessionToolbarHide": "Skrýt panel nástrojů", + "actionUpdateSiteApprovals": "Aktualizovat schválení webu" } diff --git a/messages/da-DK.json b/messages/da-DK.json index ce08bff23..f88ffa474 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", @@ -1502,6 +1511,10 @@ "actionListInvitations": "Vis invitationer", "actionExportLogs": "Eksportér logs", "actionViewLogs": "Vis logs", + "actionCreateSiteProvisioningKey": "Opret provisioneringsnøgle til site", + "actionListSiteProvisioningKeys": "Vis provisioneringsnøgler til site", + "actionUpdateSiteProvisioningKey": "Opdater provisioneringsnøgle til site", + "actionDeleteSiteProvisioningKey": "Slet provisioneringsnøgle til site", "noneSelected": "Ingen valgt", "orgNotFound2": "Ingen organisationer fundet.", "search": "Søg…", @@ -1516,6 +1529,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", @@ -3801,5 +3815,6 @@ "rdpUploadFailed": "Uploaden mislykkedes", "rdpUnicodeKeyboardMode": "Unicode tastaturtilstand", "sessionToolbarShow": "Vis værktøjslinje", - "sessionToolbarHide": "Skjul værktøjslinje" + "sessionToolbarHide": "Skjul værktøjslinje", + "actionUpdateSiteApprovals": "Opdater godkendelser på site" } diff --git a/messages/de-DE.json b/messages/de-DE.json index dc7dbb808..615759bf1 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", @@ -1502,6 +1511,10 @@ "actionListInvitations": "Einladungen auflisten", "actionExportLogs": "Logs exportieren", "actionViewLogs": "Logs anzeigen", + "actionCreateSiteProvisioningKey": "Bereitstellungsschlüssel für Standort erstellen", + "actionListSiteProvisioningKeys": "Liste der Bereitstellungsschlüssel für Standorte", + "actionUpdateSiteProvisioningKey": "Bereitstellungsschlüssel für Standort aktualisieren", + "actionDeleteSiteProvisioningKey": "Bereitstellungsschlüssel für Standort löschen", "noneSelected": "Keine ausgewählt", "orgNotFound2": "Keine Organisationen gefunden.", "search": "Suche…", @@ -1516,6 +1529,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", @@ -3801,5 +3815,6 @@ "rdpUploadFailed": "Upload fehlgeschlagen", "rdpUnicodeKeyboardMode": "Unicode-Tastaturmodus", "sessionToolbarShow": "Werkzeugleiste zeigen", - "sessionToolbarHide": "Werkzeugleiste ausblenden" + "sessionToolbarHide": "Werkzeugleiste ausblenden", + "actionUpdateSiteApprovals": "Standortgenehmigungen aktualisieren" } diff --git a/messages/en-US.json b/messages/en-US.json index 2a2c83b18..aff3be28b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -178,7 +178,7 @@ "shareDeleteConfirm": "Confirm Delete Shareable Link", "shareQuestionRemove": "Are you sure you want to delete this share link?", "shareMessageRemove": "Once deleted, the link will no longer work and anyone using it will lose access to the resource.", - "shareTokenDescription": "The access token can be passed in two ways: as a query parameter or in the request headers. These must be passed from the client on every request for authenticated access.", + "shareTokenDescription": "The access token can be passed as a query parameter or in request headers. By default it must be sent on every request. If session persistence is enabled, the first request exchanges it for a session cookie.", "accessToken": "Access Token", "usageExamples": "Usage Examples", "tokenId": "Token ID", @@ -196,8 +196,14 @@ "shareTitleOptional": "Title (optional)", "sharePathOptional": "Path (optional)", "sharePathDescription": "The link will redirect users to this path after authentication.", + "shareAssociateUserOptional": "Associate User (optional)", + "shareAssociateUserDescription": "When set, requests using this link are attributed to the user in access logs and identity headers. The link is removed if the user leaves the organization.", + "userSelect": "Select user", + "usersNotFound": "No users found", "expireIn": "Expire In", "neverExpire": "Never expire", + "sharePersistSession": "Persist session after first use", + "sharePersistSessionDescription": "When enabled, the first request with this token via a query param or header sets a session cookie so later requests do not need the token. Leave off for API clients that should send the token on every request.", "shareExpireDescription": "Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource.", "shareSeeOnce": "You will only be able to see this link once. Make sure to copy it.", "shareAccessHint": "Anyone with this link can access the resource. Share it with care.", @@ -449,8 +455,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 +478,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 +492,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", @@ -922,7 +934,7 @@ "newtVersion": "Version", "architecture": "Architecture", "sites": "Sites", - "siteWgAnyClients": "Use any WireGuard client to connect. You will have to address internal resources using the peer IP.", + "siteWgAnyClients": "Use any WireGuard client to connect. You will have to address private resources using the peer IP.", "siteWgCompatibleAllClients": "Compatible with all WireGuard clients", "siteWgManualConfigurationRequired": "Manual configuration required", "userErrorNotAdminOrOwner": "User is not an admin or owner", @@ -1420,6 +1432,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", @@ -1503,6 +1517,10 @@ "actionListInvitations": "List Invitations", "actionExportLogs": "Export Logs", "actionViewLogs": "View Logs", + "actionCreateSiteProvisioningKey": "Create Site Provisioning Key", + "actionListSiteProvisioningKeys": "List Site Provisioning Keys", + "actionUpdateSiteProvisioningKey": "Update Site Provisioning Key", + "actionDeleteSiteProvisioningKey": "Delete Site Provisioning Key", "noneSelected": "None selected", "orgNotFound2": "No organizations found.", "search": "Search…", @@ -1517,6 +1535,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", @@ -1976,6 +1995,9 @@ "domainPickerSubdomain": "Subdomain: {subdomain}", "domainPickerNamespace": "Namespace: {namespace}", "domainPickerShowMore": "Show More", + "domainPickerNoDomainsAvailableTitle": "No domains available", + "domainPickerNoDomainsAvailableDescription": "You don't have any domains set up yet. Create a domain to continue.", + "domainPickerNoDomainsAvailableAction": "Go to Domains", "regionSelectorTitle": "Select Region", "domainPickerRemoteExitNodeWarning": "Provided domains are not supported when sites connect to remote exit nodes. For resources to be available on remote nodes, use a custom domain instead.", "regionSelectorInfo": "Selecting a region helps us provide better performance for your location. You do not have to be in the same region as your server.", @@ -2339,7 +2361,7 @@ "resourcesTableProxyResources": "Public", "resourcesTableClientResources": "Private", "resourcesTableNoProxyResourcesFound": "No proxy resources found.", - "resourcesTableNoInternalResourcesFound": "No internal resources found.", + "resourcesTableNoInternalResourcesFound": "No private resources found.", "resourcesTableDestination": "Destination", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Alias Address", @@ -2362,9 +2384,9 @@ "editInternalResourceDialogCancel": "Cancel", "editInternalResourceDialogSaveResource": "Save Resource", "editInternalResourceDialogSuccess": "Success", - "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Internal resource updated successfully", + "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Private resource updated successfully", "editInternalResourceDialogError": "Error", - "editInternalResourceDialogFailedToUpdateInternalResource": "Failed to update internal resource", + "editInternalResourceDialogFailedToUpdateInternalResource": "Failed to update private resource", "editInternalResourceDialogNameRequired": "Name is required", "editInternalResourceDialogNameMaxLength": "Name must be less than 255 characters", "editInternalResourceDialogProxyPortMin": "Proxy port must be at least 1", @@ -2390,7 +2412,7 @@ "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.", "createInternalResourceDialogNoSitesAvailable": "No Sites Available", - "createInternalResourceDialogNoSitesAvailableDescription": "You need to have at least one Newt site with a subnet configured to create internal resources.", + "createInternalResourceDialogNoSitesAvailableDescription": "You need to have at least one Newt site with a subnet configured to create private resources.", "createInternalResourceDialogClose": "Close", "createInternalResourceDialogCreateClientResource": "Create Private Resource", "createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization", @@ -2419,9 +2441,9 @@ "createInternalResourceDialogCancel": "Cancel", "createInternalResourceDialogCreateResource": "Create Resource", "createInternalResourceDialogSuccess": "Success", - "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Internal resource created successfully", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Private resource created successfully", "createInternalResourceDialogError": "Error", - "createInternalResourceDialogFailedToCreateInternalResource": "Failed to create internal resource", + "createInternalResourceDialogFailedToCreateInternalResource": "Failed to create private resource", "createInternalResourceDialogNameRequired": "Name is required", "createInternalResourceDialogNameMaxLength": "Name must be less than 255 characters", "createInternalResourceDialogPleaseSelectSite": "Please select a site", @@ -3076,8 +3098,8 @@ "sourceAddress": "Source Address", "destinationAddress": "Destination Address", "duration": "Duration", - "licenseRequiredToUse": "An Enterprise Edition license or Pangolin Cloud is required to use this feature. Book a free demo or POC trial to learn more.", - "ossEnterpriseEditionRequired": "The Enterprise Edition is required to use this feature. This feature is also available in Pangolin Cloud. Book a free demo or POC trial to learn more.", + "licenseRequiredToUse": "An Enterprise Edition license or Pangolin Cloud is required to use this feature. Book a free demo or POC trial to learn more.", + "ossEnterpriseEditionRequired": "The Enterprise Edition is required to use this feature. This feature is also available in Pangolin Cloud. Book a free demo or POC trial to learn more.", "certResolver": "Certificate Resolver", "certResolverDescription": "Select the certificate resolver to use for this resource.", "selectCertResolver": "Select Certificate Resolver", @@ -3802,5 +3824,6 @@ "rdpUploadFailed": "Upload failed", "rdpUnicodeKeyboardMode": "Unicode keyboard mode", "sessionToolbarShow": "Show toolbar", - "sessionToolbarHide": "Hide toolbar" + "sessionToolbarHide": "Hide toolbar", + "actionUpdateSiteApprovals": "Update Site Approvals" } diff --git a/messages/es-ES.json b/messages/es-ES.json index 9bc4c83a1..e677252a0 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", @@ -1502,6 +1511,10 @@ "actionListInvitations": "Listar invitaciones", "actionExportLogs": "Exportar registros", "actionViewLogs": "Ver registros", + "actionCreateSiteProvisioningKey": "Crear clave de aprovisionamiento del sitio", + "actionListSiteProvisioningKeys": "Listado de claves de aprovisionamiento del sitio", + "actionUpdateSiteProvisioningKey": "Actualizar clave de aprovisionamiento del sitio", + "actionDeleteSiteProvisioningKey": "Eliminar clave de aprovisionamiento del sitio", "noneSelected": "Ninguno seleccionado", "orgNotFound2": "No se encontraron organizaciones.", "search": "Buscar…", @@ -1516,6 +1529,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", @@ -3801,5 +3815,6 @@ "rdpUploadFailed": "Error de subida", "rdpUnicodeKeyboardMode": "Modo teclado Unicode", "sessionToolbarShow": "Mostrar barra de herramientas", - "sessionToolbarHide": "Ocultar barra de herramientas" + "sessionToolbarHide": "Ocultar barra de herramientas", + "actionUpdateSiteApprovals": "Actualizar aprobaciones del sitio" } diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 4084ceae8..5a931fa70 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", @@ -1502,6 +1511,10 @@ "actionListInvitations": "Lister les invitations", "actionExportLogs": "Exporter les journaux", "actionViewLogs": "Voir les logs", + "actionCreateSiteProvisioningKey": "Créer une clé de provisionnement de site", + "actionListSiteProvisioningKeys": "Lister les clés de provisionnement de site", + "actionUpdateSiteProvisioningKey": "Mettre à jour la clé de provisionnement de site", + "actionDeleteSiteProvisioningKey": "Supprimer la clé de provisionnement de site", "noneSelected": "Aucune sélection", "orgNotFound2": "Aucune organisation trouvée.", "search": "Rechercher…", @@ -1516,6 +1529,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", @@ -3801,5 +3815,6 @@ "rdpUploadFailed": "Échec du téléchargement", "rdpUnicodeKeyboardMode": "Mode clavier Unicode", "sessionToolbarShow": "Afficher la barre d'outils", - "sessionToolbarHide": "Masquer la barre d'outils" + "sessionToolbarHide": "Masquer la barre d'outils", + "actionUpdateSiteApprovals": "Mettre à jour les approbations de site" } diff --git a/messages/it-IT.json b/messages/it-IT.json index ebb398334..794eda30f 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", @@ -1502,6 +1511,10 @@ "actionListInvitations": "Elenco Inviti", "actionExportLogs": "Esporta Log", "actionViewLogs": "Visualizza Log", + "actionCreateSiteProvisioningKey": "Crea Chiave di Provisioning del Sito", + "actionListSiteProvisioningKeys": "Elenca Chiavi di Provisioning del Sito", + "actionUpdateSiteProvisioningKey": "Aggiorna Chiave di Provisioning del Sito", + "actionDeleteSiteProvisioningKey": "Elimina Chiave di Provisioning del Sito", "noneSelected": "Nessuna selezione", "orgNotFound2": "Nessuna organizzazione trovata.", "search": "Cerca…", @@ -1516,6 +1529,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", @@ -3801,5 +3815,6 @@ "rdpUploadFailed": "Caricamento fallito", "rdpUnicodeKeyboardMode": "Modalità tastiera Unicode", "sessionToolbarShow": "Mostra barra degli strumenti", - "sessionToolbarHide": "Nascondi barra degli strumenti" + "sessionToolbarHide": "Nascondi barra degli strumenti", + "actionUpdateSiteApprovals": "Aggiorna Approvazioni del Sito" } diff --git a/messages/ko-KR.json b/messages/ko-KR.json index d8c929ead..8505e50e0 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": "리소스 생성", @@ -1502,6 +1511,10 @@ "actionListInvitations": "초대 목록", "actionExportLogs": "로그 내보내기", "actionViewLogs": "로그 보기", + "actionCreateSiteProvisioningKey": "사이트 프로비저닝 키 생성", + "actionListSiteProvisioningKeys": "사이트 프로비저닝 키 목록", + "actionUpdateSiteProvisioningKey": "사이트 프로비저닝 키 업데이트", + "actionDeleteSiteProvisioningKey": "사이트 프로비저닝 키 삭제", "noneSelected": "선택된 항목 없음", "orgNotFound2": "조직이 없습니다.", "search": "검색…", @@ -1516,6 +1529,7 @@ "otpAuthDescription": "인증 앱에서 코드를 입력하거나 단일 사용 백업 코드 중 하나를 입력하세요.", "otpAuthSubmit": "코드 제출", "idpContinue": "또는 계속 진행하십시오.", + "idpLastUsed": "마지막 사용", "otpAuthBack": "비밀번호로 돌아가기", "navbar": "탐색 메뉴", "navbarDescription": "애플리케이션의 주요 탐색 메뉴", @@ -3801,5 +3815,6 @@ "rdpUploadFailed": "업로드 실패", "rdpUnicodeKeyboardMode": "유니코드 키보드 모드", "sessionToolbarShow": "툴바 보기", - "sessionToolbarHide": "툴바 숨기기" + "sessionToolbarHide": "툴바 숨기기", + "actionUpdateSiteApprovals": "사이트 승인 업데이트" } diff --git a/messages/nb-NO.json b/messages/nb-NO.json index 1e0d6a1a3..8dd687d09 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", @@ -1502,6 +1511,10 @@ "actionListInvitations": "Liste invitasjoner", "actionExportLogs": "Eksportlogger", "actionViewLogs": "Vis logger", + "actionCreateSiteProvisioningKey": "Opprett Klargjøringsnøkkel for Sted", + "actionListSiteProvisioningKeys": "List Klargjøringsnøkler for Sted", + "actionUpdateSiteProvisioningKey": "Oppdater Klargjøringsnøkkel for Sted", + "actionDeleteSiteProvisioningKey": "Slett Klargjøringsnøkkel for Sted", "noneSelected": "Ingen valgt", "orgNotFound2": "Ingen organisasjoner funnet.", "search": "Søk…", @@ -1516,6 +1529,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", @@ -3801,5 +3815,6 @@ "rdpUploadFailed": "Opplastningen mislyktes", "rdpUnicodeKeyboardMode": "Unicode tastaturmodus", "sessionToolbarShow": "Vis verktøylinje", - "sessionToolbarHide": "Skjul verktøylinje" + "sessionToolbarHide": "Skjul verktøylinje", + "actionUpdateSiteApprovals": "Oppdater Stedsgodkjenninger" } diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 506a4a5b3..f914a3c80 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", @@ -1502,6 +1511,10 @@ "actionListInvitations": "Toon uitnodigingen", "actionExportLogs": "Logboeken exporteren", "actionViewLogs": "Logboeken bekijken", + "actionCreateSiteProvisioningKey": "Een sitevoorzie-ningssleutel aanmaken", + "actionListSiteProvisioningKeys": "Sitevoorzie-ningssleutels weergeven", + "actionUpdateSiteProvisioningKey": "Sitevoorzie-ningssleutel bijwerken", + "actionDeleteSiteProvisioningKey": "Sitevoorzie-ningssleutel verwijderen", "noneSelected": "Niet geselecteerd", "orgNotFound2": "Geen organisaties gevonden.", "search": "Zoeken…", @@ -1516,6 +1529,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", @@ -3801,5 +3815,6 @@ "rdpUploadFailed": "Upload mislukt", "rdpUnicodeKeyboardMode": "Unicode toetsenbordmodus", "sessionToolbarShow": "Toon werkbalk", - "sessionToolbarHide": "Verberg werkbalk" + "sessionToolbarHide": "Verberg werkbalk", + "actionUpdateSiteApprovals": "Sitegoedkeuringen bijwerken" } diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 718579932..1e998dcdd 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", @@ -1502,6 +1511,10 @@ "actionListInvitations": "Lista zaproszeń", "actionExportLogs": "Eksportuj dzienniki", "actionViewLogs": "Zobacz dzienniki", + "actionCreateSiteProvisioningKey": "Utwórz klucz konfiguracji strony", + "actionListSiteProvisioningKeys": "Lista kluczy konfiguracji strony", + "actionUpdateSiteProvisioningKey": "Zaktualizuj klucz konfiguracji strony", + "actionDeleteSiteProvisioningKey": "Usuń klucz konfiguracji strony", "noneSelected": "Nie wybrano", "orgNotFound2": "Nie znaleziono organizacji.", "search": "Szukaj…", @@ -1516,6 +1529,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", @@ -3801,5 +3815,6 @@ "rdpUploadFailed": "Niepowodzenie przesyłania", "rdpUnicodeKeyboardMode": "Tryb klawiatury Unicode", "sessionToolbarShow": "Pokaż pasek narzędzi", - "sessionToolbarHide": "Ukryj pasek narzędzi" + "sessionToolbarHide": "Ukryj pasek narzędzi", + "actionUpdateSiteApprovals": "Zaktualizuj zgody na stronę" } diff --git a/messages/pt-PT.json b/messages/pt-PT.json index c0d65e0fb..9d3dd1320 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", @@ -1502,6 +1511,10 @@ "actionListInvitations": "Listar Convites", "actionExportLogs": "Exportar logs", "actionViewLogs": "Visualizar registros", + "actionCreateSiteProvisioningKey": "Criar Chave de Provisionamento do Site", + "actionListSiteProvisioningKeys": "Listar Chaves de Provisionamento do Site", + "actionUpdateSiteProvisioningKey": "Atualizar Chave de Provisionamento do Site", + "actionDeleteSiteProvisioningKey": "Excluir Chave de Provisionamento do Site", "noneSelected": "Nenhum selecionado", "orgNotFound2": "Nenhuma organização encontrada.", "search": "Pesquisar…", @@ -1516,6 +1529,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", @@ -3801,5 +3815,6 @@ "rdpUploadFailed": "Falha no upload", "rdpUnicodeKeyboardMode": "Modo de teclado Unicode", "sessionToolbarShow": "Mostrar barra de ferramentas", - "sessionToolbarHide": "Ocultar barra de ferramentas" + "sessionToolbarHide": "Ocultar barra de ferramentas", + "actionUpdateSiteApprovals": "Atualizar Aprovações do Site" } diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 53bf7b015..287e61497 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": "Создать ресурс", @@ -1502,6 +1511,10 @@ "actionListInvitations": "Список приглашений", "actionExportLogs": "Экспорт журналов", "actionViewLogs": "Просмотр журналов", + "actionCreateSiteProvisioningKey": "Создать ключ конфигурации сайта", + "actionListSiteProvisioningKeys": "Список ключей конфигурации сайтов", + "actionUpdateSiteProvisioningKey": "Обновить ключ конфигурации сайта", + "actionDeleteSiteProvisioningKey": "Удалить ключ конфигурации сайта", "noneSelected": "Ничего не выбрано", "orgNotFound2": "Организации не найдены.", "search": "Поиск…", @@ -1516,6 +1529,7 @@ "otpAuthDescription": "Введите код из вашего приложения-аутентификатора или один из ваших одноразовых резервных кодов.", "otpAuthSubmit": "Отправить код", "idpContinue": "Или продолжить с", + "idpLastUsed": "Последнее использование", "otpAuthBack": "Назад к паролю", "navbar": "Навигационное меню", "navbarDescription": "Главное навигационное меню приложения", @@ -3801,5 +3815,6 @@ "rdpUploadFailed": "Ошибка загрузки", "rdpUnicodeKeyboardMode": "Режим клавиатуры Unicode", "sessionToolbarShow": "Показать панель инструментов", - "sessionToolbarHide": "Скрыть панель инструментов" + "sessionToolbarHide": "Скрыть панель инструментов", + "actionUpdateSiteApprovals": "Обновить утверждения сайта" } diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 6ab5d026d..55f09d447 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", @@ -1502,6 +1511,10 @@ "actionListInvitations": "Davetiyeleri Listele", "actionExportLogs": "Kayıtları Dışa Aktar", "actionViewLogs": "Kayıtları Görüntüle", + "actionCreateSiteProvisioningKey": "Site Sağlama Anahtarı Oluştur", + "actionListSiteProvisioningKeys": "Site Sağlama Anahtarlarını Listele", + "actionUpdateSiteProvisioningKey": "Site Sağlama Anahtarını Güncelle", + "actionDeleteSiteProvisioningKey": "Site Sağlama Anahtarını Sil", "noneSelected": "Hiçbiri seçili değil", "orgNotFound2": "Hiçbir organizasyon bulunamadı.", "search": "Ara…", @@ -1516,6 +1529,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ü", @@ -3801,5 +3815,6 @@ "rdpUploadFailed": "Yükleme başarısız", "rdpUnicodeKeyboardMode": "Unicode klavye modu", "sessionToolbarShow": "Araç çubuğunu göster", - "sessionToolbarHide": "Araç çubuğunu gizle" + "sessionToolbarHide": "Araç çubuğunu gizle", + "actionUpdateSiteApprovals": "Site Onaylarını Güncelle" } diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 3d34fef2e..2f9b8805c 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": "创建资源", @@ -1502,6 +1511,10 @@ "actionListInvitations": "邀请列表", "actionExportLogs": "导出日志", "actionViewLogs": "查看日志", + "actionCreateSiteProvisioningKey": "创建站点预配密钥", + "actionListSiteProvisioningKeys": "列出站点预配密钥", + "actionUpdateSiteProvisioningKey": "更新站点预配密钥", + "actionDeleteSiteProvisioningKey": "删除站点预配密钥", "noneSelected": "未选择", "orgNotFound2": "未找到组织。", "search": "搜索…", @@ -1516,6 +1529,7 @@ "otpAuthDescription": "从您的身份验证程序中输入代码或您的单次备份代码。", "otpAuthSubmit": "提交代码", "idpContinue": "或者继续", + "idpLastUsed": "上次使用", "otpAuthBack": "回到密码", "navbar": "导航菜单", "navbarDescription": "应用程序的主导航菜单", @@ -3801,5 +3815,6 @@ "rdpUploadFailed": "上传失败", "rdpUnicodeKeyboardMode": "Unicode 键盘模式", "sessionToolbarShow": "显示工具栏", - "sessionToolbarHide": "隐藏工具栏" + "sessionToolbarHide": "隐藏工具栏", + "actionUpdateSiteApprovals": "更新站点审批" } diff --git a/public/screenshots/resource-launcher.png b/public/screenshots/resource-launcher.png new file mode 100644 index 000000000..93b803ee6 Binary files /dev/null and b/public/screenshots/resource-launcher.png differ 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..09e12e6d0 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -107,6 +107,7 @@ export const sites = pgTable( lastPing: integer("lastPing"), address: varchar("address"), endpoint: varchar("endpoint"), + localEndpoints: varchar("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to publicKey: varchar("publicKey"), lastHolePunch: bigint("lastHolePunch", { mode: "number" }), listenPort: integer("listenPort"), @@ -200,7 +201,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 +455,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)] ); @@ -899,12 +906,16 @@ export const resourceAccessToken = pgTable("resourceAccessToken", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), + userId: varchar("userId").references(() => users.userId, { + onDelete: "cascade" + }), path: varchar("path"), tokenHash: varchar("tokenHash").notNull(), sessionLength: bigint("sessionLength", { mode: "number" }).notNull(), expiresAt: bigint("expiresAt", { mode: "number" }), title: varchar("title"), description: varchar("description"), + persistSession: boolean("persistSession").notNull().default(false), createdAt: bigint("createdAt", { mode: "number" }).notNull() }); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 4ec4916b5..61f3c2d6e 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -118,6 +118,7 @@ export const sites = sqliteTable("sites", { // exit node stuff that is how to connect to the site when it has a wg server address: text("address"), // this is the address of the wireguard interface in newt endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config + localEndpoints: text("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to publicKey: text("publicKey"), // TODO: Fix typo in publicKey lastHolePunch: integer("lastHolePunch"), listenPort: integer("listenPort"), @@ -209,7 +210,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 +449,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", { @@ -1123,12 +1126,18 @@ export const resourceAccessToken = sqliteTable("resourceAccessToken", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), + userId: text("userId").references(() => users.userId, { + onDelete: "cascade" + }), path: text("path"), tokenHash: text("tokenHash").notNull(), sessionLength: integer("sessionLength").notNull(), expiresAt: integer("expiresAt"), title: text("title"), description: text("description"), + persistSession: integer("persistSession", { mode: "boolean" }) + .notNull() + .default(false), createdAt: integer("createdAt").notNull() }); diff --git a/server/emails/templates/NotifyTrialExpiring.tsx b/server/emails/templates/NotifyTrialExpiring.tsx index 7c712e278..a4c09e152 100644 --- a/server/emails/templates/NotifyTrialExpiring.tsx +++ b/server/emails/templates/NotifyTrialExpiring.tsx @@ -30,14 +30,14 @@ export const NotifyTrialExpiring = ({ const isLastDay = daysRemaining === 1; const previewText = hasEnded - ? `Your trial for ${orgName} has ended.` + ? `Your cloud trial for ${orgName} has ended.` : isLastDay - ? `Your trial for ${orgName} ends tomorrow.` - : `Your trial for ${orgName} ends in ${daysRemaining} days.`; + ? `Your cloud trial for ${orgName} ends tomorrow.` + : `Your cloud trial for ${orgName} ends in ${daysRemaining} days.`; const heading = hasEnded - ? "Your Trial Ended" - : "Your Trial is Ending Soon"; + ? "Your Cloud Trial Ended" + : "Your Cloud Trial is Ending Soon"; return ( @@ -55,7 +55,7 @@ export const NotifyTrialExpiring = ({ {hasEnded ? ( <> - Your free trial for{" "} + Your cloud free trial for{" "} {orgName} ended on{" "} {trialEndsAt}. Your account has been moved to the free plan, which @@ -64,10 +64,11 @@ export const NotifyTrialExpiring = ({ Some features and resources may now be - restricted. To restore full - access and continue using all the features - you had during your trial, please upgrade to - a paid plan. + restricted. To restore full access and + continue using all the features you had + during your trial, please upgrade to a paid + plan. This does not effect any self hosted + licenses. @@ -93,7 +94,8 @@ export const NotifyTrialExpiring = ({ After your trial ends, your account will be moved to the free plan and some - functionality may be restricted. + functionality may be restricted. This does + not effect any self hosted licenses. diff --git a/server/integrationApiServer.ts b/server/integrationApiServer.ts index 104e02d5c..56a3d47b0 100644 --- a/server/integrationApiServer.ts +++ b/server/integrationApiServer.ts @@ -12,7 +12,7 @@ import { logIncomingMiddleware } from "./middlewares/logIncoming"; import helmet from "helmet"; import swaggerUi from "swagger-ui-express"; import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi"; -import { registry } from "./openApi"; +import { registry, openApiTags } from "./openApi"; import fs from "fs"; import path from "path"; import { APP_PATH } from "./lib/consts"; @@ -181,7 +181,8 @@ function getOpenApiDocumentation() { version: "v1", title: "Pangolin Integration API" }, - servers: [{ url: "/v1" }] + servers: [{ url: "/v1" }], + tags: openApiTags }); if (!process.env.DISABLE_GEN_OPENAPI) { 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..f3fa7bf5d 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 } @@ -233,6 +239,31 @@ export async function updatePrivateResources( ); } + if (resourceData.alias) { + const [aliasConflict] = await trx + .select({ + siteResourceId: siteResources.siteResourceId + }) + .from(siteResources) + .where( + and( + eq(siteResources.orgId, orgId), + eq(siteResources.alias, resourceData.alias), + ne( + siteResources.siteResourceId, + existingResource.siteResourceId + ) + ) + ) + .limit(1); + + if (aliasConflict) { + throw new Error( + `Alias ${resourceData.alias} already in use by another site resource in org ${orgId}` + ); + } + } + // Update existing resource const [updatedResource] = await trx .update(siteResources) @@ -243,8 +274,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 +293,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( @@ -474,6 +505,27 @@ export async function updatePrivateResources( ); } + if (resourceData.alias) { + const [aliasConflict] = await trx + .select({ + siteResourceId: siteResources.siteResourceId + }) + .from(siteResources) + .where( + and( + eq(siteResources.orgId, orgId), + eq(siteResources.alias, resourceData.alias) + ) + ) + .limit(1); + + if (aliasConflict) { + throw new Error( + `Alias ${resourceData.alias} already in use by another site resource in org ${orgId}` + ); + } + } + const [network] = await trx .insert(networks) .values({ @@ -496,8 +548,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 +568,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/consts.ts b/server/lib/consts.ts index 57a4836b8..9ee4ec4c3 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.20.0"; +export const APP_VERSION = "1.21.0"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); 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/pathMatch.ts b/server/lib/pathMatch.ts index a007f9ec3..8ef579607 100644 --- a/server/lib/pathMatch.ts +++ b/server/lib/pathMatch.ts @@ -15,10 +15,41 @@ function getSegmentRegex(patternPart: string): RegExp { return regex; } +// Decodes percent-encoding (so an encoded slash like `%2F` is treated as a +// real path separator, matching what most backends will do) and then +// resolves `.` / `..` segments, so a request like `/public%2F..%2Fadmin/` +// or `/public/../admin/` is matched as `/admin/`, not as a literal segment +// or a wildcard-swallowed sequence under `/public/*`. +function decodeAndResolvePath(p: string): string[] { + const rawParts = p.split("/").filter(Boolean); + + const resolved: string[] = []; + for (const rawPart of rawParts) { + let part: string; + try { + part = decodeURIComponent(rawPart); + } catch { + part = rawPart; + } + + // an encoded slash can turn one raw segment into several real ones + for (const segment of part.split("/").filter(Boolean)) { + if (segment === ".") { + continue; + } else if (segment === "..") { + resolved.pop(); + } else { + resolved.push(segment); + } + } + } + + return resolved; +} + export function isPathAllowed(pattern: string, path: string): boolean { - const normalize = (p: string) => p.split("/").filter(Boolean); - const patternParts = normalize(pattern); - const pathParts = normalize(path); + const patternParts = pattern.split("/").filter(Boolean); + const pathParts = decodeAndResolvePath(path); function matchSegments( patternIndex: number, 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/lib/statusHistory.ts b/server/lib/statusHistory.ts index e3b13270b..a0ede99a6 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -8,26 +8,42 @@ const STATUS_HISTORY_CACHE_TTL = 60; // seconds function statusHistoryCacheKey( entityType: string, entityId: number, - days: number + days: number, + tzOffsetMinutes: number ): string { - return `statusHistory:${entityType}:${entityId}:${days}`; + return `statusHistory:${entityType}:${entityId}:${days}:${tzOffsetMinutes}`; +} + +// Returns the epoch seconds of the most recent local-calendar-day midnight, +// where "local" is defined by tzOffsetMinutes (minutes to ADD to UTC to get +// local time, e.g. Australia/Sydney standard time is 600). Defaults to 0 +// (UTC) so callers that don't pass a timezone keep the original behavior. +function localMidnightSec(tzOffsetMinutes: number): number { + const localNow = new Date(Date.now() + tzOffsetMinutes * 60_000); + localNow.setUTCHours(0, 0, 0, 0); + return Math.floor(localNow.getTime() / 1000) - tzOffsetMinutes * 60; } export async function getCachedStatusHistory( entityType: string, entityId: number, - days: number + days: number, + tzOffsetMinutes: number = 0 ): Promise { - const cacheKey = statusHistoryCacheKey(entityType, entityId, days); + const cacheKey = statusHistoryCacheKey( + entityType, + entityId, + days, + tzOffsetMinutes + ); const cached = await cache.get(cacheKey); if (cached !== undefined) { return cached; } - // Anchor to UTC midnight so the query window aligns with stable calendar days - const utcToday = new Date(); - utcToday.setUTCHours(0, 0, 0, 0); - const todayMidnightSec = Math.floor(utcToday.getTime() / 1000); + // Anchor to local midnight (UTC when tzOffsetMinutes is 0) so the query + // window aligns with stable calendar days for the requesting client + const todayMidnightSec = localMidnightSec(tzOffsetMinutes); const startSec = todayMidnightSec - days * 86400; const events = await logsDb @@ -63,7 +79,8 @@ export async function getCachedStatusHistory( const { buckets, totalDowntime } = computeBuckets( events, days, - priorStatus + priorStatus, + tzOffsetMinutes ); const totalWindow = days * 86400; const overallUptime = @@ -99,11 +116,19 @@ export const statusHistoryQuerySchema = z days: z .string() .optional() - .transform((v) => (v ? parseInt(v, 10) : 90)) + .transform((v) => (v ? parseInt(v, 10) : 90)), + // Minutes to add to UTC to get the requesting client's local time + // (e.g. Australia/Sydney standard time is 600). Optional and + // defaults to 0 (UTC) so older clients keep the prior behavior. + tzOffsetMinutes: z + .string() + .optional() + .transform((v) => (v ? parseInt(v, 10) : 0)) }) .pipe( z.object({ - days: z.number().int().min(1).max(365) + days: z.number().int().min(1).max(365), + tzOffsetMinutes: z.number().int().min(-720).max(840) }) ); @@ -133,15 +158,15 @@ export function computeBuckets( id: number; }[], days: number, - priorStatus: string | null = null + priorStatus: string | null = null, + tzOffsetMinutes: number = 0 ): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } { const nowSec = Math.floor(Date.now() / 1000); - // Anchor bucket boundaries to UTC midnight so dates are stable calendar days - // and don't drift as the cache expires and is recomputed - const utcToday = new Date(); - utcToday.setUTCHours(0, 0, 0, 0); - const todayMidnightSec = Math.floor(utcToday.getTime() / 1000); + // Anchor bucket boundaries to local midnight (UTC when tzOffsetMinutes is + // 0) so dates are stable calendar days for the requesting client and + // don't drift as the cache expires and is recomputed + const todayMidnightSec = localMidnightSec(tzOffsetMinutes); const buckets: StatusHistoryDayBucket[] = []; let totalDowntime = 0; @@ -237,7 +262,13 @@ export function computeBuckets( ) : 100; - const dateStr = new Date(dayStartSec * 1000).toISOString().slice(0, 10); + // Shift by the client's offset before formatting so the label reflects + // their local calendar date rather than the UTC date of dayStartSec + const dateStr = new Date( + (dayStartSec + tzOffsetMinutes * 60) * 1000 + ) + .toISOString() + .slice(0, 10); const hasAnyData = currentStatus !== null || dayEvents.length > 0; diff --git a/server/lib/userOrg.ts b/server/lib/userOrg.ts index 4bff40c13..ea086d0c0 100644 --- a/server/lib/userOrg.ts +++ b/server/lib/userOrg.ts @@ -2,6 +2,7 @@ import { db, Org, orgs, + resourceAccessToken, resources, siteResources, sites, @@ -83,6 +84,15 @@ export async function removeUserFromOrg( .delete(userOrgs) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId))); + await trx + .delete(resourceAccessToken) + .where( + and( + eq(resourceAccessToken.userId, userId), + eq(resourceAccessToken.orgId, org.orgId) + ) + ); + await trx.delete(userResources).where( and( eq(userResources.userId, userId), diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts index d43854eab..be9d400cf 100644 --- a/server/middlewares/integration/index.ts +++ b/server/middlewares/integration/index.ts @@ -17,3 +17,4 @@ export * from "./verifyApiKeySiteResourceAccess"; export * from "./verifyApiKeyIdpAccess"; export * from "./verifyApiKeyDomainAccess"; export * from "./verifyApiKeyResourcePolicyAccess"; +export * from "./verifyApiKeySiteProvisioningKeyAccess"; diff --git a/server/middlewares/integration/verifyApiKeySiteProvisioningKeyAccess.ts b/server/middlewares/integration/verifyApiKeySiteProvisioningKeyAccess.ts new file mode 100644 index 000000000..d1982ad3c --- /dev/null +++ b/server/middlewares/integration/verifyApiKeySiteProvisioningKeyAccess.ts @@ -0,0 +1,111 @@ +import { Request, Response, NextFunction } from "express"; +import { + db, + siteProvisioningKeys, + siteProvisioningKeyOrg, + apiKeyOrg +} from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { getFirstString } from "@server/lib/requestParams"; + +export async function verifyApiKeySiteProvisioningKeyAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const siteProvisioningKeyId = + getFirstString(req.params.siteProvisioningKeyId) || + getFirstString(req.body.siteProvisioningKeyId) || + getFirstString(req.query.siteProvisioningKeyId); + const orgId = getFirstString(req.params.orgId); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (!siteProvisioningKeyId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID") + ); + } + + if (apiKey.isRoot) { + // Root keys can access any site provisioning key in any org + return next(); + } + + const [row] = await db + .select() + .from(siteProvisioningKeys) + .innerJoin( + siteProvisioningKeyOrg, + and( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyOrg.siteProvisioningKeyId + ), + eq(siteProvisioningKeyOrg.orgId, orgId) + ) + ) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ) + .limit(1); + + if (!row?.siteProvisioningKeys || !row.siteProvisioningKeyOrg) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site provisioning key with ID ${siteProvisioningKeyId} not found for organization ${orgId}` + ) + ); + } + + if (!req.apiKeyOrg) { + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, row.siteProvisioningKeyOrg.orgId) + ) + ) + .limit(1); + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying site provisioning key access" + ) + ); + } +} diff --git a/server/openApi.ts b/server/openApi.ts index 7132a0327..27c26b2da 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -4,22 +4,34 @@ export const registry = new OpenAPIRegistry(); export enum OpenAPITags { Site = "Site", - Org = "Organization", PublicResource = "Public Resource", + Target = "Resource Target", PrivateResource = "Private Resource", - Policy = "Policy", + Client = "Client", + Org = "Organization", + Domain = "Domain", + PublicResourcePolicy = "Public Resource Policy", Role = "Role", User = "User", - Invitation = "User Invitation", - Target = "Resource Target", Rule = "Rule", + Invitation = "User Invitation", AccessToken = "Access Token", GlobalIdp = "Identity Provider (Global)", OrgIdp = "Identity Provider (Organization Only)", - Client = "Client", ApiKey = "API Key", - Domain = "Domain", + SiteProvisioningKey = "Site Provisioning Key", Blueprint = "Blueprint", Ssh = "SSH", - Logs = "Logs" + Logs = "Logs", + EventStreamingDestination = "Event Streaming Destination", + AlertRule = "Alert Rule", + HealthCheck = "Health Check", + PublicResourcePolicyLegacy = "Public Resource Policy (Legacy)", + PublicResourceLegacy = "Public Resource (Legacy)", + PrivateResourceLegacy = "Private Resource (Legacy)" } + +// Order here controls the order tags are displayed in Swagger UI +export const openApiTags = Object.values(OpenAPITags).map((name) => ({ + name +})); diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index 7a46c84ff..51cc8ea16 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -191,7 +191,7 @@ registry.registerPath({ method: "put", path: "/org/{orgId}/alert-rule", description: "Create an alert rule for a specific organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.AlertRule], request: { params: paramsSchema, body: { diff --git a/server/private/routers/alertRule/deleteAlertRule.ts b/server/private/routers/alertRule/deleteAlertRule.ts index 0439a6622..c3a21e8ac 100644 --- a/server/private/routers/alertRule/deleteAlertRule.ts +++ b/server/private/routers/alertRule/deleteAlertRule.ts @@ -34,7 +34,7 @@ registry.registerPath({ method: "delete", path: "/org/{orgId}/alert-rule/{alertRuleId}", description: "Delete an alert rule for a specific organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.AlertRule], request: { params: paramsSchema }, diff --git a/server/private/routers/alertRule/getAlertRule.ts b/server/private/routers/alertRule/getAlertRule.ts index fcbd44f11..92a53b93e 100644 --- a/server/private/routers/alertRule/getAlertRule.ts +++ b/server/private/routers/alertRule/getAlertRule.ts @@ -48,7 +48,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/alert-rule/{alertRuleId}", description: "Get a specific alert rule for an organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.AlertRule], request: { params: paramsSchema }, diff --git a/server/private/routers/alertRule/listAlertRules.ts b/server/private/routers/alertRule/listAlertRules.ts index 0fc720d66..c6835a45d 100644 --- a/server/private/routers/alertRule/listAlertRules.ts +++ b/server/private/routers/alertRule/listAlertRules.ts @@ -90,7 +90,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/alert-rules", description: "List all alert rules for a specific organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.AlertRule], request: { query: querySchema, params: paramsSchema diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts index 690a97d29..87fa4675f 100644 --- a/server/private/routers/alertRule/updateAlertRule.ts +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -158,7 +158,7 @@ registry.registerPath({ method: "post", path: "/org/{orgId}/alert-rule/{alertRuleId}", description: "Update an alert rule for a specific organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.AlertRule], request: { params: paramsSchema, body: { diff --git a/server/private/routers/eventStreamingDestination/createEventStreamingDestination.ts b/server/private/routers/eventStreamingDestination/createEventStreamingDestination.ts index 5050c55e8..7b000c5d8 100644 --- a/server/private/routers/eventStreamingDestination/createEventStreamingDestination.ts +++ b/server/private/routers/eventStreamingDestination/createEventStreamingDestination.ts @@ -52,7 +52,7 @@ registry.registerPath({ method: "put", path: "/org/{orgId}/event-streaming-destination", description: "Create an event streaming destination for a specific organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.EventStreamingDestination], request: { params: paramsSchema, body: { diff --git a/server/private/routers/eventStreamingDestination/deleteEventStreamingDestination.ts b/server/private/routers/eventStreamingDestination/deleteEventStreamingDestination.ts index 5aa4bf314..c047b6c95 100644 --- a/server/private/routers/eventStreamingDestination/deleteEventStreamingDestination.ts +++ b/server/private/routers/eventStreamingDestination/deleteEventStreamingDestination.ts @@ -35,7 +35,7 @@ registry.registerPath({ path: "/org/{orgId}/event-streaming-destination/{destinationId}", description: "Delete an event streaming destination for a specific organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.EventStreamingDestination], request: { params: paramsSchema }, diff --git a/server/private/routers/eventStreamingDestination/listEventStreamingDestinations.ts b/server/private/routers/eventStreamingDestination/listEventStreamingDestinations.ts index e20f95b8e..dc741d482 100644 --- a/server/private/routers/eventStreamingDestination/listEventStreamingDestinations.ts +++ b/server/private/routers/eventStreamingDestination/listEventStreamingDestinations.ts @@ -109,7 +109,7 @@ registry.registerPath({ path: "/org/{orgId}/event-streaming-destination", description: "List all event streaming destinations for a specific organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.EventStreamingDestination], request: { query: querySchema, params: paramsSchema diff --git a/server/private/routers/eventStreamingDestination/updateEventStreamingDestination.ts b/server/private/routers/eventStreamingDestination/updateEventStreamingDestination.ts index adbac2cca..84202bf8e 100644 --- a/server/private/routers/eventStreamingDestination/updateEventStreamingDestination.ts +++ b/server/private/routers/eventStreamingDestination/updateEventStreamingDestination.ts @@ -55,7 +55,7 @@ registry.registerPath({ method: "post", path: "/org/{orgId}/event-streaming-destination/{destinationId}", description: "Update an event streaming destination for a specific organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.EventStreamingDestination], request: { params: paramsSchema, body: { diff --git a/server/private/routers/healthChecks/createHealthCheck.ts b/server/private/routers/healthChecks/createHealthCheck.ts index 6f49f0f18..ef7331c5f 100644 --- a/server/private/routers/healthChecks/createHealthCheck.ts +++ b/server/private/routers/healthChecks/createHealthCheck.ts @@ -75,7 +75,7 @@ registry.registerPath({ method: "put", path: "/org/{orgId}/health-check", description: "Create a health check for a specific organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.HealthCheck], request: { params: paramsSchema, body: { diff --git a/server/private/routers/healthChecks/deleteHealthCheck.ts b/server/private/routers/healthChecks/deleteHealthCheck.ts index 90bb41e50..d51c42d81 100644 --- a/server/private/routers/healthChecks/deleteHealthCheck.ts +++ b/server/private/routers/healthChecks/deleteHealthCheck.ts @@ -37,7 +37,7 @@ registry.registerPath({ method: "delete", path: "/org/{orgId}/health-check/{healthCheckId}", description: "Delete a health check for a specific organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.HealthCheck], request: { params: paramsSchema }, diff --git a/server/private/routers/healthChecks/getStatusHistory.ts b/server/private/routers/healthChecks/getStatusHistory.ts index 51a59e2e1..484870886 100644 --- a/server/private/routers/healthChecks/getStatusHistory.ts +++ b/server/private/routers/healthChecks/getStatusHistory.ts @@ -55,9 +55,14 @@ export async function getHealthCheckStatusHistory( const entityType = "health_check"; const entityId = parsedParams.data.healthCheckId; - const { days } = parsedQuery.data; + const { days, tzOffsetMinutes } = parsedQuery.data; - const data = await getCachedStatusHistory(entityType, entityId, days); + const data = await getCachedStatusHistory( + entityType, + entityId, + days, + tzOffsetMinutes + ); return response(res, { data, diff --git a/server/private/routers/healthChecks/listHealthChecks.ts b/server/private/routers/healthChecks/listHealthChecks.ts index 0d18b881e..2e34b7f6e 100644 --- a/server/private/routers/healthChecks/listHealthChecks.ts +++ b/server/private/routers/healthChecks/listHealthChecks.ts @@ -63,7 +63,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/health-checks", description: "List health checks for an organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.HealthCheck], request: { params: paramsSchema, query: querySchema diff --git a/server/private/routers/healthChecks/updateHealthCheck.ts b/server/private/routers/healthChecks/updateHealthCheck.ts index 4fb7a624b..a4022d298 100644 --- a/server/private/routers/healthChecks/updateHealthCheck.ts +++ b/server/private/routers/healthChecks/updateHealthCheck.ts @@ -109,7 +109,7 @@ registry.registerPath({ method: "post", path: "/org/{orgId}/health-check/{healthCheckId}", description: "Update a health check for a specific organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.HealthCheck], request: { params: paramsSchema, body: { diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index 124db587a..06799bcd6 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -58,7 +58,8 @@ import { resourceRules, resourcePolicyRules, userOrgRoles, - roles + roles, + resourceAccessToken } from "@server/db"; import { eq, and, inArray, isNotNull, isNull, ne, or, sql } from "drizzle-orm"; import { alias } from "@server/db"; @@ -81,11 +82,16 @@ import config from "@server/lib/config"; import { exchangeSession } from "@server/routers/badger"; import { ResourceSessionValidationResult, + createResourceSession, + serializeResourceSessionCookie, validateResourceSessionToken } from "@server/auth/sessions/resource"; import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes"; import { maxmindLookup } from "@server/db/maxmind"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; +import { generateSessionToken } from "@server/auth/sessions/app"; +import { logAccessAudit } from "#private/lib/logAccessAudit"; +import { getUserOrgRoles } from "@server/lib/userOrgRoles"; import semver from "semver"; import { maxmindAsnLookup } from "@server/db/maxmindAsn"; import { checkOrgAccessPolicy } from "@server/lib/checkOrgAccessPolicy"; @@ -178,6 +184,73 @@ const validateResourceAccessTokenBodySchema = z.strictObject({ accessToken: z.string() }); +const createAccessTokenSessionParamsSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive() +}); + +const createAccessTokenSessionBodySchema = z.strictObject({ + accessTokenId: z.string().min(1) +}); + +const getAccessTokenParamsSchema = z.strictObject({ + accessTokenId: z.string().min(1) +}); + +const logAccessAuditBodySchema = z.strictObject({ + action: z.boolean(), + type: z.string(), + orgId: z.string(), + resourceId: z.number().optional(), + siteResourceId: z.number().optional(), + user: z + .object({ + username: z.string(), + userId: z.string() + }) + .optional(), + apiKey: z + .object({ + name: z.string().nullable(), + apiKeyId: z.string() + }) + .optional(), + metadata: z.any().optional(), + userAgent: z.string().optional(), + requestIp: z.string().optional() +}); + +type AccessTokenUserData = { + userId: string; + username: string; + email: string | null; + name: string | null; + role: string | null; +}; + +async function resolveAccessTokenUserData( + userId: string, + orgId: string +): Promise { + const [user] = await db + .select() + .from(users) + .where(eq(users.userId, userId)) + .limit(1); + + if (!user) { + return undefined; + } + + const userOrgRoles = await getUserOrgRoles(user.userId, orgId); + return { + userId: user.userId, + username: user.username, + email: user.email, + name: user.name, + role: userOrgRoles.map((r) => r.roleName).join(", ") || null + }; +} + // Certificates by domains query validation const getCertificatesByDomainsQuerySchema = z.strictObject({ // Accept domains as string or array (domains or domains[]) @@ -1829,8 +1902,23 @@ hybridRouter.post( resourceId }); + let userData: AccessTokenUserData | undefined; + if ( + result.valid && + result.tokenItem?.userId && + result.tokenItem.orgId + ) { + userData = await resolveAccessTokenUserData( + result.tokenItem.userId, + result.tokenItem.orgId + ); + } + return response(res, { - data: result, + data: { + ...result, + userData + }, success: true, error: false, message: result.valid @@ -1850,6 +1938,233 @@ hybridRouter.post( } ); +// Create a resource session from a valid access token (for remote nodes) +hybridRouter.post( + "/resource/:resourceId/session/create-access-token", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = createAccessTokenSessionParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = createAccessTokenSessionBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + const { accessTokenId } = parsedBody.data; + + const [tokenItem] = await db + .select() + .from(resourceAccessToken) + .where(eq(resourceAccessToken.accessTokenId, accessTokenId)) + .limit(1); + + if (!tokenItem || tokenItem.resourceId !== resourceId) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Access token not found" + ) + ); + } + + if (!tokenItem.persistSession) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Access token does not allow session persistence" + ) + ); + } + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource || !resource.fullDomain) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + const token = generateSessionToken(); + const sess = await createResourceSession({ + resourceId: resource.resourceId, + token, + accessTokenId: tokenItem.accessTokenId, + sessionLength: tokenItem.sessionLength, + expiresAt: tokenItem.expiresAt, + doNotExtend: tokenItem.expiresAt ? true : false + }); + + const cookieName = config.getRawConfig().server.session_cookie_name; + const cookie = serializeResourceSessionCookie( + cookieName, + resource.fullDomain, + token, + !resource.ssl, + new Date(sess.expiresAt) + ); + + return response(res, { + data: { cookie }, + success: true, + error: false, + message: "Access token session created successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create access token session" + ) + ); + } + } +); + +// Resolve access token metadata for remote nodes (cookie session path) +hybridRouter.get( + "/resource/access-token/:accessTokenId", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getAccessTokenParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { accessTokenId } = parsedParams.data; + + const [tokenItem] = await db + .select() + .from(resourceAccessToken) + .where(eq(resourceAccessToken.accessTokenId, accessTokenId)) + .limit(1); + + if (!tokenItem) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Access token not found" + ) + ); + } + + let userData: AccessTokenUserData | undefined; + if (tokenItem.userId) { + userData = await resolveAccessTokenUserData( + tokenItem.userId, + tokenItem.orgId + ); + } + + return response(res, { + data: { tokenItem, userData }, + success: true, + error: false, + message: "Access token retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get access token" + ) + ); + } + } +); + +// Access audit log from remote nodes +hybridRouter.post( + "/logs/access", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedBody = logAccessAuditBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const remoteExitNode = req.remoteExitNode; + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + if ( + await checkExitNodeOrg( + remoteExitNode.exitNodeId, + parsedBody.data.orgId + ) + ) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Exit node not allowed for this organization" + ) + ); + } + + await logAccessAudit(parsedBody.data); + + return response(res, { + data: null, + success: true, + error: false, + message: "Access audit log saved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to save access audit log" + ) + ); + } + } +); + const geoIpLookupParamsSchema = z.object({ ip: z.union([z.ipv4(), z.ipv6()]) }); diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 820a843f0..2e53b5b9d 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -16,6 +16,11 @@ import * as org from "#private/routers/org"; import * as logs from "#private/routers/auditLogs"; import * as alertEvents from "#private/routers/alertEvents"; import * as certificates from "#private/routers/certificates"; +import * as siteProvisioning from "#private/routers/siteProvisioning"; +import * as policy from "#private/routers/policy"; +import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; +import * as alertRule from "#private/routers/alertRule"; +import * as healthChecks from "#private/routers/healthChecks"; import { verifyApiKeyHasAction, @@ -24,6 +29,8 @@ import { verifyApiKeyIdpAccess, verifyApiKeyRoleAccess, verifyApiKeyUserAccess, + verifyApiKeySiteProvisioningKeyAccess, + verifyApiKeyResourcePolicyAccess, verifyLimits } from "@server/middlewares"; import * as user from "#private/routers/user"; @@ -215,3 +222,192 @@ authenticated.delete( logActionAudit(ActionsEnum.removeUserRole), user.removeUserRole ); + +authenticated.put( + "/org/:orgId/site-provisioning-key", + verifyValidLicense, + verifyValidSubscription(tierMatrix.siteProvisioningKeys), + verifyApiKeyOrgAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.createSiteProvisioningKey), + logActionAudit(ActionsEnum.createSiteProvisioningKey), + siteProvisioning.createSiteProvisioningKey +); + +authenticated.get( + "/org/:orgId/site-provisioning-keys", + verifyValidLicense, + verifyValidSubscription(tierMatrix.siteProvisioningKeys), + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listSiteProvisioningKeys), + siteProvisioning.listSiteProvisioningKeys +); + +authenticated.delete( + "/org/:orgId/site-provisioning-key/:siteProvisioningKeyId", + verifyValidLicense, + verifyValidSubscription(tierMatrix.siteProvisioningKeys), + verifyApiKeyOrgAccess, + verifyApiKeySiteProvisioningKeyAccess, + verifyApiKeyHasAction(ActionsEnum.deleteSiteProvisioningKey), + logActionAudit(ActionsEnum.deleteSiteProvisioningKey), + siteProvisioning.deleteSiteProvisioningKey +); + +authenticated.patch( + "/org/:orgId/site-provisioning-key/:siteProvisioningKeyId", + verifyValidLicense, + verifyValidSubscription(tierMatrix.siteProvisioningKeys), + verifyApiKeyOrgAccess, + verifyApiKeySiteProvisioningKeyAccess, + verifyApiKeyHasAction(ActionsEnum.updateSiteProvisioningKey), + logActionAudit(ActionsEnum.updateSiteProvisioningKey), + siteProvisioning.updateSiteProvisioningKey +); + +authenticated.get( + ["/org/:orgId/resource-policies", "/org/:orgId/public-resource-policies"], + verifyValidLicense, + verifyValidSubscription(tierMatrix.resourcePolicies), + verifyApiKeyOrgAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.listResourcePolicies), + logActionAudit(ActionsEnum.listResourcePolicies), + policy.listResourcePolicies +); + +authenticated.post( + ["/org/:orgId/resource-policy", "/org/:orgId/public-resource-policy"], + verifyValidLicense, + verifyValidSubscription(tierMatrix.resourcePolicies), + verifyApiKeyOrgAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.createResourcePolicy), + logActionAudit(ActionsEnum.createResourcePolicy), + policy.createResourcePolicy +); + +authenticated.delete( + [ + "/resource-policy/:resourcePolicyId", + "/public-resource-policy/:resourcePolicyId" + ], + verifyApiKeyResourcePolicyAccess, + verifyValidLicense, + verifyValidSubscription(tierMatrix.resourcePolicies), + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.deleteResourcePolicy), + logActionAudit(ActionsEnum.deleteResourcePolicy), + policy.deleteResourcePolicy +); + +authenticated.put( + "/org/:orgId/event-streaming-destination", + verifyApiKeyOrgAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.createEventStreamingDestination), + logActionAudit(ActionsEnum.createEventStreamingDestination), + eventStreamingDestination.createEventStreamingDestination +); + +authenticated.post( + "/org/:orgId/event-streaming-destination/:destinationId", + verifyApiKeyOrgAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.updateEventStreamingDestination), + logActionAudit(ActionsEnum.updateEventStreamingDestination), + eventStreamingDestination.updateEventStreamingDestination +); + +authenticated.delete( + "/org/:orgId/event-streaming-destination/:destinationId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.deleteEventStreamingDestination), + logActionAudit(ActionsEnum.deleteEventStreamingDestination), + eventStreamingDestination.deleteEventStreamingDestination +); + +authenticated.get( + "/org/:orgId/event-streaming-destinations", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listEventStreamingDestinations), + eventStreamingDestination.listEventStreamingDestinations +); + +authenticated.put( + "/org/:orgId/alert-rule", + verifyApiKeyOrgAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.createAlertRule), + logActionAudit(ActionsEnum.createAlertRule), + alertRule.createAlertRule +); + +authenticated.post( + "/org/:orgId/alert-rule/:alertRuleId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.updateAlertRule), + logActionAudit(ActionsEnum.updateAlertRule), + alertRule.updateAlertRule +); + +authenticated.delete( + "/org/:orgId/alert-rule/:alertRuleId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.deleteAlertRule), + logActionAudit(ActionsEnum.deleteAlertRule), + alertRule.deleteAlertRule +); + +authenticated.get( + "/org/:orgId/alert-rules", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listAlertRules), + alertRule.listAlertRules +); + +authenticated.get( + "/org/:orgId/alert-rule/:alertRuleId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.getAlertRule), + alertRule.getAlertRule +); + +authenticated.get( + "/org/:orgId/health-checks", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listHealthChecks), + healthChecks.listHealthChecks +); + +authenticated.put( + "/org/:orgId/health-check", + verifyApiKeyOrgAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.createHealthCheck), + logActionAudit(ActionsEnum.createHealthCheck), + healthChecks.createHealthCheck +); + +authenticated.post( + "/org/:orgId/health-check/:healthCheckId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.updateHealthCheck), + logActionAudit(ActionsEnum.updateHealthCheck), + healthChecks.updateHealthCheck +); + +authenticated.delete( + "/org/:orgId/health-check/:healthCheckId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.deleteHealthCheck), + logActionAudit(ActionsEnum.deleteHealthCheck), + healthChecks.deleteHealthCheck +); + +authenticated.get( + "/org/:orgId/health-check/:healthCheckId/status-history", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.getTarget), + healthChecks.getHealthCheckStatusHistory +); diff --git a/server/private/routers/policy/createResourcePolicy.ts b/server/private/routers/policy/createResourcePolicy.ts index 9f02b912c..78eace74c 100644 --- a/server/private/routers/policy/createResourcePolicy.ts +++ b/server/private/routers/policy/createResourcePolicy.ts @@ -121,7 +121,7 @@ registry.registerPath({ method: "post", path: "/org/{orgId}/resource-policy", description: "Create a resource policy.", - tags: [OpenAPITags.Org, OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicy], request: { params: createResourcePolicyParamsSchema, body: { diff --git a/server/private/routers/policy/deleteResourcePolicy.ts b/server/private/routers/policy/deleteResourcePolicy.ts index a586cf3b4..6f14f1cde 100644 --- a/server/private/routers/policy/deleteResourcePolicy.ts +++ b/server/private/routers/policy/deleteResourcePolicy.ts @@ -31,7 +31,7 @@ registry.registerPath({ method: "delete", path: "/resource-policy/{resourcePolicyId}", description: "Delete a resource policy.", - tags: [OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicy], request: { params: deleteResourcePolicySchema }, diff --git a/server/private/routers/policy/listResourcePolicies.ts b/server/private/routers/policy/listResourcePolicies.ts index a5a52d9c8..af02b50c6 100644 --- a/server/private/routers/policy/listResourcePolicies.ts +++ b/server/private/routers/policy/listResourcePolicies.ts @@ -79,7 +79,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/resource-policies", description: "List resource policies for an organization.", - tags: [OpenAPITags.Org, OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicy], request: { params: z.object({ orgId: z.string() diff --git a/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts index f5b64c0f3..b810ebe4f 100644 --- a/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts +++ b/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts @@ -26,6 +26,8 @@ import { import logger from "@server/logger"; import { hashPassword } from "@server/auth/password"; import type { CreateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types"; +import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema"; +import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z.object({ orgId: z.string().nonempty() @@ -34,10 +36,17 @@ const paramsSchema = z.object({ const bodySchema = z .strictObject({ name: z.string().min(1).max(255), - maxBatchSize: z.union([ - z.null(), - z.coerce.number().int().positive().max(1_000_000) - ]), + maxBatchSize: z + .union([ + z.null(), + z.coerce.number().int().positive().max(1_000_000) + ]) + .openapi({ + type: "number", + default: null, + description: + "Maximum number of sites that can be provisioned in a single batch. If null, there is no limit." + }), validUntil: z.string().max(255).optional(), approveNewSites: z.boolean().optional().default(true) }) @@ -57,6 +66,49 @@ const bodySchema = z export type CreateSiteProvisioningKeyBody = z.infer; +const CreateSiteProvisioningKeyResponseDataSchema = z.object({ + siteProvisioningKeyId: z.string(), + orgId: z.string(), + name: z.string(), + siteProvisioningKey: z.string(), + lastChars: z.string(), + createdAt: z.string(), + lastUsed: z.string().nullable(), + maxBatchSize: z.number().nullable(), + numUsed: z.number(), + validUntil: z.string().nullable(), + approveNewSites: z.boolean() +}); + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/site-provisioning-key", + description: "Create a new site provisioning key for the organization.", + tags: [OpenAPITags.SiteProvisioningKey], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: { + 201: { + description: "Successful response", + content: { + "application/json": { + schema: createApiResponseSchema( + CreateSiteProvisioningKeyResponseDataSchema + ) + } + } + } + } +}); + export async function createSiteProvisioningKey( req: Request, res: Response, diff --git a/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts index 61fa0a850..9c77c93ca 100644 --- a/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts +++ b/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts @@ -13,23 +13,46 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { - db, - siteProvisioningKeyOrg, - siteProvisioningKeys -} from "@server/db"; +import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z.object({ siteProvisioningKeyId: z.string().nonempty(), orgId: z.string().nonempty() }); +registry.registerPath({ + method: "delete", + path: "/org/{orgId}/site-provisioning-key/{siteProvisioningKeyId}", + description: "Delete a site provisioning key.", + tags: [OpenAPITags.SiteProvisioningKey], + request: { + params: paramsSchema + }, + 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 deleteSiteProvisioningKey( req: Request, res: Response, diff --git a/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts b/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts index 0e9838b24..63ab4429a 100644 --- a/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts +++ b/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts @@ -11,11 +11,7 @@ * This file is not licensed under the AGPLv3. */ -import { - db, - siteProvisioningKeyOrg, - siteProvisioningKeys -} from "@server/db"; +import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; @@ -25,6 +21,8 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { eq } from "drizzle-orm"; import type { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types"; +import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema"; +import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z.object({ orgId: z.string().nonempty() @@ -45,11 +43,55 @@ const querySchema = z.object({ .pipe(z.int().nonnegative()) }); +const ListSiteProvisioningKeysResponseDataSchema = z.object({ + siteProvisioningKeys: z.array( + z.object({ + siteProvisioningKeyId: z.string(), + orgId: z.string(), + lastChars: z.string(), + createdAt: z.string(), + name: z.string(), + lastUsed: z.string().nullable(), + maxBatchSize: z.number().nullable(), + numUsed: z.number(), + validUntil: z.string().nullable(), + approveNewSites: z.boolean() + }) + ), + pagination: z.object({ + total: z.number(), + limit: z.number(), + offset: z.number() + }) +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/site-provisioning-keys", + description: "List all site provisioning keys for an organization.", + tags: [OpenAPITags.SiteProvisioningKey], + request: { + params: paramsSchema, + query: querySchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: createApiResponseSchema( + ListSiteProvisioningKeysResponseDataSchema + ) + } + } + } + } +}); + function querySiteProvisioningKeys(orgId: string) { return db .select({ - siteProvisioningKeyId: - siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyId: siteProvisioningKeys.siteProvisioningKeyId, orgId: siteProvisioningKeyOrg.orgId, lastChars: siteProvisioningKeys.lastChars, createdAt: siteProvisioningKeys.createdAt, diff --git a/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts index cee40212a..f97389b2b 100644 --- a/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts +++ b/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts @@ -13,11 +13,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { - db, - siteProvisioningKeyOrg, - siteProvisioningKeys -} from "@server/db"; +import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -25,6 +21,8 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import type { UpdateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types"; +import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema"; +import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z.object({ siteProvisioningKeyId: z.string().nonempty(), @@ -38,7 +36,13 @@ const bodySchema = z z.null(), z.coerce.number().int().positive().max(1_000_000) ]) - .optional(), + .optional() + .openapi({ + type: "number", + default: null, + description: + "Maximum number of sites that can be provisioned in a single batch. If null, there is no limit." + }), validUntil: z.string().max(255).optional(), approveNewSites: z.boolean().optional() }) @@ -50,7 +54,8 @@ const bodySchema = z ) { ctx.addIssue({ code: "custom", - message: "Provide maxBatchSize and/or validUntil and/or approveNewSites", + message: + "Provide maxBatchSize and/or validUntil and/or approveNewSites", path: ["maxBatchSize"] }); } @@ -69,6 +74,48 @@ const bodySchema = z export type UpdateSiteProvisioningKeyBody = z.infer; +const UpdateSiteProvisioningKeyResponseDataSchema = z.object({ + siteProvisioningKeyId: z.string(), + orgId: z.string(), + name: z.string(), + lastChars: z.string(), + createdAt: z.string(), + lastUsed: z.string().nullable(), + maxBatchSize: z.number().nullable(), + numUsed: z.number(), + validUntil: z.string().nullable(), + approveNewSites: z.boolean() +}); + +registry.registerPath({ + method: "patch", + path: "/org/{orgId}/site-provisioning-key/{siteProvisioningKeyId}", + description: "Update a site provisioning key.", + tags: [OpenAPITags.SiteProvisioningKey], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: createApiResponseSchema( + UpdateSiteProvisioningKeyResponseDataSchema + ) + } + } + } + } +}); + export async function updateSiteProvisioningKey( req: Request, res: Response, diff --git a/server/routers/accessToken/generateAccessToken.ts b/server/routers/accessToken/generateAccessToken.ts index a06068c01..79eecc59a 100644 --- a/server/routers/accessToken/generateAccessToken.ts +++ b/server/routers/accessToken/generateAccessToken.ts @@ -1,4 +1,3 @@ -import { hash } from "@node-rs/argon2"; import { generateId, generateIdFromEntropySize, @@ -8,18 +7,18 @@ import { db } from "@server/db"; import { ResourceAccessToken, resourceAccessToken, - resources + resources, + userOrgs } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { createDate, TimeSpan } from "oslo"; -import { hashPassword } from "@server/auth/password"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { OpenAPITags, registry } from "@server/openApi"; @@ -28,7 +27,9 @@ export const generateAccessTokenBodySchema = z.strictObject({ validForSeconds: z.int().positive().optional(), // seconds title: z.string().optional(), path: z.string().optional(), - description: z.string().optional() + description: z.string().optional(), + persistSession: z.boolean().optional().default(false), + userId: z.string().optional() }); export const generateAccssTokenParamsSchema = z.strictObject({ @@ -44,6 +45,39 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/access-token", description: "Generate a new access token for a resource.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: generateAccssTokenParamsSchema, + body: { + content: { + "application/json": { + schema: generateAccessTokenBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/access-token", + description: "Generate a new access token for a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.AccessToken], request: { params: generateAccssTokenParamsSchema, @@ -101,7 +135,14 @@ export async function generateAccessToken( } const { resourceId } = parsedParams.data; - const { validForSeconds, title, path, description } = parsedBody.data; + const { + validForSeconds, + title, + path, + description, + persistSession, + userId + } = parsedBody.data; const [resource] = await db .select() @@ -112,6 +153,28 @@ export async function generateAccessToken( return next(createHttpError(HttpCode.NOT_FOUND, "Resource not found")); } + if (userId) { + const [membership] = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, resource.orgId) + ) + ) + .limit(1); + + if (!membership) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User is not a member of this organization" + ) + ); + } + } + try { const sessionLength = validForSeconds ? validForSeconds * 1000 @@ -133,23 +196,27 @@ export async function generateAccessToken( accessTokenId: id, orgId: resource.orgId, resourceId, + userId: userId || null, tokenHash, expiresAt: expiresAt || null, sessionLength: sessionLength, title: title || null, path: path || null, description: description || null, + persistSession, createdAt: new Date().getTime() }) .returning({ accessTokenId: resourceAccessToken.accessTokenId, orgId: resourceAccessToken.orgId, resourceId: resourceAccessToken.resourceId, + userId: resourceAccessToken.userId, expiresAt: resourceAccessToken.expiresAt, sessionLength: resourceAccessToken.sessionLength, title: resourceAccessToken.title, path: resourceAccessToken.path, description: resourceAccessToken.description, + persistSession: resourceAccessToken.persistSession, createdAt: resourceAccessToken.createdAt }) .execute(); diff --git a/server/routers/accessToken/listAccessTokens.ts b/server/routers/accessToken/listAccessTokens.ts index 472d9da40..9a8796e0b 100644 --- a/server/routers/accessToken/listAccessTokens.ts +++ b/server/routers/accessToken/listAccessTokens.ts @@ -6,12 +6,13 @@ import { userResources, roleResources, resourceAccessToken, - sites + sites, + users } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { sql, eq, or, inArray, and, count, isNull, lt, gt } from "drizzle-orm"; +import { sql, eq, or, inArray, and, count, isNull, gt } from "drizzle-orm"; import logger from "@server/logger"; import stoi from "@server/lib/stoi"; import { fromZodError } from "zod-validation-error"; @@ -55,11 +56,16 @@ function queryAccessTokens( accessTokenId: resourceAccessToken.accessTokenId, orgId: resourceAccessToken.orgId, resourceId: resourceAccessToken.resourceId, + userId: resourceAccessToken.userId, + userName: users.name, + username: users.username, + userEmail: users.email, sessionLength: resourceAccessToken.sessionLength, expiresAt: resourceAccessToken.expiresAt, tokenHash: resourceAccessToken.tokenHash, title: resourceAccessToken.title, description: resourceAccessToken.description, + persistSession: resourceAccessToken.persistSession, createdAt: resourceAccessToken.createdAt, resourceName: resources.name, resourceNiceId: resources.niceId, @@ -75,6 +81,7 @@ function queryAccessTokens( eq(resourceAccessToken.resourceId, resources.resourceId) ) .leftJoin(sites, eq(resources.resourceId, sites.siteId)) + .leftJoin(users, eq(resourceAccessToken.userId, users.userId)) .where( and( inArray( @@ -97,6 +104,7 @@ function queryAccessTokens( eq(resourceAccessToken.resourceId, resources.resourceId) ) .leftJoin(sites, eq(resources.resourceId, sites.siteId)) + .leftJoin(users, eq(resourceAccessToken.userId, users.userId)) .where( and( inArray( @@ -151,6 +159,35 @@ registry.registerPath({ method: "get", path: "/resource/{resourceId}/access-tokens", description: "List all access tokens for a resource.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: z.object({ + resourceId: z.number() + }), + query: listAccessTokensSchema + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "get", + path: "/public-resource/{resourceId}/access-tokens", + description: "List all access tokens for a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.AccessToken], request: { params: z.object({ diff --git a/server/routers/auth/logout.ts b/server/routers/auth/logout.ts index b9a1431aa..b36d0c73a 100644 --- a/server/routers/auth/logout.ts +++ b/server/routers/auth/logout.ts @@ -16,18 +16,26 @@ export async function logout( next: NextFunction ): Promise { const { user, session } = await verifySession(req); + const isSecure = req.protocol === "https"; + + // Always clear the session cookie so logout is idempotent, even when + // the session is already missing or invalid + res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure)); + if (!user || !session) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( - `Log out failed because missing or invalid session. IP: ${req.ip}.` + `Log out with missing or invalid session. IP: ${req.ip}.` ); } - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "You must be logged in to sign out" - ) - ); + + return response(res, { + data: null, + success: true, + error: false, + message: "Logged out successfully", + status: HttpCode.OK + }); } try { @@ -37,9 +45,6 @@ export async function logout( logger.error("Failed to invalidate session", error); } - const isSecure = req.protocol === "https"; - res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure)); - return response(res, { data: null, success: true, diff --git a/server/routers/auth/requestPasswordReset.ts b/server/routers/auth/requestPasswordReset.ts index 42b53d24d..aca435c7b 100644 --- a/server/routers/auth/requestPasswordReset.ts +++ b/server/routers/auth/requestPasswordReset.ts @@ -99,7 +99,7 @@ export async function requestPasswordReset( }); }); - const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${email}&token=${token}`; + const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${encodeURIComponent(email)}&token=${token}`; if (!config.getRawConfig().email) { logger.info( diff --git a/server/routers/badger/verifySession.test.ts b/server/routers/badger/verifySession.test.ts index 681717f9c..c91805f8e 100644 --- a/server/routers/badger/verifySession.test.ts +++ b/server/routers/badger/verifySession.test.ts @@ -236,6 +236,38 @@ function runTests() { "Root path should not match non-root path" ); + // Path traversal / encoded-slash bypass regression tests + assertEquals( + isPathAllowed("public/*", "public/../admin"), + false, + "Literal .. traversal out of an allowed prefix must not match" + ); + assertEquals( + isPathAllowed("public/*", "public%2F..%2Fadmin"), + false, + "Encoded-slash traversal out of an allowed prefix must not match" + ); + assertEquals( + isPathAllowed("public/*", "public%2f..%2fadmin%2ffile"), + false, + "Encoded-slash traversal is case-insensitively decoded before matching" + ); + assertEquals( + isPathAllowed("admin/*", "public/../admin/secret"), + true, + ".. traversal INTO a restricted path should still be caught by its own rule" + ); + assertEquals( + isPathAllowed("public/*", "public%2Ffoo"), + true, + "Encoded slash without traversal should still resolve and match normally" + ); + assertEquals( + isPathAllowed("public/*", "public/./foo"), + true, + "Single-dot segments are a no-op and should not affect matching" + ); + console.log("All path matching tests passed!"); } diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index cf36fef72..33c75b101 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -1,4 +1,9 @@ -import { validateResourceSessionToken } from "@server/auth/sessions/resource"; +import { + createResourceSession, + serializeResourceSessionCookie, + validateResourceSessionToken +} from "@server/auth/sessions/resource"; +import { generateSessionToken } from "@server/auth/sessions/app"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import { getResourceByDomain, @@ -13,6 +18,7 @@ import { LoginPage, Org, Resource, + ResourceAccessToken, ResourceHeaderAuth, ResourceHeaderAuthExtendedCompatibility, ResourcePassword, @@ -21,7 +27,10 @@ import { ResourcePolicyPassword, ResourcePolicyHeaderAuth, ResourceRule, - ResourceSession + ResourceSession, + db, + resourceAccessToken, + users } from "@server/db"; import config from "@server/lib/config"; import { isIpInCidr, stripPortFromHost } from "@server/lib/ip"; @@ -41,11 +50,13 @@ import { enforceResourceSessionLength } from "#dynamic/lib/checkOrgAccessPolicy"; import { logRequestAudit } from "./logRequestAudit"; +import { logAccessAudit } from "#dynamic/lib/logAccessAudit"; import { REGIONS } from "@server/db/regions"; import { localCache } from "#dynamic/lib/cache"; import { APP_VERSION } from "@server/lib/consts"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { eq } from "drizzle-orm"; const verifyResourceSessionSchema = z.object({ sessions: z.record(z.string(), z.string()).optional(), @@ -350,22 +361,15 @@ export async function verifyResourceSession( } if (valid && tokenItem) { - logRequestAudit( - { - action: true, - reason: 102, // valid access token - resourceId: resource.resourceId, - orgId: resource.orgId, - location: ipCC, - apiKey: { - name: tokenItem.title, - apiKeyId: tokenItem.accessTokenId - } - }, - parsedBody.data + return await allowAccessToken( + res, + resource, + tokenItem, + sessions, + dontStripSession, + parsedBody.data, + ipCC ); - - return allowed(res, undefined, dontStripSession); } } @@ -401,27 +405,20 @@ export async function verifyResourceSession( } if (valid && tokenItem) { - logRequestAudit( - { - action: true, - reason: 102, // valid access token - resourceId: resource.resourceId, - orgId: resource.orgId, - location: ipCC, - apiKey: { - name: tokenItem.title, - apiKeyId: tokenItem.accessTokenId - } - }, - parsedBody.data + return await allowAccessToken( + res, + resource, + tokenItem, + sessions, + dontStripSession, + parsedBody.data, + ipCC ); - - return allowed(res, undefined, dontStripSession); } } // check for HTTP Basic Auth header - const clientHeaderAuthKey = `headerAuth:${clientHeaderAuth}`; + const clientHeaderAuthKey = `headerAuth:${resource.resourceId}:${clientHeaderAuth}`; if (headerAuth && clientHeaderAuth) { if (localCache.get(clientHeaderAuthKey)) { logger.debug( @@ -666,22 +663,37 @@ export async function verifyResourceSession( "Resource allowed because access token session is valid" ); - logRequestAudit( + const [tokenItem] = await db + .select() + .from(resourceAccessToken) + .where( + eq( + resourceAccessToken.accessTokenId, + resourceSession.accessTokenId + ) + ) + .limit(1); + + const userData = tokenItem + ? await getAccessTokenUserData( + tokenItem, + resource.orgId + ) + : undefined; + + logAccessTokenRequestAudit( { - action: true, - reason: 102, // valid access token resourceId: resource.resourceId, orgId: resource.orgId, location: ipCC, - apiKey: { - name: null, - apiKeyId: resourceSession.accessTokenId - } + accessTokenId: resourceSession.accessTokenId, + tokenTitle: tokenItem?.title ?? null, + userData }, parsedBody.data ); - return allowed(res, undefined, dontStripSession); + return allowed(res, userData, dontStripSession); } if (resourceSession.userSessionId && sso) { @@ -892,6 +904,227 @@ function allowed( return response(res, data); } +async function allowAccessToken( + res: Response, + resource: Resource, + tokenItem: ResourceAccessToken, + sessions: Record | undefined, + dontStripSession: boolean | undefined, + auditBody: VerifyResourceSessionSchema, + location?: string +) { + const userData = await getAccessTokenUserData(tokenItem, resource.orgId); + + logAccessTokenRequestAudit( + { + resourceId: resource.resourceId, + orgId: resource.orgId, + location, + accessTokenId: tokenItem.accessTokenId, + tokenTitle: tokenItem.title, + userData + }, + auditBody + ); + + if (!tokenItem.persistSession) { + logAccessTokenAccessAudit(tokenItem, resource, userData, auditBody); + return allowed(res, userData, dontStripSession); + } + + const resourceSessionToken = extractResourceSessionToken( + sessions ?? {}, + resource.ssl + ); + + if (resourceSessionToken) { + const sessionCacheKey = `session:${resourceSessionToken}`; + let resourceSession: ResourceSession | null | undefined = + localCache.get(sessionCacheKey); + + if (!resourceSession) { + const result = await validateResourceSessionToken( + resourceSessionToken, + resource.resourceId + ); + resourceSession = result?.resourceSession; + localCache.set(sessionCacheKey, resourceSession, 5); + } + + if ( + resourceSession && + !resourceSession.isRequestToken && + resourceSession.accessTokenId === tokenItem.accessTokenId + ) { + logger.debug( + "Resource allowed because existing access token session is valid" + ); + return allowed(res, userData, dontStripSession); + } + } + + logAccessTokenAccessAudit(tokenItem, resource, userData, auditBody); + return await createAccessTokenSession(res, resource, tokenItem, userData); +} + +async function createAccessTokenSession( + res: Response, + resource: Resource, + tokenItem: ResourceAccessToken, + userData?: BasicUserData +) { + const token = generateSessionToken(); + const sess = await createResourceSession({ + resourceId: resource.resourceId, + token, + accessTokenId: tokenItem.accessTokenId, + sessionLength: tokenItem.sessionLength, + expiresAt: tokenItem.expiresAt, + doNotExtend: tokenItem.expiresAt ? true : false + }); + const cookieName = config.getRawConfig().server.session_cookie_name; + const cookie = serializeResourceSessionCookie( + cookieName, + resource.fullDomain!, + token, + !resource.ssl, + new Date(sess.expiresAt) + ); + res.appendHeader("Set-Cookie", cookie); + logger.debug("Access token is valid, creating new session"); + return allowed(res, userData); +} + +async function getAccessTokenUserData( + tokenItem: ResourceAccessToken, + orgId: string +): Promise { + if (!tokenItem.userId) { + return undefined; + } + + const cacheKey = `accessTokenUser:${tokenItem.userId}:${orgId}`; + const cached = localCache.get(cacheKey) as BasicUserData | null | undefined; + if (cached !== undefined) { + return cached ?? undefined; + } + + const [user] = await db + .select() + .from(users) + .where(eq(users.userId, tokenItem.userId)) + .limit(1); + + if (!user) { + localCache.set(cacheKey, null, 5); + return undefined; + } + + const userOrgRoles = await getUserOrgRoles(user.userId, orgId); + const userData: BasicUserData = { + userId: user.userId, + username: user.username, + email: user.email, + name: user.name, + role: userOrgRoles.map((r) => r.roleName).join(", ") || null + }; + + localCache.set(cacheKey, userData, 12); + return userData; +} + +function logAccessTokenRequestAudit( + data: { + resourceId: number; + orgId: string; + location?: string; + accessTokenId: string; + tokenTitle: string | null; + userData?: BasicUserData; + }, + body: VerifyResourceSessionSchema +) { + if (data.userData) { + logRequestAudit( + { + action: true, + reason: 102, // valid access token + resourceId: data.resourceId, + orgId: data.orgId, + location: data.location, + user: { + username: data.userData.username, + userId: data.userData.userId + }, + metadata: { + accessTokenId: data.accessTokenId, + accessTokenTitle: data.tokenTitle + } + }, + body + ); + return; + } + + logRequestAudit( + { + action: true, + reason: 102, // valid access token + resourceId: data.resourceId, + orgId: data.orgId, + location: data.location, + apiKey: { + name: data.tokenTitle, + apiKeyId: data.accessTokenId + } + }, + body + ); +} + +function logAccessTokenAccessAudit( + tokenItem: ResourceAccessToken, + resource: Resource, + userData: BasicUserData | undefined, + body: VerifyResourceSessionSchema +) { + const userAgent = + body.headers?.["user-agent"] || body.headers?.["User-Agent"]; + + if (userData) { + logAccessAudit({ + orgId: resource.orgId, + resourceId: resource.resourceId, + action: true, + type: "accessToken", + user: { + username: userData.username, + userId: userData.userId + }, + metadata: { + accessTokenId: tokenItem.accessTokenId, + accessTokenTitle: tokenItem.title + }, + userAgent, + requestIp: body.requestIp + }); + return; + } + + logAccessAudit({ + orgId: resource.orgId, + resourceId: resource.resourceId, + action: true, + type: "accessToken", + apiKey: { + name: tokenItem.title, + apiKeyId: tokenItem.accessTokenId + }, + userAgent, + requestIp: body.requestIp + }); +} + async function headerAuthChallenged( res: Response, redirectPath?: string, diff --git a/server/routers/domain/updateDomain.ts b/server/routers/domain/updateDomain.ts index 61b99f542..cc2a26844 100644 --- a/server/routers/domain/updateDomain.ts +++ b/server/routers/domain/updateDomain.ts @@ -33,7 +33,7 @@ const UpdateDomainResponseDataSchema = z.object({ registry.registerPath({ - method: "patch", + method: "post", path: "/org/{orgId}/domain/{domainId}", description: "Update a domain by domainId.", tags: [OpenAPITags.Domain], @@ -55,6 +55,31 @@ registry.registerPath({ } }); +registry.registerPath({ + method: "patch", + path: "/org/{orgId}/domain/{domainId}", + description: + "Update a domain by domainId. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.Domain], + request: { + params: z.object({ + domainId: z.string(), + orgId: z.string() + }) + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: createApiResponseSchema(UpdateDomainResponseDataSchema) + } + } + } + } +}); + export async function updateOrgDomain( req: Request, res: Response, 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..13498ad25 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, @@ -162,7 +163,7 @@ authenticated.get( // Site Resource endpoints authenticated.put( - "/org/:orgId/site-resource", + ["/org/:orgId/site-resource", "/org/:orgId/private-resource"], verifyApiKeyOrgAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createSiteResource), @@ -171,7 +172,10 @@ authenticated.put( ); authenticated.get( - "/org/:orgId/site/:siteId/resources", + [ + "/org/:orgId/site/:siteId/resources", + "/org/:orgId/site/:siteId/private-resources" + ], verifyApiKeyOrgAccess, verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.listSiteResources), @@ -179,21 +183,21 @@ authenticated.get( ); authenticated.get( - "/org/:orgId/site-resources", + ["/org/:orgId/site-resources", "/org/:orgId/private-resources"], verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.listSiteResources), siteResource.listAllSiteResourcesByOrg ); authenticated.get( - "/site-resource/:siteResourceId", + ["/site-resource/:siteResourceId", "/private-resource/:siteResourceId"], verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.getSiteResource), siteResource.getSiteResource ); authenticated.post( - "/site-resource/:siteResourceId", + ["/site-resource/:siteResourceId", "/private-resource/:siteResourceId"], verifyApiKeySiteResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateSiteResource), @@ -202,7 +206,7 @@ authenticated.post( ); authenticated.delete( - "/site-resource/:siteResourceId", + ["/site-resource/:siteResourceId", "/private-resource/:siteResourceId"], verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteSiteResource), logActionAudit(ActionsEnum.deleteSiteResource), @@ -210,28 +214,40 @@ authenticated.delete( ); authenticated.get( - "/site-resource/:siteResourceId/roles", + [ + "/site-resource/:siteResourceId/roles", + "/private-resource/:siteResourceId/roles" + ], verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.listResourceRoles), siteResource.listSiteResourceRoles ); authenticated.get( - "/site-resource/:siteResourceId/users", + [ + "/site-resource/:siteResourceId/users", + "/private-resource/:siteResourceId/users" + ], verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.listResourceUsers), siteResource.listSiteResourceUsers ); authenticated.get( - "/site-resource/:siteResourceId/clients", + [ + "/site-resource/:siteResourceId/clients", + "/private-resource/:siteResourceId/clients" + ], verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.listResourceUsers), siteResource.listSiteResourceClients ); authenticated.post( - "/site-resource/:siteResourceId/roles", + [ + "/site-resource/:siteResourceId/roles", + "/private-resource/:siteResourceId/roles" + ], verifyApiKeySiteResourceAccess, verifyApiKeyRoleAccess, verifyLimits, @@ -241,7 +257,10 @@ authenticated.post( ); authenticated.post( - "/site-resource/:siteResourceId/users", + [ + "/site-resource/:siteResourceId/users", + "/private-resource/:siteResourceId/users" + ], verifyApiKeySiteResourceAccess, verifyApiKeySetResourceUsers, verifyLimits, @@ -251,7 +270,10 @@ authenticated.post( ); authenticated.post( - "/site-resource/:siteResourceId/roles/add", + [ + "/site-resource/:siteResourceId/roles/add", + "/private-resource/:siteResourceId/roles/add" + ], verifyApiKeySiteResourceAccess, verifyApiKeyRoleAccess, verifyLimits, @@ -261,7 +283,10 @@ authenticated.post( ); authenticated.post( - "/site-resource/:siteResourceId/roles/remove", + [ + "/site-resource/:siteResourceId/roles/remove", + "/private-resource/:siteResourceId/roles/remove" + ], verifyApiKeySiteResourceAccess, verifyApiKeyRoleAccess, verifyLimits, @@ -271,7 +296,10 @@ authenticated.post( ); authenticated.post( - "/site-resource/:siteResourceId/users/add", + [ + "/site-resource/:siteResourceId/users/add", + "/private-resource/:siteResourceId/users/add" + ], verifyApiKeySiteResourceAccess, verifyApiKeySetResourceUsers, verifyLimits, @@ -281,7 +309,10 @@ authenticated.post( ); authenticated.post( - "/site-resource/:siteResourceId/users/remove", + [ + "/site-resource/:siteResourceId/users/remove", + "/private-resource/:siteResourceId/users/remove" + ], verifyApiKeySiteResourceAccess, verifyApiKeySetResourceUsers, verifyLimits, @@ -291,7 +322,10 @@ authenticated.post( ); authenticated.post( - "/site-resource/:siteResourceId/clients", + [ + "/site-resource/:siteResourceId/clients", + "/private-resource/:siteResourceId/clients" + ], verifyApiKeySiteResourceAccess, verifyApiKeySetResourceClients, verifyLimits, @@ -301,7 +335,10 @@ authenticated.post( ); authenticated.post( - "/site-resource/:siteResourceId/clients/add", + [ + "/site-resource/:siteResourceId/clients/add", + "/private-resource/:siteResourceId/clients/add" + ], verifyApiKeySiteResourceAccess, verifyApiKeySetResourceClients, verifyLimits, @@ -311,7 +348,10 @@ authenticated.post( ); authenticated.post( - "/site-resource/:siteResourceId/clients/remove", + [ + "/site-resource/:siteResourceId/clients/remove", + "/private-resource/:siteResourceId/clients/remove" + ], verifyApiKeySiteResourceAccess, verifyApiKeySetResourceClients, verifyLimits, @@ -321,7 +361,7 @@ authenticated.post( ); authenticated.post( - "/client/:clientId/site-resources", + ["/client/:clientId/site-resources", "/client/:clientId/private-resources"], verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), @@ -329,7 +369,7 @@ authenticated.post( ); authenticated.put( - "/org/:orgId/resource", + ["/org/:orgId/resource", "/org/:orgId/public-resource"], verifyApiKeyOrgAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createResource), @@ -338,7 +378,10 @@ authenticated.put( ); authenticated.put( - "/org/:orgId/site/:siteId/resource", + [ + "/org/:orgId/site/:siteId/resource", + "/org/:orgId/site/:siteId/public-resource" + ], verifyApiKeyOrgAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createResource), @@ -347,14 +390,14 @@ authenticated.put( ); authenticated.get( - "/site/:siteId/resources", + ["/site/:siteId/resources", "/site/:siteId/public-resources"], verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.listResources), resource.listResources ); authenticated.get( - "/org/:orgId/resources", + ["/org/:orgId/resources", "/org/:orgId/public-resources"], verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.listResources), resource.listResources @@ -383,6 +426,15 @@ authenticated.put( domain.createOrgDomain ); +authenticated.post( + "/org/:orgId/domain/:domainId", + verifyApiKeyOrgAccess, + verifyApiKeyDomainAccess, + verifyApiKeyHasAction(ActionsEnum.updateOrgDomain), + domain.updateOrgDomain +); + +// Deprecated: use POST instead. Kept for backward compatibility. authenticated.patch( "/org/:orgId/domain/:domainId", verifyApiKeyOrgAccess, @@ -442,42 +494,45 @@ authenticated.delete( ); authenticated.get( - "/resource/:resourceId/roles", + ["/resource/:resourceId/roles", "/public-resource/:resourceId/roles"], verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.listResourceRoles), resource.listResourceRoles ); authenticated.get( - "/resource/:resourceId/users", + ["/resource/:resourceId/users", "/public-resource/:resourceId/users"], verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.listResourceUsers), resource.listResourceUsers ); authenticated.get( - "/resource/:resourceId", + ["/resource/:resourceId", "/public-resource/:resourceId"], verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.getResource), resource.getResource ); authenticated.get( - "/resource-policy/:resourcePolicyId", + [ + "/resource-policy/:resourcePolicyId", + "/public-resource-policy/:resourcePolicyId" + ], verifyApiKeyResourcePolicyAccess, verifyApiKeyHasAction(ActionsEnum.getResourcePolicy), policy.getResourcePolicy ); authenticated.get( - "/resource/:resourceId/policies", + ["/resource/:resourceId/policies", "/public-resource/:resourceId/policies"], verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.getResourcePolicy), resource.getResourcePolicies ); authenticated.post( - "/resource/:resourceId", + ["/resource/:resourceId", "/public-resource/:resourceId"], verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateResource), @@ -485,15 +540,29 @@ authenticated.post( resource.updateResource ); +authenticated.post( + [ + "/resource-policy/:resourcePolicyId", + "/public-resource-policy/:resourcePolicyId" + ], + verifyApiKeyResourcePolicyAccess, + verifyApiKeyHasAction(ActionsEnum.updateResourcePolicy), + policy.updateResourcePolicy +); + +// Deprecated: use POST instead. Kept for backward compatibility. authenticated.put( - "/resource-policy/:resourcePolicyId", + [ + "/resource-policy/:resourcePolicyId", + "/public-resource-policy/:resourcePolicyId" + ], verifyApiKeyResourcePolicyAccess, verifyApiKeyHasAction(ActionsEnum.updateResourcePolicy), policy.updateResourcePolicy ); authenticated.delete( - "/resource/:resourceId", + ["/resource/:resourceId", "/public-resource/:resourceId"], verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteResource), logActionAudit(ActionsEnum.deleteResource), @@ -501,7 +570,7 @@ authenticated.delete( ); authenticated.put( - "/resource/:resourceId/target", + ["/resource/:resourceId/target", "/public-resource/:resourceId/target"], verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createTarget), @@ -510,14 +579,14 @@ authenticated.put( ); authenticated.get( - "/resource/:resourceId/targets", + ["/resource/:resourceId/targets", "/public-resource/:resourceId/targets"], verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.listTargets), target.listTargets ); authenticated.put( - "/resource/:resourceId/rule", + ["/resource/:resourceId/rule", "/public-resource/:resourceId/rule"], verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createResourceRule), @@ -526,14 +595,17 @@ authenticated.put( ); authenticated.get( - "/resource/:resourceId/rules", + ["/resource/:resourceId/rules", "/public-resource/:resourceId/rules"], verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.listResourceRules), resource.listResourceRules ); authenticated.post( - "/resource/:resourceId/rule/:ruleId", + [ + "/resource/:resourceId/rule/:ruleId", + "/public-resource/:resourceId/rule/:ruleId" + ], verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateResourceRule), @@ -542,7 +614,10 @@ authenticated.post( ); authenticated.delete( - "/resource/:resourceId/rule/:ruleId", + [ + "/resource/:resourceId/rule/:ruleId", + "/public-resource/:resourceId/rule/:ruleId" + ], verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteResourceRule), logActionAudit(ActionsEnum.deleteResourceRule), @@ -624,7 +699,7 @@ authenticated.post( ); authenticated.post( - "/resource/:resourceId/roles", + ["/resource/:resourceId/roles", "/public-resource/:resourceId/roles"], verifyApiKeyResourceAccess, verifyApiKeyRoleAccess, verifyLimits, @@ -634,7 +709,7 @@ authenticated.post( ); authenticated.post( - "/resource/:resourceId/users", + ["/resource/:resourceId/users", "/public-resource/:resourceId/users"], verifyApiKeyResourceAccess, verifyApiKeySetResourceUsers, verifyLimits, @@ -643,8 +718,11 @@ authenticated.post( resource.setResourceUsers ); -authenticated.put( - "/resource-policy/:resourcePolicyId/access-control", +authenticated.post( + [ + "/resource-policy/:resourcePolicyId/access-control", + "/public-resource-policy/:resourcePolicyId/access-control" + ], verifyApiKeyResourcePolicyAccess, verifyApiKeyRoleAccess, verifyLimits, @@ -655,8 +733,27 @@ authenticated.put( policy.setResourcePolicyAccessControl ); +// Deprecated: use POST instead. Kept for backward compatibility. authenticated.put( - "/resource-policy/:resourcePolicyId/password", + [ + "/resource-policy/:resourcePolicyId/access-control", + "/public-resource-policy/:resourcePolicyId/access-control" + ], + verifyApiKeyResourcePolicyAccess, + verifyApiKeyRoleAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.setResourcePolicyUsers), + verifyUserHasAction(ActionsEnum.setResourcePolicyRoles), + logActionAudit(ActionsEnum.setResourcePolicyUsers), + logActionAudit(ActionsEnum.setResourcePolicyRoles), + policy.setResourcePolicyAccessControl +); + +authenticated.post( + [ + "/resource-policy/:resourcePolicyId/password", + "/public-resource-policy/:resourcePolicyId/password" + ], verifyApiKeyResourcePolicyAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourcePolicyPassword), @@ -664,8 +761,24 @@ authenticated.put( policy.setResourcePolicyPassword ); +// Deprecated: use POST instead. Kept for backward compatibility. authenticated.put( - "/resource-policy/:resourcePolicyId/pincode", + [ + "/resource-policy/:resourcePolicyId/password", + "/public-resource-policy/:resourcePolicyId/password" + ], + verifyApiKeyResourcePolicyAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyPassword), + logActionAudit(ActionsEnum.setResourcePolicyPassword), + policy.setResourcePolicyPassword +); + +authenticated.post( + [ + "/resource-policy/:resourcePolicyId/pincode", + "/public-resource-policy/:resourcePolicyId/pincode" + ], verifyApiKeyResourcePolicyAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourcePolicyPincode), @@ -673,8 +786,24 @@ authenticated.put( policy.setResourcePolicyPincode ); +// Deprecated: use POST instead. Kept for backward compatibility. authenticated.put( - "/resource-policy/:resourcePolicyId/header-auth", + [ + "/resource-policy/:resourcePolicyId/pincode", + "/public-resource-policy/:resourcePolicyId/pincode" + ], + verifyApiKeyResourcePolicyAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyPincode), + logActionAudit(ActionsEnum.setResourcePolicyPincode), + policy.setResourcePolicyPincode +); + +authenticated.post( + [ + "/resource-policy/:resourcePolicyId/header-auth", + "/public-resource-policy/:resourcePolicyId/header-auth" + ], verifyApiKeyResourcePolicyAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourcePolicyHeaderAuth), @@ -682,8 +811,24 @@ authenticated.put( policy.setResourcePolicyHeaderAuth ); +// Deprecated: use POST instead. Kept for backward compatibility. authenticated.put( - "/resource-policy/:resourcePolicyId/whitelist", + [ + "/resource-policy/:resourcePolicyId/header-auth", + "/public-resource-policy/:resourcePolicyId/header-auth" + ], + verifyApiKeyResourcePolicyAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyHeaderAuth), + logActionAudit(ActionsEnum.setResourcePolicyHeaderAuth), + policy.setResourcePolicyHeaderAuth +); + +authenticated.post( + [ + "/resource-policy/:resourcePolicyId/whitelist", + "/public-resource-policy/:resourcePolicyId/whitelist" + ], verifyApiKeyResourcePolicyAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourcePolicyWhitelist), @@ -691,8 +836,37 @@ authenticated.put( policy.setResourcePolicyWhitelist ); +// Deprecated: use POST instead. Kept for backward compatibility. authenticated.put( - "/resource-policy/:resourcePolicyId/rules", + [ + "/resource-policy/:resourcePolicyId/whitelist", + "/public-resource-policy/:resourcePolicyId/whitelist" + ], + verifyApiKeyResourcePolicyAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyWhitelist), + logActionAudit(ActionsEnum.setResourcePolicyWhitelist), + policy.setResourcePolicyWhitelist +); + +authenticated.post( + [ + "/resource-policy/:resourcePolicyId/rules", + "/public-resource-policy/:resourcePolicyId/rules" + ], + verifyApiKeyResourcePolicyAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyRules), + logActionAudit(ActionsEnum.setResourcePolicyRules), + policy.setResourcePolicyRules +); + +// Deprecated: use POST instead. Kept for backward compatibility. +authenticated.put( + [ + "/resource-policy/:resourcePolicyId/rules", + "/public-resource-policy/:resourcePolicyId/rules" + ], verifyApiKeyResourcePolicyAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourcePolicyRules), @@ -701,7 +875,10 @@ authenticated.put( ); authenticated.post( - "/resource/:resourceId/roles/add", + [ + "/resource/:resourceId/roles/add", + "/public-resource/:resourceId/roles/add" + ], verifyApiKeyResourceAccess, verifyApiKeyRoleAccess, verifyLimits, @@ -711,7 +888,10 @@ authenticated.post( ); authenticated.post( - "/resource/:resourceId/roles/remove", + [ + "/resource/:resourceId/roles/remove", + "/public-resource/:resourceId/roles/remove" + ], verifyApiKeyResourceAccess, verifyApiKeyRoleAccess, verifyLimits, @@ -721,7 +901,10 @@ authenticated.post( ); authenticated.post( - "/resource/:resourceId/users/add", + [ + "/resource/:resourceId/users/add", + "/public-resource/:resourceId/users/add" + ], verifyApiKeyResourceAccess, verifyApiKeySetResourceUsers, verifyLimits, @@ -731,7 +914,10 @@ authenticated.post( ); authenticated.post( - "/resource/:resourceId/users/remove", + [ + "/resource/:resourceId/users/remove", + "/public-resource/:resourceId/users/remove" + ], verifyApiKeyResourceAccess, verifyApiKeySetResourceUsers, verifyLimits, @@ -741,7 +927,7 @@ authenticated.post( ); authenticated.post( - `/resource/:resourceId/password`, + [`/resource/:resourceId/password`, `/public-resource/:resourceId/password`], verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourcePassword), @@ -750,7 +936,7 @@ authenticated.post( ); authenticated.post( - `/resource/:resourceId/pincode`, + [`/resource/:resourceId/pincode`, `/public-resource/:resourceId/pincode`], verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourcePincode), @@ -759,7 +945,10 @@ authenticated.post( ); authenticated.post( - `/resource/:resourceId/header-auth`, + [ + `/resource/:resourceId/header-auth`, + `/public-resource/:resourceId/header-auth` + ], verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceHeaderAuth), @@ -768,7 +957,10 @@ authenticated.post( ); authenticated.post( - `/resource/:resourceId/whitelist`, + [ + `/resource/:resourceId/whitelist`, + `/public-resource/:resourceId/whitelist` + ], verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), @@ -777,7 +969,10 @@ authenticated.post( ); authenticated.post( - `/resource/:resourceId/whitelist/add`, + [ + `/resource/:resourceId/whitelist/add`, + `/public-resource/:resourceId/whitelist/add` + ], verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), @@ -785,7 +980,10 @@ authenticated.post( ); authenticated.post( - `/resource/:resourceId/whitelist/remove`, + [ + `/resource/:resourceId/whitelist/remove`, + `/public-resource/:resourceId/whitelist/remove` + ], verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), @@ -793,14 +991,20 @@ authenticated.post( ); authenticated.get( - `/resource/:resourceId/whitelist`, + [ + `/resource/:resourceId/whitelist`, + `/public-resource/:resourceId/whitelist` + ], verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.getResourceWhitelist), resource.getResourceWhitelist ); authenticated.post( - `/resource/:resourceId/access-token`, + [ + `/resource/:resourceId/access-token`, + `/public-resource/:resourceId/access-token` + ], verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.generateAccessToken), @@ -808,16 +1012,6 @@ authenticated.post( accessToken.generateAccessToken ); -authenticated.post( - `/resource/:resourceId/session-token`, - verifyApiKeyResourceAccess, - verifyApiKeyUserAccess, - verifyLimits, - verifyApiKeyHasAction(ActionsEnum.createResourceSessionToken), - logActionAudit(ActionsEnum.createResourceSessionToken), - resource.createResourceSessionToken -); - authenticated.delete( `/access-token/:accessTokenId`, verifyApiKeyAccessTokenAccess, @@ -834,7 +1028,10 @@ authenticated.get( ); authenticated.get( - `/resource/:resourceId/access-tokens`, + [ + `/resource/:resourceId/access-tokens`, + `/public-resource/:resourceId/access-tokens` + ], verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.listAccessTokens), accessToken.listAccessTokens @@ -1164,7 +1361,7 @@ authenticated.get( ); authenticated.get( - "/org/:orgId/resource-names", + ["/org/:orgId/resource-names", "/org/:orgId/public-resource-names"], verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.listResources), resource.listAllResourceNames 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/newt/handleNewtGetConfigMessage.ts b/server/routers/newt/handleNewtGetConfigMessage.ts index fd5e2b42e..d99dd89ef 100644 --- a/server/routers/newt/handleNewtGetConfigMessage.ts +++ b/server/routers/newt/handleNewtGetConfigMessage.ts @@ -29,7 +29,7 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => { return; } - const { publicKey, port, chainId } = message.data; + const { publicKey, port, localEndpoints, chainId } = message.data; const siteId = newt.siteId; // Get the current site data @@ -69,7 +69,10 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => { .update(sites) .set({ publicKey, - listenPort: port + listenPort: port, + localEndpoints: localEndpoints + ? JSON.stringify(localEndpoints) + : null }) .where(eq(sites.siteId, siteId)) .returning(); diff --git a/server/routers/olm/buildConfiguration.ts b/server/routers/olm/buildConfiguration.ts index f72731c92..37355eae5 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"; @@ -30,6 +30,7 @@ export async function buildSiteConfigurationForOlmClient( siteId: number; name?: string; endpoint?: string; + localEndpoints?: string[]; publicKey?: string; serverIP?: string | null; serverPort?: number | null; @@ -70,7 +71,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(); @@ -200,6 +207,9 @@ export async function buildSiteConfigurationForOlmClient( name: site.name, // relayEndpoint: relayEndpoint, // this can be undefined now if not relayed // lets not do this for now because it would conflict with the hole punch testing endpoint: site.endpoint, + localEndpoints: site.localEndpoints + ? JSON.parse(site.localEndpoints) + : undefined, publicKey: site.publicKey, serverIP: site.address, serverPort: site.listenPort, diff --git a/server/routers/olm/handleOlmLocalMessage.ts b/server/routers/olm/handleOlmLocalMessage.ts new file mode 100644 index 000000000..83ef8cbf1 --- /dev/null +++ b/server/routers/olm/handleOlmLocalMessage.ts @@ -0,0 +1,74 @@ +import { db, sites } from "@server/db"; +import { MessageHandler } from "@server/routers/ws"; +import { clients, Olm } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import { updatePeer as newtUpdatePeer } from "../newt/peers"; +import logger from "@server/logger"; + +export const handleOlmLocalMessage: MessageHandler = async (context) => { + const { message, client: c, sendToClient } = context; + const olm = c as Olm; + + logger.info("Handling local olm message!"); + + if (!olm) { + logger.warn("Olm not found"); + return; + } + + if (!olm.clientId) { + logger.warn("Olm has no client!"); + return; + } + + const clientId = olm.clientId; + + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + logger.warn("Client not found"); + return; + } + + // make sure we hand endpoints for both the site and the client and the lastHolePunch is not too old + if (!client.pubKey) { + logger.warn("Client has no endpoint or listen port"); + return; + } + + const { siteId, chainId } = message.data; + + // Get the site + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!site || !site.exitNodeId) { + logger.warn("Site not found or has no exit node"); + return; + } + + // update the peer on the newt + await newtUpdatePeer(siteId, client.pubKey, { + endpoint: "" // this removes the endpoint so the newt knows to accept local + }); + + // Just ack the message, we don't keep sending it + return { + message: { + type: "olm/wg/peer/local", + data: { + siteId: siteId, + chainId + } + }, + broadcast: false, + excludeSender: false + }; +}; diff --git a/server/routers/olm/handleOlmRelayMessage.ts b/server/routers/olm/handleOlmRelayMessage.ts index 7196824d2..406ed7bbb 100644 --- a/server/routers/olm/handleOlmRelayMessage.ts +++ b/server/routers/olm/handleOlmRelayMessage.ts @@ -79,9 +79,9 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => { ) ); - // update the peer on the exit node + // update the peer on the newt await newtUpdatePeer(siteId, client.pubKey, { - endpoint: "" // this removes the endpoint so the exit node knows to relay + endpoint: "" // this removes the endpoint so the newt knows to relay }); return { diff --git a/server/routers/olm/handleOlmServerPeerAddMessage.ts b/server/routers/olm/handleOlmServerPeerAddMessage.ts index 5f46ea84c..332afe082 100644 --- a/server/routers/olm/handleOlmServerPeerAddMessage.ts +++ b/server/routers/olm/handleOlmServerPeerAddMessage.ts @@ -3,24 +3,15 @@ import { db, networks, siteNetworks, - siteResources, + siteResources } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; -import { - clients, - clientSitesAssociationsCache, - Olm, - sites -} from "@server/db"; +import { clients, clientSitesAssociationsCache, Olm, sites } from "@server/db"; import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; import logger from "@server/logger"; -import { - generateAliasConfig, -} from "@server/lib/ip"; +import { generateAliasConfig } from "@server/lib/ip"; import { generateRemoteSubnets } from "@server/lib/ip"; -import { - addPeer as newtAddPeer, -} from "@server/routers/newt/peers"; +import { addPeer as newtAddPeer } from "@server/routers/newt/peers"; export const handleOlmServerPeerAddMessage: MessageHandler = async ( context @@ -135,10 +126,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async ( clientSiteResourcesAssociationsCache.siteResourceId ) ) - .innerJoin( - networks, - eq(siteResources.networkId, networks.networkId) - ) + .innerJoin(networks, eq(siteResources.networkId, networks.networkId)) .innerJoin( siteNetworks, and( @@ -147,10 +135,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async ( ) ) .where( - eq( - clientSiteResourcesAssociationsCache.clientId, - client.clientId - ) + eq(clientSiteResourcesAssociationsCache.clientId, client.clientId) ); // Return connect message with all site configurations @@ -161,6 +146,9 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async ( siteId: site.siteId, name: site.name, endpoint: site.endpoint, + localEndpoints: site.localEndpoints + ? JSON.parse(site.localEndpoints) + : undefined, publicKey: site.publicKey, serverIP: site.address, serverPort: site.listenPort, @@ -170,7 +158,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async ( aliases: generateAliasConfig( allSiteResources.map(({ siteResources }) => siteResources) ), - chainId: chainId, + chainId: chainId } }, broadcast: false, diff --git a/server/routers/olm/handleOlmUnLocalMessage.ts b/server/routers/olm/handleOlmUnLocalMessage.ts new file mode 100644 index 000000000..55d312815 --- /dev/null +++ b/server/routers/olm/handleOlmUnLocalMessage.ts @@ -0,0 +1,95 @@ +import { db, exitNodes, sites } from "@server/db"; +import { MessageHandler } from "@server/routers/ws"; +import { clients, clientSitesAssociationsCache, Olm } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import { updatePeer as newtUpdatePeer } from "../newt/peers"; +import logger from "@server/logger"; + +export const handleOlmUnLocalMessage: MessageHandler = async (context) => { + const { message, client: c, sendToClient } = context; + const olm = c as Olm; + + logger.info("Handling unlocal olm message!"); + + if (!olm) { + logger.warn("Olm not found"); + return; + } + + if (!olm.clientId) { + logger.warn("Olm has no client!"); + return; + } + + const clientId = olm.clientId; + + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + logger.warn("Client not found"); + return; + } + + // make sure we hand endpoints for both the site and the client and the lastHolePunch is not too old + if (!client.pubKey) { + logger.warn("Client has no endpoint or listen port"); + return; + } + + const { siteId, chainId } = message.data; + + // Get the site + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!site) { + logger.warn("Site not found or has no exit node"); + return; + } + + const [clientSiteAssociation] = await db + .select() + .from(clientSitesAssociationsCache) + .where( + and( + eq(clientSitesAssociationsCache.clientId, olm.clientId), + eq(clientSitesAssociationsCache.siteId, siteId) + ) + ); + + if (!clientSiteAssociation) { + logger.warn("Client-Site association not found"); + return; + } + + if (!clientSiteAssociation.endpoint) { + logger.warn("Client-Site association has no endpoint, cannot unrelay"); + return; + } + + // update the peer on the newt + await newtUpdatePeer(siteId, client.pubKey, { + endpoint: clientSiteAssociation.isRelayed + ? "" + : clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the newt + }); + + return { + message: { + type: "olm/wg/peer/unlocal", + data: { + siteId: siteId, + chainId + } + }, + broadcast: false, + excludeSender: false + }; +}; diff --git a/server/routers/olm/handleOlmUnRelayMessage.ts b/server/routers/olm/handleOlmUnRelayMessage.ts index a7b426023..3f73a5834 100644 --- a/server/routers/olm/handleOlmUnRelayMessage.ts +++ b/server/routers/olm/handleOlmUnRelayMessage.ts @@ -77,9 +77,9 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => { return; } - // update the peer on the exit node + // update the peer on the newt await newtUpdatePeer(siteId, client.pubKey, { - endpoint: clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the exit node + endpoint: clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the newt }); return { diff --git a/server/routers/olm/index.ts b/server/routers/olm/index.ts index 5c151a8cf..e11d4e48e 100644 --- a/server/routers/olm/index.ts +++ b/server/routers/olm/index.ts @@ -13,3 +13,5 @@ export * from "./recoverOlmWithFingerprint"; export * from "./handleOlmDisconnectingMessage"; export * from "./handleOlmServerInitAddPeerHandshake"; export * from "./offlineChecker"; +export * from "./handleOlmUnLocalMessage"; +export * from "./handleOlmLocalMessage"; diff --git a/server/routers/olm/peers.ts b/server/routers/olm/peers.ts index 962d7367e..960c6ad23 100644 --- a/server/routers/olm/peers.ts +++ b/server/routers/olm/peers.ts @@ -18,6 +18,7 @@ export async function addPeer( serverPort: number | null; remoteSubnets: string[] | null; // optional, comma-separated list of subnets that this site can access aliases: Alias[]; + localEndpoints?: string[]; // optional, list of local endpoints for the peer }, olmId?: string, version?: string | null @@ -44,6 +45,7 @@ export async function addPeer( name: peer.name, publicKey: peer.publicKey, endpoint: peer.endpoint, + localEndpoints: peer.localEndpoints, relayEndpoint: peer.relayEndpoint, serverIP: peer.serverIP, serverPort: peer.serverPort, diff --git a/server/routers/policy/getResourcePolicy.ts b/server/routers/policy/getResourcePolicy.ts index d7513d58d..fafa31d7d 100644 --- a/server/routers/policy/getResourcePolicy.ts +++ b/server/routers/policy/getResourcePolicy.ts @@ -168,7 +168,7 @@ registry.registerPath({ path: "/org/{orgId}/resource-policy/{niceId}", description: "Get a resource policy by orgId and niceId. NiceId is a readable ID for the resource and unique on a per org basis.", - tags: [OpenAPITags.Org, OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicy], request: { params: z.object({ orgId: z.string(), @@ -182,7 +182,20 @@ registry.registerPath({ method: "get", path: "/resource-policy/{resourcePolicyId}", description: "Get a resource policy by its resourcePolicyId.", - tags: [OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicyLegacy], + request: { + params: z.object({ + resourcePolicyId: z.number() + }) + }, + responses: {} +}); + +registry.registerPath({ + method: "get", + path: "/public-resource-policy/{resourcePolicyId}", + description: "Get a resource policy by its resourcePolicyId.", + tags: [OpenAPITags.PublicResourcePolicy], request: { params: z.object({ resourcePolicyId: z.number() diff --git a/server/routers/policy/setResourcePolicyAccessControl.ts b/server/routers/policy/setResourcePolicyAccessControl.ts index 6c0e19b68..3af1c6eac 100644 --- a/server/routers/policy/setResourcePolicyAccessControl.ts +++ b/server/routers/policy/setResourcePolicyAccessControl.ts @@ -40,7 +40,66 @@ registry.registerPath({ path: "/resource-policy/{resourceId}/access-control", description: "Set access control users for a resource policy, including SSO, users, roles, Identity provider.", - tags: [OpenAPITags.Policy, OpenAPITags.User], + tags: [OpenAPITags.PublicResourcePolicyLegacy], + request: { + params: setResourcePolicyAccessControlParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyAcccessControlBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "post", + path: "/public-resource-policy/{resourceId}/access-control", + description: + "Set access control users for a resource policy, including SSO, users, roles, Identity provider.", + tags: [OpenAPITags.PublicResourcePolicy, OpenAPITags.User], + request: { + params: setResourcePolicyAccessControlParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyAcccessControlBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourceId}/access-control", + description: + "Set access control users for a resource policy, including SSO, users, roles, Identity provider. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicyLegacy], + request: { + params: setResourcePolicyAccessControlParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyAcccessControlBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/public-resource-policy/{resourceId}/access-control", + description: + "Set access control users for a resource policy, including SSO, users, roles, Identity provider. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicy, OpenAPITags.User], request: { params: setResourcePolicyAccessControlParamsSchema, body: { diff --git a/server/routers/policy/setResourcePolicyHeaderAuth.ts b/server/routers/policy/setResourcePolicyHeaderAuth.ts index 6dba0b8a7..6c9c2dc49 100644 --- a/server/routers/policy/setResourcePolicyHeaderAuth.ts +++ b/server/routers/policy/setResourcePolicyHeaderAuth.ts @@ -25,11 +25,70 @@ const setResourcePolicyHeaderAuthBodySchema = z.strictObject({ }); registry.registerPath({ - method: "put", + method: "post", path: "/resource-policy/{resourcePolicyId}/header-auth", description: "Set or update the header authentication for a resource policy. If user and password is not provided, it will remove the header authentication.", - tags: [OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicyLegacy], + request: { + params: setResourcePolicyHeaderAuthParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyHeaderAuthBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "post", + path: "/public-resource-policy/{resourcePolicyId}/header-auth", + description: + "Set or update the header authentication for a resource policy. If user and password is not provided, it will remove the header authentication.", + tags: [OpenAPITags.PublicResourcePolicy], + request: { + params: setResourcePolicyHeaderAuthParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyHeaderAuthBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourcePolicyId}/header-auth", + description: + "Set or update the header authentication for a resource policy. If user and password is not provided, it will remove the header authentication. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicyLegacy], + request: { + params: setResourcePolicyHeaderAuthParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyHeaderAuthBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/public-resource-policy/{resourcePolicyId}/header-auth", + description: + "Set or update the header authentication for a resource policy. If user and password is not provided, it will remove the header authentication. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicy], request: { params: setResourcePolicyHeaderAuthParamsSchema, body: { diff --git a/server/routers/policy/setResourcePolicyPassword.ts b/server/routers/policy/setResourcePolicyPassword.ts index dd436a7bf..1255245e8 100644 --- a/server/routers/policy/setResourcePolicyPassword.ts +++ b/server/routers/policy/setResourcePolicyPassword.ts @@ -20,11 +20,70 @@ const setResourcePolicyPasswordBodySchema = z.strictObject({ }); registry.registerPath({ - method: "put", + method: "post", path: "/resource-policy/{resourcePolicyId}/password", description: "Set the password for a resource policy. Setting the password to null will remove it.", - tags: [OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicyLegacy], + request: { + params: setResourcePolicyPasswordParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyPasswordBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "post", + path: "/public-resource-policy/{resourcePolicyId}/password", + description: + "Set the password for a resource policy. Setting the password to null will remove it.", + tags: [OpenAPITags.PublicResourcePolicy], + request: { + params: setResourcePolicyPasswordParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyPasswordBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourcePolicyId}/password", + description: + "Set the password for a resource policy. Setting the password to null will remove it. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicyLegacy], + request: { + params: setResourcePolicyPasswordParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyPasswordBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/public-resource-policy/{resourcePolicyId}/password", + description: + "Set the password for a resource policy. Setting the password to null will remove it. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicy], request: { params: setResourcePolicyPasswordParamsSchema, body: { diff --git a/server/routers/policy/setResourcePolicyPincode.ts b/server/routers/policy/setResourcePolicyPincode.ts index f99691275..6537a3933 100644 --- a/server/routers/policy/setResourcePolicyPincode.ts +++ b/server/routers/policy/setResourcePolicyPincode.ts @@ -23,11 +23,70 @@ const setResourcePolicyPincodeBodySchema = z.strictObject({ }); registry.registerPath({ - method: "put", + method: "post", path: "/resource-policy/{resourcePolicyId}/pincode", description: "Set the PIN code for a resource policy. Setting the PIN code to null will remove it.", - tags: [OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicyLegacy], + request: { + params: setResourcePolicyPincodeParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyPincodeBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "post", + path: "/public-resource-policy/{resourcePolicyId}/pincode", + description: + "Set the PIN code for a resource policy. Setting the PIN code to null will remove it.", + tags: [OpenAPITags.PublicResourcePolicy], + request: { + params: setResourcePolicyPincodeParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyPincodeBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourcePolicyId}/pincode", + description: + "Set the PIN code for a resource policy. Setting the PIN code to null will remove it. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicyLegacy], + request: { + params: setResourcePolicyPincodeParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyPincodeBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/public-resource-policy/{resourcePolicyId}/pincode", + description: + "Set the PIN code for a resource policy. Setting the PIN code to null will remove it. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicy], request: { params: setResourcePolicyPincodeParamsSchema, body: { diff --git a/server/routers/policy/setResourcePolicyRules.ts b/server/routers/policy/setResourcePolicyRules.ts index dfcfb7cdb..8cb5f4e09 100644 --- a/server/routers/policy/setResourcePolicyRules.ts +++ b/server/routers/policy/setResourcePolicyRules.ts @@ -43,11 +43,70 @@ const setResourcePolicyRulesParamsSchema = z.strictObject({ }); registry.registerPath({ - method: "put", + method: "post", path: "/resource-policy/{resourcePolicyId}/rules", description: "Set all rules for a resource policy at once. This will replace all existing rules.", - tags: [OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicyLegacy], + request: { + params: setResourcePolicyRulesParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyRulesBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "post", + path: "/public-resource-policy/{resourcePolicyId}/rules", + description: + "Set all rules for a resource policy at once. This will replace all existing rules.", + tags: [OpenAPITags.PublicResourcePolicy], + request: { + params: setResourcePolicyRulesParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyRulesBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourcePolicyId}/rules", + description: + "Set all rules for a resource policy at once. This will replace all existing rules. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicyLegacy], + request: { + params: setResourcePolicyRulesParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyRulesBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/public-resource-policy/{resourcePolicyId}/rules", + description: + "Set all rules for a resource policy at once. This will replace all existing rules. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicy], request: { params: setResourcePolicyRulesParamsSchema, body: { diff --git a/server/routers/policy/setResourcePolicyWhitelist.ts b/server/routers/policy/setResourcePolicyWhitelist.ts index 0bffacec2..2cdd94e98 100644 --- a/server/routers/policy/setResourcePolicyWhitelist.ts +++ b/server/routers/policy/setResourcePolicyWhitelist.ts @@ -28,11 +28,70 @@ const setResourcePolicyWhitelistParamsSchema = z.strictObject({ }); registry.registerPath({ - method: "put", + method: "post", path: "/resource-policy/{resourcePolicyId}/whitelist", description: "Set email whitelist for a resource policy. This will replace all existing emails.", - tags: [OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicyLegacy], + request: { + params: setResourcePolicyWhitelistParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyWhitelistBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "post", + path: "/public-resource-policy/{resourcePolicyId}/whitelist", + description: + "Set email whitelist for a resource policy. This will replace all existing emails.", + tags: [OpenAPITags.PublicResourcePolicy], + request: { + params: setResourcePolicyWhitelistParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyWhitelistBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourcePolicyId}/whitelist", + description: + "Set email whitelist for a resource policy. This will replace all existing emails. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicyLegacy], + request: { + params: setResourcePolicyWhitelistParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyWhitelistBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/public-resource-policy/{resourcePolicyId}/whitelist", + description: + "Set email whitelist for a resource policy. This will replace all existing emails. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicy], request: { params: setResourcePolicyWhitelistParamsSchema, body: { diff --git a/server/routers/policy/updateResourcePolicy.ts b/server/routers/policy/updateResourcePolicy.ts index ad8b19639..f42f1bc74 100644 --- a/server/routers/policy/updateResourcePolicy.ts +++ b/server/routers/policy/updateResourcePolicy.ts @@ -19,10 +19,66 @@ const updateResourcePolicyBodySchema = z.strictObject({ }); registry.registerPath({ - method: "put", + method: "post", path: "/resource-policy/{resourcePolicyId}", description: "Update a resource policy.", - tags: [OpenAPITags.Org, OpenAPITags.Policy], + tags: [OpenAPITags.PublicResourcePolicy], + request: { + params: updateResourcePolicyParamsSchema, + body: { + content: { + "application/json": { + schema: updateResourcePolicyBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "post", + path: "/public-resource-policy/{resourcePolicyId}", + description: "Update a resource policy.", + tags: [OpenAPITags.PublicResourcePolicy], + request: { + params: updateResourcePolicyParamsSchema, + body: { + content: { + "application/json": { + schema: updateResourcePolicyBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourcePolicyId}", + description: "Update a resource policy. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicy], + request: { + params: updateResourcePolicyParamsSchema, + body: { + content: { + "application/json": { + schema: updateResourcePolicyBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/public-resource-policy/{resourcePolicyId}", + description: "Update a resource policy. Deprecated: use POST instead.", + deprecated: true, + tags: [OpenAPITags.PublicResourcePolicy], request: { params: updateResourcePolicyParamsSchema, body: { diff --git a/server/routers/resource/addEmailToResourceWhitelist.ts b/server/routers/resource/addEmailToResourceWhitelist.ts index 6d5ef22cd..97eee29b8 100644 --- a/server/routers/resource/addEmailToResourceWhitelist.ts +++ b/server/routers/resource/addEmailToResourceWhitelist.ts @@ -34,6 +34,39 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/whitelist/add", description: "Add a single email to the resource whitelist.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: addEmailToResourceWhitelistParamsSchema, + body: { + content: { + "application/json": { + schema: addEmailToResourceWhitelistBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/whitelist/add", + description: "Add a single email to the resource whitelist.", tags: [OpenAPITags.PublicResource], request: { params: addEmailToResourceWhitelistParamsSchema, @@ -144,10 +177,7 @@ export async function addEmailToResourceWhitelist( .from(resourcePolicyWhiteList) .where( and( - eq( - resourcePolicyWhiteList.resourcePolicyId, - policyId - ), + eq(resourcePolicyWhiteList.resourcePolicyId, policyId), eq(resourcePolicyWhiteList.email, email) ) ); diff --git a/server/routers/resource/addRoleToResource.ts b/server/routers/resource/addRoleToResource.ts index a1d493631..9c5b6f0c6 100644 --- a/server/routers/resource/addRoleToResource.ts +++ b/server/routers/resource/addRoleToResource.ts @@ -28,6 +28,40 @@ const addRoleToResourceParamsSchema = z registry.registerPath({ method: "post", path: "/resource/{resourceId}/roles/add", + description: + "Add a single role to a resource. When the resource has an inline policy defined (no shared resource policy assigned), the role is added to the inline policy instead of directly to the resource.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: addRoleToResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addRoleToResourceBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/roles/add", description: "Add a single role to a resource. When the resource has an inline policy defined (no shared resource policy assigned), the role is added to the inline policy instead of directly to the resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.Role], diff --git a/server/routers/resource/addUserToResource.ts b/server/routers/resource/addUserToResource.ts index ebe43fccd..e43a1e324 100644 --- a/server/routers/resource/addUserToResource.ts +++ b/server/routers/resource/addUserToResource.ts @@ -28,6 +28,40 @@ const addUserToResourceParamsSchema = z registry.registerPath({ method: "post", path: "/resource/{resourceId}/users/add", + description: + "Add a single user to a resource. When the resource has an inline policy defined (no shared resource policy assigned), the user is added to the inline policy instead of directly to the resource.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: addUserToResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addUserToResourceBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/users/add", description: "Add a single user to a resource. When the resource has an inline policy defined (no shared resource policy assigned), the user is added to the inline policy instead of directly to the resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.User], diff --git a/server/routers/resource/authWithAccessToken.ts b/server/routers/resource/authWithAccessToken.ts index 195dbafe2..2e625b6a0 100644 --- a/server/routers/resource/authWithAccessToken.ts +++ b/server/routers/resource/authWithAccessToken.ts @@ -1,6 +1,6 @@ import { generateSessionToken } from "@server/auth/sessions/app"; import { db } from "@server/db"; -import { Resource, resources } from "@server/db"; +import { Resource, resources, users } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq } from "drizzle-orm"; @@ -156,11 +156,42 @@ export async function authWithAccessToken( doNotExtend: true }); + let accessAuditUser: { username: string; userId: string } | undefined; + if (tokenItem.userId) { + const [associatedUser] = await db + .select({ + userId: users.userId, + username: users.username + }) + .from(users) + .where(eq(users.userId, tokenItem.userId)) + .limit(1); + if (associatedUser) { + accessAuditUser = { + userId: associatedUser.userId, + username: associatedUser.username + }; + } + } + logAccessAudit({ orgId: resource.orgId, resourceId: resource.resourceId, action: true, type: "accessToken", + apiKey: accessAuditUser + ? undefined + : { + name: tokenItem.title, + apiKeyId: tokenItem.accessTokenId + }, + user: accessAuditUser, + metadata: accessAuditUser + ? { + accessTokenId: tokenItem.accessTokenId, + accessTokenTitle: tokenItem.title + } + : undefined, userAgent: req.headers["user-agent"], requestIp: req.ip }); diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 81c5950fb..ab758cc1c 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -153,6 +153,39 @@ registry.registerPath({ method: "put", path: "/org/{orgId}/resource", description: "Create a resource.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: createResourceParamsSchema, + body: { + content: { + "application/json": { + schema: createHttpResourceSchema.or(createRawResourceSchema) + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/public-resource", + description: "Create a resource.", tags: [OpenAPITags.PublicResource], request: { params: createResourceParamsSchema, diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts index 9d2261338..4cb9967ed 100644 --- a/server/routers/resource/createResourceRule.ts +++ b/server/routers/resource/createResourceRule.ts @@ -9,16 +9,14 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { - isValidCIDR, - isValidIP, - isValidUrlGlobPattern + RESOURCE_RULE_MATCH_TYPES, + getResourceRuleValueValidationError } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; -import { isValidRegionId } from "@server/db/regions"; const createResourceRuleSchema = z.strictObject({ action: z.enum(["ACCEPT", "DROP", "PASS"]), - match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN", "REGION"]), + match: z.enum(RESOURCE_RULE_MATCH_TYPES), value: z.string().min(1), priority: z.int(), enabled: z.boolean().optional() @@ -32,6 +30,39 @@ registry.registerPath({ method: "put", path: "/resource/{resourceId}/rule", description: "Create a resource rule.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: createResourceRuleParamsSchema, + body: { + content: { + "application/json": { + schema: createResourceRuleSchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "put", + path: "/public-resource/{resourceId}/rule", + description: "Create a resource rule.", tags: [OpenAPITags.PublicResource, OpenAPITags.Rule], request: { params: createResourceRuleParamsSchema, @@ -118,39 +149,14 @@ export async function createResourceRule( ); } - if (match === "CIDR") { - if (!isValidCIDR(value)) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid CIDR provided" - ) - ); - } - } else if (match === "IP") { - if (!isValidIP(value)) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided") - ); - } - } else if (match === "PATH") { - if (!isValidUrlGlobPattern(value)) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid URL glob pattern provided" - ) - ); - } - } else if (match === "REGION") { - if (!isValidRegionId(value)) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid region ID provided" - ) - ); - } + const valueValidationError = getResourceRuleValueValidationError( + match, + value + ); + if (valueValidationError) { + return next( + createHttpError(HttpCode.BAD_REQUEST, valueValidationError) + ); } // Create the new resource rule diff --git a/server/routers/resource/createResourceSessionToken.ts b/server/routers/resource/createResourceSessionToken.ts deleted file mode 100644 index 82f0d0cbf..000000000 --- a/server/routers/resource/createResourceSessionToken.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db } from "@server/db"; -import { resources, users, userOrgs } from "@server/db"; -import { eq, and } from "drizzle-orm"; -import { createResourceSession } from "@server/auth/sessions/resource"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import { fromError } from "zod-validation-error"; -import logger from "@server/logger"; -import { createSession, generateSessionToken } from "@server/auth/sessions/app"; -import { response } from "@server/lib/response"; - -const createResourceSessionTokenParams = z.strictObject({ - resourceId: z.coerce.number().int().positive() -}); - -const createResourceSessionTokenBody = z.strictObject({ - userId: z.string().nonempty(), - idpId: z.coerce.number().int().positive().optional() -}); - -export type CreateResourceSessionTokenResponse = { - requestToken: string; -}; - -export async function createResourceSessionToken( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const parsedParams = createResourceSessionTokenParams.safeParse( - req.params - ); - if (!parsedParams.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString() - ) - ); - } - - const parsedBody = createResourceSessionTokenBody.safeParse(req.body); - if (!parsedBody.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString() - ) - ); - } - - const { resourceId } = parsedParams.data; - const { userId, idpId } = parsedBody.data; - - const [resource] = await db - .select() - .from(resources) - .where(eq(resources.resourceId, resourceId)) - .limit(1); - - if (!resource) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Resource with ID ${resourceId} not found` - ) - ); - } - - const candidates = await db - .select({ userId: users.userId }) - .from(userOrgs) - .innerJoin(users, eq(userOrgs.userId, users.userId)) - .where( - and( - eq(users.userId, userId), - eq(userOrgs.orgId, resource.orgId) - ) - ); - - if (candidates.length === 0) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `User not found in the organization that owns this resource` - ) - ); - } - - if (candidates.length > 1) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Multiple users match this username (external users from different identity providers). Specify idpId to disambiguate." - ) - ); - } - - const targetUserId = candidates[0].userId; - - const appSessionToken = generateSessionToken(); - const appSession = await createSession(appSessionToken, targetUserId); - - const requestToken = generateSessionToken(); - await createResourceSession({ - resourceId, - token: requestToken, - userSessionId: appSession.sessionId, - isRequestToken: true, - expiresAt: Date.now() + 1000 * 30, // 30 seconds - sessionLength: 1000 * 30, - doNotExtend: true - }); - - logger.debug("Resource session token created successfully"); - - return response(res, { - data: { requestToken }, - success: true, - error: false, - message: "Resource session token created successfully", - status: HttpCode.OK - }); - } catch (error) { - logger.error(error); - return next( - createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") - ); - } -} diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index d80817360..5d42761de 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -22,6 +22,32 @@ registry.registerPath({ method: "delete", path: "/resource/{resourceId}", description: "Delete a resource.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: deleteResourceSchema + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "delete", + path: "/public-resource/{resourceId}", + description: "Delete a resource.", tags: [OpenAPITags.PublicResource], request: { params: deleteResourceSchema diff --git a/server/routers/resource/deleteResourceRule.ts b/server/routers/resource/deleteResourceRule.ts index c619a693e..d7b7c2a65 100644 --- a/server/routers/resource/deleteResourceRule.ts +++ b/server/routers/resource/deleteResourceRule.ts @@ -19,6 +19,32 @@ registry.registerPath({ method: "delete", path: "/resource/{resourceId}/rule/{ruleId}", description: "Delete a resource rule.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: deleteResourceRuleSchema + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "delete", + path: "/public-resource/{resourceId}/rule/{ruleId}", + description: "Delete a resource rule.", tags: [OpenAPITags.PublicResource, OpenAPITags.Rule], request: { params: deleteResourceRuleSchema diff --git a/server/routers/resource/getResource.ts b/server/routers/resource/getResource.ts index e139ee58f..b1f4108b3 100644 --- a/server/routers/resource/getResource.ts +++ b/server/routers/resource/getResource.ts @@ -63,7 +63,7 @@ registry.registerPath({ path: "/org/{orgId}/resource/{niceId}", description: "Get a resource by orgId and niceId. NiceId is a readable ID for the resource and unique on a per org basis.", - tags: [OpenAPITags.PublicResource], + tags: [OpenAPITags.PublicResourceLegacy], request: { params: z.object({ orgId: z.string(), @@ -92,6 +92,34 @@ registry.registerPath({ method: "get", path: "/resource/{resourceId}", description: "Get a resource by resourceId.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: z.object({ + resourceId: z.number() + }) + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "get", + path: "/public-resource/{resourceId}", + description: "Get a resource by resourceId.", tags: [OpenAPITags.PublicResource], request: { params: z.object({ diff --git a/server/routers/resource/getResourcePolicies.ts b/server/routers/resource/getResourcePolicies.ts index 6742a0bd5..b87c6caf6 100644 --- a/server/routers/resource/getResourcePolicies.ts +++ b/server/routers/resource/getResourcePolicies.ts @@ -25,8 +25,21 @@ export type GetResourcePoliciesResponse = { registry.registerPath({ method: "get", path: "/resource/{resourceId}/policies", - description: "Get the inline and shared policies associated with a resource.", - tags: [OpenAPITags.PublicResource, OpenAPITags.Policy], + description: + "Get the inline and shared policies associated with a resource.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: getResourcePoliciesParamsSchema + }, + responses: {} +}); + +registry.registerPath({ + method: "get", + path: "/public-resource/{resourceId}/policies", + description: + "Get the inline and shared policies associated with a resource.", + tags: [OpenAPITags.PublicResource, OpenAPITags.PublicResourcePolicy], request: { params: getResourcePoliciesParamsSchema }, diff --git a/server/routers/resource/getResourceWhitelist.ts b/server/routers/resource/getResourceWhitelist.ts index 0bebe6a71..ca8b5d9ee 100644 --- a/server/routers/resource/getResourceWhitelist.ts +++ b/server/routers/resource/getResourceWhitelist.ts @@ -44,6 +44,32 @@ registry.registerPath({ method: "get", path: "/resource/{resourceId}/whitelist", description: "Get the whitelist of emails for a specific resource.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: getResourceWhitelistSchema + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "get", + path: "/public-resource/{resourceId}/whitelist", + description: "Get the whitelist of emails for a specific resource.", tags: [OpenAPITags.PublicResource], request: { params: getResourceWhitelistSchema diff --git a/server/routers/resource/getStatusHistory.ts b/server/routers/resource/getStatusHistory.ts index c3dcf6c88..19e11b78f 100644 --- a/server/routers/resource/getStatusHistory.ts +++ b/server/routers/resource/getStatusHistory.ts @@ -42,9 +42,14 @@ export async function getResourceStatusHistory( const entityType = "resource"; const entityId = parsedParams.data.resourceId; - const { days } = parsedQuery.data; + const { days, tzOffsetMinutes } = parsedQuery.data; - const data = await getCachedStatusHistory(entityType, entityId, days); + const data = await getCachedStatusHistory( + entityType, + entityId, + days, + tzOffsetMinutes + ); return response(res, { data, diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 4bc9d20f0..83dbdea2a 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -17,7 +17,6 @@ export * from "./getResourceWhitelist"; export * from "./authWithWhitelist"; export * from "./authWithAccessToken"; export * from "./getExchangeToken"; -export * from "./createResourceSessionToken"; export * from "./createResourceRule"; export * from "./deleteResourceRule"; export * from "./listResourceRules"; diff --git a/server/routers/resource/listAllResourceNames.ts b/server/routers/resource/listAllResourceNames.ts index e38a3fd69..ba52a2f88 100644 --- a/server/routers/resource/listAllResourceNames.ts +++ b/server/routers/resource/listAllResourceNames.ts @@ -33,6 +33,34 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/resources-names", description: "List all resource names for an organization.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: z.object({ + orgId: z.string() + }) + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/public-resource-names", + description: "List all resource names for an organization.", tags: [OpenAPITags.PublicResource], request: { params: z.object({ diff --git a/server/routers/resource/listResourceRoles.ts b/server/routers/resource/listResourceRoles.ts index ea4d07f64..f5b84cbf9 100644 --- a/server/routers/resource/listResourceRoles.ts +++ b/server/routers/resource/listResourceRoles.ts @@ -48,6 +48,32 @@ registry.registerPath({ method: "get", path: "/resource/{resourceId}/roles", description: "List all roles for a resource.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: listResourceRolesSchema + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "get", + path: "/public-resource/{resourceId}/roles", + description: "List all roles for a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.Role], request: { params: listResourceRolesSchema diff --git a/server/routers/resource/listResourceRules.ts b/server/routers/resource/listResourceRules.ts index ec4cc332e..6b32f82bf 100644 --- a/server/routers/resource/listResourceRules.ts +++ b/server/routers/resource/listResourceRules.ts @@ -71,6 +71,33 @@ registry.registerPath({ method: "get", path: "/resource/{resourceId}/rules", description: "List rules for a resource.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: listResourceRulesParamsSchema, + query: listResourceRulesSchema + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "get", + path: "/public-resource/{resourceId}/rules", + description: "List rules for a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.Rule], request: { params: listResourceRulesParamsSchema, diff --git a/server/routers/resource/listResourceUsers.ts b/server/routers/resource/listResourceUsers.ts index c99dbf0ad..afabd3052 100644 --- a/server/routers/resource/listResourceUsers.ts +++ b/server/routers/resource/listResourceUsers.ts @@ -38,6 +38,32 @@ registry.registerPath({ method: "get", path: "/resource/{resourceId}/users", description: "List all users for a resource.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: listResourceUsersSchema + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "get", + path: "/public-resource/{resourceId}/users", + description: "List all users for a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.User], request: { params: listResourceUsersSchema diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 9567b5bcd..cc075f23b 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 === "") { @@ -400,6 +409,35 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/resources", description: "List resources for an organization.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: z.object({ + orgId: z.string() + }), + query: listResourcesSchema + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/public-resources", + description: "List resources for an organization.", tags: [OpenAPITags.PublicResource], request: { params: z.object({ @@ -451,6 +489,7 @@ export async function listResources( sort_by, order, siteId, + status, labels: labelFilter } = parsedQuery.data; @@ -660,6 +699,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/resource/removeEmailFromResourceWhitelist.ts b/server/routers/resource/removeEmailFromResourceWhitelist.ts index 03d961335..58631a5f3 100644 --- a/server/routers/resource/removeEmailFromResourceWhitelist.ts +++ b/server/routers/resource/removeEmailFromResourceWhitelist.ts @@ -34,6 +34,39 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/whitelist/remove", description: "Remove a single email from the resource whitelist.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: removeEmailFromResourceWhitelistParamsSchema, + body: { + content: { + "application/json": { + schema: removeEmailFromResourceWhitelistBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/whitelist/remove", + description: "Remove a single email from the resource whitelist.", tags: [OpenAPITags.PublicResource], request: { params: removeEmailFromResourceWhitelistParamsSchema, @@ -143,10 +176,7 @@ export async function removeEmailFromResourceWhitelist( .from(resourcePolicyWhiteList) .where( and( - eq( - resourcePolicyWhiteList.resourcePolicyId, - policyId - ), + eq(resourcePolicyWhiteList.resourcePolicyId, policyId), eq(resourcePolicyWhiteList.email, email) ) ); @@ -164,10 +194,7 @@ export async function removeEmailFromResourceWhitelist( .delete(resourcePolicyWhiteList) .where( and( - eq( - resourcePolicyWhiteList.resourcePolicyId, - policyId - ), + eq(resourcePolicyWhiteList.resourcePolicyId, policyId), eq(resourcePolicyWhiteList.email, email) ) ); diff --git a/server/routers/resource/removeRoleFromResource.ts b/server/routers/resource/removeRoleFromResource.ts index 0c4d811e2..4c91773f1 100644 --- a/server/routers/resource/removeRoleFromResource.ts +++ b/server/routers/resource/removeRoleFromResource.ts @@ -29,6 +29,39 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/roles/remove", description: "Remove a single role from a resource.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: removeRoleFromResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeRoleFromResourceBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/roles/remove", + description: "Remove a single role from a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.Role], request: { params: removeRoleFromResourceParamsSchema, diff --git a/server/routers/resource/removeUserFromResource.ts b/server/routers/resource/removeUserFromResource.ts index 43a22c904..d48f31983 100644 --- a/server/routers/resource/removeUserFromResource.ts +++ b/server/routers/resource/removeUserFromResource.ts @@ -29,6 +29,39 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/users/remove", description: "Remove a single user from a resource.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: removeUserFromResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeUserFromResourceBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/users/remove", + description: "Remove a single user from a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.User], request: { params: removeUserFromResourceParamsSchema, diff --git a/server/routers/resource/setResourceHeaderAuth.ts b/server/routers/resource/setResourceHeaderAuth.ts index 6c5ee1788..c618a2902 100644 --- a/server/routers/resource/setResourceHeaderAuth.ts +++ b/server/routers/resource/setResourceHeaderAuth.ts @@ -29,6 +29,40 @@ const setResourceAuthMethodsBodySchema = z.strictObject({ registry.registerPath({ method: "post", path: "/resource/{resourceId}/header-auth", + description: + "Set or update the header authentication for a resource. If user and password is not provided, it will remove the header authentication.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: setResourceAuthMethodsParamsSchema, + body: { + content: { + "application/json": { + schema: setResourceAuthMethodsBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/header-auth", description: "Set or update the header authentication for a resource. If user and password is not provided, it will remove the header authentication.", tags: [OpenAPITags.PublicResource], diff --git a/server/routers/resource/setResourcePassword.ts b/server/routers/resource/setResourcePassword.ts index b52ebfe2c..8a74823a9 100644 --- a/server/routers/resource/setResourcePassword.ts +++ b/server/routers/resource/setResourcePassword.ts @@ -27,6 +27,40 @@ const setResourceAuthMethodsBodySchema = z.strictObject({ registry.registerPath({ method: "post", path: "/resource/{resourceId}/password", + description: + "Set the password for a resource. Setting the password to null will remove it.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: setResourceAuthMethodsParamsSchema, + body: { + content: { + "application/json": { + schema: setResourceAuthMethodsBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/password", description: "Set the password for a resource. Setting the password to null will remove it.", tags: [OpenAPITags.PublicResource], diff --git a/server/routers/resource/setResourcePincode.ts b/server/routers/resource/setResourcePincode.ts index 577b11dbf..033b226e1 100644 --- a/server/routers/resource/setResourcePincode.ts +++ b/server/routers/resource/setResourcePincode.ts @@ -27,6 +27,40 @@ const setResourceAuthMethodsBodySchema = z.strictObject({ registry.registerPath({ method: "post", path: "/resource/{resourceId}/pincode", + description: + "Set the PIN code for a resource. Setting the PIN code to null will remove it.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: setResourceAuthMethodsParamsSchema, + body: { + content: { + "application/json": { + schema: setResourceAuthMethodsBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/pincode", description: "Set the PIN code for a resource. Setting the PIN code to null will remove it.", tags: [OpenAPITags.PublicResource], diff --git a/server/routers/resource/setResourceRoles.ts b/server/routers/resource/setResourceRoles.ts index 681e00650..d21cca478 100644 --- a/server/routers/resource/setResourceRoles.ts +++ b/server/routers/resource/setResourceRoles.ts @@ -21,6 +21,40 @@ const setResourceRolesParamsSchema = z.strictObject({ registry.registerPath({ method: "post", path: "/resource/{resourceId}/roles", + description: + "Set roles for a resource. This will replace all existing roles. When the resource has an inline policy defined (no shared resource policy assigned), roles are set on the inline policy instead of directly on the resource.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: setResourceRolesParamsSchema, + body: { + content: { + "application/json": { + schema: setResourceRolesBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/roles", description: "Set roles for a resource. This will replace all existing roles. When the resource has an inline policy defined (no shared resource policy assigned), roles are set on the inline policy instead of directly on the resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.Role], diff --git a/server/routers/resource/setResourceUsers.ts b/server/routers/resource/setResourceUsers.ts index d292ccba2..0ae833071 100644 --- a/server/routers/resource/setResourceUsers.ts +++ b/server/routers/resource/setResourceUsers.ts @@ -21,6 +21,40 @@ const setUserResourcesParamsSchema = z.strictObject({ registry.registerPath({ method: "post", path: "/resource/{resourceId}/users", + description: + "Set users for a resource. This will replace all existing users. When the resource has an inline policy defined (no shared resource policy assigned), users are set on the inline policy instead of directly on the resource.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: setUserResourcesParamsSchema, + body: { + content: { + "application/json": { + schema: setUserResourcesBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/users", description: "Set users for a resource. This will replace all existing users. When the resource has an inline policy defined (no shared resource policy assigned), users are set on the inline policy instead of directly on the resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.User], diff --git a/server/routers/resource/setResourceWhitelist.ts b/server/routers/resource/setResourceWhitelist.ts index 10bf5b480..e6df96de8 100644 --- a/server/routers/resource/setResourceWhitelist.ts +++ b/server/routers/resource/setResourceWhitelist.ts @@ -35,6 +35,40 @@ const setResourceWhitelistParamsSchema = z.strictObject({ registry.registerPath({ method: "post", path: "/resource/{resourceId}/whitelist", + description: + "Set email whitelist for a resource. This will replace all existing emails.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: setResourceWhitelistParamsSchema, + body: { + content: { + "application/json": { + schema: setResourceWhitelistBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/whitelist", description: "Set email whitelist for a resource. This will replace all existing emails.", tags: [OpenAPITags.PublicResource], diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 35995d517..f3e0c3909 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -240,6 +240,42 @@ const updateRawResourceBodySchema = z registry.registerPath({ method: "post", path: "/resource/{resourceId}", + description: + "Update a resource. Policy fields (sso, mfa, pincode, password, whitelist) update the inline policy when no shared resource policy is assigned; when a shared policy is assigned those fields override the shared policy for this resource only.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: updateResourceParamsSchema, + body: { + content: { + "application/json": { + schema: updateHttpResourceBodySchema.and( + updateRawResourceBodySchema + ) + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}", description: "Update a resource. Policy fields (sso, mfa, pincode, password, whitelist) update the inline policy when no shared resource policy is assigned; when a shared policy is assigned those fields override the shared policy for this resource only.", tags: [OpenAPITags.PublicResource], diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index c724d00d2..5eba8aaa0 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -9,12 +9,11 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { - isValidCIDR, - isValidIP, - isValidUrlGlobPattern + RESOURCE_RULE_MATCH_TYPES, + getResourceRuleValueValidationError, + ResourceRuleMatchType } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; -import { isValidRegionId } from "@server/db/regions"; // Define Zod schema for request parameters validation const updateResourceRuleParamsSchema = z.strictObject({ @@ -22,15 +21,7 @@ const updateResourceRuleParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); -const resourceRuleMatchSchema = z.enum([ - "CIDR", - "IP", - "PATH", - "COUNTRY", - "COUNTRY_IS_NOT", - "ASN", - "REGION" -]); +const resourceRuleMatchSchema = z.enum(RESOURCE_RULE_MATCH_TYPES); // Define Zod schema for request body validation const updateResourceRuleSchema = z @@ -49,6 +40,39 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/rule/{ruleId}", description: "Update a resource rule.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: updateResourceRuleParamsSchema, + body: { + content: { + "application/json": { + schema: updateResourceRuleSchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/public-resource/{resourceId}/rule/{ruleId}", + description: "Update a resource rule.", tags: [OpenAPITags.PublicResource, OpenAPITags.Rule], request: { params: updateResourceRuleParamsSchema, @@ -140,14 +164,7 @@ export async function updateResourceRule( resource.resourcePolicyId === null && resource.defaultResourcePolicyId !== null; - let existingMatch: - | "CIDR" - | "IP" - | "PATH" - | "COUNTRY" - | "COUNTRY_IS_NOT" - | "ASN" - | "REGION"; + let existingMatch: ResourceRuleMatchType; if (isInlinePolicy) { const policyId = resource.defaultResourcePolicyId!; @@ -231,42 +248,14 @@ export async function updateResourceRule( const { value } = updateData; if (value !== undefined) { - if (match === "CIDR") { - if (!isValidCIDR(value)) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid CIDR provided" - ) - ); - } - } else if (match === "IP") { - if (!isValidIP(value)) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid IP provided" - ) - ); - } - } else if (match === "PATH") { - if (!isValidUrlGlobPattern(value)) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid URL glob pattern provided" - ) - ); - } - } else if (match === "REGION") { - if (!isValidRegionId(value)) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid region ID provided" - ) - ); - } + const valueValidationError = getResourceRuleValueValidationError( + match, + value + ); + if (valueValidationError) { + return next( + createHttpError(HttpCode.BAD_REQUEST, valueValidationError) + ); } } 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/getStatusHistory.ts b/server/routers/site/getStatusHistory.ts index 26f1dbbd2..d281961e9 100644 --- a/server/routers/site/getStatusHistory.ts +++ b/server/routers/site/getStatusHistory.ts @@ -42,9 +42,14 @@ export async function getSiteStatusHistory( const entityType = "site"; const entityId = parsedParams.data.siteId; - const { days } = parsedQuery.data; + const { days, tzOffsetMinutes } = parsedQuery.data; - const data = await getCachedStatusHistory(entityType, entityId, days); + const data = await getCachedStatusHistory( + entityType, + entityId, + days, + tzOffsetMinutes + ); return response(res, { data, 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/listSites.ts b/server/routers/site/listSites.ts index 1f8e41f26..cdc3a5630 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -177,7 +177,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/sites", description: "List all sites in an organization", - tags: [OpenAPITags.Org, OpenAPITags.Site], + tags: [OpenAPITags.Site], request: { params: listSitesParamsSchema, query: listSitesSchema 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/addClientToSiteResource.ts b/server/routers/siteResource/addClientToSiteResource.ts index 5e81cf8f7..c9ead2cbd 100644 --- a/server/routers/siteResource/addClientToSiteResource.ts +++ b/server/routers/siteResource/addClientToSiteResource.ts @@ -31,6 +31,40 @@ const addClientToSiteResourceParamsSchema = z registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/clients/add", + description: + "Add a single client to a site resource. Clients with a userId cannot be added.", + tags: [OpenAPITags.PrivateResourceLegacy], + request: { + params: addClientToSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addClientToSiteResourceBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/private-resource/{siteResourceId}/clients/add", description: "Add a single client to a site resource. Clients with a userId cannot be added.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], diff --git a/server/routers/siteResource/addRoleToSiteResource.ts b/server/routers/siteResource/addRoleToSiteResource.ts index d3dd164cc..2cdae780c 100644 --- a/server/routers/siteResource/addRoleToSiteResource.ts +++ b/server/routers/siteResource/addRoleToSiteResource.ts @@ -33,6 +33,39 @@ registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/roles/add", description: "Add a single role to a site resource.", + tags: [OpenAPITags.PrivateResourceLegacy], + request: { + params: addRoleToSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addRoleToSiteResourceBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/private-resource/{siteResourceId}/roles/add", + description: "Add a single role to a site resource.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], request: { params: addRoleToSiteResourceParamsSchema, diff --git a/server/routers/siteResource/addUserToSiteResource.ts b/server/routers/siteResource/addUserToSiteResource.ts index 0a93868ae..526494265 100644 --- a/server/routers/siteResource/addUserToSiteResource.ts +++ b/server/routers/siteResource/addUserToSiteResource.ts @@ -33,6 +33,39 @@ registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/users/add", description: "Add a single user to a site resource.", + tags: [OpenAPITags.PrivateResourceLegacy], + request: { + params: addUserToSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addUserToSiteResourceBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/private-resource/{siteResourceId}/users/add", + description: "Add a single user to a site resource.", tags: [OpenAPITags.PrivateResource, OpenAPITags.User], request: { params: addUserToSiteResourceParamsSchema, diff --git a/server/routers/siteResource/batchAddClientToSiteResources.ts b/server/routers/siteResource/batchAddClientToSiteResources.ts index 3eb282310..6fde3b55a 100644 --- a/server/routers/siteResource/batchAddClientToSiteResources.ts +++ b/server/routers/siteResource/batchAddClientToSiteResources.ts @@ -38,6 +38,39 @@ registry.registerPath({ method: "post", path: "/client/{clientId}/site-resources", description: "Add a machine client to multiple site resources at once.", + tags: [OpenAPITags.PrivateResourceLegacy], + request: { + params: batchAddClientToSiteResourcesParamsSchema, + body: { + content: { + "application/json": { + schema: batchAddClientToSiteResourcesBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/client/{clientId}/private-resources", + description: "Add a machine client to multiple site resources at once.", tags: [OpenAPITags.Client], request: { params: batchAddClientToSiteResourcesParamsSchema, diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index eaf4cf130..d3e32babd 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( @@ -208,6 +207,39 @@ registry.registerPath({ method: "put", path: "/org/{orgId}/site-resource", description: "Create a new site resource.", + tags: [OpenAPITags.PrivateResourceLegacy], + request: { + params: createSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: createSiteResourceSchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/private-resource", + description: "Create a new site resource.", tags: [OpenAPITags.PrivateResource], request: { params: createSiteResourceParamsSchema, @@ -275,7 +307,6 @@ export async function createSiteResource( scheme, destinationPort, destination, - enabled, ssl, alias, userIds, @@ -539,7 +570,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/deleteSiteResource.ts b/server/routers/siteResource/deleteSiteResource.ts index 63479c399..cdb438736 100644 --- a/server/routers/siteResource/deleteSiteResource.ts +++ b/server/routers/siteResource/deleteSiteResource.ts @@ -27,6 +27,32 @@ registry.registerPath({ method: "delete", path: "/site-resource/{siteResourceId}", description: "Delete a site resource.", + tags: [OpenAPITags.PrivateResourceLegacy], + request: { + params: deleteSiteResourceParamsSchema + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "delete", + path: "/private-resource/{siteResourceId}", + description: "Delete a site resource.", tags: [OpenAPITags.PrivateResource], request: { params: deleteSiteResourceParamsSchema diff --git a/server/routers/siteResource/getSiteResource.ts b/server/routers/siteResource/getSiteResource.ts index f25723d4b..47a89d1b1 100644 --- a/server/routers/siteResource/getSiteResource.ts +++ b/server/routers/siteResource/getSiteResource.ts @@ -57,6 +57,36 @@ registry.registerPath({ method: "get", path: "/site-resource/{siteResourceId}", description: "Get a specific site resource by siteResourceId.", + tags: [OpenAPITags.PrivateResourceLegacy], + request: { + params: z.object({ + siteResourceId: z.number(), + siteId: z.number(), + orgId: z.string() + }) + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "get", + path: "/private-resource/{siteResourceId}", + description: "Get a specific site resource by siteResourceId.", tags: [OpenAPITags.PrivateResource], request: { params: z.object({ diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 4515e033e..50acbeb4f 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 === "") { @@ -221,6 +230,33 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/site-resources", description: "List all site resources for an organization.", + tags: [OpenAPITags.PrivateResourceLegacy], + request: { + params: listAllSiteResourcesByOrgParamsSchema, + query: listAllSiteResourcesByOrgQuerySchema + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/private-resources", + description: "List all site resources for an organization.", tags: [OpenAPITags.PrivateResource], request: { params: listAllSiteResourcesByOrgParamsSchema, @@ -283,6 +319,7 @@ export async function listAllSiteResourcesByOrg( sort_by, order, siteId, + status, labels: labelFilter } = parsedQuery.data; @@ -315,6 +352,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/listSiteResourceClients.ts b/server/routers/siteResource/listSiteResourceClients.ts index 5ae6f6f8d..916345190 100644 --- a/server/routers/siteResource/listSiteResourceClients.ts +++ b/server/routers/siteResource/listSiteResourceClients.ts @@ -39,6 +39,32 @@ registry.registerPath({ method: "get", path: "/site-resource/{siteResourceId}/clients", description: "List all clients for a site resource.", + tags: [OpenAPITags.PrivateResourceLegacy], + request: { + params: listSiteResourceClientsSchema + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "get", + path: "/private-resource/{siteResourceId}/clients", + description: "List all clients for a site resource.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], request: { params: listSiteResourceClientsSchema diff --git a/server/routers/siteResource/listSiteResourceRoles.ts b/server/routers/siteResource/listSiteResourceRoles.ts index d430c8c43..132b4fed0 100644 --- a/server/routers/siteResource/listSiteResourceRoles.ts +++ b/server/routers/siteResource/listSiteResourceRoles.ts @@ -40,6 +40,32 @@ registry.registerPath({ method: "get", path: "/site-resource/{siteResourceId}/roles", description: "List all roles for a site resource.", + tags: [OpenAPITags.PrivateResourceLegacy], + request: { + params: listSiteResourceRolesSchema + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "get", + path: "/private-resource/{siteResourceId}/roles", + description: "List all roles for a site resource.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], request: { params: listSiteResourceRolesSchema diff --git a/server/routers/siteResource/listSiteResourceUsers.ts b/server/routers/siteResource/listSiteResourceUsers.ts index d13a58d3b..70604d718 100644 --- a/server/routers/siteResource/listSiteResourceUsers.ts +++ b/server/routers/siteResource/listSiteResourceUsers.ts @@ -43,6 +43,32 @@ registry.registerPath({ method: "get", path: "/site-resource/{siteResourceId}/users", description: "List all users for a site resource.", + tags: [OpenAPITags.PrivateResourceLegacy], + request: { + params: listSiteResourceUsersSchema + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "get", + path: "/private-resource/{siteResourceId}/users", + description: "List all users for a site resource.", tags: [OpenAPITags.PrivateResource, OpenAPITags.User], request: { params: listSiteResourceUsersSchema diff --git a/server/routers/siteResource/listSiteResources.ts b/server/routers/siteResource/listSiteResources.ts index a9688a9c6..da0ba9a49 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" }) }); @@ -58,6 +67,33 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/site/{siteId}/resources", description: "List site resources for a site.", + tags: [OpenAPITags.PrivateResourceLegacy], + request: { + params: listSiteResourcesParamsSchema, + query: listSiteResourcesQuerySchema + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/site/{siteId}/private-resources", + description: "List site resources for a site.", tags: [OpenAPITags.PrivateResource], request: { params: listSiteResourcesParamsSchema, @@ -110,7 +146,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 +160,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 +177,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/removeClientFromSiteResource.ts b/server/routers/siteResource/removeClientFromSiteResource.ts index e955dd5c6..bf57668bf 100644 --- a/server/routers/siteResource/removeClientFromSiteResource.ts +++ b/server/routers/siteResource/removeClientFromSiteResource.ts @@ -31,6 +31,40 @@ const removeClientFromSiteResourceParamsSchema = z registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/clients/remove", + description: + "Remove a single client from a site resource. Clients with a userId cannot be removed.", + tags: [OpenAPITags.PrivateResourceLegacy], + request: { + params: removeClientFromSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeClientFromSiteResourceBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/private-resource/{siteResourceId}/clients/remove", description: "Remove a single client from a site resource. Clients with a userId cannot be removed.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], diff --git a/server/routers/siteResource/removeRoleFromSiteResource.ts b/server/routers/siteResource/removeRoleFromSiteResource.ts index 509088597..cd60b288d 100644 --- a/server/routers/siteResource/removeRoleFromSiteResource.ts +++ b/server/routers/siteResource/removeRoleFromSiteResource.ts @@ -33,6 +33,39 @@ registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/roles/remove", description: "Remove a single role from a site resource.", + tags: [OpenAPITags.PrivateResourceLegacy], + request: { + params: removeRoleFromSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeRoleFromSiteResourceBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/private-resource/{siteResourceId}/roles/remove", + description: "Remove a single role from a site resource.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], request: { params: removeRoleFromSiteResourceParamsSchema, diff --git a/server/routers/siteResource/removeUserFromSiteResource.ts b/server/routers/siteResource/removeUserFromSiteResource.ts index 3d8e1fd97..42b719e85 100644 --- a/server/routers/siteResource/removeUserFromSiteResource.ts +++ b/server/routers/siteResource/removeUserFromSiteResource.ts @@ -33,6 +33,39 @@ registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/users/remove", description: "Remove a single user from a site resource.", + tags: [OpenAPITags.PrivateResourceLegacy], + request: { + params: removeUserFromSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeUserFromSiteResourceBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/private-resource/{siteResourceId}/users/remove", + description: "Remove a single user from a site resource.", tags: [OpenAPITags.PrivateResource, OpenAPITags.User], request: { params: removeUserFromSiteResourceParamsSchema, diff --git a/server/routers/siteResource/setSiteResourceClients.ts b/server/routers/siteResource/setSiteResourceClients.ts index 59dff6ef9..e1915badc 100644 --- a/server/routers/siteResource/setSiteResourceClients.ts +++ b/server/routers/siteResource/setSiteResourceClients.ts @@ -31,6 +31,40 @@ const setSiteResourceClientsParamsSchema = z registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/clients", + description: + "Set clients for a site resource. This will replace all existing clients. Clients with a userId cannot be added.", + tags: [OpenAPITags.PrivateResourceLegacy], + request: { + params: setSiteResourceClientsParamsSchema, + body: { + content: { + "application/json": { + schema: setSiteResourceClientsBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/private-resource/{siteResourceId}/clients", description: "Set clients for a site resource. This will replace all existing clients. Clients with a userId cannot be added.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], diff --git a/server/routers/siteResource/setSiteResourceRoles.ts b/server/routers/siteResource/setSiteResourceRoles.ts index 277ef437e..7d1c8db15 100644 --- a/server/routers/siteResource/setSiteResourceRoles.ts +++ b/server/routers/siteResource/setSiteResourceRoles.ts @@ -32,6 +32,40 @@ const setSiteResourceRolesParamsSchema = z registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/roles", + description: + "Set roles for a site resource. This will replace all existing roles.", + tags: [OpenAPITags.PrivateResourceLegacy], + request: { + params: setSiteResourceRolesParamsSchema, + body: { + content: { + "application/json": { + schema: setSiteResourceRolesBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/private-resource/{siteResourceId}/roles", description: "Set roles for a site resource. This will replace all existing roles.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], diff --git a/server/routers/siteResource/setSiteResourceUsers.ts b/server/routers/siteResource/setSiteResourceUsers.ts index 4a423bf06..06953b927 100644 --- a/server/routers/siteResource/setSiteResourceUsers.ts +++ b/server/routers/siteResource/setSiteResourceUsers.ts @@ -33,6 +33,40 @@ const setSiteResourceUsersParamsSchema = z registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/users", + description: + "Set users for a site resource. This will replace all existing users.", + tags: [OpenAPITags.PrivateResourceLegacy], + request: { + params: setSiteResourceUsersParamsSchema, + body: { + content: { + "application/json": { + schema: setSiteResourceUsersBodySchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/private-resource/{siteResourceId}/users", description: "Set users for a site resource. This will replace all existing users.", tags: [OpenAPITags.PrivateResource, OpenAPITags.User], diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 173a15190..7b0ef0783 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; @@ -208,6 +213,39 @@ registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}", description: "Update a site resource.", + tags: [OpenAPITags.PrivateResourceLegacy], + request: { + params: updateSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: updateSiteResourceSchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "post", + path: "/private-resource/{siteResourceId}", + description: "Update a site resource.", tags: [OpenAPITags.PrivateResource], request: { params: updateSiteResourceParamsSchema, @@ -411,8 +449,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 +488,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 +561,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/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 289f47c76..cf2cd6b5f 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -93,6 +93,39 @@ registry.registerPath({ method: "put", path: "/resource/{resourceId}/target", description: "Create a target for a resource.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: createTargetParamsSchema, + body: { + content: { + "application/json": { + schema: createTargetSchema + } + } + } + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "put", + path: "/public-resource/{resourceId}/target", + description: "Create a target for a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.Target], request: { params: createTargetParamsSchema, diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index 68f80197a..f2d16dd21 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -92,6 +92,33 @@ registry.registerPath({ method: "get", path: "/resource/{resourceId}/targets", description: "List targets for a resource.", + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: listTargetsParamsSchema, + query: listTargetsSchema + }, + 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() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "get", + path: "/public-resource/{resourceId}/targets", + description: "List targets for a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.Target], request: { params: listTargetsParamsSchema, diff --git a/server/routers/user/adminGeneratePasswordResetCode.ts b/server/routers/user/adminGeneratePasswordResetCode.ts index 562a459e1..7ba8ce9df 100644 --- a/server/routers/user/adminGeneratePasswordResetCode.ts +++ b/server/routers/user/adminGeneratePasswordResetCode.ts @@ -94,7 +94,7 @@ export async function adminGeneratePasswordResetCode( }); }); - const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${existingUser[0].email}&token=${token}`; + const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${encodeURIComponent(existingUser[0].email!)}&token=${token}`; logger.info( `Admin generated password reset code for user ${existingUser[0].email} (${userId})` diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 7445ee910..7b7ae9250 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -287,7 +287,7 @@ export async function inviteUser( ) ); - const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`; + const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`; if (doEmail) { await sendEmail( @@ -341,7 +341,7 @@ export async function inviteUser( .values(uniqueRoleIds.map((roleId) => ({ inviteId, roleId }))); }); - const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`; + const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`; if (doEmail) { await sendEmail( diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts index f89284389..496002142 100644 --- a/server/routers/ws/messageHandlers.ts +++ b/server/routers/ws/messageHandlers.ts @@ -20,7 +20,9 @@ import { handleOlmServerPeerAddMessage, handleOlmUnRelayMessage, handleOlmDisconnectingMessage, - handleOlmServerInitAddPeerHandshake + handleOlmServerInitAddPeerHandshake, + handleOlmLocalMessage, + handleOlmUnLocalMessage } from "../olm"; import { handleHealthcheckStatusMessage } from "../target"; import { handleRoundTripMessage } from "./handleRoundTripMessage"; @@ -32,6 +34,8 @@ export const messageHandlers: Record = { "olm/wg/register": handleOlmRegisterMessage, "olm/wg/relay": handleOlmRelayMessage, "olm/wg/unrelay": handleOlmUnRelayMessage, + "olm/wg/local": handleOlmLocalMessage, + "olm/wg/unlocal": handleOlmUnLocalMessage, "olm/ping": handleOlmPingMessage, "olm/disconnecting": handleOlmDisconnectingMessage, "newt/disconnecting": handleNewtDisconnectingMessage, diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index a271ef96d..4a6d4861c 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -27,6 +27,7 @@ import m18 from "./scriptsPg/1.18.3"; import m19 from "./scriptsPg/1.18.4"; import m20 from "./scriptsPg/1.19.0"; import m21 from "./scriptsPg/1.20.0"; +import m22 from "./scriptsPg/1.21.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -53,7 +54,8 @@ const migrations = [ { version: "1.18.3", run: m18 }, { version: "1.18.4", run: m19 }, { version: "1.19.0", run: m20 }, - { version: "1.20.0", run: m21 } + { version: "1.20.0", run: m21 }, + { version: "1.21.0", run: m22 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index a8f6f9e33..c6b4debef 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -46,6 +46,7 @@ import m40 from "./scriptsSqlite/1.18.4"; import m41 from "./scriptsSqlite/1.19.0"; import m42 from "./scriptsSqlite/1.19.1"; import m43 from "./scriptsSqlite/1.20.0"; +import m44 from "./scriptsSqlite/1.21.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -89,7 +90,8 @@ const migrations = [ { version: "1.18.4", run: m40 }, { version: "1.19.0", run: m41 }, { version: "1.19.1", run: m42 }, - { version: "1.20.0", run: m43 } + { version: "1.20.0", run: m43 }, + { version: "1.21.0", run: m44 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.21.0.ts b/server/setup/scriptsPg/1.21.0.ts new file mode 100644 index 000000000..d5195c382 --- /dev/null +++ b/server/setup/scriptsPg/1.21.0.ts @@ -0,0 +1,95 @@ +import { db } from "@server/db/pg/driver"; +import { APP_PATH } from "@server/lib/consts"; +import { sql } from "drizzle-orm"; +import fs from "fs"; +import yaml from "js-yaml"; +import path from "path"; +import z from "zod"; +import { fromZodError } from "zod-validation-error"; + +const version = "1.21.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + await db.execute(sql`BEGIN`); + + await db.execute(sql` + ALTER TABLE "resourceAccessToken" ADD COLUMN "userId" varchar; + `); + + await db.execute(sql` + ALTER TABLE "resourceAccessToken" ADD COLUMN "persistSession" boolean DEFAULT false NOT NULL; + `); + + await db.execute(sql` + ALTER TABLE "resources" ADD COLUMN "status" varchar DEFAULT 'approved'; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "status" varchar DEFAULT 'approved'; + `); + + await db.execute(sql` + ALTER TABLE "sites" ADD COLUMN "localEndpoints" varchar; + `); + + await db.execute(sql` + ALTER TABLE "resourceAccessToken" ADD CONSTRAINT "resourceAccessToken_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql`COMMIT`); + console.log("Migrated database"); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } + + try { + const traefikPath = path.join( + APP_PATH, + "traefik", + "traefik_config.yml" + ); + + const schema = z.object({ + experimental: z.object({ + plugins: z.object({ + badger: z.object({ + moduleName: z.string(), + version: z.string() + }) + }) + }) + }); + + const traefikFileContents = fs.readFileSync(traefikPath, "utf8"); + const traefikConfig = yaml.load(traefikFileContents) as any; + + const parsedConfig = schema.safeParse(traefikConfig); + + if (!parsedConfig.success) { + throw new Error(fromZodError(parsedConfig.error).toString()); + } + + traefikConfig.experimental.plugins.badger.version = "v1.5.0"; + + const updatedTraefikYaml = yaml.dump(traefikConfig); + + fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8"); + + console.log( + "Updated the version of Badger in your Traefik configuration to v1.5.0" + ); + } catch (e) { + console.log( + "We were unable to update the version of Badger in your Traefik configuration. Please update it manually. Check the release notes for this version for more information." + ); + console.error(e); + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.21.0.ts b/server/setup/scriptsSqlite/1.21.0.ts new file mode 100644 index 000000000..b710c783b --- /dev/null +++ b/server/setup/scriptsSqlite/1.21.0.ts @@ -0,0 +1,104 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import fs from "fs"; +import yaml from "js-yaml"; +import path from "path"; +import z from "zod"; +import { fromZodError } from "zod-validation-error"; + +const version = "1.21.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + + db.transaction(() => { + db.prepare( + ` + ALTER TABLE 'resourceAccessToken' ADD 'userId' text REFERENCES user(id); + ` + ).run(); + + db.prepare( + ` + ALTER TABLE 'resourceAccessToken' ADD 'persistSession' integer DEFAULT false NOT NULL; + ` + ).run(); + + db.prepare( + ` + ALTER TABLE 'resources' ADD 'status' text DEFAULT 'approved'; + ` + ).run(); + + db.prepare( + ` + ALTER TABLE 'siteResources' ADD 'status' text DEFAULT 'approved'; + ` + ).run(); + + db.prepare( + ` + ALTER TABLE 'sites' ADD 'localEndpoints' text; + ` + ).run(); + })(); + + db.pragma("foreign_keys = ON"); + + console.log("Migrated database"); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + try { + const traefikPath = path.join( + APP_PATH, + "traefik", + "traefik_config.yml" + ); + + const schema = z.object({ + experimental: z.object({ + plugins: z.object({ + badger: z.object({ + moduleName: z.string(), + version: z.string() + }) + }) + }) + }); + + const traefikFileContents = fs.readFileSync(traefikPath, "utf8"); + const traefikConfig = yaml.load(traefikFileContents) as any; + + const parsedConfig = schema.safeParse(traefikConfig); + + if (!parsedConfig.success) { + throw new Error(fromZodError(parsedConfig.error).toString()); + } + + traefikConfig.experimental.plugins.badger.version = "v1.5.0"; + + const updatedTraefikYaml = yaml.dump(traefikConfig); + + fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8"); + + console.log( + "Updated the version of Badger in your Traefik configuration to v1.5.0" + ); + } catch (e) { + console.log( + "We were unable to update the version of Badger in your Traefik configuration. Please update it manually. Check the release notes for this version for more information." + ); + console.error(e); + } + + console.log(`${version} migration complete`); +} diff --git a/src/actions/server.ts b/src/actions/server.ts index 2759e6213..cd75c2c57 100644 --- a/src/actions/server.ts +++ b/src/actions/server.ts @@ -248,6 +248,39 @@ export async function loginProxy( return await makeApiRequest(url, "POST", request); } +export async function logoutProxy(): Promise> { + const env = pullEnv(); + const serverPort = process.env.SERVER_EXTERNAL_PORT; + const url = `http://localhost:${serverPort}/api/v1/auth/logout`; + + const result = await makeApiRequest(url, "POST"); + + try { + const headersList = await reqHeaders(); + const host = headersList.get("host")?.split(":")[0]; + const allCookies = await cookies(); + const clearOptions = { + httpOnly: true, + secure: true, + sameSite: "lax" as const, + path: "/", + maxAge: 0 + }; + // Clear both host-only and domain-scoped variants. + allCookies.set(env.server.sessionCookieName, "", clearOptions); + if (host) { + allCookies.set(env.server.sessionCookieName, "", { + ...clearOptions, + domain: host + }); + } + } catch (cookieError) { + console.error("Failed to clear session cookie:", cookieError); + } + + return result; +} + export async function securityKeyStartProxy( request: SecurityKeyStartRequest, forceLogin?: boolean 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/CreateShareLinkForm.tsx b/src/components/CreateShareLinkForm.tsx index 95884b486..e6bdc60b0 100644 --- a/src/components/CreateShareLinkForm.tsx +++ b/src/components/CreateShareLinkForm.tsx @@ -52,7 +52,6 @@ import { ChevronsUpDown } from "lucide-react"; import { Checkbox } from "@app/components/ui/checkbox"; import { GenerateAccessTokenResponse } from "@server/routers/accessToken"; import { constructShareLink } from "@app/lib/shareLinks"; -import { ShareLinkRow } from "@app/components/ShareLinksTable"; import { QRCodeCanvas, QRCodeSVG } from "qrcode.react"; import { Collapsible, @@ -63,11 +62,26 @@ import AccessTokenSection from "@app/components/AccessTokenUsage"; import { useTranslations } from "next-intl"; import { toUnicode } from "punycode"; import { ResourceSelector, type SelectedResource } from "./resource-selector"; +import { UserSelector, type SelectedUser } from "@app/components/user-selector"; + +type CreatedShareLink = { + accessTokenId: string; + resourceId: number; + resourceName: string; + resourceNiceId: string; + title: string | null; + createdAt: number; + expiresAt: number | null; + userId?: string | null; + userName?: string | null; + username?: string | null; + userEmail?: string | null; +}; type FormProps = { open: boolean; setOpen: (open: boolean) => void; - onCreated?: (result: ShareLinkRow) => void; + onCreated?: (result: CreatedShareLink) => void; }; export default function CreateShareLinkForm({ @@ -85,6 +99,8 @@ export default function CreateShareLinkForm({ const [accessToken, setAccessToken] = useState(null); const [loading, setLoading] = useState(false); const [neverExpire, setNeverExpire] = useState(false); + const [persistSession, setPersistSession] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); const [isOpen, setIsOpen] = useState(false); const t = useTranslations(); @@ -175,7 +191,9 @@ export default function CreateShareLinkForm({ values.resourceName || "Resource" + values.resourceId }), - path: values.path + path: values.path, + persistSession, + userId: selectedUser?.id } ) .catch((e) => { @@ -205,7 +223,11 @@ export default function CreateShareLinkForm({ resourceNiceId: selectedResource ? selectedResource.niceId : "", title: token.title, createdAt: token.createdAt, - expiresAt: token.expiresAt + expiresAt: token.expiresAt, + userId: token.userId, + userName: selectedUser?.text ?? null, + username: null, + userEmail: null }); } @@ -220,6 +242,9 @@ export default function CreateShareLinkForm({ setOpen(val); setLink(null); setLoading(false); + setNeverExpire(false); + setPersistSession(false); + setSelectedUser(null); form.reset(); }} > @@ -344,6 +369,48 @@ export default function CreateShareLinkForm({ )} /> +
+ + {t( + "shareAssociateUserOptional" + )} + + + + + + + + + +

+ {t( + "shareAssociateUserDescription" + )} +

+
+
@@ -437,6 +504,34 @@ export default function CreateShareLinkForm({
+
+ + setPersistSession( + val as boolean + ) + } + className="mt-0.5" + /> +
+ +

+ {t( + "sharePersistSessionDescription" + )} +

+
+
+

{t("shareExpireDescription")}

diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index daacf892b..e06c3e57d 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -1,6 +1,6 @@ "use client"; -import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { @@ -43,10 +43,12 @@ import { CheckCircle2, ChevronsUpDown, ExternalLink, + Globe, KeyRound, Zap } from "lucide-react"; import { useTranslations } from "next-intl"; +import Link from "next/link"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { usePaidStatus } from "@/hooks/usePaidStatus"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; @@ -494,6 +496,28 @@ export default function DomainPicker({ const hasMoreProvided = sortedAvailableOptions.length > providedDomainsShown; + const noDomainsAvailable = + !loadingDomains && + organizationDomains.length === 0 && + (build === "oss" || hideFreeDomain || requiresPaywall); + + if (noDomainsAvailable) { + return ( + + + {t("domainPickerNoDomainsAvailableTitle")} + +

{t("domainPickerNoDomainsAvailableDescription")}

+ +
+
+ ); + } + return (
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/InviteStatusCard.tsx b/src/components/InviteStatusCard.tsx index 40b7123e7..790249904 100644 --- a/src/components/InviteStatusCard.tsx +++ b/src/components/InviteStatusCard.tsx @@ -93,14 +93,20 @@ export default function InviteStatusCard({ setType(type); if (!user && type === "user_does_not_exist") { + const inviteRedirect = encodeURIComponent( + `/invite?token=${tokenParam}` + ); const redirectUrl = email - ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}` - : `/auth/signup?redirect=/invite?token=${tokenParam}`; + ? `/auth/signup?redirect=${inviteRedirect}&email=${encodeURIComponent(email)}` + : `/auth/signup?redirect=${inviteRedirect}`; router.push(redirectUrl); } else if (!user && type === "not_logged_in") { + const inviteRedirect = encodeURIComponent( + `/invite?token=${tokenParam}` + ); const redirectUrl = email - ? `/auth/login?redirect=/invite?token=${tokenParam}&user=${email}` - : `/auth/login?redirect=/invite?token=${tokenParam}`; + ? `/auth/login?redirect=${inviteRedirect}&user=${encodeURIComponent(email)}` + : `/auth/login?redirect=${inviteRedirect}`; router.push(redirectUrl); } else { setLoading(false); @@ -112,17 +118,23 @@ export default function InviteStatusCard({ async function goToLogin() { await api.post("/auth/logout", {}); + const inviteRedirect = encodeURIComponent( + `/invite?token=${tokenParam}` + ); const redirectUrl = email - ? `/auth/login?redirect=/invite?token=${tokenParam}&user=${email}` - : `/auth/login?redirect=/invite?token=${tokenParam}`; + ? `/auth/login?redirect=${inviteRedirect}&user=${encodeURIComponent(email)}` + : `/auth/login?redirect=${inviteRedirect}`; router.push(redirectUrl); } async function goToSignup() { await api.post("/auth/logout", {}); + const inviteRedirect = encodeURIComponent( + `/invite?token=${tokenParam}` + ); const redirectUrl = email - ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}` - : `/auth/signup?redirect=/invite?token=${tokenParam}`; + ? `/auth/signup?redirect=${inviteRedirect}&email=${encodeURIComponent(email)}` + : `/auth/signup?redirect=${inviteRedirect}`; router.push(redirectUrl); } 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/OrgPolicyRequired.tsx b/src/components/OrgPolicyRequired.tsx index 3765cd1bf..6d28ddbf5 100644 --- a/src/components/OrgPolicyRequired.tsx +++ b/src/components/OrgPolicyRequired.tsx @@ -12,8 +12,8 @@ import { Shield, ArrowRight } from "lucide-react"; import Link from "next/link"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useState } from "react"; +import { logoutProxy } from "@app/actions/server"; type OrgPolicyRequiredProps = { orgId: string; @@ -40,21 +40,23 @@ export default function OrgPolicyRequired({ }: OrgPolicyRequiredProps) { const t = useTranslations(); const router = useRouter(); - - const api = createApiClient(useEnvContext()); + const [loading, setLoading] = useState(false); const sessionExpired = policies?.maxSessionLength && policies.maxSessionLength.compliant === false; - function reauthenticate() { - api.post("/auth/logout") - .catch(() => {}) - .then(() => { - const destination = redirectAfterAuth ?? `/${orgId}`; - router.push(destination); - router.refresh(); - }); + async function reauthenticate() { + setLoading(true); + try { + await logoutProxy(); + } catch (error) { + console.error("Error during logout:", error); + } finally { + const destination = redirectAfterAuth ?? `/${orgId}`; + router.push(destination); + router.refresh(); + } } if (sessionExpired) { @@ -76,6 +78,7 @@ export default function OrgPolicyRequired({ + ); + } + }, { 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/ShareLinksTable.tsx b/src/components/ShareLinksTable.tsx index 8394edf89..b4df1966f 100644 --- a/src/components/ShareLinksTable.tsx +++ b/src/components/ShareLinksTable.tsx @@ -35,6 +35,7 @@ import moment from "moment"; import CreateShareLinkForm from "@app/components/CreateShareLinkForm"; import { constructShareLink } from "@app/lib/shareLinks"; import { useTranslations } from "next-intl"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; export type ShareLinkRow = { accessTokenId: string; @@ -44,6 +45,10 @@ export type ShareLinkRow = { title: string | null; createdAt: number; expiresAt: number | null; + userId?: string | null; + userName?: string | null; + username?: string | null; + userEmail?: string | null; }; type ShareLinksTableProps = { @@ -155,6 +160,41 @@ export default function ShareLinksTable({ ); } }, + { + accessorKey: "userId", + friendlyName: t("user"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const r = row.original; + if (!r.userId) { + return -; + } + return ( + + + + ); + } + }, // { // accessorKey: "domain", // header: "Link", 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 && ( + + )} + void; + allowClear?: boolean; +}; + +export function UserSelector({ + orgId, + selectedUser, + onSelectUser, + allowClear = true +}: UserSelectorProps) { + const t = useTranslations(); + const [userSearchQuery, setUserSearchQuery] = useState(""); + const [debouncedValue] = useDebounce(userSearchQuery, 150); + + const { data: users = [] } = useQuery( + orgQueries.users({ orgId, perPage: 10, query: debouncedValue }) + ); + + const usersShown = useMemo(() => { + const allUsers: Array = users.map((u) => ({ + id: u.id, + text: getUserDisplayName(u) + })); + if ( + debouncedValue.trim().length === 0 && + selectedUser && + !allUsers.find((user) => user.id === selectedUser.id) + ) { + allUsers.unshift(selectedUser); + } + return allUsers; + }, [users, selectedUser, debouncedValue]); + + return ( + + + + {t("usersNotFound")} + + {allowClear && ( + { + onSelectUser(null); + }} + > + + {t("none")} + + )} + {usersShown.map((user) => ( + { + onSelectUser(user); + }} + > + + {user.text} + + ))} + + + + ); +} diff --git a/src/app/[orgId]/settings/resources/private/useSaveSiteResource.ts b/src/hooks/useSaveSiteResource.ts similarity index 100% rename from src/app/[orgId]/settings/resources/private/useSaveSiteResource.ts rename to src/hooks/useSaveSiteResource.ts diff --git a/src/lib/api/isOrgSubscribed.ts b/src/lib/api/isOrgSubscribed.ts index ce19b9178..16fc082c0 100644 --- a/src/lib/api/isOrgSubscribed.ts +++ b/src/lib/api/isOrgSubscribed.ts @@ -4,9 +4,13 @@ import { getCachedSubscription } from "./getCachedSubscription"; import { priv } from "."; import { AxiosResponse } from "axios"; import { GetLicenseStatusResponse } from "@server/routers/license/types"; +import { Tier } from "@server/types/Tiers"; -export const isOrgSubscribed = cache(async (orgId: string) => { +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/queries.ts b/src/lib/queries.ts index 19b28e2d9..b552fd26c 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -672,9 +672,13 @@ export const orgQueries = { queryOptions({ queryKey: ["SITE_STATUS_HISTORY", siteId, days] as const, queryFn: async ({ signal, meta }) => { + const tzOffsetMinutes = -new Date().getTimezoneOffset(); const res = await meta!.api.get< AxiosResponse - >(`/site/${siteId}/status-history?days=${days}`, { signal }); + >( + `/site/${siteId}/status-history?days=${days}&tzOffsetMinutes=${tzOffsetMinutes}`, + { signal } + ); return res.data.data; } }), @@ -689,11 +693,13 @@ export const orgQueries = { queryOptions({ queryKey: ["RESOURCE_STATUS_HISTORY", resourceId, days] as const, queryFn: async ({ signal, meta }) => { + const tzOffsetMinutes = -new Date().getTimezoneOffset(); const res = await meta!.api.get< AxiosResponse - >(`/resource/${resourceId}/status-history?days=${days}`, { - signal - }); + >( + `/resource/${resourceId}/status-history?days=${days}&tzOffsetMinutes=${tzOffsetMinutes}`, + { signal } + ); return res.data.data; } }), @@ -715,10 +721,11 @@ export const orgQueries = { days ] as const, queryFn: async ({ signal, meta }) => { + const tzOffsetMinutes = -new Date().getTimezoneOffset(); const res = await meta!.api.get< AxiosResponse >( - `/org/${orgId}/health-check/${healthCheckId}/status-history?days=${days}`, + `/org/${orgId}/health-check/${healthCheckId}/status-history?days=${days}&tzOffsetMinutes=${tzOffsetMinutes}`, { signal } ); return res.data.data; 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; +}