Compare commits

..

1 Commits

Author SHA1 Message Date
Owen
52a90fbd2b Add regional redis cache 2026-05-05 14:23:59 -07:00
62 changed files with 1944 additions and 2792 deletions

View File

@@ -1,28 +0,0 @@
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);
}
}
};

View File

@@ -9,7 +9,6 @@ import { rotateServerSecret } from "./commands/rotateServerSecret";
import { clearLicenseKeys } from "./commands/clearLicenseKeys"; import { clearLicenseKeys } from "./commands/clearLicenseKeys";
import { deleteClient } from "./commands/deleteClient"; import { deleteClient } from "./commands/deleteClient";
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys"; import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
import { clearCertificates } from "./commands/clearCertificates";
yargs(hideBin(process.argv)) yargs(hideBin(process.argv))
.scriptName("pangctl") .scriptName("pangctl")
@@ -20,6 +19,5 @@ yargs(hideBin(process.argv))
.command(clearLicenseKeys) .command(clearLicenseKeys)
.command(deleteClient) .command(deleteClient)
.command(generateOrgCaKeys) .command(generateOrgCaKeys)
.command(clearCertificates)
.demandCommand() .demandCommand()
.help().argv; .help().argv;

View File

@@ -1,12 +0,0 @@
services:
mailer:
image: axllent/mailpit
ports:
- 8025:8025
- 1025:1025
volumes:
- mailpit-storage:/data
environment:
- MP_DATABASE=/data/mailpit.db
volumes:
mailpit-storage:

View File

@@ -2660,19 +2660,19 @@
"noMoreAuthMethods": "Няма валидни методи за удостоверение", "noMoreAuthMethods": "Няма валидни методи за удостоверение",
"ip": "IP", "ip": "IP",
"reason": "Причина", "reason": "Причина",
"requestLogs": "Логове за HTTP заявки", "requestLogs": "Заявка за логове",
"requestAnalytics": "Анализи На Заявки", "requestAnalytics": "Анализи На Заявки",
"host": "Хост", "host": "Хост",
"location": "Местоположение", "location": "Местоположение",
"actionLogs": "Дневници на действията", "actionLogs": "Дневници на действията",
"sidebarLogsRequest": "Логове за HTTP заявки", "sidebarLogsRequest": "Заявка за логове",
"sidebarLogsAccess": "Достъп до логове", "sidebarLogsAccess": "Достъп до логове",
"sidebarLogsAction": "Дневници на действията", "sidebarLogsAction": "Дневници на действията",
"logRetention": "Задържане на логове", "logRetention": "Задържане на логове",
"logRetentionDescription": "Управлявайте времето за задържане на различни видове логове за тази организация или ги деактивирайте", "logRetentionDescription": "Управлявайте времето за задържане на различни видове логове за тази организация или ги деактивирайте",
"requestLogsDescription": "Прегледайте подробни логове на заявки за ресурси в тази организация", "requestLogsDescription": "Прегледайте подробни логове на заявки за ресурси в тази организация",
"requestAnalyticsDescription": "Вижте подробни анализи на заявки за ресурсите в тази организация", "requestAnalyticsDescription": "Вижте подробни анализи на заявки за ресурсите в тази организация",
"logRetentionRequestLabel": "Задържане на логове за HTTP заявки", "logRetentionRequestLabel": "Задържане на логове на заявки",
"logRetentionRequestDescription": "Колко дълго да се задържат логовете на заявките", "logRetentionRequestDescription": "Колко дълго да се задържат логовете на заявките",
"logRetentionAccessLabel": "Задържане на логове за достъп", "logRetentionAccessLabel": "Задържане на логове за достъп",
"logRetentionAccessDescription": "Колко дълго да се задържат логовете за достъп", "logRetentionAccessDescription": "Колко дълго да се задържат логовете за достъп",
@@ -3134,7 +3134,7 @@
"httpDestActionLogsDescription": "Административни действия, извършени от потребители в организацията.", "httpDestActionLogsDescription": "Административни действия, извършени от потребители в организацията.",
"httpDestConnectionLogsTitle": "Логове на връзката", "httpDestConnectionLogsTitle": "Логове на връзката",
"httpDestConnectionLogsDescription": "Събития на свързване и прекъсване на сайта и тунела, включително свръзки и прекъсвания.", "httpDestConnectionLogsDescription": "Събития на свързване и прекъсване на сайта и тунела, включително свръзки и прекъсвания.",
"httpDestRequestLogsTitle": "Логове за HTTP заявки", "httpDestRequestLogsTitle": "Заявки за логове",
"httpDestRequestLogsDescription": "Регистри за HTTP заявките към проксирани ресурси, включително метод, път и код на отговор.", "httpDestRequestLogsDescription": "Регистри за HTTP заявките към проксирани ресурси, включително метод, път и код на отговор.",
"httpDestSaveChanges": "Запази промените", "httpDestSaveChanges": "Запази промените",
"httpDestCreateDestination": "Създаване на дестинация", "httpDestCreateDestination": "Създаване на дестинация",
@@ -3208,48 +3208,5 @@
"domainPickerWildcardCertWarning": "Ресурсите с уайлдкард може да изискват допълнителна конфигурация за правилна работа.", "domainPickerWildcardCertWarning": "Ресурсите с уайлдкард може да изискват допълнителна конфигурация за правилна работа.",
"domainPickerWildcardCertWarningLink": "Научете повече", "domainPickerWildcardCertWarningLink": "Научете повече",
"health": "Здраве", "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": "Следващ"
} }

View File

@@ -2660,19 +2660,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "IP adresa", "ip": "IP adresa",
"reason": "Důvod", "reason": "Důvod",
"requestLogs": "Záznamy HTTP požadavků", "requestLogs": "Záznamy požadavků",
"requestAnalytics": "Vyžádat analýzu", "requestAnalytics": "Vyžádat analýzu",
"host": "Hostitel", "host": "Hostitel",
"location": "Poloha", "location": "Poloha",
"actionLogs": "Záznamy akcí", "actionLogs": "Záznamy akcí",
"sidebarLogsRequest": "Záznamy HTTP požadavků", "sidebarLogsRequest": "Záznamy požadavků",
"sidebarLogsAccess": "Protokoly přístupu", "sidebarLogsAccess": "Protokoly přístupu",
"sidebarLogsAction": "Záznamy akcí", "sidebarLogsAction": "Záznamy akcí",
"logRetention": "Zaznamenávání záznamu", "logRetention": "Zaznamenávání záznamu",
"logRetentionDescription": "Spravovat, jak dlouho jsou různé typy logů uloženy pro tuto organizaci nebo je zakázat", "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", "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", "requestAnalyticsDescription": "Zobrazit podrobnou analýzu požadavků pro zdroje v této organizaci",
"logRetentionRequestLabel": "Zachování logu HTTP požadavků", "logRetentionRequestLabel": "Zachování logu žádosti",
"logRetentionRequestDescription": "Jak dlouho uchovávat záznamy požadavků", "logRetentionRequestDescription": "Jak dlouho uchovávat záznamy požadavků",
"logRetentionAccessLabel": "Zachování záznamu přístupu", "logRetentionAccessLabel": "Zachování záznamu přístupu",
"logRetentionAccessDescription": "Jak dlouho uchovávat přístupové záznamy", "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.", "httpDestActionLogsDescription": "Správní opatření prováděná uživateli v rámci organizace.",
"httpDestConnectionLogsTitle": "Protokoly připojení", "httpDestConnectionLogsTitle": "Protokoly připojení",
"httpDestConnectionLogsDescription": "Události týkající se připojení lokality a tunelu, včetně připojení a odpojení.", "httpDestConnectionLogsDescription": "Události týkající se připojení lokality a tunelu, včetně připojení a odpojení.",
"httpDestRequestLogsTitle": "Záznamy HTTP požadavků", "httpDestRequestLogsTitle": "Záznamy požadavků",
"httpDestRequestLogsDescription": "HTTP záznamy požadavků pro proxy zdroje, včetně metod, cesty a kódu odpovědi.", "httpDestRequestLogsDescription": "HTTP záznamy požadavků pro proxy zdroje, včetně metod, cesty a kódu odpovědi.",
"httpDestSaveChanges": "Uložit změny", "httpDestSaveChanges": "Uložit změny",
"httpDestCreateDestination": "Vytvořit cíl", "httpDestCreateDestination": "Vytvořit cíl",
@@ -3208,48 +3208,5 @@
"domainPickerWildcardCertWarning": "Zástupné zdroje mohou vyžadovat dodatečnou konfiguraci pro správnou funkci.", "domainPickerWildcardCertWarning": "Zástupné zdroje mohou vyžadovat dodatečnou konfiguraci pro správnou funkci.",
"domainPickerWildcardCertWarningLink": "Zjistit více", "domainPickerWildcardCertWarningLink": "Zjistit více",
"health": "Zdraví", "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í"
} }

View File

@@ -2660,19 +2660,19 @@
"noMoreAuthMethods": "Keine gültige Authentifizierungsmethode verfügbar", "noMoreAuthMethods": "Keine gültige Authentifizierungsmethode verfügbar",
"ip": "IP", "ip": "IP",
"reason": "Grund", "reason": "Grund",
"requestLogs": "HTTP Anforderungsprotokolle", "requestLogs": "Logs anfordern",
"requestAnalytics": "Anfrage-Analyse anzeigen", "requestAnalytics": "Anfrage-Analyse anzeigen",
"host": "Host", "host": "Host",
"location": "Standort", "location": "Standort",
"actionLogs": "Aktionsprotokolle", "actionLogs": "Aktionsprotokolle",
"sidebarLogsRequest": "HTTP Anforderungsprotokolle", "sidebarLogsRequest": "Logs anfordern",
"sidebarLogsAccess": "Zugriffsprotokolle", "sidebarLogsAccess": "Zugriffsprotokolle",
"sidebarLogsAction": "Aktionsprotokolle", "sidebarLogsAction": "Aktionsprotokolle",
"logRetention": "Log-Speicherung", "logRetention": "Log-Speicherung",
"logRetentionDescription": "Verwalten, wie lange verschiedene Logs für diese Organisation gespeichert werden oder deaktivieren", "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", "requestLogsDescription": "Detaillierte Request-Logs für Ressourcen in dieser Organisation anzeigen",
"requestAnalyticsDescription": "Detaillierte Anfrage-Analyse für Ressourcen in dieser Organisation anzeigen", "requestAnalyticsDescription": "Detaillierte Anfrage-Analyse für Ressourcen in dieser Organisation anzeigen",
"logRetentionRequestLabel": "HTTP Anforderungsprotokoll Aufbewahrung", "logRetentionRequestLabel": "Log-Speicherung anfordern",
"logRetentionRequestDescription": "Wie lange sollen Request-Logs gespeichert werden", "logRetentionRequestDescription": "Wie lange sollen Request-Logs gespeichert werden",
"logRetentionAccessLabel": "Zugriffsprotokoll-Speicherung", "logRetentionAccessLabel": "Zugriffsprotokoll-Speicherung",
"logRetentionAccessDescription": "Wie lange Zugriffsprotokolle beibehalten werden sollen", "logRetentionAccessDescription": "Wie lange Zugriffsprotokolle beibehalten werden sollen",
@@ -3134,7 +3134,7 @@
"httpDestActionLogsDescription": "Administrative Maßnahmen, die von Benutzern innerhalb der Organisation durchgeführt werden.", "httpDestActionLogsDescription": "Administrative Maßnahmen, die von Benutzern innerhalb der Organisation durchgeführt werden.",
"httpDestConnectionLogsTitle": "Verbindungsprotokolle", "httpDestConnectionLogsTitle": "Verbindungsprotokolle",
"httpDestConnectionLogsDescription": "Site- und Tunnelverbindungen, einschließlich Verbindungen und Trennungen.", "httpDestConnectionLogsDescription": "Site- und Tunnelverbindungen, einschließlich Verbindungen und Trennungen.",
"httpDestRequestLogsTitle": "HTTP Anforderungsprotokolle", "httpDestRequestLogsTitle": "Logs anfordern",
"httpDestRequestLogsDescription": "HTTP-Request-Protokolle für proxiierte Ressourcen, einschließlich Methode, Pfad und Antwort-Code.", "httpDestRequestLogsDescription": "HTTP-Request-Protokolle für proxiierte Ressourcen, einschließlich Methode, Pfad und Antwort-Code.",
"httpDestSaveChanges": "Änderungen speichern", "httpDestSaveChanges": "Änderungen speichern",
"httpDestCreateDestination": "Ziel erstellen", "httpDestCreateDestination": "Ziel erstellen",
@@ -3208,48 +3208,5 @@
"domainPickerWildcardCertWarning": "Wildcard-Ressourcen erfordern möglicherweise zusätzliche Konfigurationen, um ordnungsgemäß zu funktionieren.", "domainPickerWildcardCertWarning": "Wildcard-Ressourcen erfordern möglicherweise zusätzliche Konfigurationen, um ordnungsgemäß zu funktionieren.",
"domainPickerWildcardCertWarningLink": "Mehr erfahren", "domainPickerWildcardCertWarningLink": "Mehr erfahren",
"health": "Gesundheit", "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"
} }

View File

@@ -2660,19 +2660,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "IP", "ip": "IP",
"reason": "Reason", "reason": "Reason",
"requestLogs": "HTTP Request Logs", "requestLogs": "HTTPS Request Logs",
"requestAnalytics": "Request Analytics", "requestAnalytics": "Request Analytics",
"host": "Host", "host": "Host",
"location": "Location", "location": "Location",
"actionLogs": "Admin Action Logs", "actionLogs": "Admin Action Logs",
"sidebarLogsRequest": "HTTP Request Logs", "sidebarLogsRequest": "HTTPS Request Logs",
"sidebarLogsAccess": "Authentication Logs", "sidebarLogsAccess": "Authentication Logs",
"sidebarLogsAction": "Admin Action Logs", "sidebarLogsAction": "Admin Action Logs",
"logRetention": "Log Retention", "logRetention": "Log Retention",
"logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", "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", "requestLogsDescription": "View detailed request logs for HTTPS resources in this organization",
"requestAnalyticsDescription": "View detailed request analytics for resources in this organization", "requestAnalyticsDescription": "View detailed request analytics for resources in this organization",
"logRetentionRequestLabel": "HTTP Request Log Retention", "logRetentionRequestLabel": "HTTPS Request Log Retention",
"logRetentionRequestDescription": "How long to retain request logs", "logRetentionRequestDescription": "How long to retain request logs",
"logRetentionAccessLabel": "Authentication Log Retention", "logRetentionAccessLabel": "Authentication Log Retention",
"logRetentionAccessDescription": "How long to retain access logs", "logRetentionAccessDescription": "How long to retain access logs",
@@ -3134,7 +3134,7 @@
"httpDestActionLogsDescription": "Administrative actions performed by users within the organization.", "httpDestActionLogsDescription": "Administrative actions performed by users within the organization.",
"httpDestConnectionLogsTitle": "Network Logs", "httpDestConnectionLogsTitle": "Network Logs",
"httpDestConnectionLogsDescription": "Site and tunnel connection events, including connects and disconnects.", "httpDestConnectionLogsDescription": "Site and tunnel connection events, including connects and disconnects.",
"httpDestRequestLogsTitle": "HTTP Request Logs", "httpDestRequestLogsTitle": "HTTPS Request Logs",
"httpDestRequestLogsDescription": "HTTP request logs for proxied resources, including method, path, and response code.", "httpDestRequestLogsDescription": "HTTP request logs for proxied resources, including method, path, and response code.",
"httpDestSaveChanges": "Save Changes", "httpDestSaveChanges": "Save Changes",
"httpDestCreateDestination": "Create Destination", "httpDestCreateDestination": "Create Destination",
@@ -3208,48 +3208,5 @@
"domainPickerWildcardCertWarning": "Wildcard resources may require additional configuration to work properly.", "domainPickerWildcardCertWarning": "Wildcard resources may require additional configuration to work properly.",
"domainPickerWildcardCertWarningLink": "Learn more", "domainPickerWildcardCertWarningLink": "Learn more",
"health": "Health", "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"
} }

View File

@@ -2660,19 +2660,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "IP", "ip": "IP",
"reason": "Razón", "reason": "Razón",
"requestLogs": "Registros de Solicitud HTTP", "requestLogs": "Registros de Solicitud",
"requestAnalytics": "Analítica de Solicitud", "requestAnalytics": "Analítica de Solicitud",
"host": "Anfitrión", "host": "Anfitrión",
"location": "Ubicación", "location": "Ubicación",
"actionLogs": "Registros de acción", "actionLogs": "Registros de acción",
"sidebarLogsRequest": "Registros de Solicitud HTTP", "sidebarLogsRequest": "Registros de Solicitud",
"sidebarLogsAccess": "Registros de acceso", "sidebarLogsAccess": "Registros de acceso",
"sidebarLogsAction": "Registros de acción", "sidebarLogsAction": "Registros de acción",
"logRetention": "Retención de Log", "logRetention": "Retención de Log",
"logRetentionDescription": "Administrar cuánto tiempo se conservan los diferentes tipos de registros para esta organización o desactivarlos", "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", "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", "requestAnalyticsDescription": "Ver análisis de solicitudes detalladas de recursos en esta organización",
"logRetentionRequestLabel": "Retención de Registro de Solicitud HTTP", "logRetentionRequestLabel": "Retención de Registro de Solicitud",
"logRetentionRequestDescription": "Cuánto tiempo conservar los registros de solicitudes", "logRetentionRequestDescription": "Cuánto tiempo conservar los registros de solicitudes",
"logRetentionAccessLabel": "Retención de Log de Acceso", "logRetentionAccessLabel": "Retención de Log de Acceso",
"logRetentionAccessDescription": "Cuánto tiempo retener los registros 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.", "httpDestActionLogsDescription": "Acciones administrativas realizadas por los usuarios dentro de la organización.",
"httpDestConnectionLogsTitle": "Registros de conexión", "httpDestConnectionLogsTitle": "Registros de conexión",
"httpDestConnectionLogsDescription": "Eventos de conexión de sitios y túneles, incluyendo conexiones y desconexiones.", "httpDestConnectionLogsDescription": "Eventos de conexión de sitios y túneles, incluyendo conexiones y desconexiones.",
"httpDestRequestLogsTitle": "Registros de Solicitud HTTP", "httpDestRequestLogsTitle": "Registros de Solicitud",
"httpDestRequestLogsDescription": "Registros de peticiones HTTP para recursos proxyficados, incluyendo método, ruta y código de respuesta.", "httpDestRequestLogsDescription": "Registros de peticiones HTTP para recursos proxyficados, incluyendo método, ruta y código de respuesta.",
"httpDestSaveChanges": "Guardar Cambios", "httpDestSaveChanges": "Guardar Cambios",
"httpDestCreateDestination": "Crear destino", "httpDestCreateDestination": "Crear destino",
@@ -3208,48 +3208,5 @@
"domainPickerWildcardCertWarning": "Los recursos comodín pueden requerir configuración adicional para funcionar correctamente.", "domainPickerWildcardCertWarning": "Los recursos comodín pueden requerir configuración adicional para funcionar correctamente.",
"domainPickerWildcardCertWarningLink": "Más información", "domainPickerWildcardCertWarningLink": "Más información",
"health": "Salud", "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"
} }

View File

@@ -2660,19 +2660,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "IP", "ip": "IP",
"reason": "Raison", "reason": "Raison",
"requestLogs": "Journal des Requêtes HTTP", "requestLogs": "Journal des requêtes",
"requestAnalytics": "Demander des analyses", "requestAnalytics": "Demander des analyses",
"host": "Hôte", "host": "Hôte",
"location": "Localisation", "location": "Localisation",
"actionLogs": "Journaux des actions", "actionLogs": "Journaux des actions",
"sidebarLogsRequest": "Journal des Requêtes HTTP", "sidebarLogsRequest": "Journal des requêtes",
"sidebarLogsAccess": "Journaux d'accès", "sidebarLogsAccess": "Journaux d'accès",
"sidebarLogsAction": "Journaux des actions", "sidebarLogsAction": "Journaux des actions",
"logRetention": "Journaliser la rétention", "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", "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", "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", "requestAnalyticsDescription": "Voir les analyses détaillées des demandes pour les ressources de cette organisation",
"logRetentionRequestLabel": "Rétention des Journaux de Requêtes HTTP", "logRetentionRequestLabel": "Demander la rétention des journaux",
"logRetentionRequestDescription": "Durée de conservation des journaux de requêtes", "logRetentionRequestDescription": "Durée de conservation des journaux de requêtes",
"logRetentionAccessLabel": "Rétention du journal d'accès", "logRetentionAccessLabel": "Rétention du journal d'accès",
"logRetentionAccessDescription": "Durée de conservation des journaux 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.", "httpDestActionLogsDescription": "Actions administratives effectuées par les utilisateurs au sein de l'organisation.",
"httpDestConnectionLogsTitle": "Journaux de connexion", "httpDestConnectionLogsTitle": "Journaux de connexion",
"httpDestConnectionLogsDescription": "Événements de connexion du site et du tunnel, y compris les connexions et les déconnexions.", "httpDestConnectionLogsDescription": "Événements de connexion du site et du tunnel, y compris les connexions et les déconnexions.",
"httpDestRequestLogsTitle": "Journal des Requêtes HTTP", "httpDestRequestLogsTitle": "Journal des requêtes",
"httpDestRequestLogsDescription": "Journaux des requêtes HTTP pour les ressources proxiées, y compris la méthode, le chemin et le code de réponse.", "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", "httpDestSaveChanges": "Enregistrer les modifications",
"httpDestCreateDestination": "Créer une destination", "httpDestCreateDestination": "Créer une destination",
@@ -3208,48 +3208,5 @@
"domainPickerWildcardCertWarning": "Les ressources Joker peuvent nécessiter une configuration supplémentaire pour fonctionner correctement.", "domainPickerWildcardCertWarning": "Les ressources Joker peuvent nécessiter une configuration supplémentaire pour fonctionner correctement.",
"domainPickerWildcardCertWarningLink": "En savoir plus", "domainPickerWildcardCertWarningLink": "En savoir plus",
"health": "Santé", "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"
} }

View File

@@ -2660,19 +2660,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "IP", "ip": "IP",
"reason": "Motivo", "reason": "Motivo",
"requestLogs": "Log Richieste HTTP", "requestLogs": "Log Richiesta",
"requestAnalytics": "Richiedi Analisi", "requestAnalytics": "Richiedi Analisi",
"host": "Host", "host": "Host",
"location": "Posizione", "location": "Posizione",
"actionLogs": "Log Azioni", "actionLogs": "Log Azioni",
"sidebarLogsRequest": "Log Richieste HTTP", "sidebarLogsRequest": "Log Richiesta",
"sidebarLogsAccess": "Log Accesso", "sidebarLogsAccess": "Log Accesso",
"sidebarLogsAction": "Log Azioni", "sidebarLogsAction": "Log Azioni",
"logRetention": "Ritenzione Registro", "logRetention": "Ritenzione Registro",
"logRetentionDescription": "Gestisci per quanto tempo i diversi tipi di log sono mantenuti per questa organizzazione o disabilitali", "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", "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", "requestAnalyticsDescription": "Visualizza le analisi dettagliate della richiesta per le risorse in questa organizzazione",
"logRetentionRequestLabel": "Conservazione Log Richieste HTTP", "logRetentionRequestLabel": "Richiedi Ritenzione Log",
"logRetentionRequestDescription": "Per quanto tempo conservare i log delle richieste", "logRetentionRequestDescription": "Per quanto tempo conservare i log delle richieste",
"logRetentionAccessLabel": "Ritenzione Registro Accesso", "logRetentionAccessLabel": "Ritenzione Registro Accesso",
"logRetentionAccessDescription": "Per quanto tempo conservare i log di accesso", "logRetentionAccessDescription": "Per quanto tempo conservare i log di accesso",
@@ -3134,7 +3134,7 @@
"httpDestActionLogsDescription": "Azioni amministrative eseguite dagli utenti all'interno dell'organizzazione.", "httpDestActionLogsDescription": "Azioni amministrative eseguite dagli utenti all'interno dell'organizzazione.",
"httpDestConnectionLogsTitle": "Log Di Connessione", "httpDestConnectionLogsTitle": "Log Di Connessione",
"httpDestConnectionLogsDescription": "Eventi di connessione al sito e al tunnel, inclusi collegamenti e disconnessioni.", "httpDestConnectionLogsDescription": "Eventi di connessione al sito e al tunnel, inclusi collegamenti e disconnessioni.",
"httpDestRequestLogsTitle": "Log Richieste HTTP", "httpDestRequestLogsTitle": "Log Richiesta",
"httpDestRequestLogsDescription": "Registri di richiesta HTTP per le risorse proxy, inclusi metodo, percorso e codice di risposta.", "httpDestRequestLogsDescription": "Registri di richiesta HTTP per le risorse proxy, inclusi metodo, percorso e codice di risposta.",
"httpDestSaveChanges": "Salva Modifiche", "httpDestSaveChanges": "Salva Modifiche",
"httpDestCreateDestination": "Crea Destinazione", "httpDestCreateDestination": "Crea Destinazione",
@@ -3208,48 +3208,5 @@
"domainPickerWildcardCertWarning": "Le risorse wildcard potrebbero richiedere configurazioni aggiuntive per funzionare correttamente.", "domainPickerWildcardCertWarning": "Le risorse wildcard potrebbero richiedere configurazioni aggiuntive per funzionare correttamente.",
"domainPickerWildcardCertWarningLink": "Scopri di più", "domainPickerWildcardCertWarningLink": "Scopri di più",
"health": "Salute", "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"
} }

View File

@@ -2660,19 +2660,19 @@
"noMoreAuthMethods": "유효한 인증 없음", "noMoreAuthMethods": "유효한 인증 없음",
"ip": "IP", "ip": "IP",
"reason": "이유", "reason": "이유",
"requestLogs": "HTTP 요청 로그", "requestLogs": "요청 로그",
"requestAnalytics": "요청 분석", "requestAnalytics": "요청 분석",
"host": "호스트", "host": "호스트",
"location": "위치", "location": "위치",
"actionLogs": "작업 로그", "actionLogs": "작업 로그",
"sidebarLogsRequest": "HTTP 요청 로그", "sidebarLogsRequest": "요청 로그",
"sidebarLogsAccess": "접근 로그", "sidebarLogsAccess": "접근 로그",
"sidebarLogsAction": "작업 로그", "sidebarLogsAction": "작업 로그",
"logRetention": "로그 보관", "logRetention": "로그 보관",
"logRetentionDescription": "다양한 유형의 로그를 이 조직에 대해 얼마나 오래 보관할지 관리하거나 비활성화합니다", "logRetentionDescription": "다양한 유형의 로그를 이 조직에 대해 얼마나 오래 보관할지 관리하거나 비활성화합니다",
"requestLogsDescription": "이 조직의 자원에 대한 상세한 요청 로그를 봅니다", "requestLogsDescription": "이 조직의 자원에 대한 상세한 요청 로그를 봅니다",
"requestAnalyticsDescription": "이 조직의 리소스에 대한 자세한 요청 분석 보기", "requestAnalyticsDescription": "이 조직의 리소스에 대한 자세한 요청 분석 보기",
"logRetentionRequestLabel": "HTTP 요청 로그 보관", "logRetentionRequestLabel": "요청 로그 보관",
"logRetentionRequestDescription": "요청 로그를 얼마나 오래 보관할지", "logRetentionRequestDescription": "요청 로그를 얼마나 오래 보관할지",
"logRetentionAccessLabel": "접근 로그 보관", "logRetentionAccessLabel": "접근 로그 보관",
"logRetentionAccessDescription": "접근 로그를 얼마나 오래 보관할지", "logRetentionAccessDescription": "접근 로그를 얼마나 오래 보관할지",
@@ -3134,7 +3134,7 @@
"httpDestActionLogsDescription": "조직 내에서 사용자가 수행한 관리 작업.", "httpDestActionLogsDescription": "조직 내에서 사용자가 수행한 관리 작업.",
"httpDestConnectionLogsTitle": "연결 로그", "httpDestConnectionLogsTitle": "연결 로그",
"httpDestConnectionLogsDescription": "사이트 및 터널 연결 이벤트, 연결 및 연결 끊기를 포함합니다.", "httpDestConnectionLogsDescription": "사이트 및 터널 연결 이벤트, 연결 및 연결 끊기를 포함합니다.",
"httpDestRequestLogsTitle": "HTTP 요청 로그", "httpDestRequestLogsTitle": "요청 로그",
"httpDestRequestLogsDescription": "프록시된 리소스에 대한 HTTP 요청 로그, 메서드, 경로 및 응답 코드를 포함합니다.", "httpDestRequestLogsDescription": "프록시된 리소스에 대한 HTTP 요청 로그, 메서드, 경로 및 응답 코드를 포함합니다.",
"httpDestSaveChanges": "변경 사항 저장", "httpDestSaveChanges": "변경 사항 저장",
"httpDestCreateDestination": "대상지 생성", "httpDestCreateDestination": "대상지 생성",
@@ -3208,48 +3208,5 @@
"domainPickerWildcardCertWarning": "와일드카드 리소스는 올바르게 작동하려면 추가 구성이 필요할 수 있습니다.", "domainPickerWildcardCertWarning": "와일드카드 리소스는 올바르게 작동하려면 추가 구성이 필요할 수 있습니다.",
"domainPickerWildcardCertWarningLink": "자세히 알아보기", "domainPickerWildcardCertWarningLink": "자세히 알아보기",
"health": "건강", "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": "다음"
} }

View File

@@ -2660,19 +2660,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "IP", "ip": "IP",
"reason": "Grunn", "reason": "Grunn",
"requestLogs": "HTTP-forespørselslogger", "requestLogs": "Forespørselslogger (Automatic Translation)",
"requestAnalytics": "Be om analyser", "requestAnalytics": "Be om analyser",
"host": "Vert", "host": "Vert",
"location": "Sted", "location": "Sted",
"actionLogs": "Handlingslogger", "actionLogs": "Handlingslogger",
"sidebarLogsRequest": "HTTP-forespørselslogger", "sidebarLogsRequest": "Forespørselslogger (Automatic Translation)",
"sidebarLogsAccess": "Tilgangslogger (Automatic Translation)", "sidebarLogsAccess": "Tilgangslogger (Automatic Translation)",
"sidebarLogsAction": "Handlingslogger", "sidebarLogsAction": "Handlingslogger",
"logRetention": "Logg tilbaketrekning", "logRetention": "Logg tilbaketrekning",
"logRetentionDescription": "Håndter hvor lenge ulike typer logger beholdes for denne organisasjonen, eller deaktiver dem", "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", "requestLogsDescription": "Se detaljerte forespørselslogger for ressurser i denne organisasjonen",
"requestAnalyticsDescription": "Se detaljert rekvisisjonsanalyse for ressurser i denne organisasjonen", "requestAnalyticsDescription": "Se detaljert rekvisisjonsanalyse for ressurser i denne organisasjonen",
"logRetentionRequestLabel": "Be om loggbevaring", "logRetentionRequestLabel": "Be om loggoverføring",
"logRetentionRequestDescription": "Hvor lenge du vil beholde forespørselslogger", "logRetentionRequestDescription": "Hvor lenge du vil beholde forespørselslogger",
"logRetentionAccessLabel": "Få tilgang til loggoverføring", "logRetentionAccessLabel": "Få tilgang til loggoverføring",
"logRetentionAccessDescription": "Hvor lenge du vil beholde adgangslogger", "logRetentionAccessDescription": "Hvor lenge du vil beholde adgangslogger",
@@ -3134,7 +3134,7 @@
"httpDestActionLogsDescription": "Administrative tiltak som utføres av brukere innenfor organisasjonen.", "httpDestActionLogsDescription": "Administrative tiltak som utføres av brukere innenfor organisasjonen.",
"httpDestConnectionLogsTitle": "Loggfiler for tilkobling", "httpDestConnectionLogsTitle": "Loggfiler for tilkobling",
"httpDestConnectionLogsDescription": "Utstyrs- og tunneltilkoblingshendelser, inkludert forbindelser og frakobling.", "httpDestConnectionLogsDescription": "Utstyrs- og tunneltilkoblingshendelser, inkludert forbindelser og frakobling.",
"httpDestRequestLogsTitle": "HTTP-forespørselslogger", "httpDestRequestLogsTitle": "Forespørselslogger (Automatic Translation)",
"httpDestRequestLogsDescription": "HTTP-forespørsel logger for bekreftede ressurser, inkludert metode, bane og responskode.", "httpDestRequestLogsDescription": "HTTP-forespørsel logger for bekreftede ressurser, inkludert metode, bane og responskode.",
"httpDestSaveChanges": "Lagre endringer", "httpDestSaveChanges": "Lagre endringer",
"httpDestCreateDestination": "Opprett mål", "httpDestCreateDestination": "Opprett mål",
@@ -3208,48 +3208,5 @@
"domainPickerWildcardCertWarning": "Jokertegnressurser kan kreve ekstra konfigurasjon for å fungere skikkelig.", "domainPickerWildcardCertWarning": "Jokertegnressurser kan kreve ekstra konfigurasjon for å fungere skikkelig.",
"domainPickerWildcardCertWarningLink": "Lær mer", "domainPickerWildcardCertWarningLink": "Lær mer",
"health": "Helse", "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"
} }

View File

@@ -2660,19 +2660,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "IP-adres", "ip": "IP-adres",
"reason": "Reden", "reason": "Reden",
"requestLogs": "HTTP-aanvraaglogboeken", "requestLogs": "Logboeken aanvragen",
"requestAnalytics": "Analytics opvragen", "requestAnalytics": "Analytics opvragen",
"host": "Hostnaam", "host": "Hostnaam",
"location": "Locatie", "location": "Locatie",
"actionLogs": "Actie logs", "actionLogs": "Actie logs",
"sidebarLogsRequest": "HTTP-aanvraaglogboeken", "sidebarLogsRequest": "Logboeken aanvragen",
"sidebarLogsAccess": "Toegang tot logboek", "sidebarLogsAccess": "Toegang tot logboek",
"sidebarLogsAction": "Actie logs", "sidebarLogsAction": "Actie logs",
"logRetention": "Log bewaring", "logRetention": "Log bewaring",
"logRetentionDescription": "Beheren hoe lang verschillende soorten logs bewaard worden voor deze organisatie of schakel ze uit", "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", "requestLogsDescription": "Bekijk gedetailleerde verzoeklogboeken voor resources in deze organisatie",
"requestAnalyticsDescription": "Bekijk gedetailleerde request analytics voor resources in deze organisatie", "requestAnalyticsDescription": "Bekijk gedetailleerde request analytics voor resources in deze organisatie",
"logRetentionRequestLabel": "Bewaring van HTTP-aanvraaglogboeken", "logRetentionRequestLabel": "Logboekbewaring aanvragen",
"logRetentionRequestDescription": "Hoe lang de aanvraaglogboeken te behouden", "logRetentionRequestDescription": "Hoe lang de aanvraaglogboeken te behouden",
"logRetentionAccessLabel": "Toegang logboek bewaring", "logRetentionAccessLabel": "Toegang logboek bewaring",
"logRetentionAccessDescription": "Hoe lang de toegangslogboeken behouden blijven", "logRetentionAccessDescription": "Hoe lang de toegangslogboeken behouden blijven",
@@ -3134,7 +3134,7 @@
"httpDestActionLogsDescription": "Administratieve acties uitgevoerd door gebruikers binnen de organisatie.", "httpDestActionLogsDescription": "Administratieve acties uitgevoerd door gebruikers binnen de organisatie.",
"httpDestConnectionLogsTitle": "Connectie Logs", "httpDestConnectionLogsTitle": "Connectie Logs",
"httpDestConnectionLogsDescription": "Verbinding met de Site en tunnel maken verbroken, inclusief verbindingen en verbindingen.", "httpDestConnectionLogsDescription": "Verbinding met de Site en tunnel maken verbroken, inclusief verbindingen en verbindingen.",
"httpDestRequestLogsTitle": "HTTP-aanvraaglogboeken", "httpDestRequestLogsTitle": "Logboeken aanvragen",
"httpDestRequestLogsDescription": "HTTP request logs voor proxied hulpmiddelen, waaronder methode, pad en response code.", "httpDestRequestLogsDescription": "HTTP request logs voor proxied hulpmiddelen, waaronder methode, pad en response code.",
"httpDestSaveChanges": "Wijzigingen opslaan", "httpDestSaveChanges": "Wijzigingen opslaan",
"httpDestCreateDestination": "Maak bestemming aan", "httpDestCreateDestination": "Maak bestemming aan",
@@ -3208,48 +3208,5 @@
"domainPickerWildcardCertWarning": "Wildcard-bronnen hebben mogelijk extra configuratie nodig om correct te werken.", "domainPickerWildcardCertWarning": "Wildcard-bronnen hebben mogelijk extra configuratie nodig om correct te werken.",
"domainPickerWildcardCertWarningLink": "Meer informatie", "domainPickerWildcardCertWarningLink": "Meer informatie",
"health": "Gezondheid", "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"
} }

View File

@@ -2660,19 +2660,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "IP", "ip": "IP",
"reason": "Powód", "reason": "Powód",
"requestLogs": "Dzienniki żądań HTTP", "requestLogs": "Dzienniki żądań",
"requestAnalytics": "Żądanie Analityki", "requestAnalytics": "Żądanie Analityki",
"host": "Host", "host": "Host",
"location": "Lokalizacja", "location": "Lokalizacja",
"actionLogs": "Dzienniki działań", "actionLogs": "Dzienniki działań",
"sidebarLogsRequest": "Dzienniki żądań HTTP", "sidebarLogsRequest": "Dzienniki żądań",
"sidebarLogsAccess": "Logi dostępu", "sidebarLogsAccess": "Logi dostępu",
"sidebarLogsAction": "Dzienniki działań", "sidebarLogsAction": "Dzienniki działań",
"logRetention": "Zachowanie dziennika", "logRetention": "Zachowanie dziennika",
"logRetentionDescription": "Zarządzaj jak długo różne typy logów są zachowane dla tej organizacji lub wyłącz je", "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", "requestLogsDescription": "Zobacz szczegółowe dzienniki żądań zasobów w tej organizacji",
"requestAnalyticsDescription": "Zobacz szczegółowe analizy żądań dla zasobów w tej organizacji", "requestAnalyticsDescription": "Zobacz szczegółowe analizy żądań dla zasobów w tej organizacji",
"logRetentionRequestLabel": "Przechowywanie dzienników żądań HTTP", "logRetentionRequestLabel": "Zachowanie dziennika żądań",
"logRetentionRequestDescription": "Jak długo zachować dzienniki żądań", "logRetentionRequestDescription": "Jak długo zachować dzienniki żądań",
"logRetentionAccessLabel": "Zachowanie dziennika dostępu", "logRetentionAccessLabel": "Zachowanie dziennika dostępu",
"logRetentionAccessDescription": "Jak długo zachować dzienniki 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.", "httpDestActionLogsDescription": "Działania administracyjne wykonywane przez użytkowników w organizacji.",
"httpDestConnectionLogsTitle": "Dzienniki połączeń", "httpDestConnectionLogsTitle": "Dzienniki połączeń",
"httpDestConnectionLogsDescription": "Zdarzenia związane z miejscem i tunelem, w tym połączenia i rozłączenia.", "httpDestConnectionLogsDescription": "Zdarzenia związane z miejscem i tunelem, w tym połączenia i rozłączenia.",
"httpDestRequestLogsTitle": "Dzienniki żądań HTTP", "httpDestRequestLogsTitle": "Dzienniki żądań",
"httpDestRequestLogsDescription": "Logi żądań HTTP dla zasobów proxy, w tym metody, ścieżki i kodu odpowiedzi.", "httpDestRequestLogsDescription": "Logi żądań HTTP dla zasobów proxy, w tym metody, ścieżki i kodu odpowiedzi.",
"httpDestSaveChanges": "Zapisz zmiany", "httpDestSaveChanges": "Zapisz zmiany",
"httpDestCreateDestination": "Utwórz cel", "httpDestCreateDestination": "Utwórz cel",
@@ -3208,48 +3208,5 @@
"domainPickerWildcardCertWarning": "Uniwersalne zasoby mogą wymagać dodatkowej konfiguracji, aby działać poprawnie.", "domainPickerWildcardCertWarning": "Uniwersalne zasoby mogą wymagać dodatkowej konfiguracji, aby działać poprawnie.",
"domainPickerWildcardCertWarningLink": "Dowiedz się więcej", "domainPickerWildcardCertWarningLink": "Dowiedz się więcej",
"health": "Zdrowie", "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"
} }

View File

@@ -2660,19 +2660,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "PI", "ip": "PI",
"reason": "Motivo", "reason": "Motivo",
"requestLogs": "Registros de Pedidos HTTP", "requestLogs": "Registro de pedidos",
"requestAnalytics": "Solicitar análise", "requestAnalytics": "Solicitar análise",
"host": "Servidor", "host": "Servidor",
"location": "Local:", "location": "Local:",
"actionLogs": "Logs de Ações", "actionLogs": "Logs de Ações",
"sidebarLogsRequest": "Registros de Pedidos HTTP", "sidebarLogsRequest": "Registro de pedidos",
"sidebarLogsAccess": "Logs de Acesso", "sidebarLogsAccess": "Logs de Acesso",
"sidebarLogsAction": "Logs de Ações", "sidebarLogsAction": "Logs de Ações",
"logRetention": "Retenção de Log", "logRetention": "Retenção de Log",
"logRetentionDescription": "Gerenciar quanto tempo os diferentes tipos de logs são mantidos para esta organização ou desativá-los", "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", "requestLogsDescription": "Ver registros de pedidos detalhados de recursos nesta organização",
"requestAnalyticsDescription": "Exibir análise detalhada de pedidos para recursos nesta organização", "requestAnalyticsDescription": "Exibir análise detalhada de pedidos para recursos nesta organização",
"logRetentionRequestLabel": "Retenção de Registro de Pedido HTTP", "logRetentionRequestLabel": "Solicitar retenção de registro",
"logRetentionRequestDescription": "Por quanto tempo manter os registros de pedidos", "logRetentionRequestDescription": "Por quanto tempo manter os registros de pedidos",
"logRetentionAccessLabel": "Retenção de Log de Acesso", "logRetentionAccessLabel": "Retenção de Log de Acesso",
"logRetentionAccessDescription": "Por quanto tempo manter os registros 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.", "httpDestActionLogsDescription": "Ações administrativas realizadas por usuários dentro da organização.",
"httpDestConnectionLogsTitle": "Logs da conexão", "httpDestConnectionLogsTitle": "Logs da conexão",
"httpDestConnectionLogsDescription": "Eventos de conexão de site e túnel, incluindo conexões e desconexões.", "httpDestConnectionLogsDescription": "Eventos de conexão de site e túnel, incluindo conexões e desconexões.",
"httpDestRequestLogsTitle": "Registros de Pedidos HTTP", "httpDestRequestLogsTitle": "Registro de pedidos",
"httpDestRequestLogsDescription": "Logs de solicitação HTTP para recursos proxy incluindo o método, o caminho e o código de resposta.", "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", "httpDestSaveChanges": "Salvar as alterações",
"httpDestCreateDestination": "Criar destino", "httpDestCreateDestination": "Criar destino",
@@ -3208,48 +3208,5 @@
"domainPickerWildcardCertWarning": "Recursos curinga podem exigir configurações adicionais para funcionarem corretamente.", "domainPickerWildcardCertWarning": "Recursos curinga podem exigir configurações adicionais para funcionarem corretamente.",
"domainPickerWildcardCertWarningLink": "Saiba mais", "domainPickerWildcardCertWarningLink": "Saiba mais",
"health": "Saúde", "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"
} }

View File

@@ -2660,19 +2660,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "IP", "ip": "IP",
"reason": "Причина", "reason": "Причина",
"requestLogs": "HTTP Запросы Логи", "requestLogs": "Запросить журналы",
"requestAnalytics": "Аналитика запроса", "requestAnalytics": "Аналитика запроса",
"host": "Хост", "host": "Хост",
"location": "Местоположение", "location": "Местоположение",
"actionLogs": "Журнал действий", "actionLogs": "Журнал действий",
"sidebarLogsRequest": "HTTP Запросы Логи", "sidebarLogsRequest": "Запросить журналы",
"sidebarLogsAccess": "Журналы доступа", "sidebarLogsAccess": "Журналы доступа",
"sidebarLogsAction": "Журнал действий", "sidebarLogsAction": "Журнал действий",
"logRetention": "Сохранение журнала", "logRetention": "Сохранение журнала",
"logRetentionDescription": "Управление сохранением различных типов журналов для этой организации или отключение их", "logRetentionDescription": "Управление сохранением различных типов журналов для этой организации или отключение их",
"requestLogsDescription": "Просмотреть подробные журналы запроса ресурсов в этой организации", "requestLogsDescription": "Просмотреть подробные журналы запроса ресурсов в этой организации",
"requestAnalyticsDescription": "Просмотреть подробную аналитику запроса для ресурсов в этой организации", "requestAnalyticsDescription": "Просмотреть подробную аналитику запроса для ресурсов в этой организации",
"logRetentionRequestLabel": "Сохранение HTTP Запросов Лога", "logRetentionRequestLabel": "Запросить сохранение журнала",
"logRetentionRequestDescription": "Как долго сохранять журналы запросов", "logRetentionRequestDescription": "Как долго сохранять журналы запросов",
"logRetentionAccessLabel": "Хранение журнала доступа", "logRetentionAccessLabel": "Хранение журнала доступа",
"logRetentionAccessDescription": "Как долго сохранять журналы доступа", "logRetentionAccessDescription": "Как долго сохранять журналы доступа",
@@ -3134,7 +3134,7 @@
"httpDestActionLogsDescription": "Административные меры, осуществляемые пользователями в рамках организации.", "httpDestActionLogsDescription": "Административные меры, осуществляемые пользователями в рамках организации.",
"httpDestConnectionLogsTitle": "Журнал подключений", "httpDestConnectionLogsTitle": "Журнал подключений",
"httpDestConnectionLogsDescription": "События связи с сайтами и туннелями, включая соединения и отключения.", "httpDestConnectionLogsDescription": "События связи с сайтами и туннелями, включая соединения и отключения.",
"httpDestRequestLogsTitle": "HTTP Запросы Логи", "httpDestRequestLogsTitle": "Запросить журналы",
"httpDestRequestLogsDescription": "Журналы запросов HTTP для проксируемых ресурсов, включая метод, путь и код ответа.", "httpDestRequestLogsDescription": "Журналы запросов HTTP для проксируемых ресурсов, включая метод, путь и код ответа.",
"httpDestSaveChanges": "Сохранить изменения", "httpDestSaveChanges": "Сохранить изменения",
"httpDestCreateDestination": "Создать адрес назначения", "httpDestCreateDestination": "Создать адрес назначения",
@@ -3208,48 +3208,5 @@
"domainPickerWildcardCertWarning": "Wildcard ресурсы могут потребовать дополнительной настройки для правильной работы.", "domainPickerWildcardCertWarning": "Wildcard ресурсы могут потребовать дополнительной настройки для правильной работы.",
"domainPickerWildcardCertWarningLink": "Узнать больше", "domainPickerWildcardCertWarningLink": "Узнать больше",
"health": "Состояние", "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": "Следующий"
} }

View File

@@ -2660,19 +2660,19 @@
"noMoreAuthMethods": "Daha Fazla Kimlik Doğrulama Yöntemi Yok", "noMoreAuthMethods": "Daha Fazla Kimlik Doğrulama Yöntemi Yok",
"ip": "IP", "ip": "IP",
"reason": "Sebep", "reason": "Sebep",
"requestLogs": "HTTP İstek Günlükleri", "requestLogs": "İstek Günlükleri",
"requestAnalytics": "İstek Analizi", "requestAnalytics": "İstek Analizi",
"host": "Sunucu", "host": "Sunucu",
"location": "Konum", "location": "Konum",
"actionLogs": "Eylem Günlükleri", "actionLogs": "Eylem Günlükleri",
"sidebarLogsRequest": "HTTP İstek Günlükleri", "sidebarLogsRequest": "İstek Günlükleri",
"sidebarLogsAccess": "Erişim Günlükleri", "sidebarLogsAccess": "Erişim Günlükleri",
"sidebarLogsAction": "Eylem Günlükleri", "sidebarLogsAction": "Eylem Günlükleri",
"logRetention": "Kayıt Saklama", "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", "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", "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.", "requestAnalyticsDescription": "Bu organizasyondaki kaynaklar için ayrıntılı istek analizlerini görüntüleyin.",
"logRetentionRequestLabel": "HTTP İstek Günlüğü Saklama", "logRetentionRequestLabel": "İstek Günlüğü Saklama",
"logRetentionRequestDescription": "İstek günlüklerini ne kadar süre tutacağını belirle", "logRetentionRequestDescription": "İstek günlüklerini ne kadar süre tutacağını belirle",
"logRetentionAccessLabel": "Erişim Günlüğü Saklama", "logRetentionAccessLabel": "Erişim Günlüğü Saklama",
"logRetentionAccessDescription": "Erişim günlüklerini ne kadar süre tutacağını belirle", "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.", "httpDestActionLogsDescription": "Kullanıcılar tarafından organizasyon içerisinde yapılan yönetici eylemleri.",
"httpDestConnectionLogsTitle": "Bağlantı Kayıtları", "httpDestConnectionLogsTitle": "Bağlantı Kayıtları",
"httpDestConnectionLogsDescription": "Site ve tünel bağlantı olayları, bağlantılar ve bağlantı kesilmeleri dahil.", "httpDestConnectionLogsDescription": "Site ve tünel bağlantı olayları, bağlantılar ve bağlantı kesilmeleri dahil.",
"httpDestRequestLogsTitle": "HTTP İstek Günlükleri", "httpDestRequestLogsTitle": "İstek Kayıtları",
"httpDestRequestLogsDescription": "Yönlendirilmiş kaynaklar için HTTP istek kayıtları, yöntem, yol ve yanıt kodu dahil.", "httpDestRequestLogsDescription": "Yönlendirilmiş kaynaklar için HTTP istek kayıtları, yöntem, yol ve yanıt kodu dahil.",
"httpDestSaveChanges": "Değişiklikleri Kaydet", "httpDestSaveChanges": "Değişiklikleri Kaydet",
"httpDestCreateDestination": "Hedef Oluştur", "httpDestCreateDestination": "Hedef Oluştur",
@@ -3208,48 +3208,5 @@
"domainPickerWildcardCertWarning": "Genel kaynaklar düzgün çalışmak için ek yapılandırma gerektirebilir.", "domainPickerWildcardCertWarning": "Genel kaynaklar düzgün çalışmak için ek yapılandırma gerektirebilir.",
"domainPickerWildcardCertWarningLink": "Daha fazla bilgi", "domainPickerWildcardCertWarningLink": "Daha fazla bilgi",
"health": "Sağlık", "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"
} }

View File

@@ -2672,7 +2672,7 @@
"logRetentionDescription": "管理不同类型的日志为这个机构保留多长时间或禁用这些日志", "logRetentionDescription": "管理不同类型的日志为这个机构保留多长时间或禁用这些日志",
"requestLogsDescription": "查看此机构资源的详细请求日志", "requestLogsDescription": "查看此机构资源的详细请求日志",
"requestAnalyticsDescription": "查看此机构资源的详细请求分析", "requestAnalyticsDescription": "查看此机构资源的详细请求分析",
"logRetentionRequestLabel": "HTTP 请求日志保留", "logRetentionRequestLabel": "请求日志保留",
"logRetentionRequestDescription": "保留请求日志的时间", "logRetentionRequestDescription": "保留请求日志的时间",
"logRetentionAccessLabel": "访问日志保留", "logRetentionAccessLabel": "访问日志保留",
"logRetentionAccessDescription": "保留访问日志的时间", "logRetentionAccessDescription": "保留访问日志的时间",
@@ -3208,48 +3208,5 @@
"domainPickerWildcardCertWarning": "通配符资源可能需要额外配置才能正常工作。", "domainPickerWildcardCertWarning": "通配符资源可能需要额外配置才能正常工作。",
"domainPickerWildcardCertWarningLink": "了解更多", "domainPickerWildcardCertWarningLink": "了解更多",
"health": "健康", "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": "下一页"
} }

View File

@@ -361,7 +361,7 @@ export async function updateClientResources(
} else { } else {
let aliasAddress: string | null = null; let aliasAddress: string | null = null;
if (resourceData.mode === "host" || resourceData.mode === "http") { if (resourceData.mode === "host" || resourceData.mode === "http") {
aliasAddress = await getNextAvailableAliasAddress(orgId, trx); aliasAddress = await getNextAvailableAliasAddress(orgId);
} }
let domainInfo: let domainInfo:

View File

@@ -28,159 +28,6 @@ export async function calculateUserClientsForOrgs(
trx?: Transaction trx?: Transaction
): Promise<void> { ): Promise<void> {
const execute = async (transaction: Transaction) => { const execute = async (transaction: Transaction) => {
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
const adminRoleCache = new Map<
string,
typeof roles.$inferSelect | null
>();
const exitNodesCache = new Map<
string,
Awaited<ReturnType<typeof listExitNodes>>
>();
const isOrgLicensedCache = new Map<string, boolean>();
const existingClientCache = new Map<
string,
typeof clients.$inferSelect | null
>();
const roleClientAccessCache = new Map<string, boolean>();
const userClientAccessCache = new Map<string, boolean>();
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 // Get all OLMs for this user
const userOlms = await transaction const userOlms = await transaction
.select() .select()
@@ -207,9 +54,7 @@ export async function calculateUserClientsForOrgs(
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) .innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(eq(userOrgs.userId, userId)); .where(eq(userOrgs.userId, userId));
const userOrgIds = [ const userOrgIds = [...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))];
...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))
];
const orgIdToRoleRows = new Map< const orgIdToRoleRows = new Map<
string, string,
(typeof userOrgRoleRows)[0][] (typeof userOrgRoleRows)[0][]
@@ -219,13 +64,6 @@ export async function calculateUserClientsForOrgs(
list.push(r); list.push(r);
orgIdToRoleRows.set(r.userOrgs.orgId, list); orgIdToRoleRows.set(r.userOrgs.orgId, list);
} }
const orgRequiresDeviceApprovalRole = new Map<string, boolean>();
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 each OLM, ensure there's a client in each org the user is in
for (const olm of userOlms) { for (const olm of userOlms) {
@@ -233,7 +71,10 @@ export async function calculateUserClientsForOrgs(
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!; const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
const userOrg = roleRowsForOrg[0].userOrgs; const userOrg = roleRowsForOrg[0].userOrgs;
const org = await getOrg(orgId); const [org] = await transaction
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
if (!org) { if (!org) {
logger.warn( logger.warn(
@@ -250,7 +91,11 @@ export async function calculateUserClientsForOrgs(
} }
// Get admin role for this org (needed for access grants) // Get admin role for this org (needed for access grants)
const adminRole = await getAdminRole(orgId); const [adminRole] = await transaction
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (!adminRole) { if (!adminRole) {
logger.warn( logger.warn(
@@ -260,50 +105,64 @@ export async function calculateUserClientsForOrgs(
} }
// Check if a client already exists for this OLM+user+org combination // Check if a client already exists for this OLM+user+org combination
const existingClient = await getExistingClient( const [existingClient] = await transaction
orgId, .select()
olm.olmId .from(clients)
); .where(
and(
eq(clients.userId, userId),
eq(clients.orgId, orgId),
eq(clients.olmId, olm.olmId)
)
)
.limit(1);
if (existingClient) { if (existingClient) {
// Ensure admin role has access to the client // Ensure admin role has access to the client
const hasRoleAccess = await hasRoleClientAccess( const [existingRoleClient] = await transaction
adminRole.roleId, .select()
.from(roleClients)
.where(
and(
eq(roleClients.roleId, adminRole.roleId),
eq(
roleClients.clientId,
existingClient.clientId existingClient.clientId
); )
)
)
.limit(1);
if (!hasRoleAccess) { if (!existingRoleClient) {
await transaction.insert(roleClients).values({ await transaction.insert(roleClients).values({
roleId: adminRole.roleId, roleId: adminRole.roleId,
clientId: existingClient.clientId clientId: existingClient.clientId
}); });
roleClientAccessCache.set(
getRoleClientKey(
adminRole.roleId,
existingClient.clientId
),
true
);
logger.debug( logger.debug(
`Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})` `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 // Ensure user has access to the client
const hasUserAccess = await hasUserClientAccess( const [existingUserClient] = await transaction
userId, .select()
.from(userClients)
.where(
and(
eq(userClients.userId, userId),
eq(
userClients.clientId,
existingClient.clientId existingClient.clientId
); )
)
)
.limit(1);
if (!hasUserAccess) { if (!existingUserClient) {
await transaction.insert(userClients).values({ await transaction.insert(userClients).values({
userId, userId,
clientId: existingClient.clientId clientId: existingClient.clientId
}); });
userClientAccessCache.set(
getUserClientKey(userId, existingClient.clientId),
true
);
logger.debug( logger.debug(
`Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})` `Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
); );
@@ -316,7 +175,7 @@ export async function calculateUserClientsForOrgs(
} }
// Get exit nodes for this org // Get exit nodes for this org
const exitNodesList = await getExitNodes(orgId); const exitNodesList = await listExitNodes(orgId);
if (exitNodesList.length === 0) { if (exitNodesList.length === 0) {
logger.warn( logger.warn(
@@ -347,11 +206,14 @@ export async function calculateUserClientsForOrgs(
const niceId = await getUniqueClientName(orgId); const niceId = await getUniqueClientName(orgId);
const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId); const isOrgLicensed = await isLicensedOrSubscribed(
userOrg.orgId,
tierMatrix.deviceApprovals
);
const requireApproval = const requireApproval =
build !== "oss" && build !== "oss" &&
isOrgLicensed && isOrgLicensed &&
orgRequiresDeviceApprovalRole.get(orgId) === true; roleRowsForOrg.some((r) => r.roles.requireDeviceApproval);
const newClientData: InferInsertModel<typeof clients> = { const newClientData: InferInsertModel<typeof clients> = {
userId, userId,
@@ -370,10 +232,6 @@ export async function calculateUserClientsForOrgs(
.insert(clients) .insert(clients)
.values(newClientData) .values(newClientData)
.returning(); .returning();
existingClientCache.set(
getOrgOlmKey(orgId, olm.olmId),
newClient
);
// create approval request // create approval request
if (requireApproval) { if (requireApproval) {
@@ -399,20 +257,12 @@ export async function calculateUserClientsForOrgs(
roleId: adminRole.roleId, roleId: adminRole.roleId,
clientId: newClient.clientId clientId: newClient.clientId
}); });
roleClientAccessCache.set(
getRoleClientKey(adminRole.roleId, newClient.clientId),
true
);
// Grant user access to the client // Grant user access to the client
await transaction.insert(userClients).values({ await transaction.insert(userClients).values({
userId, userId,
clientId: newClient.clientId clientId: newClient.clientId
}); });
userClientAccessCache.set(
getUserClientKey(userId, newClient.clientId),
true
);
logger.debug( logger.debug(
`Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user` `Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user`

View File

@@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process // This is a placeholder value replaced by the build process
export const APP_VERSION = "1.18.3"; export const APP_VERSION = "1.18.2";
export const __FILENAME = fileURLToPath(import.meta.url); export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME); export const __DIRNAME = path.dirname(__FILENAME);

View File

@@ -6,7 +6,6 @@ import z from "zod";
import logger from "@server/logger"; import logger from "@server/logger";
import semver from "semver"; import semver from "semver";
import { getValidCertificatesForDomains } from "#dynamic/lib/certificates"; import { getValidCertificatesForDomains } from "#dynamic/lib/certificates";
import { lockManager } from "#dynamic/lib/lock";
interface IPRange { interface IPRange {
start: bigint; start: bigint;
@@ -328,9 +327,6 @@ export async function getNextAvailableClientSubnet(
orgId: string, orgId: string,
transaction: Transaction | typeof db = db transaction: Transaction | typeof db = db
): Promise<string> { ): Promise<string> {
return await lockManager.withLock(
`client-subnet-allocation:${orgId}`,
async () => {
const [org] = await transaction const [org] = await transaction
.select() .select()
.from(orgs) .from(orgs)
@@ -341,9 +337,7 @@ export async function getNextAvailableClientSubnet(
} }
if (!org.subnet) { if (!org.subnet) {
throw new Error( throw new Error(`Organization with ID ${orgId} has no subnet defined`);
`Organization with ID ${orgId} has no subnet defined`
);
} }
const existingAddressesSites = await transaction const existingAddressesSites = await transaction
@@ -358,9 +352,7 @@ export async function getNextAvailableClientSubnet(
address: clients.subnet address: clients.subnet
}) })
.from(clients) .from(clients)
.where( .where(and(isNotNull(clients.subnet), eq(clients.orgId, orgId)));
and(isNotNull(clients.subnet), eq(clients.orgId, orgId))
);
const addresses = [ const addresses = [
...existingAddressesSites.map( ...existingAddressesSites.map(
@@ -377,30 +369,19 @@ export async function getNextAvailableClientSubnet(
} }
return subnet; return subnet;
}
);
} }
export async function getNextAvailableAliasAddress( export async function getNextAvailableAliasAddress(
orgId: string, orgId: string
trx: Transaction | typeof db = db
): Promise<string> { ): Promise<string> {
return await lockManager.withLock( const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId));
`alias-address-allocation:${orgId}`,
async () => {
const [org] = await trx
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
if (!org) { if (!org) {
throw new Error(`Organization with ID ${orgId} not found`); throw new Error(`Organization with ID ${orgId} not found`);
} }
if (!org.subnet) { if (!org.subnet) {
throw new Error( throw new Error(`Organization with ID ${orgId} has no subnet defined`);
`Organization with ID ${orgId} has no subnet defined`
);
} }
if (!org.utilitySubnet) { if (!org.utilitySubnet) {
@@ -409,7 +390,7 @@ export async function getNextAvailableAliasAddress(
); );
} }
const existingAddresses = await trx const existingAddresses = await db
.select({ .select({
aliasAddress: siteResources.aliasAddress aliasAddress: siteResources.aliasAddress
}) })
@@ -429,11 +410,7 @@ export async function getNextAvailableAliasAddress(
`${org.utilitySubnet.split("/")[0]}/29` `${org.utilitySubnet.split("/")[0]}/29`
].filter((address) => address !== null) as string[]; ].filter((address) => address !== null) as string[];
let subnet = findNextAvailableCidr( let subnet = findNextAvailableCidr(addresses, 32, org.utilitySubnet);
addresses,
32,
org.utilitySubnet
);
if (!subnet) { if (!subnet) {
throw new Error("No available subnets remaining in space"); throw new Error("No available subnets remaining in space");
} }
@@ -442,12 +419,9 @@ export async function getNextAvailableAliasAddress(
subnet = subnet.split("/")[0]; subnet = subnet.split("/")[0];
return subnet; return subnet;
}
);
} }
export async function getNextAvailableOrgSubnet(): Promise<string> { export async function getNextAvailableOrgSubnet(): Promise<string> {
return await lockManager.withLock("org-subnet-allocation", async () => {
const existingAddresses = await db const existingAddresses = await db
.select({ .select({
subnet: orgs.subnet subnet: orgs.subnet
@@ -467,7 +441,6 @@ export async function getNextAvailableOrgSubnet(): Promise<string> {
} }
return subnet; return subnet;
});
} }
export function generateRemoteSubnets( export function generateRemoteSubnets(
@@ -505,12 +478,7 @@ export type Alias = { alias: string | null; aliasAddress: string | null };
export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] { export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
return allSiteResources return allSiteResources
.filter( .filter((sr) => sr.aliasAddress && ((sr.alias && sr.mode == "host") || (sr.fullDomain && sr.mode == "http")))
(sr) =>
sr.aliasAddress &&
((sr.alias && sr.mode == "host") ||
(sr.fullDomain && sr.mode == "http"))
)
.map((sr) => ({ .map((sr) => ({
alias: sr.alias || sr.fullDomain, alias: sr.alias || sr.fullDomain,
aliasAddress: sr.aliasAddress aliasAddress: sr.aliasAddress

View File

@@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { db, logsDb, statusHistory } from "@server/db"; import { db, logsDb, statusHistory } from "@server/db";
import { and, eq, gte, asc } from "drizzle-orm"; import { and, eq, gte, asc } from "drizzle-orm";
import cache from "@server/lib/cache"; import { regionalCache as cache } from "@server/private/lib/cache";
const STATUS_HISTORY_CACHE_TTL = 60; // seconds const STATUS_HISTORY_CACHE_TTL = 60; // seconds
@@ -24,11 +24,8 @@ export async function getCachedStatusHistory(
return cached; return cached;
} }
// Anchor to UTC midnight so the query window aligns with stable calendar days const nowSec = Math.floor(Date.now() / 1000);
const utcToday = new Date(); const startSec = nowSec - days * 86400;
utcToday.setUTCHours(0, 0, 0, 0);
const todayMidnightSec = Math.floor(utcToday.getTime() / 1000);
const startSec = todayMidnightSec - days * 86400;
const events = await logsDb const events = await logsDb
.select() .select()
@@ -66,7 +63,7 @@ export async function invalidateStatusHistoryCache(
entityId: number entityId: number
): Promise<void> { ): Promise<void> {
const prefix = `statusHistory:${entityType}:${entityId}:`; const prefix = `statusHistory:${entityType}:${entityId}:`;
const keys = cache.keys().filter((k) => k.startsWith(prefix)); const keys = await cache.keysWithPrefix(prefix);
if (keys.length > 0) { if (keys.length > 0) {
await cache.del(keys); await cache.del(keys);
} }
@@ -113,18 +110,11 @@ export function computeBuckets(
days: number days: number
): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } { ): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } {
const nowSec = Math.floor(Date.now() / 1000); 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[] = []; const buckets: StatusHistoryDayBucket[] = [];
let totalDowntime = 0; let totalDowntime = 0;
for (let d = 0; d < days; d++) { for (let d = 0; d < days; d++) {
const dayStartSec = todayMidnightSec - (days - d) * 86400; const dayStartSec = nowSec - (days - d) * 86400;
const dayEndSec = dayStartSec + 86400; const dayEndSec = dayStartSec + 86400;
const dayEvents = events.filter( const dayEvents = events.filter(

View File

@@ -29,10 +29,7 @@ import { decrypt } from "@server/lib/crypto";
import logger from "@server/logger"; import logger from "@server/logger";
import { sendAlertWebhook } from "./sendAlertWebhook"; import { sendAlertWebhook } from "./sendAlertWebhook";
import { sendAlertEmail } from "./sendAlertEmail"; import { sendAlertEmail } from "./sendAlertEmail";
import { import { AlertContext, WebhookAlertConfig } from "@server/routers/alertRule/types";
AlertContext,
WebhookAlertConfig
} from "@server/routers/alertRule/types";
/** /**
* Core alert processing pipeline. * Core alert processing pipeline.
@@ -102,10 +99,7 @@ export async function processAlerts(context: AlertContext): Promise<void> {
baseConditions, baseConditions,
or( or(
eq(alertRules.allHealthChecks, true), eq(alertRules.allHealthChecks, true),
eq( eq(alertHealthChecks.healthCheckId, context.healthCheckId)
alertHealthChecks.healthCheckId,
context.healthCheckId
)
) )
) )
); );
@@ -214,19 +208,14 @@ async function processRule(
for (const action of emailActions) { for (const action of emailActions) {
try { try {
const recipients = await resolveEmailRecipients( const recipients = await resolveEmailRecipients(action.emailActionId);
action.emailActionId
);
if (recipients.length > 0) { if (recipients.length > 0) {
await sendAlertEmail(recipients, context); await sendAlertEmail(recipients, context);
await db await db
.update(alertEmailActions) .update(alertEmailActions)
.set({ lastSentAt: now }) .set({ lastSentAt: now })
.where( .where(
eq( eq(alertEmailActions.emailActionId, action.emailActionId)
alertEmailActions.emailActionId,
action.emailActionId
)
); );
} }
} catch (err) { } catch (err) {
@@ -280,7 +269,7 @@ async function processRule(
) )
); );
} catch (err) { } catch (err) {
logger.warn( logger.error(
`processAlerts: failed to send alert webhook for action ${action.webhookActionId}`, `processAlerts: failed to send alert webhook for action ${action.webhookActionId}`,
err err
); );
@@ -300,9 +289,7 @@ async function processRule(
* - All users in a role (by `roleId`, resolved via `userOrgRoles`) * - All users in a role (by `roleId`, resolved via `userOrgRoles`)
* - Direct external email addresses * - Direct external email addresses
*/ */
async function resolveEmailRecipients( async function resolveEmailRecipients(emailActionId: number): Promise<string[]> {
emailActionId: number
): Promise<string[]> {
const rows = await db const rows = await db
.select() .select()
.from(alertEmailRecipients) .from(alertEmailRecipients)

View File

@@ -236,43 +236,15 @@ interface TemplateContext {
} }
/** /**
* Render a body template with {{event}}, {{timestamp}}, {{status}}, {{data}}, * Render a body template with {{event}}, {{timestamp}}, {{status}}, and
* and individual data-field placeholders (e.g. {{orgId}}, {{siteId}}, …). * {{data}} placeholders, mirroring the logic in HttpLogDestination.
* *
* Replacement order: * {{data}} is replaced first (as raw JSON) so that any literal "{{…}}"
* 1. {{data}} → raw JSON of the full data object (prevents re-expansion of * strings inside data values are not re-expanded.
* 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 { function renderTemplate(template: string, ctx: TemplateContext): string {
// Step 1 expand {{data}} first so its contents are already serialised const rendered = template
// and won't be touched by later passes. .replace(/\{\{data\}\}/g, JSON.stringify(ctx.data))
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(/\{\{event\}\}/g, escapeJsonString(ctx.event))
.replace(/\{\{timestamp\}\}/g, escapeJsonString(ctx.timestamp)) .replace(/\{\{timestamp\}\}/g, escapeJsonString(ctx.timestamp))
.replace(/\{\{status\}\}/g, escapeJsonString(ctx.status)); .replace(/\{\{status\}\}/g, escapeJsonString(ctx.status));

View File

@@ -13,7 +13,7 @@
import NodeCache from "node-cache"; import NodeCache from "node-cache";
import logger from "@server/logger"; import logger from "@server/logger";
import { redisManager } from "@server/private/lib/redis"; import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
// Create local cache with maxKeys limit to prevent memory leaks // Create local cache with maxKeys limit to prevent memory leaks
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient // With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
@@ -298,3 +298,147 @@ class AdaptiveCache {
// Export singleton instance // Export singleton instance
export const cache = new AdaptiveCache(); export const cache = new AdaptiveCache();
export default cache; export default cache;
/**
* Regional adaptive cache backed by the in-cluster Redis instance.
* Falls back to a local NodeCache when the regional Redis is unavailable.
* Use this for data that is regional in nature (e.g. status history) so
* reads are served from the same cluster the user is hitting.
*/
const regionalLocalCache = new NodeCache({
stdTTL: 3600,
checkperiod: 120,
maxKeys: 10000
});
class RegionalAdaptiveCache {
private useRedis(): boolean {
return (
regionalRedisManager.isRedisEnabled() &&
regionalRedisManager.getHealthStatus().isHealthy
);
}
async set(key: string, value: any, ttl?: number): Promise<boolean> {
const effectiveTtl = ttl === 0 ? undefined : ttl;
const redisTtl = ttl === 0 ? undefined : (ttl ?? 3600);
if (this.useRedis()) {
try {
const serialized = JSON.stringify(value);
const success = await regionalRedisManager.set(
key,
serialized,
redisTtl
);
if (success) {
logger.debug(`[regional] Set key in Redis: ${key}`);
return true;
}
} catch (error) {
logger.error(
`[regional] Redis set error for key ${key}:`,
error
);
}
}
const success = regionalLocalCache.set(key, value, effectiveTtl || 0);
if (success) logger.debug(`[regional] Set key in local cache: ${key}`);
return success;
}
async get<T = any>(key: string): Promise<T | undefined> {
if (this.useRedis()) {
try {
const value = await regionalRedisManager.get(key);
if (value !== null) {
logger.debug(`[regional] Cache hit in Redis: ${key}`);
return JSON.parse(value) as T;
}
logger.debug(`[regional] Cache miss in Redis: ${key}`);
return undefined;
} catch (error) {
logger.error(
`[regional] Redis get error for key ${key}:`,
error
);
}
}
const value = regionalLocalCache.get<T>(key);
if (value !== undefined) {
logger.debug(`[regional] Cache hit in local cache: ${key}`);
} else {
logger.debug(`[regional] Cache miss in local cache: ${key}`);
}
return value;
}
async del(key: string | string[]): Promise<number> {
const keys = Array.isArray(key) ? key : [key];
let deletedCount = 0;
if (this.useRedis()) {
try {
for (const k of keys) {
const success = await regionalRedisManager.del(k);
if (success) {
deletedCount++;
logger.debug(`[regional] Deleted key from Redis: ${k}`);
}
}
if (deletedCount === keys.length) return deletedCount;
deletedCount = 0;
} catch (error) {
logger.error(`[regional] Redis del error:`, error);
deletedCount = 0;
}
}
for (const k of keys) {
const count = regionalLocalCache.del(k);
if (count > 0) {
deletedCount++;
logger.debug(`[regional] Deleted key from local cache: ${k}`);
}
}
return deletedCount;
}
async has(key: string): Promise<boolean> {
if (this.useRedis()) {
try {
const value = await regionalRedisManager.get(key);
return value !== null;
} catch (error) {
logger.error(
`[regional] Redis has error for key ${key}:`,
error
);
}
}
return regionalLocalCache.has(key);
}
/**
* Returns keys matching the given prefix from whichever backend is active.
* Redis uses a KEYS scan; local cache filters in-memory keys.
*/
async keysWithPrefix(prefix: string): Promise<string[]> {
if (this.useRedis()) {
try {
return await regionalRedisManager.keys(`${prefix}*`);
} catch (error) {
logger.error(`[regional] Redis keys error:`, error);
}
}
return regionalLocalCache.keys().filter((k) => k.startsWith(prefix));
}
getCurrentBackend(): "redis" | "local" {
return this.useRedis() ? "redis" : "local";
}
}
export const regionalCache = new RegionalAdaptiveCache();

View File

@@ -73,6 +73,25 @@ export const privateConfigSchema = z
.object({ .object({
rejectUnauthorized: z.boolean().optional().default(true) rejectUnauthorized: z.boolean().optional().default(true)
}) })
.optional(),
regional_redis: z
.object({
host: z.string(),
port: portSchema,
password: z
.string()
.optional()
.transform(getEnvOrYaml("REGIONAL_REDIS_PASSWORD")),
db: z.int().nonnegative().optional().default(0),
tls: z
.object({
rejectUnauthorized: z
.boolean()
.optional()
.default(true)
})
.optional()
})
.optional() .optional()
}) })
.optional(), .optional(),

View File

@@ -855,3 +855,163 @@ class RedisManager {
export const redisManager = new RedisManager(); export const redisManager = new RedisManager();
export const redis = redisManager.getClient(); export const redis = redisManager.getClient();
export default redisManager; export default redisManager;
/**
* Lightweight Redis manager for the regional (in-cluster) Redis instance.
* Connects only when `redis.regional_redis` is present in the private config
* and `flags.enable_redis` is true. No pub/sub — designed for low-latency
* caching of regionally-scoped data.
*/
class RegionalRedisManager {
private writeClient: Redis | null = null;
private readClient: Redis | null = null;
private isEnabled: boolean = false;
private isHealthy: boolean = false;
private connectionTimeout: number = 5000;
private commandTimeout: number = 5000;
constructor() {
if (build === "oss") return;
const cfg = privateConfig.getRawPrivateConfig();
if (!cfg.flags.enable_redis || !cfg.redis?.regional_redis) return;
this.isEnabled = true;
this.initializeClients();
}
private getConfig(): RedisOptions {
const r = privateConfig.getRawPrivateConfig().redis!.regional_redis!;
const opts: RedisOptions = {
host: r.host,
port: r.port,
password: r.password,
db: r.db
};
if (r.tls) {
opts.tls = { rejectUnauthorized: r.tls.rejectUnauthorized ?? true };
}
return opts;
}
private initializeClients(): void {
const cfg = this.getConfig();
const baseOpts = {
...cfg,
enableReadyCheck: false,
maxRetriesPerRequest: 3,
keepAlive: 10000,
connectTimeout: this.connectionTimeout,
commandTimeout: this.commandTimeout
};
try {
this.writeClient = new Redis(baseOpts);
// redis-1 (replica) handles reads; fall back to primary if not resolvable
this.readClient = new Redis({
...baseOpts,
host: cfg.host!.replace(/^(.*?)(\.\S+)$/, (_, h, rest) => {
// Derive replica hostname from the headless service pattern:
// redis.redis.svc.cluster.local -> redis-1.redis-headless.redis.svc.cluster.local
// If it doesn't look like a k8s service, just use the same host
return h + rest;
})
});
// For simplicity use same host for both; callers can always read from primary
// The real replica routing is handled by the StatefulSet headless service
this.readClient = this.writeClient;
this.writeClient.on("ready", () => {
logger.info("Regional Redis client ready");
this.isHealthy = true;
});
this.writeClient.on("error", (err) => {
logger.error("Regional Redis client error:", err);
this.isHealthy = false;
});
this.writeClient.on("reconnecting", () => {
logger.info("Regional Redis client reconnecting...");
this.isHealthy = false;
});
logger.info("Regional Redis client initialized");
} catch (error) {
logger.error("Failed to initialize regional Redis client:", error);
this.isEnabled = false;
}
}
public isRedisEnabled(): boolean {
return this.isEnabled && this.writeClient !== null && this.isHealthy;
}
public getHealthStatus() {
return { isEnabled: this.isEnabled, isHealthy: this.isHealthy };
}
public async set(
key: string,
value: string,
ttl?: number
): Promise<boolean> {
if (!this.isRedisEnabled() || !this.writeClient) return false;
try {
if (ttl) {
await this.writeClient.setex(key, ttl, value);
} else {
await this.writeClient.set(key, value);
}
return true;
} catch (error) {
logger.error("Regional Redis SET error:", error);
return false;
}
}
public async get(key: string): Promise<string | null> {
if (!this.isRedisEnabled() || !this.readClient) return null;
try {
return await this.readClient.get(key);
} catch (error) {
logger.error("Regional Redis GET error:", error);
return null;
}
}
public async del(key: string): Promise<boolean> {
if (!this.isRedisEnabled() || !this.writeClient) return false;
try {
await this.writeClient.del(key);
return true;
} catch (error) {
logger.error("Regional Redis DEL error:", error);
return false;
}
}
public async keys(pattern: string): Promise<string[]> {
if (!this.isRedisEnabled() || !this.readClient) return [];
try {
return await this.readClient.keys(pattern);
} catch (error) {
logger.error("Regional Redis KEYS error:", error);
return [];
}
}
public async disconnect(): Promise<void> {
try {
if (this.writeClient) {
await this.writeClient.quit();
this.writeClient = null;
}
this.readClient = null;
logger.info("Regional Redis client disconnected");
} catch (error) {
logger.error("Error disconnecting regional Redis client:", error);
}
}
}
export const regionalRedisManager = new RegionalRedisManager();

View File

@@ -151,8 +151,6 @@ export async function getUserResources(
destination: string; destination: string;
mode: string; mode: string;
scheme: string | null; scheme: string | null;
ssl: boolean;
fullDomain: string | null;
enabled: boolean; enabled: boolean;
alias: string | null; alias: string | null;
aliasAddress: string | null; aliasAddress: string | null;
@@ -166,8 +164,6 @@ export async function getUserResources(
destination: siteResources.destination, destination: siteResources.destination,
mode: siteResources.mode, mode: siteResources.mode,
scheme: siteResources.scheme, scheme: siteResources.scheme,
ssl: siteResources.ssl,
fullDomain: siteResources.fullDomain,
enabled: siteResources.enabled, enabled: siteResources.enabled,
alias: siteResources.alias, alias: siteResources.alias,
aliasAddress: siteResources.aliasAddress aliasAddress: siteResources.aliasAddress
@@ -255,8 +251,6 @@ export async function getUserResources(
destination: siteResource.destination, destination: siteResource.destination,
mode: siteResource.mode, mode: siteResource.mode,
protocol: siteResource.scheme, protocol: siteResource.scheme,
ssl: siteResource.ssl,
fullDomain: siteResource.fullDomain,
enabled: siteResource.enabled, enabled: siteResource.enabled,
alias: siteResource.alias, alias: siteResource.alias,
aliasAddress: siteResource.aliasAddress, aliasAddress: siteResource.aliasAddress,
@@ -302,8 +296,6 @@ export type GetUserResourcesResponse = {
destination: string; destination: string;
mode: string; mode: string;
protocol: string | null; protocol: string | null;
ssl: boolean;
fullDomain: string | null;
enabled: boolean; enabled: boolean;
alias: string | null; alias: string | null;
aliasAddress: string | null; aliasAddress: string | null;

View File

@@ -74,6 +74,7 @@ const createSiteResourceSchema = z
.refine( .refine(
(data) => { (data) => {
if (data.mode === "host") { if (data.mode === "host") {
if (data.mode == "host") {
// Check if it's a valid IP address using zod (v4 or v6) // Check if it's a valid IP address using zod (v4 or v6)
const isValidIP = z const isValidIP = z
// .union([z.ipv4(), z.ipv6()]) // .union([z.ipv4(), z.ipv6()])
@@ -83,6 +84,7 @@ const createSiteResourceSchema = z
if (isValidIP) { if (isValidIP) {
return true; return true;
} }
}
// Check if it's a valid domain (hostname pattern, TLD not required) // Check if it's a valid domain (hostname pattern, TLD not required)
const domainRegex = const domainRegex =
@@ -94,12 +96,17 @@ const createSiteResourceSchema = z
data.alias.trim() !== ""; data.alias.trim() !== "";
return isValidDomain && isValidAlias; // require the alias to be set in the case of domain return isValidDomain && isValidAlias; // require the alias to be set in the case of domain
} else if (data.mode === "http") {
// we have to have a domainId defined
if (!data.domainId) {
return false;
} }
} else if (data.mode === "cidr") { return true;
},
{
message:
"Destination must be a valid IPV4 address or valid domain AND alias is required"
}
)
.refine(
(data) => {
if (data.mode === "cidr") {
// Check if it's a valid CIDR (v4 or v6) // Check if it's a valid CIDR (v4 or v6)
const isValidCIDR = z const isValidCIDR = z
.union([z.cidrv4(), z.cidrv6()]) .union([z.cidrv4(), z.cidrv6()])
@@ -109,8 +116,7 @@ const createSiteResourceSchema = z
return true; return true;
}, },
{ {
message: message: "Destination must be a valid CIDR notation for cidr mode"
"Destination must be a valid IPV4 address or valid domain AND alias is required"
} }
) )
.refine( .refine(

View File

@@ -104,17 +104,6 @@ const updateSiteResourceSchema = z
data.alias.trim() !== ""; data.alias.trim() !== "";
return isValidDomain && isValidAlias; // require the alias to be set in the case of domain 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; return true;
}, },
@@ -123,6 +112,21 @@ const updateSiteResourceSchema = z
"Destination must be a valid IP address or valid domain AND alias is required" "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( .refine(
(data) => { (data) => {
if (data.mode !== "http") return true; if (data.mode !== "http") return true;

View File

@@ -77,7 +77,7 @@ export default async function migration() {
} }
console.log( console.log(
`Updated names for ${existingHealthChecks.length} existing targetHealthCheck row(s)` `Migrated ${existingHealthChecks.length} targetHealthCheck row(s) with corrected IDs`
); );
} catch (e) { } catch (e) {
console.error("Error while migrating targetHealthCheck rows:", e); console.error("Error while migrating targetHealthCheck rows:", e);

View File

@@ -175,6 +175,26 @@ export default function GeneralPage() {
}, [variant]); }, [variant]);
useEffect(() => { useEffect(() => {
async function fetchRoles() {
const res = await api
.get<AxiosResponse<ListRolesResponse>>(`/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 ( const loadIdp = async (
availableRoles: { roleId: number; name: string }[] availableRoles: { roleId: number; name: string }[]
) => { ) => {
@@ -500,7 +520,6 @@ export default function GeneralPage() {
onAutoProvisionChange={(checked) => { onAutoProvisionChange={(checked) => {
form.setValue("autoProvision", checked); form.setValue("autoProvision", checked);
}} }}
orgId={orgId as string}
roleMappingMode={roleMappingMode} roleMappingMode={roleMappingMode}
onRoleMappingModeChange={(data) => { onRoleMappingModeChange={(data) => {
setRoleMappingMode(data); setRoleMappingMode(data);

View File

@@ -246,10 +246,7 @@ export default function Page() {
<PaidFeaturesAlert tiers={tierMatrix.orgOidc} /> <PaidFeaturesAlert tiers={tierMatrix.orgOidc} />
<fieldset <fieldset disabled={disabled} className={disabled ? "opacity-50 pointer-events-none" : ""}>
disabled={disabled}
className={disabled ? "opacity-50 pointer-events-none" : ""}
>
<SettingsContainer> <SettingsContainer>
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
@@ -264,10 +261,7 @@ export default function Page() {
<OidcIdpProviderTypeSelect <OidcIdpProviderTypeSelect
value={form.watch("type")} value={form.watch("type")}
onTypeChange={(next) => { onTypeChange={(next) => {
applyOidcIdpProviderType( applyOidcIdpProviderType(form.setValue, next);
form.setValue,
next
);
}} }}
/> />
@@ -324,42 +318,30 @@ export default function Page() {
> >
<AutoProvisionConfigWidget <AutoProvisionConfigWidget
autoProvision={ autoProvision={
form.watch( form.watch("autoProvision") as boolean
"autoProvision"
) as boolean
} // is this right? } // is this right?
onAutoProvisionChange={(checked) => { onAutoProvisionChange={(checked) => {
form.setValue( form.setValue("autoProvision", checked);
"autoProvision",
checked
);
}} }}
orgId={params.orgId as string}
roleMappingMode={roleMappingMode} roleMappingMode={roleMappingMode}
onRoleMappingModeChange={(data) => { onRoleMappingModeChange={(data) => {
setRoleMappingMode(data); setRoleMappingMode(data);
}} }}
roles={roles} roles={roles}
fixedRoleNames={fixedRoleNames} fixedRoleNames={fixedRoleNames}
onFixedRoleNamesChange={ onFixedRoleNamesChange={setFixedRoleNames}
setFixedRoleNames
}
mappingBuilderClaimPath={ mappingBuilderClaimPath={
mappingBuilderClaimPath mappingBuilderClaimPath
} }
onMappingBuilderClaimPathChange={ onMappingBuilderClaimPathChange={
setMappingBuilderClaimPath setMappingBuilderClaimPath
} }
mappingBuilderRules={ mappingBuilderRules={mappingBuilderRules}
mappingBuilderRules
}
onMappingBuilderRulesChange={ onMappingBuilderRulesChange={
setMappingBuilderRules setMappingBuilderRules
} }
rawExpression={rawRoleExpression} rawExpression={rawRoleExpression}
onRawExpressionChange={ onRawExpressionChange={setRawRoleExpression}
setRawRoleExpression
}
orgMappingField={{ orgMappingField={{
control: form.control, control: form.control,
name: "orgMapping" name: "orgMapping"
@@ -386,9 +368,7 @@ export default function Page() {
<form <form
className="space-y-4" className="space-y-4"
id="create-idp-form" id="create-idp-form"
onSubmit={form.handleSubmit( onSubmit={form.handleSubmit(onSubmit)}
onSubmit
)}
> >
<FormField <FormField
control={form.control} control={form.control}
@@ -417,9 +397,7 @@ export default function Page() {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t( {t("idpClientSecret")}
"idpClientSecret"
)}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
@@ -459,9 +437,7 @@ export default function Page() {
<form <form
className="space-y-4" className="space-y-4"
id="create-idp-form" id="create-idp-form"
onSubmit={form.handleSubmit( onSubmit={form.handleSubmit(onSubmit)}
onSubmit
)}
> >
<FormField <FormField
control={form.control} control={form.control}
@@ -469,9 +445,7 @@ export default function Page() {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t( {t("idpTenantIdLabel")}
"idpTenantIdLabel"
)}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
@@ -513,9 +487,7 @@ export default function Page() {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t( {t("idpClientSecret")}
"idpClientSecret"
)}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
@@ -555,9 +527,7 @@ export default function Page() {
<form <form
className="space-y-4" className="space-y-4"
id="create-idp-form" id="create-idp-form"
onSubmit={form.handleSubmit( onSubmit={form.handleSubmit(onSubmit)}
onSubmit
)}
> >
<FormField <FormField
control={form.control} control={form.control}
@@ -586,9 +556,7 @@ export default function Page() {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t( {t("idpClientSecret")}
"idpClientSecret"
)}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
@@ -672,9 +640,7 @@ export default function Page() {
<form <form
className="space-y-4" className="space-y-4"
id="create-idp-form" id="create-idp-form"
onSubmit={form.handleSubmit( onSubmit={form.handleSubmit(onSubmit)}
onSubmit
)}
> >
<FormField <FormField
control={form.control} control={form.control}
@@ -682,9 +648,7 @@ export default function Page() {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t( {t("idpJmespathLabel")}
"idpJmespathLabel"
)}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />

View File

@@ -1,40 +1,44 @@
"use client"; "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 { import {
Form, Form,
FormControl, FormControl,
FormField, FormField,
FormItem, FormItem,
FormLabel FormLabel,
FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { Checkbox } from "@app/components/ui/checkbox";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; import OrgRolesTagField from "@app/components/OrgRolesTagField";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build"; import { AxiosResponse } from "axios";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { useEffect, useState } from "react";
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 { useForm } from "react-hook-form";
import { z } from "zod"; 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({ const accessControlsFormSchema = z.object({
username: z.string(), username: z.string(),
@@ -55,6 +59,12 @@ export default function AccessControlsPage() {
const { orgId } = useParams(); const { orgId } = useParams();
const [loading, setLoading] = useState(false);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [activeRoleTagIndex, setActiveRoleTagIndex] = useState<number | null>(
null
);
const t = useTranslations(); const t = useTranslations();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.fullRbac); const isPaid = isPaidUser(tierMatrix.fullRbac);
@@ -87,21 +97,44 @@ export default function AccessControlsPage() {
text: r.name text: r.name
})) }))
); );
}, [user.userId, currentRoleIds.join(",")]);
useEffect(() => {
async function fetchRoles() {
const res = await api
.get<AxiosResponse<ListRolesResponse>>(`/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); form.setValue("autoProvisioned", user.autoProvisioned || false);
}, [user.userId, user.autoProvisioned, currentRoleIds.join(",")]); }, []);
const allRoleOptions = roles.map((role) => ({
id: role.roleId.toString(),
text: role.name
}));
const paywallMessage = const paywallMessage =
build === "saas" build === "saas"
? t("singleRolePerUserPlanNotice") ? t("singleRolePerUserPlanNotice")
: t("singleRolePerUserEditionNotice"); : t("singleRolePerUserEditionNotice");
const [, action, isSubmitting] = useActionState(onSubmit, null); async function onSubmit(values: z.infer<typeof accessControlsFormSchema>) {
async function onSubmit() {
const isValid = await form.trigger();
if (!isValid) return;
const values = form.getValues();
if (values.roles.length === 0) { if (values.roles.length === 0) {
toast({ toast({
variant: "destructive", variant: "destructive",
@@ -111,6 +144,7 @@ export default function AccessControlsPage() {
return; return;
} }
setLoading(true);
try { try {
const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const updateRoleRequest = supportsMultipleRolesPerUser const updateRoleRequest = supportsMultipleRolesPerUser
@@ -150,6 +184,7 @@ export default function AccessControlsPage() {
) )
}); });
} }
setLoading(false);
} }
return ( return (
@@ -168,7 +203,7 @@ export default function AccessControlsPage() {
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...form}>
<form <form
action={action} onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4" className="space-y-4"
id="access-controls-form" id="access-controls-form"
> >
@@ -191,7 +226,9 @@ export default function AccessControlsPage() {
<OrgRolesTagField <OrgRolesTagField
form={form} form={form}
name="roles" name="roles"
orgId={orgId as string} label={t("roles")}
placeholder={t("accessRoleSelect2")}
allRoleOptions={allRoleOptions}
supportsMultipleRolesPerUser={ supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser supportsMultipleRolesPerUser
} }
@@ -199,6 +236,9 @@ export default function AccessControlsPage() {
showMultiRolePaywallMessage showMultiRolePaywallMessage
} }
paywallMessage={paywallMessage} paywallMessage={paywallMessage}
loading={loading}
activeTagIndex={activeRoleTagIndex}
setActiveTagIndex={setActiveRoleTagIndex}
/> />
{user.idpAutoProvision && ( {user.idpAutoProvision && (
@@ -237,8 +277,8 @@ export default function AccessControlsPage() {
<SettingsSectionFooter> <SettingsSectionFooter>
<Button <Button
type="submit" type="submit"
loading={isSubmitting} loading={loading}
disabled={isSubmitting} disabled={loading}
form="access-controls-form" form="access-controls-form"
> >
{t("accessControlsSubmit")} {t("accessControlsSubmit")}

View File

@@ -13,7 +13,7 @@ import { StrategyOption, StrategySelect } from "@app/components/StrategySelect";
import HeaderTitle from "@app/components/SettingsSectionTitle"; import HeaderTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useActionState, useState } from "react"; import { useState } from "react";
import { import {
Form, Form,
FormControl, FormControl,
@@ -91,7 +91,7 @@ export default function Page() {
"internal" "internal"
); );
const [inviteLink, setInviteLink] = useState<string | null>(null); const [inviteLink, setInviteLink] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [expiresInDays, setExpiresInDays] = useState(1); const [expiresInDays, setExpiresInDays] = useState(1);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [idps, setIdps] = useState<IdpOption[]>([]); const [idps, setIdps] = useState<IdpOption[]>([]);
@@ -311,29 +311,10 @@ export default function Page() {
setUserOptions(options); setUserOptions(options);
}, [idps, t]); }, [idps, t]);
const [, submitInternalAction, isSubmittingInternal] = useActionState( async function onSubmitInternal(
onSubmitInternal, values: z.infer<typeof internalFormSchema>
null ) {
); setLoading(true);
const [, submitGoogleAzureAction, isSubmittingGoogleAzure] = useActionState(
onSubmitGoogleAzure,
null
);
const [, submitGenericOidcAction, isSubmittingGenericOidc] = useActionState(
onSubmitGenericOidc,
null
);
const loading =
isSubmittingInternal ||
isSubmittingGoogleAzure ||
isSubmittingGenericOidc;
async function onSubmitInternal() {
const isValid = await internalForm.trigger();
if (!isValid) return;
const values = internalForm.getValues();
const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const roleIds = values.roles.map((r) => parseInt(r.id, 10));
@@ -376,24 +357,25 @@ export default function Page() {
setExpiresInDays(parseInt(values.validForHours) / 24); setExpiresInDays(parseInt(values.validForHours) / 24);
} }
setLoading(false);
} }
async function onSubmitGoogleAzure() { async function onSubmitGoogleAzure(
const isValid = await googleAzureForm.trigger(); values: z.infer<typeof googleAzureFormSchema>
if (!isValid) return; ) {
const values = googleAzureForm.getValues();
const selectedUserOption = userOptions.find( const selectedUserOption = userOptions.find(
(opt) => opt.id === selectedOption (opt) => opt.id === selectedOption
); );
if (!selectedUserOption?.idpId) return; if (!selectedUserOption?.idpId) return;
setLoading(true);
const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const res = await api const res = await api
.put(`/org/${orgId}/user`, { .put(`/org/${orgId}/user`, {
username: values.email, username: values.email, // Use email as username for Google/Azure
email: values.email || undefined, email: values.email || undefined,
name: values.name, name: values.name,
type: "oidc", type: "oidc",
@@ -419,19 +401,20 @@ export default function Page() {
}); });
router.push(`/${orgId}/settings/access/users`); router.push(`/${orgId}/settings/access/users`);
} }
setLoading(false);
} }
async function onSubmitGenericOidc() { async function onSubmitGenericOidc(
const isValid = await genericOidcForm.trigger(); values: z.infer<typeof genericOidcFormSchema>
if (!isValid) return; ) {
const values = genericOidcForm.getValues();
const selectedUserOption = userOptions.find( const selectedUserOption = userOptions.find(
(opt) => opt.id === selectedOption (opt) => opt.id === selectedOption
); );
if (!selectedUserOption?.idpId) return; if (!selectedUserOption?.idpId) return;
setLoading(true);
const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const res = await api const res = await api
@@ -462,6 +445,8 @@ export default function Page() {
}); });
router.push(`/${orgId}/settings/access/users`); router.push(`/${orgId}/settings/access/users`);
} }
setLoading(false);
} }
return ( return (
@@ -528,9 +513,9 @@ export default function Page() {
<SettingsSectionForm> <SettingsSectionForm>
<Form {...internalForm}> <Form {...internalForm}>
<form <form
action={ onSubmit={internalForm.handleSubmit(
submitInternalAction onSubmitInternal
} )}
className="space-y-4" className="space-y-4"
id="create-user-form" id="create-user-form"
> >
@@ -610,7 +595,13 @@ export default function Page() {
<OrgRolesTagField <OrgRolesTagField
form={internalForm} form={internalForm}
name="roles" name="roles"
orgId={orgId as string} label={t("roles")}
placeholder={t(
"accessRoleSelect2"
)}
allRoleOptions={
allRoleOptions
}
supportsMultipleRolesPerUser={ supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser supportsMultipleRolesPerUser
} }
@@ -620,6 +611,13 @@ export default function Page() {
paywallMessage={ paywallMessage={
invitePaywallMessage invitePaywallMessage
} }
loading={loading}
activeTagIndex={
activeInviteRoleTagIndex
}
setActiveTagIndex={
setActiveInviteRoleTagIndex
}
/> />
{env.email.emailEnabled && ( {env.email.emailEnabled && (
@@ -714,9 +712,9 @@ export default function Page() {
})() && ( })() && (
<Form {...googleAzureForm}> <Form {...googleAzureForm}>
<form <form
action={ onSubmit={googleAzureForm.handleSubmit(
submitGoogleAzureAction onSubmitGoogleAzure
} )}
className="space-y-4" className="space-y-4"
id="create-user-form" id="create-user-form"
> >
@@ -765,7 +763,13 @@ export default function Page() {
<OrgRolesTagField <OrgRolesTagField
form={googleAzureForm} form={googleAzureForm}
name="roles" name="roles"
orgId={orgId as string} label={t("roles")}
placeholder={t(
"accessRoleSelect2"
)}
allRoleOptions={
allRoleOptions
}
supportsMultipleRolesPerUser={ supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser supportsMultipleRolesPerUser
} }
@@ -775,6 +779,13 @@ export default function Page() {
paywallMessage={ paywallMessage={
invitePaywallMessage invitePaywallMessage
} }
loading={loading}
activeTagIndex={
activeOidcRoleTagIndex
}
setActiveTagIndex={
setActiveOidcRoleTagIndex
}
/> />
</form> </form>
</Form> </Form>
@@ -797,9 +808,9 @@ export default function Page() {
})() && ( })() && (
<Form {...genericOidcForm}> <Form {...genericOidcForm}>
<form <form
action={ onSubmit={genericOidcForm.handleSubmit(
submitGenericOidcAction onSubmitGenericOidc
} )}
className="space-y-4" className="space-y-4"
id="create-user-form" id="create-user-form"
> >
@@ -877,7 +888,13 @@ export default function Page() {
<OrgRolesTagField <OrgRolesTagField
form={genericOidcForm} form={genericOidcForm}
name="roles" name="roles"
orgId={orgId as string} label={t("roles")}
placeholder={t(
"accessRoleSelect2"
)}
allRoleOptions={
allRoleOptions
}
supportsMultipleRolesPerUser={ supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser supportsMultipleRolesPerUser
} }
@@ -887,6 +904,13 @@ export default function Page() {
paywallMessage={ paywallMessage={
invitePaywallMessage invitePaywallMessage
} }
loading={loading}
activeTagIndex={
activeOidcRoleTagIndex
}
setActiveTagIndex={
setActiveOidcRoleTagIndex
}
/> />
</form> </form>
</Form> </Form>

View File

@@ -3,12 +3,10 @@
import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor"; import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor";
import HeaderTitle from "@app/components/SettingsSectionTitle"; import HeaderTitle from "@app/components/SettingsSectionTitle";
import { defaultFormValues } from "@app/lib/alertRuleForm"; import { defaultFormValues } from "@app/lib/alertRuleForm";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useParams, useRouter } from "next/navigation"; import { useParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect } from "react";
export default function NewAlertRulePage() { export default function NewAlertRulePage() {
const params = useParams(); const params = useParams();
@@ -16,19 +14,6 @@ export default function NewAlertRulePage() {
const t = useTranslations(); const t = useTranslations();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.alertingRules); const isPaid = isPaidUser(tierMatrix.alertingRules);
const { env } = useEnvContext();
const router = useRouter();
const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures;
useEffect(() => {
if (disableEnterpriseFeatures) {
router.replace(`/${orgId}/settings/alerting/rules`);
}
}, [disableEnterpriseFeatures, orgId, router]);
if (disableEnterpriseFeatures) {
return null;
}
return ( return (
<> <>

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { RolesSelector } from "@app/components/roles-selector";
import SetResourceHeaderAuthForm from "@app/components/SetResourceHeaderAuthForm"; import SetResourceHeaderAuthForm from "@app/components/SetResourceHeaderAuthForm";
import SetResourcePincodeForm from "@app/components/SetResourcePincodeForm"; import SetResourcePincodeForm from "@app/components/SetResourcePincodeForm";
import { import {
@@ -34,7 +33,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from "@app/components/ui/select"; } from "@app/components/ui/select";
import { UsersSelector } from "@app/components/users-selector";
import type { ResourceContextType } from "@app/contexts/resourceContext"; import type { ResourceContextType } from "@app/contexts/resourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
@@ -182,6 +180,13 @@ export default function ResourceAuthenticationPage() {
return []; return [];
}, [orgIdps]); }, [orgIdps]);
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
number | null
>(null);
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
number | null
>(null);
const [ssoEnabled, setSsoEnabled] = useState(resource.sso ?? false); const [ssoEnabled, setSsoEnabled] = useState(resource.sso ?? false);
useEffect(() => { useEffect(() => {
@@ -492,27 +497,46 @@ export default function ResourceAuthenticationPage() {
{t("roles")} {t("roles")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<RolesSelector <TagInput
selectedRoles={ {...field}
field.value ?? activeTagIndex={
[] activeRolesTagIndex
} }
restrictAdminRole setActiveTagIndex={
orgId={ setActiveRolesTagIndex
org.org
.orgId
} }
onSelectRoles={( placeholder={t(
newUsers "accessRoleSelect2"
)}
size="sm"
tags={
usersRolesForm.getValues()
.roles
}
setTags={(
newRoles
) => { ) => {
usersRolesForm.setValue( usersRolesForm.setValue(
"roles", "roles",
newUsers as [ newRoles as [
Tag, Tag,
...Tag[] ...Tag[]
] ]
); );
}} }}
enableAutocomplete={
true
}
autocompleteOptions={
allRoles
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -533,16 +557,23 @@ export default function ResourceAuthenticationPage() {
{t("users")} {t("users")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<UsersSelector <TagInput
selectedUsers={ {...field}
field.value ?? activeTagIndex={
[] activeUsersTagIndex
} }
orgId={ setActiveTagIndex={
org.org setActiveUsersTagIndex
.orgId
} }
onSelectUsers={( placeholder={t(
"accessUserSelect"
)}
tags={
usersRolesForm.getValues()
.users
}
size="sm"
setTags={(
newUsers newUsers
) => { ) => {
usersRolesForm.setValue( usersRolesForm.setValue(
@@ -553,6 +584,19 @@ export default function ResourceAuthenticationPage() {
] ]
); );
}} }}
enableAutocomplete={
true
}
autocompleteOptions={
allUsers
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />

View File

@@ -681,9 +681,6 @@ export default function PoliciesPage() {
control: form.control, control: form.control,
name: "orgMapping" name: "orgMapping"
}} }}
orgId={
editingPolicy?.orgId || policyFormOrgId
}
roleMappingFieldIdPrefix="admin-idp-policy-role" roleMappingFieldIdPrefix="admin-idp-policy-role"
roleMappingMode={policyRoleMappingMode} roleMappingMode={policyRoleMappingMode}
onRoleMappingModeChange={ onRoleMappingModeChange={

View File

@@ -212,22 +212,16 @@ export const orgNavSections = (
title: "sidebarManagement", title: "sidebarManagement",
icon: <Building2 className="size-4 flex-none" />, icon: <Building2 className="size-4 flex-none" />,
items: [ items: [
...(!env?.flags.disableEnterpriseFeatures
? [
{ {
title: "sidebarAlerting", title: "sidebarAlerting",
href: "/{orgId}/settings/alerting", href: "/{orgId}/settings/alerting",
icon: ( icon: <BellRing className="size-4 flex-none" />
<BellRing className="size-4 flex-none" />
)
}, },
{ {
title: "sidebarProvisioning", title: "sidebarProvisioning",
href: "/{orgId}/settings/provisioning", href: "/{orgId}/settings/provisioning",
icon: <Boxes className="size-4 flex-none" /> icon: <Boxes className="size-4 flex-none" />
} },
]
: []),
{ {
title: "sidebarBluePrints", title: "sidebarBluePrints",
href: "/{orgId}/settings/blueprints", href: "/{orgId}/settings/blueprints",

View File

@@ -134,9 +134,7 @@ export default function AlertingRulesTable({
}: AlertingRulesTableProps) { }: AlertingRulesTableProps) {
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
const envContext = useEnvContext(); const api = createApiClient(useEnvContext());
const api = createApiClient(envContext);
const { env } = envContext;
const [isRefreshing, startRefresh] = useTransition(); const [isRefreshing, startRefresh] = useTransition();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.alertingRules); const isPaid = isPaidUser(tierMatrix.alertingRules);
@@ -428,15 +426,9 @@ export default function AlertingRulesTable({
searchQuery={query} searchQuery={query}
manualFiltering manualFiltering
manualSorting manualSorting
onAdd={ onAdd={() => {
!env.flags.disableEnterpriseFeatures router.push(`/${orgId}/settings/alerting/create`);
? () => { }}
router.push(
`/${orgId}/settings/alerting/create`
);
}
: undefined
}
onRefresh={refreshList} onRefresh={refreshList}
isRefreshing={isRefreshing || isFiltering} isRefreshing={isRefreshing || isFiltering}
addButtonText={t("alertingAddRule")} addButtonText={t("alertingAddRule")}

View File

@@ -47,7 +47,6 @@ type AutoProvisionConfigWidgetProps = {
roleMappingFieldIdPrefix?: string; roleMappingFieldIdPrefix?: string;
showFreeformRoleNamesHint?: boolean; showFreeformRoleNamesHint?: boolean;
autoProvisionSwitchId?: string; autoProvisionSwitchId?: string;
orgId?: string;
}; };
export default function AutoProvisionConfigWidget({ export default function AutoProvisionConfigWidget({
@@ -68,8 +67,7 @@ export default function AutoProvisionConfigWidget({
showAutoProvisionSwitch = true, showAutoProvisionSwitch = true,
roleMappingFieldIdPrefix = "org-idp-auto-provision", roleMappingFieldIdPrefix = "org-idp-auto-provision",
showFreeformRoleNamesHint = false, showFreeformRoleNamesHint = false,
autoProvisionSwitchId = "auto-provision-toggle", autoProvisionSwitchId = "auto-provision-toggle"
orgId
}: AutoProvisionConfigWidgetProps) { }: AutoProvisionConfigWidgetProps) {
const t = useTranslations(); const t = useTranslations();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
@@ -108,7 +106,6 @@ export default function AutoProvisionConfigWidget({
showFreeformRoleNamesHint={ showFreeformRoleNamesHint={
showFreeformRoleNamesHint showFreeformRoleNamesHint
} }
orgId={orgId}
roleMappingMode={roleMappingMode} roleMappingMode={roleMappingMode}
onRoleMappingModeChange={onRoleMappingModeChange} onRoleMappingModeChange={onRoleMappingModeChange}
roles={roles} roles={roles}

View File

@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
return ( return (
<CredenzaContent <CredenzaContent
className={cn( className={cn(
"flex min-h-0 max-h-[100dvh] flex-col overflow-y-auto md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0", "flex min-h-0 max-h-[100dvh] flex-col overflow-hidden md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0",
className className
)} )}
{...props} {...props}

View File

@@ -40,12 +40,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import { ChevronsUpDown, ExternalLink } from "lucide-react";
ArrowDownIcon,
ChevronDownIcon,
ChevronsUpDown,
ExternalLink
} from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -55,13 +50,11 @@ import {
formatMultiSitesSelectorLabel formatMultiSitesSelectorLabel
} from "./multi-site-selector"; } from "./multi-site-selector";
import type { Selectedsite } from "./site-selector"; import type { Selectedsite } from "./site-selector";
import { CaretSortIcon } from "@radix-ui/react-icons";
import { MachinesSelector } from "./machines-selector"; import { MachinesSelector } from "./machines-selector";
import DomainPicker from "@app/components/DomainPicker"; import DomainPicker from "@app/components/DomainPicker";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import CertificateStatus from "@app/components/CertificateStatus"; import CertificateStatus from "@app/components/CertificateStatus";
import { UsersSelector } from "./users-selector";
import { RolesSelector } from "./roles-selector";
import { build } from "@server/build"; import { build } from "@server/build";
// --- Helpers (shared) --- // --- Helpers (shared) ---
@@ -840,16 +833,12 @@ export function InternalResourceForm({
modeCidrKey modeCidrKey
) )
}, },
...(!disableEnterpriseFeatures
? [
{ {
value: "http" as const, value: "http",
label: t( label: t(
modeHttpKey modeHttpKey
) )
} }
]
: [])
]; ];
return ( return (
<FormItem> <FormItem>
@@ -1495,22 +1484,40 @@ export function InternalResourceForm({
<FormItem className="flex flex-col items-start"> <FormItem className="flex flex-col items-start">
<FormLabel>{t("roles")}</FormLabel> <FormLabel>{t("roles")}</FormLabel>
<FormControl> <FormControl>
<RolesSelector <TagInput
selectedRoles={ {...field}
field.value ?? [] activeTagIndex={
activeRolesTagIndex
} }
orgId={orgId} setActiveTagIndex={
onSelectRoles={( setActiveRolesTagIndex
newUsers }
) => { placeholder={t(
"accessRoleSelect2"
)}
size="sm"
tags={
form.getValues()
.roles ?? []
}
setTags={(newRoles) =>
form.setValue( form.setValue(
"roles", "roles",
newUsers as [ newRoles as [
Tag, Tag,
...Tag[] ...Tag[]
] ]
); )
}} }
enableAutocomplete
autocompleteOptions={
allRoles
}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -1523,21 +1530,43 @@ export function InternalResourceForm({
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col items-start"> <FormItem className="flex flex-col items-start">
<FormLabel>{t("users")}</FormLabel> <FormLabel>{t("users")}</FormLabel>
<UsersSelector <FormControl>
selectedUsers={ <TagInput
field.value ?? [] {...field}
activeTagIndex={
activeUsersTagIndex
} }
orgId={orgId} setActiveTagIndex={
onSelectUsers={(newUsers) => { setActiveUsersTagIndex
}
placeholder={t(
"accessUserSelect"
)}
tags={
form.getValues()
.users ?? []
}
size="sm"
setTags={(newUsers) =>
form.setValue( form.setValue(
"users", "users",
newUsers as [ newUsers as [
Tag, Tag,
...Tag[] ...Tag[]
] ]
); )
}} }
enableAutocomplete={true}
autocompleteOptions={
allUsers
}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/> />
</FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -1551,9 +1580,60 @@ export function InternalResourceForm({
<FormLabel> <FormLabel>
{t("machineClients")} {t("machineClients")}
</FormLabel> </FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"justify-between w-full",
"text-muted-foreground pl-1.5"
)}
>
<span
className={cn(
"inline-flex items-center gap-1",
"overflow-x-auto"
)}
>
{(
field.value ??
[]
).map(
(
client
) => (
<span
key={
client.clientId
}
className={cn(
"bg-muted-foreground/20 font-normal text-foreground rounded-sm",
"py-1 px-1.5 text-xs"
)}
>
{
client.name
}
</span>
)
)}
<span className="pl-1 font-normal">
{t(
"accessClientSelect"
)}
</span>
</span>
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0">
<MachinesSelector <MachinesSelector
selectedMachines={ selectedMachines={
field.value ?? [] field.value ??
[]
} }
orgId={orgId} orgId={orgId}
onSelectMachines={( onSelectMachines={(
@@ -1565,6 +1645,8 @@ export function InternalResourceForm({
); );
}} }}
/> />
</PopoverContent>
</Popover>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}

View File

@@ -129,7 +129,9 @@ export function LayoutSidebar({
user.serverAdmin || Boolean(currentOrg?.isOwner || currentOrg?.isAdmin); user.serverAdmin || Boolean(currentOrg?.isOwner || currentOrg?.isAdmin);
const showTrial = const showTrial =
build === "saas" && Boolean(orgId) && subscriptionContext?.isTrial; build === "saas" &&
Boolean(orgId) &&
subscriptionContext?.isTrial;
return ( return (
<div <div
@@ -238,16 +240,11 @@ export function LayoutSidebar({
<div className="px-4"> <div className="px-4">
<ProductUpdates isCollapsed={isSidebarCollapsed} /> <ProductUpdates isCollapsed={isSidebarCollapsed} />
</div> </div>
) : ( ) : <div className="mt-0.2"></div>}
<div className="mt-0.2"></div>
)}
{showTrial && ( {showTrial && (
<div className="px-4"> <div className="px-4">
<ShowTrialCard <ShowTrialCard isCollapsed={isSidebarCollapsed} />
isCollapsed={isSidebarCollapsed}
isOwner={Boolean(currentOrg?.isOwner)}
/>
</div> </div>
)} )}

View File

@@ -40,7 +40,6 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger TooltipTrigger
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import CopyToClipboard from "@app/components/CopyToClipboard";
// Update Resource type to include site information // Update Resource type to include site information
type Resource = { type Resource = {
@@ -65,8 +64,6 @@ type SiteResource = {
destination: string; destination: string;
mode: string; mode: string;
protocol: string | null; protocol: string | null;
ssl: boolean;
fullDomain: string | null;
enabled: boolean; enabled: boolean;
alias: string | null; alias: string | null;
aliasAddress: string | null; aliasAddress: string | null;
@@ -126,7 +123,6 @@ const ResourceFavicon = ({
// Resource Info component // Resource Info component
const ResourceInfo = ({ resource }: { resource: Resource }) => { const ResourceInfo = ({ resource }: { resource: Resource }) => {
const t = useTranslations();
const hasAuthMethods = const hasAuthMethods =
resource.sso || resource.sso ||
resource.password || resource.password ||
@@ -145,9 +141,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
{/* Site Information */} {/* Site Information */}
{resource.siteName && ( {resource.siteName && (
<div> <div>
<div className="text-xs font-medium mb-1.5"> <div className="text-xs font-medium mb-1.5">Site</div>
{t("site")}
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Combine className="h-4 w-4 text-foreground shrink-0" /> <Combine className="h-4 w-4 text-foreground shrink-0" />
<span className="text-sm">{resource.siteName}</span> <span className="text-sm">{resource.siteName}</span>
@@ -163,7 +157,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
} }
> >
<div className="text-xs font-medium mb-1.5"> <div className="text-xs font-medium mb-1.5">
{t("memberPortalAuthMethods")} Authentication Methods
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{resource.sso && ( {resource.sso && (
@@ -172,7 +166,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
<Key className="h-3 w-3 text-blue-700 dark:text-blue-300" /> <Key className="h-3 w-3 text-blue-700 dark:text-blue-300" />
</div> </div>
<span className="text-sm"> <span className="text-sm">
{t("memberPortalSso")} Single Sign-On (SSO)
</span> </span>
</div> </div>
)} )}
@@ -182,7 +176,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
<KeyRound className="h-3 w-3 text-purple-700 dark:text-purple-300" /> <KeyRound className="h-3 w-3 text-purple-700 dark:text-purple-300" />
</div> </div>
<span className="text-sm"> <span className="text-sm">
{t("memberPortalPasswordProtected")} Password Protected
</span> </span>
</div> </div>
)} )}
@@ -191,9 +185,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-emerald-50/50 dark:bg-emerald-950/50"> <div className="h-5 w-5 rounded-full flex items-center justify-center bg-emerald-50/50 dark:bg-emerald-950/50">
<Fingerprint className="h-3 w-3 text-emerald-700 dark:text-emerald-300" /> <Fingerprint className="h-3 w-3 text-emerald-700 dark:text-emerald-300" />
</div> </div>
<span className="text-sm"> <span className="text-sm">PIN Code</span>
{t("memberPortalPinCode")}
</span>
</div> </div>
)} )}
{resource.whitelist && ( {resource.whitelist && (
@@ -201,9 +193,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-amber-50/50 dark:bg-amber-950/50"> <div className="h-5 w-5 rounded-full flex items-center justify-center bg-amber-50/50 dark:bg-amber-950/50">
<AtSign className="h-3 w-3 text-amber-700 dark:text-amber-300" /> <AtSign className="h-3 w-3 text-amber-700 dark:text-amber-300" />
</div> </div>
<span className="text-sm"> <span className="text-sm">Email Whitelist</span>
{t("memberPortalEmailWhitelist")}
</span>
</div> </div>
)} )}
</div> </div>
@@ -218,7 +208,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-destructive shrink-0" /> <AlertCircle className="h-4 w-4 text-destructive shrink-0" />
<span className="text-sm text-destructive"> <span className="text-sm text-destructive">
{t("memberPortalResourceDisabled")} Resource Disabled
</span> </span>
</div> </div>
</div> </div>
@@ -243,7 +233,6 @@ const PaginationControls = ({
totalItems: number; totalItems: number;
itemsPerPage: number; itemsPerPage: number;
}) => { }) => {
const t = useTranslations();
const startItem = (currentPage - 1) * itemsPerPage + 1; const startItem = (currentPage - 1) * itemsPerPage + 1;
const endItem = Math.min(currentPage * itemsPerPage, totalItems); const endItem = Math.min(currentPage * itemsPerPage, totalItems);
@@ -252,11 +241,7 @@ const PaginationControls = ({
return ( return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-8"> <div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-8">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{t("memberPortalShowingResources", { Showing {startItem}-{endItem} of {totalItems} resources
start: startItem,
end: endItem,
total: totalItems
})}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -268,7 +253,7 @@ const PaginationControls = ({
className="gap-1" className="gap-1"
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
{t("memberPortalPrevious")} Previous
</Button> </Button>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -324,7 +309,7 @@ const PaginationControls = ({
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="gap-1" className="gap-1"
> >
{t("memberPortalNext")} Next
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -404,11 +389,13 @@ export default function MemberResourcesPortal({
response.data.data.siteResources || [] response.data.data.siteResources || []
); );
} else { } else {
setError(t("memberPortalFailedToLoad")); setError("Failed to load resources");
} }
} catch (err) { } catch (err) {
console.error("Error fetching user resources:", err); console.error("Error fetching user resources:", err);
setError(t("memberPortalFailedToLoadDescription")); setError(
"Failed to load resources. Please check your connection and try again."
);
} finally { } finally {
setLoading(false); setLoading(false);
setRefreshing(false); setRefreshing(false);
@@ -539,8 +526,8 @@ export default function MemberResourcesPortal({
return ( return (
<div className="container mx-auto max-w-12xl"> <div className="container mx-auto max-w-12xl">
<SettingsSectionTitle <SettingsSectionTitle
title={t("memberPortalTitle")} title="Resources"
description={t("memberPortalDescription")} description="Resources you have access to in this organization"
/> />
{/* Search and Sort Controls - Skeleton */} {/* Search and Sort Controls - Skeleton */}
@@ -567,8 +554,8 @@ export default function MemberResourcesPortal({
return ( return (
<div className="container mx-auto max-w-12xl"> <div className="container mx-auto max-w-12xl">
<SettingsSectionTitle <SettingsSectionTitle
title={t("memberPortalTitle")} title="Resources"
description={t("memberPortalDescription")} description="Resources you have access to in this organization"
/> />
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-20 text-center"> <CardContent className="flex flex-col items-center justify-center py-20 text-center">
@@ -576,7 +563,7 @@ export default function MemberResourcesPortal({
<AlertCircle className="h-16 w-16 text-destructive/60" /> <AlertCircle className="h-16 w-16 text-destructive/60" />
</div> </div>
<h3 className="text-xl font-semibold text-foreground mb-3"> <h3 className="text-xl font-semibold text-foreground mb-3">
{t("memberPortalUnableToLoad")} Unable to Load Resources
</h3> </h3>
<p className="text-muted-foreground max-w-lg text-base mb-6"> <p className="text-muted-foreground max-w-lg text-base mb-6">
{error} {error}
@@ -587,7 +574,7 @@ export default function MemberResourcesPortal({
className="gap-2" className="gap-2"
> >
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
{t("memberPortalTryAgain")} Try Again
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -598,8 +585,8 @@ export default function MemberResourcesPortal({
return ( return (
<div className="container mx-auto max-w-12xl"> <div className="container mx-auto max-w-12xl">
<SettingsSectionTitle <SettingsSectionTitle
title={t("memberPortalTitle")} title="Resources"
description={t("memberPortalDescription")} description="Resources you have access to in this organization"
/> />
{/* Search and Sort Controls with Refresh */} {/* Search and Sort Controls with Refresh */}
@@ -608,7 +595,7 @@ export default function MemberResourcesPortal({
{/* Search */} {/* Search */}
<div className="relative w-full sm:w-80"> <div className="relative w-full sm:w-80">
<Input <Input
placeholder={t("resourcesSearch")} placeholder="Search resources..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 bg-card" className="w-full pl-8 bg-card"
@@ -620,28 +607,26 @@ export default function MemberResourcesPortal({
<div className="w-full sm:w-36"> <div className="w-full sm:w-36">
<Select value={sortBy} onValueChange={setSortBy}> <Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="bg-card"> <SelectTrigger className="bg-card">
<SelectValue <SelectValue placeholder="Sort by..." />
placeholder={t("memberPortalSortBy")}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="name-asc"> <SelectItem value="name-asc">
{t("memberPortalSortNameAsc")} Name A-Z
</SelectItem> </SelectItem>
<SelectItem value="name-desc"> <SelectItem value="name-desc">
{t("memberPortalSortNameDesc")} Name Z-A
</SelectItem> </SelectItem>
<SelectItem value="domain-asc"> <SelectItem value="domain-asc">
{t("memberPortalSortDomainAsc")} Domain A-Z
</SelectItem> </SelectItem>
<SelectItem value="domain-desc"> <SelectItem value="domain-desc">
{t("memberPortalSortDomainDesc")} Domain Z-A
</SelectItem> </SelectItem>
<SelectItem value="status-enabled"> <SelectItem value="status-enabled">
{t("memberPortalSortEnabledFirst")} Enabled First
</SelectItem> </SelectItem>
<SelectItem value="status-disabled"> <SelectItem value="status-disabled">
{t("memberPortalSortDisabledFirst")} Disabled First
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -659,7 +644,7 @@ export default function MemberResourcesPortal({
<RefreshCw <RefreshCw
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`} className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
/> />
{t("memberPortalRefresh")} Refresh
</Button> </Button>
</div> </div>
@@ -678,15 +663,13 @@ export default function MemberResourcesPortal({
</div> </div>
<h3 className="text-2xl font-semibold text-foreground mb-3"> <h3 className="text-2xl font-semibold text-foreground mb-3">
{searchQuery {searchQuery
? t("memberPortalNoResourcesFound") ? "No Resources Found"
: t("memberPortalNoResourcesAvailable")} : "No Resources Available"}
</h3> </h3>
<p className="text-muted-foreground max-w-lg text-base mb-6"> <p className="text-muted-foreground max-w-lg text-base mb-6">
{searchQuery {searchQuery
? t("memberPortalNoResourcesMatchSearch", { ? `No resources match "${searchQuery}". Try adjusting your search terms or clearing the search to see all resources.`
query: searchQuery : "You don't have access to any resources yet. Contact your administrator to get access to resources you need."}
})
: t("memberPortalNoResourcesAccess")}
</p> </p>
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3">
{searchQuery ? ( {searchQuery ? (
@@ -695,7 +678,7 @@ export default function MemberResourcesPortal({
variant="outline" variant="outline"
className="gap-2" className="gap-2"
> >
{t("memberPortalClearSearch")} Clear Search
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -707,7 +690,7 @@ export default function MemberResourcesPortal({
<RefreshCw <RefreshCw
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`} className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
/> />
{t("memberPortalRefreshResources")} Refresh Resources
</Button> </Button>
)} )}
</div> </div>
@@ -721,12 +704,11 @@ export default function MemberResourcesPortal({
<div className="mb-4"> <div className="mb-4">
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2"> <h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Globe className="h-5 w-5" /> <Globe className="h-5 w-5" />
{t("memberPortalPublicResources")} Public Resources
</h3> </h3>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
{t( Web applications and services accessible via
"memberPortalPublicResourcesDescription" browser
)}
</p> </p>
</div> </div>
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8"> <div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8">
@@ -786,12 +768,9 @@ export default function MemberResourcesPortal({
resource.domain resource.domain
); );
toast({ toast({
title: t( title: "Copied to clipboard",
"memberPortalCopiedToClipboard" description:
), "Resource URL has been copied to your clipboard.",
description: t(
"memberPortalCopiedUrlDescription"
),
duration: 2000 duration: 2000
}); });
}} }}
@@ -812,7 +791,7 @@ export default function MemberResourcesPortal({
disabled={!resource.enabled} disabled={!resource.enabled}
> >
<ExternalLink className="h-3.5 w-3.5 mr-2" /> <ExternalLink className="h-3.5 w-3.5 mr-2" />
{t("memberPortalOpenResource")} Open Resource
</Button> </Button>
</div> </div>
</Card> </Card>
@@ -827,12 +806,11 @@ export default function MemberResourcesPortal({
<div className="mb-4"> <div className="mb-4">
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2"> <h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Combine className="h-5 w-5" /> <Combine className="h-5 w-5" />
{t("memberPortalPrivateResources")} Private Resources
</h3> </h3>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
{t( Internal network resources accessible via
"memberPortalPrivateResourcesDescription" client
)}
</p> </p>
</div> </div>
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8"> <div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8">
@@ -865,16 +843,11 @@ export default function MemberResourcesPortal({
<InfoPopup> <InfoPopup>
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="text-xs font-medium mb-1.5"> <div className="text-xs font-medium mb-1.5">
{t( Resource Details
"memberPortalResourceDetails"
)}
</div> </div>
<div> <div>
<span className="font-medium"> <span className="font-medium">
{t( Mode:
"memberPortalMode"
)}
:
</span> </span>
<span className="ml-2 text-muted-foreground capitalize"> <span className="ml-2 text-muted-foreground capitalize">
{ {
@@ -885,10 +858,7 @@ export default function MemberResourcesPortal({
{siteResource.protocol && ( {siteResource.protocol && (
<div> <div>
<span className="font-medium"> <span className="font-medium">
{t( Protocol:
"protocol"
)}
:
</span> </span>
<span className="ml-2 text-muted-foreground uppercase"> <span className="ml-2 text-muted-foreground uppercase">
{ {
@@ -899,10 +869,7 @@ export default function MemberResourcesPortal({
)} )}
<div> <div>
<span className="font-medium"> <span className="font-medium">
{t( Destination:
"memberPortalDestination"
)}
:
</span> </span>
<span className="ml-2 text-muted-foreground"> <span className="ml-2 text-muted-foreground">
{ {
@@ -913,10 +880,7 @@ export default function MemberResourcesPortal({
{siteResource.alias && ( {siteResource.alias && (
<div> <div>
<span className="font-medium"> <span className="font-medium">
{t( Alias:
"memberPortalAlias"
)}
:
</span> </span>
<span className="ml-2 text-muted-foreground"> <span className="ml-2 text-muted-foreground">
{ {
@@ -927,21 +891,14 @@ export default function MemberResourcesPortal({
)} )}
<div> <div>
<span className="font-medium"> <span className="font-medium">
{t( Status:
"status"
)}
:
</span> </span>
<span <span
className={`ml-2 ${siteResource.enabled ? "text-green-600" : "text-red-600"}`} className={`ml-2 ${siteResource.enabled ? "text-green-600" : "text-red-600"}`}
> >
{siteResource.enabled {siteResource.enabled
? t( ? "Enabled"
"enabled" : "Disabled"}
)
: t(
"disabled"
)}
</span> </span>
</div> </div>
</div> </div>
@@ -950,14 +907,7 @@ export default function MemberResourcesPortal({
</div> </div>
<div className="mt-3"> <div className="mt-3">
{siteResource.mode === "http" && {siteResource.alias ? (
siteResource.fullDomain ? (
/* HTTP mode - show as clickable link */
<CopyToClipboard
text={`${siteResource.ssl ? "https" : (siteResource.protocol ?? "http")}://${siteResource.fullDomain}`}
isLink={true}
/>
) : siteResource.alias ? (
<> <>
{/* Alias as primary */} {/* Alias as primary */}
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
@@ -975,13 +925,9 @@ export default function MemberResourcesPortal({
siteResource.alias! siteResource.alias!
); );
toast({ toast({
title: t( title: "Copied to clipboard",
"memberPortalCopiedToClipboard"
),
description: description:
t( "Resource alias has been copied to your clipboard.",
"memberPortalCopiedAliasDescription"
),
duration: 2000 duration: 2000
}); });
}} }}
@@ -1013,13 +959,9 @@ export default function MemberResourcesPortal({
siteResource.destination siteResource.destination
); );
toast({ toast({
title: t( title: "Copied to clipboard",
"memberPortalCopiedToClipboard"
),
description: description:
t( "Resource destination has been copied to your clipboard.",
"memberPortalCopiedDestinationDescription"
),
duration: 2000 duration: 2000
}); });
}} }}
@@ -1031,34 +973,10 @@ export default function MemberResourcesPortal({
</div> </div>
</div> </div>
<div className="p-6 pt-0 mt-auto space-y-2"> <div className="p-6 pt-0 mt-auto">
{siteResource.mode === "http" &&
siteResource.fullDomain ? (
<Button
onClick={() =>
window.open(
`${siteResource.ssl ? "https" : (siteResource.protocol ?? "http")}://${siteResource.fullDomain}`,
"_blank"
)
}
className="w-full h-9"
variant="outline"
size="sm"
disabled={
!siteResource.enabled
}
>
<ExternalLink className="h-3.5 w-3.5 mr-2" />
{t(
"memberPortalOpenResource"
)}
</Button>
) : null}
<div className="flex items-center justify-center py-2 px-4 bg-muted/50 rounded text-sm text-muted-foreground"> <div className="flex items-center justify-center py-2 px-4 bg-muted/50 rounded text-sm text-muted-foreground">
<Combine className="h-3.5 w-3.5 mr-2" /> <Combine className="h-3.5 w-3.5 mr-2" />
{t( Requires Client Connection
"memberPortalRequiresClientConnection"
)}
</div> </div>
</div> </div>
</Card> </Card>

View File

@@ -8,42 +8,51 @@ import {
FormLabel, FormLabel,
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type { Dispatch, SetStateAction } from "react";
import type { FieldValues, Path, UseFormReturn } from "react-hook-form"; import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
import { RolesSelector, type SelectedRole } from "./roles-selector";
export type RoleTag = {
id: string;
text: string;
};
type OrgRolesTagFieldProps<TFieldValues extends FieldValues> = { type OrgRolesTagFieldProps<TFieldValues extends FieldValues> = {
form: Pick< form: Pick<UseFormReturn<TFieldValues>, "control" | "getValues" | "setValue">;
UseFormReturn<TFieldValues>,
"control" | "getValues" | "setValue"
>;
orgId: string;
/** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */ /** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */
name?: Path<TFieldValues>; name?: Path<TFieldValues>;
label?: string; label: string;
placeholder: string;
allRoleOptions: Tag[];
supportsMultipleRolesPerUser: boolean; supportsMultipleRolesPerUser: boolean;
showMultiRolePaywallMessage: boolean; showMultiRolePaywallMessage: boolean;
paywallMessage: string; paywallMessage: string;
disabled?: boolean; loading?: boolean;
activeTagIndex: number | null;
setActiveTagIndex: Dispatch<SetStateAction<number | null>>;
}; };
export default function OrgRolesTagField<TFieldValues extends FieldValues>({ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
form, form,
name = "roles" as Path<TFieldValues>, name = "roles" as Path<TFieldValues>,
label, label,
orgId, placeholder,
allRoleOptions,
supportsMultipleRolesPerUser, supportsMultipleRolesPerUser,
showMultiRolePaywallMessage, showMultiRolePaywallMessage,
paywallMessage, paywallMessage,
disabled loading = false,
activeTagIndex,
setActiveTagIndex
}: OrgRolesTagFieldProps<TFieldValues>) { }: OrgRolesTagFieldProps<TFieldValues>) {
const t = useTranslations(); const t = useTranslations();
function setRoleTags(nextValue: SelectedRole[]) { function setRoleTags(updater: Tag[] | ((prev: Tag[]) => Tag[])) {
const prev = form.getValues(name) as SelectedRole[]; const prev = form.getValues(name) as Tag[];
const nextValue =
typeof updater === "function" ? updater(prev) : updater;
const next = supportsMultipleRolesPerUser const next = supportsMultipleRolesPerUser
? nextValue ? nextValue
: nextValue.length > 1 : nextValue.length > 1
@@ -79,13 +88,22 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
name={name} name={name}
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col items-start"> <FormItem className="flex flex-col items-start">
<FormLabel>{label ?? t("roles")}</FormLabel> <FormLabel>{label}</FormLabel>
<FormControl> <FormControl>
<RolesSelector <TagInput
orgId={orgId} {...field}
selectedRoles={field.value ?? []} activeTagIndex={activeTagIndex}
onSelectRoles={setRoleTags} setActiveTagIndex={setActiveTagIndex}
disabled={disabled} placeholder={placeholder}
size="sm"
tags={field.value}
setTags={setRoleTags}
enableAutocomplete={true}
autocompleteOptions={allRoleOptions}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
disabled={loading}
/> />
</FormControl> </FormControl>
{showMultiRolePaywallMessage && ( {showMultiRolePaywallMessage && (

View File

@@ -16,7 +16,6 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { build } from "@server/build"; import { build } from "@server/build";
import { RolesSelector } from "./roles-selector";
export type RoleMappingRoleOption = { export type RoleMappingRoleOption = {
roleId: number; roleId: number;
@@ -39,8 +38,6 @@ export type RoleMappingConfigFieldsProps = {
fieldIdPrefix?: string; fieldIdPrefix?: string;
/** When true, show extra hint for global default policies (no org role list). */ /** When true, show extra hint for global default policies (no org role list). */
showFreeformRoleNamesHint?: boolean; showFreeformRoleNamesHint?: boolean;
/** Org ID to use for role lookup. Falls back to URL params when not provided. */
orgId?: string;
}; };
export default function RoleMappingConfigFields({ export default function RoleMappingConfigFields({
@@ -56,12 +53,14 @@ export default function RoleMappingConfigFields({
rawExpression, rawExpression,
onRawExpressionChange, onRawExpressionChange,
fieldIdPrefix = "role-mapping", fieldIdPrefix = "role-mapping",
showFreeformRoleNamesHint = false, showFreeformRoleNamesHint = false
orgId
}: RoleMappingConfigFieldsProps) { }: RoleMappingConfigFieldsProps) {
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext(); const { env } = useEnvContext();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState<
number | null
>(null);
const supportsMultipleRolesPerUser = isPaidUser(tierMatrix.fullRbac); const supportsMultipleRolesPerUser = isPaidUser(tierMatrix.fullRbac);
const showSingleRoleDisclaimer = const showSingleRoleDisclaimer =
@@ -95,10 +94,6 @@ export default function RoleMappingConfigFields({
} }
}, [supportsMultipleRolesPerUser, fixedRoleNames, onFixedRoleNamesChange]); }, [supportsMultipleRolesPerUser, fixedRoleNames, onFixedRoleNamesChange]);
const [fixedRolesActiveTagIndex, setFixedRolesActiveTagIndex] = useState<
number | null
>(null);
const fixedRadioId = `${fieldIdPrefix}-fixed-roles-mode`; const fixedRadioId = `${fieldIdPrefix}-fixed-roles-mode`;
const builderRadioId = `${fieldIdPrefix}-mapping-builder-mode`; const builderRadioId = `${fieldIdPrefix}-mapping-builder-mode`;
const rawRadioId = `${fieldIdPrefix}-raw-expression-mode`; const rawRadioId = `${fieldIdPrefix}-raw-expression-mode`;
@@ -165,53 +160,19 @@ export default function RoleMappingConfigFields({
{roleMappingMode === "fixedRoles" && ( {roleMappingMode === "fixedRoles" && (
<div className="space-y-2 min-w-0 max-w-full"> <div className="space-y-2 min-w-0 max-w-full">
{restrictToOrgRoles ? (
<RolesSelector
selectedRoles={fixedRoleNames.map((name) => ({
id: name,
text: name
}))}
mapRolesByName
orgId={orgId as string}
onSelectRoles={(nextTags) => {
let names = [
...new Set(nextTags.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);
}}
/>
) : (
<TagInput <TagInput
tags={fixedRoleNames.map((name) => ({ tags={fixedRoleNames.map((name) => ({
id: name, id: name,
text: name text: name
}))} }))}
setTags={(nextTags) => { setTags={(nextTags) => {
const prev = fixedRoleNames.map((name) => ({ const prevTags = fixedRoleNames.map((name) => ({
id: name, id: name,
text: name text: name
})); }));
const next = const next =
typeof nextTags === "function" typeof nextTags === "function"
? nextTags(prev) ? nextTags(prevTags)
: nextTags; : nextTags;
let names = [ let names = [
@@ -237,22 +198,20 @@ export default function RoleMappingConfigFields({
onFixedRoleNamesChange(names); onFixedRoleNamesChange(names);
}} }}
activeTagIndex={fixedRolesActiveTagIndex} activeTagIndex={activeFixedRoleTagIndex}
setActiveTagIndex={setFixedRolesActiveTagIndex} setActiveTagIndex={setActiveFixedRoleTagIndex}
placeholder={t( placeholder={
"roleMappingAssignRolesPlaceholderFreeform" restrictToOrgRoles
)} ? t("roleMappingFixedRolesPlaceholderSelect")
enableAutocomplete={false} : t("roleMappingFixedRolesPlaceholderFreeform")
}
enableAutocomplete={restrictToOrgRoles}
autocompleteOptions={roleOptions} autocompleteOptions={roleOptions}
restrictTagsToAutocompleteOptions={false} restrictTagsToAutocompleteOptions={restrictToOrgRoles}
allowDuplicates={false} allowDuplicates={false}
sortTags={true} sortTags={true}
size="sm" size="sm"
styleClasses={{
inlineTagsContainer: "min-w-0 max-w-full"
}}
/> />
)}
<FormDescription> <FormDescription>
{showFreeformRoleNamesHint {showFreeformRoleNamesHint
? t("roleMappingFixedRolesDescriptionDefaultPolicy") ? t("roleMappingFixedRolesDescriptionDefaultPolicy")
@@ -302,7 +261,6 @@ export default function RoleMappingConfigFields({
showFreeformRoleNamesHint={ showFreeformRoleNamesHint={
showFreeformRoleNamesHint showFreeformRoleNamesHint
} }
orgId={orgId}
supportsMultipleRolesPerUser={ supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser supportsMultipleRolesPerUser
} }
@@ -379,8 +337,7 @@ function BuilderRuleRow({
supportsMultipleRolesPerUser, supportsMultipleRolesPerUser,
showRemoveButton, showRemoveButton,
onChange, onChange,
onRemove, onRemove
orgId
}: { }: {
rule: MappingBuilderRule; rule: MappingBuilderRule;
roleOptions: Tag[]; roleOptions: Tag[];
@@ -392,7 +349,6 @@ function BuilderRuleRow({
showRemoveButton: boolean; showRemoveButton: boolean;
onChange: (rule: MappingBuilderRule) => void; onChange: (rule: MappingBuilderRule) => void;
onRemove: () => void; onRemove: () => void;
orgId?: string;
}) { }) {
const t = useTranslations(); const t = useTranslations();
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null); const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
@@ -422,59 +378,16 @@ function BuilderRuleRow({
{t("roleMappingAssignRoles")} {t("roleMappingAssignRoles")}
</FormLabel> </FormLabel>
<div className="min-w-0 max-w-full"> <div className="min-w-0 max-w-full">
{restrictToOrgRoles ? (
<RolesSelector
selectedRoles={rule.roleNames.map((name) => ({
id: name,
text: name
}))}
buttonText={t("roleMappingAssignRoles")}
mapRolesByName
orgId={orgId as string}
onSelectRoles={(nextTags) => {
let names = [
...new Set(nextTags.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
});
}}
/>
) : (
<TagInput <TagInput
tags={rule.roleNames.map((name) => ({ tags={rule.roleNames.map((name) => ({
id: name, id: name,
text: name text: name
}))} }))}
setTags={(nextTags) => { setTags={(nextTags) => {
const prevRoleTags = rule.roleNames.map( const prevRoleTags = rule.roleNames.map((name) => ({
(name) => ({
id: name, id: name,
text: name text: name
}) }));
);
const next = const next =
typeof nextTags === "function" typeof nextTags === "function"
? nextTags(prevRoleTags) ? nextTags(prevRoleTags)
@@ -511,12 +424,14 @@ function BuilderRuleRow({
}} }}
activeTagIndex={activeTagIndex} activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex} setActiveTagIndex={setActiveTagIndex}
placeholder={t( placeholder={
"roleMappingAssignRolesPlaceholderFreeform" restrictToOrgRoles
)} ? t("roleMappingAssignRoles")
enableAutocomplete={false} : t("roleMappingAssignRolesPlaceholderFreeform")
}
enableAutocomplete={restrictToOrgRoles}
autocompleteOptions={roleOptions} autocompleteOptions={roleOptions}
restrictTagsToAutocompleteOptions={false} restrictTagsToAutocompleteOptions={restrictToOrgRoles}
allowDuplicates={false} allowDuplicates={false}
sortTags={true} sortTags={true}
size="sm" size="sm"
@@ -524,7 +439,6 @@ function BuilderRuleRow({
inlineTagsContainer: "min-w-0 max-w-full" inlineTagsContainer: "min-w-0 max-w-full"
}} }}
/> />
)}
</div> </div>
{showFreeformRoleNamesHint && ( {showFreeformRoleNamesHint && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

@@ -17,11 +17,9 @@ import { useTranslations } from "next-intl";
const TRIAL_DURATION_DAYS = 10; const TRIAL_DURATION_DAYS = 10;
export default function ShowTrialCard({ export default function ShowTrialCard({
isCollapsed, isCollapsed
isOwner = false
}: { }: {
isCollapsed?: boolean; isCollapsed?: boolean;
isOwner?: boolean;
}) { }) {
const context = useSubscriptionStatusContext(); const context = useSubscriptionStatusContext();
const params = useParams(); const params = useParams();
@@ -34,55 +32,53 @@ export default function ShowTrialCard({
const now = Date.now(); const now = Date.now();
const remainingMs = trialExpiresAt - now; const remainingMs = trialExpiresAt - now;
const remainingDays = Math.max( const remainingDays = Math.max(0, Math.ceil(remainingMs / (1000 * 60 * 60 * 24)));
0,
Math.ceil(remainingMs / (1000 * 60 * 60 * 24))
);
const totalMs = TRIAL_DURATION_DAYS * 24 * 60 * 60 * 1000; const totalMs = TRIAL_DURATION_DAYS * 24 * 60 * 60 * 1000;
const progressPct = Math.min( const progressPct = Math.min(100, Math.max(0, ((now - (trialExpiresAt - totalMs)) / totalMs) * 100));
100,
Math.max(0, ((now - (trialExpiresAt - totalMs)) / totalMs) * 100)
);
// Inverted: full bar at start, drains to empty as trial ends // Inverted: full bar at start, drains to empty as trial ends
const displayPct = 100 - progressPct; const displayPct = 100 - progressPct;
const billingHref = orgId ? `/${orgId}/settings/billing` : "/"; const billingHref = orgId ? `/${orgId}/settings/billing` : "/";
if (isCollapsed) { if (isCollapsed) {
const icon = ( return (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="flex items-center justify-center rounded-md p-2 text-muted-foreground"> <Link
href={billingHref}
className="flex items-center justify-center rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 transition-colors"
>
<ClockIcon className="h-4 w-4 flex-none" /> <ClockIcon className="h-4 w-4 flex-none" />
</span> </Link>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" sideOffset={8}> <TooltipContent side="right" sideOffset={8}>
<p> <p>
{remainingDays === 0 {remainingDays === 0
? t("trialExpired") ? t("trialExpired")
: t("trialDaysLeftShort", { : t("trialDaysLeftShort", { days: remainingDays })}
days: remainingDays
})}
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
); );
if (isOwner) {
return <Link href={billingHref}>{icon}</Link>;
} }
return icon; return (
} <Link
href={billingHref}
const cardContent = ( className={cn(
<> "group cursor-pointer block",
"rounded-md border bg-secondary p-2 py-3 w-full flex flex-col gap-2 text-sm",
"transition duration-200 ease-in-out hover:bg-secondary/80 dark:hover:bg-secondary/60"
)}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ClockIcon className="flex-none size-4 text-muted-foreground" /> <ClockIcon className="flex-none size-4 text-muted-foreground" />
<p className="font-medium flex-1 leading-tight"> <p className="font-medium flex-1 leading-tight">
{remainingDays === 0 ? t("trialExpired") : t("trialActive")} {remainingDays === 0
? t("trialExpired")
: t("trialActive")}
</p> </p>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@@ -92,37 +88,11 @@ export default function ShowTrialCard({
? t("trialHasEnded") ? t("trialHasEnded")
: t("trialDaysRemaining", { count: remainingDays })} : t("trialDaysRemaining", { count: remainingDays })}
</small> </small>
{isOwner && ( <div className="inline-flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground transition-colors">
<div className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<span>{t("trialGoToBilling")}</span> <span>{t("trialGoToBilling")}</span>
<ArrowRight className="flex-none size-3" /> <ArrowRight className="flex-none size-3" />
</div> </div>
)}
</div> </div>
</>
);
if (isOwner) {
return (
<Link
href={billingHref}
className={cn(
"group cursor-pointer block",
"rounded-md border bg-secondary p-2 py-3 w-full flex flex-col gap-2 text-sm"
)}
>
{cardContent}
</Link> </Link>
); );
}
return (
<div
className={cn(
"rounded-md border bg-secondary p-2 py-3 w-full flex flex-col gap-2 text-sm"
)}
>
{cardContent}
</div>
);
} }

View File

@@ -1,5 +1,18 @@
"use client"; "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 { import {
Credenza, Credenza,
CredenzaBody, CredenzaBody,
@@ -10,32 +23,18 @@ import {
CredenzaHeader, CredenzaHeader,
CredenzaTitle CredenzaTitle
} from "@app/components/Credenza"; } 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 { Input } from "@app/components/ui/input";
import { Label } from "@app/components/ui/label"; import { Label } from "@app/components/ui/label";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { TagInput, type Tag } from "@app/components/tags/tag-input";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; 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 { orgQueries } from "@app/lib/queries";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; 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 { 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 { interface UptimeAlertSectionProps {
orgId: string; orgId: string;
@@ -53,12 +52,10 @@ export default function UptimeAlertSection({
days = 90 days = 90
}: UptimeAlertSectionProps) { }: UptimeAlertSectionProps) {
const t = useTranslations(); const t = useTranslations();
const envContext = useEnvContext(); const api = createApiClient(useEnvContext());
const api = createApiClient(envContext);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.alertingRules); const isPaid = isPaidUser(tierMatrix.alertingRules);
const { env } = envContext;
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [name, setName] = useState( const [name, setName] = useState(
@@ -67,7 +64,12 @@ export default function UptimeAlertSection({
const [userTags, setUserTags] = useState<Tag[]>([]); const [userTags, setUserTags] = useState<Tag[]>([]);
const [roleTags, setRoleTags] = useState<Tag[]>([]); const [roleTags, setRoleTags] = useState<Tag[]>([]);
const [emailTags, setEmailTags] = useState<Tag[]>([]); const [emailTags, setEmailTags] = useState<Tag[]>([]);
const [activeUserTagIndex, setActiveUserTagIndex] = useState<number | null>(
null
);
const [activeRoleTagIndex, setActiveRoleTagIndex] = useState<number | null>(
null
);
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
number | null number | null
>(null); >(null);
@@ -78,6 +80,27 @@ export default function UptimeAlertSection({
enabled: isPaid 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; const hasRules = (alertRules?.length ?? 0) > 0;
async function handleSubmit() { async function handleSubmit() {
@@ -178,9 +201,7 @@ export default function UptimeAlertSection({
{t("uptimeSectionDescription", { days })} {t("uptimeSectionDescription", { days })}
</SettingsSectionDescription> </SettingsSectionDescription>
</div> </div>
{!env.flags.disableEnterpriseFeatures {alertButton}
? alertButton
: null}
</div> </div>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
@@ -206,16 +227,10 @@ export default function UptimeAlertSection({
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
<div className="space-y-4"> <div className="space-y-4">
<PaidFeaturesAlert <PaidFeaturesAlert tiers={tierMatrix.alertingRules} />
tiers={tierMatrix.alertingRules}
/>
<fieldset <fieldset
disabled={!isPaid} disabled={!isPaid}
className={ className={!isPaid ? "opacity-50 pointer-events-none" : ""}
!isPaid
? "opacity-50 pointer-events-none"
: ""
}
> >
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -225,53 +240,65 @@ export default function UptimeAlertSection({
<Input <Input
id="alert-name" id="alert-name"
value={name} value={name}
onChange={(e) => onChange={(e) => setName(e.target.value)}
setName(e.target.value) placeholder={t("uptimeAlertNamePlaceholder")}
}
placeholder={t(
"uptimeAlertNamePlaceholder"
)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label> <Label>{t("alertingNotifyUsers")}</Label>
{t("alertingNotifyUsers")} <TagInput
</Label> activeTagIndex={activeUserTagIndex}
<UsersSelector setActiveTagIndex={setActiveUserTagIndex}
selectedUsers={userTags} placeholder={t("alertingSelectUsers")}
orgId={orgId} size="sm"
onSelectUsers={setUserTags} tags={userTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(userTags)
: newTags;
setUserTags(next as Tag[]);
}}
enableAutocomplete
autocompleteOptions={allUsers}
restrictTagsToAutocompleteOptions
allowDuplicates={false}
sortTags
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label> <Label>{t("alertingNotifyRoles")}</Label>
{t("alertingNotifyRoles")} <TagInput
</Label> activeTagIndex={activeRoleTagIndex}
<RolesSelector setActiveTagIndex={setActiveRoleTagIndex}
selectedRoles={roleTags} placeholder={t("alertingSelectRoles")}
restrictAdminRole size="sm"
orgId={orgId} tags={roleTags}
onSelectRoles={setRoleTags} setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(roleTags)
: newTags;
setRoleTags(next as Tag[]);
}}
enableAutocomplete
autocompleteOptions={allRoles}
restrictTagsToAutocompleteOptions
allowDuplicates={false}
sortTags
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label> <Label>{t("uptimeAdditionalEmails")}</Label>
{t("uptimeAdditionalEmails")}
</Label>
<TagInput <TagInput
activeTagIndex={activeEmailTagIndex} activeTagIndex={activeEmailTagIndex}
setActiveTagIndex={ setActiveTagIndex={setActiveEmailTagIndex}
setActiveEmailTagIndex placeholder={t("alertingEmailPlaceholder")}
}
placeholder={t(
"alertingEmailPlaceholder"
)}
size="sm" size="sm"
tags={emailTags} tags={emailTags}
setTags={(newTags) => { setTags={(newTags) => {
const next = const next =
typeof newTags === typeof newTags === "function"
"function"
? newTags(emailTags) ? newTags(emailTags)
: newTags; : newTags;
setEmailTags(next as Tag[]); setEmailTags(next as Tag[]);
@@ -279,9 +306,7 @@ export default function UptimeAlertSection({
allowDuplicates={false} allowDuplicates={false}
sortTags sortTags
validateTag={(tag) => validateTag={(tag) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag)
tag
)
} }
delimiterList={[",", "Enter"]} delimiterList={[",", "Enter"]}
/> />

View File

@@ -1,8 +1,5 @@
"use client"; "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 { Button } from "@app/components/ui/button";
import { Checkbox } from "@app/components/ui/checkbox"; import { Checkbox } from "@app/components/ui/checkbox";
import { import {
@@ -24,13 +21,11 @@ import {
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { Switch } from "@app/components/ui/switch"; import { Switch } from "@app/components/ui/switch";
import { Textarea } from "@app/components/ui/textarea"; import { Textarea } from "@app/components/ui/textarea";
import { Label } from "@app/components/ui/label";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger PopoverTrigger
} from "@app/components/ui/popover"; } from "@app/components/ui/popover";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -38,21 +33,24 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from "@app/components/ui/select"; } 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 { import {
type AlertRuleFormAction, type AlertRuleFormAction,
type AlertRuleFormValues type AlertRuleFormValues
} from "@app/lib/alertRuleForm"; } from "@app/lib/alertRuleForm";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { orgQueries } from "@app/lib/queries"; import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Bell, ChevronsUpDown, Globe, Plus, Trash2 } from "lucide-react"; import { ContactSalesBanner } from "@app/components/ContactSalesBanner";
import { Bell, Globe, ChevronsUpDown, Plus, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { Control, UseFormReturn } from "react-hook-form"; import type { Control, UseFormReturn } from "react-hook-form";
import { useFormContext, useWatch } from "react-hook-form"; import { useFormContext, useWatch } from "react-hook-form";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { RolesSelector } from "../roles-selector";
import { UsersSelector } from "../users-selector";
export function AddActionPanel({ export function AddActionPanel({
onAdd onAdd
@@ -500,6 +498,12 @@ function NotifyActionFields({
const t = useTranslations(); const t = useTranslations();
const [emailActiveIdx, setEmailActiveIdx] = useState<number | null>(null); const [emailActiveIdx, setEmailActiveIdx] = useState<number | null>(null);
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
number | null
>(null);
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
number | null
>(null);
const { data: orgUsers = [], isLoading: isLoadingUsers } = useQuery( const { data: orgUsers = [], isLoading: isLoadingUsers } = useQuery(
orgQueries.users({ orgId }) orgQueries.users({ orgId })
@@ -570,6 +574,14 @@ function NotifyActionFields({
hasResolvedTagsRef.current = true; hasResolvedTagsRef.current = true;
}, [isLoadingUsers, isLoadingRoles, allUsers, allRoles]); }, [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({ const emailTags = (useWatch({
control, control,
name: `actions.${index}.emailTags` name: `actions.${index}.emailTags`
@@ -584,16 +596,29 @@ function NotifyActionFields({
<FormItem className="flex flex-col items-start"> <FormItem className="flex flex-col items-start">
<FormLabel>{t("alertingNotifyUsers")}</FormLabel> <FormLabel>{t("alertingNotifyUsers")}</FormLabel>
<FormControl> <FormControl>
<UsersSelector <TagInput
selectedUsers={field.value ?? []} {...field}
orgId={orgId} activeTagIndex={activeUsersTagIndex}
onSelectUsers={(newUsers) => { setActiveTagIndex={setActiveUsersTagIndex}
placeholder={t("alertingSelectUsers")}
size="sm"
tags={userTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(userTags)
: newTags;
form.setValue( form.setValue(
`actions.${index}.userTags`, `actions.${index}.userTags`,
newUsers as [Tag, ...Tag[]], next as Tag[],
{ shouldDirty: true } { shouldDirty: true }
); );
}} }}
enableAutocomplete={true}
autocompleteOptions={allUsers}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -607,17 +632,29 @@ function NotifyActionFields({
<FormItem className="flex flex-col items-start"> <FormItem className="flex flex-col items-start">
<FormLabel>{t("alertingNotifyRoles")}</FormLabel> <FormLabel>{t("alertingNotifyRoles")}</FormLabel>
<FormControl> <FormControl>
<RolesSelector <TagInput
selectedRoles={field.value ?? []} {...field}
restrictAdminRole activeTagIndex={activeRolesTagIndex}
orgId={orgId} setActiveTagIndex={setActiveRolesTagIndex}
onSelectRoles={(newUsers) => { placeholder={t("alertingSelectRoles")}
size="sm"
tags={roleTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(roleTags)
: newTags;
form.setValue( form.setValue(
`actions.${index}.roleTags`, `actions.${index}.roleTags`,
newUsers as [Tag, ...Tag[]], next as Tag[],
{ shouldDirty: true } { shouldDirty: true }
); );
}} }}
enableAutocomplete={true}
autocompleteOptions={allRoles}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />

View File

@@ -5,7 +5,7 @@ import { useMemo, useState } from "react";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input"; import { MultiSelectTags } from "./multi-select-tags";
export type SelectedMachine = Pick< export type SelectedMachine = Pick<
ListClientsResponse["clients"][number], ListClientsResponse["clients"][number],
@@ -28,13 +28,11 @@ export function MachinesSelector({
const [debouncedValue] = useDebounce(machineSearchQuery, 150); const [debouncedValue] = useDebounce(machineSearchQuery, 150);
const perPage = 7;
const { data: machines = [] } = useQuery( const { data: machines = [] } = useQuery(
orgQueries.machineClients({ orgId, perPage, query: debouncedValue }) orgQueries.machineClients({ orgId, perPage: 10, query: debouncedValue })
); );
// always include the selected machines in the list (if the user isn't searching) // always include the selected machines in the list of machines shown (if the user isn't searching)
const machinesShown = useMemo(() => { const machinesShown = useMemo(() => {
const allMachines: Array<SelectedMachine> = [...machines]; const allMachines: Array<SelectedMachine> = [...machines];
if (debouncedValue.trim().length === 0) { if (debouncedValue.trim().length === 0) {
@@ -46,32 +44,75 @@ export function MachinesSelector({
} }
} }
} }
return allMachines; return allMachines;
}, [machines, selectedMachines, debouncedValue]); }, [machines, selectedMachines, debouncedValue]);
// const selectedMachinesIds = new Set(
// selectedMachines.map((m) => m.clientId)
// );
return ( return (
<MultiSelectTagInput <MultiSelectTags
buttonText={t("accessClientSelect")}
searchPlaceholder={t("search")}
emptyPlaceholder={t("machineNotFound")} emptyPlaceholder={t("machineNotFound")}
searchQuery={machineSearchQuery} searchPlaceholder={t("machineSearch")}
onSearch={setMachineSearchQuery} value={selectedMachines.map((m) => ({
options={machinesShown.map((mc) => ({ ...m,
id: mc.clientId.toString(), text: m.name,
text: mc.name id: m.clientId.toString()
}))} }))}
value={selectedMachines.map((mc) => ({ onChange={(values) => {
id: mc.clientId.toString(), onSelectMachines(values);
text: mc.name
}))}
onChange={(newValues) => {
onSelectMachines(
newValues.map((v) => ({
clientId: Number(v.id),
name: v.text
}))
);
}} }}
options={machinesShown.map((m) => ({
...m,
id: m.clientId.toString(),
text: m.name
}))}
onSearch={setMachineSearchQuery}
searchQuery={machineSearchQuery}
/> />
// <Command shouldFilter={false}>
// <CommandInput
// placeholder={t("machineSearch")}
// value={machineSearchQuery}
// onValueChange={setMachineSearchQuery}
// />
// <CommandList>
// <CommandEmpty>{t("machineNotFound")}</CommandEmpty>
// <CommandGroup>
// {machinesShown.map((m) => (
// <CommandItem
// value={`${m.name}:${m.clientId}`}
// key={m.clientId}
// onSelect={() => {
// let newMachineClients = [];
// if (selectedMachinesIds.has(m.clientId)) {
// newMachineClients = selectedMachines.filter(
// (mc) => mc.clientId !== m.clientId
// );
// } else {
// newMachineClients = [
// ...selectedMachines,
// m
// ];
// }
// onSelectMachines(newMachineClients);
// }}
// >
// <CheckIcon
// className={cn(
// "mr-2 h-4 w-4",
// selectedMachinesIds.has(m.clientId)
// ? "opacity-100"
// : "opacity-0"
// )}
// />
// {`${m.name}`}
// </CommandItem>
// ))}
// </CommandGroup>
// </CommandList>
// </Command>
); );
} }

View File

@@ -6,26 +6,24 @@ import {
CommandInput, CommandInput,
CommandItem, CommandItem,
CommandList CommandList
} from "../ui/command"; } from "./ui/command";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { CheckIcon } from "lucide-react"; import { CheckIcon } from "lucide-react";
import { useTranslations } from "next-intl";
export type TagValue = { text: string; id: string }; export type TagValue = { text: string; id: string };
export type MultiSelectTagsProps<T extends TagValue> = { export type MultiSelectTagsProps<T extends TagValue> = {
emptyPlaceholder?: string; emptyPlaceholder: string;
searchPlaceholder?: string; searchPlaceholder: string;
searchQuery?: string; searchQuery?: string;
options: Array<T>; options: Array<T>;
value: Array<T>; value: Array<T>;
onChange: (newValue: Array<T>) => void; onChange: (newValue: Array<T>) => void;
onSearch: (query: string) => void; onSearch: (query: string) => void;
ref?: Ref<HTMLButtonElement>; ref?: Ref<HTMLButtonElement>;
disabled?: boolean;
}; };
export function MultiSelectContent<T extends TagValue>({ export function MultiSelectTags<T extends TagValue>({
emptyPlaceholder, emptyPlaceholder,
searchPlaceholder, searchPlaceholder,
searchQuery, searchQuery,
@@ -34,19 +32,16 @@ export function MultiSelectContent<T extends TagValue>({
onSearch, onSearch,
onChange onChange
}: MultiSelectTagsProps<T>) { }: MultiSelectTagsProps<T>) {
const t = useTranslations();
const selectedValues = new Set(value.map((v) => v.id)); const selectedValues = new Set(value.map((v) => v.id));
return ( return (
<Command shouldFilter={false}> <Command shouldFilter={false}>
<CommandInput <CommandInput
placeholder={searchPlaceholder ?? t("search")} placeholder={searchPlaceholder}
value={searchQuery} value={searchQuery}
onValueChange={onSearch} onValueChange={onSearch}
/> />
<CommandList> <CommandList>
<CommandEmpty className="text-muted-foreground"> <CommandEmpty>{emptyPlaceholder}</CommandEmpty>
{emptyPlaceholder ?? t("noResults")}
</CommandEmpty>
<CommandGroup> <CommandGroup>
{options.map((option) => ( {options.map((option) => (
<CommandItem <CommandItem

View File

@@ -1,98 +0,0 @@
import { buttonVariants } from "@app/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import { ChevronDownIcon, XIcon } from "lucide-react";
import {
type MultiSelectTagsProps,
type TagValue,
MultiSelectContent
} from "./multi-select-content";
export interface MultiSelectInputProps<
T extends TagValue
> extends MultiSelectTagsProps<T> {
buttonText?: string;
}
export function MultiSelectTagInput<T extends TagValue>({
buttonText,
...props
}: MultiSelectInputProps<T>) {
const selectedValues = new Set(props.value.map((v) => v.id));
return (
<Popover
onOpenChange={(open) => {
if (!open) {
// clear input when popover is closed
props.onSearch("");
}
}}
>
<PopoverTrigger asChild>
<div
role="combobox"
className={cn(
buttonVariants({
variant: "outline"
}),
"justify-between w-full inline-flex",
"text-muted-foreground pl-1.5 cursor-text",
"hover:bg-transparent hover:text-muted-foreground",
props.disabled && "pointer-events-none opacity-50"
)}
>
<span
className={cn(
"inline-flex items-center gap-1",
"overflow-x-auto"
)}
>
{props.value.map((option) => (
<span
key={option.id}
className={cn(
"bg-muted-foreground/10 font-normal text-foreground rounded-sm",
"py-1 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5"
)}
onClick={(e) => e.stopPropagation()}
>
{option.text}
<button
className="p-0.5 flex-none cursor-pointer"
type="button"
onClick={(e) => {
e.stopPropagation();
let newValues = [];
if (selectedValues.has(option.id)) {
newValues = props.value.filter(
(v) => v.id !== option.id
);
} else {
newValues = [
...props.value,
option
];
}
props.onChange(newValues);
}}
>
<XIcon className="size-3.5" />
</button>
</span>
))}
<span className="pl-1 font-normal">{buttonText}</span>
</span>
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
</div>
</PopoverTrigger>
<PopoverContent className="p-0">
<MultiSelectContent {...props} />
</PopoverContent>
</Popover>
);
}

View File

@@ -1,81 +0,0 @@
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<SelectedRole & { isAdmin?: boolean }> = 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 (
<MultiSelectTagInput
buttonText={buttonText ?? t("alertingSelectRoles")}
searchQuery={roleSearchQuery}
onSearch={setRoleSearchQuery}
options={rolesShown}
value={selectedRoles}
onChange={onSelectRoles}
disabled={disabled}
/>
);
}

View File

@@ -1,10 +1,4 @@
import React, { import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
useCallback,
useEffect,
useMemo,
useRef,
useState
} from "react";
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input"; import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
import { import {
Command, Command,
@@ -226,7 +220,7 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
> >
<PopoverAnchor asChild> <PopoverAnchor asChild>
<div <div
className="relative h-full flex items-center rounded-md border border-input bg-transparent pr-1" className="relative h-full flex items-center rounded-md border border-input bg-transparent pr-3"
ref={triggerContainerRef} ref={triggerContainerRef}
> >
{childrenWithProps} {childrenWithProps}
@@ -266,7 +260,10 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
side="bottom" side="bottom"
align="start" align="start"
forceMount forceMount
className={cn("p-0", classStyleProps?.popoverContent)} className={cn(
"p-0",
classStyleProps?.popoverContent
)}
style={{ style={{
width: `${popoverWidth}px`, width: `${popoverWidth}px`,
minWidth: `${popoverWidth}px`, minWidth: `${popoverWidth}px`,
@@ -303,9 +300,7 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
key={option.id} key={option.id}
value={`${option.text} ${option.id}`} value={`${option.text} ${option.id}`}
onSelect={() => toggleTag(option)} onSelect={() => toggleTag(option)}
className={ className={classStyleProps?.commandItem}
classStyleProps?.commandItem
}
> >
<Check <Check
className={cn( className={cn(

View File

@@ -85,8 +85,6 @@ export interface TagInputProps
autocompleteFilter?: (option: string) => boolean; autocompleteFilter?: (option: string) => boolean;
direction?: "row" | "column"; direction?: "row" | "column";
onInputChange?: (value: string) => void; onInputChange?: (value: string) => void;
searchQuery?: string;
onSearchQueryChange?: (value: string) => void;
customTagRenderer?: (tag: Tag, isActiveTag: boolean) => React.ReactNode; customTagRenderer?: (tag: Tag, isActiveTag: boolean) => React.ReactNode;
onFocus?: React.FocusEventHandler<HTMLInputElement>; onFocus?: React.FocusEventHandler<HTMLInputElement>;
onBlur?: React.FocusEventHandler<HTMLInputElement>; onBlur?: React.FocusEventHandler<HTMLInputElement>;
@@ -159,24 +157,10 @@ export function TagInput({ ref, ...props }: TagInputProps) {
disabled = false, disabled = false,
usePortal = false, usePortal = false,
addOnPaste = false, addOnPaste = false,
generateTagId = uuid, generateTagId = uuid
searchQuery,
onSearchQueryChange
} = props; } = props;
const [inputValue, setInputValue] = React.useState(""); const [inputValue, setInputValue] = React.useState("");
const isControlled = searchQuery !== undefined;
const effectiveQuery = isControlled ? searchQuery : inputValue;
const updateQuery = React.useCallback(
(action: React.SetStateAction<string>) => {
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 [tagCount, setTagCount] = React.useState(Math.max(0, tags.length));
const inputRef = React.useRef<HTMLInputElement>(null); const inputRef = React.useRef<HTMLInputElement>(null);
@@ -250,9 +234,9 @@ export function TagInput({ ref, ...props }: TagInputProps) {
); );
} }
}); });
updateQuery(""); setInputValue("");
} else { } else {
updateQuery(newValue); setInputValue(newValue);
} }
onInputChange?.(newValue); onInputChange?.(newValue);
}; };
@@ -263,8 +247,8 @@ export function TagInput({ ref, ...props }: TagInputProps) {
}; };
const handleInputBlur = (event: React.FocusEvent<HTMLInputElement>) => { const handleInputBlur = (event: React.FocusEvent<HTMLInputElement>) => {
if (addTagsOnBlur && effectiveQuery.trim()) { if (addTagsOnBlur && inputValue.trim()) {
const newTagText = effectiveQuery.trim(); const newTagText = inputValue.trim();
if (validateTag && !validateTag(newTagText)) { if (validateTag && !validateTag(newTagText)) {
return; return;
@@ -289,7 +273,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
setTags([...tags, { id: newTagId, text: newTagText }]); setTags([...tags, { id: newTagId, text: newTagText }]);
onTagAdd?.(newTagText); onTagAdd?.(newTagText);
setTagCount((prevTagCount) => prevTagCount + 1); setTagCount((prevTagCount) => prevTagCount + 1);
updateQuery(""); setInputValue("");
} }
} }
@@ -303,7 +287,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
: e.key === delimiter || e.key === Delimiter.Enter : e.key === delimiter || e.key === Delimiter.Enter
) { ) {
e.preventDefault(); e.preventDefault();
const newTagText = effectiveQuery.trim(); const newTagText = inputValue.trim();
// Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true // Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true
if ( if (
@@ -345,7 +329,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
onTagAdd?.(newTagText); onTagAdd?.(newTagText);
setTagCount((prevTagCount) => prevTagCount + 1); setTagCount((prevTagCount) => prevTagCount + 1);
} }
updateQuery(""); setInputValue("");
} else { } else {
switch (e.key) { switch (e.key) {
case "Delete": case "Delete":
@@ -435,6 +419,9 @@ export function TagInput({ ref, ...props }: TagInputProps) {
onClearAll?.(); onClearAll?.();
}; };
// const filteredAutocompleteOptions = autocompleteFilter
// ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text))
// : autocompleteOptions;
const displayedTags = sortTags ? [...tags].sort() : tags; const displayedTags = sortTags ? [...tags].sort() : tags;
const truncatedTags = truncate const truncatedTags = truncate
@@ -449,15 +436,13 @@ export function TagInput({ ref, ...props }: TagInputProps) {
return ( return (
<div <div
className={cn( className={`w-full flex ${!inlineTags && tags.length > 0 ? "gap-3" : ""} ${
`w-full flex`,
!inlineTags && tags.length > 0 && "gap-3",
inputFieldPosition === "bottom" inputFieldPosition === "bottom"
? "flex-col" ? "flex-col"
: inputFieldPosition === "top" : inputFieldPosition === "top"
? "flex-col-reverse" ? "flex-col-reverse"
: "flex-row" : "flex-row"
)} }`}
> >
{!usePopoverForTags && {!usePopoverForTags &&
(!inlineTags ? ( (!inlineTags ? (
@@ -530,14 +515,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
? placeholderWhenFull ? placeholderWhenFull
: placeholder : placeholder
} }
value={effectiveQuery} value={inputValue}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={handleInputFocus} onFocus={handleInputFocus}
onBlur={handleInputBlur} onBlur={handleInputBlur}
{...inputProps} {...inputProps}
className={cn( className={cn(
"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", "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",
// className, // className,
styleClasses?.input styleClasses?.input
)} )}
@@ -559,17 +544,16 @@ export function TagInput({ ref, ...props }: TagInputProps) {
</div> </div>
) )
))} ))}
{enableAutocomplete ? ( {enableAutocomplete ? (
<div className="w-full"> <div className="w-full">
<Autocomplete <Autocomplete
tags={tags} tags={tags}
setTags={setTags} setTags={setTags}
setInputValue={updateQuery} setInputValue={setInputValue}
autocompleteOptions={ autocompleteOptions={
(autocompleteOptions || []) as Tag[] (autocompleteOptions || []) as Tag[]
} }
filterQuery={effectiveQuery} filterQuery={inputValue}
setTagCount={setTagCount} setTagCount={setTagCount}
maxTags={maxTags} maxTags={maxTags}
onTagAdd={onTagAdd} onTagAdd={onTagAdd}
@@ -595,7 +579,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
// <CommandInput // <CommandInput
// placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder} // placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
// ref={inputRef} // ref={inputRef}
// value={effectiveQuery} // value={inputValue}
// disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} // disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
// onChangeCapture={handleInputChange} // onChangeCapture={handleInputChange}
// onKeyDown={handleKeyDown} // onKeyDown={handleKeyDown}
@@ -617,14 +601,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
? placeholderWhenFull ? placeholderWhenFull
: placeholder : placeholder
} }
value={effectiveQuery} value={inputValue}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={handleInputFocus} onFocus={handleInputFocus}
onBlur={handleInputBlur} onBlur={handleInputBlur}
{...inputProps} {...inputProps}
className={cn( className={cn(
"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", "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",
// className, // className,
styleClasses?.input styleClasses?.input
)} )}
@@ -678,7 +662,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
{/* <CommandInput {/* <CommandInput
placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder} placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
ref={inputRef} ref={inputRef}
value={effectiveQuery} value={inputValue}
disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
onChangeCapture={handleInputChange} onChangeCapture={handleInputChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@@ -701,14 +685,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
? placeholderWhenFull ? placeholderWhenFull
: placeholder : placeholder
} }
value={effectiveQuery} value={inputValue}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={handleInputFocus} onFocus={handleInputFocus}
onBlur={handleInputBlur} onBlur={handleInputBlur}
{...inputProps} {...inputProps}
className={cn( className={cn(
"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", "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",
// className, // className,
styleClasses?.input styleClasses?.input
)} )}
@@ -757,7 +741,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
{/* <CommandInput {/* <CommandInput
placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder} placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
ref={inputRef} ref={inputRef}
value={effectiveQuery} value={inputValue}
disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
onChangeCapture={handleInputChange} onChangeCapture={handleInputChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@@ -779,14 +763,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
? placeholderWhenFull ? placeholderWhenFull
: placeholder : placeholder
} }
value={effectiveQuery} value={inputValue}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={handleInputFocus} onFocus={handleInputFocus}
onBlur={handleInputBlur} onBlur={handleInputBlur}
{...inputProps} {...inputProps}
className={cn( className={cn(
"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", "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",
// className, // className,
styleClasses?.input styleClasses?.input
)} )}
@@ -822,7 +806,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
? placeholderWhenFull ? placeholderWhenFull
: placeholder : placeholder
} }
value={effectiveQuery} value={inputValue}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={handleInputFocus} onFocus={handleInputFocus}
@@ -882,7 +866,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
? placeholderWhenFull ? placeholderWhenFull
: placeholder : placeholder
} }
value={effectiveQuery} value={inputValue}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={handleInputFocus} onFocus={handleInputFocus}

View File

@@ -87,7 +87,7 @@ function CommandList({
<CommandPrimitive.List <CommandPrimitive.List
data-slot="command-list" data-slot="command-list"
className={cn( className={cn(
"max-h-[300px] scroll-py-1 overflow-x-clip overflow-y-auto", "max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className className
)} )}
{...props} {...props}
@@ -96,13 +96,12 @@ function CommandList({
} }
function CommandEmpty({ function CommandEmpty({
className,
...props ...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) { }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return ( return (
<CommandPrimitive.Empty <CommandPrimitive.Empty
data-slot="command-empty" data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)} className="py-6 text-center text-sm"
{...props} {...props}
/> />
); );
@@ -116,7 +115,7 @@ function CommandGroup({
<CommandPrimitive.Group <CommandPrimitive.Group
data-slot="command-group" data-slot="command-group"
className={cn( className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-y-auto p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium", "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className className
)} )}
{...props} {...props}

View File

@@ -566,7 +566,7 @@ export function ControlledDataTable<TData, TValue>({
))} ))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{(table.getRowModel().rows ?? []).length > 0 ? ( {table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<TableRow <TableRow
key={row.id} key={row.id}

View File

@@ -1,63 +0,0 @@
import { orgQueries } from "@app/lib/queries";
import type { ListUsersResponse } from "@server/routers/user";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
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 SelectedUser = {
id: string;
text: string;
ipdName?: string | null;
};
export type UsersSelectorProps = {
orgId: string;
selectedUsers?: SelectedUser[];
onSelectUsers: (users: SelectedUser[]) => 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<SelectedUser> = 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 (
<MultiSelectTagInput
buttonText={t("alertingSelectUsers")}
searchQuery={userSearchQuery}
onSearch={setUserSearchQuery}
options={usersShown}
value={selectedUsers}
onChange={onSelectUsers}
/>
);
}

View File

@@ -8,7 +8,6 @@ type UserDisplayNameInput =
email?: string | null; email?: string | null;
name?: string | null; name?: string | null;
username?: string | null; username?: string | null;
idpName?: string | null;
}; };
/** /**
@@ -22,25 +21,16 @@ export function getUserDisplayName(input: UserDisplayNameInput): string {
let email: string | null | undefined; let email: string | null | undefined;
let name: string | null | undefined; let name: string | null | undefined;
let username: string | null | undefined; let username: string | null | undefined;
let idpName: string | null | undefined;
if ("user" in input) { if ("user" in input) {
email = input.user.email; email = input.user.email;
name = input.user.name; name = input.user.name;
username = input.user.username; username = input.user.username;
idpName = input.user.idpName;
} else { } else {
email = input.email; email = input.email;
name = input.name; name = input.name;
username = input.username; username = input.username;
idpName = input.idpName;
} }
let nameShown = email || name || username || ""; return email || name || username || "";
if (idpName) {
nameShown = `${nameShown} (${idpName})`;
}
return nameShown;
} }

View File

@@ -125,56 +125,24 @@ export const orgQueries = {
return res.data.data.clients; return res.data.data.clients;
} }
}), }),
users: ({ users: ({ orgId }: { orgId: string }) =>
orgId,
query,
perPage = 10_000
}: {
orgId: string;
query?: string;
perPage?: number;
}) =>
queryOptions({ queryOptions({
queryKey: ["ORG", orgId, "USERS", { query, perPage }] as const, queryKey: ["ORG", orgId, "USERS"] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: perPage.toString()
});
if (query?.trim()) {
sp.set("query", query);
}
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<ListUsersResponse> AxiosResponse<ListUsersResponse>
>(`/org/${orgId}/users?${sp.toString()}`, { signal }); >(`/org/${orgId}/users`, { signal });
return res.data.data.users; return res.data.data.users;
} }
}), }),
roles: ({ roles: ({ orgId }: { orgId: string }) =>
orgId,
query,
perPage = 10_000
}: {
orgId: string;
query?: string;
perPage?: number;
}) =>
queryOptions({ queryOptions({
queryKey: ["ORG", orgId, "ROLES", { query, perPage }] as const, queryKey: ["ORG", orgId, "ROLES"] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: perPage.toString()
});
if (query?.trim()) {
sp.set("query", query);
}
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<ListRolesResponse> AxiosResponse<ListRolesResponse>
>(`/org/${orgId}/roles?${sp.toString()}`, { signal }); >(`/org/${orgId}/roles`, { signal });
return res.data.data.roles; return res.data.data.roles;
} }