diff --git a/cli/commands/clearCertificates.ts b/cli/commands/clearCertificates.ts new file mode 100644 index 000000000..ff6ef8239 --- /dev/null +++ b/cli/commands/clearCertificates.ts @@ -0,0 +1,28 @@ +import { CommandModule } from "yargs"; +import { db, certificates } from "@server/db"; + +type ClearCertificatesArgs = {}; + +export const clearCertificates: CommandModule<{}, ClearCertificatesArgs> = { + command: "clear-certificates", + describe: "Delete all entries from the certificates table", + builder: (yargs) => { + return yargs; + }, + handler: async (argv: {}) => { + try { + console.log("Clearing all certificates from the database..."); + + const deleted = await db.delete(certificates).returning(); + + console.log( + `Deleted ${deleted.length} certificate(s) from the database` + ); + + process.exit(0); + } catch (error) { + console.error("Error:", error); + process.exit(1); + } + } +}; diff --git a/cli/index.ts b/cli/index.ts index 7605904ee..3664bb8f8 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -9,6 +9,7 @@ import { rotateServerSecret } from "./commands/rotateServerSecret"; import { clearLicenseKeys } from "./commands/clearLicenseKeys"; import { deleteClient } from "./commands/deleteClient"; import { generateOrgCaKeys } from "./commands/generateOrgCaKeys"; +import { clearCertificates } from "./commands/clearCertificates"; yargs(hideBin(process.argv)) .scriptName("pangctl") @@ -19,5 +20,6 @@ yargs(hideBin(process.argv)) .command(clearLicenseKeys) .command(deleteClient) .command(generateOrgCaKeys) + .command(clearCertificates) .demandCommand() .help().argv; diff --git a/docker-compose.mailpit.yml b/docker-compose.mailpit.yml new file mode 100644 index 000000000..b801ec735 --- /dev/null +++ b/docker-compose.mailpit.yml @@ -0,0 +1,12 @@ +services: + mailer: + image: axllent/mailpit + ports: + - 8025:8025 + - 1025:1025 + volumes: + - mailpit-storage:/data + environment: + - MP_DATABASE=/data/mailpit.db +volumes: + mailpit-storage: diff --git a/messages/bg-BG.json b/messages/bg-BG.json index e9ca96733..46327afd0 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -2660,19 +2660,19 @@ "noMoreAuthMethods": "Няма валидни методи за удостоверение", "ip": "IP", "reason": "Причина", - "requestLogs": "Заявка за логове", + "requestLogs": "Логове за HTTP заявки", "requestAnalytics": "Анализи На Заявки", "host": "Хост", "location": "Местоположение", "actionLogs": "Дневници на действията", - "sidebarLogsRequest": "Заявка за логове", + "sidebarLogsRequest": "Логове за HTTP заявки", "sidebarLogsAccess": "Достъп до логове", "sidebarLogsAction": "Дневници на действията", "logRetention": "Задържане на логове", "logRetentionDescription": "Управлявайте времето за задържане на различни видове логове за тази организация или ги деактивирайте", "requestLogsDescription": "Прегледайте подробни логове на заявки за ресурси в тази организация", "requestAnalyticsDescription": "Вижте подробни анализи на заявки за ресурсите в тази организация", - "logRetentionRequestLabel": "Задържане на логове на заявки", + "logRetentionRequestLabel": "Задържане на логове за HTTP заявки", "logRetentionRequestDescription": "Колко дълго да се задържат логовете на заявките", "logRetentionAccessLabel": "Задържане на логове за достъп", "logRetentionAccessDescription": "Колко дълго да се задържат логовете за достъп", @@ -3134,7 +3134,7 @@ "httpDestActionLogsDescription": "Административни действия, извършени от потребители в организацията.", "httpDestConnectionLogsTitle": "Логове на връзката", "httpDestConnectionLogsDescription": "Събития на свързване и прекъсване на сайта и тунела, включително свръзки и прекъсвания.", - "httpDestRequestLogsTitle": "Заявки за логове", + "httpDestRequestLogsTitle": "Логове за HTTP заявки", "httpDestRequestLogsDescription": "Регистри за HTTP заявките към проксирани ресурси, включително метод, път и код на отговор.", "httpDestSaveChanges": "Запази промените", "httpDestCreateDestination": "Създаване на дестинация", @@ -3208,5 +3208,48 @@ "domainPickerWildcardCertWarning": "Ресурсите с уайлдкард може да изискват допълнителна конфигурация за правилна работа.", "domainPickerWildcardCertWarningLink": "Научете повече", "health": "Здраве", - "domainPendingErrorTitle": "Проблем при проверка" + "domainPendingErrorTitle": "Проблем при проверка", + "memberPortalTitle": "Ресурси", + "memberPortalDescription": "Ресурси, до които имате достъп в тази организация", + "memberPortalSortBy": "Сортиране по...", + "memberPortalSortNameAsc": "Име А-Я", + "memberPortalSortNameDesc": "Име Я-А", + "memberPortalSortDomainAsc": "Домен А-Я", + "memberPortalSortDomainDesc": "Домен Я-А", + "memberPortalSortEnabledFirst": "Активирани Първи", + "memberPortalSortDisabledFirst": "Деактивирани Първи", + "memberPortalRefresh": "Обнови", + "memberPortalRefreshResources": "Обнови ресурсите", + "memberPortalFailedToLoad": "Грешка при зареждане на ресурсите", + "memberPortalFailedToLoadDescription": "Грешка при зареждане на ресурсите. Моля, проверете връзката си и опитайте отново.", + "memberPortalUnableToLoad": "Неуспешно зареждане на ресурси", + "memberPortalTryAgain": "Опитай отново", + "memberPortalNoResourcesFound": "Няма намерени ресурси", + "memberPortalNoResourcesAvailable": "Няма налични ресурси", + "memberPortalNoResourcesMatchSearch": "Няма ресурси, съвпадащи с \"{query}\". Опитайте да промените търсените условия или нулирайте търсенето, за да видите всички ресурси.", + "memberPortalNoResourcesAccess": "Още нямате достъп до ресурси. Свържете се с вашия администратор, за да получите достъп до нужните ресурси.", + "memberPortalClearSearch": "Изчисти търсенето", + "memberPortalPublicResources": "Публични ресурси", + "memberPortalPublicResourcesDescription": "Уеб приложения и услуги, достъпни през браузър", + "memberPortalCopiedToClipboard": "Копирано в клипборда", + "memberPortalCopiedUrlDescription": "URL адресът на ресурса е копиран в клипборда.", + "memberPortalOpenResource": "Отвори ресурса", + "memberPortalPrivateResources": "Частни ресурси", + "memberPortalPrivateResourcesDescription": "Ресурси на вътрешната мрежа, достъпни чрез клиент", + "memberPortalResourceDetails": "Детайли за ресурса", + "memberPortalMode": "Режим", + "memberPortalDestination": "Дестинация", + "memberPortalAlias": "Алиас", + "memberPortalCopiedAliasDescription": "Алиасът на ресурса е копиран в клипборда.", + "memberPortalCopiedDestinationDescription": "Дестинацията на ресурса е копирана в клипборда.", + "memberPortalRequiresClientConnection": "Изисква връзка с клиента", + "memberPortalAuthMethods": "Методи на удостоверяване", + "memberPortalSso": "Единно вход (SSO)", + "memberPortalPasswordProtected": "Защитено с парола", + "memberPortalPinCode": "ПИН код", + "memberPortalEmailWhitelist": "Бял списък на имейли", + "memberPortalResourceDisabled": "Ресурсът е деактивиран", + "memberPortalShowingResources": "Показва {start}-{end} от {total} ресурси", + "memberPortalPrevious": "Предишен", + "memberPortalNext": "Следващ" } diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 2a1803486..38da0604a 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -2660,19 +2660,19 @@ "noMoreAuthMethods": "No Valid Auth", "ip": "IP adresa", "reason": "Důvod", - "requestLogs": "Záznamy požadavků", + "requestLogs": "Záznamy HTTP požadavků", "requestAnalytics": "Vyžádat analýzu", "host": "Hostitel", "location": "Poloha", "actionLogs": "Záznamy akcí", - "sidebarLogsRequest": "Záznamy požadavků", + "sidebarLogsRequest": "Záznamy HTTP požadavků", "sidebarLogsAccess": "Protokoly přístupu", "sidebarLogsAction": "Záznamy akcí", "logRetention": "Zaznamenávání záznamu", "logRetentionDescription": "Spravovat, jak dlouho jsou různé typy logů uloženy pro tuto organizaci nebo je zakázat", "requestLogsDescription": "Zobrazit podrobné protokoly požadavků pro zdroje v této organizaci", "requestAnalyticsDescription": "Zobrazit podrobnou analýzu požadavků pro zdroje v této organizaci", - "logRetentionRequestLabel": "Zachování logu žádosti", + "logRetentionRequestLabel": "Zachování logu HTTP požadavků", "logRetentionRequestDescription": "Jak dlouho uchovávat záznamy požadavků", "logRetentionAccessLabel": "Zachování záznamu přístupu", "logRetentionAccessDescription": "Jak dlouho uchovávat přístupové záznamy", @@ -3134,7 +3134,7 @@ "httpDestActionLogsDescription": "Správní opatření prováděná uživateli v rámci organizace.", "httpDestConnectionLogsTitle": "Protokoly připojení", "httpDestConnectionLogsDescription": "Události týkající se připojení lokality a tunelu, včetně připojení a odpojení.", - "httpDestRequestLogsTitle": "Záznamy požadavků", + "httpDestRequestLogsTitle": "Záznamy HTTP požadavků", "httpDestRequestLogsDescription": "HTTP záznamy požadavků pro proxy zdroje, včetně metod, cesty a kódu odpovědi.", "httpDestSaveChanges": "Uložit změny", "httpDestCreateDestination": "Vytvořit cíl", @@ -3208,5 +3208,48 @@ "domainPickerWildcardCertWarning": "Zástupné zdroje mohou vyžadovat dodatečnou konfiguraci pro správnou funkci.", "domainPickerWildcardCertWarningLink": "Zjistit více", "health": "Zdraví", - "domainPendingErrorTitle": "Problém s ověřením" + "domainPendingErrorTitle": "Problém s ověřením", + "memberPortalTitle": "Zdroje", + "memberPortalDescription": "Zdroje, ke kterým máte v této organizaci přístup", + "memberPortalSortBy": "Řadit podle...", + "memberPortalSortNameAsc": "Názvu A-Z", + "memberPortalSortNameDesc": "Názvu Z-A", + "memberPortalSortDomainAsc": "Domény A-Z", + "memberPortalSortDomainDesc": "Domény Z-A", + "memberPortalSortEnabledFirst": "Nejprve povoleno", + "memberPortalSortDisabledFirst": "Nejprve zakázáno", + "memberPortalRefresh": "Aktualizovat", + "memberPortalRefreshResources": "Aktualizovat zdroje", + "memberPortalFailedToLoad": "Nepodařilo se načíst zdroje", + "memberPortalFailedToLoadDescription": "Nepodařilo se načíst zdroje. Zkontrolujte prosím své připojení a zkuste to znovu.", + "memberPortalUnableToLoad": "Nelze načíst zdroje", + "memberPortalTryAgain": "Zkusit znovu", + "memberPortalNoResourcesFound": "Žádné zdroje nebyly nalezeny", + "memberPortalNoResourcesAvailable": "Žádné zdroje nejsou k dispozici", + "memberPortalNoResourcesMatchSearch": "Žádné zdroje neodpovídají \"{query}\". Zkuste přizpůsobit své vyhledávací termíny nebo vyčistit hledání, abyste viděli všechny zdroje.", + "memberPortalNoResourcesAccess": "Zatím nemáte přístup k žádným zdrojům. Kontaktujte svého správce, aby vám poskytl přístup k potřebným zdrojům.", + "memberPortalClearSearch": "Vymazat hledání", + "memberPortalPublicResources": "Veřejné zdroje", + "memberPortalPublicResourcesDescription": "Webové aplikace a služby přístupné přes prohlížeč", + "memberPortalCopiedToClipboard": "Zkopírováno do schránky", + "memberPortalCopiedUrlDescription": "URL zdroje byla zkopírována do vaší schránky.", + "memberPortalOpenResource": "Otevřít zdroj", + "memberPortalPrivateResources": "Soukromé zdroje", + "memberPortalPrivateResourcesDescription": "Interní síťové zdroje přístupné přes klienta", + "memberPortalResourceDetails": "Podrobnosti o zdroji", + "memberPortalMode": "Režim", + "memberPortalDestination": "Cíl", + "memberPortalAlias": "Přezdívka", + "memberPortalCopiedAliasDescription": "Alias zdroje byl zkopírován do vaší schránky.", + "memberPortalCopiedDestinationDescription": "Cíl zdroje byl zkopírován do vaší schránky.", + "memberPortalRequiresClientConnection": "Vyžaduje klientské připojení", + "memberPortalAuthMethods": "Metody ověřování", + "memberPortalSso": "Jedno přihlášení (SSO)", + "memberPortalPasswordProtected": "Heslo chráněno", + "memberPortalPinCode": "PIN kód", + "memberPortalEmailWhitelist": "Seznam povolených emailů", + "memberPortalResourceDisabled": "Zdroj je zakázán", + "memberPortalShowingResources": "Zobrazeny {start}-{end} z {total} zdrojů", + "memberPortalPrevious": "Předchozí", + "memberPortalNext": "Následující" } diff --git a/messages/de-DE.json b/messages/de-DE.json index 376e934c9..367c95cd3 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -2660,19 +2660,19 @@ "noMoreAuthMethods": "Keine gültige Authentifizierungsmethode verfügbar", "ip": "IP", "reason": "Grund", - "requestLogs": "Logs anfordern", + "requestLogs": "HTTP Anforderungsprotokolle", "requestAnalytics": "Anfrage-Analyse anzeigen", "host": "Host", "location": "Standort", "actionLogs": "Aktionsprotokolle", - "sidebarLogsRequest": "Logs anfordern", + "sidebarLogsRequest": "HTTP Anforderungsprotokolle", "sidebarLogsAccess": "Zugriffsprotokolle", "sidebarLogsAction": "Aktionsprotokolle", "logRetention": "Log-Speicherung", "logRetentionDescription": "Verwalten, wie lange verschiedene Logs für diese Organisation gespeichert werden oder deaktivieren", "requestLogsDescription": "Detaillierte Request-Logs für Ressourcen in dieser Organisation anzeigen", "requestAnalyticsDescription": "Detaillierte Anfrage-Analyse für Ressourcen in dieser Organisation anzeigen", - "logRetentionRequestLabel": "Log-Speicherung anfordern", + "logRetentionRequestLabel": "HTTP Anforderungsprotokoll Aufbewahrung", "logRetentionRequestDescription": "Wie lange sollen Request-Logs gespeichert werden", "logRetentionAccessLabel": "Zugriffsprotokoll-Speicherung", "logRetentionAccessDescription": "Wie lange Zugriffsprotokolle beibehalten werden sollen", @@ -3134,7 +3134,7 @@ "httpDestActionLogsDescription": "Administrative Maßnahmen, die von Benutzern innerhalb der Organisation durchgeführt werden.", "httpDestConnectionLogsTitle": "Verbindungsprotokolle", "httpDestConnectionLogsDescription": "Site- und Tunnelverbindungen, einschließlich Verbindungen und Trennungen.", - "httpDestRequestLogsTitle": "Logs anfordern", + "httpDestRequestLogsTitle": "HTTP Anforderungsprotokolle", "httpDestRequestLogsDescription": "HTTP-Request-Protokolle für proxiierte Ressourcen, einschließlich Methode, Pfad und Antwort-Code.", "httpDestSaveChanges": "Änderungen speichern", "httpDestCreateDestination": "Ziel erstellen", @@ -3208,5 +3208,48 @@ "domainPickerWildcardCertWarning": "Wildcard-Ressourcen erfordern möglicherweise zusätzliche Konfigurationen, um ordnungsgemäß zu funktionieren.", "domainPickerWildcardCertWarningLink": "Mehr erfahren", "health": "Gesundheit", - "domainPendingErrorTitle": "Verifizierungsproblem" + "domainPendingErrorTitle": "Verifizierungsproblem", + "memberPortalTitle": "Ressourcen", + "memberPortalDescription": "Ressourcen, auf die Sie in dieser Organisation Zugriff haben", + "memberPortalSortBy": "Sortieren nach...", + "memberPortalSortNameAsc": "Name A-Z", + "memberPortalSortNameDesc": "Name Z-A", + "memberPortalSortDomainAsc": "Domain A-Z", + "memberPortalSortDomainDesc": "Domain Z-A", + "memberPortalSortEnabledFirst": "Zuerst aktiviert", + "memberPortalSortDisabledFirst": "Zuerst deaktiviert", + "memberPortalRefresh": "Aktualisieren", + "memberPortalRefreshResources": "Ressourcen aktualisieren", + "memberPortalFailedToLoad": "Fehler beim Laden der Ressourcen", + "memberPortalFailedToLoadDescription": "Fehler beim Laden der Ressourcen. Bitte überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.", + "memberPortalUnableToLoad": "Ressourcen konnten nicht geladen werden", + "memberPortalTryAgain": "Nochmal versuchen", + "memberPortalNoResourcesFound": "Keine Ressourcen gefunden", + "memberPortalNoResourcesAvailable": "Keine Ressourcen verfügbar", + "memberPortalNoResourcesMatchSearch": "Keine Ressourcen passen zu \"{query}\". Versuchen Sie, Ihre Suchbegriffe anzupassen oder die Suche zu löschen, um alle Ressourcen anzuzeigen.", + "memberPortalNoResourcesAccess": "Sie haben noch keinen Zugriff auf Ressourcen. Wenden Sie sich an Ihren Administrator, um Zugriff auf die benötigten Ressourcen zu erhalten.", + "memberPortalClearSearch": "Suchverlauf löschen", + "memberPortalPublicResources": "Öffentliche Ressourcen", + "memberPortalPublicResourcesDescription": "Webanwendungen und Dienste, die über den Browser zugänglich sind", + "memberPortalCopiedToClipboard": "In die Zwischenablage kopiert", + "memberPortalCopiedUrlDescription": "Ressourcen-URL wurde in Ihre Zwischenablage kopiert.", + "memberPortalOpenResource": "Ressource öffnen", + "memberPortalPrivateResources": "Private Ressourcen", + "memberPortalPrivateResourcesDescription": "Interne Netzwerkressourcen, die über den Client zugänglich sind", + "memberPortalResourceDetails": "Ressourcendetails", + "memberPortalMode": "Modus", + "memberPortalDestination": "Ziel", + "memberPortalAlias": "Alias", + "memberPortalCopiedAliasDescription": "Ressourcenalias wurde in Ihre Zwischenablage kopiert.", + "memberPortalCopiedDestinationDescription": "Ressourcenziel wurde in Ihre Zwischenablage kopiert.", + "memberPortalRequiresClientConnection": "Erfordert Client-Verbindung", + "memberPortalAuthMethods": "Authentifizierungsmethoden", + "memberPortalSso": "Single Sign-On (SSO)", + "memberPortalPasswordProtected": "Passwortgeschützt", + "memberPortalPinCode": "PIN-Code", + "memberPortalEmailWhitelist": "E-Mail-Whitelist", + "memberPortalResourceDisabled": "Ressource deaktiviert", + "memberPortalShowingResources": "Zeige {start}-{end} von {total} Ressourcen", + "memberPortalPrevious": "Vorherige", + "memberPortalNext": "Nächste" } diff --git a/messages/en-US.json b/messages/en-US.json index ee4ef143d..a598dcc39 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2660,19 +2660,19 @@ "noMoreAuthMethods": "No Valid Auth", "ip": "IP", "reason": "Reason", - "requestLogs": "HTTPS Request Logs", + "requestLogs": "HTTP Request Logs", "requestAnalytics": "Request Analytics", "host": "Host", "location": "Location", "actionLogs": "Admin Action Logs", - "sidebarLogsRequest": "HTTPS Request Logs", + "sidebarLogsRequest": "HTTP Request Logs", "sidebarLogsAccess": "Authentication Logs", "sidebarLogsAction": "Admin Action Logs", "logRetention": "Log Retention", "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", "requestLogsDescription": "View detailed request logs for HTTPS resources in this organization", "requestAnalyticsDescription": "View detailed request analytics for resources in this organization", - "logRetentionRequestLabel": "HTTPS Request Log Retention", + "logRetentionRequestLabel": "HTTP Request Log Retention", "logRetentionRequestDescription": "How long to retain request logs", "logRetentionAccessLabel": "Authentication Log Retention", "logRetentionAccessDescription": "How long to retain access logs", @@ -3134,7 +3134,7 @@ "httpDestActionLogsDescription": "Administrative actions performed by users within the organization.", "httpDestConnectionLogsTitle": "Network Logs", "httpDestConnectionLogsDescription": "Site and tunnel connection events, including connects and disconnects.", - "httpDestRequestLogsTitle": "HTTPS Request Logs", + "httpDestRequestLogsTitle": "HTTP Request Logs", "httpDestRequestLogsDescription": "HTTP request logs for proxied resources, including method, path, and response code.", "httpDestSaveChanges": "Save Changes", "httpDestCreateDestination": "Create Destination", @@ -3208,5 +3208,48 @@ "domainPickerWildcardCertWarning": "Wildcard resources may require additional configuration to work properly.", "domainPickerWildcardCertWarningLink": "Learn more", "health": "Health", - "domainPendingErrorTitle": "Verification Issue" + "domainPendingErrorTitle": "Verification Issue", + "memberPortalTitle": "Resources", + "memberPortalDescription": "Resources you have access to in this organization", + "memberPortalSortBy": "Sort by...", + "memberPortalSortNameAsc": "Name A-Z", + "memberPortalSortNameDesc": "Name Z-A", + "memberPortalSortDomainAsc": "Domain A-Z", + "memberPortalSortDomainDesc": "Domain Z-A", + "memberPortalSortEnabledFirst": "Enabled First", + "memberPortalSortDisabledFirst": "Disabled First", + "memberPortalRefresh": "Refresh", + "memberPortalRefreshResources": "Refresh Resources", + "memberPortalFailedToLoad": "Failed to load resources", + "memberPortalFailedToLoadDescription": "Failed to load resources. Please check your connection and try again.", + "memberPortalUnableToLoad": "Unable to Load Resources", + "memberPortalTryAgain": "Try Again", + "memberPortalNoResourcesFound": "No Resources Found", + "memberPortalNoResourcesAvailable": "No Resources Available", + "memberPortalNoResourcesMatchSearch": "No resources match \"{query}\". Try adjusting your search terms or clearing the search to see all resources.", + "memberPortalNoResourcesAccess": "You don't have access to any resources yet. Contact your administrator to get access to resources you need.", + "memberPortalClearSearch": "Clear Search", + "memberPortalPublicResources": "Public Resources", + "memberPortalPublicResourcesDescription": "Web applications and services accessible via browser", + "memberPortalCopiedToClipboard": "Copied to clipboard", + "memberPortalCopiedUrlDescription": "Resource URL has been copied to your clipboard.", + "memberPortalOpenResource": "Open Resource", + "memberPortalPrivateResources": "Private Resources", + "memberPortalPrivateResourcesDescription": "Internal network resources accessible via client", + "memberPortalResourceDetails": "Resource Details", + "memberPortalMode": "Mode", + "memberPortalDestination": "Destination", + "memberPortalAlias": "Alias", + "memberPortalCopiedAliasDescription": "Resource alias has been copied to your clipboard.", + "memberPortalCopiedDestinationDescription": "Resource destination has been copied to your clipboard.", + "memberPortalRequiresClientConnection": "Requires Client Connection", + "memberPortalAuthMethods": "Authentication Methods", + "memberPortalSso": "Single Sign-On (SSO)", + "memberPortalPasswordProtected": "Password Protected", + "memberPortalPinCode": "PIN Code", + "memberPortalEmailWhitelist": "Email Whitelist", + "memberPortalResourceDisabled": "Resource Disabled", + "memberPortalShowingResources": "Showing {start}-{end} of {total} resources", + "memberPortalPrevious": "Previous", + "memberPortalNext": "Next" } diff --git a/messages/es-ES.json b/messages/es-ES.json index e93aff379..e610233f7 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -2660,19 +2660,19 @@ "noMoreAuthMethods": "No Valid Auth", "ip": "IP", "reason": "Razón", - "requestLogs": "Registros de Solicitud", + "requestLogs": "Registros de Solicitud HTTP", "requestAnalytics": "Analítica de Solicitud", "host": "Anfitrión", "location": "Ubicación", "actionLogs": "Registros de acción", - "sidebarLogsRequest": "Registros de Solicitud", + "sidebarLogsRequest": "Registros de Solicitud HTTP", "sidebarLogsAccess": "Registros de acceso", "sidebarLogsAction": "Registros de acción", "logRetention": "Retención de Log", "logRetentionDescription": "Administrar cuánto tiempo se conservan los diferentes tipos de registros para esta organización o desactivarlos", "requestLogsDescription": "Ver registros de solicitudes detallados para los recursos de esta organización", "requestAnalyticsDescription": "Ver análisis de solicitudes detalladas de recursos en esta organización", - "logRetentionRequestLabel": "Retención de Registro de Solicitud", + "logRetentionRequestLabel": "Retención de Registro de Solicitud HTTP", "logRetentionRequestDescription": "Cuánto tiempo conservar los registros de solicitudes", "logRetentionAccessLabel": "Retención de Log de Acceso", "logRetentionAccessDescription": "Cuánto tiempo retener los registros de acceso", @@ -3134,7 +3134,7 @@ "httpDestActionLogsDescription": "Acciones administrativas realizadas por los usuarios dentro de la organización.", "httpDestConnectionLogsTitle": "Registros de conexión", "httpDestConnectionLogsDescription": "Eventos de conexión de sitios y túneles, incluyendo conexiones y desconexiones.", - "httpDestRequestLogsTitle": "Registros de Solicitud", + "httpDestRequestLogsTitle": "Registros de Solicitud HTTP", "httpDestRequestLogsDescription": "Registros de peticiones HTTP para recursos proxyficados, incluyendo método, ruta y código de respuesta.", "httpDestSaveChanges": "Guardar Cambios", "httpDestCreateDestination": "Crear destino", @@ -3208,5 +3208,48 @@ "domainPickerWildcardCertWarning": "Los recursos comodín pueden requerir configuración adicional para funcionar correctamente.", "domainPickerWildcardCertWarningLink": "Más información", "health": "Salud", - "domainPendingErrorTitle": "Problema de verificación" + "domainPendingErrorTitle": "Problema de verificación", + "memberPortalTitle": "Recursos", + "memberPortalDescription": "Recursos a los que tiene acceso en esta organización", + "memberPortalSortBy": "Ordenar por...", + "memberPortalSortNameAsc": "Nombre A-Z", + "memberPortalSortNameDesc": "Nombre Z-A", + "memberPortalSortDomainAsc": "Dominio A-Z", + "memberPortalSortDomainDesc": "Dominio Z-A", + "memberPortalSortEnabledFirst": "Habilitado Primero", + "memberPortalSortDisabledFirst": "Deshabilitado Primero", + "memberPortalRefresh": "Actualizar", + "memberPortalRefreshResources": "Actualizar Recursos", + "memberPortalFailedToLoad": "No se pudieron cargar los recursos", + "memberPortalFailedToLoadDescription": "No se pudieron cargar los recursos. Por favor, revise su conexión e intente de nuevo.", + "memberPortalUnableToLoad": "No se pudieron cargar los recursos", + "memberPortalTryAgain": "Intentar de Nuevo", + "memberPortalNoResourcesFound": "No se encontraron Recursos", + "memberPortalNoResourcesAvailable": "No Hay Recursos Disponibles", + "memberPortalNoResourcesMatchSearch": "No hay recursos que coincidan con \"{query}\". Intenta ajustar tus términos de búsqueda o limpiar la búsqueda para ver todos los recursos.", + "memberPortalNoResourcesAccess": "Aún no tiene acceso a ningún recurso. Comuníquese con su administrador para obtener acceso a los recursos que necesita.", + "memberPortalClearSearch": "Limpiar Búsqueda", + "memberPortalPublicResources": "Recursos Públicos", + "memberPortalPublicResourcesDescription": "Aplicaciones web y servicios accesibles vía navegador", + "memberPortalCopiedToClipboard": "Copiado al portapapeles", + "memberPortalCopiedUrlDescription": "La URL del recurso ha sido copiada a su portapapeles.", + "memberPortalOpenResource": "Abrir Recurso", + "memberPortalPrivateResources": "Recursos Privados", + "memberPortalPrivateResourcesDescription": "Recursos de red interna accesibles vía cliente", + "memberPortalResourceDetails": "Detalles del Recurso", + "memberPortalMode": "Modo", + "memberPortalDestination": "Destino", + "memberPortalAlias": "Alias", + "memberPortalCopiedAliasDescription": "El alias del recurso ha sido copiado a su portapapeles.", + "memberPortalCopiedDestinationDescription": "El destino del recurso ha sido copiado a su portapapeles.", + "memberPortalRequiresClientConnection": "Requiere Conexión de Cliente", + "memberPortalAuthMethods": "Métodos de Autenticación", + "memberPortalSso": "Inicio de Sesión Único (SSO)", + "memberPortalPasswordProtected": "Protegido por Contraseña", + "memberPortalPinCode": "Código PIN", + "memberPortalEmailWhitelist": "Lista Blanca de Correo", + "memberPortalResourceDisabled": "Recurso Deshabilitado", + "memberPortalShowingResources": "Mostrando {start}-{end} de {total} recursos", + "memberPortalPrevious": "Anterior", + "memberPortalNext": "Siguiente" } diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 58eb7d628..4bcbeaa0f 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -1356,7 +1356,7 @@ "sidebarSites": "Nœuds", "sidebarApprovals": "Demandes d'approbation", "sidebarResources": "Ressource", - "sidebarProxyResources": "Publique", + "sidebarProxyResources": "Publiques", "sidebarClientResources": "Privé", "sidebarAccessControl": "Contrôle d'accès", "sidebarLogsAndAnalytics": "Journaux & Analytiques", @@ -2458,8 +2458,8 @@ "manageUserDevicesDescription": "Voir et gérer les appareils que les utilisateurs utilisent pour se connecter en privé aux ressources", "downloadClientBannerTitle": "Télécharger le client Pangolin", "downloadClientBannerDescription": "Téléchargez le client Pangolin pour votre système afin de vous connecter au réseau Pangolin et accéder aux ressources de manière privée.", - "manageMachineClients": "Gérer les clients de la machine", - "manageMachineClientsDescription": "Créer et gérer des clients que les serveurs et les systèmes utilisent pour se connecter en privé aux ressources", + "manageMachineClients": "Gérer les machines", + "manageMachineClientsDescription": "Créer et gérer les clients que les serveurs et systèmes utilisent pour se connecter en privé aux ressources", "machineClientsBannerTitle": "Serveurs & Systèmes automatisés", "machineClientsBannerDescription": "Les clients de machine sont conçus pour les serveurs et les systèmes automatisés qui ne sont pas associés à un utilisateur spécifique. Ils s'authentifient avec un identifiant et une clé secrète, et peuvent être exécutés avec Pangolin CLI, Olm CLI ou Olm en tant que conteneur.", "machineClientsBannerPangolinCLI": "Pangolin CLI", @@ -2660,19 +2660,19 @@ "noMoreAuthMethods": "No Valid Auth", "ip": "IP", "reason": "Raison", - "requestLogs": "Journal des requêtes", + "requestLogs": "Journal des Requêtes HTTP", "requestAnalytics": "Demander des analyses", "host": "Hôte", "location": "Localisation", "actionLogs": "Journaux des actions", - "sidebarLogsRequest": "Journal des requêtes", + "sidebarLogsRequest": "Journal des Requêtes HTTP", "sidebarLogsAccess": "Journaux d'accès", "sidebarLogsAction": "Journaux des actions", "logRetention": "Journaliser la rétention", "logRetentionDescription": "Gérer la durée de conservation des différents types de logs pour cette organisation ou les désactiver", "requestLogsDescription": "Voir les journaux détaillés des requêtes pour les ressources de cette organisation", "requestAnalyticsDescription": "Voir les analyses détaillées des demandes pour les ressources de cette organisation", - "logRetentionRequestLabel": "Demander la rétention des journaux", + "logRetentionRequestLabel": "Rétention des Journaux de Requêtes HTTP", "logRetentionRequestDescription": "Durée de conservation des journaux de requêtes", "logRetentionAccessLabel": "Rétention du journal d'accès", "logRetentionAccessDescription": "Durée de conservation des journaux d'accès", @@ -3134,7 +3134,7 @@ "httpDestActionLogsDescription": "Actions administratives effectuées par les utilisateurs au sein de l'organisation.", "httpDestConnectionLogsTitle": "Journaux de connexion", "httpDestConnectionLogsDescription": "Événements de connexion du site et du tunnel, y compris les connexions et les déconnexions.", - "httpDestRequestLogsTitle": "Journal des requêtes", + "httpDestRequestLogsTitle": "Journal des Requêtes HTTP", "httpDestRequestLogsDescription": "Journaux des requêtes HTTP pour les ressources proxiées, y compris la méthode, le chemin et le code de réponse.", "httpDestSaveChanges": "Enregistrer les modifications", "httpDestCreateDestination": "Créer une destination", @@ -3154,6 +3154,7 @@ "healthCheckTabAdvanced": "Avancé", "healthCheckStrategyNotAvailable": "Cette stratégie n'est pas disponible. Veuillez contacter le service commercial pour activer cette fonctionnalité.", "uptime30d": "Disponibilité (30j)", + "uptimeNoData": "Aucune donnée", "idpAddActionCreateNew": "Créer un nouveau fournisseur d'identité", "idpAddActionImportFromOrg": "Importer d'une autre organisation", "idpImportDialogTitle": "Importer le fournisseur d'identité", @@ -3208,5 +3209,48 @@ "domainPickerWildcardCertWarning": "Les ressources Joker peuvent nécessiter une configuration supplémentaire pour fonctionner correctement.", "domainPickerWildcardCertWarningLink": "En savoir plus", "health": "Santé", - "domainPendingErrorTitle": "Problème de vérification" + "domainPendingErrorTitle": "Problème de vérification", + "memberPortalTitle": "Ressources", + "memberPortalDescription": "Ressources auxquelles vous avez accès dans cette organisation", + "memberPortalSortBy": "Trier par...", + "memberPortalSortNameAsc": "Nom A-Z", + "memberPortalSortNameDesc": "Nom Z-A", + "memberPortalSortDomainAsc": "Domaine A-Z", + "memberPortalSortDomainDesc": "Domaine Z-A", + "memberPortalSortEnabledFirst": "Activé en premier", + "memberPortalSortDisabledFirst": "Désactivé en premier", + "memberPortalRefresh": "Actualiser", + "memberPortalRefreshResources": "Actualiser les ressources", + "memberPortalFailedToLoad": "Échec du chargement des ressources", + "memberPortalFailedToLoadDescription": "Échec du chargement des ressources. Veuillez vérifier votre connexion et réessayer.", + "memberPortalUnableToLoad": "Impossible de charger les ressources", + "memberPortalTryAgain": "Réessayer", + "memberPortalNoResourcesFound": "Aucune ressource trouvée", + "memberPortalNoResourcesAvailable": "Aucune ressource disponible", + "memberPortalNoResourcesMatchSearch": "Aucune ressource ne correspond à \"{query}\". Essayez d'ajuster vos termes de recherche ou de vider la recherche pour voir toutes les ressources.", + "memberPortalNoResourcesAccess": "Vous n'avez encore accès à aucune ressource. Contactez votre administrateur pour obtenir l'accès aux ressources dont vous avez besoin.", + "memberPortalClearSearch": "Effacer la recherche", + "memberPortalPublicResources": "Ressources publiques", + "memberPortalPublicResourcesDescription": "Applications et services web accessibles via un navigateur", + "memberPortalCopiedToClipboard": "Copié dans le presse-papiers", + "memberPortalCopiedUrlDescription": "L'URL de la ressource a été copiée dans votre presse-papiers.", + "memberPortalOpenResource": "Ouvrir la ressource", + "memberPortalPrivateResources": "Ressources privées", + "memberPortalPrivateResourcesDescription": "Ressources réseau internes accessibles via un client", + "memberPortalResourceDetails": "Détails de la ressource", + "memberPortalMode": "Mode", + "memberPortalDestination": "Destination", + "memberPortalAlias": "Alias", + "memberPortalCopiedAliasDescription": "L'alias de la ressource a été copié dans votre presse-papiers.", + "memberPortalCopiedDestinationDescription": "La destination de la ressource a été copiée dans votre presse-papiers.", + "memberPortalRequiresClientConnection": "Nécessite une connexion client", + "memberPortalAuthMethods": "Méthodes d'authentification", + "memberPortalSso": "Authentification unique (SSO)", + "memberPortalPasswordProtected": "Protégé par un mot de passe", + "memberPortalPinCode": "Code PIN", + "memberPortalEmailWhitelist": "Liste blanche des e-mails", + "memberPortalResourceDisabled": "Ressource désactivée", + "memberPortalShowingResources": "Affichage de {start}-{end} sur {total} ressources", + "memberPortalPrevious": "Précédent", + "memberPortalNext": "Suivant" } diff --git a/messages/it-IT.json b/messages/it-IT.json index d98df3cc2..c739b269d 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -2660,19 +2660,19 @@ "noMoreAuthMethods": "No Valid Auth", "ip": "IP", "reason": "Motivo", - "requestLogs": "Log Richiesta", + "requestLogs": "Log Richieste HTTP", "requestAnalytics": "Richiedi Analisi", "host": "Host", "location": "Posizione", "actionLogs": "Log Azioni", - "sidebarLogsRequest": "Log Richiesta", + "sidebarLogsRequest": "Log Richieste HTTP", "sidebarLogsAccess": "Log Accesso", "sidebarLogsAction": "Log Azioni", "logRetention": "Ritenzione Registro", "logRetentionDescription": "Gestisci per quanto tempo i diversi tipi di log sono mantenuti per questa organizzazione o disabilitali", "requestLogsDescription": "Visualizza i registri di richiesta dettagliati per le risorse in questa organizzazione", "requestAnalyticsDescription": "Visualizza le analisi dettagliate della richiesta per le risorse in questa organizzazione", - "logRetentionRequestLabel": "Richiedi Ritenzione Log", + "logRetentionRequestLabel": "Conservazione Log Richieste HTTP", "logRetentionRequestDescription": "Per quanto tempo conservare i log delle richieste", "logRetentionAccessLabel": "Ritenzione Registro Accesso", "logRetentionAccessDescription": "Per quanto tempo conservare i log di accesso", @@ -3134,7 +3134,7 @@ "httpDestActionLogsDescription": "Azioni amministrative eseguite dagli utenti all'interno dell'organizzazione.", "httpDestConnectionLogsTitle": "Log Di Connessione", "httpDestConnectionLogsDescription": "Eventi di connessione al sito e al tunnel, inclusi collegamenti e disconnessioni.", - "httpDestRequestLogsTitle": "Log Richiesta", + "httpDestRequestLogsTitle": "Log Richieste HTTP", "httpDestRequestLogsDescription": "Registri di richiesta HTTP per le risorse proxy, inclusi metodo, percorso e codice di risposta.", "httpDestSaveChanges": "Salva Modifiche", "httpDestCreateDestination": "Crea Destinazione", @@ -3208,5 +3208,48 @@ "domainPickerWildcardCertWarning": "Le risorse wildcard potrebbero richiedere configurazioni aggiuntive per funzionare correttamente.", "domainPickerWildcardCertWarningLink": "Scopri di più", "health": "Salute", - "domainPendingErrorTitle": "Problema di Verifica" + "domainPendingErrorTitle": "Problema di Verifica", + "memberPortalTitle": "Risorse", + "memberPortalDescription": "Risorse a cui hai accesso in questa organizzazione", + "memberPortalSortBy": "Ordina per...", + "memberPortalSortNameAsc": "Nome A-Z", + "memberPortalSortNameDesc": "Nome Z-A", + "memberPortalSortDomainAsc": "Dominio A-Z", + "memberPortalSortDomainDesc": "Dominio Z-A", + "memberPortalSortEnabledFirst": "Abilitati per primi", + "memberPortalSortDisabledFirst": "Disabilitati per primi", + "memberPortalRefresh": "Aggiorna", + "memberPortalRefreshResources": "Aggiorna Risorse", + "memberPortalFailedToLoad": "Caricamento delle risorse non riuscito", + "memberPortalFailedToLoadDescription": "Caricamento delle risorse non riuscito. Controlla la tua connessione e riprova.", + "memberPortalUnableToLoad": "Impossibile caricare le risorse", + "memberPortalTryAgain": "Riprova", + "memberPortalNoResourcesFound": "Nessuna risorsa trovata", + "memberPortalNoResourcesAvailable": "Nessuna risorsa disponibile", + "memberPortalNoResourcesMatchSearch": "Nessuna risorsa corrisponde a \"{query}\". Prova ad aggiustare i termini di ricerca o a cancellare la ricerca per vedere tutte le risorse.", + "memberPortalNoResourcesAccess": "Non hai ancora accesso a nessuna risorsa. Contatta il tuo amministratore per ottenere l'accesso alle risorse di cui hai bisogno.", + "memberPortalClearSearch": "Cancella Ricerca", + "memberPortalPublicResources": "Risorse Pubbliche", + "memberPortalPublicResourcesDescription": "Applicazioni web e servizi accessibili tramite browser", + "memberPortalCopiedToClipboard": "Copiato negli appunti", + "memberPortalCopiedUrlDescription": "L'URL della risorsa è stato copiato negli appunti.", + "memberPortalOpenResource": "Apri Risorsa", + "memberPortalPrivateResources": "Risorse Private", + "memberPortalPrivateResourcesDescription": "Risorse di rete interne accessibili tramite client", + "memberPortalResourceDetails": "Dettagli della Risorsa", + "memberPortalMode": "Modalità", + "memberPortalDestination": "Destinazione", + "memberPortalAlias": "Alias", + "memberPortalCopiedAliasDescription": "L'alias della risorsa è stato copiato negli appunti.", + "memberPortalCopiedDestinationDescription": "La destinazione della risorsa è stata copiata negli appunti.", + "memberPortalRequiresClientConnection": "Richiede Connessione Client", + "memberPortalAuthMethods": "Metodi di Autenticazione", + "memberPortalSso": "Accesso unico (Single Sign-On, SSO)", + "memberPortalPasswordProtected": "Protetto da password", + "memberPortalPinCode": "Codice PIN", + "memberPortalEmailWhitelist": "Lista Autorizzazioni Email", + "memberPortalResourceDisabled": "Risorsa Disabilitata", + "memberPortalShowingResources": "Mostrando {start}-{end} di {total} risorse", + "memberPortalPrevious": "Precedente", + "memberPortalNext": "Successivo" } diff --git a/messages/ko-KR.json b/messages/ko-KR.json index e82112f10..1d3d77fe2 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -2660,19 +2660,19 @@ "noMoreAuthMethods": "유효한 인증 없음", "ip": "IP", "reason": "이유", - "requestLogs": "요청 로그", + "requestLogs": "HTTP 요청 로그", "requestAnalytics": "요청 분석", "host": "호스트", "location": "위치", "actionLogs": "작업 로그", - "sidebarLogsRequest": "요청 로그", + "sidebarLogsRequest": "HTTP 요청 로그", "sidebarLogsAccess": "접근 로그", "sidebarLogsAction": "작업 로그", "logRetention": "로그 보관", "logRetentionDescription": "다양한 유형의 로그를 이 조직에 대해 얼마나 오래 보관할지 관리하거나 비활성화합니다", "requestLogsDescription": "이 조직의 자원에 대한 상세한 요청 로그를 봅니다", "requestAnalyticsDescription": "이 조직의 리소스에 대한 자세한 요청 분석 보기", - "logRetentionRequestLabel": "요청 로그 보관", + "logRetentionRequestLabel": "HTTP 요청 로그 보관", "logRetentionRequestDescription": "요청 로그를 얼마나 오래 보관할지", "logRetentionAccessLabel": "접근 로그 보관", "logRetentionAccessDescription": "접근 로그를 얼마나 오래 보관할지", @@ -3134,7 +3134,7 @@ "httpDestActionLogsDescription": "조직 내에서 사용자가 수행한 관리 작업.", "httpDestConnectionLogsTitle": "연결 로그", "httpDestConnectionLogsDescription": "사이트 및 터널 연결 이벤트, 연결 및 연결 끊기를 포함합니다.", - "httpDestRequestLogsTitle": "요청 로그", + "httpDestRequestLogsTitle": "HTTP 요청 로그", "httpDestRequestLogsDescription": "프록시된 리소스에 대한 HTTP 요청 로그, 메서드, 경로 및 응답 코드를 포함합니다.", "httpDestSaveChanges": "변경 사항 저장", "httpDestCreateDestination": "대상지 생성", @@ -3208,5 +3208,48 @@ "domainPickerWildcardCertWarning": "와일드카드 리소스는 올바르게 작동하려면 추가 구성이 필요할 수 있습니다.", "domainPickerWildcardCertWarningLink": "자세히 알아보기", "health": "건강", - "domainPendingErrorTitle": "확인 문제" + "domainPendingErrorTitle": "확인 문제", + "memberPortalTitle": "리소스", + "memberPortalDescription": "이 조직에서 접근할 수 있는 리소스", + "memberPortalSortBy": "정렬 기준...", + "memberPortalSortNameAsc": "이름 A-Z", + "memberPortalSortNameDesc": "이름 Z-A", + "memberPortalSortDomainAsc": "도메인 A-Z", + "memberPortalSortDomainDesc": "도메인 Z-A", + "memberPortalSortEnabledFirst": "사용 활성화 우선", + "memberPortalSortDisabledFirst": "사용 비활성화 우선", + "memberPortalRefresh": "새로 고침", + "memberPortalRefreshResources": "리소스 새로 고침", + "memberPortalFailedToLoad": "리소스를 불러오는 데 실패했습니다", + "memberPortalFailedToLoadDescription": "리소스를 불러오는 데 실패했습니다. 연결을 확인하고 다시 시도해 주십시오.", + "memberPortalUnableToLoad": "리소스를 가져오는 데 실패했습니다", + "memberPortalTryAgain": "다시 시도", + "memberPortalNoResourcesFound": "리소스를 발견하지 못했습니다", + "memberPortalNoResourcesAvailable": "사용 가능한 리소스가 없습니다", + "memberPortalNoResourcesMatchSearch": "\"{query}\"와 일치하는 리소스가 없습니다. 검색어를 수정하거나 검색을 초기화하여 모든 리소스를 확인하십시오.", + "memberPortalNoResourcesAccess": "아직 접근할 수 있는 리소스가 없습니다. 필요한 리소스 접근을 위해 관리자에게 문의하세요.", + "memberPortalClearSearch": "검색 초기화", + "memberPortalPublicResources": "공공 리소스", + "memberPortalPublicResourcesDescription": "브라우저를 통해 접근 가능한 웹 애플리케이션 및 서비스", + "memberPortalCopiedToClipboard": "클립보드에 복사됨", + "memberPortalCopiedUrlDescription": "리소스 URL이 클립보드에 복사되었습니다.", + "memberPortalOpenResource": "리소스 열기", + "memberPortalPrivateResources": "비공개 리소스", + "memberPortalPrivateResourcesDescription": "클라이언트를 통해 접근 가능한 내부 네트워크 리소스", + "memberPortalResourceDetails": "리소스 세부 정보", + "memberPortalMode": "모드", + "memberPortalDestination": "대상지", + "memberPortalAlias": "별칭", + "memberPortalCopiedAliasDescription": "리소스 별칭이 클립보드에 복사되었습니다.", + "memberPortalCopiedDestinationDescription": "리소스 대상지가 클립보드에 복사되었습니다.", + "memberPortalRequiresClientConnection": "클라이언트 연결 필요", + "memberPortalAuthMethods": "인증 방법", + "memberPortalSso": "싱글 사인온 (SSO)", + "memberPortalPasswordProtected": "비밀번호 보호", + "memberPortalPinCode": "PIN 코드", + "memberPortalEmailWhitelist": "이메일 화이트리스트", + "memberPortalResourceDisabled": "리소스 비활성화됨", + "memberPortalShowingResources": "{start}-{end} 중 {total}개의 리소스를 표시 중", + "memberPortalPrevious": "이전", + "memberPortalNext": "다음" } diff --git a/messages/nb-NO.json b/messages/nb-NO.json index b33c457f7..72a7c21df 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -2660,19 +2660,19 @@ "noMoreAuthMethods": "No Valid Auth", "ip": "IP", "reason": "Grunn", - "requestLogs": "Forespørselslogger (Automatic Translation)", + "requestLogs": "HTTP-forespørselslogger", "requestAnalytics": "Be om analyser", "host": "Vert", "location": "Sted", "actionLogs": "Handlingslogger", - "sidebarLogsRequest": "Forespørselslogger (Automatic Translation)", + "sidebarLogsRequest": "HTTP-forespørselslogger", "sidebarLogsAccess": "Tilgangslogger (Automatic Translation)", "sidebarLogsAction": "Handlingslogger", "logRetention": "Logg tilbaketrekning", "logRetentionDescription": "Håndter hvor lenge ulike typer logger beholdes for denne organisasjonen, eller deaktiver dem", "requestLogsDescription": "Se detaljerte forespørselslogger for ressurser i denne organisasjonen", "requestAnalyticsDescription": "Se detaljert rekvisisjonsanalyse for ressurser i denne organisasjonen", - "logRetentionRequestLabel": "Be om loggoverføring", + "logRetentionRequestLabel": "Be om loggbevaring", "logRetentionRequestDescription": "Hvor lenge du vil beholde forespørselslogger", "logRetentionAccessLabel": "Få tilgang til loggoverføring", "logRetentionAccessDescription": "Hvor lenge du vil beholde adgangslogger", @@ -3134,7 +3134,7 @@ "httpDestActionLogsDescription": "Administrative tiltak som utføres av brukere innenfor organisasjonen.", "httpDestConnectionLogsTitle": "Loggfiler for tilkobling", "httpDestConnectionLogsDescription": "Utstyrs- og tunneltilkoblingshendelser, inkludert forbindelser og frakobling.", - "httpDestRequestLogsTitle": "Forespørselslogger (Automatic Translation)", + "httpDestRequestLogsTitle": "HTTP-forespørselslogger", "httpDestRequestLogsDescription": "HTTP-forespørsel logger for bekreftede ressurser, inkludert metode, bane og responskode.", "httpDestSaveChanges": "Lagre endringer", "httpDestCreateDestination": "Opprett mål", @@ -3208,5 +3208,48 @@ "domainPickerWildcardCertWarning": "Jokertegnressurser kan kreve ekstra konfigurasjon for å fungere skikkelig.", "domainPickerWildcardCertWarningLink": "Lær mer", "health": "Helse", - "domainPendingErrorTitle": "Verifiseringsproblem" + "domainPendingErrorTitle": "Verifiseringsproblem", + "memberPortalTitle": "Ressurser", + "memberPortalDescription": "Ressurser du har tilgang til i denne organisasjonen", + "memberPortalSortBy": "Sorter etter...", + "memberPortalSortNameAsc": "Navn A-Å", + "memberPortalSortNameDesc": "Navn Å-A", + "memberPortalSortDomainAsc": "Domene A-Å", + "memberPortalSortDomainDesc": "Domene Å-A", + "memberPortalSortEnabledFirst": "Aktivert først", + "memberPortalSortDisabledFirst": "Deaktivert først", + "memberPortalRefresh": "Oppdater", + "memberPortalRefreshResources": "Oppdater ressurser", + "memberPortalFailedToLoad": "Kunne ikke laste inn ressurser", + "memberPortalFailedToLoadDescription": "Kunne ikke laste inn ressurser. Vennligst sjekk tilkoblingen din og prøv igjen.", + "memberPortalUnableToLoad": "Kan ikke laste inn ressurser", + "memberPortalTryAgain": "Prøv igjen", + "memberPortalNoResourcesFound": "Ingen ressurser funnet", + "memberPortalNoResourcesAvailable": "Ingen ressurser tilgjengelig", + "memberPortalNoResourcesMatchSearch": "Ingen ressurser samsvarer med \"{query}\". Prøv å justere søkeordene dine eller fjern søket for å se alle ressurser.", + "memberPortalNoResourcesAccess": "Du har ennå ikke tilgang til noen ressurser. Kontakt administratoren din for å få tilgang til de ressursene du trenger.", + "memberPortalClearSearch": "Fjern søk", + "memberPortalPublicResources": "Offentlige ressurser", + "memberPortalPublicResourcesDescription": "Webapplikasjoner og -tjenester tilgjengelige via nettleser", + "memberPortalCopiedToClipboard": "Kopiert til utklippstavlen", + "memberPortalCopiedUrlDescription": "Ressurs-URL er kopiert til utklippstavlen din.", + "memberPortalOpenResource": "Åpne ressurs", + "memberPortalPrivateResources": "Private ressurser", + "memberPortalPrivateResourcesDescription": "Interne nettverksressurser tilgjengelige via klient", + "memberPortalResourceDetails": "Ressursdetaljer", + "memberPortalMode": "Modus", + "memberPortalDestination": "Destinasjon", + "memberPortalAlias": "Navn", + "memberPortalCopiedAliasDescription": "Ressursalias er kopiert til utklippstavlen din.", + "memberPortalCopiedDestinationDescription": "Ressursdestinasjon er kopiert til utklippstavlen din.", + "memberPortalRequiresClientConnection": "Krever klienttilkobling", + "memberPortalAuthMethods": "Autentiseringsmetoder", + "memberPortalSso": "Enkeltpålogging (SSO)", + "memberPortalPasswordProtected": "Passordbeskyttet", + "memberPortalPinCode": "PIN-kode", + "memberPortalEmailWhitelist": "E-post-hviteliste", + "memberPortalResourceDisabled": "Ressurs deaktivert", + "memberPortalShowingResources": "Viser {start}-{end} av {total} ressurser", + "memberPortalPrevious": "Forrige", + "memberPortalNext": "Neste" } diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 60a35cc5d..5218c3388 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -2660,19 +2660,19 @@ "noMoreAuthMethods": "No Valid Auth", "ip": "IP-adres", "reason": "Reden", - "requestLogs": "Logboeken aanvragen", + "requestLogs": "HTTP-aanvraaglogboeken", "requestAnalytics": "Analytics opvragen", "host": "Hostnaam", "location": "Locatie", "actionLogs": "Actie logs", - "sidebarLogsRequest": "Logboeken aanvragen", + "sidebarLogsRequest": "HTTP-aanvraaglogboeken", "sidebarLogsAccess": "Toegang tot logboek", "sidebarLogsAction": "Actie logs", "logRetention": "Log bewaring", "logRetentionDescription": "Beheren hoe lang verschillende soorten logs bewaard worden voor deze organisatie of schakel ze uit", "requestLogsDescription": "Bekijk gedetailleerde verzoeklogboeken voor resources in deze organisatie", "requestAnalyticsDescription": "Bekijk gedetailleerde request analytics voor resources in deze organisatie", - "logRetentionRequestLabel": "Logboekbewaring aanvragen", + "logRetentionRequestLabel": "Bewaring van HTTP-aanvraaglogboeken", "logRetentionRequestDescription": "Hoe lang de aanvraaglogboeken te behouden", "logRetentionAccessLabel": "Toegang logboek bewaring", "logRetentionAccessDescription": "Hoe lang de toegangslogboeken behouden blijven", @@ -3134,7 +3134,7 @@ "httpDestActionLogsDescription": "Administratieve acties uitgevoerd door gebruikers binnen de organisatie.", "httpDestConnectionLogsTitle": "Connectie Logs", "httpDestConnectionLogsDescription": "Verbinding met de Site en tunnel maken verbroken, inclusief verbindingen en verbindingen.", - "httpDestRequestLogsTitle": "Logboeken aanvragen", + "httpDestRequestLogsTitle": "HTTP-aanvraaglogboeken", "httpDestRequestLogsDescription": "HTTP request logs voor proxied hulpmiddelen, waaronder methode, pad en response code.", "httpDestSaveChanges": "Wijzigingen opslaan", "httpDestCreateDestination": "Maak bestemming aan", @@ -3208,5 +3208,48 @@ "domainPickerWildcardCertWarning": "Wildcard-bronnen hebben mogelijk extra configuratie nodig om correct te werken.", "domainPickerWildcardCertWarningLink": "Meer informatie", "health": "Gezondheid", - "domainPendingErrorTitle": "Verificatieprobleem" + "domainPendingErrorTitle": "Verificatieprobleem", + "memberPortalTitle": "Bronnen", + "memberPortalDescription": "Bronnen waartoe je toegang hebt binnen deze organisatie", + "memberPortalSortBy": "Sorteren op...", + "memberPortalSortNameAsc": "Naam A-Z", + "memberPortalSortNameDesc": "Naam Z-A", + "memberPortalSortDomainAsc": "Domein A-Z", + "memberPortalSortDomainDesc": "Domein Z-A", + "memberPortalSortEnabledFirst": "Ingeschakeld Eerst", + "memberPortalSortDisabledFirst": "Uitgeschakeld Eerst", + "memberPortalRefresh": "Vernieuwen", + "memberPortalRefreshResources": "Bronnen Vernieuwen", + "memberPortalFailedToLoad": "Fout bij het laden van bronnen", + "memberPortalFailedToLoadDescription": "Fout bij het laden van bronnen. Controleer uw verbinding en probeer het opnieuw.", + "memberPortalUnableToLoad": "Niet in staat om bronnen te laden", + "memberPortalTryAgain": "Probeer Opnieuw", + "memberPortalNoResourcesFound": "Geen Bronnen Gevonden", + "memberPortalNoResourcesAvailable": "Geen Bronnen Beschikbaar", + "memberPortalNoResourcesMatchSearch": "Geen bronnen komen overeen met \"{query}\". Probeer uw zoektermen aan te passen of wis de zoekopdracht om alle bronnen te zien.", + "memberPortalNoResourcesAccess": "Je hebt nog geen toegang tot bronnen. Neem contact op met je beheerder om toegang te krijgen tot de benodigde bronnen.", + "memberPortalClearSearch": "Zoekopdracht Wissen", + "memberPortalPublicResources": "Publieke Bronnen", + "memberPortalPublicResourcesDescription": "Webapplicaties en services toegankelijk via browser", + "memberPortalCopiedToClipboard": "Gekopieerd naar klembord", + "memberPortalCopiedUrlDescription": "Bron URL is naar uw klembord gekopieerd.", + "memberPortalOpenResource": "Bron Openen", + "memberPortalPrivateResources": "Privé Bronnen", + "memberPortalPrivateResourcesDescription": "Interne netwerkbronnen toegankelijk via client", + "memberPortalResourceDetails": "Bron Details", + "memberPortalMode": "Modus", + "memberPortalDestination": "Bestemming", + "memberPortalAlias": "Alias", + "memberPortalCopiedAliasDescription": "Bron alias is naar uw klembord gekopieerd.", + "memberPortalCopiedDestinationDescription": "Bron bestemming is naar uw klembord gekopieerd.", + "memberPortalRequiresClientConnection": "Clientverbinding Vereist", + "memberPortalAuthMethods": "Authenticatiemethoden", + "memberPortalSso": "Single Sign-On (SSO)", + "memberPortalPasswordProtected": "Wachtwoord Beveiligd", + "memberPortalPinCode": "Pincode", + "memberPortalEmailWhitelist": "E-mail whitelist", + "memberPortalResourceDisabled": "Bron Uitgeschakeld", + "memberPortalShowingResources": "Toont {start}-{end} van {total} bronnen", + "memberPortalPrevious": "Vorige", + "memberPortalNext": "Volgende" } diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 45046ce79..df4a391fc 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -2660,19 +2660,19 @@ "noMoreAuthMethods": "No Valid Auth", "ip": "IP", "reason": "Powód", - "requestLogs": "Dzienniki żądań", + "requestLogs": "Dzienniki żądań HTTP", "requestAnalytics": "Żądanie Analityki", "host": "Host", "location": "Lokalizacja", "actionLogs": "Dzienniki działań", - "sidebarLogsRequest": "Dzienniki żądań", + "sidebarLogsRequest": "Dzienniki żądań HTTP", "sidebarLogsAccess": "Logi dostępu", "sidebarLogsAction": "Dzienniki działań", "logRetention": "Zachowanie dziennika", "logRetentionDescription": "Zarządzaj jak długo różne typy logów są zachowane dla tej organizacji lub wyłącz je", "requestLogsDescription": "Zobacz szczegółowe dzienniki żądań zasobów w tej organizacji", "requestAnalyticsDescription": "Zobacz szczegółowe analizy żądań dla zasobów w tej organizacji", - "logRetentionRequestLabel": "Zachowanie dziennika żądań", + "logRetentionRequestLabel": "Przechowywanie dzienników żądań HTTP", "logRetentionRequestDescription": "Jak długo zachować dzienniki żądań", "logRetentionAccessLabel": "Zachowanie dziennika dostępu", "logRetentionAccessDescription": "Jak długo zachować dzienniki dostępu", @@ -3134,7 +3134,7 @@ "httpDestActionLogsDescription": "Działania administracyjne wykonywane przez użytkowników w organizacji.", "httpDestConnectionLogsTitle": "Dzienniki połączeń", "httpDestConnectionLogsDescription": "Zdarzenia związane z miejscem i tunelem, w tym połączenia i rozłączenia.", - "httpDestRequestLogsTitle": "Dzienniki żądań", + "httpDestRequestLogsTitle": "Dzienniki żądań HTTP", "httpDestRequestLogsDescription": "Logi żądań HTTP dla zasobów proxy, w tym metody, ścieżki i kodu odpowiedzi.", "httpDestSaveChanges": "Zapisz zmiany", "httpDestCreateDestination": "Utwórz cel", @@ -3208,5 +3208,48 @@ "domainPickerWildcardCertWarning": "Uniwersalne zasoby mogą wymagać dodatkowej konfiguracji, aby działać poprawnie.", "domainPickerWildcardCertWarningLink": "Dowiedz się więcej", "health": "Zdrowie", - "domainPendingErrorTitle": "Problem z weryfikacją" + "domainPendingErrorTitle": "Problem z weryfikacją", + "memberPortalTitle": "Zasoby", + "memberPortalDescription": "Zasoby, do których masz dostęp w tej organizacji", + "memberPortalSortBy": "Sortuj według...", + "memberPortalSortNameAsc": "Nazwa A-Z", + "memberPortalSortNameDesc": "Nazwa Z-A", + "memberPortalSortDomainAsc": "Domena A-Z", + "memberPortalSortDomainDesc": "Domena Z-A", + "memberPortalSortEnabledFirst": "Włączone najpierw", + "memberPortalSortDisabledFirst": "Wyłączone najpierw", + "memberPortalRefresh": "Odśwież", + "memberPortalRefreshResources": "Odśwież zasoby", + "memberPortalFailedToLoad": "Nie udało się załadować zasobów", + "memberPortalFailedToLoadDescription": "Nie udało się załadować zasobów. Sprawdź połączenie i spróbuj ponownie.", + "memberPortalUnableToLoad": "Nie można załadować zasobów", + "memberPortalTryAgain": "Spróbuj ponownie", + "memberPortalNoResourcesFound": "Nie znaleziono zasobów", + "memberPortalNoResourcesAvailable": "Brak dostępnych zasobów", + "memberPortalNoResourcesMatchSearch": "Żadne zasoby nie pasują do „{query}”. Spróbuj dostosować swoje warunki wyszukiwania lub wyczyść wyszukiwanie, aby zobaczyć wszystkie zasoby.", + "memberPortalNoResourcesAccess": "Nie masz jeszcze dostępu do żadnych zasobów. Skontaktuj się z administratorem, aby uzyskać dostęp do potrzebnych zasobów.", + "memberPortalClearSearch": "Wyczyść wyszukiwanie", + "memberPortalPublicResources": "Publiczne zasoby", + "memberPortalPublicResourcesDescription": "Aplikacje i usługi internetowe dostępne za pośrednictwem przeglądarki", + "memberPortalCopiedToClipboard": "Skopiowano do schowka", + "memberPortalCopiedUrlDescription": "URL zasobu został skopiowany do schowka.", + "memberPortalOpenResource": "Otwórz zasób", + "memberPortalPrivateResources": "Prywatne zasoby", + "memberPortalPrivateResourcesDescription": "Zasoby sieci wewnętrznej dostępne za pośrednictwem klienta", + "memberPortalResourceDetails": "Szczegóły zasobu", + "memberPortalMode": "Tryb", + "memberPortalDestination": "Miejsce docelowe", + "memberPortalAlias": "Pseudonim", + "memberPortalCopiedAliasDescription": "Alias zasobu został skopiowany do schowka.", + "memberPortalCopiedDestinationDescription": "Miejsce docelowe zasobu zostało skopiowane do schowka.", + "memberPortalRequiresClientConnection": "Wymaga połączenia z klientem", + "memberPortalAuthMethods": "Metody uwierzytelniania", + "memberPortalSso": "Jednorazowe logowanie (SSO)", + "memberPortalPasswordProtected": "Chronione hasłem", + "memberPortalPinCode": "Kod PIN", + "memberPortalEmailWhitelist": "Biała lista e-mail", + "memberPortalResourceDisabled": "Zasób wyłączony", + "memberPortalShowingResources": "Wyświetlanie zasobów od {start} do {end} z {total}", + "memberPortalPrevious": "Poprzedni", + "memberPortalNext": "Następny" } diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 691e25e6d..bc683dc77 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -2660,19 +2660,19 @@ "noMoreAuthMethods": "No Valid Auth", "ip": "PI", "reason": "Motivo", - "requestLogs": "Registro de pedidos", + "requestLogs": "Registros de Pedidos HTTP", "requestAnalytics": "Solicitar análise", "host": "Servidor", "location": "Local:", "actionLogs": "Logs de Ações", - "sidebarLogsRequest": "Registro de pedidos", + "sidebarLogsRequest": "Registros de Pedidos HTTP", "sidebarLogsAccess": "Logs de Acesso", "sidebarLogsAction": "Logs de Ações", "logRetention": "Retenção de Log", "logRetentionDescription": "Gerenciar quanto tempo os diferentes tipos de logs são mantidos para esta organização ou desativá-los", "requestLogsDescription": "Ver registros de pedidos detalhados de recursos nesta organização", "requestAnalyticsDescription": "Exibir análise detalhada de pedidos para recursos nesta organização", - "logRetentionRequestLabel": "Solicitar retenção de registro", + "logRetentionRequestLabel": "Retenção de Registro de Pedido HTTP", "logRetentionRequestDescription": "Por quanto tempo manter os registros de pedidos", "logRetentionAccessLabel": "Retenção de Log de Acesso", "logRetentionAccessDescription": "Por quanto tempo manter os registros de acesso", @@ -3134,7 +3134,7 @@ "httpDestActionLogsDescription": "Ações administrativas realizadas por usuários dentro da organização.", "httpDestConnectionLogsTitle": "Logs da conexão", "httpDestConnectionLogsDescription": "Eventos de conexão de site e túnel, incluindo conexões e desconexões.", - "httpDestRequestLogsTitle": "Registro de pedidos", + "httpDestRequestLogsTitle": "Registros de Pedidos HTTP", "httpDestRequestLogsDescription": "Logs de solicitação HTTP para recursos proxy incluindo o método, o caminho e o código de resposta.", "httpDestSaveChanges": "Salvar as alterações", "httpDestCreateDestination": "Criar destino", @@ -3208,5 +3208,48 @@ "domainPickerWildcardCertWarning": "Recursos curinga podem exigir configurações adicionais para funcionarem corretamente.", "domainPickerWildcardCertWarningLink": "Saiba mais", "health": "Saúde", - "domainPendingErrorTitle": "Problema de Verificação" + "domainPendingErrorTitle": "Problema de Verificação", + "memberPortalTitle": "Recursos", + "memberPortalDescription": "Recursos aos quais você tem acesso nesta organização", + "memberPortalSortBy": "Ordenar por...", + "memberPortalSortNameAsc": "Nome A-Z", + "memberPortalSortNameDesc": "Nome Z-A", + "memberPortalSortDomainAsc": "Domínio A-Z", + "memberPortalSortDomainDesc": "Domínio Z-A", + "memberPortalSortEnabledFirst": "Habilitados Primeiro", + "memberPortalSortDisabledFirst": "Desabilitados Primeiro", + "memberPortalRefresh": "Atualizar", + "memberPortalRefreshResources": "Atualizar Recursos", + "memberPortalFailedToLoad": "Falha ao carregar recursos", + "memberPortalFailedToLoadDescription": "Falha ao carregar recursos. Por favor, verifique sua conexão e tente novamente.", + "memberPortalUnableToLoad": "Incapaz de Carregar Recursos", + "memberPortalTryAgain": "Tentar Novamente", + "memberPortalNoResourcesFound": "Nenhum Recurso Encontrado", + "memberPortalNoResourcesAvailable": "Nenhum Recurso Disponível", + "memberPortalNoResourcesMatchSearch": "Nenhum recurso corresponde a \"{query}\". Tente ajustar seus termos de pesquisa ou limpe a pesquisa para ver todos os recursos.", + "memberPortalNoResourcesAccess": "Você ainda não tem acesso a nenhum recurso. Entre em contato com seu administrador para obter acesso aos recursos que precisa.", + "memberPortalClearSearch": "Limpar Pesquisa", + "memberPortalPublicResources": "Recursos Públicos", + "memberPortalPublicResourcesDescription": "Aplicações e serviços web acessíveis via navegador", + "memberPortalCopiedToClipboard": "Copiado para a área de transferência", + "memberPortalCopiedUrlDescription": "A URL do recurso foi copiada para sua área de transferência.", + "memberPortalOpenResource": "Abrir Recurso", + "memberPortalPrivateResources": "Recursos Privados", + "memberPortalPrivateResourcesDescription": "Recursos da rede interna acessíveis via cliente", + "memberPortalResourceDetails": "Detalhes do Recurso", + "memberPortalMode": "Modo", + "memberPortalDestination": "Destino", + "memberPortalAlias": "Apelido", + "memberPortalCopiedAliasDescription": "O apelido do recurso foi copiado para sua área de transferência.", + "memberPortalCopiedDestinationDescription": "O destino do recurso foi copiado para sua área de transferência.", + "memberPortalRequiresClientConnection": "Requer Conexão de Cliente", + "memberPortalAuthMethods": "Métodos de Autenticação", + "memberPortalSso": "Logon Único (SSO)", + "memberPortalPasswordProtected": "Protegido por Senha", + "memberPortalPinCode": "Código PIN", + "memberPortalEmailWhitelist": "Lista de E-mails Permitidos", + "memberPortalResourceDisabled": "Recurso Desativado", + "memberPortalShowingResources": "Mostrando {start}-{end} de {total} recursos", + "memberPortalPrevious": "Anterior", + "memberPortalNext": "Próximo" } diff --git a/messages/ru-RU.json b/messages/ru-RU.json index c036dde3a..46bb5911a 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -2660,19 +2660,19 @@ "noMoreAuthMethods": "No Valid Auth", "ip": "IP", "reason": "Причина", - "requestLogs": "Запросить журналы", + "requestLogs": "HTTP Запросы Логи", "requestAnalytics": "Аналитика запроса", "host": "Хост", "location": "Местоположение", "actionLogs": "Журнал действий", - "sidebarLogsRequest": "Запросить журналы", + "sidebarLogsRequest": "HTTP Запросы Логи", "sidebarLogsAccess": "Журналы доступа", "sidebarLogsAction": "Журнал действий", "logRetention": "Сохранение журнала", "logRetentionDescription": "Управление сохранением различных типов журналов для этой организации или отключение их", "requestLogsDescription": "Просмотреть подробные журналы запроса ресурсов в этой организации", "requestAnalyticsDescription": "Просмотреть подробную аналитику запроса для ресурсов в этой организации", - "logRetentionRequestLabel": "Запросить сохранение журнала", + "logRetentionRequestLabel": "Сохранение HTTP Запросов Лога", "logRetentionRequestDescription": "Как долго сохранять журналы запросов", "logRetentionAccessLabel": "Хранение журнала доступа", "logRetentionAccessDescription": "Как долго сохранять журналы доступа", @@ -3134,7 +3134,7 @@ "httpDestActionLogsDescription": "Административные меры, осуществляемые пользователями в рамках организации.", "httpDestConnectionLogsTitle": "Журнал подключений", "httpDestConnectionLogsDescription": "События связи с сайтами и туннелями, включая соединения и отключения.", - "httpDestRequestLogsTitle": "Запросить журналы", + "httpDestRequestLogsTitle": "HTTP Запросы Логи", "httpDestRequestLogsDescription": "Журналы запросов HTTP для проксируемых ресурсов, включая метод, путь и код ответа.", "httpDestSaveChanges": "Сохранить изменения", "httpDestCreateDestination": "Создать адрес назначения", @@ -3208,5 +3208,48 @@ "domainPickerWildcardCertWarning": "Wildcard ресурсы могут потребовать дополнительной настройки для правильной работы.", "domainPickerWildcardCertWarningLink": "Узнать больше", "health": "Состояние", - "domainPendingErrorTitle": "Проблема с подтверждением" + "domainPendingErrorTitle": "Проблема с подтверждением", + "memberPortalTitle": "Ресурсы", + "memberPortalDescription": "Ресурсы, к которым у вас есть доступ в этой организации", + "memberPortalSortBy": "Сортировать по...", + "memberPortalSortNameAsc": "Имя A-Я", + "memberPortalSortNameDesc": "Имя Я-A", + "memberPortalSortDomainAsc": "Домен A-Я", + "memberPortalSortDomainDesc": "Домен Я-A", + "memberPortalSortEnabledFirst": "Включённые сначала", + "memberPortalSortDisabledFirst": "Отключённые сначала", + "memberPortalRefresh": "Обновить", + "memberPortalRefreshResources": "Обновить ресурсы", + "memberPortalFailedToLoad": "Не удалось загрузить ресурсы", + "memberPortalFailedToLoadDescription": "Не удалось загрузить ресурсы. Пожалуйста, проверьте подключение и попробуйте снова.", + "memberPortalUnableToLoad": "Не удалось загрузить ресурсы", + "memberPortalTryAgain": "Попробуйте снова", + "memberPortalNoResourcesFound": "Ресурсы не найдены", + "memberPortalNoResourcesAvailable": "Нет доступных ресурсов", + "memberPortalNoResourcesMatchSearch": "Нет ресурсов, соответствующих \"{query}\". Попробуйте изменить условия поиска или очистить поиск, чтобы увидеть все ресурсы.", + "memberPortalNoResourcesAccess": "У вас пока нет доступа к ресурсам. Свяжитесь с администратором, чтобы получить доступ к нужным вам ресурсам.", + "memberPortalClearSearch": "Очистить поиск", + "memberPortalPublicResources": "Публичные ресурсы", + "memberPortalPublicResourcesDescription": "Веб-приложения и сервисы, доступные через браузер", + "memberPortalCopiedToClipboard": "Скопировано в буфер обмена", + "memberPortalCopiedUrlDescription": "URL ресурса был скопирован в ваш буфер обмена.", + "memberPortalOpenResource": "Открыть ресурс", + "memberPortalPrivateResources": "Приватные ресурсы", + "memberPortalPrivateResourcesDescription": "Ресурсы внутренней сети, доступные через клиент", + "memberPortalResourceDetails": "Детали ресурса", + "memberPortalMode": "Режим", + "memberPortalDestination": "Назначение", + "memberPortalAlias": "Псевдоним", + "memberPortalCopiedAliasDescription": "Псевдоним ресурса был скопирован в ваш буфер обмена.", + "memberPortalCopiedDestinationDescription": "Назначение ресурса было скопировано в ваш буфер обмена.", + "memberPortalRequiresClientConnection": "Требуется подключение клиента", + "memberPortalAuthMethods": "Методы аутентификации", + "memberPortalSso": "Единый вход (SSO)", + "memberPortalPasswordProtected": "Защищено паролем", + "memberPortalPinCode": "PIN-код", + "memberPortalEmailWhitelist": "Белый список email", + "memberPortalResourceDisabled": "Ресурс отключён", + "memberPortalShowingResources": "Показаны {start}-{end} из {total} ресурсов", + "memberPortalPrevious": "Предыдущий", + "memberPortalNext": "Следующий" } diff --git a/messages/tr-TR.json b/messages/tr-TR.json index a7c63be56..0bcd5d313 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -2660,19 +2660,19 @@ "noMoreAuthMethods": "Daha Fazla Kimlik Doğrulama Yöntemi Yok", "ip": "IP", "reason": "Sebep", - "requestLogs": "İstek Günlükleri", + "requestLogs": "HTTP İstek Günlükleri", "requestAnalytics": "İstek Analizi", "host": "Sunucu", "location": "Konum", "actionLogs": "Eylem Günlükleri", - "sidebarLogsRequest": "İstek Günlükleri", + "sidebarLogsRequest": "HTTP İstek Günlükleri", "sidebarLogsAccess": "Erişim Günlükleri", "sidebarLogsAction": "Eylem Günlükleri", "logRetention": "Kayıt Saklama", "logRetentionDescription": "Bu organizasyon için farklı türdeki günlüklerin ne kadar süre saklanacağını yönetin veya devre dışı bırakın", "requestLogsDescription": "Bu organizasyondaki kaynaklar için ayrıntılı istek günlüklerini görüntüleyin", "requestAnalyticsDescription": "Bu organizasyondaki kaynaklar için ayrıntılı istek analizlerini görüntüleyin.", - "logRetentionRequestLabel": "İstek Günlüğü Saklama", + "logRetentionRequestLabel": "HTTP İstek Günlüğü Saklama", "logRetentionRequestDescription": "İstek günlüklerini ne kadar süre tutacağını belirle", "logRetentionAccessLabel": "Erişim Günlüğü Saklama", "logRetentionAccessDescription": "Erişim günlüklerini ne kadar süre tutacağını belirle", @@ -3134,7 +3134,7 @@ "httpDestActionLogsDescription": "Kullanıcılar tarafından organizasyon içerisinde yapılan yönetici eylemleri.", "httpDestConnectionLogsTitle": "Bağlantı Kayıtları", "httpDestConnectionLogsDescription": "Site ve tünel bağlantı olayları, bağlantılar ve bağlantı kesilmeleri dahil.", - "httpDestRequestLogsTitle": "İstek Kayıtları", + "httpDestRequestLogsTitle": "HTTP İstek Günlükleri", "httpDestRequestLogsDescription": "Yönlendirilmiş kaynaklar için HTTP istek kayıtları, yöntem, yol ve yanıt kodu dahil.", "httpDestSaveChanges": "Değişiklikleri Kaydet", "httpDestCreateDestination": "Hedef Oluştur", @@ -3208,5 +3208,48 @@ "domainPickerWildcardCertWarning": "Genel kaynaklar düzgün çalışmak için ek yapılandırma gerektirebilir.", "domainPickerWildcardCertWarningLink": "Daha fazla bilgi", "health": "Sağlık", - "domainPendingErrorTitle": "Doğrulama Sorunu" + "domainPendingErrorTitle": "Doğrulama Sorunu", + "memberPortalTitle": "Kaynaklar", + "memberPortalDescription": "Bu organizasyondaki erişiminiz olan kaynaklar", + "memberPortalSortBy": "Şuna göre sırala...", + "memberPortalSortNameAsc": "İsim A-Z", + "memberPortalSortNameDesc": "İsim Z-A", + "memberPortalSortDomainAsc": "Alan A-Z", + "memberPortalSortDomainDesc": "Alan Z-A", + "memberPortalSortEnabledFirst": "İlk Etkinleştirilenler", + "memberPortalSortDisabledFirst": "İlk Devre Dışı Bırakılanlar", + "memberPortalRefresh": "Yenile", + "memberPortalRefreshResources": "Kaynakları Yenile", + "memberPortalFailedToLoad": "Kaynaklar yüklenemedi", + "memberPortalFailedToLoadDescription": "Kaynaklar yüklenemedi. Lütfen bağlantınızı kontrol edin ve tekrar deneyin.", + "memberPortalUnableToLoad": "Kaynaklar Yüklenemiyor", + "memberPortalTryAgain": "Tekrar Dene", + "memberPortalNoResourcesFound": "Hiçbir Kaynak Bulunamadı", + "memberPortalNoResourcesAvailable": "Uygun Kaynak Yok", + "memberPortalNoResourcesMatchSearch": "Hiçbir kaynak \"{query}\" ile eşleşmiyor. Arama terimlerinizi değiştirerek veya tüm kaynakları görmek için aramayı temizleyerek deneyin.", + "memberPortalNoResourcesAccess": "Henüz herhangi bir kaynağa erişiminiz yok. İhtiyacınız olan kaynaklara erişim sağlamak için yöneticinizle iletişime geçin.", + "memberPortalClearSearch": "Aramayı Temizle", + "memberPortalPublicResources": "Genel Kaynaklar", + "memberPortalPublicResourcesDescription": "Tarayıcı üzerinden erişilebilen web uygulamaları ve hizmetler", + "memberPortalCopiedToClipboard": "Panoya kopyalandı", + "memberPortalCopiedUrlDescription": "Kaynak URL'si panonuza kopyalandı.", + "memberPortalOpenResource": "Kaynağı Aç", + "memberPortalPrivateResources": "Özel Kaynaklar", + "memberPortalPrivateResourcesDescription": "İstemci üzerinden erişilebilen dahili ağ kaynakları", + "memberPortalResourceDetails": "Kaynak Detayları", + "memberPortalMode": "Mod", + "memberPortalDestination": "Hedef", + "memberPortalAlias": "Takma İsim", + "memberPortalCopiedAliasDescription": "Kaynak takma adı panonuza kopyalandı.", + "memberPortalCopiedDestinationDescription": "Kaynak hedefi panonuza kopyalandı.", + "memberPortalRequiresClientConnection": "İstemci Bağlantısı Gerektirir", + "memberPortalAuthMethods": "Kimlik Doğrulama Yöntemleri", + "memberPortalSso": "Tek Oturum Açma (SSO)", + "memberPortalPasswordProtected": "Parola ile Korunan", + "memberPortalPinCode": "PIN Kodu", + "memberPortalEmailWhitelist": "E-posta Beyaz Listesi", + "memberPortalResourceDisabled": "Kaynak Devre Dışı", + "memberPortalShowingResources": "{total} kaynaktan {start}-{end} gösteriliyor", + "memberPortalPrevious": "Önceki", + "memberPortalNext": "Sonraki" } diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 47beca7dd..e61e0c61a 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -2672,7 +2672,7 @@ "logRetentionDescription": "管理不同类型的日志为这个机构保留多长时间或禁用这些日志", "requestLogsDescription": "查看此机构资源的详细请求日志", "requestAnalyticsDescription": "查看此机构资源的详细请求分析", - "logRetentionRequestLabel": "请求日志保留", + "logRetentionRequestLabel": "HTTP 请求日志保留", "logRetentionRequestDescription": "保留请求日志的时间", "logRetentionAccessLabel": "访问日志保留", "logRetentionAccessDescription": "保留访问日志的时间", @@ -3208,5 +3208,48 @@ "domainPickerWildcardCertWarning": "通配符资源可能需要额外配置才能正常工作。", "domainPickerWildcardCertWarningLink": "了解更多", "health": "健康", - "domainPendingErrorTitle": "验证问题" + "domainPendingErrorTitle": "验证问题", + "memberPortalTitle": "资源", + "memberPortalDescription": "您在此组织中可以访问的资源", + "memberPortalSortBy": "排序依据……", + "memberPortalSortNameAsc": "名称 A-Z", + "memberPortalSortNameDesc": "名称 Z-A", + "memberPortalSortDomainAsc": "域名 A-Z", + "memberPortalSortDomainDesc": "域名 Z-A", + "memberPortalSortEnabledFirst": "启用优先", + "memberPortalSortDisabledFirst": "禁用优先", + "memberPortalRefresh": "刷新", + "memberPortalRefreshResources": "刷新资源", + "memberPortalFailedToLoad": "加载资源失败", + "memberPortalFailedToLoadDescription": "加载资源失败。请检查您的连接并再试一次。", + "memberPortalUnableToLoad": "无法加载资源", + "memberPortalTryAgain": "再试一次", + "memberPortalNoResourcesFound": "找不到资源", + "memberPortalNoResourcesAvailable": "无可用资源", + "memberPortalNoResourcesMatchSearch": "没有与\"{query}\"匹配的资源。尝试调整您的搜索词或清除搜索以查看所有资源。", + "memberPortalNoResourcesAccess": "您尚无访问任何资源的权限。请联系您的管理员获取所需资源的访问权限。", + "memberPortalClearSearch": "清除搜索", + "memberPortalPublicResources": "公共资源", + "memberPortalPublicResourcesDescription": "通过浏览器可访问的网络应用和服务", + "memberPortalCopiedToClipboard": "已复制到剪贴板", + "memberPortalCopiedUrlDescription": "资源 URL 已复制到您的剪贴板。", + "memberPortalOpenResource": "打开资源", + "memberPortalPrivateResources": "私有资源", + "memberPortalPrivateResourcesDescription": "通过客户端可访问的内部网络资源", + "memberPortalResourceDetails": "资源详情", + "memberPortalMode": "模式", + "memberPortalDestination": "目标", + "memberPortalAlias": "别名", + "memberPortalCopiedAliasDescription": "资源别名已复制到您的剪贴板。", + "memberPortalCopiedDestinationDescription": "资源目的地已复制到您的剪贴板。", + "memberPortalRequiresClientConnection": "需要客户端连接", + "memberPortalAuthMethods": "身份验证方法", + "memberPortalSso": "单一登录 (SSO)", + "memberPortalPasswordProtected": "密码保护", + "memberPortalPinCode": "PIN 码", + "memberPortalEmailWhitelist": "电子邮件白名单", + "memberPortalResourceDisabled": "资源已禁用", + "memberPortalShowingResources": "显示 {start}-{end} 共 {total} 个资源", + "memberPortalPrevious": "上一页", + "memberPortalNext": "下一页" } diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index 21476b580..22b951870 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -361,7 +361,7 @@ export async function updateClientResources( } else { let aliasAddress: string | null = null; if (resourceData.mode === "host" || resourceData.mode === "http") { - aliasAddress = await getNextAvailableAliasAddress(orgId); + aliasAddress = await getNextAvailableAliasAddress(orgId, trx); } let domainInfo: diff --git a/server/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts index 02ac0c417..7d8f41a1e 100644 --- a/server/lib/calculateUserClientsForOrgs.ts +++ b/server/lib/calculateUserClientsForOrgs.ts @@ -28,6 +28,159 @@ export async function calculateUserClientsForOrgs( trx?: Transaction ): Promise { const execute = async (transaction: Transaction) => { + const orgCache = new Map(); + const adminRoleCache = new Map< + string, + typeof roles.$inferSelect | null + >(); + const exitNodesCache = new Map< + string, + Awaited> + >(); + const isOrgLicensedCache = new Map(); + const existingClientCache = new Map< + string, + typeof clients.$inferSelect | null + >(); + const roleClientAccessCache = new Map(); + const userClientAccessCache = new Map(); + + const getOrgOlmKey = (orgId: string, olmId: string) => + `${orgId}:${olmId}`; + const getRoleClientKey = (roleId: number, clientId: number) => + `${roleId}:${clientId}`; + const getUserClientKey = (cachedUserId: string, clientId: number) => + `${cachedUserId}:${clientId}`; + + const getOrg = async (orgId: string) => { + if (orgCache.has(orgId)) { + return orgCache.get(orgId) ?? null; + } + + const [org] = await transaction + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)); + orgCache.set(orgId, org ?? null); + + return org ?? null; + }; + + const getAdminRole = async (orgId: string) => { + if (adminRoleCache.has(orgId)) { + return adminRoleCache.get(orgId) ?? null; + } + + const [adminRole] = await transaction + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); + adminRoleCache.set(orgId, adminRole ?? null); + + return adminRole ?? null; + }; + + const getExitNodes = async (orgId: string) => { + if (exitNodesCache.has(orgId)) { + return exitNodesCache.get(orgId)!; + } + + const exitNodes = await listExitNodes(orgId); + exitNodesCache.set(orgId, exitNodes); + + return exitNodes; + }; + + const getIsOrgLicensed = async (orgId: string) => { + if (isOrgLicensedCache.has(orgId)) { + return isOrgLicensedCache.get(orgId)!; + } + + const isOrgLicensed = await isLicensedOrSubscribed( + orgId, + tierMatrix.deviceApprovals + ); + isOrgLicensedCache.set(orgId, isOrgLicensed); + + return isOrgLicensed; + }; + + const getExistingClient = async (orgId: string, olmId: string) => { + const key = getOrgOlmKey(orgId, olmId); + if (existingClientCache.has(key)) { + return existingClientCache.get(key) ?? null; + } + + const [existingClient] = await transaction + .select() + .from(clients) + .where( + and( + eq(clients.userId, userId), + eq(clients.orgId, orgId), + eq(clients.olmId, olmId) + ) + ) + .limit(1); + + existingClientCache.set(key, existingClient ?? null); + + return existingClient ?? null; + }; + + const hasRoleClientAccess = async ( + roleId: number, + clientId: number + ) => { + const key = getRoleClientKey(roleId, clientId); + if (roleClientAccessCache.has(key)) { + return roleClientAccessCache.get(key)!; + } + + const [existingRoleClient] = await transaction + .select() + .from(roleClients) + .where( + and( + eq(roleClients.roleId, roleId), + eq(roleClients.clientId, clientId) + ) + ) + .limit(1); + + const hasAccess = Boolean(existingRoleClient); + roleClientAccessCache.set(key, hasAccess); + + return hasAccess; + }; + + const hasUserClientAccess = async ( + cachedUserId: string, + clientId: number + ) => { + const key = getUserClientKey(cachedUserId, clientId); + if (userClientAccessCache.has(key)) { + return userClientAccessCache.get(key)!; + } + + const [existingUserClient] = await transaction + .select() + .from(userClients) + .where( + and( + eq(userClients.userId, cachedUserId), + eq(userClients.clientId, clientId) + ) + ) + .limit(1); + + const hasAccess = Boolean(existingUserClient); + userClientAccessCache.set(key, hasAccess); + + return hasAccess; + }; + // Get all OLMs for this user const userOlms = await transaction .select() @@ -54,7 +207,9 @@ export async function calculateUserClientsForOrgs( .innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) .where(eq(userOrgs.userId, userId)); - const userOrgIds = [...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))]; + const userOrgIds = [ + ...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId)) + ]; const orgIdToRoleRows = new Map< string, (typeof userOrgRoleRows)[0][] @@ -64,6 +219,13 @@ export async function calculateUserClientsForOrgs( list.push(r); orgIdToRoleRows.set(r.userOrgs.orgId, list); } + const orgRequiresDeviceApprovalRole = new Map(); + for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) { + orgRequiresDeviceApprovalRole.set( + orgId, + roleRowsForOrg.some((r) => r.roles.requireDeviceApproval) + ); + } // For each OLM, ensure there's a client in each org the user is in for (const olm of userOlms) { @@ -71,10 +233,7 @@ export async function calculateUserClientsForOrgs( const roleRowsForOrg = orgIdToRoleRows.get(orgId)!; const userOrg = roleRowsForOrg[0].userOrgs; - const [org] = await transaction - .select() - .from(orgs) - .where(eq(orgs.orgId, orgId)); + const org = await getOrg(orgId); if (!org) { logger.warn( @@ -91,11 +250,7 @@ export async function calculateUserClientsForOrgs( } // Get admin role for this org (needed for access grants) - const [adminRole] = await transaction - .select() - .from(roles) - .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) - .limit(1); + const adminRole = await getAdminRole(orgId); if (!adminRole) { logger.warn( @@ -105,64 +260,50 @@ export async function calculateUserClientsForOrgs( } // Check if a client already exists for this OLM+user+org combination - const [existingClient] = await transaction - .select() - .from(clients) - .where( - and( - eq(clients.userId, userId), - eq(clients.orgId, orgId), - eq(clients.olmId, olm.olmId) - ) - ) - .limit(1); + const existingClient = await getExistingClient( + orgId, + olm.olmId + ); if (existingClient) { // Ensure admin role has access to the client - const [existingRoleClient] = await transaction - .select() - .from(roleClients) - .where( - and( - eq(roleClients.roleId, adminRole.roleId), - eq( - roleClients.clientId, - existingClient.clientId - ) - ) - ) - .limit(1); + const hasRoleAccess = await hasRoleClientAccess( + adminRole.roleId, + existingClient.clientId + ); - if (!existingRoleClient) { + if (!hasRoleAccess) { await transaction.insert(roleClients).values({ roleId: adminRole.roleId, clientId: existingClient.clientId }); + roleClientAccessCache.set( + getRoleClientKey( + adminRole.roleId, + existingClient.clientId + ), + true + ); logger.debug( `Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})` ); } // Ensure user has access to the client - const [existingUserClient] = await transaction - .select() - .from(userClients) - .where( - and( - eq(userClients.userId, userId), - eq( - userClients.clientId, - existingClient.clientId - ) - ) - ) - .limit(1); + const hasUserAccess = await hasUserClientAccess( + userId, + existingClient.clientId + ); - if (!existingUserClient) { + if (!hasUserAccess) { await transaction.insert(userClients).values({ userId, clientId: existingClient.clientId }); + userClientAccessCache.set( + getUserClientKey(userId, existingClient.clientId), + true + ); logger.debug( `Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})` ); @@ -175,7 +316,7 @@ export async function calculateUserClientsForOrgs( } // Get exit nodes for this org - const exitNodesList = await listExitNodes(orgId); + const exitNodesList = await getExitNodes(orgId); if (exitNodesList.length === 0) { logger.warn( @@ -206,14 +347,11 @@ export async function calculateUserClientsForOrgs( const niceId = await getUniqueClientName(orgId); - const isOrgLicensed = await isLicensedOrSubscribed( - userOrg.orgId, - tierMatrix.deviceApprovals - ); + const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId); const requireApproval = build !== "oss" && isOrgLicensed && - roleRowsForOrg.some((r) => r.roles.requireDeviceApproval); + orgRequiresDeviceApprovalRole.get(orgId) === true; const newClientData: InferInsertModel = { userId, @@ -232,6 +370,10 @@ export async function calculateUserClientsForOrgs( .insert(clients) .values(newClientData) .returning(); + existingClientCache.set( + getOrgOlmKey(orgId, olm.olmId), + newClient + ); // create approval request if (requireApproval) { @@ -257,12 +399,20 @@ export async function calculateUserClientsForOrgs( roleId: adminRole.roleId, clientId: newClient.clientId }); + roleClientAccessCache.set( + getRoleClientKey(adminRole.roleId, newClient.clientId), + true + ); // Grant user access to the client await transaction.insert(userClients).values({ userId, clientId: newClient.clientId }); + userClientAccessCache.set( + getUserClientKey(userId, newClient.clientId), + true + ); logger.debug( `Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user` diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 3d290d93b..b71b3299f 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.18.2"; +export const APP_VERSION = "1.18.3"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 929399f7b..9989b978f 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -6,6 +6,7 @@ import z from "zod"; import logger from "@server/logger"; import semver from "semver"; import { getValidCertificatesForDomains } from "#dynamic/lib/certificates"; +import { lockManager } from "#dynamic/lib/lock"; interface IPRange { start: bigint; @@ -327,120 +328,146 @@ export async function getNextAvailableClientSubnet( orgId: string, transaction: Transaction | typeof db = db ): Promise { - const [org] = await transaction - .select() - .from(orgs) - .where(eq(orgs.orgId, orgId)); + return await lockManager.withLock( + `client-subnet-allocation:${orgId}`, + async () => { + const [org] = await transaction + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)); - if (!org) { - throw new Error(`Organization with ID ${orgId} not found`); - } + if (!org) { + throw new Error(`Organization with ID ${orgId} not found`); + } - if (!org.subnet) { - throw new Error(`Organization with ID ${orgId} has no subnet defined`); - } + if (!org.subnet) { + throw new Error( + `Organization with ID ${orgId} has no subnet defined` + ); + } - const existingAddressesSites = await transaction - .select({ - address: sites.address - }) - .from(sites) - .where(and(isNotNull(sites.address), eq(sites.orgId, orgId))); + const existingAddressesSites = await transaction + .select({ + address: sites.address + }) + .from(sites) + .where(and(isNotNull(sites.address), eq(sites.orgId, orgId))); - const existingAddressesClients = await transaction - .select({ - address: clients.subnet - }) - .from(clients) - .where(and(isNotNull(clients.subnet), eq(clients.orgId, orgId))); + const existingAddressesClients = await transaction + .select({ + address: clients.subnet + }) + .from(clients) + .where( + and(isNotNull(clients.subnet), eq(clients.orgId, orgId)) + ); - const addresses = [ - ...existingAddressesSites.map( - (site) => `${site.address?.split("/")[0]}/32` - ), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org - ...existingAddressesClients.map( - (client) => `${client.address.split("/")}/32` - ) - ].filter((address) => address !== null) as string[]; + const addresses = [ + ...existingAddressesSites.map( + (site) => `${site.address?.split("/")[0]}/32` + ), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org + ...existingAddressesClients.map( + (client) => `${client.address.split("/")}/32` + ) + ].filter((address) => address !== null) as string[]; - const subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org - if (!subnet) { - throw new Error("No available subnets remaining in space"); - } + const subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org + if (!subnet) { + throw new Error("No available subnets remaining in space"); + } - return subnet; + return subnet; + } + ); } export async function getNextAvailableAliasAddress( - orgId: string + orgId: string, + trx: Transaction | typeof db = db ): Promise { - const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); + return await lockManager.withLock( + `alias-address-allocation:${orgId}`, + async () => { + const [org] = await trx + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)); - if (!org) { - throw new Error(`Organization with ID ${orgId} not found`); - } + if (!org) { + throw new Error(`Organization with ID ${orgId} not found`); + } - if (!org.subnet) { - throw new Error(`Organization with ID ${orgId} has no subnet defined`); - } + if (!org.subnet) { + throw new Error( + `Organization with ID ${orgId} has no subnet defined` + ); + } - if (!org.utilitySubnet) { - throw new Error( - `Organization with ID ${orgId} has no utility subnet defined` - ); - } + if (!org.utilitySubnet) { + throw new Error( + `Organization with ID ${orgId} has no utility subnet defined` + ); + } - const existingAddresses = await db - .select({ - aliasAddress: siteResources.aliasAddress - }) - .from(siteResources) - .where( - and( - isNotNull(siteResources.aliasAddress), - eq(siteResources.orgId, orgId) - ) - ); + const existingAddresses = await trx + .select({ + aliasAddress: siteResources.aliasAddress + }) + .from(siteResources) + .where( + and( + isNotNull(siteResources.aliasAddress), + eq(siteResources.orgId, orgId) + ) + ); - const addresses = [ - ...existingAddresses.map( - (site) => `${site.aliasAddress?.split("/")[0]}/32` - ), - // reserve a /29 for the dns server and other stuff - `${org.utilitySubnet.split("/")[0]}/29` - ].filter((address) => address !== null) as string[]; + const addresses = [ + ...existingAddresses.map( + (site) => `${site.aliasAddress?.split("/")[0]}/32` + ), + // reserve a /29 for the dns server and other stuff + `${org.utilitySubnet.split("/")[0]}/29` + ].filter((address) => address !== null) as string[]; - let subnet = findNextAvailableCidr(addresses, 32, org.utilitySubnet); - if (!subnet) { - throw new Error("No available subnets remaining in space"); - } + let subnet = findNextAvailableCidr( + addresses, + 32, + org.utilitySubnet + ); + if (!subnet) { + throw new Error("No available subnets remaining in space"); + } - // remove the cidr - subnet = subnet.split("/")[0]; + // remove the cidr + subnet = subnet.split("/")[0]; - return subnet; + return subnet; + } + ); } export async function getNextAvailableOrgSubnet(): Promise { - const existingAddresses = await db - .select({ - subnet: orgs.subnet - }) - .from(orgs) - .where(isNotNull(orgs.subnet)); + return await lockManager.withLock("org-subnet-allocation", async () => { + const existingAddresses = await db + .select({ + subnet: orgs.subnet + }) + .from(orgs) + .where(isNotNull(orgs.subnet)); - const addresses = existingAddresses.map((org) => org.subnet!); + const addresses = existingAddresses.map((org) => org.subnet!); - const subnet = findNextAvailableCidr( - addresses, - config.getRawConfig().orgs.block_size, - config.getRawConfig().orgs.subnet_group - ); - if (!subnet) { - throw new Error("No available subnets remaining in space"); - } + const subnet = findNextAvailableCidr( + addresses, + config.getRawConfig().orgs.block_size, + config.getRawConfig().orgs.subnet_group + ); + if (!subnet) { + throw new Error("No available subnets remaining in space"); + } - return subnet; + return subnet; + }); } export function generateRemoteSubnets( @@ -478,7 +505,12 @@ export type Alias = { alias: string | null; aliasAddress: string | null }; export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] { return allSiteResources - .filter((sr) => sr.aliasAddress && ((sr.alias && sr.mode == "host") || (sr.fullDomain && sr.mode == "http"))) + .filter( + (sr) => + sr.aliasAddress && + ((sr.alias && sr.mode == "host") || + (sr.fullDomain && sr.mode == "http")) + ) .map((sr) => ({ alias: sr.alias || sr.fullDomain, aliasAddress: sr.aliasAddress diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index 3a9b1f6ef..8db76bbb0 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -24,8 +24,11 @@ export async function getCachedStatusHistory( return cached; } - const nowSec = Math.floor(Date.now() / 1000); - const startSec = nowSec - days * 86400; + // 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); + const startSec = todayMidnightSec - days * 86400; const events = await logsDb .select() @@ -110,11 +113,18 @@ export function computeBuckets( days: number ): { 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); + const buckets: StatusHistoryDayBucket[] = []; let totalDowntime = 0; for (let d = 0; d < days; d++) { - const dayStartSec = nowSec - (days - d) * 86400; + const dayStartSec = todayMidnightSec - (days - d) * 86400; const dayEndSec = dayStartSec + 86400; const dayEvents = events.filter( diff --git a/server/private/lib/alerts/processAlerts.ts b/server/private/lib/alerts/processAlerts.ts index a08a55494..c6e7ded9a 100644 --- a/server/private/lib/alerts/processAlerts.ts +++ b/server/private/lib/alerts/processAlerts.ts @@ -29,7 +29,10 @@ import { decrypt } from "@server/lib/crypto"; import logger from "@server/logger"; import { sendAlertWebhook } from "./sendAlertWebhook"; import { sendAlertEmail } from "./sendAlertEmail"; -import { AlertContext, WebhookAlertConfig } from "@server/routers/alertRule/types"; +import { + AlertContext, + WebhookAlertConfig +} from "@server/routers/alertRule/types"; /** * Core alert processing pipeline. @@ -99,7 +102,10 @@ export async function processAlerts(context: AlertContext): Promise { baseConditions, or( eq(alertRules.allHealthChecks, true), - eq(alertHealthChecks.healthCheckId, context.healthCheckId) + eq( + alertHealthChecks.healthCheckId, + context.healthCheckId + ) ) ) ); @@ -208,14 +214,19 @@ async function processRule( for (const action of emailActions) { try { - const recipients = await resolveEmailRecipients(action.emailActionId); + const recipients = await resolveEmailRecipients( + action.emailActionId + ); if (recipients.length > 0) { await sendAlertEmail(recipients, context); await db .update(alertEmailActions) .set({ lastSentAt: now }) .where( - eq(alertEmailActions.emailActionId, action.emailActionId) + eq( + alertEmailActions.emailActionId, + action.emailActionId + ) ); } } catch (err) { @@ -269,7 +280,7 @@ async function processRule( ) ); } catch (err) { - logger.error( + logger.warn( `processAlerts: failed to send alert webhook for action ${action.webhookActionId}`, err ); @@ -289,7 +300,9 @@ async function processRule( * - All users in a role (by `roleId`, resolved via `userOrgRoles`) * - Direct external email addresses */ -async function resolveEmailRecipients(emailActionId: number): Promise { +async function resolveEmailRecipients( + emailActionId: number +): Promise { const rows = await db .select() .from(alertEmailRecipients) diff --git a/server/private/lib/alerts/sendAlertWebhook.ts b/server/private/lib/alerts/sendAlertWebhook.ts index dd5088a6c..27e142cc4 100644 --- a/server/private/lib/alerts/sendAlertWebhook.ts +++ b/server/private/lib/alerts/sendAlertWebhook.ts @@ -236,15 +236,43 @@ interface TemplateContext { } /** - * Render a body template with {{event}}, {{timestamp}}, {{status}}, and - * {{data}} placeholders, mirroring the logic in HttpLogDestination. + * Render a body template with {{event}}, {{timestamp}}, {{status}}, {{data}}, + * and individual data-field placeholders (e.g. {{orgId}}, {{siteId}}, …). * - * {{data}} is replaced first (as raw JSON) so that any literal "{{…}}" - * strings inside data values are not re-expanded. + * Replacement order: + * 1. {{data}} → raw JSON of the full data object (prevents re-expansion of + * nested values that might look like placeholders). + * 2. Top-level scalar fields from data (string values are JSON-escaped; + * numbers and booleans are rendered as-is). Unknown placeholders are + * left untouched. + * 3. The fixed top-level keys: event, timestamp, status. */ function renderTemplate(template: string, ctx: TemplateContext): string { - const rendered = template - .replace(/\{\{data\}\}/g, JSON.stringify(ctx.data)) + // Step 1 – expand {{data}} first so its contents are already serialised + // and won't be touched by later passes. + let rendered = template.replace(/\{\{data\}\}/g, JSON.stringify(ctx.data)); + + // Step 2 – expand individual data fields. Only replace placeholders whose + // key actually exists in ctx.data; leave everything else as-is. + for (const [key, value] of Object.entries(ctx.data)) { + if (value === null || value === undefined) continue; + const placeholder = new RegExp( + `\\{\\{${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\}\\}`, + "g" + ); + let serialised: string; + if (typeof value === "string") { + serialised = escapeJsonString(value); + } else if (typeof value === "number" || typeof value === "boolean") { + serialised = String(value); + } else { + serialised = escapeJsonString(JSON.stringify(value)); + } + rendered = rendered.replace(placeholder, serialised); + } + + // Step 3 – expand the fixed top-level keys. + rendered = rendered .replace(/\{\{event\}\}/g, escapeJsonString(ctx.event)) .replace(/\{\{timestamp\}\}/g, escapeJsonString(ctx.timestamp)) .replace(/\{\{status\}\}/g, escapeJsonString(ctx.status)); diff --git a/server/routers/resource/getUserResources.ts b/server/routers/resource/getUserResources.ts index c0f21a440..7b82870ea 100644 --- a/server/routers/resource/getUserResources.ts +++ b/server/routers/resource/getUserResources.ts @@ -151,6 +151,8 @@ export async function getUserResources( destination: string; mode: string; scheme: string | null; + ssl: boolean; + fullDomain: string | null; enabled: boolean; alias: string | null; aliasAddress: string | null; @@ -164,6 +166,8 @@ export async function getUserResources( destination: siteResources.destination, mode: siteResources.mode, scheme: siteResources.scheme, + ssl: siteResources.ssl, + fullDomain: siteResources.fullDomain, enabled: siteResources.enabled, alias: siteResources.alias, aliasAddress: siteResources.aliasAddress @@ -251,6 +255,8 @@ export async function getUserResources( destination: siteResource.destination, mode: siteResource.mode, protocol: siteResource.scheme, + ssl: siteResource.ssl, + fullDomain: siteResource.fullDomain, enabled: siteResource.enabled, alias: siteResource.alias, aliasAddress: siteResource.aliasAddress, @@ -296,6 +302,8 @@ export type GetUserResourcesResponse = { destination: string; mode: string; protocol: string | null; + ssl: boolean; + fullDomain: string | null; enabled: boolean; alias: string | null; aliasAddress: string | null; diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 01f7a0d9c..1cf9c38d5 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -74,16 +74,14 @@ const createSiteResourceSchema = z .refine( (data) => { if (data.mode === "host") { - if (data.mode == "host") { - // Check if it's a valid IP address using zod (v4 or v6) - const isValidIP = z - // .union([z.ipv4(), z.ipv6()]) - .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere - .safeParse(data.destination).success; + // Check if it's a valid IP address using zod (v4 or v6) + const isValidIP = z + // .union([z.ipv4(), z.ipv6()]) + .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere + .safeParse(data.destination).success; - if (isValidIP) { - return true; - } + if (isValidIP) { + return true; } // Check if it's a valid domain (hostname pattern, TLD not required) @@ -96,17 +94,12 @@ const createSiteResourceSchema = z data.alias.trim() !== ""; return isValidDomain && isValidAlias; // require the alias to be set in the case of domain - } - return true; - }, - { - message: - "Destination must be a valid IPV4 address or valid domain AND alias is required" - } - ) - .refine( - (data) => { - if (data.mode === "cidr") { + } else if (data.mode === "http") { + // we have to have a domainId defined + if (!data.domainId) { + return false; + } + } else if (data.mode === "cidr") { // Check if it's a valid CIDR (v4 or v6) const isValidCIDR = z .union([z.cidrv4(), z.cidrv6()]) @@ -116,7 +109,8 @@ const createSiteResourceSchema = z return true; }, { - message: "Destination must be a valid CIDR notation for cidr mode" + message: + "Destination must be a valid IPV4 address or valid domain AND alias is required" } ) .refine( diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 8a3f93326..7f8ef3e25 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -104,6 +104,17 @@ const updateSiteResourceSchema = z data.alias.trim() !== ""; return isValidDomain && isValidAlias; // require the alias to be set in the case of domain + } else if (data.mode === "cidr" && data.destination) { + // Check if it's a valid CIDR (v4 or v6) + const isValidCIDR = z + .union([z.cidrv4(), z.cidrv6()]) + .safeParse(data.destination).success; + return isValidCIDR; + } else if (data.mode === "http") { + // we have to have a domainId defined + if (!data.domainId) { + return false; + } } return true; }, @@ -112,21 +123,6 @@ const updateSiteResourceSchema = z "Destination must be a valid IP address or valid domain AND alias is required" } ) - .refine( - (data) => { - if (data.mode === "cidr" && data.destination) { - // Check if it's a valid CIDR (v4 or v6) - const isValidCIDR = z - .union([z.cidrv4(), z.cidrv6()]) - .safeParse(data.destination).success; - return isValidCIDR; - } - return true; - }, - { - message: "Destination must be a valid CIDR notation for cidr mode" - } - ) .refine( (data) => { if (data.mode !== "http") return true; diff --git a/server/setup/scriptsPg/1.18.3.ts b/server/setup/scriptsPg/1.18.3.ts index 301ed820c..e71de3f89 100644 --- a/server/setup/scriptsPg/1.18.3.ts +++ b/server/setup/scriptsPg/1.18.3.ts @@ -3,6 +3,8 @@ import { sql } from "drizzle-orm"; const version = "1.18.3"; +await migration(); + export default async function migration() { console.log(`Running setup script ${version}...`); @@ -77,7 +79,7 @@ export default async function migration() { } console.log( - `Migrated ${existingHealthChecks.length} targetHealthCheck row(s) with corrected IDs` + `Updated names for ${existingHealthChecks.length} existing targetHealthCheck row(s)` ); } catch (e) { console.error("Error while migrating targetHealthCheck rows:", e); diff --git a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx index 90b89f76f..b2ad61d67 100644 --- a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx @@ -175,26 +175,6 @@ export default function GeneralPage() { }, [variant]); useEffect(() => { - async function fetchRoles() { - const res = await api - .get>(`/org/${orgId}/roles`) - .catch((e) => { - console.error(e); - toast({ - variant: "destructive", - title: t("accessRoleErrorFetch"), - description: formatAxiosError( - e, - t("accessRoleErrorFetchDescription") - ) - }); - }); - - if (res?.status === 200) { - setRoles(res.data.data.roles); - } - } - const loadIdp = async ( availableRoles: { roleId: number; name: string }[] ) => { @@ -520,6 +500,7 @@ export default function GeneralPage() { onAutoProvisionChange={(checked) => { form.setValue("autoProvision", checked); }} + orgId={orgId as string} roleMappingMode={roleMappingMode} onRoleMappingModeChange={(data) => { setRoleMappingMode(data); diff --git a/src/app/[orgId]/settings/(private)/idp/create/page.tsx b/src/app/[orgId]/settings/(private)/idp/create/page.tsx index a7796e2a9..33ef71eee 100644 --- a/src/app/[orgId]/settings/(private)/idp/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/create/page.tsx @@ -246,523 +246,559 @@ export default function Page() { -
- - - - - {t("idpTitle")} - - - {t("idpCreateSettingsDescription")} - - - - { - applyOidcIdpProviderType(form.setValue, next); - }} - /> +
+ + + + + {t("idpTitle")} + + + {t("idpCreateSettingsDescription")} + + + + { + applyOidcIdpProviderType( + form.setValue, + next + ); + }} + /> - + +
+ + ( + + + {t("name")} + + + + + + {t("idpDisplayName")} + + + + )} + /> + + +
+
+
+ + {/* Auto Provision Settings */} + + + + {t("idpAutoProvisionUsers")} + + + + + + +
- ( - - - {t("name")} - - - - - - {t("idpDisplayName")} - - - - )} + { + form.setValue( + "autoProvision", + checked + ); + }} + orgId={params.orgId as string} + roleMappingMode={roleMappingMode} + onRoleMappingModeChange={(data) => { + setRoleMappingMode(data); + }} + roles={roles} + fixedRoleNames={fixedRoleNames} + onFixedRoleNamesChange={ + setFixedRoleNames + } + mappingBuilderClaimPath={ + mappingBuilderClaimPath + } + onMappingBuilderClaimPathChange={ + setMappingBuilderClaimPath + } + mappingBuilderRules={ + mappingBuilderRules + } + onMappingBuilderRulesChange={ + setMappingBuilderRules + } + rawExpression={rawRoleExpression} + onRawExpressionChange={ + setRawRoleExpression + } + orgMappingField={{ + control: form.control, + name: "orgMapping" + }} /> - -
-
- - {/* Auto Provision Settings */} - - - - {t("idpAutoProvisionUsers")} - - - - - - - -
- - { - form.setValue("autoProvision", checked); - }} - roleMappingMode={roleMappingMode} - onRoleMappingModeChange={(data) => { - setRoleMappingMode(data); - }} - roles={roles} - fixedRoleNames={fixedRoleNames} - onFixedRoleNamesChange={setFixedRoleNames} - mappingBuilderClaimPath={ - mappingBuilderClaimPath - } - onMappingBuilderClaimPathChange={ - setMappingBuilderClaimPath - } - mappingBuilderRules={mappingBuilderRules} - onMappingBuilderRulesChange={ - setMappingBuilderRules - } - rawExpression={rawRoleExpression} - onRawExpressionChange={setRawRoleExpression} - orgMappingField={{ - control: form.control, - name: "orgMapping" - }} - /> - - -
-
- - {form.watch("type") === "google" && ( - - - - {t("idpGoogleConfigurationTitle")} - - - {t("idpGoogleConfigurationDescription")} - - - - -
- - ( - - - {t("idpClientId")} - - - - - - {t( - "idpGoogleClientIdDescription" - )} - - - - )} - /> - - ( - - - {t("idpClientSecret")} - - - - - - {t( - "idpGoogleClientSecretDescription" - )} - - - - )} - /> - - -
- )} - {form.watch("type") === "azure" && ( - - - - {t("idpAzureConfigurationTitle")} - - - {t("idpAzureConfigurationDescription")} - - - - -
- - ( - - - {t("idpTenantIdLabel")} - - - - - - {t( - "idpAzureTenantIdDescription" - )} - - - - )} - /> - - ( - - - {t("idpClientId")} - - - - - - {t( - "idpAzureClientIdDescription2" - )} - - - - )} - /> - - ( - - - {t("idpClientSecret")} - - - - - - {t( - "idpAzureClientSecretDescription2" - )} - - - - )} - /> - - -
-
-
- )} - - {form.watch("type") === "oidc" && ( - + {form.watch("type") === "google" && ( - {t("idpOidcConfigure")} + {t("idpGoogleConfigurationTitle")} - {t("idpOidcConfigureDescription")} + {t("idpGoogleConfigurationDescription")} -
- - ( - - - {t("idpClientId")} - - - - - - {t( - "idpClientIdDescription" - )} - - - + + + + > + ( + + + {t("idpClientId")} + + + + + + {t( + "idpGoogleClientIdDescription" + )} + + + + )} + /> - ( - - - {t("idpClientSecret")} - - - - - - {t( - "idpClientSecretDescription" - )} - - - - )} - /> - - ( - - - {t("idpAuthUrl")} - - - - - - {t( - "idpAuthUrlDescription" - )} - - - - )} - /> - - ( - - - {t("idpTokenUrl")} - - - - - - {t( - "idpTokenUrlDescription" - )} - - - - )} - /> - - + ( + + + {t( + "idpClientSecret" + )} + + + + + + {t( + "idpGoogleClientSecretDescription" + )} + + + + )} + /> + + +
+ )} + {form.watch("type") === "azure" && ( - {t("idpToken")} + {t("idpAzureConfigurationTitle")} - {t("idpTokenDescription")} + {t("idpAzureConfigurationDescription")} -
- - ( - - - {t("idpJmespathLabel")} - - - - - - {t( - "idpJmespathLabelDescription" - )} - - - + + + + > + ( + + + {t( + "idpTenantIdLabel" + )} + + + + + + {t( + "idpAzureTenantIdDescription" + )} + + + + )} + /> - ( - - - {t( - "idpJmespathEmailPathOptional" - )} - - - - - - {t( - "idpJmespathEmailPathOptionalDescription" - )} - - - - )} - /> + ( + + + {t("idpClientId")} + + + + + + {t( + "idpAzureClientIdDescription2" + )} + + + + )} + /> - ( - - - {t( - "idpJmespathNamePathOptional" - )} - - - - - - {t( - "idpJmespathNamePathOptionalDescription" - )} - - - - )} - /> - - ( - - - {t( - "idpOidcConfigureScopes" - )} - - - - - - {t( - "idpOidcConfigureScopesDescription" - )} - - - - )} - /> - - + ( + + + {t( + "idpClientSecret" + )} + + + + + + {t( + "idpAzureClientSecretDescription2" + )} + + + + )} + /> + + +
-
- )} -
+ )} -
- - -
+ {form.watch("type") === "oidc" && ( + + + + + {t("idpOidcConfigure")} + + + {t("idpOidcConfigureDescription")} + + + +
+ + ( + + + {t("idpClientId")} + + + + + + {t( + "idpClientIdDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpClientSecret" + )} + + + + + + {t( + "idpClientSecretDescription" + )} + + + + )} + /> + + ( + + + {t("idpAuthUrl")} + + + + + + {t( + "idpAuthUrlDescription" + )} + + + + )} + /> + + ( + + + {t("idpTokenUrl")} + + + + + + {t( + "idpTokenUrlDescription" + )} + + + + )} + /> + + +
+
+ + + + + {t("idpToken")} + + + {t("idpTokenDescription")} + + + +
+ + ( + + + {t( + "idpJmespathLabel" + )} + + + + + + {t( + "idpJmespathLabelDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpJmespathEmailPathOptional" + )} + + + + + + {t( + "idpJmespathEmailPathOptionalDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpJmespathNamePathOptional" + )} + + + + + + {t( + "idpJmespathNamePathOptionalDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpOidcConfigureScopes" + )} + + + + + + {t( + "idpOidcConfigureScopesDescription" + )} + + + + )} + /> + + +
+
+
+ )} + + +
+ + +
); diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index 9ab9e93fa..717d7f211 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -1,44 +1,40 @@ "use client"; +import IdpTypeBadge from "@app/components/IdpTypeBadge"; +import OrgRolesTagField from "@app/components/OrgRolesTagField"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { Checkbox } from "@app/components/ui/checkbox"; import { Form, FormControl, FormField, FormItem, - FormLabel, - FormMessage + FormLabel } from "@app/components/ui/form"; -import { Checkbox } from "@app/components/ui/checkbox"; -import OrgRolesTagField from "@app/components/OrgRolesTagField"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; import { zodResolver } from "@hookform/resolvers/zod"; -import { AxiosResponse } from "axios"; -import { useEffect, useState } from "react"; +import { build } from "@server/build"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { UserType } from "@server/types/UserTypes"; +import { useTranslations } from "next-intl"; +import { useParams } from "next/navigation"; +import { useActionState, useEffect } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { ListRolesResponse } from "@server/routers/role"; -import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; -import { useParams } from "next/navigation"; -import { Button } from "@app/components/ui/button"; -import { - SettingsContainer, - SettingsSection, - SettingsSectionHeader, - SettingsSectionTitle, - SettingsSectionDescription, - SettingsSectionBody, - SettingsSectionForm, - SettingsSectionFooter -} from "@app/components/Settings"; -import { formatAxiosError } from "@app/lib/api"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useTranslations } from "next-intl"; -import IdpTypeBadge from "@app/components/IdpTypeBadge"; -import { UserType } from "@server/types/UserTypes"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { build } from "@server/build"; const accessControlsFormSchema = z.object({ username: z.string(), @@ -59,12 +55,6 @@ export default function AccessControlsPage() { const { orgId } = useParams(); - const [loading, setLoading] = useState(false); - const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); - const [activeRoleTagIndex, setActiveRoleTagIndex] = useState( - null - ); - const t = useTranslations(); const { isPaidUser } = usePaidStatus(); const isPaid = isPaidUser(tierMatrix.fullRbac); @@ -97,44 +87,21 @@ export default function AccessControlsPage() { text: r.name })) ); - }, [user.userId, currentRoleIds.join(",")]); - - useEffect(() => { - async function fetchRoles() { - const res = await api - .get>(`/org/${orgId}/roles`) - .catch((e) => { - console.error(e); - toast({ - variant: "destructive", - title: t("accessRoleErrorFetch"), - description: formatAxiosError( - e, - t("accessRoleErrorFetchDescription") - ) - }); - }); - - if (res?.status === 200) { - setRoles(res.data.data.roles); - } - } - - fetchRoles(); form.setValue("autoProvisioned", user.autoProvisioned || false); - }, []); - - const allRoleOptions = roles.map((role) => ({ - id: role.roleId.toString(), - text: role.name - })); + }, [user.userId, user.autoProvisioned, currentRoleIds.join(",")]); const paywallMessage = build === "saas" ? t("singleRolePerUserPlanNotice") : t("singleRolePerUserEditionNotice"); - async function onSubmit(values: z.infer) { + const [, action, isSubmitting] = useActionState(onSubmit, null); + async function onSubmit() { + const isValid = await form.trigger(); + if (!isValid) return; + + const values = form.getValues(); + if (values.roles.length === 0) { toast({ variant: "destructive", @@ -144,7 +111,6 @@ export default function AccessControlsPage() { return; } - setLoading(true); try { const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const updateRoleRequest = supportsMultipleRolesPerUser @@ -184,7 +150,6 @@ export default function AccessControlsPage() { ) }); } - setLoading(false); } return ( @@ -203,7 +168,7 @@ export default function AccessControlsPage() {
@@ -226,9 +191,7 @@ export default function AccessControlsPage() { {user.idpAutoProvision && ( @@ -277,8 +237,8 @@ export default function AccessControlsPage() { - - - - { - form.setValue( - "clients", - machines - ); - }} - /> - - + { + form.setValue( + "clients", + machines + ); + }} + /> )} diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index 69149c9f4..9ca7c4cc4 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -129,9 +129,7 @@ export function LayoutSidebar({ user.serverAdmin || Boolean(currentOrg?.isOwner || currentOrg?.isAdmin); const showTrial = - build === "saas" && - Boolean(orgId) && - subscriptionContext?.isTrial; + build === "saas" && Boolean(orgId) && subscriptionContext?.isTrial; return (
- ) :
} + ) : ( +
+ )} {showTrial && (
- +
)} diff --git a/src/components/MemberResourcesPortal.tsx b/src/components/MemberResourcesPortal.tsx index 0ca6c550b..87d527e11 100644 --- a/src/components/MemberResourcesPortal.tsx +++ b/src/components/MemberResourcesPortal.tsx @@ -40,6 +40,7 @@ import { TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import CopyToClipboard from "@app/components/CopyToClipboard"; // Update Resource type to include site information type Resource = { @@ -64,6 +65,8 @@ type SiteResource = { destination: string; mode: string; protocol: string | null; + ssl: boolean; + fullDomain: string | null; enabled: boolean; alias: string | null; aliasAddress: string | null; @@ -123,6 +126,7 @@ const ResourceFavicon = ({ // Resource Info component const ResourceInfo = ({ resource }: { resource: Resource }) => { + const t = useTranslations(); const hasAuthMethods = resource.sso || resource.password || @@ -141,7 +145,9 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => { {/* Site Information */} {resource.siteName && (
-
Site
+
+ {t("site")} +
{resource.siteName} @@ -157,7 +163,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => { } >
- Authentication Methods + {t("memberPortalAuthMethods")}
{resource.sso && ( @@ -166,7 +172,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
- Single Sign-On (SSO) + {t("memberPortalSso")}
)} @@ -176,7 +182,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
- Password Protected + {t("memberPortalPasswordProtected")} )} @@ -185,7 +191,9 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
- PIN Code + + {t("memberPortalPinCode")} + )} {resource.whitelist && ( @@ -193,7 +201,9 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
- Email Whitelist + + {t("memberPortalEmailWhitelist")} + )} @@ -208,7 +218,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
- Resource Disabled + {t("memberPortalResourceDisabled")}
@@ -233,6 +243,7 @@ const PaginationControls = ({ totalItems: number; itemsPerPage: number; }) => { + const t = useTranslations(); const startItem = (currentPage - 1) * itemsPerPage + 1; const endItem = Math.min(currentPage * itemsPerPage, totalItems); @@ -241,7 +252,11 @@ const PaginationControls = ({ return (
- Showing {startItem}-{endItem} of {totalItems} resources + {t("memberPortalShowingResources", { + start: startItem, + end: endItem, + total: totalItems + })}
@@ -253,7 +268,7 @@ const PaginationControls = ({ className="gap-1" > - Previous + {t("memberPortalPrevious")}
@@ -309,7 +324,7 @@ const PaginationControls = ({ disabled={currentPage === totalPages} className="gap-1" > - Next + {t("memberPortalNext")}
@@ -389,13 +404,11 @@ export default function MemberResourcesPortal({ response.data.data.siteResources || [] ); } else { - setError("Failed to load resources"); + setError(t("memberPortalFailedToLoad")); } } catch (err) { console.error("Error fetching user resources:", err); - setError( - "Failed to load resources. Please check your connection and try again." - ); + setError(t("memberPortalFailedToLoadDescription")); } finally { setLoading(false); setRefreshing(false); @@ -526,8 +539,8 @@ export default function MemberResourcesPortal({ return (
{/* Search and Sort Controls - Skeleton */} @@ -554,8 +567,8 @@ export default function MemberResourcesPortal({ return (
@@ -563,7 +576,7 @@ export default function MemberResourcesPortal({

- Unable to Load Resources + {t("memberPortalUnableToLoad")}

{error} @@ -574,7 +587,7 @@ export default function MemberResourcesPortal({ className="gap-2" > - Try Again + {t("memberPortalTryAgain")} @@ -585,8 +598,8 @@ export default function MemberResourcesPortal({ return (

{/* Search and Sort Controls with Refresh */} @@ -595,7 +608,7 @@ export default function MemberResourcesPortal({ {/* Search */}
setSearchQuery(e.target.value)} className="w-full pl-8 bg-card" @@ -607,26 +620,28 @@ export default function MemberResourcesPortal({
@@ -644,7 +659,7 @@ export default function MemberResourcesPortal({ - Refresh + {t("memberPortalRefresh")}
@@ -663,13 +678,15 @@ export default function MemberResourcesPortal({

{searchQuery - ? "No Resources Found" - : "No Resources Available"} + ? t("memberPortalNoResourcesFound") + : t("memberPortalNoResourcesAvailable")}

{searchQuery - ? `No resources match "${searchQuery}". Try adjusting your search terms or clearing the search to see all resources.` - : "You don't have access to any resources yet. Contact your administrator to get access to resources you need."} + ? t("memberPortalNoResourcesMatchSearch", { + query: searchQuery + }) + : t("memberPortalNoResourcesAccess")}

{searchQuery ? ( @@ -678,7 +695,7 @@ export default function MemberResourcesPortal({ variant="outline" className="gap-2" > - Clear Search + {t("memberPortalClearSearch")} ) : ( )}
@@ -704,11 +721,12 @@ export default function MemberResourcesPortal({

- Public Resources + {t("memberPortalPublicResources")}

- Web applications and services accessible via - browser + {t( + "memberPortalPublicResourcesDescription" + )}

@@ -768,9 +786,12 @@ export default function MemberResourcesPortal({ resource.domain ); toast({ - title: "Copied to clipboard", - description: - "Resource URL has been copied to your clipboard.", + title: t( + "memberPortalCopiedToClipboard" + ), + description: t( + "memberPortalCopiedUrlDescription" + ), duration: 2000 }); }} @@ -791,7 +812,7 @@ export default function MemberResourcesPortal({ disabled={!resource.enabled} > - Open Resource + {t("memberPortalOpenResource")}
@@ -806,11 +827,12 @@ export default function MemberResourcesPortal({

- Private Resources + {t("memberPortalPrivateResources")}

- Internal network resources accessible via - client + {t( + "memberPortalPrivateResourcesDescription" + )}

@@ -843,11 +865,16 @@ export default function MemberResourcesPortal({
- Resource Details + {t( + "memberPortalResourceDetails" + )}
- Mode: + {t( + "memberPortalMode" + )} + : { @@ -858,7 +885,10 @@ export default function MemberResourcesPortal({ {siteResource.protocol && (
- Protocol: + {t( + "protocol" + )} + : { @@ -869,7 +899,10 @@ export default function MemberResourcesPortal({ )}
- Destination: + {t( + "memberPortalDestination" + )} + : { @@ -880,7 +913,10 @@ export default function MemberResourcesPortal({ {siteResource.alias && (
- Alias: + {t( + "memberPortalAlias" + )} + : { @@ -891,14 +927,21 @@ export default function MemberResourcesPortal({ )}
- Status: + {t( + "status" + )} + : {siteResource.enabled - ? "Enabled" - : "Disabled"} + ? t( + "enabled" + ) + : t( + "disabled" + )}
@@ -907,7 +950,14 @@ export default function MemberResourcesPortal({
- {siteResource.alias ? ( + {siteResource.mode === "http" && + siteResource.fullDomain ? ( + /* HTTP mode - show as clickable link */ + + ) : siteResource.alias ? ( <> {/* Alias as primary */}
@@ -925,9 +975,13 @@ export default function MemberResourcesPortal({ siteResource.alias! ); toast({ - title: "Copied to clipboard", + title: t( + "memberPortalCopiedToClipboard" + ), description: - "Resource alias has been copied to your clipboard.", + t( + "memberPortalCopiedAliasDescription" + ), duration: 2000 }); }} @@ -959,9 +1013,13 @@ export default function MemberResourcesPortal({ siteResource.destination ); toast({ - title: "Copied to clipboard", + title: t( + "memberPortalCopiedToClipboard" + ), description: - "Resource destination has been copied to your clipboard.", + t( + "memberPortalCopiedDestinationDescription" + ), duration: 2000 }); }} @@ -973,10 +1031,34 @@ export default function MemberResourcesPortal({
-
+
+ {siteResource.mode === "http" && + siteResource.fullDomain ? ( + + ) : null}
- Requires Client Connection + {t( + "memberPortalRequiresClientConnection" + )}
diff --git a/src/components/OrgRolesTagField.tsx b/src/components/OrgRolesTagField.tsx index dcd679663..bc8e5a0b5 100644 --- a/src/components/OrgRolesTagField.tsx +++ b/src/components/OrgRolesTagField.tsx @@ -8,51 +8,42 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; -import { Tag, TagInput } from "@app/components/tags/tag-input"; + import { toast } from "@app/hooks/useToast"; import { useTranslations } from "next-intl"; -import type { Dispatch, SetStateAction } from "react"; -import type { FieldValues, Path, UseFormReturn } from "react-hook-form"; -export type RoleTag = { - id: string; - text: string; -}; +import type { FieldValues, Path, UseFormReturn } from "react-hook-form"; +import { RolesSelector, type SelectedRole } from "./roles-selector"; type OrgRolesTagFieldProps = { - form: Pick, "control" | "getValues" | "setValue">; + form: Pick< + UseFormReturn, + "control" | "getValues" | "setValue" + >; + orgId: string; /** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */ name?: Path; - label: string; - placeholder: string; - allRoleOptions: Tag[]; + label?: string; supportsMultipleRolesPerUser: boolean; showMultiRolePaywallMessage: boolean; paywallMessage: string; - loading?: boolean; - activeTagIndex: number | null; - setActiveTagIndex: Dispatch>; + disabled?: boolean; }; export default function OrgRolesTagField({ form, name = "roles" as Path, label, - placeholder, - allRoleOptions, + orgId, supportsMultipleRolesPerUser, showMultiRolePaywallMessage, paywallMessage, - loading = false, - activeTagIndex, - setActiveTagIndex + disabled }: OrgRolesTagFieldProps) { const t = useTranslations(); - function setRoleTags(updater: Tag[] | ((prev: Tag[]) => Tag[])) { - const prev = form.getValues(name) as Tag[]; - const nextValue = - typeof updater === "function" ? updater(prev) : updater; + function setRoleTags(nextValue: SelectedRole[]) { + const prev = form.getValues(name) as SelectedRole[]; const next = supportsMultipleRolesPerUser ? nextValue : nextValue.length > 1 @@ -88,22 +79,13 @@ export default function OrgRolesTagField({ name={name} render={({ field }) => ( - {label} + {label ?? t("roles")} - {showMultiRolePaywallMessage && ( diff --git a/src/components/RoleMappingConfigFields.tsx b/src/components/RoleMappingConfigFields.tsx index d62b7f9e8..3fa96fc86 100644 --- a/src/components/RoleMappingConfigFields.tsx +++ b/src/components/RoleMappingConfigFields.tsx @@ -16,6 +16,7 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { build } from "@server/build"; +import { RolesSelector } from "./roles-selector"; export type RoleMappingRoleOption = { roleId: number; @@ -38,6 +39,8 @@ export type RoleMappingConfigFieldsProps = { fieldIdPrefix?: string; /** When true, show extra hint for global default policies (no org role list). */ showFreeformRoleNamesHint?: boolean; + /** Org ID to use for role lookup. Falls back to URL params when not provided. */ + orgId?: string; }; export default function RoleMappingConfigFields({ @@ -53,14 +56,12 @@ export default function RoleMappingConfigFields({ rawExpression, onRawExpressionChange, fieldIdPrefix = "role-mapping", - showFreeformRoleNamesHint = false + showFreeformRoleNamesHint = false, + orgId }: RoleMappingConfigFieldsProps) { const t = useTranslations(); const { env } = useEnvContext(); const { isPaidUser } = usePaidStatus(); - const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState< - number | null - >(null); const supportsMultipleRolesPerUser = isPaidUser(tierMatrix.fullRbac); const showSingleRoleDisclaimer = @@ -94,6 +95,10 @@ export default function RoleMappingConfigFields({ } }, [supportsMultipleRolesPerUser, fixedRoleNames, onFixedRoleNamesChange]); + const [fixedRolesActiveTagIndex, setFixedRolesActiveTagIndex] = useState< + number | null + >(null); + const fixedRadioId = `${fieldIdPrefix}-fixed-roles-mode`; const builderRadioId = `${fieldIdPrefix}-mapping-builder-mode`; const rawRadioId = `${fieldIdPrefix}-raw-expression-mode`; @@ -160,58 +165,94 @@ export default function RoleMappingConfigFields({ {roleMappingMode === "fixedRoles" && (
- ({ - id: name, - text: name - }))} - setTags={(nextTags) => { - const prevTags = fixedRoleNames.map((name) => ({ + {restrictToOrgRoles ? ( + ({ id: name, text: name - })); - const next = - typeof nextTags === "function" - ? nextTags(prevTags) - : nextTags; + }))} + mapRolesByName + orgId={orgId as string} + onSelectRoles={(nextTags) => { + let names = [ + ...new Set(nextTags.map((tag) => tag.text)) + ]; - let names = [ - ...new Set(next.map((tag) => tag.text)) - ]; - - if (!supportsMultipleRolesPerUser) { - if ( - names.length === 0 && - fixedRoleNames.length > 0 - ) { - onFixedRoleNamesChange([ - fixedRoleNames[ - fixedRoleNames.length - 1 - ]! - ]); - return; + if (!supportsMultipleRolesPerUser) { + if ( + names.length === 0 && + fixedRoleNames.length > 0 + ) { + onFixedRoleNamesChange([ + fixedRoleNames[ + fixedRoleNames.length - 1 + ]! + ]); + return; + } + if (names.length > 1) { + names = [names[names.length - 1]!]; + } } - if (names.length > 1) { - names = [names[names.length - 1]!]; - } - } - onFixedRoleNamesChange(names); - }} - activeTagIndex={activeFixedRoleTagIndex} - setActiveTagIndex={setActiveFixedRoleTagIndex} - placeholder={ - restrictToOrgRoles - ? t("roleMappingFixedRolesPlaceholderSelect") - : t("roleMappingFixedRolesPlaceholderFreeform") - } - enableAutocomplete={restrictToOrgRoles} - autocompleteOptions={roleOptions} - restrictTagsToAutocompleteOptions={restrictToOrgRoles} - allowDuplicates={false} - sortTags={true} - size="sm" - /> + onFixedRoleNamesChange(names); + }} + /> + ) : ( + ({ + id: name, + text: name + }))} + setTags={(nextTags) => { + const prev = fixedRoleNames.map((name) => ({ + id: name, + text: name + })); + const next = + typeof nextTags === "function" + ? nextTags(prev) + : nextTags; + + let names = [ + ...new Set(next.map((tag) => tag.text)) + ]; + + if (!supportsMultipleRolesPerUser) { + if ( + names.length === 0 && + fixedRoleNames.length > 0 + ) { + onFixedRoleNamesChange([ + fixedRoleNames[ + fixedRoleNames.length - 1 + ]! + ]); + return; + } + if (names.length > 1) { + names = [names[names.length - 1]!]; + } + } + + onFixedRoleNamesChange(names); + }} + activeTagIndex={fixedRolesActiveTagIndex} + setActiveTagIndex={setFixedRolesActiveTagIndex} + placeholder={t( + "roleMappingAssignRolesPlaceholderFreeform" + )} + enableAutocomplete={false} + autocompleteOptions={roleOptions} + restrictTagsToAutocompleteOptions={false} + allowDuplicates={false} + sortTags={true} + size="sm" + styleClasses={{ + inlineTagsContainer: "min-w-0 max-w-full" + }} + /> + )} {showFreeformRoleNamesHint ? t("roleMappingFixedRolesDescriptionDefaultPolicy") @@ -261,6 +302,7 @@ export default function RoleMappingConfigFields({ showFreeformRoleNamesHint={ showFreeformRoleNamesHint } + orgId={orgId} supportsMultipleRolesPerUser={ supportsMultipleRolesPerUser } @@ -337,7 +379,8 @@ function BuilderRuleRow({ supportsMultipleRolesPerUser, showRemoveButton, onChange, - onRemove + onRemove, + orgId }: { rule: MappingBuilderRule; roleOptions: Tag[]; @@ -349,6 +392,7 @@ function BuilderRuleRow({ showRemoveButton: boolean; onChange: (rule: MappingBuilderRule) => void; onRemove: () => void; + orgId?: string; }) { const t = useTranslations(); const [activeTagIndex, setActiveTagIndex] = useState(null); @@ -378,67 +422,109 @@ function BuilderRuleRow({ {t("roleMappingAssignRoles")}
- ({ - id: name, - text: name - }))} - setTags={(nextTags) => { - const prevRoleTags = rule.roleNames.map((name) => ({ + {restrictToOrgRoles ? ( + ({ id: name, text: name - })); - const next = - typeof nextTags === "function" - ? nextTags(prevRoleTags) - : nextTags; + }))} + buttonText={t("roleMappingAssignRoles")} + mapRolesByName + orgId={orgId as string} + onSelectRoles={(nextTags) => { + let names = [ + ...new Set(nextTags.map((tag) => tag.text)) + ]; - let names = [ - ...new Set(next.map((tag) => tag.text)) - ]; - - if (!supportsMultipleRolesPerUser) { - if ( - names.length === 0 && - rule.roleNames.length > 0 - ) { - onChange({ - ...rule, - roleNames: [ - rule.roleNames[ - rule.roleNames.length - 1 - ]! - ] - }); - return; + if (!supportsMultipleRolesPerUser) { + if ( + names.length === 0 && + rule.roleNames.length > 0 + ) { + onChange({ + ...rule, + roleNames: [ + rule.roleNames[ + rule.roleNames.length - 1 + ]! + ] + }); + return; + } + if (names.length > 1) { + names = [names[names.length - 1]!]; + } } - if (names.length > 1) { - names = [names[names.length - 1]!]; - } - } - onChange({ - ...rule, - roleNames: names - }); - }} - activeTagIndex={activeTagIndex} - setActiveTagIndex={setActiveTagIndex} - placeholder={ - restrictToOrgRoles - ? t("roleMappingAssignRoles") - : t("roleMappingAssignRolesPlaceholderFreeform") - } - enableAutocomplete={restrictToOrgRoles} - autocompleteOptions={roleOptions} - restrictTagsToAutocompleteOptions={restrictToOrgRoles} - allowDuplicates={false} - sortTags={true} - size="sm" - styleClasses={{ - inlineTagsContainer: "min-w-0 max-w-full" - }} - /> + onChange({ + ...rule, + roleNames: names + }); + }} + /> + ) : ( + ({ + id: name, + text: name + }))} + setTags={(nextTags) => { + const prevRoleTags = rule.roleNames.map( + (name) => ({ + id: name, + text: name + }) + ); + const next = + typeof nextTags === "function" + ? nextTags(prevRoleTags) + : nextTags; + + let names = [ + ...new Set(next.map((tag) => tag.text)) + ]; + + if (!supportsMultipleRolesPerUser) { + if ( + names.length === 0 && + rule.roleNames.length > 0 + ) { + onChange({ + ...rule, + roleNames: [ + rule.roleNames[ + rule.roleNames.length - 1 + ]! + ] + }); + return; + } + if (names.length > 1) { + names = [names[names.length - 1]!]; + } + } + + onChange({ + ...rule, + roleNames: names + }); + }} + activeTagIndex={activeTagIndex} + setActiveTagIndex={setActiveTagIndex} + placeholder={t( + "roleMappingAssignRolesPlaceholderFreeform" + )} + enableAutocomplete={false} + autocompleteOptions={roleOptions} + restrictTagsToAutocompleteOptions={false} + allowDuplicates={false} + sortTags={true} + size="sm" + styleClasses={{ + inlineTagsContainer: "min-w-0 max-w-full" + }} + /> + )}
{showFreeformRoleNamesHint && (

diff --git a/src/components/ShowTrialCard.tsx b/src/components/ShowTrialCard.tsx index dc58483f8..a8133de19 100644 --- a/src/components/ShowTrialCard.tsx +++ b/src/components/ShowTrialCard.tsx @@ -17,9 +17,11 @@ import { useTranslations } from "next-intl"; const TRIAL_DURATION_DAYS = 10; export default function ShowTrialCard({ - isCollapsed + isCollapsed, + isOwner = false }: { isCollapsed?: boolean; + isOwner?: boolean; }) { const context = useSubscriptionStatusContext(); const params = useParams(); @@ -32,53 +34,55 @@ export default function ShowTrialCard({ const now = Date.now(); const remainingMs = trialExpiresAt - now; - const remainingDays = Math.max(0, Math.ceil(remainingMs / (1000 * 60 * 60 * 24))); + const remainingDays = Math.max( + 0, + Math.ceil(remainingMs / (1000 * 60 * 60 * 24)) + ); const totalMs = TRIAL_DURATION_DAYS * 24 * 60 * 60 * 1000; - const progressPct = Math.min(100, Math.max(0, ((now - (trialExpiresAt - totalMs)) / totalMs) * 100)); + const progressPct = Math.min( + 100, + Math.max(0, ((now - (trialExpiresAt - totalMs)) / totalMs) * 100) + ); // Inverted: full bar at start, drains to empty as trial ends const displayPct = 100 - progressPct; const billingHref = orgId ? `/${orgId}/settings/billing` : "/"; if (isCollapsed) { - return ( + const icon = ( - + - +

{remainingDays === 0 ? t("trialExpired") - : t("trialDaysLeftShort", { days: remainingDays })} + : t("trialDaysLeftShort", { + days: remainingDays + })}

); + + if (isOwner) { + return {icon}; + } + + return icon; } - return ( - + const cardContent = ( + <>

- {remainingDays === 0 - ? t("trialExpired") - : t("trialActive")} + {remainingDays === 0 ? t("trialExpired") : t("trialActive")}

@@ -88,11 +92,37 @@ export default function ShowTrialCard({ ? t("trialHasEnded") : t("trialDaysRemaining", { count: remainingDays })} -
- {t("trialGoToBilling")} - -
+ {isOwner && ( +
+ {t("trialGoToBilling")} + +
+ )}
- + + ); + + if (isOwner) { + return ( + + {cardContent} + + ); + } + + return ( +
+ {cardContent} +
); } diff --git a/src/components/UptimeAlertSection.tsx b/src/components/UptimeAlertSection.tsx index 791bb9ddd..a671a2dd5 100644 --- a/src/components/UptimeAlertSection.tsx +++ b/src/components/UptimeAlertSection.tsx @@ -1,18 +1,5 @@ "use client"; -import { useState, useMemo } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import Link from "next/link"; -import { BellPlus, BellRing } from "lucide-react"; -import { - SettingsSection, - SettingsSectionHeader, - SettingsSectionTitle, - SettingsSectionDescription, - SettingsSectionBody -} from "@app/components/Settings"; -import UptimeBar from "@app/components/UptimeBar"; -import { Button } from "@app/components/ui/button"; import { Credenza, CredenzaBody, @@ -23,18 +10,32 @@ import { CredenzaHeader, CredenzaTitle } from "@app/components/Credenza"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import UptimeBar from "@app/components/UptimeBar"; +import { TagInput, type Tag } from "@app/components/tags/tag-input"; +import { Button } from "@app/components/ui/button"; import { Input } from "@app/components/ui/input"; import { Label } from "@app/components/ui/label"; -import { TagInput, type Tag } from "@app/components/tags/tag-input"; -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { toast } from "@app/hooks/useToast"; -import { orgQueries } from "@app/lib/queries"; -import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { orgQueries } from "@app/lib/queries"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { BellPlus, BellRing } from "lucide-react"; import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useState } from "react"; +import { RolesSelector } from "./roles-selector"; +import { UsersSelector } from "./users-selector"; interface UptimeAlertSectionProps { orgId: string; @@ -52,10 +53,12 @@ export default function UptimeAlertSection({ days = 90 }: UptimeAlertSectionProps) { const t = useTranslations(); - const api = createApiClient(useEnvContext()); + const envContext = useEnvContext(); + const api = createApiClient(envContext); const queryClient = useQueryClient(); const { isPaidUser } = usePaidStatus(); const isPaid = isPaidUser(tierMatrix.alertingRules); + const { env } = envContext; const [open, setOpen] = useState(false); const [name, setName] = useState( @@ -64,12 +67,7 @@ export default function UptimeAlertSection({ const [userTags, setUserTags] = useState([]); const [roleTags, setRoleTags] = useState([]); const [emailTags, setEmailTags] = useState([]); - const [activeUserTagIndex, setActiveUserTagIndex] = useState( - null - ); - const [activeRoleTagIndex, setActiveRoleTagIndex] = useState( - null - ); + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< number | null >(null); @@ -80,27 +78,6 @@ export default function UptimeAlertSection({ enabled: isPaid }); - const { data: orgUsers = [] } = useQuery(orgQueries.users({ orgId })); - const { data: orgRoles = [] } = useQuery(orgQueries.roles({ orgId })); - - const allUsers = useMemo( - () => - orgUsers.map((u) => ({ - id: String(u.id), - text: getUserDisplayName({ - email: u.email, - name: u.name, - username: u.username - }) - })), - [orgUsers] - ); - - const allRoles = useMemo( - () => orgRoles.map((r) => ({ id: String(r.roleId), text: r.name })), - [orgRoles] - ); - const hasRules = (alertRules?.length ?? 0) > 0; async function handleSubmit() { @@ -201,7 +178,9 @@ export default function UptimeAlertSection({ {t("uptimeSectionDescription", { days })}
- {alertButton} + {!env.flags.disableEnterpriseFeatures + ? alertButton + : null}
@@ -227,10 +206,16 @@ export default function UptimeAlertSection({
- +
@@ -240,65 +225,53 @@ export default function UptimeAlertSection({ setName(e.target.value)} - placeholder={t("uptimeAlertNamePlaceholder")} + onChange={(e) => + setName(e.target.value) + } + placeholder={t( + "uptimeAlertNamePlaceholder" + )} />
- - { - const next = - typeof newTags === "function" - ? newTags(userTags) - : newTags; - setUserTags(next as Tag[]); - }} - enableAutocomplete - autocompleteOptions={allUsers} - restrictTagsToAutocompleteOptions - allowDuplicates={false} - sortTags + +
- - { - const next = - typeof newTags === "function" - ? newTags(roleTags) - : newTags; - setRoleTags(next as Tag[]); - }} - enableAutocomplete - autocompleteOptions={allRoles} - restrictTagsToAutocompleteOptions - allowDuplicates={false} - sortTags + +
- + { const next = - typeof newTags === "function" + typeof newTags === + "function" ? newTags(emailTags) : newTags; setEmailTags(next as Tag[]); @@ -306,7 +279,9 @@ export default function UptimeAlertSection({ allowDuplicates={false} sortTags validateTag={(tag) => - /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag) + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( + tag + ) } delimiterList={[",", "Enter"]} /> diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index b374df5f8..d787595ed 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -1,5 +1,8 @@ "use client"; +import { ContactSalesBanner } from "@app/components/ContactSalesBanner"; +import { StrategySelect } from "@app/components/StrategySelect"; +import { TagInput, type Tag } from "@app/components/tags/tag-input"; import { Button } from "@app/components/ui/button"; import { Checkbox } from "@app/components/ui/checkbox"; import { @@ -21,11 +24,13 @@ import { import { Input } from "@app/components/ui/input"; import { Switch } from "@app/components/ui/switch"; import { Textarea } from "@app/components/ui/textarea"; +import { Label } from "@app/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; +import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { Select, SelectContent, @@ -33,24 +38,21 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; -import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; -import { Label } from "@app/components/ui/label"; -import { StrategySelect } from "@app/components/StrategySelect"; -import { TagInput, type Tag } from "@app/components/tags/tag-input"; -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { type AlertRuleFormAction, type AlertRuleFormValues } from "@app/lib/alertRuleForm"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; -import { ContactSalesBanner } from "@app/components/ContactSalesBanner"; -import { Bell, Globe, ChevronsUpDown, Plus, Trash2 } from "lucide-react"; +import { Bell, ChevronsUpDown, Globe, Plus, Trash2 } from "lucide-react"; import { useTranslations } from "next-intl"; import { useEffect, useMemo, useRef, useState } from "react"; import type { Control, UseFormReturn } from "react-hook-form"; import { useFormContext, useWatch } from "react-hook-form"; import { useDebounce } from "use-debounce"; +import { RolesSelector } from "../roles-selector"; +import { UsersSelector } from "../users-selector"; export function AddActionPanel({ onAdd @@ -498,12 +500,6 @@ function NotifyActionFields({ const t = useTranslations(); const [emailActiveIdx, setEmailActiveIdx] = useState(null); - const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< - number | null - >(null); - const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< - number | null - >(null); const { data: orgUsers = [], isLoading: isLoadingUsers } = useQuery( orgQueries.users({ orgId }) @@ -574,14 +570,6 @@ function NotifyActionFields({ hasResolvedTagsRef.current = true; }, [isLoadingUsers, isLoadingRoles, allUsers, allRoles]); - const userTags = (useWatch({ - control, - name: `actions.${index}.userTags` - }) ?? []) as Tag[]; - const roleTags = (useWatch({ - control, - name: `actions.${index}.roleTags` - }) ?? []) as Tag[]; const emailTags = (useWatch({ control, name: `actions.${index}.emailTags` @@ -596,29 +584,16 @@ function NotifyActionFields({ {t("alertingNotifyUsers")} - { - const next = - typeof newTags === "function" - ? newTags(userTags) - : newTags; + { form.setValue( `actions.${index}.userTags`, - next as Tag[], + newUsers as [Tag, ...Tag[]], { shouldDirty: true } ); }} - enableAutocomplete={true} - autocompleteOptions={allUsers} - allowDuplicates={false} - restrictTagsToAutocompleteOptions={true} - sortTags={true} /> @@ -632,29 +607,17 @@ function NotifyActionFields({ {t("alertingNotifyRoles")} - { - const next = - typeof newTags === "function" - ? newTags(roleTags) - : newTags; + { form.setValue( `actions.${index}.roleTags`, - next as Tag[], + newUsers as [Tag, ...Tag[]], { shouldDirty: true } ); }} - enableAutocomplete={true} - autocompleteOptions={allRoles} - allowDuplicates={false} - restrictTagsToAutocompleteOptions={true} - sortTags={true} /> diff --git a/src/components/machines-selector.tsx b/src/components/machines-selector.tsx index 99515135e..cfae4c2d8 100644 --- a/src/components/machines-selector.tsx +++ b/src/components/machines-selector.tsx @@ -5,7 +5,7 @@ import { useMemo, useState } from "react"; import { useDebounce } from "use-debounce"; import { useTranslations } from "next-intl"; -import { MultiSelectTags } from "./multi-select-tags"; +import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input"; export type SelectedMachine = Pick< ListClientsResponse["clients"][number], @@ -28,11 +28,13 @@ export function MachinesSelector({ const [debouncedValue] = useDebounce(machineSearchQuery, 150); + const perPage = 7; + const { data: machines = [] } = useQuery( - orgQueries.machineClients({ orgId, perPage: 10, query: debouncedValue }) + orgQueries.machineClients({ orgId, perPage, query: debouncedValue }) ); - // always include the selected machines in the list of machines shown (if the user isn't searching) + // always include the selected machines in the list (if the user isn't searching) const machinesShown = useMemo(() => { const allMachines: Array = [...machines]; if (debouncedValue.trim().length === 0) { @@ -44,75 +46,32 @@ export function MachinesSelector({ } } } - return allMachines; }, [machines, selectedMachines, debouncedValue]); - // const selectedMachinesIds = new Set( - // selectedMachines.map((m) => m.clientId) - // ); - return ( - ({ - ...m, - text: m.name, - id: m.clientId.toString() - }))} - onChange={(values) => { - onSelectMachines(values); - }} - options={machinesShown.map((m) => ({ - ...m, - id: m.clientId.toString(), - text: m.name - }))} - onSearch={setMachineSearchQuery} searchQuery={machineSearchQuery} + onSearch={setMachineSearchQuery} + options={machinesShown.map((mc) => ({ + id: mc.clientId.toString(), + text: mc.name + }))} + value={selectedMachines.map((mc) => ({ + id: mc.clientId.toString(), + text: mc.name + }))} + onChange={(newValues) => { + onSelectMachines( + newValues.map((v) => ({ + clientId: Number(v.id), + name: v.text + })) + ); + }} /> - // - // - // - // {t("machineNotFound")} - // - // {machinesShown.map((m) => ( - // { - // let newMachineClients = []; - // if (selectedMachinesIds.has(m.clientId)) { - // newMachineClients = selectedMachines.filter( - // (mc) => mc.clientId !== m.clientId - // ); - // } else { - // newMachineClients = [ - // ...selectedMachines, - // m - // ]; - // } - // onSelectMachines(newMachineClients); - // }} - // > - // - // {`${m.name}`} - // - // ))} - // - // - // ); } diff --git a/src/components/multi-select-tags.tsx b/src/components/multi-select/multi-select-content.tsx similarity index 83% rename from src/components/multi-select-tags.tsx rename to src/components/multi-select/multi-select-content.tsx index 2fb9b097d..9f49b41ca 100644 --- a/src/components/multi-select-tags.tsx +++ b/src/components/multi-select/multi-select-content.tsx @@ -6,24 +6,26 @@ import { CommandInput, CommandItem, CommandList -} from "./ui/command"; +} from "../ui/command"; import { cn } from "@app/lib/cn"; import { CheckIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; export type TagValue = { text: string; id: string }; export type MultiSelectTagsProps = { - emptyPlaceholder: string; - searchPlaceholder: string; + emptyPlaceholder?: string; + searchPlaceholder?: string; searchQuery?: string; options: Array; value: Array; onChange: (newValue: Array) => void; onSearch: (query: string) => void; ref?: Ref; + disabled?: boolean; }; -export function MultiSelectTags({ +export function MultiSelectContent({ emptyPlaceholder, searchPlaceholder, searchQuery, @@ -32,16 +34,19 @@ export function MultiSelectTags({ onSearch, onChange }: MultiSelectTagsProps) { + const t = useTranslations(); const selectedValues = new Set(value.map((v) => v.id)); return ( - {emptyPlaceholder} + + {emptyPlaceholder ?? t("noResults")} + {options.map((option) => ( extends MultiSelectTagsProps { + buttonText?: string; +} + +export function MultiSelectTagInput({ + buttonText, + ...props +}: MultiSelectInputProps) { + const selectedValues = new Set(props.value.map((v) => v.id)); + + return ( + { + if (!open) { + // clear input when popover is closed + props.onSearch(""); + } + }} + > + +
+ + {props.value.map((option) => ( + e.stopPropagation()} + > + {option.text} + + + ))} + {buttonText} + + +
+
+ + + +
+ ); +} diff --git a/src/components/roles-selector.tsx b/src/components/roles-selector.tsx new file mode 100644 index 000000000..7f1b62e60 --- /dev/null +++ b/src/components/roles-selector.tsx @@ -0,0 +1,81 @@ +import { orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { useDebounce } from "use-debounce"; + +import { useTranslations } from "next-intl"; +import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input"; + +export type SelectedRole = { id: string; text: string }; + +export type RolesSelectorProps = { + orgId: string; + selectedRoles?: SelectedRole[]; + onSelectRoles: (roles: SelectedRole[]) => void; + disabled?: boolean; + restrictAdminRole?: boolean; + mapRolesByName?: boolean; + buttonText?: string; +}; + +export function RolesSelector({ + orgId, + selectedRoles = [], + onSelectRoles, + disabled, + restrictAdminRole, + mapRolesByName, + buttonText +}: RolesSelectorProps) { + const t = useTranslations(); + const [roleSearchQuery, setRoleSearchQuery] = useState(""); + + const [debouncedValue] = useDebounce(roleSearchQuery, 150); + + const { data: roles = [] } = useQuery( + orgQueries.roles({ orgId, perPage: 10, query: debouncedValue }) + ); + + // always include the selected roles in the list (if the user isn't searching) + const rolesShown = useMemo(() => { + let allRoles: Array = roles.map( + (r) => ({ + id: mapRolesByName ? r.name : r.roleId.toString(), + text: r.name, + isAdmin: Boolean(r.isAdmin) + }) + ); + + if (debouncedValue.trim().length === 0) { + for (const role of selectedRoles) { + if (!allRoles.find((r) => r.id === role.id)) { + allRoles.unshift(role); + } + } + } + + if (restrictAdminRole) { + allRoles = allRoles.filter((role) => !role.isAdmin); + } + + return allRoles; + }, [ + roles, + selectedRoles, + debouncedValue, + restrictAdminRole, + mapRolesByName + ]); + + return ( + + ); +} diff --git a/src/components/tags/autocomplete.tsx b/src/components/tags/autocomplete.tsx index 916e7aeed..938853a1d 100644 --- a/src/components/tags/autocomplete.tsx +++ b/src/components/tags/autocomplete.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState +} from "react"; import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input"; import { Command, @@ -220,7 +226,7 @@ export const Autocomplete: React.FC = ({ >
{childrenWithProps} @@ -260,10 +266,7 @@ export const Autocomplete: React.FC = ({ side="bottom" align="start" forceMount - className={cn( - "p-0", - classStyleProps?.popoverContent - )} + className={cn("p-0", classStyleProps?.popoverContent)} style={{ width: `${popoverWidth}px`, minWidth: `${popoverWidth}px`, @@ -300,7 +303,9 @@ export const Autocomplete: React.FC = ({ key={option.id} value={`${option.text} ${option.id}`} onSelect={() => toggleTag(option)} - className={classStyleProps?.commandItem} + className={ + classStyleProps?.commandItem + } > boolean; direction?: "row" | "column"; onInputChange?: (value: string) => void; + searchQuery?: string; + onSearchQueryChange?: (value: string) => void; customTagRenderer?: (tag: Tag, isActiveTag: boolean) => React.ReactNode; onFocus?: React.FocusEventHandler; onBlur?: React.FocusEventHandler; @@ -157,10 +159,24 @@ export function TagInput({ ref, ...props }: TagInputProps) { disabled = false, usePortal = false, addOnPaste = false, - generateTagId = uuid + generateTagId = uuid, + searchQuery, + onSearchQueryChange } = props; const [inputValue, setInputValue] = React.useState(""); + const isControlled = searchQuery !== undefined; + const effectiveQuery = isControlled ? searchQuery : inputValue; + + const updateQuery = React.useCallback( + (action: React.SetStateAction) => { + const resolved = + typeof action === "function" ? action(effectiveQuery) : action; + if (!isControlled) setInputValue(resolved); + onSearchQueryChange?.(resolved); + }, + [isControlled, effectiveQuery, onSearchQueryChange] + ); const [tagCount, setTagCount] = React.useState(Math.max(0, tags.length)); const inputRef = React.useRef(null); @@ -234,9 +250,9 @@ export function TagInput({ ref, ...props }: TagInputProps) { ); } }); - setInputValue(""); + updateQuery(""); } else { - setInputValue(newValue); + updateQuery(newValue); } onInputChange?.(newValue); }; @@ -247,8 +263,8 @@ export function TagInput({ ref, ...props }: TagInputProps) { }; const handleInputBlur = (event: React.FocusEvent) => { - if (addTagsOnBlur && inputValue.trim()) { - const newTagText = inputValue.trim(); + if (addTagsOnBlur && effectiveQuery.trim()) { + const newTagText = effectiveQuery.trim(); if (validateTag && !validateTag(newTagText)) { return; @@ -273,7 +289,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { setTags([...tags, { id: newTagId, text: newTagText }]); onTagAdd?.(newTagText); setTagCount((prevTagCount) => prevTagCount + 1); - setInputValue(""); + updateQuery(""); } } @@ -287,7 +303,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { : e.key === delimiter || e.key === Delimiter.Enter ) { e.preventDefault(); - const newTagText = inputValue.trim(); + const newTagText = effectiveQuery.trim(); // Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true if ( @@ -329,7 +345,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { onTagAdd?.(newTagText); setTagCount((prevTagCount) => prevTagCount + 1); } - setInputValue(""); + updateQuery(""); } else { switch (e.key) { case "Delete": @@ -419,9 +435,6 @@ export function TagInput({ ref, ...props }: TagInputProps) { onClearAll?.(); }; - // const filteredAutocompleteOptions = autocompleteFilter - // ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text)) - // : autocompleteOptions; const displayedTags = sortTags ? [...tags].sort() : tags; const truncatedTags = truncate @@ -436,13 +449,15 @@ export function TagInput({ ref, ...props }: TagInputProps) { return (
0 ? "gap-3" : ""} ${ + className={cn( + `w-full flex`, + !inlineTags && tags.length > 0 && "gap-3", inputFieldPosition === "bottom" ? "flex-col" : inputFieldPosition === "top" ? "flex-col-reverse" : "flex-row" - }`} + )} > {!usePopoverForTags && (!inlineTags ? ( @@ -515,14 +530,14 @@ export function TagInput({ ref, ...props }: TagInputProps) { ? placeholderWhenFull : placeholder } - value={inputValue} + value={effectiveQuery} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={handleInputFocus} onBlur={handleInputBlur} {...inputProps} className={cn( - "border-0 px-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", + "border-0 px-2 h-6 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", // className, styleClasses?.input )} @@ -544,16 +559,17 @@ export function TagInput({ ref, ...props }: TagInputProps) {
) ))} + {enableAutocomplete ? (
= maxTags ? placeholderWhenFull : placeholder} // ref={inputRef} - // value={inputValue} + // value={effectiveQuery} // disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} // onChangeCapture={handleInputChange} // onKeyDown={handleKeyDown} @@ -601,14 +617,14 @@ export function TagInput({ ref, ...props }: TagInputProps) { ? placeholderWhenFull : placeholder } - value={inputValue} + value={effectiveQuery} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={handleInputFocus} onBlur={handleInputBlur} {...inputProps} className={cn( - "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", + "border-0 h-6 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", // className, styleClasses?.input )} @@ -662,7 +678,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { {/* = maxTags ? placeholderWhenFull : placeholder} ref={inputRef} - value={inputValue} + value={effectiveQuery} disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} onChangeCapture={handleInputChange} onKeyDown={handleKeyDown} @@ -685,14 +701,14 @@ export function TagInput({ ref, ...props }: TagInputProps) { ? placeholderWhenFull : placeholder } - value={inputValue} + value={effectiveQuery} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={handleInputFocus} onBlur={handleInputBlur} {...inputProps} className={cn( - "border-0 px-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", + "border-0 px-2 h-6 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", // className, styleClasses?.input )} @@ -741,7 +757,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { {/* = maxTags ? placeholderWhenFull : placeholder} ref={inputRef} - value={inputValue} + value={effectiveQuery} disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} onChangeCapture={handleInputChange} onKeyDown={handleKeyDown} @@ -763,14 +779,14 @@ export function TagInput({ ref, ...props }: TagInputProps) { ? placeholderWhenFull : placeholder } - value={inputValue} + value={effectiveQuery} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={handleInputFocus} onBlur={handleInputBlur} {...inputProps} className={cn( - "border-0 px-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", + "border-0 px-2 h-6 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", // className, styleClasses?.input )} @@ -806,7 +822,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { ? placeholderWhenFull : placeholder } - value={inputValue} + value={effectiveQuery} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={handleInputFocus} @@ -866,7 +882,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { ? placeholderWhenFull : placeholder } - value={inputValue} + value={effectiveQuery} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={handleInputFocus} diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 8b2b6748a..eab0f517a 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -87,7 +87,7 @@ function CommandList({ ) { return ( ); @@ -115,7 +116,7 @@ function CommandGroup({ ({ ))} - {table.getRowModel().rows?.length ? ( + {(table.getRowModel().rows ?? []).length > 0 ? ( table.getRowModel().rows.map((row) => ( void; +}; + +export function UsersSelector({ + orgId, + selectedUsers = [], + onSelectUsers +}: UsersSelectorProps) { + const t = useTranslations(); + const [userSearchQuery, setUserSearchQuery] = useState(""); + + const [debouncedValue] = useDebounce(userSearchQuery, 150); + + const { data: users = [] } = useQuery( + orgQueries.users({ orgId, perPage: 10, query: debouncedValue }) + ); + + // always include the selected users in the list (if the user isn't searching) + const usersShown = useMemo(() => { + const allUsers: Array = users.map((u) => ({ + id: u.id, + text: getUserDisplayName(u) + })); + if (debouncedValue.trim().length === 0) { + for (const user of selectedUsers) { + if (!allUsers.find((u) => u.id === user.id)) { + allUsers.unshift(user); + } + } + } + return allUsers; + }, [users, selectedUsers, debouncedValue]); + + return ( + + ); +} diff --git a/src/lib/getUserDisplayName.ts b/src/lib/getUserDisplayName.ts index e95096c16..508b198c3 100644 --- a/src/lib/getUserDisplayName.ts +++ b/src/lib/getUserDisplayName.ts @@ -8,6 +8,7 @@ type UserDisplayNameInput = email?: string | null; name?: string | null; username?: string | null; + idpName?: string | null; }; /** @@ -21,16 +22,25 @@ export function getUserDisplayName(input: UserDisplayNameInput): string { let email: string | null | undefined; let name: string | null | undefined; let username: string | null | undefined; + let idpName: string | null | undefined; if ("user" in input) { email = input.user.email; name = input.user.name; username = input.user.username; + idpName = input.user.idpName; } else { email = input.email; name = input.name; username = input.username; + idpName = input.idpName; } - return email || name || username || ""; + let nameShown = email || name || username || ""; + + if (idpName) { + nameShown = `${nameShown} (${idpName})`; + } + + return nameShown; } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 3e38a7ba0..e58a5d471 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -125,24 +125,56 @@ export const orgQueries = { return res.data.data.clients; } }), - users: ({ orgId }: { orgId: string }) => + users: ({ + orgId, + query, + perPage = 10_000 + }: { + orgId: string; + query?: string; + perPage?: number; + }) => queryOptions({ - queryKey: ["ORG", orgId, "USERS"] as const, + queryKey: ["ORG", orgId, "USERS", { query, perPage }] as const, queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + pageSize: perPage.toString() + }); + + if (query?.trim()) { + sp.set("query", query); + } + const res = await meta!.api.get< AxiosResponse - >(`/org/${orgId}/users`, { signal }); + >(`/org/${orgId}/users?${sp.toString()}`, { signal }); return res.data.data.users; } }), - roles: ({ orgId }: { orgId: string }) => + roles: ({ + orgId, + query, + perPage = 10_000 + }: { + orgId: string; + query?: string; + perPage?: number; + }) => queryOptions({ - queryKey: ["ORG", orgId, "ROLES"] as const, + queryKey: ["ORG", orgId, "ROLES", { query, perPage }] as const, queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + pageSize: perPage.toString() + }); + + if (query?.trim()) { + sp.set("query", query); + } + const res = await meta!.api.get< AxiosResponse - >(`/org/${orgId}/roles`, { signal }); + >(`/org/${orgId}/roles?${sp.toString()}`, { signal }); return res.data.data.roles; }