diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index bf6ccfde..fdb70251 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -17,6 +17,7 @@ on: push: tags: - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+.rc.[0-9]+" concurrency: group: ${{ github.ref }} @@ -110,13 +111,6 @@ jobs: echo "Built & pushed to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}" shell: bash - - name: Login in to GHCR - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Install skopeo + jq # skopeo: copy/inspect images between registries # jq: JSON parsing tool used to extract digest values @@ -126,6 +120,11 @@ jobs: skopeo --version shell: bash + - name: Login to GHCR + run: | + skopeo login ghcr.io -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}" + shell: bash + - name: Copy tag from Docker Hub to GHCR # Mirror the already-built image (all architectures) to GHCR so we can sign it run: | diff --git a/cli/commands/setAdminCredentials.ts b/cli/commands/setAdminCredentials.ts index 91a6bcf7..f8b1bd40 100644 --- a/cli/commands/setAdminCredentials.ts +++ b/cli/commands/setAdminCredentials.ts @@ -90,7 +90,8 @@ export const setAdminCredentials: CommandModule<{}, SetAdminCredentialsArgs> = { passwordHash, dateCreated: moment().toISOString(), serverAdmin: true, - emailVerified: true + emailVerified: true, + lastPasswordChange: new Date().getTime() }); console.log("Server admin created"); diff --git a/install/config/config.yml b/install/config/config.yml index 4d6aeb51..90d1bf5d 100644 --- a/install/config/config.yml +++ b/install/config/config.yml @@ -14,7 +14,6 @@ app: domains: domain1: base_domain: "{{.BaseDomain}}" - cert_resolver: "letsencrypt" server: secret: "{{.Secret}}" diff --git a/install/config/traefik/dynamic_config.yml b/install/config/traefik/dynamic_config.yml index 8fcf8e55..f795016b 100644 --- a/install/config/traefik/dynamic_config.yml +++ b/install/config/traefik/dynamic_config.yml @@ -51,3 +51,12 @@ http: loadBalancer: servers: - url: "http://pangolin:3000" # API/WebSocket server + +tcp: + serversTransports: + pp-transport-v1: + proxyProtocol: + version: 1 + pp-transport-v2: + proxyProtocol: + version: 2 \ No newline at end of file diff --git a/install/main.go b/install/main.go index 72ffbac0..a1b7d901 100644 --- a/install/main.go +++ b/install/main.go @@ -378,7 +378,7 @@ func collectUserInput(reader *bufio.Reader) Config { fmt.Println("\n=== Advanced Configuration ===") config.EnableIPv6 = readBool(reader, "Is your server IPv6 capable?", true) - config.EnableGeoblocking = readBool(reader, "Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", false) + config.EnableGeoblocking = readBool(reader, "Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true) if config.DashboardDomain == "" { fmt.Println("Error: Dashboard Domain name is required") diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 33eea2e6..e8cb9bf7 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -911,6 +911,18 @@ "passwordResetCodeDescription": "Проверете имейла си за код за нулиране.", "passwordNew": "Нова парола", "passwordNewConfirm": "Потвърдете новата парола", + "changePassword": "Change Password", + "changePasswordDescription": "Update your account password", + "oldPassword": "Current Password", + "newPassword": "New Password", + "confirmNewPassword": "Confirm New Password", + "changePasswordError": "Failed to change password", + "changePasswordErrorDescription": "An error occurred while changing your password", + "changePasswordSuccess": "Password Changed Successfully", + "changePasswordSuccessDescription": "Your password has been updated successfully", + "passwordExpiryRequired": "Password Expiry Required", + "passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.", + "changePasswordNow": "Change Password Now", "pincodeAuth": "Код на удостоверителя", "pincodeSubmit2": "Изпрати код", "passwordResetSubmit": "Заявка за нулиране", @@ -1340,6 +1352,19 @@ "securityKeyUnknownError": "Възникна проблем при използването на ключа за сигурност. Моля, опитайте отново.", "twoFactorRequired": "Двуфакторното удостоверяване е необходимо за регистрация на ключ за защита.", "twoFactor": "Двуфакторно удостоверяване", + "twoFactorAuthentication": "Two-Factor Authentication", + "twoFactorDescription": "This organization requires two-factor authentication.", + "enableTwoFactor": "Enable Two-Factor Authentication", + "organizationSecurityPolicy": "Organization Security Policy", + "organizationSecurityPolicyDescription": "This organization has security requirements that must be met before you can access it", + "securityRequirements": "Security Requirements", + "allRequirementsMet": "All requirements have been met", + "completeRequirementsToContinue": "Complete the requirements below to continue accessing this organization", + "youCanNowAccessOrganization": "You can now access this organization", + "reauthenticationRequired": "Session Length", + "reauthenticationDescription": "This organization requires you to log in every {maxDays} days.", + "reauthenticationDescriptionHours": "This organization requires you to log in every {maxHours} hours.", + "reauthenticateNow": "Log In Again", "adminEnabled2FaOnYourAccount": "Вашият администратор е активирал двуфакторно удостоверяване за {email}. Моля, завършете процеса по настройка, за да продължите.", "securityKeyAdd": "Добавяне на ключ за сигурност", "securityKeyRegisterTitle": "Регистриране на нов ключ за сигурност", @@ -1719,6 +1744,7 @@ "orgAuthNoIdpConfigured": "Тази организация няма конфигурирани доставчици на идентичност. Можете да влезете с вашата Pangolin идентичност.", "orgAuthSignInWithPangolin": "Впишете се с Pangolin", "subscriptionRequiredToUse": "Необходим е абонамент, за да използвате тази функция.", + "licenseRequiredToUse": "An Enterprise license is required to use this feature.", "idpDisabled": "Доставчиците на идентичност са деактивирани.", "orgAuthPageDisabled": "Страницата за удостоверяване на организацията е деактивирана.", "domainRestartedDescription": "Проверка на домейна е рестартирана успешно", @@ -1726,6 +1752,47 @@ "resourceExposePortsEditFile": "Редактиране на файл: docker-compose.yml", "emailVerificationRequired": "Потвърждението на Email е необходимо. Моля, влезте отново чрез {dashboardUrl}/auth/login, за да завършите тази стъпка. След това, върнете се тук.", "twoFactorSetupRequired": "Необходима е настройка на двуфакторно удостоверяване. Моля, влезте отново чрез {dashboardUrl}/auth/login, за да завършите тази стъпка. След това, върнете се тук.", + "additionalSecurityRequired": "Additional Security Required", + "organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.", + "completeTheseSteps": "Complete these steps", + "enableTwoFactorAuthentication": "Enable two-factor authentication", + "completeSecuritySteps": "Complete Security Steps", + "securitySettings": "Security Settings", + "securitySettingsDescription": "Configure security policies for your organization", + "requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users", + "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", + "requireTwoFactorDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "requireTwoFactorCannotEnableDescription": "You must enable two-factor authentication for your account before enforcing it for all users", + "maxSessionLength": "Maximum Session Length", + "maxSessionLengthDescription": "Set the maximum duration for user sessions. After this time, users will need to re-authenticate.", + "maxSessionLengthDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "selectSessionLength": "Select session length", + "unenforced": "Unenforced", + "1Hour": "1 hour", + "3Hours": "3 hours", + "6Hours": "6 hours", + "12Hours": "12 hours", + "1DaySession": "1 day", + "3Days": "3 days", + "7Days": "7 days", + "14Days": "14 days", + "30DaysSession": "30 days", + "90DaysSession": "90 days", + "180DaysSession": "180 days", + "passwordExpiryDays": "Password Expiry", + "editPasswordExpiryDescription": "Set the number of days before users are required to change their password.", + "selectPasswordExpiry": "Select password expiry", + "30Days": "30 days", + "1Day": "1 day", + "60Days": "60 days", + "90Days": "90 days", + "180Days": "180 days", + "1Year": "1 year", + "subscriptionBadge": "Subscription Required", + "securityPolicyChangeWarning": "Security Policy Change Warning", + "securityPolicyChangeDescription": "You are about to change security policy settings. After saving, you may need to reauthenticate to comply with these policy updates. All users who are not compliant will also need to reauthenticate.", + "securityPolicyChangeConfirmMessage": "I confirm", + "securityPolicyChangeWarningText": "This will affect all users in the organization", "authPageErrorUpdateMessage": "Възникна грешка при актуализирането на настройките на страницата за удостоверяване", "authPageUpdated": "Страницата за удостоверяване е актуализирана успешно", "healthCheckNotAvailable": "Локална", @@ -1891,5 +1958,86 @@ "cannotbeUndone": "Това не може да се отмени.", "toConfirm": "за потвърждение", "deleteClientQuestion": "Сигурни ли сте, че искате да премахнете клиента от сайта и организацията?", - "clientMessageRemove": "След като клиентът бъде премахнат, той вече няма да може да се свързва с сайта." + "clientMessageRemove": "След като клиентът бъде премахнат, той вече няма да може да се свързва с сайта.", + "sidebarLogs": "Logs", + "request": "Request", + "logs": "Logs", + "logsSettingsDescription": "Monitor logs collected from this orginization", + "searchLogs": "Search logs...", + "action": "Action", + "actor": "Actor", + "timestamp": "Timestamp", + "accessLogs": "Access Logs", + "exportCsv": "Export CSV", + "actorId": "Actor ID", + "allowedByRule": "Allowed by Rule", + "allowedNoAuth": "Allowed No Auth", + "validAccessToken": "Valid Access Token", + "validHeaderAuth": "Valid header auth", + "validPincode": "Valid Pincode", + "validPassword": "Valid Password", + "validEmail": "Valid email", + "validSSO": "Valid SSO", + "resourceBlocked": "Resource Blocked", + "droppedByRule": "Dropped by Rule", + "noSessions": "No Sessions", + "temporaryRequestToken": "Temporary Request Token", + "noMoreAuthMethods": "No Valid Auth", + "ip": "IP", + "reason": "Reason", + "requestLogs": "Request Logs", + "host": "Host", + "location": "Location", + "actionLogs": "Action Logs", + "sidebarLogsRequest": "Request Logs", + "sidebarLogsAccess": "Access Logs", + "sidebarLogsAction": "Action Logs", + "logRetention": "Log Retention", + "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", + "requestLogsDescription": "View detailed request logs for resources in this organization", + "logRetentionRequestLabel": "Request Log Retention", + "logRetentionRequestDescription": "How long to retain request logs", + "logRetentionAccessLabel": "Access Log Retention", + "logRetentionAccessDescription": "How long to retain access logs", + "logRetentionActionLabel": "Action Log Retention", + "logRetentionActionDescription": "How long to retain action logs", + "logRetentionDisabled": "Disabled", + "logRetention3Days": "3 days", + "logRetention7Days": "7 days", + "logRetention14Days": "14 days", + "logRetention30Days": "30 days", + "logRetention90Days": "90 days", + "logRetentionForever": "Forever", + "actionLogsDescription": "View a history of actions performed in this organization", + "accessLogsDescription": "View access auth requests for resources in this organization", + "certResolver": "Решавач на сертификати", + "certResolverDescription": "Изберете решавач на сертификати за използване за този ресурс.", + "selectCertResolver": "Изберете решавач на сертификати", + "enterCustomResolver": "Въведете персонализиран решавач", + "preferWildcardCert": "Предпочитайте универсален сертификат", + "unverified": "Невалидиран", + "domainSetting": "Настройки на домейните", + "domainSettingDescription": "Конфигурирайте настройките за вашия домейн", + "preferWildcardCertDescription": "Опит за генериране на универсален сертификат (изисква правилно конфигуриран решавач на сертификати).", + "recordName": "Име на запис", + "auto": "Автоматично", + "TTL": "TTL", + "howToAddRecords": "Как да добавите записи", + "dnsRecord": "DNS записи", + "required": "Задължително", + "domainSettingsUpdated": "Настройките на домейна са успешно актуализирани", + "orgOrDomainIdMissing": "Липсва идентификатор на организация или домейн", + "loadingDNSRecords": "Зареждане на DNS записи...", + "olmUpdateAvailableInfo": "Налична е актуализирана версия на Olm. Моля, актуализирайте до най-новата версия за най-добро преживяване.", + "client": "Клиент", + "proxyProtocol": "Настройки на прокси протокол", + "proxyProtocolDescription": "Конфигурирайте прокси протокол за запазване на IP адресите на клиентите за TCP/UDP услуги.", + "enableProxyProtocol": "Активирайте прокси протокола", + "proxyProtocolInfo": "Запазете IP адресите на клиентите за TCP/UDP бекенди", + "proxyProtocolVersion": "Версия на прокси протокола", + "version1": "Версия 1 (Препоръчително)", + "version2": "Версия 2", + "versionDescription": "Версия 1 е текстово-базирана и широко поддържана. Версия 2 е бърна и по-ефективна, но по-малко съвместима.", + "warning": "Предупреждение", + "proxyProtocolWarning": "Вашето бекенд приложение трябва да бъде конфигурирано да приема прокси протоколни връзки. Ако вашият бекенд не поддържа прокси протокол, активирането му ще прекъсне всички връзки. Уверете се, че сте конфигурирали вашия бекенд да се доверява на заглавията на прокси протокола от Traefik." } diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 761b780f..ddaba6ec 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -911,6 +911,18 @@ "passwordResetCodeDescription": "Zkontrolujte svůj e-mail pro kód pro obnovení.", "passwordNew": "Nové heslo", "passwordNewConfirm": "Potvrdit nové heslo", + "changePassword": "Change Password", + "changePasswordDescription": "Update your account password", + "oldPassword": "Current Password", + "newPassword": "New Password", + "confirmNewPassword": "Confirm New Password", + "changePasswordError": "Failed to change password", + "changePasswordErrorDescription": "An error occurred while changing your password", + "changePasswordSuccess": "Password Changed Successfully", + "changePasswordSuccessDescription": "Your password has been updated successfully", + "passwordExpiryRequired": "Password Expiry Required", + "passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.", + "changePasswordNow": "Change Password Now", "pincodeAuth": "Ověřovací kód", "pincodeSubmit2": "Odeslat kód", "passwordResetSubmit": "Žádost o obnovení", @@ -1340,6 +1352,19 @@ "securityKeyUnknownError": "Vyskytl se problém s použitím bezpečnostního klíče. Zkuste to prosím znovu.", "twoFactorRequired": "Pro registraci bezpečnostního klíče je nutné dvoufaktorové ověření.", "twoFactor": "Dvoufaktorové ověření", + "twoFactorAuthentication": "Two-Factor Authentication", + "twoFactorDescription": "This organization requires two-factor authentication.", + "enableTwoFactor": "Enable Two-Factor Authentication", + "organizationSecurityPolicy": "Organization Security Policy", + "organizationSecurityPolicyDescription": "This organization has security requirements that must be met before you can access it", + "securityRequirements": "Security Requirements", + "allRequirementsMet": "All requirements have been met", + "completeRequirementsToContinue": "Complete the requirements below to continue accessing this organization", + "youCanNowAccessOrganization": "You can now access this organization", + "reauthenticationRequired": "Session Length", + "reauthenticationDescription": "This organization requires you to log in every {maxDays} days.", + "reauthenticationDescriptionHours": "This organization requires you to log in every {maxHours} hours.", + "reauthenticateNow": "Log In Again", "adminEnabled2FaOnYourAccount": "Váš správce povolil dvoufaktorové ověřování pro {email}. Chcete-li pokračovat, dokončete proces nastavení.", "securityKeyAdd": "Přidat bezpečnostní klíč", "securityKeyRegisterTitle": "Registrovat nový bezpečnostní klíč", @@ -1719,6 +1744,7 @@ "orgAuthNoIdpConfigured": "Tato organizace nemá nakonfigurovány žádné poskytovatele identity. Místo toho se můžete přihlásit s vaší Pangolinovou identitou.", "orgAuthSignInWithPangolin": "Přihlásit se pomocí Pangolinu", "subscriptionRequiredToUse": "Pro použití této funkce je vyžadováno předplatné.", + "licenseRequiredToUse": "An Enterprise license is required to use this feature.", "idpDisabled": "Poskytovatelé identit jsou zakázáni.", "orgAuthPageDisabled": "Ověřovací stránka organizace je zakázána.", "domainRestartedDescription": "Ověření domény bylo úspěšně restartováno", @@ -1726,6 +1752,47 @@ "resourceExposePortsEditFile": "Upravit soubor: docker-compose.yml", "emailVerificationRequired": "Je vyžadováno ověření e-mailu. Přihlaste se znovu pomocí {dashboardUrl}/auth/login dokončete tento krok. Poté se vraťte zde.", "twoFactorSetupRequired": "Je vyžadováno nastavení dvoufaktorového ověřování. Přihlaste se znovu pomocí {dashboardUrl}/autentizace/přihlášení dokončí tento krok. Poté se vraťte zde.", + "additionalSecurityRequired": "Additional Security Required", + "organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.", + "completeTheseSteps": "Complete these steps", + "enableTwoFactorAuthentication": "Enable two-factor authentication", + "completeSecuritySteps": "Complete Security Steps", + "securitySettings": "Security Settings", + "securitySettingsDescription": "Configure security policies for your organization", + "requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users", + "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", + "requireTwoFactorDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "requireTwoFactorCannotEnableDescription": "You must enable two-factor authentication for your account before enforcing it for all users", + "maxSessionLength": "Maximum Session Length", + "maxSessionLengthDescription": "Set the maximum duration for user sessions. After this time, users will need to re-authenticate.", + "maxSessionLengthDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "selectSessionLength": "Select session length", + "unenforced": "Unenforced", + "1Hour": "1 hour", + "3Hours": "3 hours", + "6Hours": "6 hours", + "12Hours": "12 hours", + "1DaySession": "1 day", + "3Days": "3 days", + "7Days": "7 days", + "14Days": "14 days", + "30DaysSession": "30 days", + "90DaysSession": "90 days", + "180DaysSession": "180 days", + "passwordExpiryDays": "Password Expiry", + "editPasswordExpiryDescription": "Set the number of days before users are required to change their password.", + "selectPasswordExpiry": "Select password expiry", + "30Days": "30 days", + "1Day": "1 day", + "60Days": "60 days", + "90Days": "90 days", + "180Days": "180 days", + "1Year": "1 year", + "subscriptionBadge": "Subscription Required", + "securityPolicyChangeWarning": "Security Policy Change Warning", + "securityPolicyChangeDescription": "You are about to change security policy settings. After saving, you may need to reauthenticate to comply with these policy updates. All users who are not compliant will also need to reauthenticate.", + "securityPolicyChangeConfirmMessage": "I confirm", + "securityPolicyChangeWarningText": "This will affect all users in the organization", "authPageErrorUpdateMessage": "Při aktualizaci nastavení autentizační stránky došlo k chybě", "authPageUpdated": "Autentizační stránka byla úspěšně aktualizována", "healthCheckNotAvailable": "Místní", @@ -1891,5 +1958,86 @@ "cannotbeUndone": "To nelze vrátit zpět.", "toConfirm": "Potvrdit", "deleteClientQuestion": "Jste si jisti, že chcete odstranit klienta z webu a organizace?", - "clientMessageRemove": "Po odstranění se klient již nebude moci připojit k webu." + "clientMessageRemove": "Po odstranění se klient již nebude moci připojit k webu.", + "sidebarLogs": "Logs", + "request": "Request", + "logs": "Logs", + "logsSettingsDescription": "Monitor logs collected from this orginization", + "searchLogs": "Search logs...", + "action": "Action", + "actor": "Actor", + "timestamp": "Timestamp", + "accessLogs": "Access Logs", + "exportCsv": "Export CSV", + "actorId": "Actor ID", + "allowedByRule": "Allowed by Rule", + "allowedNoAuth": "Allowed No Auth", + "validAccessToken": "Valid Access Token", + "validHeaderAuth": "Valid header auth", + "validPincode": "Valid Pincode", + "validPassword": "Valid Password", + "validEmail": "Valid email", + "validSSO": "Valid SSO", + "resourceBlocked": "Resource Blocked", + "droppedByRule": "Dropped by Rule", + "noSessions": "No Sessions", + "temporaryRequestToken": "Temporary Request Token", + "noMoreAuthMethods": "No Valid Auth", + "ip": "IP", + "reason": "Reason", + "requestLogs": "Request Logs", + "host": "Host", + "location": "Location", + "actionLogs": "Action Logs", + "sidebarLogsRequest": "Request Logs", + "sidebarLogsAccess": "Access Logs", + "sidebarLogsAction": "Action Logs", + "logRetention": "Log Retention", + "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", + "requestLogsDescription": "View detailed request logs for resources in this organization", + "logRetentionRequestLabel": "Request Log Retention", + "logRetentionRequestDescription": "How long to retain request logs", + "logRetentionAccessLabel": "Access Log Retention", + "logRetentionAccessDescription": "How long to retain access logs", + "logRetentionActionLabel": "Action Log Retention", + "logRetentionActionDescription": "How long to retain action logs", + "logRetentionDisabled": "Disabled", + "logRetention3Days": "3 days", + "logRetention7Days": "7 days", + "logRetention14Days": "14 days", + "logRetention30Days": "30 days", + "logRetention90Days": "90 days", + "logRetentionForever": "Forever", + "actionLogsDescription": "View a history of actions performed in this organization", + "accessLogsDescription": "View access auth requests for resources in this organization", + "certResolver": "Oddělovač certifikátů", + "certResolverDescription": "Vyberte řešitele certifikátů pro tento dokument.", + "selectCertResolver": "Vyberte řešič certifikátů", + "enterCustomResolver": "Zadejte vlastní rozlišovač", + "preferWildcardCert": "Preferovat Wildcard certifikát", + "unverified": "Neověřeno", + "domainSetting": "Nastavení domény", + "domainSettingDescription": "Konfigurace nastavení pro vaši doménu", + "preferWildcardCertDescription": "Pokus o vygenerování zástupného certifikátu (vyžaduje správně nakonfigurovaný certifikát).", + "recordName": "Název záznamu", + "auto": "Automaticky", + "TTL": "TTL", + "howToAddRecords": "Jak přidat záznamy", + "dnsRecord": "Záznamy DNS", + "required": "Požadováno", + "domainSettingsUpdated": "Nastavení domény bylo úspěšně aktualizováno", + "orgOrDomainIdMissing": "Chybí ID organizace nebo domény", + "loadingDNSRecords": "Načítání DNS záznamů...", + "olmUpdateAvailableInfo": "Je k dispozici aktualizovaná verze Olm. Pro nejlepší zážitek prosím aktualizujte na nejnovější verzi.", + "client": "Zákazník", + "proxyProtocol": "Nastavení proxy protokolu", + "proxyProtocolDescription": "Konfigurace Proxy protokolu pro zachování klientských IP adres pro služby TCP/UDP.", + "enableProxyProtocol": "Povolit Proxy protokol", + "proxyProtocolInfo": "Zachovat IP adresy klienta pro TCP/UDP backends", + "proxyProtocolVersion": "Verze proxy protokolu", + "version1": " Verze 1 (doporučeno)", + "version2": "Verze 2", + "versionDescription": "Verze 1 je textová a široce podporovaná. Verze 2 je binární a efektivnější, ale méně kompatibilní.", + "warning": "Varování", + "proxyProtocolWarning": "Vaše backend aplikace musí být nakonfigurována, aby mohla být přijata spojení s Proxy protokolem. Pokud vaše backend nepodporuje Proxy protokol, povolením tohoto protokolu dojde k přerušení všech připojení. Ujistěte se, že nastavíte svou backend a důvěřujte hlavičkám Proxy protokolu z Traefik." } diff --git a/messages/de-DE.json b/messages/de-DE.json index 774b0bd8..7d651614 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -10,7 +10,7 @@ "setupErrorIdentifier": "Organisations-ID ist bereits vergeben. Bitte wähle eine andere.", "componentsErrorNoMemberCreate": "Du bist derzeit kein Mitglied einer Organisation. Erstelle eine Organisation, um zu starten.", "componentsErrorNoMember": "Du bist aktuell kein Mitglied einer Organisation.", - "welcome": "Willkommen zu Pangolin", + "welcome": "Willkommen bei Pangolin!", "welcomeTo": "Willkommen bei", "componentsCreateOrg": "Erstelle eine Organisation", "componentsMember": "Du bist Mitglied von {count, plural, =0 {keiner Organisation} one {einer Organisation} other {# Organisationen}}.", @@ -27,7 +27,7 @@ "inviteLogInOtherUser": "Als anderer Benutzer anmelden", "createAnAccount": "Konto erstellen", "inviteNotAccepted": "Einladung nicht angenommen", - "authCreateAccount": "Erstellen ein Konto um loszulegen", + "authCreateAccount": "Erstelle ein Konto um loszulegen", "authNoAccount": "Du besitzt noch kein Konto?", "email": "E-Mail", "password": "Passwort", @@ -63,7 +63,7 @@ "siteLearnNewt": "Wie du Newt auf deinem System installieren kannst", "siteSeeConfigOnce": "Du kannst die Konfiguration nur einmalig ansehen.", "siteLoadWGConfig": "Lade WireGuard Konfiguration...", - "siteDocker": "Erweitern für Docker Details", + "siteDocker": "Docker-Details anzeigen", "toggle": "Umschalten", "dockerCompose": "Docker Compose", "dockerRun": "Docker Run", @@ -118,7 +118,7 @@ "tokenId": "Token-ID", "requestHeades": "Anfrage-Header", "queryParameter": "Abfrageparameter", - "importantNote": "Wichtige Notiz", + "importantNote": "Wichtiger Hinweis", "shareImportantDescription": "Aus Sicherheitsgründen wird die Verwendung von Headern über Abfrageparameter empfohlen, wenn möglich, da Abfrageparameter in Server-Logs oder Browserverlauf protokolliert werden können.", "token": "Token", "shareTokenSecurety": "Halten Sie Ihr Zugangs-Token sicher. Teilen Sie es nicht in öffentlich zugänglichen Bereichen oder Client-seitigem Code.", @@ -131,7 +131,7 @@ "expireIn": "Verfällt in", "neverExpire": "Nie ablaufen", "shareExpireDescription": "Ablaufzeit ist, wie lange der Link verwendet werden kann und bietet Zugriff auf die Ressource. Nach dieser Zeit wird der Link nicht mehr funktionieren und Benutzer, die diesen Link benutzt haben, verlieren den Zugriff auf die Ressource.", - "shareSeeOnce": "Sie können diese Linie nur sehen. Bitte kopieren Sie sie.", + "shareSeeOnce": "Sie können diesen Link nur ein einziges Mal sehen. Bitte kopieren Sie ihn.", "shareAccessHint": "Jeder mit diesem Link kann auf die Ressource zugreifen. Teilen Sie sie mit Vorsicht.", "shareTokenUsage": "Zugriffstoken-Nutzung anzeigen", "createLink": "Link erstellen", @@ -156,7 +156,7 @@ "resourceQuestionRemove": "Sind Sie sicher, dass Sie die Ressource aus der Organisation entfernen möchten?", "resourceHTTP": "HTTPS-Ressource", "resourceHTTPDescription": "Proxy-Anfragen an Ihre App über HTTPS unter Verwendung einer Subdomain oder einer Basis-Domain.", - "resourceRaw": "Rohe TCP/UDP Ressource", + "resourceRaw": "Direkte TCP/UDP Ressource (raw)", "resourceRawDescription": "Proxy-Anfragen an Ihre App über TCP/UDP mit einer Portnummer.", "resourceCreate": "Ressource erstellen", "resourceCreateDescription": "Folgen Sie den Schritten unten, um eine neue Ressource zu erstellen", @@ -174,10 +174,10 @@ "resourceTypeDescription": "Legen Sie fest, wie Sie auf Ihre Ressource zugreifen möchten", "resourceHTTPSSettings": "HTTPS-Einstellungen", "resourceHTTPSSettingsDescription": "Konfigurieren Sie den Zugriff auf Ihre Ressource über HTTPS", - "domainType": "Domänentyp", + "domainType": "Domain-Typ", "subdomain": "Subdomain", - "baseDomain": "Basisdomäne", - "subdomnainDescription": "Die Subdomäne, auf die Ihre Ressource zugegriffen werden soll.", + "baseDomain": "Basis-Domain", + "subdomnainDescription": "Die Subdomain, auf der Ihre Ressource erreichbar sein soll.", "resourceRawSettings": "TCP/UDP Einstellungen", "resourceRawSettingsDescription": "Konfigurieren Sie den Zugriff auf Ihre Ressource über TCP/UDP", "protocol": "Protokoll", @@ -188,7 +188,7 @@ "resourceConfig": "Konfiguration Snippets", "resourceConfigDescription": "Kopieren und fügen Sie diese Konfigurations-Snippets ein, um Ihre TCP/UDP Ressource einzurichten", "resourceAddEntrypoints": "Traefik: Einstiegspunkte hinzufügen", - "resourceExposePorts": "Gerbil: Ports im Docker Compose ausblenden", + "resourceExposePorts": "Gerbil: Ports im Docker Compose freigeben", "resourceLearnRaw": "Lernen Sie, wie Sie TCP/UDP Ressourcen konfigurieren", "resourceBack": "Zurück zu den Ressourcen", "resourceGoTo": "Zu Ressource gehen", @@ -461,8 +461,8 @@ "accessUsersRolesDescription": "Laden Sie Benutzer ein und fügen Sie sie zu Rollen hinzu, um den Zugriff auf Ihre Organisation zu verwalten", "key": "Schlüssel", "createdAt": "Erstellt am", - "proxyErrorInvalidHeader": "Ungültiger benutzerdefinierter Host-Header-Wert. Verwenden Sie das Domänennamensformat oder speichern Sie leer, um den benutzerdefinierten Host-Header zu deaktivieren.", - "proxyErrorTls": "Ungültiger TLS-Servername. Verwenden Sie das Domänennamensformat oder speichern Sie leer, um den TLS-Servernamen zu entfernen.", + "proxyErrorInvalidHeader": "Ungültiger benutzerdefinierter Host-Header-Wert. Verwenden Sie das Domain-Namensformat oder speichern Sie leer, um den benutzerdefinierten Host-Header zu deaktivieren.", + "proxyErrorTls": "Ungültiger TLS-Servername. Verwenden Sie das Domain-Namensformat oder speichern Sie leer, um den TLS-Servernamen zu entfernen.", "proxyEnableSSL": "SSL aktivieren", "proxyEnableSSLDescription": "Aktiviere SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zu deinen Zielen.", "target": "Target", @@ -526,7 +526,7 @@ "ipAddressErrorInvalidFormat": "Ungültiges IP-Adressformat", "ipAddressErrorInvalidOctet": "Ungültiges IP-Adress-Oktett", "path": "Pfad", - "matchPath": "Spielpfad", + "matchPath": "Match-Pfad", "ipAddressRange": "IP-Bereich", "rulesErrorFetch": "Fehler beim Abrufen der Regeln", "rulesErrorFetchDescription": "Beim Abrufen der Regeln ist ein Fehler aufgetreten", @@ -586,7 +586,7 @@ "none": "Keine", "unknown": "Unbekannt", "resources": "Ressourcen", - "resourcesDescription": "Ressourcen sind Proxies zu Anwendungen in Ihrem privaten Netzwerk. Erstellen Sie eine Ressource für jeden HTTP/HTTPS- oder rohen TCP/UDP-Dienst in Ihrem privaten Netzwerk. Jede Ressource muss mit einer Site verbunden sein, um private, sichere Konnektivität über einen verschlüsselten WireGuard-Tunnel zu ermöglichen.", + "resourcesDescription": "Ressourcen sind Proxies zu Anwendungen in Ihrem privaten Netzwerk. Erstellen Sie eine Ressource für jeden HTTP/HTTPS- oder direkten TCP/UDP-Dienst in Ihrem privaten Netzwerk. Jede Ressource muss mit einer Site verbunden sein, um private, sichere Konnektivität über einen verschlüsselten WireGuard-Tunnel zu ermöglichen.", "resourcesWireGuardConnect": "Sichere Verbindung mit WireGuard-Verschlüsselung", "resourcesMultipleAuthenticationMethods": "Mehrere Authentifizierungsmethoden konfigurieren", "resourcesUsersRolesAccess": "Benutzer- und rollenbasierte Zugriffskontrolle", @@ -911,6 +911,18 @@ "passwordResetCodeDescription": "Prüfen Sie Ihre E-Mail für den Reset-Code.", "passwordNew": "Neues Passwort", "passwordNewConfirm": "Neues Passwort bestätigen", + "changePassword": "Change Password", + "changePasswordDescription": "Update your account password", + "oldPassword": "Current Password", + "newPassword": "New Password", + "confirmNewPassword": "Confirm New Password", + "changePasswordError": "Failed to change password", + "changePasswordErrorDescription": "An error occurred while changing your password", + "changePasswordSuccess": "Password Changed Successfully", + "changePasswordSuccessDescription": "Your password has been updated successfully", + "passwordExpiryRequired": "Password Expiry Required", + "passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.", + "changePasswordNow": "Change Password Now", "pincodeAuth": "Authentifizierungscode", "pincodeSubmit2": "Code absenden", "passwordResetSubmit": "Zurücksetzung anfordern", @@ -1004,7 +1016,7 @@ "actionUpdateUser": "Benutzer aktualisieren", "actionGetUser": "Benutzer abrufen", "actionGetOrgUser": "Organisationsbenutzer abrufen", - "actionListOrgDomains": "Organisationsdomänen auflisten", + "actionListOrgDomains": "Organisationsdomains auflisten", "actionCreateSite": "Standort erstellen", "actionDeleteSite": "Standort löschen", "actionGetSite": "Standort abrufen", @@ -1158,15 +1170,15 @@ "containersIn": "Container in {siteName}", "selectContainerDescription": "Wählen Sie einen Container, der als Hostname für dieses Ziel verwendet werden soll. Klicken Sie auf einen Port, um einen Port zu verwenden.", "containerName": "Name", - "containerImage": "Bild", - "containerState": "Bundesland", + "containerImage": "Image", + "containerState": "Status", "containerNetworks": "Netzwerke", "containerHostnameIp": "Hostname/IP", "containerLabels": "Etiketten", "containerLabelsCount": "{count, plural, one {# Etikett} other {# Etiketten}}", "containerLabelsTitle": "Container-Labels", "containerLabelEmpty": "", - "containerPorts": "Häfen", + "containerPorts": "Ports", "containerPortsMore": "+{count} mehr", "containerActions": "Aktionen", "select": "Auswählen", @@ -1178,7 +1190,7 @@ "searchResultsCount": "{count, plural, one {# Ergebnis} other {# Ergebnisse}}", "filters": "Filter", "filterOptions": "Filteroptionen", - "filterPorts": "Häfen", + "filterPorts": "Ports", "filterStopped": "Stoppt", "clearAllFilters": "Alle Filter löschen", "columns": "Spalten", @@ -1247,13 +1259,13 @@ "settingsErrorUpdate": "Einstellungen konnten nicht aktualisiert werden", "settingsErrorUpdateDescription": "Beim Aktualisieren der Einstellungen ist ein Fehler aufgetreten", "sidebarCollapse": "Zusammenklappen", - "sidebarExpand": "Erweitern", + "sidebarExpand": "Aufklappen", "newtUpdateAvailable": "Update verfügbar", "newtUpdateAvailableInfo": "Eine neue Version von Newt ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.", "domainPickerEnterDomain": "Domäne", "domainPickerPlaceholder": "myapp.example.com", - "domainPickerDescription": "Geben Sie die vollständige Domäne der Ressource ein, um verfügbare Optionen zu sehen.", - "domainPickerDescriptionSaas": "Geben Sie eine vollständige Domäne, Subdomäne oder einfach einen Namen ein, um verfügbare Optionen zu sehen", + "domainPickerDescription": "Geben Sie die vollständige Domain der Ressource ein, um verfügbare Optionen zu sehen.", + "domainPickerDescriptionSaas": "Geben Sie eine vollständige Domain, Subdomain oder einfach einen Namen ein, um verfügbare Optionen zu sehen", "domainPickerTabAll": "Alle", "domainPickerTabOrganization": "Organisation", "domainPickerTabProvided": "Bereitgestellt", @@ -1278,7 +1290,7 @@ "billingDataUsage": "Datenverbrauch", "billingOnlineTime": "Online-Zeit der Seite", "billingUsers": "Aktive Benutzer", - "billingDomains": "Aktive Domänen", + "billingDomains": "Aktive Domains", "billingRemoteExitNodes": "Aktive selbstgehostete Nodes", "billingNoLimitConfigured": "Kein Limit konfiguriert", "billingEstimatedPeriod": "Geschätzter Abrechnungszeitraum", @@ -1306,7 +1318,7 @@ "billingDataUsageInfo": "Wenn Sie mit der Cloud verbunden sind, werden alle Daten über Ihre sicheren Tunnel belastet. Dies schließt eingehenden und ausgehenden Datenverkehr über alle Ihre Websites ein. Wenn Sie Ihr Limit erreichen, werden Ihre Seiten die Verbindung trennen, bis Sie Ihr Paket upgraden oder die Nutzung verringern. Daten werden nicht belastet, wenn Sie Knoten verwenden.", "billingOnlineTimeInfo": "Sie werden belastet, abhängig davon, wie lange Ihre Seiten mit der Cloud verbunden bleiben. Zum Beispiel 44.640 Minuten entspricht einer Site, die 24 Stunden am Tag des Monats läuft. Wenn Sie Ihr Limit erreichen, werden Ihre Seiten die Verbindung trennen, bis Sie Ihr Paket upgraden oder die Nutzung verringern. Die Zeit wird nicht belastet, wenn Sie Knoten verwenden.", "billingUsersInfo": "Ihnen wird für jeden Benutzer in Ihrer Organisation berechnet. Die Abrechnung erfolgt täglich basierend auf der Anzahl der aktiven Benutzerkonten in Ihrer Organisation.", - "billingDomainInfo": "Ihnen wird für jede Domäne in Ihrer Organisation berechnet. Die Abrechnung erfolgt täglich basierend auf der Anzahl der aktiven Domänenkonten in Ihrer Organisation.", + "billingDomainInfo": "Ihnen wird jede Domain in Ihrer Organisation berechnet. Die Abrechnung erfolgt täglich, basierend auf der Anzahl der aktiven Domain-Konten in Ihrer Organisation.", "billingRemoteExitNodesInfo": "Ihnen wird für jeden verwalteten Node in Ihrer Organisation berechnet. Die Abrechnung erfolgt täglich basierend auf der Anzahl der aktiven verwalteten Nodes in Ihrer Organisation.", "domainNotFound": "Domain nicht gefunden", "domainNotFoundDescription": "Diese Ressource ist deaktiviert, weil die Domain nicht mehr in unserem System existiert. Bitte setzen Sie eine neue Domain für diese Ressource.", @@ -1340,6 +1352,19 @@ "securityKeyUnknownError": "Es gab ein Problem mit Ihrem Sicherheitsschlüssel. Bitte versuchen Sie es erneut.", "twoFactorRequired": "Zur Registrierung eines Sicherheitsschlüssels ist eine Zwei-Faktor-Authentifizierung erforderlich.", "twoFactor": "Zwei-Faktor-Authentifizierung", + "twoFactorAuthentication": "Two-Factor Authentication", + "twoFactorDescription": "This organization requires two-factor authentication.", + "enableTwoFactor": "Enable Two-Factor Authentication", + "organizationSecurityPolicy": "Organization Security Policy", + "organizationSecurityPolicyDescription": "This organization has security requirements that must be met before you can access it", + "securityRequirements": "Security Requirements", + "allRequirementsMet": "All requirements have been met", + "completeRequirementsToContinue": "Complete the requirements below to continue accessing this organization", + "youCanNowAccessOrganization": "You can now access this organization", + "reauthenticationRequired": "Session Length", + "reauthenticationDescription": "This organization requires you to log in every {maxDays} days.", + "reauthenticationDescriptionHours": "This organization requires you to log in every {maxHours} hours.", + "reauthenticateNow": "Log In Again", "adminEnabled2FaOnYourAccount": "Ihr Administrator hat die Zwei-Faktor-Authentifizierung für {email} aktiviert. Bitte schließen Sie den Einrichtungsprozess ab, um fortzufahren.", "securityKeyAdd": "Sicherheitsschlüssel hinzufügen", "securityKeyRegisterTitle": "Neuen Sicherheitsschlüssel registrieren", @@ -1381,7 +1406,7 @@ "olmTunnel": "Olm-Tunnel", "olmTunnelDescription": "Nutzen Sie Olm für die Kundenverbindung", "errorCreatingClient": "Fehler beim Erstellen des Clients", - "clientDefaultsNotFound": "Kundenvorgaben nicht gefunden", + "clientDefaultsNotFound": "Standardeinstellungen des Clients nicht gefunden", "createClient": "Client erstellen", "createClientDescription": "Erstellen Sie einen neuen Client für die Verbindung zu Ihren Standorten.", "seeAllClients": "Alle Clients anzeigen", @@ -1458,13 +1483,13 @@ "httpMethod": "HTTP-Methode", "selectHttpMethod": "HTTP-Methode auswählen", "domainPickerSubdomainLabel": "Subdomain", - "domainPickerBaseDomainLabel": "Basisdomäne", + "domainPickerBaseDomainLabel": "Basisdomain", "domainPickerSearchDomains": "Domains suchen...", "domainPickerNoDomainsFound": "Keine Domains gefunden", "domainPickerLoadingDomains": "Domains werden geladen...", - "domainPickerSelectBaseDomain": "Basisdomäne auswählen...", + "domainPickerSelectBaseDomain": "Basisdomain auswählen...", "domainPickerNotAvailableForCname": "Für CNAME-Domains nicht verfügbar", - "domainPickerEnterSubdomainOrLeaveBlank": "Geben Sie eine Subdomain ein oder lassen Sie das Feld leer, um die Basisdomäne zu verwenden.", + "domainPickerEnterSubdomainOrLeaveBlank": "Geben Sie eine Subdomain ein oder lassen Sie das Feld leer, um die Basisdomain zu verwenden.", "domainPickerEnterSubdomainToSearch": "Geben Sie eine Subdomain ein, um verfügbare freie Domains zu suchen und auszuwählen.", "domainPickerFreeDomains": "Freie Domains", "domainPickerSearchForAvailableDomains": "Verfügbare Domains suchen", @@ -1479,7 +1504,7 @@ "resourcesTableNoInternalResourcesFound": "Keine internen Ressourcen gefunden.", "resourcesTableDestination": "Ziel", "resourcesTableTheseResourcesForUseWith": "Diese Ressourcen sind zur Verwendung mit", - "resourcesTableClients": "Kunden", + "resourcesTableClients": "Clients", "resourcesTableAndOnlyAccessibleInternally": "und sind nur intern zugänglich, wenn mit einem Client verbunden.", "editInternalResourceDialogEditClientResource": "Client-Ressource bearbeiten", "editInternalResourceDialogUpdateResourceProperties": "Aktualisieren Sie die Ressourceneigenschaften und die Zielkonfiguration für {resourceName}.", @@ -1552,7 +1577,7 @@ "autoLoginErrorNoRedirectUrl": "Keine Weiterleitungs-URL vom Identitätsanbieter erhalten.", "autoLoginErrorGeneratingUrl": "Fehler beim Generieren der Authentifizierungs-URL.", "remoteExitNodeManageRemoteExitNodes": "Entfernte Knoten", - "remoteExitNodeDescription": "Self-Hoster einen oder mehrere entfernte Knoten, um Ihre Netzwerkverbindung zu erweitern und die Abhängigkeit von der Cloud zu verringern", + "remoteExitNodeDescription": "Self-Hosten Sie einen oder mehrere entfernte Knoten, um Ihr Netzwerk zu erweitern und die Abhängigkeit von der Cloud zu verringern", "remoteExitNodes": "Knoten", "searchRemoteExitNodes": "Knoten suchen...", "remoteExitNodeAdd": "Knoten hinzufügen", @@ -1564,7 +1589,7 @@ "sidebarRemoteExitNodes": "Entfernte Knoten", "remoteExitNodeCreate": { "title": "Knoten erstellen", - "description": "Erstellen Sie einen neuen Knoten, um Ihre Netzwerkverbindung zu erweitern", + "description": "Erstellen Sie einen neuen Knoten, um Ihr Netzwerk zu erweitern", "viewAllButton": "Alle Knoten anzeigen", "strategy": { "title": "Erstellungsstrategie", @@ -1672,7 +1697,7 @@ "idpAzureConfigurationDescription": "Konfigurieren Sie Ihre Azure Entra ID OAuth2 Zugangsdaten", "idpTenantId": "Mandanten-ID", "idpTenantIdPlaceholder": "deine Mandant-ID", - "idpAzureTenantIdDescription": "Ihre Azure Mieter-ID (gefunden in Azure Active Directory Übersicht)", + "idpAzureTenantIdDescription": "Ihre Azure Tenant-ID (gefunden in Azure Active Directory Übersicht)", "idpAzureClientIdDescription": "Ihre Azure App Registration Client ID", "idpAzureClientSecretDescription": "Ihr Azure App Registration Client Secret", "idpGoogleTitle": "Google", @@ -1691,7 +1716,7 @@ "authPage": "Auth Seite", "authPageDescription": "Konfigurieren Sie die Auth-Seite für Ihre Organisation", "authPageDomain": "Domain der Auth Seite", - "noDomainSet": "Keine Domäne gesetzt", + "noDomainSet": "Keine Domain gesetzt", "changeDomain": "Domain ändern", "selectDomain": "Domain auswählen", "restartCertificate": "Zertifikat neu starten", @@ -1707,7 +1732,7 @@ "domainPickerUnverified": "Nicht verifiziert", "domainPickerInvalidSubdomainStructure": "Diese Subdomain enthält ungültige Zeichen oder Struktur. Sie wird beim Speichern automatisch bereinigt.", "domainPickerError": "Fehler", - "domainPickerErrorLoadDomains": "Fehler beim Laden der Organisations-Domänen", + "domainPickerErrorLoadDomains": "Fehler beim Laden der Organisations-Domains", "domainPickerErrorCheckAvailability": "Fehler beim Prüfen der Domain-Verfügbarkeit", "domainPickerInvalidSubdomain": "Ungültige Subdomain", "domainPickerInvalidSubdomainRemoved": "Die Eingabe \"{sub}\" wurde entfernt, weil sie nicht gültig ist.", @@ -1719,6 +1744,7 @@ "orgAuthNoIdpConfigured": "Diese Organisation hat keine Identitätsanbieter konfiguriert. Sie können sich stattdessen mit Ihrer Pangolin-Identität anmelden.", "orgAuthSignInWithPangolin": "Mit Pangolin anmelden", "subscriptionRequiredToUse": "Um diese Funktion nutzen zu können, ist ein Abonnement erforderlich.", + "licenseRequiredToUse": "An Enterprise license is required to use this feature.", "idpDisabled": "Identitätsanbieter sind deaktiviert.", "orgAuthPageDisabled": "Organisations-Authentifizierungsseite ist deaktiviert.", "domainRestartedDescription": "Domain-Verifizierung erfolgreich neu gestartet", @@ -1726,6 +1752,47 @@ "resourceExposePortsEditFile": "Datei bearbeiten: docker-compose.yml", "emailVerificationRequired": "E-Mail-Verifizierung ist erforderlich. Bitte melden Sie sich erneut über {dashboardUrl}/auth/login an. Kommen Sie dann wieder hierher.", "twoFactorSetupRequired": "Die Zwei-Faktor-Authentifizierung ist erforderlich. Bitte melden Sie sich erneut über {dashboardUrl}/auth/login an. Dann kommen Sie hierher zurück.", + "additionalSecurityRequired": "Additional Security Required", + "organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.", + "completeTheseSteps": "Complete these steps", + "enableTwoFactorAuthentication": "Enable two-factor authentication", + "completeSecuritySteps": "Complete Security Steps", + "securitySettings": "Security Settings", + "securitySettingsDescription": "Configure security policies for your organization", + "requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users", + "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", + "requireTwoFactorDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "requireTwoFactorCannotEnableDescription": "You must enable two-factor authentication for your account before enforcing it for all users", + "maxSessionLength": "Maximum Session Length", + "maxSessionLengthDescription": "Set the maximum duration for user sessions. After this time, users will need to re-authenticate.", + "maxSessionLengthDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "selectSessionLength": "Select session length", + "unenforced": "Unenforced", + "1Hour": "1 hour", + "3Hours": "3 hours", + "6Hours": "6 hours", + "12Hours": "12 hours", + "1DaySession": "1 day", + "3Days": "3 days", + "7Days": "7 days", + "14Days": "14 days", + "30DaysSession": "30 days", + "90DaysSession": "90 days", + "180DaysSession": "180 days", + "passwordExpiryDays": "Password Expiry", + "editPasswordExpiryDescription": "Set the number of days before users are required to change their password.", + "selectPasswordExpiry": "Select password expiry", + "30Days": "30 days", + "1Day": "1 day", + "60Days": "60 days", + "90Days": "90 days", + "180Days": "180 days", + "1Year": "1 year", + "subscriptionBadge": "Subscription Required", + "securityPolicyChangeWarning": "Security Policy Change Warning", + "securityPolicyChangeDescription": "You are about to change security policy settings. After saving, you may need to reauthenticate to comply with these policy updates. All users who are not compliant will also need to reauthenticate.", + "securityPolicyChangeConfirmMessage": "I confirm", + "securityPolicyChangeWarningText": "This will affect all users in the organization", "authPageErrorUpdateMessage": "Beim Aktualisieren der Auth-Seiten-Einstellungen ist ein Fehler aufgetreten", "authPageUpdated": "Auth-Seite erfolgreich aktualisiert", "healthCheckNotAvailable": "Lokal", @@ -1753,7 +1820,7 @@ "enterpriseEdition": "Enterprise Edition", "unlicensed": "Nicht lizenziert", "beta": "Beta", - "manageClients": "Kunden verwalten", + "manageClients": "Clients verwalten", "manageClientsDescription": "Clients sind Geräte, die sich mit Ihren Websites verbinden können", "licenseTableValidUntil": "Gültig bis", "saasLicenseKeysSettingsTitle": "Enterprise-Lizenzen", @@ -1885,11 +1952,92 @@ "pathRewritePrefix": "Präfix", "pathRewriteExact": "Exakt", "pathRewriteRegex": "Regex", - "pathRewriteStrip": "Streifen", - "pathRewriteStripLabel": "streifen", + "pathRewriteStrip": "Entfernen", + "pathRewriteStripLabel": "entfernen", "sidebarEnableEnterpriseLicense": "Enterprise-Lizenz aktivieren", "cannotbeUndone": "Dies kann nicht rückgängig gemacht werden.", "toConfirm": "bestätigen", "deleteClientQuestion": "Sind Sie sicher, dass Sie den Client von der Website und der Organisation entfernen möchten?", - "clientMessageRemove": "Nach dem Entfernen kann sich der Client nicht mehr mit der Website verbinden." + "clientMessageRemove": "Nach dem Entfernen kann sich der Client nicht mehr mit der Website verbinden.", + "sidebarLogs": "Logs", + "request": "Request", + "logs": "Logs", + "logsSettingsDescription": "Monitor logs collected from this orginization", + "searchLogs": "Search logs...", + "action": "Action", + "actor": "Actor", + "timestamp": "Timestamp", + "accessLogs": "Access Logs", + "exportCsv": "Export CSV", + "actorId": "Actor ID", + "allowedByRule": "Allowed by Rule", + "allowedNoAuth": "Allowed No Auth", + "validAccessToken": "Valid Access Token", + "validHeaderAuth": "Valid header auth", + "validPincode": "Valid Pincode", + "validPassword": "Valid Password", + "validEmail": "Valid email", + "validSSO": "Valid SSO", + "resourceBlocked": "Resource Blocked", + "droppedByRule": "Dropped by Rule", + "noSessions": "No Sessions", + "temporaryRequestToken": "Temporary Request Token", + "noMoreAuthMethods": "No Valid Auth", + "ip": "IP", + "reason": "Reason", + "requestLogs": "Request Logs", + "host": "Host", + "location": "Location", + "actionLogs": "Action Logs", + "sidebarLogsRequest": "Request Logs", + "sidebarLogsAccess": "Access Logs", + "sidebarLogsAction": "Action Logs", + "logRetention": "Log Retention", + "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", + "requestLogsDescription": "View detailed request logs for resources in this organization", + "logRetentionRequestLabel": "Request Log Retention", + "logRetentionRequestDescription": "How long to retain request logs", + "logRetentionAccessLabel": "Access Log Retention", + "logRetentionAccessDescription": "How long to retain access logs", + "logRetentionActionLabel": "Action Log Retention", + "logRetentionActionDescription": "How long to retain action logs", + "logRetentionDisabled": "Disabled", + "logRetention3Days": "3 days", + "logRetention7Days": "7 days", + "logRetention14Days": "14 days", + "logRetention30Days": "30 days", + "logRetention90Days": "90 days", + "logRetentionForever": "Forever", + "actionLogsDescription": "View a history of actions performed in this organization", + "accessLogsDescription": "View access auth requests for resources in this organization", + "certResolver": "Zertifikatsauflöser", + "certResolverDescription": "Wählen Sie den Zertifikatslöser aus, der für diese Ressource verwendet werden soll.", + "selectCertResolver": "Zertifikatsauflöser auswählen", + "enterCustomResolver": "Eigenen Auflöser eingeben", + "preferWildcardCert": "Wildcard-Zertifikat bevorzugen", + "unverified": "Nicht verifiziert", + "domainSetting": "Domänen-Einstellungen", + "domainSettingDescription": "Einstellungen für Ihre Domain konfigurieren", + "preferWildcardCertDescription": "Versuch ein Platzhalterzertifikat zu generieren (erfordert einen richtig konfigurierten Zertifikatslöser).", + "recordName": "Name des Datensatzes", + "auto": "Auto", + "TTL": "TTL", + "howToAddRecords": "So kann man Datensätze hinzufügen", + "dnsRecord": "DNS-Einträge", + "required": "Benötigt", + "domainSettingsUpdated": "Domain-Einstellungen erfolgreich aktualisiert", + "orgOrDomainIdMissing": "Organisation oder Domänen-ID fehlt", + "loadingDNSRecords": "Lade DNS-Einträge...", + "olmUpdateAvailableInfo": "Eine aktualisierte Version von Olm ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für die beste Erfahrung.", + "client": "Kunde", + "proxyProtocol": "Proxy-Protokoll-Einstellungen", + "proxyProtocolDescription": "Konfigurieren Sie das Proxy-Protokoll, um die IP-Adressen des Clients für TCP/UDP-Dienste zu erhalten.", + "enableProxyProtocol": "Proxy-Protokoll aktivieren", + "proxyProtocolInfo": "Client-IP-Adressen für TCP/UDP Backends beibehalten", + "proxyProtocolVersion": "Proxy-Protokollversion", + "version1": " Version 1 (empfohlen)", + "version2": "Version 2", + "versionDescription": "Die Version 1 ist textbasiert und unterstützt die Version 2, ist binär und effizienter, aber weniger kompatibel.", + "warning": "Warnung", + "proxyProtocolWarning": "Ihre Backend-Anwendung muss so konfiguriert sein, dass sie Proxy-Protokoll-Verbindungen akzeptiert. Wenn Ihr Backend das Proxy-Protokoll nicht unterstützt, wird die Aktivierung dieser Option alle Verbindungen zerstören. Stellen Sie sicher, dass Sie Ihr Backend so konfigurieren, dass es Proxy-Protokoll-Header von Traefik vertraut." } diff --git a/messages/en-US.json b/messages/en-US.json index 48b1be62..eac20db3 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -911,6 +911,18 @@ "passwordResetCodeDescription": "Check your email for the reset code.", "passwordNew": "New Password", "passwordNewConfirm": "Confirm New Password", + "changePassword": "Change Password", + "changePasswordDescription": "Update your account password", + "oldPassword": "Current Password", + "newPassword": "New Password", + "confirmNewPassword": "Confirm New Password", + "changePasswordError": "Failed to change password", + "changePasswordErrorDescription": "An error occurred while changing your password", + "changePasswordSuccess": "Password Changed Successfully", + "changePasswordSuccessDescription": "Your password has been updated successfully", + "passwordExpiryRequired": "Password Expiry Required", + "passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.", + "changePasswordNow": "Change Password Now", "pincodeAuth": "Authenticator Code", "pincodeSubmit2": "Submit Code", "passwordResetSubmit": "Request Reset", @@ -1360,6 +1372,19 @@ "securityKeyUnknownError": "There was a problem using your security key. Please try again.", "twoFactorRequired": "Two-factor authentication is required to register a security key.", "twoFactor": "Two-Factor Authentication", + "twoFactorAuthentication": "Two-Factor Authentication", + "twoFactorDescription": "This organization requires two-factor authentication.", + "enableTwoFactor": "Enable Two-Factor Authentication", + "organizationSecurityPolicy": "Organization Security Policy", + "organizationSecurityPolicyDescription": "This organization has security requirements that must be met before you can access it", + "securityRequirements": "Security Requirements", + "allRequirementsMet": "All requirements have been met", + "completeRequirementsToContinue": "Complete the requirements below to continue accessing this organization", + "youCanNowAccessOrganization": "You can now access this organization", + "reauthenticationRequired": "Session Length", + "reauthenticationDescription": "This organization requires you to log in every {maxDays} days.", + "reauthenticationDescriptionHours": "This organization requires you to log in every {maxHours} hours.", + "reauthenticateNow": "Log In Again", "adminEnabled2FaOnYourAccount": "Your administrator has enabled two-factor authentication for {email}. Please complete the setup process to continue.", "securityKeyAdd": "Add Security Key", "securityKeyRegisterTitle": "Register New Security Key", @@ -1746,6 +1771,47 @@ "resourceExposePortsEditFile": "Edit file: docker-compose.yml", "emailVerificationRequired": "Email verification is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here.", "twoFactorSetupRequired": "Two-factor authentication setup is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here.", + "additionalSecurityRequired": "Additional Security Required", + "organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.", + "completeTheseSteps": "Complete these steps", + "enableTwoFactorAuthentication": "Enable two-factor authentication", + "completeSecuritySteps": "Complete Security Steps", + "securitySettings": "Security Settings", + "securitySettingsDescription": "Configure security policies for your organization", + "requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users", + "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", + "requireTwoFactorDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "requireTwoFactorCannotEnableDescription": "You must enable two-factor authentication for your account before enforcing it for all users", + "maxSessionLength": "Maximum Session Length", + "maxSessionLengthDescription": "Set the maximum duration for user sessions. After this time, users will need to re-authenticate.", + "maxSessionLengthDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "selectSessionLength": "Select session length", + "unenforced": "Unenforced", + "1Hour": "1 hour", + "3Hours": "3 hours", + "6Hours": "6 hours", + "12Hours": "12 hours", + "1DaySession": "1 day", + "3Days": "3 days", + "7Days": "7 days", + "14Days": "14 days", + "30DaysSession": "30 days", + "90DaysSession": "90 days", + "180DaysSession": "180 days", + "passwordExpiryDays": "Password Expiry", + "editPasswordExpiryDescription": "Set the number of days before users are required to change their password.", + "selectPasswordExpiry": "Select password expiry", + "30Days": "30 days", + "1Day": "1 day", + "60Days": "60 days", + "90Days": "90 days", + "180Days": "180 days", + "1Year": "1 year", + "subscriptionBadge": "Subscription Required", + "securityPolicyChangeWarning": "Security Policy Change Warning", + "securityPolicyChangeDescription": "You are about to change security policy settings. After saving, you may need to reauthenticate to comply with these policy updates. All users who are not compliant will also need to reauthenticate.", + "securityPolicyChangeConfirmMessage": "I confirm", + "securityPolicyChangeWarningText": "This will affect all users in the organization", "authPageErrorUpdateMessage": "An error occurred while updating the auth page settings", "authPageUpdated": "Auth page updated successfully", "healthCheckNotAvailable": "Local", @@ -1911,5 +1977,108 @@ "cannotbeUndone": "This can not be undone.", "toConfirm": "to confirm", "deleteClientQuestion": "Are you sure you want to remove the client from the site and organization?", - "clientMessageRemove": "Once removed, the client will no longer be able to connect to the site." + "clientMessageRemove": "Once removed, the client will no longer be able to connect to the site.", + "sidebarLogs": "Logs", + "request": "Request", + "logs": "Logs", + "logsSettingsDescription": "Monitor logs collected from this orginization", + "searchLogs": "Search logs...", + "action": "Action", + "actor": "Actor", + "timestamp": "Timestamp", + "accessLogs": "Access Logs", + "exportCsv": "Export CSV", + "actorId": "Actor ID", + "allowedByRule": "Allowed by Rule", + "allowedNoAuth": "Allowed No Auth", + "validAccessToken": "Valid Access Token", + "validHeaderAuth": "Valid header auth", + "validPincode": "Valid Pincode", + "validPassword": "Valid Password", + "validEmail": "Valid email", + "validSSO": "Valid SSO", + "resourceBlocked": "Resource Blocked", + "droppedByRule": "Dropped by Rule", + "noSessions": "No Sessions", + "temporaryRequestToken": "Temporary Request Token", + "noMoreAuthMethods": "No Valid Auth", + "ip": "IP", + "reason": "Reason", + "requestLogs": "Request Logs", + "host": "Host", + "location": "Location", + "actionLogs": "Action Logs", + "sidebarLogsRequest": "Request Logs", + "sidebarLogsAccess": "Access Logs", + "sidebarLogsAction": "Action Logs", + "logRetention": "Log Retention", + "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", + "requestLogsDescription": "View detailed request logs for resources in this organization", + "logRetentionRequestLabel": "Request Log Retention", + "logRetentionRequestDescription": "How long to retain request logs", + "logRetentionAccessLabel": "Access Log Retention", + "logRetentionAccessDescription": "How long to retain access logs", + "logRetentionActionLabel": "Action Log Retention", + "logRetentionActionDescription": "How long to retain action logs", + "logRetentionDisabled": "Disabled", + "logRetention3Days": "3 days", + "logRetention7Days": "7 days", + "logRetention14Days": "14 days", + "logRetention30Days": "30 days", + "logRetention90Days": "90 days", + "logRetentionForever": "Forever", + "actionLogsDescription": "View a history of actions performed in this organization", + "accessLogsDescription": "View access auth requests for resources in this organization", + "licenseRequiredToUse": "An Enterprise license is required to use this feature.", + "certResolver": "Certificate Resolver", + "certResolverDescription": "Select the certificate resolver to use for this resource.", + "selectCertResolver": "Select Certificate Resolver", + "enterCustomResolver": "Enter Custom Resolver", + "preferWildcardCert": "Prefer Wildcard Certificate", + "unverified": "Unverified", + "domainSetting": "Domain Settings", + "domainSettingDescription": "Configure settings for your domain", + "preferWildcardCertDescription": "Attempt to generate a wildcard certificate (require a properly configured certificate resolver).", + "recordName": "Record Name", + "auto": "Auto", + "TTL": "TTL", + "howToAddRecords": "How to Add Records", + "dnsRecord": "DNS Records", + "required": "Required", + "domainSettingsUpdated": "Domain settings updated successfully", + "orgOrDomainIdMissing": "Organization or Domain ID is missing", + "loadingDNSRecords": "Loading DNS records...", + "olmUpdateAvailableInfo": "An updated version of Olm is available. Please update to the latest version for the best experience.", + "client": "Client", + "proxyProtocol": "Proxy Protocol Settings", + "proxyProtocolDescription": "Configure Proxy Protocol to preserve client IP addresses for TCP/UDP services.", + "enableProxyProtocol": "Enable Proxy Protocol", + "proxyProtocolInfo": "Preserve client IP addresses for TCP/UDP backends", + "proxyProtocolVersion": "Proxy Protocol Version", + "version1": " Version 1 (Recommended)", + "version2": "Version 2", + "versionDescription": "Version 1 is text-based and widely supported. Version 2 is binary and more efficient but less compatible.", + "warning": "Warning", + "proxyProtocolWarning": "Your backend application must be configured to accept Proxy Protocol connections. If your backend doesn't support Proxy Protocol, enabling this will break all connections. Make sure to configure your backend to trust Proxy Protocol headers from Traefik.", + "restarting": "Restarting...", + "manual": "Manual", + "messageSupport": "Message Support", + "supportNotAvailableTitle": "Support Not Available", + "supportNotAvailableDescription": "Support is not available right now. You can send an email to support@pangolin.net.", + "supportRequestSentTitle": "Support Request Sent", + "supportRequestSentDescription": "Your message has been sent successfully.", + "supportRequestFailedTitle": "Failed to Send Request", + "supportRequestFailedDescription": "An error occurred while sending your support request.", + "supportSubjectRequired": "Subject is required", + "supportSubjectMaxLength": "Subject must be 255 characters or less", + "supportMessageRequired": "Message is required", + "supportReplyTo": "Reply To", + "supportSubject": "Subject", + "supportSubjectPlaceholder": "Enter subject", + "supportMessage": "Message", + "supportMessagePlaceholder": "Enter your message", + "supportSending": "Sending...", + "supportSend": "Send", + "supportMessageSent": "Message Sent!", + "supportWillContact": "We'll be in touch shortly!" } diff --git a/messages/es-ES.json b/messages/es-ES.json index 661af6fc..671453f0 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -911,6 +911,18 @@ "passwordResetCodeDescription": "Revisa tu correo electrónico para ver el código de restablecimiento.", "passwordNew": "Nueva contraseña", "passwordNewConfirm": "Confirmar nueva contraseña", + "changePassword": "Change Password", + "changePasswordDescription": "Update your account password", + "oldPassword": "Current Password", + "newPassword": "New Password", + "confirmNewPassword": "Confirm New Password", + "changePasswordError": "Failed to change password", + "changePasswordErrorDescription": "An error occurred while changing your password", + "changePasswordSuccess": "Password Changed Successfully", + "changePasswordSuccessDescription": "Your password has been updated successfully", + "passwordExpiryRequired": "Password Expiry Required", + "passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.", + "changePasswordNow": "Change Password Now", "pincodeAuth": "Código de autenticación", "pincodeSubmit2": "Enviar código", "passwordResetSubmit": "Reiniciar Solicitud", @@ -1340,6 +1352,19 @@ "securityKeyUnknownError": "Hubo un problema al usar tu llave de seguridad. Por favor, inténtalo de nuevo.", "twoFactorRequired": "Se requiere autenticación de dos factores para registrar una llave de seguridad.", "twoFactor": "Autenticación de dos factores", + "twoFactorAuthentication": "Two-Factor Authentication", + "twoFactorDescription": "This organization requires two-factor authentication.", + "enableTwoFactor": "Enable Two-Factor Authentication", + "organizationSecurityPolicy": "Organization Security Policy", + "organizationSecurityPolicyDescription": "This organization has security requirements that must be met before you can access it", + "securityRequirements": "Security Requirements", + "allRequirementsMet": "All requirements have been met", + "completeRequirementsToContinue": "Complete the requirements below to continue accessing this organization", + "youCanNowAccessOrganization": "You can now access this organization", + "reauthenticationRequired": "Session Length", + "reauthenticationDescription": "This organization requires you to log in every {maxDays} days.", + "reauthenticationDescriptionHours": "This organization requires you to log in every {maxHours} hours.", + "reauthenticateNow": "Log In Again", "adminEnabled2FaOnYourAccount": "Su administrador ha habilitado la autenticación de dos factores para {email}. Por favor, complete el proceso de configuración para continuar.", "securityKeyAdd": "Agregar llave de seguridad", "securityKeyRegisterTitle": "Registrar nueva llave de seguridad", @@ -1719,6 +1744,7 @@ "orgAuthNoIdpConfigured": "Esta organización no tiene ningún proveedor de identidad configurado. En su lugar puedes iniciar sesión con tu identidad de Pangolin.", "orgAuthSignInWithPangolin": "Iniciar sesión con Pangolin", "subscriptionRequiredToUse": "Se requiere una suscripción para utilizar esta función.", + "licenseRequiredToUse": "An Enterprise license is required to use this feature.", "idpDisabled": "Los proveedores de identidad están deshabilitados.", "orgAuthPageDisabled": "La página de autenticación de la organización está deshabilitada.", "domainRestartedDescription": "Verificación de dominio reiniciada con éxito", @@ -1726,6 +1752,47 @@ "resourceExposePortsEditFile": "Editar archivo: docker-compose.yml", "emailVerificationRequired": "Se requiere verificación de correo electrónico. Por favor, inicie sesión de nuevo a través de {dashboardUrl}/auth/login complete este paso. Luego, vuelva aquí.", "twoFactorSetupRequired": "La configuración de autenticación de doble factor es requerida. Por favor, inicia sesión de nuevo a través de {dashboardUrl}/auth/login completa este paso. Luego, vuelve aquí.", + "additionalSecurityRequired": "Additional Security Required", + "organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.", + "completeTheseSteps": "Complete these steps", + "enableTwoFactorAuthentication": "Enable two-factor authentication", + "completeSecuritySteps": "Complete Security Steps", + "securitySettings": "Security Settings", + "securitySettingsDescription": "Configure security policies for your organization", + "requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users", + "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", + "requireTwoFactorDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "requireTwoFactorCannotEnableDescription": "You must enable two-factor authentication for your account before enforcing it for all users", + "maxSessionLength": "Maximum Session Length", + "maxSessionLengthDescription": "Set the maximum duration for user sessions. After this time, users will need to re-authenticate.", + "maxSessionLengthDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "selectSessionLength": "Select session length", + "unenforced": "Unenforced", + "1Hour": "1 hour", + "3Hours": "3 hours", + "6Hours": "6 hours", + "12Hours": "12 hours", + "1DaySession": "1 day", + "3Days": "3 days", + "7Days": "7 days", + "14Days": "14 days", + "30DaysSession": "30 days", + "90DaysSession": "90 days", + "180DaysSession": "180 days", + "passwordExpiryDays": "Password Expiry", + "editPasswordExpiryDescription": "Set the number of days before users are required to change their password.", + "selectPasswordExpiry": "Select password expiry", + "30Days": "30 days", + "1Day": "1 day", + "60Days": "60 days", + "90Days": "90 days", + "180Days": "180 days", + "1Year": "1 year", + "subscriptionBadge": "Subscription Required", + "securityPolicyChangeWarning": "Security Policy Change Warning", + "securityPolicyChangeDescription": "You are about to change security policy settings. After saving, you may need to reauthenticate to comply with these policy updates. All users who are not compliant will also need to reauthenticate.", + "securityPolicyChangeConfirmMessage": "I confirm", + "securityPolicyChangeWarningText": "This will affect all users in the organization", "authPageErrorUpdateMessage": "Ocurrió un error mientras se actualizaban los ajustes de la página auth", "authPageUpdated": "Página auth actualizada correctamente", "healthCheckNotAvailable": "Local", @@ -1891,5 +1958,86 @@ "cannotbeUndone": "Esto no se puede deshacer.", "toConfirm": "confirmar", "deleteClientQuestion": "¿Está seguro que desea eliminar el cliente del sitio y la organización?", - "clientMessageRemove": "Una vez eliminado, el cliente ya no podrá conectarse al sitio." + "clientMessageRemove": "Una vez eliminado, el cliente ya no podrá conectarse al sitio.", + "sidebarLogs": "Logs", + "request": "Request", + "logs": "Logs", + "logsSettingsDescription": "Monitor logs collected from this orginization", + "searchLogs": "Search logs...", + "action": "Action", + "actor": "Actor", + "timestamp": "Timestamp", + "accessLogs": "Access Logs", + "exportCsv": "Export CSV", + "actorId": "Actor ID", + "allowedByRule": "Allowed by Rule", + "allowedNoAuth": "Allowed No Auth", + "validAccessToken": "Valid Access Token", + "validHeaderAuth": "Valid header auth", + "validPincode": "Valid Pincode", + "validPassword": "Valid Password", + "validEmail": "Valid email", + "validSSO": "Valid SSO", + "resourceBlocked": "Resource Blocked", + "droppedByRule": "Dropped by Rule", + "noSessions": "No Sessions", + "temporaryRequestToken": "Temporary Request Token", + "noMoreAuthMethods": "No Valid Auth", + "ip": "IP", + "reason": "Reason", + "requestLogs": "Request Logs", + "host": "Host", + "location": "Location", + "actionLogs": "Action Logs", + "sidebarLogsRequest": "Request Logs", + "sidebarLogsAccess": "Access Logs", + "sidebarLogsAction": "Action Logs", + "logRetention": "Log Retention", + "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", + "requestLogsDescription": "View detailed request logs for resources in this organization", + "logRetentionRequestLabel": "Request Log Retention", + "logRetentionRequestDescription": "How long to retain request logs", + "logRetentionAccessLabel": "Access Log Retention", + "logRetentionAccessDescription": "How long to retain access logs", + "logRetentionActionLabel": "Action Log Retention", + "logRetentionActionDescription": "How long to retain action logs", + "logRetentionDisabled": "Disabled", + "logRetention3Days": "3 days", + "logRetention7Days": "7 days", + "logRetention14Days": "14 days", + "logRetention30Days": "30 days", + "logRetention90Days": "90 days", + "logRetentionForever": "Forever", + "actionLogsDescription": "View a history of actions performed in this organization", + "accessLogsDescription": "View access auth requests for resources in this organization", + "certResolver": "Resolver certificado", + "certResolverDescription": "Seleccione la resolución de certificados a utilizar para este recurso.", + "selectCertResolver": "Seleccionar Resolver Certificado", + "enterCustomResolver": "Introducir resolución personalizada", + "preferWildcardCert": "Certificado de comodín preferido", + "unverified": "Sin verificar", + "domainSetting": "Ajustes de dominio", + "domainSettingDescription": "Configurar ajustes para tu dominio", + "preferWildcardCertDescription": "Intento de generar un certificado comodín (requiere una resolución de certificados correctamente configurada).", + "recordName": "Nombre del registro", + "auto": "Auto", + "TTL": "TTL", + "howToAddRecords": "Cómo añadir registros", + "dnsRecord": "Registros DNS", + "required": "Requerido", + "domainSettingsUpdated": "Configuración de dominio actualizada correctamente", + "orgOrDomainIdMissing": "Falta el ID de organización o dominio", + "loadingDNSRecords": "Cargando registros DNS...", + "olmUpdateAvailableInfo": "Una versión actualizada de Olm está disponible. Por favor, actualice a la última versión para obtener la mejor experiencia.", + "client": "Cliente", + "proxyProtocol": "Configuración del Protocolo Proxy", + "proxyProtocolDescription": "Configurar el protocolo de proxy para preservar las direcciones IP del cliente para los servicios TCP/UDP.", + "enableProxyProtocol": "Habilitar protocolo proxy", + "proxyProtocolInfo": "Conservar direcciones IP del cliente para backends TCP/UDP", + "proxyProtocolVersion": "Versión del Protocolo Proxy", + "version1": " Versión 1 (Recomendado)", + "version2": "Versión 2", + "versionDescription": "La versión 1 está basada en texto y es ampliamente soportada. La versión 2 es binaria y más eficiente pero menos compatible.", + "warning": "Advertencia", + "proxyProtocolWarning": "Su aplicación de backend debe estar configurada para aceptar conexiones Proxy Protocol. Si su backend no soporta Proxy Protocol, habilitando esto romperá todas las conexiones. Asegúrese de configurar su backend para que confíe en las cabeceras del protocolo Proxy de Traefik." } diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 7f25bcca..3c2d6bff 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -911,6 +911,18 @@ "passwordResetCodeDescription": "Vérifiez votre e-mail pour le code de réinitialisation.", "passwordNew": "Nouveau mot de passe", "passwordNewConfirm": "Confirmer le nouveau mot de passe", + "changePassword": "Change Password", + "changePasswordDescription": "Update your account password", + "oldPassword": "Current Password", + "newPassword": "New Password", + "confirmNewPassword": "Confirm New Password", + "changePasswordError": "Failed to change password", + "changePasswordErrorDescription": "An error occurred while changing your password", + "changePasswordSuccess": "Password Changed Successfully", + "changePasswordSuccessDescription": "Your password has been updated successfully", + "passwordExpiryRequired": "Password Expiry Required", + "passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.", + "changePasswordNow": "Change Password Now", "pincodeAuth": "Code d'authentification", "pincodeSubmit2": "Soumettre le code", "passwordResetSubmit": "Demander la réinitialisation", @@ -1340,6 +1352,19 @@ "securityKeyUnknownError": "Un problème est survenu avec votre clé de sécurité. Veuillez réessayer.", "twoFactorRequired": "L'authentification à deux facteurs est requise pour enregistrer une clé de sécurité.", "twoFactor": "Authentification à deux facteurs", + "twoFactorAuthentication": "Two-Factor Authentication", + "twoFactorDescription": "This organization requires two-factor authentication.", + "enableTwoFactor": "Enable Two-Factor Authentication", + "organizationSecurityPolicy": "Organization Security Policy", + "organizationSecurityPolicyDescription": "This organization has security requirements that must be met before you can access it", + "securityRequirements": "Security Requirements", + "allRequirementsMet": "All requirements have been met", + "completeRequirementsToContinue": "Complete the requirements below to continue accessing this organization", + "youCanNowAccessOrganization": "You can now access this organization", + "reauthenticationRequired": "Session Length", + "reauthenticationDescription": "This organization requires you to log in every {maxDays} days.", + "reauthenticationDescriptionHours": "This organization requires you to log in every {maxHours} hours.", + "reauthenticateNow": "Log In Again", "adminEnabled2FaOnYourAccount": "Votre administrateur a activé l'authentification à deux facteurs pour {email}. Veuillez terminer le processus d'installation pour continuer.", "securityKeyAdd": "Ajouter une clé de sécurité", "securityKeyRegisterTitle": "Enregistrer une nouvelle clé de sécurité", @@ -1719,6 +1744,7 @@ "orgAuthNoIdpConfigured": "Cette organisation n'a aucun fournisseur d'identité configuré. Vous pouvez vous connecter avec votre identité Pangolin à la place.", "orgAuthSignInWithPangolin": "Se connecter avec Pangolin", "subscriptionRequiredToUse": "Un abonnement est requis pour utiliser cette fonctionnalité.", + "licenseRequiredToUse": "An Enterprise license is required to use this feature.", "idpDisabled": "Les fournisseurs d'identité sont désactivés.", "orgAuthPageDisabled": "La page d'authentification de l'organisation est désactivée.", "domainRestartedDescription": "La vérification du domaine a été redémarrée avec succès", @@ -1726,6 +1752,47 @@ "resourceExposePortsEditFile": "Modifier le fichier : docker-compose.yml", "emailVerificationRequired": "La vérification de l'e-mail est requise. Veuillez vous reconnecter via {dashboardUrl}/auth/login terminé cette étape. Puis revenez ici.", "twoFactorSetupRequired": "La configuration d'authentification à deux facteurs est requise. Veuillez vous reconnecter via {dashboardUrl}/auth/login terminé cette étape. Puis revenez ici.", + "additionalSecurityRequired": "Additional Security Required", + "organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.", + "completeTheseSteps": "Complete these steps", + "enableTwoFactorAuthentication": "Enable two-factor authentication", + "completeSecuritySteps": "Complete Security Steps", + "securitySettings": "Security Settings", + "securitySettingsDescription": "Configure security policies for your organization", + "requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users", + "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", + "requireTwoFactorDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "requireTwoFactorCannotEnableDescription": "You must enable two-factor authentication for your account before enforcing it for all users", + "maxSessionLength": "Maximum Session Length", + "maxSessionLengthDescription": "Set the maximum duration for user sessions. After this time, users will need to re-authenticate.", + "maxSessionLengthDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "selectSessionLength": "Select session length", + "unenforced": "Unenforced", + "1Hour": "1 hour", + "3Hours": "3 hours", + "6Hours": "6 hours", + "12Hours": "12 hours", + "1DaySession": "1 day", + "3Days": "3 days", + "7Days": "7 days", + "14Days": "14 days", + "30DaysSession": "30 days", + "90DaysSession": "90 days", + "180DaysSession": "180 days", + "passwordExpiryDays": "Password Expiry", + "editPasswordExpiryDescription": "Set the number of days before users are required to change their password.", + "selectPasswordExpiry": "Select password expiry", + "30Days": "30 days", + "1Day": "1 day", + "60Days": "60 days", + "90Days": "90 days", + "180Days": "180 days", + "1Year": "1 year", + "subscriptionBadge": "Subscription Required", + "securityPolicyChangeWarning": "Security Policy Change Warning", + "securityPolicyChangeDescription": "You are about to change security policy settings. After saving, you may need to reauthenticate to comply with these policy updates. All users who are not compliant will also need to reauthenticate.", + "securityPolicyChangeConfirmMessage": "I confirm", + "securityPolicyChangeWarningText": "This will affect all users in the organization", "authPageErrorUpdateMessage": "Une erreur s'est produite lors de la mise à jour de la page d\u000027authentification", "authPageUpdated": "Page d\u000027authentification mise à jour avec succès", "healthCheckNotAvailable": "Locale", @@ -1891,5 +1958,86 @@ "cannotbeUndone": "Cela ne peut pas être annulé.", "toConfirm": "pour confirmer", "deleteClientQuestion": "Êtes-vous sûr de vouloir supprimer le client du site et de l'organisation ?", - "clientMessageRemove": "Une fois supprimé, le client ne pourra plus se connecter au site." + "clientMessageRemove": "Une fois supprimé, le client ne pourra plus se connecter au site.", + "sidebarLogs": "Logs", + "request": "Request", + "logs": "Logs", + "logsSettingsDescription": "Monitor logs collected from this orginization", + "searchLogs": "Search logs...", + "action": "Action", + "actor": "Actor", + "timestamp": "Timestamp", + "accessLogs": "Access Logs", + "exportCsv": "Export CSV", + "actorId": "Actor ID", + "allowedByRule": "Allowed by Rule", + "allowedNoAuth": "Allowed No Auth", + "validAccessToken": "Valid Access Token", + "validHeaderAuth": "Valid header auth", + "validPincode": "Valid Pincode", + "validPassword": "Valid Password", + "validEmail": "Valid email", + "validSSO": "Valid SSO", + "resourceBlocked": "Resource Blocked", + "droppedByRule": "Dropped by Rule", + "noSessions": "No Sessions", + "temporaryRequestToken": "Temporary Request Token", + "noMoreAuthMethods": "No Valid Auth", + "ip": "IP", + "reason": "Reason", + "requestLogs": "Request Logs", + "host": "Host", + "location": "Location", + "actionLogs": "Action Logs", + "sidebarLogsRequest": "Request Logs", + "sidebarLogsAccess": "Access Logs", + "sidebarLogsAction": "Action Logs", + "logRetention": "Log Retention", + "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", + "requestLogsDescription": "View detailed request logs for resources in this organization", + "logRetentionRequestLabel": "Request Log Retention", + "logRetentionRequestDescription": "How long to retain request logs", + "logRetentionAccessLabel": "Access Log Retention", + "logRetentionAccessDescription": "How long to retain access logs", + "logRetentionActionLabel": "Action Log Retention", + "logRetentionActionDescription": "How long to retain action logs", + "logRetentionDisabled": "Disabled", + "logRetention3Days": "3 days", + "logRetention7Days": "7 days", + "logRetention14Days": "14 days", + "logRetention30Days": "30 days", + "logRetention90Days": "90 days", + "logRetentionForever": "Forever", + "actionLogsDescription": "View a history of actions performed in this organization", + "accessLogsDescription": "View access auth requests for resources in this organization", + "certResolver": "Résolveur de certificat", + "certResolverDescription": "Sélectionnez le solveur de certificat à utiliser pour cette ressource.", + "selectCertResolver": "Sélectionnez le résolveur de certificat", + "enterCustomResolver": "Entrez le résolveur personnalisé", + "preferWildcardCert": "Préférez le certificat Wildcard", + "unverified": "Non vérifié", + "domainSetting": "Paramètres de domaine", + "domainSettingDescription": "Configurer les paramètres de votre domaine", + "preferWildcardCertDescription": "Tentative de génération d'un certificat générique (nécessite un résolveur de certificat correctement configuré).", + "recordName": "Nom de l'enregistrement", + "auto": "Automatique", + "TTL": "TTC", + "howToAddRecords": "Comment ajouter des enregistrements", + "dnsRecord": "Enregistrements DNS", + "required": "Requis", + "domainSettingsUpdated": "Paramètres de domaine mis à jour avec succès", + "orgOrDomainIdMissing": "L'organisation ou l'identifiant de domaine est manquant", + "loadingDNSRecords": "Chargement des enregistrements DNS...", + "olmUpdateAvailableInfo": "Une version mise à jour de Olm est disponible. Veuillez mettre à jour vers la dernière version pour la meilleure expérience.", + "client": "Client", + "proxyProtocol": "Paramètres du protocole proxy", + "proxyProtocolDescription": "Configurer le protocole Proxy pour préserver les adresses IP du client pour les services TCP/UDP.", + "enableProxyProtocol": "Activer le protocole Proxy", + "proxyProtocolInfo": "Conserver les adresses IP du client pour les backends TCP/UDP", + "proxyProtocolVersion": "Version du protocole proxy", + "version1": " Version 1 (Recommandé)", + "version2": "Version 2", + "versionDescription": "La version 1 est basée sur du texte et est largement supportée. La version 2 est binaire et plus efficace mais moins compatible.", + "warning": "Avertissement", + "proxyProtocolWarning": "Votre application backend doit être configurée pour accepter les connexions Proxy Protocol. Si votre backend ne prend pas en charge le protocole Proxy, activer ceci va casser toutes les connexions. Assurez-vous de configurer votre backend pour faire confiance aux en-têtes du protocole Proxy de Traefik." } diff --git a/messages/it-IT.json b/messages/it-IT.json index ba60967c..3ebe3733 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -911,6 +911,18 @@ "passwordResetCodeDescription": "Controlla la tua email per il codice di reset.", "passwordNew": "Nuova Password", "passwordNewConfirm": "Conferma Nuova Password", + "changePassword": "Change Password", + "changePasswordDescription": "Update your account password", + "oldPassword": "Current Password", + "newPassword": "New Password", + "confirmNewPassword": "Confirm New Password", + "changePasswordError": "Failed to change password", + "changePasswordErrorDescription": "An error occurred while changing your password", + "changePasswordSuccess": "Password Changed Successfully", + "changePasswordSuccessDescription": "Your password has been updated successfully", + "passwordExpiryRequired": "Password Expiry Required", + "passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.", + "changePasswordNow": "Change Password Now", "pincodeAuth": "Codice Autenticatore", "pincodeSubmit2": "Invia Codice", "passwordResetSubmit": "Richiedi Reset", @@ -1340,6 +1352,19 @@ "securityKeyUnknownError": "Si è verificato un problema con la tua chiave di sicurezza. Riprova.", "twoFactorRequired": "È richiesta l'autenticazione a due fattori per registrare una chiave di sicurezza.", "twoFactor": "Autenticazione a Due Fattori", + "twoFactorAuthentication": "Two-Factor Authentication", + "twoFactorDescription": "This organization requires two-factor authentication.", + "enableTwoFactor": "Enable Two-Factor Authentication", + "organizationSecurityPolicy": "Organization Security Policy", + "organizationSecurityPolicyDescription": "This organization has security requirements that must be met before you can access it", + "securityRequirements": "Security Requirements", + "allRequirementsMet": "All requirements have been met", + "completeRequirementsToContinue": "Complete the requirements below to continue accessing this organization", + "youCanNowAccessOrganization": "You can now access this organization", + "reauthenticationRequired": "Session Length", + "reauthenticationDescription": "This organization requires you to log in every {maxDays} days.", + "reauthenticationDescriptionHours": "This organization requires you to log in every {maxHours} hours.", + "reauthenticateNow": "Log In Again", "adminEnabled2FaOnYourAccount": "Il tuo amministratore ha abilitato l'autenticazione a due fattori per {email}. Completa il processo di configurazione per continuare.", "securityKeyAdd": "Aggiungi Chiave di Sicurezza", "securityKeyRegisterTitle": "Registra Nuova Chiave di Sicurezza", @@ -1719,6 +1744,7 @@ "orgAuthNoIdpConfigured": "Questa organizzazione non ha nessun provider di identità configurato. Puoi accedere con la tua identità Pangolin.", "orgAuthSignInWithPangolin": "Accedi con Pangolino", "subscriptionRequiredToUse": "Per utilizzare questa funzionalità è necessario un abbonamento.", + "licenseRequiredToUse": "An Enterprise license is required to use this feature.", "idpDisabled": "I provider di identità sono disabilitati.", "orgAuthPageDisabled": "La pagina di autenticazione dell'organizzazione è disabilitata.", "domainRestartedDescription": "Verifica del dominio riavviata con successo", @@ -1726,6 +1752,47 @@ "resourceExposePortsEditFile": "Modifica file: docker-compose.yml", "emailVerificationRequired": "Verifica via email. Effettua nuovamente il login via {dashboardUrl}/auth/login completa questo passaggio. Quindi, torna qui.", "twoFactorSetupRequired": "È richiesta la configurazione di autenticazione a due fattori. Effettua nuovamente l'accesso tramite {dashboardUrl}/auth/login completa questo passaggio. Quindi, torna qui.", + "additionalSecurityRequired": "Additional Security Required", + "organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.", + "completeTheseSteps": "Complete these steps", + "enableTwoFactorAuthentication": "Enable two-factor authentication", + "completeSecuritySteps": "Complete Security Steps", + "securitySettings": "Security Settings", + "securitySettingsDescription": "Configure security policies for your organization", + "requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users", + "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", + "requireTwoFactorDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "requireTwoFactorCannotEnableDescription": "You must enable two-factor authentication for your account before enforcing it for all users", + "maxSessionLength": "Maximum Session Length", + "maxSessionLengthDescription": "Set the maximum duration for user sessions. After this time, users will need to re-authenticate.", + "maxSessionLengthDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "selectSessionLength": "Select session length", + "unenforced": "Unenforced", + "1Hour": "1 hour", + "3Hours": "3 hours", + "6Hours": "6 hours", + "12Hours": "12 hours", + "1DaySession": "1 day", + "3Days": "3 days", + "7Days": "7 days", + "14Days": "14 days", + "30DaysSession": "30 days", + "90DaysSession": "90 days", + "180DaysSession": "180 days", + "passwordExpiryDays": "Password Expiry", + "editPasswordExpiryDescription": "Set the number of days before users are required to change their password.", + "selectPasswordExpiry": "Select password expiry", + "30Days": "30 days", + "1Day": "1 day", + "60Days": "60 days", + "90Days": "90 days", + "180Days": "180 days", + "1Year": "1 year", + "subscriptionBadge": "Subscription Required", + "securityPolicyChangeWarning": "Security Policy Change Warning", + "securityPolicyChangeDescription": "You are about to change security policy settings. After saving, you may need to reauthenticate to comply with these policy updates. All users who are not compliant will also need to reauthenticate.", + "securityPolicyChangeConfirmMessage": "I confirm", + "securityPolicyChangeWarningText": "This will affect all users in the organization", "authPageErrorUpdateMessage": "Si è verificato un errore durante l'aggiornamento delle impostazioni della pagina di autenticazione", "authPageUpdated": "Pagina di autenticazione aggiornata con successo", "healthCheckNotAvailable": "Locale", @@ -1891,5 +1958,86 @@ "cannotbeUndone": "Questo non può essere annullato.", "toConfirm": "per confermare", "deleteClientQuestion": "Sei sicuro di voler rimuovere il client dal sito e dall'organizzazione?", - "clientMessageRemove": "Una volta rimosso, il client non sarà più in grado di connettersi al sito." + "clientMessageRemove": "Una volta rimosso, il client non sarà più in grado di connettersi al sito.", + "sidebarLogs": "Logs", + "request": "Request", + "logs": "Logs", + "logsSettingsDescription": "Monitor logs collected from this orginization", + "searchLogs": "Search logs...", + "action": "Action", + "actor": "Actor", + "timestamp": "Timestamp", + "accessLogs": "Access Logs", + "exportCsv": "Export CSV", + "actorId": "Actor ID", + "allowedByRule": "Allowed by Rule", + "allowedNoAuth": "Allowed No Auth", + "validAccessToken": "Valid Access Token", + "validHeaderAuth": "Valid header auth", + "validPincode": "Valid Pincode", + "validPassword": "Valid Password", + "validEmail": "Valid email", + "validSSO": "Valid SSO", + "resourceBlocked": "Resource Blocked", + "droppedByRule": "Dropped by Rule", + "noSessions": "No Sessions", + "temporaryRequestToken": "Temporary Request Token", + "noMoreAuthMethods": "No Valid Auth", + "ip": "IP", + "reason": "Reason", + "requestLogs": "Request Logs", + "host": "Host", + "location": "Location", + "actionLogs": "Action Logs", + "sidebarLogsRequest": "Request Logs", + "sidebarLogsAccess": "Access Logs", + "sidebarLogsAction": "Action Logs", + "logRetention": "Log Retention", + "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", + "requestLogsDescription": "View detailed request logs for resources in this organization", + "logRetentionRequestLabel": "Request Log Retention", + "logRetentionRequestDescription": "How long to retain request logs", + "logRetentionAccessLabel": "Access Log Retention", + "logRetentionAccessDescription": "How long to retain access logs", + "logRetentionActionLabel": "Action Log Retention", + "logRetentionActionDescription": "How long to retain action logs", + "logRetentionDisabled": "Disabled", + "logRetention3Days": "3 days", + "logRetention7Days": "7 days", + "logRetention14Days": "14 days", + "logRetention30Days": "30 days", + "logRetention90Days": "90 days", + "logRetentionForever": "Forever", + "actionLogsDescription": "View a history of actions performed in this organization", + "accessLogsDescription": "View access auth requests for resources in this organization", + "certResolver": "Risolutore Di Certificato", + "certResolverDescription": "Selezionare il risolutore di certificati da usare per questa risorsa.", + "selectCertResolver": "Seleziona Risolutore Di Certificato", + "enterCustomResolver": "Inserisci Risolutore Personalizzato", + "preferWildcardCert": "Preferisci Certificato Wildcard", + "unverified": "Non Verificato", + "domainSetting": "Impostazioni Dominio", + "domainSettingDescription": "Configura le impostazioni per il tuo dominio", + "preferWildcardCertDescription": "Tentativo di generare un certificato jolly (richiede un risolutore di certificati correttamente configurato).", + "recordName": "Nome Record", + "auto": "Automatico", + "TTL": "TTL", + "howToAddRecords": "Come aggiungere record", + "dnsRecord": "Record DNS", + "required": "Richiesto", + "domainSettingsUpdated": "Impostazioni dominio aggiornate con successo", + "orgOrDomainIdMissing": "Manca l'ID dell'organizzazione o del dominio", + "loadingDNSRecords": "Caricamento record DNS...", + "olmUpdateAvailableInfo": "È disponibile una versione aggiornata di Olm. Si prega di aggiornare all'ultima versione per la migliore esperienza.", + "client": "Client", + "proxyProtocol": "Impostazioni Protocollo Proxy", + "proxyProtocolDescription": "Configurare il protocollo proxy per preservare gli indirizzi IP client per i servizi TCP/UDP.", + "enableProxyProtocol": "Abilita Protocollo Proxy", + "proxyProtocolInfo": "Conserva gli indirizzi IP del client per i backend TCP/UDP", + "proxyProtocolVersion": "Versione Protocollo Proxy", + "version1": " Versione 1 (Consigliato)", + "version2": "Versione 2", + "versionDescription": "La versione 1 è testuale e ampiamente supportata. La versione 2 è binaria e più efficiente, ma meno compatibile.", + "warning": "Attenzione", + "proxyProtocolWarning": "La tua applicazione backend deve essere configurata per accettare le connessioni del protocollo proxy. Se il tuo backend non supporta il protocollo proxy, abilitando questa opzione si interromperanno tutte le connessioni. Assicurati di configurare il tuo backend per fidarti delle intestazioni del protocollo proxy da Traefik." } diff --git a/messages/ko-KR.json b/messages/ko-KR.json index 53da6588..fa024da9 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -911,6 +911,18 @@ "passwordResetCodeDescription": "재설정 코드를 확인하려면 이메일을 확인하세요.", "passwordNew": "새 비밀번호", "passwordNewConfirm": "새 비밀번호 확인", + "changePassword": "Change Password", + "changePasswordDescription": "Update your account password", + "oldPassword": "Current Password", + "newPassword": "New Password", + "confirmNewPassword": "Confirm New Password", + "changePasswordError": "Failed to change password", + "changePasswordErrorDescription": "An error occurred while changing your password", + "changePasswordSuccess": "Password Changed Successfully", + "changePasswordSuccessDescription": "Your password has been updated successfully", + "passwordExpiryRequired": "Password Expiry Required", + "passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.", + "changePasswordNow": "Change Password Now", "pincodeAuth": "인증 코드", "pincodeSubmit2": "코드 제출", "passwordResetSubmit": "재설정 요청", @@ -1340,6 +1352,19 @@ "securityKeyUnknownError": "보안 키를 사용하는 데 문제가 발생했습니다. 다시 시도하세요.", "twoFactorRequired": "보안 키를 등록하려면 이중 인증이 필요합니다.", "twoFactor": "이중 인증", + "twoFactorAuthentication": "Two-Factor Authentication", + "twoFactorDescription": "This organization requires two-factor authentication.", + "enableTwoFactor": "Enable Two-Factor Authentication", + "organizationSecurityPolicy": "Organization Security Policy", + "organizationSecurityPolicyDescription": "This organization has security requirements that must be met before you can access it", + "securityRequirements": "Security Requirements", + "allRequirementsMet": "All requirements have been met", + "completeRequirementsToContinue": "Complete the requirements below to continue accessing this organization", + "youCanNowAccessOrganization": "You can now access this organization", + "reauthenticationRequired": "Session Length", + "reauthenticationDescription": "This organization requires you to log in every {maxDays} days.", + "reauthenticationDescriptionHours": "This organization requires you to log in every {maxHours} hours.", + "reauthenticateNow": "Log In Again", "adminEnabled2FaOnYourAccount": "관리자가 {email}에 대한 이중 인증을 활성화했습니다. 계속하려면 설정을 완료하세요.", "securityKeyAdd": "보안 키 추가", "securityKeyRegisterTitle": "새 보안 키 등록", @@ -1719,6 +1744,7 @@ "orgAuthNoIdpConfigured": "이 조직은 구성된 신원 공급자가 없습니다. 대신 Pangolin 아이덴티티로 로그인할 수 있습니다.", "orgAuthSignInWithPangolin": "Pangolin으로 로그인", "subscriptionRequiredToUse": "이 기능을 사용하려면 구독이 필요합니다.", + "licenseRequiredToUse": "An Enterprise license is required to use this feature.", "idpDisabled": "신원 공급자가 비활성화되었습니다.", "orgAuthPageDisabled": "조직 인증 페이지가 비활성화되었습니다.", "domainRestartedDescription": "도메인 인증이 성공적으로 재시작되었습니다.", @@ -1726,6 +1752,47 @@ "resourceExposePortsEditFile": "파일 편집: docker-compose.yml", "emailVerificationRequired": "이메일 인증이 필요합니다. 이 단계를 완료하려면 {dashboardUrl}/auth/login 통해 다시 로그인하십시오. 그런 다음 여기로 돌아오세요.", "twoFactorSetupRequired": "이중 인증 설정이 필요합니다. 이 단계를 완료하려면 {dashboardUrl}/auth/login 통해 다시 로그인하십시오. 그런 다음 여기로 돌아오세요.", + "additionalSecurityRequired": "Additional Security Required", + "organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.", + "completeTheseSteps": "Complete these steps", + "enableTwoFactorAuthentication": "Enable two-factor authentication", + "completeSecuritySteps": "Complete Security Steps", + "securitySettings": "Security Settings", + "securitySettingsDescription": "Configure security policies for your organization", + "requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users", + "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", + "requireTwoFactorDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "requireTwoFactorCannotEnableDescription": "You must enable two-factor authentication for your account before enforcing it for all users", + "maxSessionLength": "Maximum Session Length", + "maxSessionLengthDescription": "Set the maximum duration for user sessions. After this time, users will need to re-authenticate.", + "maxSessionLengthDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "selectSessionLength": "Select session length", + "unenforced": "Unenforced", + "1Hour": "1 hour", + "3Hours": "3 hours", + "6Hours": "6 hours", + "12Hours": "12 hours", + "1DaySession": "1 day", + "3Days": "3 days", + "7Days": "7 days", + "14Days": "14 days", + "30DaysSession": "30 days", + "90DaysSession": "90 days", + "180DaysSession": "180 days", + "passwordExpiryDays": "Password Expiry", + "editPasswordExpiryDescription": "Set the number of days before users are required to change their password.", + "selectPasswordExpiry": "Select password expiry", + "30Days": "30 days", + "1Day": "1 day", + "60Days": "60 days", + "90Days": "90 days", + "180Days": "180 days", + "1Year": "1 year", + "subscriptionBadge": "Subscription Required", + "securityPolicyChangeWarning": "Security Policy Change Warning", + "securityPolicyChangeDescription": "You are about to change security policy settings. After saving, you may need to reauthenticate to comply with these policy updates. All users who are not compliant will also need to reauthenticate.", + "securityPolicyChangeConfirmMessage": "I confirm", + "securityPolicyChangeWarningText": "This will affect all users in the organization", "authPageErrorUpdateMessage": "인증 페이지 설정을 업데이트하는 동안 오류가 발생했습니다", "authPageUpdated": "인증 페이지가 성공적으로 업데이트되었습니다", "healthCheckNotAvailable": "로컬", @@ -1891,5 +1958,86 @@ "cannotbeUndone": "이 작업은 되돌릴 수 없습니다.", "toConfirm": "확인하려면", "deleteClientQuestion": "고객을 사이트와 조직에서 제거하시겠습니까?", - "clientMessageRemove": "제거되면 클라이언트는 사이트에 더 이상 연결할 수 없습니다." + "clientMessageRemove": "제거되면 클라이언트는 사이트에 더 이상 연결할 수 없습니다.", + "sidebarLogs": "Logs", + "request": "Request", + "logs": "Logs", + "logsSettingsDescription": "Monitor logs collected from this orginization", + "searchLogs": "Search logs...", + "action": "Action", + "actor": "Actor", + "timestamp": "Timestamp", + "accessLogs": "Access Logs", + "exportCsv": "Export CSV", + "actorId": "Actor ID", + "allowedByRule": "Allowed by Rule", + "allowedNoAuth": "Allowed No Auth", + "validAccessToken": "Valid Access Token", + "validHeaderAuth": "Valid header auth", + "validPincode": "Valid Pincode", + "validPassword": "Valid Password", + "validEmail": "Valid email", + "validSSO": "Valid SSO", + "resourceBlocked": "Resource Blocked", + "droppedByRule": "Dropped by Rule", + "noSessions": "No Sessions", + "temporaryRequestToken": "Temporary Request Token", + "noMoreAuthMethods": "No Valid Auth", + "ip": "IP", + "reason": "Reason", + "requestLogs": "Request Logs", + "host": "Host", + "location": "Location", + "actionLogs": "Action Logs", + "sidebarLogsRequest": "Request Logs", + "sidebarLogsAccess": "Access Logs", + "sidebarLogsAction": "Action Logs", + "logRetention": "Log Retention", + "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", + "requestLogsDescription": "View detailed request logs for resources in this organization", + "logRetentionRequestLabel": "Request Log Retention", + "logRetentionRequestDescription": "How long to retain request logs", + "logRetentionAccessLabel": "Access Log Retention", + "logRetentionAccessDescription": "How long to retain access logs", + "logRetentionActionLabel": "Action Log Retention", + "logRetentionActionDescription": "How long to retain action logs", + "logRetentionDisabled": "Disabled", + "logRetention3Days": "3 days", + "logRetention7Days": "7 days", + "logRetention14Days": "14 days", + "logRetention30Days": "30 days", + "logRetention90Days": "90 days", + "logRetentionForever": "Forever", + "actionLogsDescription": "View a history of actions performed in this organization", + "accessLogsDescription": "View access auth requests for resources in this organization", + "certResolver": "인증서 해결사", + "certResolverDescription": "이 리소스에 사용할 인증서 해결사를 선택하세요.", + "selectCertResolver": "인증서 해결사 선택", + "enterCustomResolver": "사용자 정의 해결사 입력", + "preferWildcardCert": "와일드카드 인증서 선호", + "unverified": "검증되지 않음", + "domainSetting": "도메인 설정", + "domainSettingDescription": "도메인에 대한 설정을 구성하세요.", + "preferWildcardCertDescription": "와일드카드 인증서를 생성하려고 시도합니다 (올바르게 구성된 인증서 해결사가 필요합니다).", + "recordName": "레코드 이름", + "auto": "자동", + "TTL": "TTL", + "howToAddRecords": "레코드 추가 방법", + "dnsRecord": "DNS 레코드", + "required": "필수", + "domainSettingsUpdated": "도메인 설정이 성공적으로 업데이트되었습니다", + "orgOrDomainIdMissing": "조직 ID 또는 도메인 ID가 누락되었습니다", + "loadingDNSRecords": "DNS 레코드를 로드하는 중...", + "olmUpdateAvailableInfo": "올름의 새 버전이 이용 가능합니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", + "client": "클라이언트", + "proxyProtocol": "프록시 프로토콜 설정", + "proxyProtocolDescription": "프록시 프로토콜을 구성하여 TCP/UDP 서비스에 대한 클라이언트 IP 주소를 보존하십시오.", + "enableProxyProtocol": "프록시 프로토콜 활성화", + "proxyProtocolInfo": "TCP/UDP 백엔드의 클라이언트 IP 주소를 보존합니다", + "proxyProtocolVersion": "프록시 프로토콜 버전", + "version1": " 버전 1 (추천)", + "version2": "버전 2", + "versionDescription": "버전 1은 텍스트 기반으로 널리 지원됩니다. 버전 2는 이진 기반으로 더 효율적이지만 호환성이 낮습니다.", + "warning": "경고", + "proxyProtocolWarning": "백엔드 애플리케이션이 프록시 프로토콜 연결을 허용하도록 구성되어야 합니다. 백엔드가 프록시 프로토콜을 지원하지 않으면, 이를 활성화하면 모든 연결이 끊어집니다. 트래픽에서 온 프록시 프로토콜 헤더를 백엔드가 신뢰하도록 구성하십시오." } diff --git a/messages/nb-NO.json b/messages/nb-NO.json index ab8473bc..954cb24c 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -911,6 +911,18 @@ "passwordResetCodeDescription": "Sjekk e-posten din for tilbakestillingskoden.", "passwordNew": "Nytt passord", "passwordNewConfirm": "Bekreft nytt passord", + "changePassword": "Change Password", + "changePasswordDescription": "Update your account password", + "oldPassword": "Current Password", + "newPassword": "New Password", + "confirmNewPassword": "Confirm New Password", + "changePasswordError": "Failed to change password", + "changePasswordErrorDescription": "An error occurred while changing your password", + "changePasswordSuccess": "Password Changed Successfully", + "changePasswordSuccessDescription": "Your password has been updated successfully", + "passwordExpiryRequired": "Password Expiry Required", + "passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.", + "changePasswordNow": "Change Password Now", "pincodeAuth": "Autentiseringskode", "pincodeSubmit2": "Send inn kode", "passwordResetSubmit": "Be om tilbakestilling", @@ -1340,6 +1352,19 @@ "securityKeyUnknownError": "Det oppstod et problem med å bruke sikkerhetsnøkkelen din. Vennligst prøv igjen.", "twoFactorRequired": "Tofaktorautentisering er påkrevd for å registrere en sikkerhetsnøkkel.", "twoFactor": "Tofaktorautentisering", + "twoFactorAuthentication": "Two-Factor Authentication", + "twoFactorDescription": "This organization requires two-factor authentication.", + "enableTwoFactor": "Enable Two-Factor Authentication", + "organizationSecurityPolicy": "Organization Security Policy", + "organizationSecurityPolicyDescription": "This organization has security requirements that must be met before you can access it", + "securityRequirements": "Security Requirements", + "allRequirementsMet": "All requirements have been met", + "completeRequirementsToContinue": "Complete the requirements below to continue accessing this organization", + "youCanNowAccessOrganization": "You can now access this organization", + "reauthenticationRequired": "Session Length", + "reauthenticationDescription": "This organization requires you to log in every {maxDays} days.", + "reauthenticationDescriptionHours": "This organization requires you to log in every {maxHours} hours.", + "reauthenticateNow": "Log In Again", "adminEnabled2FaOnYourAccount": "Din administrator har aktivert tofaktorautentisering for {email}. Vennligst fullfør oppsettsprosessen for å fortsette.", "securityKeyAdd": "Legg til sikkerhetsnøkkel", "securityKeyRegisterTitle": "Registrer ny sikkerhetsnøkkel", @@ -1719,6 +1744,7 @@ "orgAuthNoIdpConfigured": "Denne organisasjonen har ikke noen identitetstjeneste konfigurert. Du kan i stedet logge inn med Pangolin identiteten din.", "orgAuthSignInWithPangolin": "Logg inn med Pangolin", "subscriptionRequiredToUse": "Et abonnement er påkrevd for å bruke denne funksjonen.", + "licenseRequiredToUse": "An Enterprise license is required to use this feature.", "idpDisabled": "Identitetsleverandører er deaktivert.", "orgAuthPageDisabled": "Informasjons-siden for organisasjon er deaktivert.", "domainRestartedDescription": "Domene-verifiseringen ble startet på nytt", @@ -1726,6 +1752,47 @@ "resourceExposePortsEditFile": "Rediger fil: docker-compose.yml", "emailVerificationRequired": "E-postbekreftelse er nødvendig. Logg inn på nytt via {dashboardUrl}/auth/login og fullfør dette trinnet. Kom deretter tilbake her.", "twoFactorSetupRequired": "To-faktor autentiseringsoppsett er nødvendig. Vennligst logg inn igjen via {dashboardUrl}/auth/login og fullfør dette steget. Kom deretter tilbake her.", + "additionalSecurityRequired": "Additional Security Required", + "organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.", + "completeTheseSteps": "Complete these steps", + "enableTwoFactorAuthentication": "Enable two-factor authentication", + "completeSecuritySteps": "Complete Security Steps", + "securitySettings": "Security Settings", + "securitySettingsDescription": "Configure security policies for your organization", + "requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users", + "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", + "requireTwoFactorDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "requireTwoFactorCannotEnableDescription": "You must enable two-factor authentication for your account before enforcing it for all users", + "maxSessionLength": "Maximum Session Length", + "maxSessionLengthDescription": "Set the maximum duration for user sessions. After this time, users will need to re-authenticate.", + "maxSessionLengthDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "selectSessionLength": "Select session length", + "unenforced": "Unenforced", + "1Hour": "1 hour", + "3Hours": "3 hours", + "6Hours": "6 hours", + "12Hours": "12 hours", + "1DaySession": "1 day", + "3Days": "3 days", + "7Days": "7 days", + "14Days": "14 days", + "30DaysSession": "30 days", + "90DaysSession": "90 days", + "180DaysSession": "180 days", + "passwordExpiryDays": "Password Expiry", + "editPasswordExpiryDescription": "Set the number of days before users are required to change their password.", + "selectPasswordExpiry": "Select password expiry", + "30Days": "30 days", + "1Day": "1 day", + "60Days": "60 days", + "90Days": "90 days", + "180Days": "180 days", + "1Year": "1 year", + "subscriptionBadge": "Subscription Required", + "securityPolicyChangeWarning": "Security Policy Change Warning", + "securityPolicyChangeDescription": "You are about to change security policy settings. After saving, you may need to reauthenticate to comply with these policy updates. All users who are not compliant will also need to reauthenticate.", + "securityPolicyChangeConfirmMessage": "I confirm", + "securityPolicyChangeWarningText": "This will affect all users in the organization", "authPageErrorUpdateMessage": "Det oppstod en feil under oppdatering av innstillingene for godkjenningssiden", "authPageUpdated": "Godkjenningsside oppdatert", "healthCheckNotAvailable": "Lokal", @@ -1891,5 +1958,86 @@ "cannotbeUndone": "Dette kan ikke angres.", "toConfirm": "å bekrefte", "deleteClientQuestion": "Er du sikker på at du vil fjerne klienten fra nettstedet og organisasjonen?", - "clientMessageRemove": "Når klienten er fjernet, kan den ikke lenger koble seg til nettstedet." + "clientMessageRemove": "Når klienten er fjernet, kan den ikke lenger koble seg til nettstedet.", + "sidebarLogs": "Logs", + "request": "Request", + "logs": "Logs", + "logsSettingsDescription": "Monitor logs collected from this orginization", + "searchLogs": "Search logs...", + "action": "Action", + "actor": "Actor", + "timestamp": "Timestamp", + "accessLogs": "Access Logs", + "exportCsv": "Export CSV", + "actorId": "Actor ID", + "allowedByRule": "Allowed by Rule", + "allowedNoAuth": "Allowed No Auth", + "validAccessToken": "Valid Access Token", + "validHeaderAuth": "Valid header auth", + "validPincode": "Valid Pincode", + "validPassword": "Valid Password", + "validEmail": "Valid email", + "validSSO": "Valid SSO", + "resourceBlocked": "Resource Blocked", + "droppedByRule": "Dropped by Rule", + "noSessions": "No Sessions", + "temporaryRequestToken": "Temporary Request Token", + "noMoreAuthMethods": "No Valid Auth", + "ip": "IP", + "reason": "Reason", + "requestLogs": "Request Logs", + "host": "Host", + "location": "Location", + "actionLogs": "Action Logs", + "sidebarLogsRequest": "Request Logs", + "sidebarLogsAccess": "Access Logs", + "sidebarLogsAction": "Action Logs", + "logRetention": "Log Retention", + "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", + "requestLogsDescription": "View detailed request logs for resources in this organization", + "logRetentionRequestLabel": "Request Log Retention", + "logRetentionRequestDescription": "How long to retain request logs", + "logRetentionAccessLabel": "Access Log Retention", + "logRetentionAccessDescription": "How long to retain access logs", + "logRetentionActionLabel": "Action Log Retention", + "logRetentionActionDescription": "How long to retain action logs", + "logRetentionDisabled": "Disabled", + "logRetention3Days": "3 days", + "logRetention7Days": "7 days", + "logRetention14Days": "14 days", + "logRetention30Days": "30 days", + "logRetention90Days": "90 days", + "logRetentionForever": "Forever", + "actionLogsDescription": "View a history of actions performed in this organization", + "accessLogsDescription": "View access auth requests for resources in this organization", + "certResolver": "Sertifikat løser", + "certResolverDescription": "Velg sertifikatløser som skal brukes for denne ressursen.", + "selectCertResolver": "Velg sertifikatløser", + "enterCustomResolver": "Legg inn egendefinert løser", + "preferWildcardCert": "Foretrekk Wildcard sertifikat", + "unverified": "Uverifisert", + "domainSetting": "Domene innstillinger", + "domainSettingDescription": "Konfigurer innstillinger for ditt domene", + "preferWildcardCertDescription": "Forsøk på å generere et jokertegn (krever en riktig konfigurert sertifikatløsning).", + "recordName": "Lagre navn", + "auto": "Automatisk", + "TTL": "TTL", + "howToAddRecords": "Hvordan legge til poster", + "dnsRecord": "DNS registre", + "required": "Påkrevd", + "domainSettingsUpdated": "Domene innstillinger ble oppdatert", + "orgOrDomainIdMissing": "ID for organisasjon eller domene mangler", + "loadingDNSRecords": "Laster DNS-poster...", + "olmUpdateAvailableInfo": "En oppdatert versjon av Olm er tilgjengelig. Oppdater til den nyeste versjonen for å få den beste opplevelsen.", + "client": "Klient", + "proxyProtocol": "Protokoll innstillinger for Protokoll", + "proxyProtocolDescription": "Konfigurer Proxy-protokoll for å bevare klientens IP-adresser til TCP/UDP tjenester.", + "enableProxyProtocol": "Aktiver Proxy-protokoll", + "proxyProtocolInfo": "Bevar klientens IP-adresser for TCP/UDP bakover", + "proxyProtocolVersion": "Proxy protokoll versjon", + "version1": " Versjon 1 (Anbefalt)", + "version2": "Versjon 2", + "versionDescription": "Versjon 1 er tekstbasert og støttet. Versjon 2 er binært og mer effektivt, men mindre kompatibel.", + "warning": "Advarsel", + "proxyProtocolWarning": "Din backend applikasjon må være konfigurert for å godta Proxy Protokoller. Hvis din backend ikke støtter Proxy Protocol, vil aktivering av dette bryte alle tilkoblinger. Sørg for å konfigurere backend til å stole på Proxy Protokoll overskrifter fra Traefik." } diff --git a/messages/nl-NL.json b/messages/nl-NL.json index e38db9f1..c9c10d29 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -911,6 +911,18 @@ "passwordResetCodeDescription": "Controleer je e-mail voor de reset code.", "passwordNew": "Nieuw wachtwoord", "passwordNewConfirm": "Bevestig nieuw wachtwoord", + "changePassword": "Change Password", + "changePasswordDescription": "Update your account password", + "oldPassword": "Current Password", + "newPassword": "New Password", + "confirmNewPassword": "Confirm New Password", + "changePasswordError": "Failed to change password", + "changePasswordErrorDescription": "An error occurred while changing your password", + "changePasswordSuccess": "Password Changed Successfully", + "changePasswordSuccessDescription": "Your password has been updated successfully", + "passwordExpiryRequired": "Password Expiry Required", + "passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.", + "changePasswordNow": "Change Password Now", "pincodeAuth": "Authenticatiecode", "pincodeSubmit2": "Code indienen", "passwordResetSubmit": "Opnieuw instellen aanvragen", @@ -1340,6 +1352,19 @@ "securityKeyUnknownError": "Er was een probleem met het gebruik van je beveiligingssleutel. Probeer het opnieuw.", "twoFactorRequired": "Tweestapsverificatie is vereist om een beveiligingssleutel te registreren.", "twoFactor": "Tweestapsverificatie", + "twoFactorAuthentication": "Two-Factor Authentication", + "twoFactorDescription": "This organization requires two-factor authentication.", + "enableTwoFactor": "Enable Two-Factor Authentication", + "organizationSecurityPolicy": "Organization Security Policy", + "organizationSecurityPolicyDescription": "This organization has security requirements that must be met before you can access it", + "securityRequirements": "Security Requirements", + "allRequirementsMet": "All requirements have been met", + "completeRequirementsToContinue": "Complete the requirements below to continue accessing this organization", + "youCanNowAccessOrganization": "You can now access this organization", + "reauthenticationRequired": "Session Length", + "reauthenticationDescription": "This organization requires you to log in every {maxDays} days.", + "reauthenticationDescriptionHours": "This organization requires you to log in every {maxHours} hours.", + "reauthenticateNow": "Log In Again", "adminEnabled2FaOnYourAccount": "Je beheerder heeft tweestapsverificatie voor {email} ingeschakeld. Voltooi het instellingsproces om verder te gaan.", "securityKeyAdd": "Beveiligingssleutel toevoegen", "securityKeyRegisterTitle": "Nieuwe beveiligingssleutel registreren", @@ -1719,6 +1744,7 @@ "orgAuthNoIdpConfigured": "Deze organisatie heeft geen identiteitsproviders geconfigureerd. Je kunt in plaats daarvan inloggen met je Pangolin-identiteit.", "orgAuthSignInWithPangolin": "Log in met Pangolin", "subscriptionRequiredToUse": "Een abonnement is vereist om deze functie te gebruiken.", + "licenseRequiredToUse": "An Enterprise license is required to use this feature.", "idpDisabled": "Identiteitsaanbieders zijn uitgeschakeld.", "orgAuthPageDisabled": "Pagina voor organisatie-authenticatie is uitgeschakeld.", "domainRestartedDescription": "Domeinverificatie met succes opnieuw gestart", @@ -1726,6 +1752,47 @@ "resourceExposePortsEditFile": "Bestand bewerken: docker-compose.yml", "emailVerificationRequired": "E-mail verificatie is vereist. Log opnieuw in via {dashboardUrl}/auth/login voltooide deze stap. Kom daarna hier terug.", "twoFactorSetupRequired": "Tweestapsverificatie instellen is vereist. Log opnieuw in via {dashboardUrl}/auth/login voltooide deze stap. Kom daarna hier terug.", + "additionalSecurityRequired": "Additional Security Required", + "organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.", + "completeTheseSteps": "Complete these steps", + "enableTwoFactorAuthentication": "Enable two-factor authentication", + "completeSecuritySteps": "Complete Security Steps", + "securitySettings": "Security Settings", + "securitySettingsDescription": "Configure security policies for your organization", + "requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users", + "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", + "requireTwoFactorDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "requireTwoFactorCannotEnableDescription": "You must enable two-factor authentication for your account before enforcing it for all users", + "maxSessionLength": "Maximum Session Length", + "maxSessionLengthDescription": "Set the maximum duration for user sessions. After this time, users will need to re-authenticate.", + "maxSessionLengthDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "selectSessionLength": "Select session length", + "unenforced": "Unenforced", + "1Hour": "1 hour", + "3Hours": "3 hours", + "6Hours": "6 hours", + "12Hours": "12 hours", + "1DaySession": "1 day", + "3Days": "3 days", + "7Days": "7 days", + "14Days": "14 days", + "30DaysSession": "30 days", + "90DaysSession": "90 days", + "180DaysSession": "180 days", + "passwordExpiryDays": "Password Expiry", + "editPasswordExpiryDescription": "Set the number of days before users are required to change their password.", + "selectPasswordExpiry": "Select password expiry", + "30Days": "30 days", + "1Day": "1 day", + "60Days": "60 days", + "90Days": "90 days", + "180Days": "180 days", + "1Year": "1 year", + "subscriptionBadge": "Subscription Required", + "securityPolicyChangeWarning": "Security Policy Change Warning", + "securityPolicyChangeDescription": "You are about to change security policy settings. After saving, you may need to reauthenticate to comply with these policy updates. All users who are not compliant will also need to reauthenticate.", + "securityPolicyChangeConfirmMessage": "I confirm", + "securityPolicyChangeWarningText": "This will affect all users in the organization", "authPageErrorUpdateMessage": "Er is een fout opgetreden bij het bijwerken van de instellingen van de auth-pagina", "authPageUpdated": "Auth-pagina succesvol bijgewerkt", "healthCheckNotAvailable": "Lokaal", @@ -1891,5 +1958,86 @@ "cannotbeUndone": "Dit kan niet ongedaan worden gemaakt.", "toConfirm": "om te bevestigen", "deleteClientQuestion": "Weet u zeker dat u de client van de site en organisatie wilt verwijderen?", - "clientMessageRemove": "Eenmaal verwijderd, kan de client geen verbinding meer maken met de site." + "clientMessageRemove": "Eenmaal verwijderd, kan de client geen verbinding meer maken met de site.", + "sidebarLogs": "Logs", + "request": "Request", + "logs": "Logs", + "logsSettingsDescription": "Monitor logs collected from this orginization", + "searchLogs": "Search logs...", + "action": "Action", + "actor": "Actor", + "timestamp": "Timestamp", + "accessLogs": "Access Logs", + "exportCsv": "Export CSV", + "actorId": "Actor ID", + "allowedByRule": "Allowed by Rule", + "allowedNoAuth": "Allowed No Auth", + "validAccessToken": "Valid Access Token", + "validHeaderAuth": "Valid header auth", + "validPincode": "Valid Pincode", + "validPassword": "Valid Password", + "validEmail": "Valid email", + "validSSO": "Valid SSO", + "resourceBlocked": "Resource Blocked", + "droppedByRule": "Dropped by Rule", + "noSessions": "No Sessions", + "temporaryRequestToken": "Temporary Request Token", + "noMoreAuthMethods": "No Valid Auth", + "ip": "IP", + "reason": "Reason", + "requestLogs": "Request Logs", + "host": "Host", + "location": "Location", + "actionLogs": "Action Logs", + "sidebarLogsRequest": "Request Logs", + "sidebarLogsAccess": "Access Logs", + "sidebarLogsAction": "Action Logs", + "logRetention": "Log Retention", + "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", + "requestLogsDescription": "View detailed request logs for resources in this organization", + "logRetentionRequestLabel": "Request Log Retention", + "logRetentionRequestDescription": "How long to retain request logs", + "logRetentionAccessLabel": "Access Log Retention", + "logRetentionAccessDescription": "How long to retain access logs", + "logRetentionActionLabel": "Action Log Retention", + "logRetentionActionDescription": "How long to retain action logs", + "logRetentionDisabled": "Disabled", + "logRetention3Days": "3 days", + "logRetention7Days": "7 days", + "logRetention14Days": "14 days", + "logRetention30Days": "30 days", + "logRetention90Days": "90 days", + "logRetentionForever": "Forever", + "actionLogsDescription": "View a history of actions performed in this organization", + "accessLogsDescription": "View access auth requests for resources in this organization", + "certResolver": "Certificaat Resolver", + "certResolverDescription": "Selecteer de certificaat resolver die moet worden gebruikt voor deze resource.", + "selectCertResolver": "Certificaat Resolver selecteren", + "enterCustomResolver": "Aangepaste Oplossing invoeren", + "preferWildcardCert": "Bij voorkeur Wildcard Certificaat", + "unverified": "Ongeverifieerd", + "domainSetting": "Domein instellingen", + "domainSettingDescription": "Configureer instellingen voor uw domein", + "preferWildcardCertDescription": "Poging om een certificaat met een wildcard te genereren (vereist een correct geconfigureerde certificaatresolver).", + "recordName": "Record Naam", + "auto": "Automatisch", + "TTL": "TTL", + "howToAddRecords": "Hoe voeg ik Records toe", + "dnsRecord": "DNS Records", + "required": "vereist", + "domainSettingsUpdated": "Domeininstellingen succesvol bijgewerkt", + "orgOrDomainIdMissing": "Organisatie of domein ID ontbreekt", + "loadingDNSRecords": "DNS-records laden...", + "olmUpdateAvailableInfo": "Er is een bijgewerkte versie van Olm beschikbaar. Update alstublieft naar de nieuwste versie voor de beste ervaring.", + "client": "Klant", + "proxyProtocol": "Proxy Protocol Instellingen", + "proxyProtocolDescription": "Proxyprotocol configureren om de IP-adressen van de client voor TCP/UDP-diensten te bewaren.", + "enableProxyProtocol": "Proxy Protocol inschakelen", + "proxyProtocolInfo": "Behoud IP adressen van de client voor TCP/UDP backends", + "proxyProtocolVersion": "Proxy Protocol Versie", + "version1": " Versie 1 (Aanbevolen)", + "version2": "Versie 2", + "versionDescription": "Versie 1 is text-based en breed ondersteund. Versie 2 is binair en efficiënter maar minder compatibel.", + "warning": "Waarschuwing", + "proxyProtocolWarning": "Je backend applicatie moet worden geconfigureerd om connecties met Proxy Protocol te accepteren. Als je backend geen Proxy Protocol ondersteunt, zal het inschakelen van dit alle verbindingen verbreken. Zorg ervoor dat je je backend configureert om Proxy Protocol headers van Traefik." } diff --git a/messages/pl-PL.json b/messages/pl-PL.json index d963468b..781c50c5 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -911,6 +911,18 @@ "passwordResetCodeDescription": "Sprawdź swój e-mail, aby znaleźć kod resetowania.", "passwordNew": "Nowe hasło", "passwordNewConfirm": "Potwierdź nowe hasło", + "changePassword": "Change Password", + "changePasswordDescription": "Update your account password", + "oldPassword": "Current Password", + "newPassword": "New Password", + "confirmNewPassword": "Confirm New Password", + "changePasswordError": "Failed to change password", + "changePasswordErrorDescription": "An error occurred while changing your password", + "changePasswordSuccess": "Password Changed Successfully", + "changePasswordSuccessDescription": "Your password has been updated successfully", + "passwordExpiryRequired": "Password Expiry Required", + "passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.", + "changePasswordNow": "Change Password Now", "pincodeAuth": "Kod uwierzytelniający", "pincodeSubmit2": "Wyślij kod", "passwordResetSubmit": "Zażądaj resetowania", @@ -1340,6 +1352,19 @@ "securityKeyUnknownError": "Wystąpił problem z używaniem klucza bezpieczeństwa. Proszę spróbować ponownie.", "twoFactorRequired": "Uwierzytelnianie dwuskładnikowe jest wymagane do zarejestrowania klucza bezpieczeństwa.", "twoFactor": "Uwierzytelnianie dwuskładnikowe", + "twoFactorAuthentication": "Two-Factor Authentication", + "twoFactorDescription": "This organization requires two-factor authentication.", + "enableTwoFactor": "Enable Two-Factor Authentication", + "organizationSecurityPolicy": "Organization Security Policy", + "organizationSecurityPolicyDescription": "This organization has security requirements that must be met before you can access it", + "securityRequirements": "Security Requirements", + "allRequirementsMet": "All requirements have been met", + "completeRequirementsToContinue": "Complete the requirements below to continue accessing this organization", + "youCanNowAccessOrganization": "You can now access this organization", + "reauthenticationRequired": "Session Length", + "reauthenticationDescription": "This organization requires you to log in every {maxDays} days.", + "reauthenticationDescriptionHours": "This organization requires you to log in every {maxHours} hours.", + "reauthenticateNow": "Log In Again", "adminEnabled2FaOnYourAccount": "Twój administrator włączył uwierzytelnianie dwuskładnikowe dla {email}. Proszę ukończyć proces konfiguracji, aby kontynuować.", "securityKeyAdd": "Dodaj klucz bezpieczeństwa", "securityKeyRegisterTitle": "Zarejestruj nowy klucz bezpieczeństwa", @@ -1719,6 +1744,7 @@ "orgAuthNoIdpConfigured": "Ta organizacja nie ma skonfigurowanych żadnych dostawców tożsamości. Zamiast tego możesz zalogować się za pomocą swojej tożsamości Pangolin.", "orgAuthSignInWithPangolin": "Zaloguj się używając Pangolin", "subscriptionRequiredToUse": "Do korzystania z tej funkcji wymagana jest subskrypcja.", + "licenseRequiredToUse": "An Enterprise license is required to use this feature.", "idpDisabled": "Dostawcy tożsamości są wyłączeni", "orgAuthPageDisabled": "Strona autoryzacji organizacji jest wyłączona.", "domainRestartedDescription": "Weryfikacja domeny zrestartowana pomyślnie", @@ -1726,6 +1752,47 @@ "resourceExposePortsEditFile": "Edytuj plik: docker-compose.yml", "emailVerificationRequired": "Weryfikacja adresu e-mail jest wymagana. Zaloguj się ponownie przez {dashboardUrl}/auth/login zakończył ten krok. Następnie wróć tutaj.", "twoFactorSetupRequired": "Konfiguracja uwierzytelniania dwuskładnikowego jest wymagana. Zaloguj się ponownie przez {dashboardUrl}/auth/login dokończ ten krok. Następnie wróć tutaj.", + "additionalSecurityRequired": "Additional Security Required", + "organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.", + "completeTheseSteps": "Complete these steps", + "enableTwoFactorAuthentication": "Enable two-factor authentication", + "completeSecuritySteps": "Complete Security Steps", + "securitySettings": "Security Settings", + "securitySettingsDescription": "Configure security policies for your organization", + "requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users", + "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", + "requireTwoFactorDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "requireTwoFactorCannotEnableDescription": "You must enable two-factor authentication for your account before enforcing it for all users", + "maxSessionLength": "Maximum Session Length", + "maxSessionLengthDescription": "Set the maximum duration for user sessions. After this time, users will need to re-authenticate.", + "maxSessionLengthDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "selectSessionLength": "Select session length", + "unenforced": "Unenforced", + "1Hour": "1 hour", + "3Hours": "3 hours", + "6Hours": "6 hours", + "12Hours": "12 hours", + "1DaySession": "1 day", + "3Days": "3 days", + "7Days": "7 days", + "14Days": "14 days", + "30DaysSession": "30 days", + "90DaysSession": "90 days", + "180DaysSession": "180 days", + "passwordExpiryDays": "Password Expiry", + "editPasswordExpiryDescription": "Set the number of days before users are required to change their password.", + "selectPasswordExpiry": "Select password expiry", + "30Days": "30 days", + "1Day": "1 day", + "60Days": "60 days", + "90Days": "90 days", + "180Days": "180 days", + "1Year": "1 year", + "subscriptionBadge": "Subscription Required", + "securityPolicyChangeWarning": "Security Policy Change Warning", + "securityPolicyChangeDescription": "You are about to change security policy settings. After saving, you may need to reauthenticate to comply with these policy updates. All users who are not compliant will also need to reauthenticate.", + "securityPolicyChangeConfirmMessage": "I confirm", + "securityPolicyChangeWarningText": "This will affect all users in the organization", "authPageErrorUpdateMessage": "Wystąpił błąd podczas aktualizacji ustawień strony uwierzytelniania", "authPageUpdated": "Strona uwierzytelniania została pomyślnie zaktualizowana", "healthCheckNotAvailable": "Lokalny", @@ -1891,5 +1958,86 @@ "cannotbeUndone": "Tej operacji nie można cofnąć.", "toConfirm": "potwierdzić", "deleteClientQuestion": "Czy na pewno chcesz usunąć klienta z witryny i organizacji?", - "clientMessageRemove": "Po usunięciu, klient nie będzie już mógł połączyć się z witryną." + "clientMessageRemove": "Po usunięciu, klient nie będzie już mógł połączyć się z witryną.", + "sidebarLogs": "Logs", + "request": "Request", + "logs": "Logs", + "logsSettingsDescription": "Monitor logs collected from this orginization", + "searchLogs": "Search logs...", + "action": "Action", + "actor": "Actor", + "timestamp": "Timestamp", + "accessLogs": "Access Logs", + "exportCsv": "Export CSV", + "actorId": "Actor ID", + "allowedByRule": "Allowed by Rule", + "allowedNoAuth": "Allowed No Auth", + "validAccessToken": "Valid Access Token", + "validHeaderAuth": "Valid header auth", + "validPincode": "Valid Pincode", + "validPassword": "Valid Password", + "validEmail": "Valid email", + "validSSO": "Valid SSO", + "resourceBlocked": "Resource Blocked", + "droppedByRule": "Dropped by Rule", + "noSessions": "No Sessions", + "temporaryRequestToken": "Temporary Request Token", + "noMoreAuthMethods": "No Valid Auth", + "ip": "IP", + "reason": "Reason", + "requestLogs": "Request Logs", + "host": "Host", + "location": "Location", + "actionLogs": "Action Logs", + "sidebarLogsRequest": "Request Logs", + "sidebarLogsAccess": "Access Logs", + "sidebarLogsAction": "Action Logs", + "logRetention": "Log Retention", + "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", + "requestLogsDescription": "View detailed request logs for resources in this organization", + "logRetentionRequestLabel": "Request Log Retention", + "logRetentionRequestDescription": "How long to retain request logs", + "logRetentionAccessLabel": "Access Log Retention", + "logRetentionAccessDescription": "How long to retain access logs", + "logRetentionActionLabel": "Action Log Retention", + "logRetentionActionDescription": "How long to retain action logs", + "logRetentionDisabled": "Disabled", + "logRetention3Days": "3 days", + "logRetention7Days": "7 days", + "logRetention14Days": "14 days", + "logRetention30Days": "30 days", + "logRetention90Days": "90 days", + "logRetentionForever": "Forever", + "actionLogsDescription": "View a history of actions performed in this organization", + "accessLogsDescription": "View access auth requests for resources in this organization", + "certResolver": "Rozwiązywanie certyfikatów", + "certResolverDescription": "Wybierz resolver certyfikatów do użycia dla tego zasobu.", + "selectCertResolver": "Wybierz Resolver certyfikatów", + "enterCustomResolver": "Wprowadź niestandardowy Resolver", + "preferWildcardCert": "Preferuj Certyfikat Wildcard", + "unverified": "Niezweryfikowane", + "domainSetting": "Ustawienia domeny", + "domainSettingDescription": "Skonfiguruj ustawienia domeny", + "preferWildcardCertDescription": "Próba wygenerowania certyfikatu wieloznacznego (wymaga poprawnie skonfigurowanego resolwera certyfikatów).", + "recordName": "Nazwa rekordu", + "auto": "Auto", + "TTL": "TTL", + "howToAddRecords": "Jak dodać rekordy", + "dnsRecord": "Wpisy DNS", + "required": "Wymagane", + "domainSettingsUpdated": "Ustawienia domeny zaktualizowane pomyślnie", + "orgOrDomainIdMissing": "Brakuje identyfikatora organizacji lub domeny", + "loadingDNSRecords": "Ładowanie rekordów DNS...", + "olmUpdateAvailableInfo": "Dostępna jest zaktualizowana wersja Olm. Zaktualizuj do najnowszej wersji, aby uzyskać najlepsze doświadczenia.", + "client": "Klient", + "proxyProtocol": "Ustawienia protokołu proxy", + "proxyProtocolDescription": "Skonfiguruj protokół Proxy aby zachować adresy IP klienta dla usług TCP/UDP.", + "enableProxyProtocol": "Włącz protokół proxy", + "proxyProtocolInfo": "Zachowaj adresy IP klienta dla backendów TCP/UDP", + "proxyProtocolVersion": "Wersja protokołu proxy", + "version1": " Wersja 1 (zalecane)", + "version2": "Wersja 2", + "versionDescription": "Wersja 1 jest oparta na tekście i szeroko wspierana. Wersja 2 jest binarna i bardziej efektywna, ale mniej kompatybilna.", + "warning": "Ostrzeżenie", + "proxyProtocolWarning": "Twoja aplikacja backend musi być skonfigurowana tak, aby przyjmować połączenia z protokołem proxy. Jeśli Twój backend nie obsługuje protokołu proxy, włączenie to spowoduje przerwanie wszystkich połączeń. Upewnij się, że konfiguracja twojego backendu do zaufanych nagłówków protokołu proxy z Traefik." } diff --git a/messages/pt-PT.json b/messages/pt-PT.json index c2ac3099..0267f23e 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -911,6 +911,18 @@ "passwordResetCodeDescription": "Verifique o seu email para obter o código de redefinição.", "passwordNew": "Nova Palavra-passe", "passwordNewConfirm": "Confirmar Nova Palavra-passe", + "changePassword": "Change Password", + "changePasswordDescription": "Update your account password", + "oldPassword": "Current Password", + "newPassword": "New Password", + "confirmNewPassword": "Confirm New Password", + "changePasswordError": "Failed to change password", + "changePasswordErrorDescription": "An error occurred while changing your password", + "changePasswordSuccess": "Password Changed Successfully", + "changePasswordSuccessDescription": "Your password has been updated successfully", + "passwordExpiryRequired": "Password Expiry Required", + "passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.", + "changePasswordNow": "Change Password Now", "pincodeAuth": "Código do Autenticador", "pincodeSubmit2": "Submeter Código", "passwordResetSubmit": "Solicitar Redefinição", @@ -1340,6 +1352,19 @@ "securityKeyUnknownError": "Houve um problema ao usar sua chave de segurança. Tente novamente.", "twoFactorRequired": "A autenticação de dois fatores é necessária para registrar uma chave de segurança.", "twoFactor": "Autenticação de Dois Fatores", + "twoFactorAuthentication": "Two-Factor Authentication", + "twoFactorDescription": "This organization requires two-factor authentication.", + "enableTwoFactor": "Enable Two-Factor Authentication", + "organizationSecurityPolicy": "Organization Security Policy", + "organizationSecurityPolicyDescription": "This organization has security requirements that must be met before you can access it", + "securityRequirements": "Security Requirements", + "allRequirementsMet": "All requirements have been met", + "completeRequirementsToContinue": "Complete the requirements below to continue accessing this organization", + "youCanNowAccessOrganization": "You can now access this organization", + "reauthenticationRequired": "Session Length", + "reauthenticationDescription": "This organization requires you to log in every {maxDays} days.", + "reauthenticationDescriptionHours": "This organization requires you to log in every {maxHours} hours.", + "reauthenticateNow": "Log In Again", "adminEnabled2FaOnYourAccount": "Seu administrador ativou a autenticação de dois fatores para {email}. Complete o processo de configuração para continuar.", "securityKeyAdd": "Adicionar Chave de Segurança", "securityKeyRegisterTitle": "Registrar Nova Chave de Segurança", @@ -1719,6 +1744,7 @@ "orgAuthNoIdpConfigured": "Esta organização não tem nenhum provedor de identidade configurado. Você pode entrar com a identidade do seu Pangolin.", "orgAuthSignInWithPangolin": "Entrar com o Pangolin", "subscriptionRequiredToUse": "Uma assinatura é necessária para usar esse recurso.", + "licenseRequiredToUse": "An Enterprise license is required to use this feature.", "idpDisabled": "Provedores de identidade estão desabilitados.", "orgAuthPageDisabled": "A página de autenticação da organização está desativada.", "domainRestartedDescription": "Verificação de domínio reiniciado com sucesso", @@ -1726,6 +1752,47 @@ "resourceExposePortsEditFile": "Editar arquivo: docker-compose.yml", "emailVerificationRequired": "Verificação de e-mail é necessária. Por favor, faça login novamente via {dashboardUrl}/auth/login conclui esta etapa. Em seguida, volte aqui.", "twoFactorSetupRequired": "Configuração de autenticação de dois fatores é necessária. Por favor, entre novamente via {dashboardUrl}/auth/login conclua este passo. Em seguida, volte aqui.", + "additionalSecurityRequired": "Additional Security Required", + "organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.", + "completeTheseSteps": "Complete these steps", + "enableTwoFactorAuthentication": "Enable two-factor authentication", + "completeSecuritySteps": "Complete Security Steps", + "securitySettings": "Security Settings", + "securitySettingsDescription": "Configure security policies for your organization", + "requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users", + "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", + "requireTwoFactorDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "requireTwoFactorCannotEnableDescription": "You must enable two-factor authentication for your account before enforcing it for all users", + "maxSessionLength": "Maximum Session Length", + "maxSessionLengthDescription": "Set the maximum duration for user sessions. After this time, users will need to re-authenticate.", + "maxSessionLengthDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "selectSessionLength": "Select session length", + "unenforced": "Unenforced", + "1Hour": "1 hour", + "3Hours": "3 hours", + "6Hours": "6 hours", + "12Hours": "12 hours", + "1DaySession": "1 day", + "3Days": "3 days", + "7Days": "7 days", + "14Days": "14 days", + "30DaysSession": "30 days", + "90DaysSession": "90 days", + "180DaysSession": "180 days", + "passwordExpiryDays": "Password Expiry", + "editPasswordExpiryDescription": "Set the number of days before users are required to change their password.", + "selectPasswordExpiry": "Select password expiry", + "30Days": "30 days", + "1Day": "1 day", + "60Days": "60 days", + "90Days": "90 days", + "180Days": "180 days", + "1Year": "1 year", + "subscriptionBadge": "Subscription Required", + "securityPolicyChangeWarning": "Security Policy Change Warning", + "securityPolicyChangeDescription": "You are about to change security policy settings. After saving, you may need to reauthenticate to comply with these policy updates. All users who are not compliant will also need to reauthenticate.", + "securityPolicyChangeConfirmMessage": "I confirm", + "securityPolicyChangeWarningText": "This will affect all users in the organization", "authPageErrorUpdateMessage": "Ocorreu um erro ao atualizar as configurações da página de autenticação", "authPageUpdated": "Página de autenticação atualizada com sucesso", "healthCheckNotAvailable": "Localização", @@ -1891,5 +1958,86 @@ "cannotbeUndone": "Isso não pode ser desfeito.", "toConfirm": "para confirmar", "deleteClientQuestion": "Você tem certeza que deseja remover o cliente do site e da organização?", - "clientMessageRemove": "Depois de removido, o cliente não poderá mais se conectar ao site." + "clientMessageRemove": "Depois de removido, o cliente não poderá mais se conectar ao site.", + "sidebarLogs": "Logs", + "request": "Request", + "logs": "Logs", + "logsSettingsDescription": "Monitor logs collected from this orginization", + "searchLogs": "Search logs...", + "action": "Action", + "actor": "Actor", + "timestamp": "Timestamp", + "accessLogs": "Access Logs", + "exportCsv": "Export CSV", + "actorId": "Actor ID", + "allowedByRule": "Allowed by Rule", + "allowedNoAuth": "Allowed No Auth", + "validAccessToken": "Valid Access Token", + "validHeaderAuth": "Valid header auth", + "validPincode": "Valid Pincode", + "validPassword": "Valid Password", + "validEmail": "Valid email", + "validSSO": "Valid SSO", + "resourceBlocked": "Resource Blocked", + "droppedByRule": "Dropped by Rule", + "noSessions": "No Sessions", + "temporaryRequestToken": "Temporary Request Token", + "noMoreAuthMethods": "No Valid Auth", + "ip": "IP", + "reason": "Reason", + "requestLogs": "Request Logs", + "host": "Host", + "location": "Location", + "actionLogs": "Action Logs", + "sidebarLogsRequest": "Request Logs", + "sidebarLogsAccess": "Access Logs", + "sidebarLogsAction": "Action Logs", + "logRetention": "Log Retention", + "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", + "requestLogsDescription": "View detailed request logs for resources in this organization", + "logRetentionRequestLabel": "Request Log Retention", + "logRetentionRequestDescription": "How long to retain request logs", + "logRetentionAccessLabel": "Access Log Retention", + "logRetentionAccessDescription": "How long to retain access logs", + "logRetentionActionLabel": "Action Log Retention", + "logRetentionActionDescription": "How long to retain action logs", + "logRetentionDisabled": "Disabled", + "logRetention3Days": "3 days", + "logRetention7Days": "7 days", + "logRetention14Days": "14 days", + "logRetention30Days": "30 days", + "logRetention90Days": "90 days", + "logRetentionForever": "Forever", + "actionLogsDescription": "View a history of actions performed in this organization", + "accessLogsDescription": "View access auth requests for resources in this organization", + "certResolver": "Resolvedor de Certificado", + "certResolverDescription": "Selecione o resolvedor de certificados para este recurso.", + "selectCertResolver": "Selecionar solucionador de certificado", + "enterCustomResolver": "Inserir Resolvedor Personalizado", + "preferWildcardCert": "Prefere Certificado Wildcard", + "unverified": "Não verificado", + "domainSetting": "Configurações do domínio", + "domainSettingDescription": "Configure as configurações para o seu domínio", + "preferWildcardCertDescription": "Tentativa de gerar um certificado coringa (requer um resolvedor de certificado devidamente configurado).", + "recordName": "Nome da gravação", + "auto": "Automático", + "TTL": "TTL", + "howToAddRecords": "Como adicionar registros", + "dnsRecord": "Registros DNS", + "required": "Obrigatório", + "domainSettingsUpdated": "Configurações de domínio atualizadas com sucesso", + "orgOrDomainIdMissing": "ID da organização ou domínio está faltando", + "loadingDNSRecords": "Carregando registros DNS...", + "olmUpdateAvailableInfo": "Uma versão atualizada do Olm está disponível. Atualize para a versão mais recente para ter a melhor experiência.", + "client": "Cliente", + "proxyProtocol": "Configurações de Protocolo Proxy", + "proxyProtocolDescription": "Configurar o protocolo Proxy para preservar endereços IP do cliente para serviços TCP/UDP.", + "enableProxyProtocol": "Habilitar protocolo proxy", + "proxyProtocolInfo": "Preservar endereços IP do cliente para backends TCP/UDP", + "proxyProtocolVersion": "Versão do Protocolo Proxy", + "version1": " Versão 1 (recomendado)", + "version2": "Versão 2", + "versionDescription": "A versão 1 é baseada em texto e amplamente suportada. A versão 2 é binária e mais eficiente, mas menos compatível.", + "warning": "ATENÇÃO", + "proxyProtocolWarning": "Seu aplicativo de backend deve ser configurado para aceitar conexões de protocolo de proxy. Se o seu backend não suportar o protocolo de protocolo, habilitando isso quebrará todas as conexões. Certifique-se de configurar seu backend para confiar nos cabeçalhos do protocolo proxy no Traefik." } diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 96c70584..568fae86 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -911,6 +911,18 @@ "passwordResetCodeDescription": "Проверьте вашу почту для получения кода сброса пароля.", "passwordNew": "Новый пароль", "passwordNewConfirm": "Подтвердите новый пароль", + "changePassword": "Change Password", + "changePasswordDescription": "Update your account password", + "oldPassword": "Current Password", + "newPassword": "New Password", + "confirmNewPassword": "Confirm New Password", + "changePasswordError": "Failed to change password", + "changePasswordErrorDescription": "An error occurred while changing your password", + "changePasswordSuccess": "Password Changed Successfully", + "changePasswordSuccessDescription": "Your password has been updated successfully", + "passwordExpiryRequired": "Password Expiry Required", + "passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.", + "changePasswordNow": "Change Password Now", "pincodeAuth": "Код аутентификатора", "pincodeSubmit2": "Отправить код", "passwordResetSubmit": "Запросить сброс", @@ -1340,6 +1352,19 @@ "securityKeyUnknownError": "Произошла проблема при использовании вашего ключа безопасности. Пожалуйста, попробуйте еще раз.", "twoFactorRequired": "Для регистрации ключа безопасности требуется двухфакторная аутентификация.", "twoFactor": "Двухфакторная аутентификация", + "twoFactorAuthentication": "Two-Factor Authentication", + "twoFactorDescription": "This organization requires two-factor authentication.", + "enableTwoFactor": "Enable Two-Factor Authentication", + "organizationSecurityPolicy": "Organization Security Policy", + "organizationSecurityPolicyDescription": "This organization has security requirements that must be met before you can access it", + "securityRequirements": "Security Requirements", + "allRequirementsMet": "All requirements have been met", + "completeRequirementsToContinue": "Complete the requirements below to continue accessing this organization", + "youCanNowAccessOrganization": "You can now access this organization", + "reauthenticationRequired": "Session Length", + "reauthenticationDescription": "This organization requires you to log in every {maxDays} days.", + "reauthenticationDescriptionHours": "This organization requires you to log in every {maxHours} hours.", + "reauthenticateNow": "Log In Again", "adminEnabled2FaOnYourAccount": "Ваш администратор включил двухфакторную аутентификацию для {email}. Пожалуйста, завершите процесс настройки, чтобы продолжить.", "securityKeyAdd": "Добавить ключ безопасности", "securityKeyRegisterTitle": "Регистрация нового ключа безопасности", @@ -1719,6 +1744,7 @@ "orgAuthNoIdpConfigured": "Эта организация не имеет настроенных поставщиков идентификационных данных. Вместо этого вы можете войти в свой Pangolin.", "orgAuthSignInWithPangolin": "Войти через Pangolin", "subscriptionRequiredToUse": "Для использования этой функции требуется подписка.", + "licenseRequiredToUse": "An Enterprise license is required to use this feature.", "idpDisabled": "Провайдеры идентификации отключены.", "orgAuthPageDisabled": "Страница авторизации организации отключена.", "domainRestartedDescription": "Проверка домена успешно перезапущена", @@ -1726,6 +1752,47 @@ "resourceExposePortsEditFile": "Редактировать файл: docker-compose.yml", "emailVerificationRequired": "Требуется подтверждение адреса электронной почты. Пожалуйста, войдите снова через {dashboardUrl}/auth/login завершить этот шаг. Затем вернитесь сюда.", "twoFactorSetupRequired": "Требуется настройка двухфакторной аутентификации. Пожалуйста, войдите снова через {dashboardUrl}/auth/login завершить этот шаг. Затем вернитесь сюда.", + "additionalSecurityRequired": "Additional Security Required", + "organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.", + "completeTheseSteps": "Complete these steps", + "enableTwoFactorAuthentication": "Enable two-factor authentication", + "completeSecuritySteps": "Complete Security Steps", + "securitySettings": "Security Settings", + "securitySettingsDescription": "Configure security policies for your organization", + "requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users", + "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", + "requireTwoFactorDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "requireTwoFactorCannotEnableDescription": "You must enable two-factor authentication for your account before enforcing it for all users", + "maxSessionLength": "Maximum Session Length", + "maxSessionLengthDescription": "Set the maximum duration for user sessions. After this time, users will need to re-authenticate.", + "maxSessionLengthDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "selectSessionLength": "Select session length", + "unenforced": "Unenforced", + "1Hour": "1 hour", + "3Hours": "3 hours", + "6Hours": "6 hours", + "12Hours": "12 hours", + "1DaySession": "1 day", + "3Days": "3 days", + "7Days": "7 days", + "14Days": "14 days", + "30DaysSession": "30 days", + "90DaysSession": "90 days", + "180DaysSession": "180 days", + "passwordExpiryDays": "Password Expiry", + "editPasswordExpiryDescription": "Set the number of days before users are required to change their password.", + "selectPasswordExpiry": "Select password expiry", + "30Days": "30 days", + "1Day": "1 day", + "60Days": "60 days", + "90Days": "90 days", + "180Days": "180 days", + "1Year": "1 year", + "subscriptionBadge": "Subscription Required", + "securityPolicyChangeWarning": "Security Policy Change Warning", + "securityPolicyChangeDescription": "You are about to change security policy settings. After saving, you may need to reauthenticate to comply with these policy updates. All users who are not compliant will also need to reauthenticate.", + "securityPolicyChangeConfirmMessage": "I confirm", + "securityPolicyChangeWarningText": "This will affect all users in the organization", "authPageErrorUpdateMessage": "Произошла ошибка при обновлении настроек страницы авторизации", "authPageUpdated": "Страница авторизации успешно обновлена", "healthCheckNotAvailable": "Локальный", @@ -1891,5 +1958,86 @@ "cannotbeUndone": "Это действие не может быть отменено.", "toConfirm": "для подтверждения", "deleteClientQuestion": "Вы уверены, что хотите удалить клиента из сайта и организации?", - "clientMessageRemove": "После удаления клиент больше не сможет подключиться к сайту." + "clientMessageRemove": "После удаления клиент больше не сможет подключиться к сайту.", + "sidebarLogs": "Logs", + "request": "Request", + "logs": "Logs", + "logsSettingsDescription": "Monitor logs collected from this orginization", + "searchLogs": "Search logs...", + "action": "Action", + "actor": "Actor", + "timestamp": "Timestamp", + "accessLogs": "Access Logs", + "exportCsv": "Export CSV", + "actorId": "Actor ID", + "allowedByRule": "Allowed by Rule", + "allowedNoAuth": "Allowed No Auth", + "validAccessToken": "Valid Access Token", + "validHeaderAuth": "Valid header auth", + "validPincode": "Valid Pincode", + "validPassword": "Valid Password", + "validEmail": "Valid email", + "validSSO": "Valid SSO", + "resourceBlocked": "Resource Blocked", + "droppedByRule": "Dropped by Rule", + "noSessions": "No Sessions", + "temporaryRequestToken": "Temporary Request Token", + "noMoreAuthMethods": "No Valid Auth", + "ip": "IP", + "reason": "Reason", + "requestLogs": "Request Logs", + "host": "Host", + "location": "Location", + "actionLogs": "Action Logs", + "sidebarLogsRequest": "Request Logs", + "sidebarLogsAccess": "Access Logs", + "sidebarLogsAction": "Action Logs", + "logRetention": "Log Retention", + "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", + "requestLogsDescription": "View detailed request logs for resources in this organization", + "logRetentionRequestLabel": "Request Log Retention", + "logRetentionRequestDescription": "How long to retain request logs", + "logRetentionAccessLabel": "Access Log Retention", + "logRetentionAccessDescription": "How long to retain access logs", + "logRetentionActionLabel": "Action Log Retention", + "logRetentionActionDescription": "How long to retain action logs", + "logRetentionDisabled": "Disabled", + "logRetention3Days": "3 days", + "logRetention7Days": "7 days", + "logRetention14Days": "14 days", + "logRetention30Days": "30 days", + "logRetention90Days": "90 days", + "logRetentionForever": "Forever", + "actionLogsDescription": "View a history of actions performed in this organization", + "accessLogsDescription": "View access auth requests for resources in this organization", + "certResolver": "Резольвер сертификата", + "certResolverDescription": "Выберите резолвер сертификата, который будет использоваться для этого ресурса.", + "selectCertResolver": "Выберите резолвер сертификата", + "enterCustomResolver": "Введите пользовательский резолвер", + "preferWildcardCert": "Предпочитать сертификат Wildcard", + "unverified": "Не подтверждено", + "domainSetting": "Настройки домена", + "domainSettingDescription": "Настройка параметров для вашего домена", + "preferWildcardCertDescription": "Попытка создания шаблона сертификата (требуется должным образом сконфигурированный резолвер сертификата).", + "recordName": "Имя записи", + "auto": "Авто", + "TTL": "TTL", + "howToAddRecords": "Как добавить записи", + "dnsRecord": "DNS записи", + "required": "Требуется", + "domainSettingsUpdated": "Настройки домена успешно обновлены", + "orgOrDomainIdMissing": "Отсутствует организация или ID домена", + "loadingDNSRecords": "Загрузка записей DNS...", + "olmUpdateAvailableInfo": "Доступна обновленная версия Олма. Пожалуйста, обновитесь до последней версии.", + "client": "Клиент", + "proxyProtocol": "Настройки протокола прокси", + "proxyProtocolDescription": "Настроить Прокси-протокол для сохранения IP-адресов клиента для служб TCP/UDP.", + "enableProxyProtocol": "Включить Прокси Протокол", + "proxyProtocolInfo": "Сохранять IP-адреса клиента для кэша TCP/UDP", + "proxyProtocolVersion": "Версия протокола прокси", + "version1": " Версия 1 (рекомендуется)", + "version2": "Версия 2", + "versionDescription": "Версия 1 основана на тексте и широко поддерживается. Версия 2 является бинарной и более эффективной, но менее совместимой.", + "warning": "Предупреждение", + "proxyProtocolWarning": "Бэкэнд приложение должно быть сконфигурировано для принятия прокси-соединений. Если ваш бэкэнд не поддерживает Прокси-протокол, это нарушит все соединения. Обязательно настройте вашего бэкэнда на доверие заголовкам Proxy Protocol от Traefik." } diff --git a/messages/tr-TR.json b/messages/tr-TR.json index bec492a3..e1c9d9c3 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -911,6 +911,18 @@ "passwordResetCodeDescription": "E-posta gelen kutunuzda sıfırlama kodunu kontrol edin.", "passwordNew": "Yeni Şifre", "passwordNewConfirm": "Yeni Şifreyi Onayla", + "changePassword": "Change Password", + "changePasswordDescription": "Update your account password", + "oldPassword": "Current Password", + "newPassword": "New Password", + "confirmNewPassword": "Confirm New Password", + "changePasswordError": "Failed to change password", + "changePasswordErrorDescription": "An error occurred while changing your password", + "changePasswordSuccess": "Password Changed Successfully", + "changePasswordSuccessDescription": "Your password has been updated successfully", + "passwordExpiryRequired": "Password Expiry Required", + "passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.", + "changePasswordNow": "Change Password Now", "pincodeAuth": "Kimlik Doğrulama Kodu", "pincodeSubmit2": "Kodu Gönder", "passwordResetSubmit": "Sıfırlama İsteği", @@ -1340,6 +1352,19 @@ "securityKeyUnknownError": "Güvenlik anahtarınızı kullanırken bir sorun oluştu. Lütfen tekrar deneyin.", "twoFactorRequired": "Güvenlik anahtarını kaydetmek için iki faktörlü kimlik doğrulama gereklidir.", "twoFactor": "İki Faktörlü Kimlik Doğrulama", + "twoFactorAuthentication": "Two-Factor Authentication", + "twoFactorDescription": "This organization requires two-factor authentication.", + "enableTwoFactor": "Enable Two-Factor Authentication", + "organizationSecurityPolicy": "Organization Security Policy", + "organizationSecurityPolicyDescription": "This organization has security requirements that must be met before you can access it", + "securityRequirements": "Security Requirements", + "allRequirementsMet": "All requirements have been met", + "completeRequirementsToContinue": "Complete the requirements below to continue accessing this organization", + "youCanNowAccessOrganization": "You can now access this organization", + "reauthenticationRequired": "Session Length", + "reauthenticationDescription": "This organization requires you to log in every {maxDays} days.", + "reauthenticationDescriptionHours": "This organization requires you to log in every {maxHours} hours.", + "reauthenticateNow": "Log In Again", "adminEnabled2FaOnYourAccount": "Yöneticiniz {email} için iki faktörlü kimlik doğrulamayı etkinleştirdi. Devam etmek için kurulum işlemini tamamlayın.", "securityKeyAdd": "Güvenlik Anahtarı Ekle", "securityKeyRegisterTitle": "Yeni Güvenlik Anahtarı Kaydet", @@ -1719,6 +1744,7 @@ "orgAuthNoIdpConfigured": "Bu kuruluşta yapılandırılmış kimlik sağlayıcı yok. Bunun yerine Pangolin kimliğinizle giriş yapabilirsiniz.", "orgAuthSignInWithPangolin": "Pangolin ile Giriş Yap", "subscriptionRequiredToUse": "Bu özelliği kullanmak için abonelik gerekmektedir.", + "licenseRequiredToUse": "An Enterprise license is required to use this feature.", "idpDisabled": "Kimlik sağlayıcılar devre dışı bırakılmıştır.", "orgAuthPageDisabled": "Kuruluş kimlik doğrulama sayfası devre dışı bırakılmıştır.", "domainRestartedDescription": "Alan doğrulaması başarıyla yeniden başlatıldı", @@ -1726,6 +1752,47 @@ "resourceExposePortsEditFile": "Dosyayı düzenle: docker-compose.yml", "emailVerificationRequired": "E-posta doğrulaması gereklidir. Bu adımı tamamlamak için lütfen tekrar {dashboardUrl}/auth/login üzerinden oturum açın. Sonra buraya geri dönün.", "twoFactorSetupRequired": "İki faktörlü kimlik doğrulama ayarı gereklidir. Bu adımı tamamlamak için lütfen tekrar {dashboardUrl}/auth/login üzerinden oturum açın. Sonra buraya geri dönün.", + "additionalSecurityRequired": "Additional Security Required", + "organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.", + "completeTheseSteps": "Complete these steps", + "enableTwoFactorAuthentication": "Enable two-factor authentication", + "completeSecuritySteps": "Complete Security Steps", + "securitySettings": "Security Settings", + "securitySettingsDescription": "Configure security policies for your organization", + "requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users", + "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", + "requireTwoFactorDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "requireTwoFactorCannotEnableDescription": "You must enable two-factor authentication for your account before enforcing it for all users", + "maxSessionLength": "Maximum Session Length", + "maxSessionLengthDescription": "Set the maximum duration for user sessions. After this time, users will need to re-authenticate.", + "maxSessionLengthDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "selectSessionLength": "Select session length", + "unenforced": "Unenforced", + "1Hour": "1 hour", + "3Hours": "3 hours", + "6Hours": "6 hours", + "12Hours": "12 hours", + "1DaySession": "1 day", + "3Days": "3 days", + "7Days": "7 days", + "14Days": "14 days", + "30DaysSession": "30 days", + "90DaysSession": "90 days", + "180DaysSession": "180 days", + "passwordExpiryDays": "Password Expiry", + "editPasswordExpiryDescription": "Set the number of days before users are required to change their password.", + "selectPasswordExpiry": "Select password expiry", + "30Days": "30 days", + "1Day": "1 day", + "60Days": "60 days", + "90Days": "90 days", + "180Days": "180 days", + "1Year": "1 year", + "subscriptionBadge": "Subscription Required", + "securityPolicyChangeWarning": "Security Policy Change Warning", + "securityPolicyChangeDescription": "You are about to change security policy settings. After saving, you may need to reauthenticate to comply with these policy updates. All users who are not compliant will also need to reauthenticate.", + "securityPolicyChangeConfirmMessage": "I confirm", + "securityPolicyChangeWarningText": "This will affect all users in the organization", "authPageErrorUpdateMessage": "Kimlik doğrulama sayfası ayarları güncellenirken bir hata oluştu.", "authPageUpdated": "Kimlik doğrulama sayfası başarıyla güncellendi", "healthCheckNotAvailable": "Yerel", @@ -1891,5 +1958,86 @@ "cannotbeUndone": "Bu geri alınamaz.", "toConfirm": "doğrulamak için", "deleteClientQuestion": "Müşteriyi siteden ve organizasyondan kaldırmak istediğinizden emin misiniz?", - "clientMessageRemove": "Kaldırıldıktan sonra müşteri siteye bağlanamayacaktır." + "clientMessageRemove": "Kaldırıldıktan sonra müşteri siteye bağlanamayacaktır.", + "sidebarLogs": "Logs", + "request": "Request", + "logs": "Logs", + "logsSettingsDescription": "Monitor logs collected from this orginization", + "searchLogs": "Search logs...", + "action": "Action", + "actor": "Actor", + "timestamp": "Timestamp", + "accessLogs": "Access Logs", + "exportCsv": "Export CSV", + "actorId": "Actor ID", + "allowedByRule": "Allowed by Rule", + "allowedNoAuth": "Allowed No Auth", + "validAccessToken": "Valid Access Token", + "validHeaderAuth": "Valid header auth", + "validPincode": "Valid Pincode", + "validPassword": "Valid Password", + "validEmail": "Valid email", + "validSSO": "Valid SSO", + "resourceBlocked": "Resource Blocked", + "droppedByRule": "Dropped by Rule", + "noSessions": "No Sessions", + "temporaryRequestToken": "Temporary Request Token", + "noMoreAuthMethods": "No Valid Auth", + "ip": "IP", + "reason": "Reason", + "requestLogs": "Request Logs", + "host": "Host", + "location": "Location", + "actionLogs": "Action Logs", + "sidebarLogsRequest": "Request Logs", + "sidebarLogsAccess": "Access Logs", + "sidebarLogsAction": "Action Logs", + "logRetention": "Log Retention", + "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", + "requestLogsDescription": "View detailed request logs for resources in this organization", + "logRetentionRequestLabel": "Request Log Retention", + "logRetentionRequestDescription": "How long to retain request logs", + "logRetentionAccessLabel": "Access Log Retention", + "logRetentionAccessDescription": "How long to retain access logs", + "logRetentionActionLabel": "Action Log Retention", + "logRetentionActionDescription": "How long to retain action logs", + "logRetentionDisabled": "Disabled", + "logRetention3Days": "3 days", + "logRetention7Days": "7 days", + "logRetention14Days": "14 days", + "logRetention30Days": "30 days", + "logRetention90Days": "90 days", + "logRetentionForever": "Forever", + "actionLogsDescription": "View a history of actions performed in this organization", + "accessLogsDescription": "View access auth requests for resources in this organization", + "certResolver": "Sertifika Çözücü", + "certResolverDescription": "Bu kaynak için kullanılacak sertifika çözücüsünü seçin.", + "selectCertResolver": "Sertifika Çözücü Seçin", + "enterCustomResolver": "Özel Çözücü Girin", + "preferWildcardCert": "Joker Sertifikayı Tercih Et", + "unverified": "Doğrulanmadı", + "domainSetting": "Alan Adı Ayarları", + "domainSettingDescription": "Alan adınız için ayarları yapılandırın", + "preferWildcardCertDescription": "Joker sertifika üretmeye çalışın (doğru yapılandırılmış bir sertifika çözücü gereklidir).", + "recordName": "Kayıt Adı", + "auto": "Otomatik", + "TTL": "TTL", + "howToAddRecords": "Kayıtları Nasıl Ekleyebilirsiniz", + "dnsRecord": "DNS Kayıtları", + "required": "Gerekli", + "domainSettingsUpdated": "Alan adına yönelik ayarlar başarıyla güncellendi", + "orgOrDomainIdMissing": "Organizasyon veya Alan Adı Kimliği eksik", + "loadingDNSRecords": "DNS kayıtları yükleniyor...", + "olmUpdateAvailableInfo": "Olm'nin güncellenmiş bir sürümü mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.", + "client": "İstemci", + "proxyProtocol": "Proxy Protokol Ayarları", + "proxyProtocolDescription": "TCP/UDP hizmetleri için istemci IP adreslerini korumak için Proxy Protokolünü yapılandırın.", + "enableProxyProtocol": "Proxy Protokolünü Etkinleştir", + "proxyProtocolInfo": "TCP/UDP arka uçları için istemci IP adreslerini koruyun", + "proxyProtocolVersion": "Proxy Protokol Versiyonu", + "version1": " Versiyon 1 (Önerilen)", + "version2": "Versiyon 2", + "versionDescription": "Versiyon 1 metin tabanlı ve yaygın olarak desteklenir. Versiyon 2 ise ikili ve daha verimlidir ama daha az uyumludur.", + "warning": "Uyarı", + "proxyProtocolWarning": "Arka uç uygulamanız, Proxy Protokol bağlantılarını kabul etmek üzere yapılandırılmalıdır. Arka ucunuz Proxy Protokolünü desteklemiyorsa, bunu etkinleştirmek tüm bağlantıları koparır. Traefik'ten gelen Proxy Protokol başlıklarına güvenecek şekilde arka ucunuzu yapılandırdığınızdan emin olun." } diff --git a/messages/zh-CN.json b/messages/zh-CN.json index d6df6e5a..5d810422 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -911,6 +911,18 @@ "passwordResetCodeDescription": "请检查您的电子邮件以获取验证码。", "passwordNew": "新密码", "passwordNewConfirm": "确认新密码", + "changePassword": "Change Password", + "changePasswordDescription": "Update your account password", + "oldPassword": "Current Password", + "newPassword": "New Password", + "confirmNewPassword": "Confirm New Password", + "changePasswordError": "Failed to change password", + "changePasswordErrorDescription": "An error occurred while changing your password", + "changePasswordSuccess": "Password Changed Successfully", + "changePasswordSuccessDescription": "Your password has been updated successfully", + "passwordExpiryRequired": "Password Expiry Required", + "passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.", + "changePasswordNow": "Change Password Now", "pincodeAuth": "验证器代码", "pincodeSubmit2": "提交代码", "passwordResetSubmit": "请求重置", @@ -1340,6 +1352,19 @@ "securityKeyUnknownError": "使用安全密钥时出现问题。请再试一次。", "twoFactorRequired": "注册安全密钥需要两步验证。", "twoFactor": "两步验证", + "twoFactorAuthentication": "Two-Factor Authentication", + "twoFactorDescription": "This organization requires two-factor authentication.", + "enableTwoFactor": "Enable Two-Factor Authentication", + "organizationSecurityPolicy": "Organization Security Policy", + "organizationSecurityPolicyDescription": "This organization has security requirements that must be met before you can access it", + "securityRequirements": "Security Requirements", + "allRequirementsMet": "All requirements have been met", + "completeRequirementsToContinue": "Complete the requirements below to continue accessing this organization", + "youCanNowAccessOrganization": "You can now access this organization", + "reauthenticationRequired": "Session Length", + "reauthenticationDescription": "This organization requires you to log in every {maxDays} days.", + "reauthenticationDescriptionHours": "This organization requires you to log in every {maxHours} hours.", + "reauthenticateNow": "Log In Again", "adminEnabled2FaOnYourAccount": "管理员已为{email}启用两步验证。请完成设置以继续。", "securityKeyAdd": "添加安全密钥", "securityKeyRegisterTitle": "注册新安全密钥", @@ -1719,6 +1744,7 @@ "orgAuthNoIdpConfigured": "此机构没有配置任何身份提供者。您可以使用您的 Pangolin 身份登录。", "orgAuthSignInWithPangolin": "使用 Pangolin 登录", "subscriptionRequiredToUse": "需要订阅才能使用此功能。", + "licenseRequiredToUse": "An Enterprise license is required to use this feature.", "idpDisabled": "身份提供者已禁用。", "orgAuthPageDisabled": "组织认证页面已禁用。", "domainRestartedDescription": "域验证重新启动成功", @@ -1726,6 +1752,47 @@ "resourceExposePortsEditFile": "编辑文件:docker-compose.yml", "emailVerificationRequired": "需要电子邮件验证。 请通过 {dashboardUrl}/auth/login 再次登录以完成此步骤。 然后,回到这里。", "twoFactorSetupRequired": "需要设置双因素身份验证。 请通过 {dashboardUrl}/auth/login 再次登录以完成此步骤。 然后,回到这里。", + "additionalSecurityRequired": "Additional Security Required", + "organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.", + "completeTheseSteps": "Complete these steps", + "enableTwoFactorAuthentication": "Enable two-factor authentication", + "completeSecuritySteps": "Complete Security Steps", + "securitySettings": "Security Settings", + "securitySettingsDescription": "Configure security policies for your organization", + "requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users", + "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", + "requireTwoFactorDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "requireTwoFactorCannotEnableDescription": "You must enable two-factor authentication for your account before enforcing it for all users", + "maxSessionLength": "Maximum Session Length", + "maxSessionLengthDescription": "Set the maximum duration for user sessions. After this time, users will need to re-authenticate.", + "maxSessionLengthDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "selectSessionLength": "Select session length", + "unenforced": "Unenforced", + "1Hour": "1 hour", + "3Hours": "3 hours", + "6Hours": "6 hours", + "12Hours": "12 hours", + "1DaySession": "1 day", + "3Days": "3 days", + "7Days": "7 days", + "14Days": "14 days", + "30DaysSession": "30 days", + "90DaysSession": "90 days", + "180DaysSession": "180 days", + "passwordExpiryDays": "Password Expiry", + "editPasswordExpiryDescription": "Set the number of days before users are required to change their password.", + "selectPasswordExpiry": "Select password expiry", + "30Days": "30 days", + "1Day": "1 day", + "60Days": "60 days", + "90Days": "90 days", + "180Days": "180 days", + "1Year": "1 year", + "subscriptionBadge": "Subscription Required", + "securityPolicyChangeWarning": "Security Policy Change Warning", + "securityPolicyChangeDescription": "You are about to change security policy settings. After saving, you may need to reauthenticate to comply with these policy updates. All users who are not compliant will also need to reauthenticate.", + "securityPolicyChangeConfirmMessage": "I confirm", + "securityPolicyChangeWarningText": "This will affect all users in the organization", "authPageErrorUpdateMessage": "更新身份验证页面设置时出错", "authPageUpdated": "身份验证页面更新成功", "healthCheckNotAvailable": "本地的", @@ -1891,5 +1958,86 @@ "cannotbeUndone": "无法撤消。", "toConfirm": "确认", "deleteClientQuestion": "您确定要从站点和组织中删除客户吗?", - "clientMessageRemove": "一旦删除,客户端将无法连接到站点。" + "clientMessageRemove": "一旦删除,客户端将无法连接到站点。", + "sidebarLogs": "Logs", + "request": "Request", + "logs": "Logs", + "logsSettingsDescription": "Monitor logs collected from this orginization", + "searchLogs": "Search logs...", + "action": "Action", + "actor": "Actor", + "timestamp": "Timestamp", + "accessLogs": "Access Logs", + "exportCsv": "Export CSV", + "actorId": "Actor ID", + "allowedByRule": "Allowed by Rule", + "allowedNoAuth": "Allowed No Auth", + "validAccessToken": "Valid Access Token", + "validHeaderAuth": "Valid header auth", + "validPincode": "Valid Pincode", + "validPassword": "Valid Password", + "validEmail": "Valid email", + "validSSO": "Valid SSO", + "resourceBlocked": "Resource Blocked", + "droppedByRule": "Dropped by Rule", + "noSessions": "No Sessions", + "temporaryRequestToken": "Temporary Request Token", + "noMoreAuthMethods": "No Valid Auth", + "ip": "IP", + "reason": "Reason", + "requestLogs": "Request Logs", + "host": "Host", + "location": "Location", + "actionLogs": "Action Logs", + "sidebarLogsRequest": "Request Logs", + "sidebarLogsAccess": "Access Logs", + "sidebarLogsAction": "Action Logs", + "logRetention": "Log Retention", + "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", + "requestLogsDescription": "View detailed request logs for resources in this organization", + "logRetentionRequestLabel": "Request Log Retention", + "logRetentionRequestDescription": "How long to retain request logs", + "logRetentionAccessLabel": "Access Log Retention", + "logRetentionAccessDescription": "How long to retain access logs", + "logRetentionActionLabel": "Action Log Retention", + "logRetentionActionDescription": "How long to retain action logs", + "logRetentionDisabled": "Disabled", + "logRetention3Days": "3 days", + "logRetention7Days": "7 days", + "logRetention14Days": "14 days", + "logRetention30Days": "30 days", + "logRetention90Days": "90 days", + "logRetentionForever": "Forever", + "actionLogsDescription": "View a history of actions performed in this organization", + "accessLogsDescription": "View access auth requests for resources in this organization", + "certResolver": "证书解决器", + "certResolverDescription": "选择用于此资源的证书解析器。", + "selectCertResolver": "选择证书解析", + "enterCustomResolver": "输入自定义解析器", + "preferWildcardCert": "喜欢通配符证书", + "unverified": "未验证", + "domainSetting": "域设置", + "domainSettingDescription": "配置您的域的设置", + "preferWildcardCertDescription": "尝试生成通配符证书(需要正确配置的证书解析器)。", + "recordName": "记录名称", + "auto": "自动操作", + "TTL": "TTL", + "howToAddRecords": "如何添加记录", + "dnsRecord": "DNS记录", + "required": "必填", + "domainSettingsUpdated": "域设置更新成功", + "orgOrDomainIdMissing": "缺少机构或域 ID", + "loadingDNSRecords": "正在载入DNS记录...", + "olmUpdateAvailableInfo": "有最新版本的 Olm 可用。请更新到最新版本以获取最佳体验。", + "client": "客户端:", + "proxyProtocol": "代理协议设置", + "proxyProtocolDescription": "配置代理协议以保留TCP/UDP 服务的客户端IP地址。", + "enableProxyProtocol": "启用代理协议", + "proxyProtocolInfo": "为TCP/UDP 后端保留客户端IP地址", + "proxyProtocolVersion": "代理协议版本", + "version1": " 版本 1 (推荐)", + "version2": "版本 2", + "versionDescription": "版本 1 是基于文本和广泛支持的版本。版本 2 是二进制和更有效率但不那么兼容。", + "warning": "警告", + "proxyProtocolWarning": "您的后端应用程序必须配置为接受代理协议连接。如果您的后端不支持代理协议,启用这将会中断所有连接。 请务必从Traefik配置您的后端到信任代理协议标题。" } diff --git a/package-lock.json b/package-lock.json index 506813a1..9662e481 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "cookies": "^0.9.1", "cors": "2.8.5", "crypto-js": "^4.2.0", + "date-fns": "4.1.0", "drizzle-orm": "0.44.6", "eslint": "9.37.0", "eslint-config-next": "15.5.6", @@ -84,6 +85,7 @@ "posthog-node": "^5.9.5", "qrcode.react": "4.2.0", "react": "19.2.0", + "react-day-picker": "9.11.1", "react-dom": "19.2.0", "react-easy-sort": "^1.8.0", "react-hook-form": "7.65.0", @@ -1634,6 +1636,7 @@ "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -1985,6 +1988,12 @@ "kuler": "^2.0.0" } }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "license": "MIT" + }, "node_modules/@dotenvx/dotenvx": { "version": "1.51.0", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.0.tgz", @@ -4061,6 +4070,7 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -6890,6 +6900,7 @@ "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -7095,6 +7106,7 @@ "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7105,6 +7117,7 @@ "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.25.0" }, @@ -8536,6 +8549,7 @@ "integrity": "sha512-fnQmj8lELIj7BSrZQAdBMHEHX8OZLYIHXqAKT1O7tDfLxaINzf00PMjw22r3N/xXh0w/sGHlO6SVaCQ2mj78lg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -8622,6 +8636,7 @@ "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -8715,6 +8730,7 @@ "integrity": "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.14.0" } @@ -8750,6 +8766,7 @@ "integrity": "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -8783,6 +8800,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -8793,6 +8811,7 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -8936,6 +8955,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz", "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", @@ -9609,6 +9629,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10139,6 +10160,7 @@ "integrity": "sha512-mXpa5jnIKKHeoGzBrUJrc65cXFKcILGZpU3FXR0pradUEm9MA7UZz02qfEejaMcm9iXrSOCenwwYMJ/tZ1y5Ig==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -10252,6 +10274,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -10989,6 +11012,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, "node_modules/debounce": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/debounce/-/debounce-2.2.0.tgz", @@ -11899,6 +11938,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -11995,6 +12035,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -12162,6 +12203,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -12451,6 +12493,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -17616,6 +17659,7 @@ "version": "4.0.3", "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -18600,6 +18644,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -18776,6 +18821,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -19227,15 +19273,38 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.11.1.tgz", + "integrity": "sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0", + "date-fns-jalali": "^4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -19527,6 +19596,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz", "integrity": "sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -20011,6 +20081,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -21182,7 +21253,8 @@ "version": "4.1.14", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz", "integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -21739,6 +21811,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -22244,6 +22317,7 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz", "integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==", "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", @@ -22551,6 +22625,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 178c9f65..6f5b3101 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "cookies": "^0.9.1", "cors": "2.8.5", "crypto-js": "^4.2.0", + "date-fns": "4.1.0", "drizzle-orm": "0.44.6", "eslint": "9.37.0", "eslint-config-next": "15.5.6", @@ -107,6 +108,7 @@ "posthog-node": "^5.9.5", "qrcode.react": "4.2.0", "react": "19.2.0", + "react-day-picker": "9.11.1", "react-dom": "19.2.0", "react-easy-sort": "^1.8.0", "react-hook-form": "7.65.0", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 50ebd964..d00610de 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -81,6 +81,9 @@ export enum ActionsEnum { listClients = "listClients", getClient = "getClient", listOrgDomains = "listOrgDomains", + getDomain = "getDomain", + updateOrgDomain = "updateOrgDomain", + getDNSRecords = "getDNSRecords", createNewt = "createNewt", createIdp = "createIdp", updateIdp = "updateIdp", @@ -121,6 +124,9 @@ export enum ActionsEnum { listBlueprints = "listBlueprints", getBlueprint = "getBlueprint", applyBlueprint = "applyBlueprint" + applyBlueprint = "applyBlueprint", + viewLogs = "viewLogs", + exportLogs = "exportLogs" } export async function checkUserActionPermission( diff --git a/server/auth/sessions/app.ts b/server/auth/sessions/app.ts index e846396d..0e3da100 100644 --- a/server/auth/sessions/app.ts +++ b/server/auth/sessions/app.ts @@ -39,7 +39,8 @@ export async function createSession( const session: Session = { sessionId: sessionId, userId, - expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime() + expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(), + issuedAt: new Date().getTime() }; await db.insert(sessions).values(session); return session; diff --git a/server/auth/sessions/resource.ts b/server/auth/sessions/resource.ts index 31ab2b38..9a5b2b5f 100644 --- a/server/auth/sessions/resource.ts +++ b/server/auth/sessions/resource.ts @@ -50,7 +50,8 @@ export async function createResourceSession(opts: { doNotExtend: opts.doNotExtend || false, accessTokenId: opts.accessTokenId || null, isRequestToken: opts.isRequestToken || false, - userSessionId: opts.userSessionId || null + userSessionId: opts.userSessionId || null, + issuedAt: new Date().getTime() }; await db.insert(resourceSessions).values(session); diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 67fb28ec..266a8646 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -6,7 +6,8 @@ import { integer, bigint, real, - text + text, + index } from "drizzle-orm/pg-core"; import { InferSelectModel } from "drizzle-orm"; import { domains, orgs, targets, users, exitNodes, sessions } from "./schema"; @@ -213,6 +214,43 @@ export const sessionTransferToken = pgTable("sessionTransferToken", { expiresAt: bigint("expiresAt", { mode: "number" }).notNull() }); +export const actionAuditLog = pgTable("actionAuditLog", { + id: serial("id").primaryKey(), + timestamp: bigint("timestamp", { mode: "number" }).notNull(), // this is EPOCH time in seconds + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + actorType: varchar("actorType", { length: 50 }).notNull(), + actor: varchar("actor", { length: 255 }).notNull(), + actorId: varchar("actorId", { length: 255 }).notNull(), + action: varchar("action", { length: 100 }).notNull(), + metadata: text("metadata") +}, (table) => ([ + index("idx_actionAuditLog_timestamp").on(table.timestamp), + index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) +])); + +export const accessAuditLog = pgTable("accessAuditLog", { + id: serial("id").primaryKey(), + timestamp: bigint("timestamp", { mode: "number" }).notNull(), // this is EPOCH time in seconds + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + actorType: varchar("actorType", { length: 50 }), + actor: varchar("actor", { length: 255 }), + actorId: varchar("actorId", { length: 255 }), + resourceId: integer("resourceId"), + ip: varchar("ip", { length: 45 }), + type: varchar("type", { length: 100 }).notNull(), + action: boolean("action").notNull(), + location: text("location"), + userAgent: text("userAgent"), + metadata: text("metadata") +}, (table) => ([ + index("idx_identityAuditLog_timestamp").on(table.timestamp), + index("idx_identityAuditLog_org_timestamp").on(table.orgId, table.timestamp) +])); + export type Limit = InferSelectModel; export type Account = InferSelectModel; export type Certificate = InferSelectModel; @@ -230,3 +268,5 @@ export type RemoteExitNodeSession = InferSelectModel< >; export type ExitNodeOrg = InferSelectModel; export type LoginPage = InferSelectModel; +export type ActionAuditLog = InferSelectModel; +export type AccessAuditLog = InferSelectModel; \ No newline at end of file diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 622963a2..5342fda0 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -6,7 +6,8 @@ import { integer, bigint, real, - text + text, + index } from "drizzle-orm/pg-core"; import { InferSelectModel } from "drizzle-orm"; import { randomUUID } from "crypto"; @@ -18,7 +19,22 @@ export const domains = pgTable("domains", { type: varchar("type"), // "ns", "cname", "wildcard" verified: boolean("verified").notNull().default(false), failed: boolean("failed").notNull().default(false), - tries: integer("tries").notNull().default(0) + tries: integer("tries").notNull().default(0), + certResolver: varchar("certResolver"), + customCertResolver: varchar("customCertResolver"), + preferWildcardCert: boolean("preferWildcardCert") +}); + + +export const dnsRecords = pgTable("dnsRecords", { + id: serial("id").primaryKey(), + domainId: varchar("domainId") + .notNull() + .references(() => domains.domainId, { onDelete: "cascade" }), + recordType: varchar("recordType").notNull(), // "NS" | "CNAME" | "A" | "TXT" + baseDomain: varchar("baseDomain"), + value: varchar("value").notNull(), + verified: boolean("verified").notNull().default(false), }); export const orgs = pgTable("orgs", { @@ -26,7 +42,18 @@ export const orgs = pgTable("orgs", { name: varchar("name").notNull(), subnet: varchar("subnet"), createdAt: text("createdAt"), - settings: text("settings") // JSON blob of org-specific settings + requireTwoFactor: boolean("requireTwoFactor"), + maxSessionLengthHours: integer("maxSessionLengthHours"), + passwordExpiryDays: integer("passwordExpiryDays"), + settingsLogRetentionDaysRequest: integer("settingsLogRetentionDaysRequest") // where 0 = dont keep logs and -1 = keep forever + .notNull() + .default(7), + settingsLogRetentionDaysAccess: integer("settingsLogRetentionDaysAccess") + .notNull() + .default(0), + settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") + .notNull() + .default(0) }); export const orgDomains = pgTable("orgDomains", { @@ -100,9 +127,11 @@ export const resources = pgTable("resources", { setHostHeader: varchar("setHostHeader"), enableProxy: boolean("enableProxy").default(true), skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { - onDelete: "cascade" + onDelete: "set null" }), - headers: text("headers") // comma-separated list of headers to add to the request + headers: text("headers"), // comma-separated list of headers to add to the request + proxyProtocol: boolean("proxyProtocol").notNull().default(false), + proxyProtocolVersion: integer("proxyProtocolVersion").default(1) }); export const targets = pgTable("targets", { @@ -126,7 +155,7 @@ export const targets = pgTable("targets", { pathMatchType: text("pathMatchType"), // exact, prefix, regex rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix - priority: integer("priority").default(100) + priority: integer("priority").notNull().default(100) }); export const targetHealthCheck = pgTable("targetHealthCheck", { @@ -200,7 +229,8 @@ export const users = pgTable("user", { dateCreated: varchar("dateCreated").notNull(), termsAcceptedTimestamp: varchar("termsAcceptedTimestamp"), termsVersion: varchar("termsVersion"), - serverAdmin: boolean("serverAdmin").notNull().default(false) + serverAdmin: boolean("serverAdmin").notNull().default(false), + lastPasswordChange: bigint("lastPasswordChange", { mode: "number" }) }); export const newts = pgTable("newt", { @@ -226,7 +256,8 @@ export const sessions = pgTable("session", { userId: varchar("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), - expiresAt: bigint("expiresAt", { mode: "number" }).notNull() + expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), + issuedAt: bigint("issuedAt", { mode: "number" }) }); export const newtSessions = pgTable("newtSession", { @@ -443,7 +474,8 @@ export const resourceSessions = pgTable("resourceSessions", { { onDelete: "cascade" } - ) + ), + issuedAt: bigint("issuedAt", { mode: "number" }) }); export const resourceWhitelist = pgTable("resourceWhitelist", { @@ -681,6 +713,41 @@ export const blueprints = pgTable("blueprints", { contents: text("contents").notNull(), message: text("message") }); +export const requestAuditLog = pgTable( + "requestAuditLog", + { + id: serial("id").primaryKey(), + timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds + orgId: text("orgId").references(() => orgs.orgId, { + onDelete: "cascade" + }), + action: boolean("action").notNull(), + reason: integer("reason").notNull(), + actorType: text("actorType"), + actor: text("actor"), + actorId: text("actorId"), + resourceId: integer("resourceId"), + ip: text("ip"), + location: text("location"), + userAgent: text("userAgent"), + metadata: text("metadata"), + headers: text("headers"), // JSON blob + query: text("query"), // JSON blob + originalRequestURL: text("originalRequestURL"), + scheme: text("scheme"), + host: text("host"), + path: text("path"), + method: text("method"), + tls: boolean("tls") + }, + (table) => [ + index("idx_requestAuditLog_timestamp").on(table.timestamp), + index("idx_requestAuditLog_org_timestamp").on( + table.orgId, + table.timestamp + ) + ] +); export type Org = InferSelectModel; export type User = InferSelectModel; @@ -734,3 +801,7 @@ export type HostMeta = InferSelectModel; export type TargetHealthCheck = InferSelectModel; export type IdpOidcConfig = InferSelectModel; export type Blueprint = InferSelectModel; +export type LicenseKey = InferSelectModel; +export type SecurityKey = InferSelectModel; +export type WebauthnChallenge = InferSelectModel; +export type RequestAuditLog = InferSelectModel; diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index 8944a491..85bd7cc7 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -1,4 +1,4 @@ -import { db, loginPage, LoginPage, loginPageOrg } from "@server/db"; +import { db, loginPage, LoginPage, loginPageOrg, Org, orgs } from "@server/db"; import { Resource, ResourcePassword, @@ -23,6 +23,7 @@ export type ResourceWithAuth = { pincode: ResourcePincode | null; password: ResourcePassword | null; headerAuth: ResourceHeaderAuth | null; + org: Org; }; export type UserSessionWithUser = { @@ -51,6 +52,10 @@ export async function getResourceByDomain( resourceHeaderAuth, eq(resourceHeaderAuth.resourceId, resources.resourceId) ) + .innerJoin( + orgs, + eq(orgs.orgId, resources.orgId) + ) .where(eq(resources.fullDomain, domain)) .limit(1); @@ -62,7 +67,8 @@ export async function getResourceByDomain( resource: result.resources, pincode: result.resourcePincode, password: result.resourcePassword, - headerAuth: result.resourceHeaderAuth + headerAuth: result.resourceHeaderAuth, + org: result.orgs }; } diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 557ebfd6..89d11310 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -2,10 +2,12 @@ import { sqliteTable, integer, text, - real + real, + index } from "drizzle-orm/sqlite-core"; import { InferSelectModel } from "drizzle-orm"; import { domains, orgs, targets, users, exitNodes, sessions } from "./schema"; +import { metadata } from "@app/app/[orgId]/settings/layout"; export const certificates = sqliteTable("certificates", { certId: integer("certId").primaryKey({ autoIncrement: true }), @@ -207,6 +209,43 @@ export const sessionTransferToken = sqliteTable("sessionTransferToken", { expiresAt: integer("expiresAt").notNull() }); +export const actionAuditLog = sqliteTable("actionAuditLog", { + id: integer("id").primaryKey({ autoIncrement: true }), + timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + actorType: text("actorType").notNull(), + actor: text("actor").notNull(), + actorId: text("actorId").notNull(), + action: text("action").notNull(), + metadata: text("metadata") +}, (table) => ([ + index("idx_actionAuditLog_timestamp").on(table.timestamp), + index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) +])); + +export const accessAuditLog = sqliteTable("accessAuditLog", { + id: integer("id").primaryKey({ autoIncrement: true }), + timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + actorType: text("actorType"), + actor: text("actor"), + actorId: text("actorId"), + resourceId: integer("resourceId"), + ip: text("ip"), + location: text("location"), + type: text("type").notNull(), + action: integer("action", { mode: "boolean" }).notNull(), + userAgent: text("userAgent"), + metadata: text("metadata") +}, (table) => ([ + index("idx_identityAuditLog_timestamp").on(table.timestamp), + index("idx_identityAuditLog_org_timestamp").on(table.orgId, table.timestamp) +])); + export type Limit = InferSelectModel; export type Account = InferSelectModel; export type Certificate = InferSelectModel; @@ -224,3 +263,5 @@ export type RemoteExitNodeSession = InferSelectModel< >; export type ExitNodeOrg = InferSelectModel; export type LoginPage = InferSelectModel; +export type ActionAuditLog = InferSelectModel; +export type AccessAuditLog = InferSelectModel; \ No newline at end of file diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index cbce0048..13453d2e 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1,6 +1,7 @@ import { randomUUID } from "crypto"; import { InferSelectModel } from "drizzle-orm"; -import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"; +import { boolean } from "yargs"; export const domains = sqliteTable("domains", { domainId: text("domainId").primaryKey(), @@ -11,15 +12,41 @@ export const domains = sqliteTable("domains", { type: text("type"), // "ns", "cname", "wildcard" verified: integer("verified", { mode: "boolean" }).notNull().default(false), failed: integer("failed", { mode: "boolean" }).notNull().default(false), - tries: integer("tries").notNull().default(0) + tries: integer("tries").notNull().default(0), + certResolver: text("certResolver"), + preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }) }); +export const dnsRecords = sqliteTable("dnsRecords", { + id: integer("id").primaryKey({ autoIncrement: true }), + domainId: text("domainId") + .notNull() + .references(() => domains.domainId, { onDelete: "cascade" }), + + recordType: text("recordType").notNull(), // "NS" | "CNAME" | "A" | "TXT" + baseDomain: text("baseDomain"), + value: text("value").notNull(), + verified: integer("verified", { mode: "boolean" }).notNull().default(false), +}); + + export const orgs = sqliteTable("orgs", { orgId: text("orgId").primaryKey(), name: text("name").notNull(), subnet: text("subnet"), createdAt: text("createdAt"), - settings: text("settings") // JSON blob of org-specific settings + requireTwoFactor: integer("requireTwoFactor", { mode: "boolean" }), + maxSessionLengthHours: integer("maxSessionLengthHours"), // hours + passwordExpiryDays: integer("passwordExpiryDays"), // days + settingsLogRetentionDaysRequest: integer("settingsLogRetentionDaysRequest") // where 0 = dont keep logs and -1 = keep forever + .notNull() + .default(7), + settingsLogRetentionDaysAccess: integer("settingsLogRetentionDaysAccess") + .notNull() + .default(0), + settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") + .notNull() + .default(0) }); export const userDomains = sqliteTable("userDomains", { @@ -112,9 +139,12 @@ export const resources = sqliteTable("resources", { setHostHeader: text("setHostHeader"), enableProxy: integer("enableProxy", { mode: "boolean" }).default(true), skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { - onDelete: "cascade" + onDelete: "set null" }), - headers: text("headers") // comma-separated list of headers to add to the request + headers: text("headers"), // comma-separated list of headers to add to the request + proxyProtocol: integer("proxyProtocol", { mode: "boolean" }).notNull().default(false), + proxyProtocolVersion: integer("proxyProtocolVersion").default(1) + }); export const targets = sqliteTable("targets", { @@ -138,7 +168,7 @@ export const targets = sqliteTable("targets", { pathMatchType: text("pathMatchType"), // exact, prefix, regex rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix - priority: integer("priority").default(100) + priority: integer("priority").notNull().default(100) }); export const targetHealthCheck = sqliteTable("targetHealthCheck", { @@ -228,7 +258,8 @@ export const users = sqliteTable("user", { termsVersion: text("termsVersion"), serverAdmin: integer("serverAdmin", { mode: "boolean" }) .notNull() - .default(false) + .default(false), + lastPasswordChange: integer("lastPasswordChange") }); export const securityKeys = sqliteTable("webauthnCredentials", { @@ -333,7 +364,8 @@ export const sessions = sqliteTable("session", { userId: text("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), - expiresAt: integer("expiresAt").notNull() + expiresAt: integer("expiresAt").notNull(), + issuedAt: integer("issuedAt") }); export const newtSessions = sqliteTable("newtSession", { @@ -583,7 +615,8 @@ export const resourceSessions = sqliteTable("resourceSessions", { { onDelete: "cascade" } - ) + ), + issuedAt: integer("issuedAt") }); export const resourceWhitelist = sqliteTable("resourceWhitelist", { @@ -733,6 +766,41 @@ export const blueprints = sqliteTable("blueprints", { contents: text("contents").notNull(), message: text("message") }); +export const requestAuditLog = sqliteTable( + "requestAuditLog", + { + id: integer("id").primaryKey({ autoIncrement: true }), + timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds + orgId: text("orgId").references(() => orgs.orgId, { + onDelete: "cascade" + }), + action: integer("action", { mode: "boolean" }).notNull(), + reason: integer("reason").notNull(), + actorType: text("actorType"), + actor: text("actor"), + actorId: text("actorId"), + resourceId: integer("resourceId"), + ip: text("ip"), + location: text("location"), + userAgent: text("userAgent"), + metadata: text("metadata"), + headers: text("headers"), // JSON blob + query: text("query"), // JSON blob + originalRequestURL: text("originalRequestURL"), + scheme: text("scheme"), + host: text("host"), + path: text("path"), + method: text("method"), + tls: integer("tls", { mode: "boolean" }) + }, + (table) => [ + index("idx_requestAuditLog_timestamp").on(table.timestamp), + index("idx_requestAuditLog_org_timestamp").on( + table.orgId, + table.timestamp + ) + ] +); export type Org = InferSelectModel; export type User = InferSelectModel; @@ -770,6 +838,7 @@ export type ResourceWhitelist = InferSelectModel; export type VersionMigration = InferSelectModel; export type ResourceRule = InferSelectModel; export type Domain = InferSelectModel; +export type DnsRecord = InferSelectModel; export type Client = InferSelectModel; export type ClientSite = InferSelectModel; export type RoleClient = InferSelectModel; @@ -786,3 +855,7 @@ export type HostMeta = InferSelectModel; export type TargetHealthCheck = InferSelectModel; export type IdpOidcConfig = InferSelectModel; export type Blueprint = InferSelectModel; +export type LicenseKey = InferSelectModel; +export type SecurityKey = InferSelectModel; +export type WebauthnChallenge = InferSelectModel; +export type RequestAuditLog = InferSelectModel; diff --git a/server/emails/templates/SupportEmail.tsx b/server/emails/templates/SupportEmail.tsx new file mode 100644 index 00000000..5e03d577 --- /dev/null +++ b/server/emails/templates/SupportEmail.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailGreeting, + EmailLetterHead, + EmailText +} from "./components/Email"; + +interface SupportEmailProps { + email: string; + username: string; + subject: string; + body: string; +} + +export const SupportEmail = ({ + username, + email, + body, + subject +}: SupportEmailProps) => { + const previewText = subject; + + return ( + + + {previewText} + + + + + + Hi support, + + + You have received a new support request from{" "} + {username} ({email}). + + + + Subject: {subject} + + + + Message: {body} + + + + + + ); +}; + +export default SupportEmail; diff --git a/server/index.ts b/server/index.ts index 8b4b3728..daa4b7d3 100644 --- a/server/index.ts +++ b/server/index.ts @@ -5,6 +5,7 @@ import { runSetupFunctions } from "./setup"; import { createApiServer } from "./apiServer"; import { createNextServer } from "./nextServer"; import { createInternalServer } from "./internalServer"; +import { createIntegrationApiServer } from "./integrationApiServer"; import { ApiKey, ApiKeyOrg, @@ -13,13 +14,14 @@ import { User, UserOrg } from "@server/db"; -import { createIntegrationApiServer } from "./integrationApiServer"; import config from "@server/lib/config"; import { setHostMeta } from "@server/lib/hostMeta"; -import { initTelemetryClient } from "./lib/telemetry.js"; -import { TraefikConfigManager } from "./lib/traefik/TraefikConfigManager.js"; +import { initTelemetryClient } from "@server/lib/telemetry"; +import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager"; import { initCleanup } from "#dynamic/cleanup"; import license from "#dynamic/license/license"; +import { initLogCleanupInterval } from "@server/lib/cleanupLogs"; +import { fetchServerIp } from "@server/lib/serverIpService"; async function startServers() { await setHostMeta(); @@ -31,14 +33,17 @@ async function startServers() { await runSetupFunctions(); + await fetchServerIp(); + initTelemetryClient(); + initLogCleanupInterval(); + // Start all servers const apiServer = createApiServer(); const internalServer = createInternalServer(); - let nextServer; - nextServer = await createNextServer(); + const nextServer = await createNextServer(); if (config.getRawConfig().traefik.file_mode) { const monitor = new TraefikConfigManager(); await monitor.start(); diff --git a/server/lib/billing/getOrgTierData.ts b/server/lib/billing/getOrgTierData.ts index 24664790..75f12559 100644 --- a/server/lib/billing/getOrgTierData.ts +++ b/server/lib/billing/getOrgTierData.ts @@ -1,8 +1,8 @@ export async function getOrgTierData( orgId: string ): Promise<{ tier: string | null; active: boolean }> { - let tier = null; - let active = false; + const tier = null; + const active = false; return { tier, active }; } diff --git a/server/lib/billing/usageService.ts b/server/lib/billing/usageService.ts index 999d5b63..8e6f5e9c 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -1,5 +1,4 @@ import { eq, sql, and } from "drizzle-orm"; -import NodeCache from "node-cache"; import { v4 as uuidv4 } from "uuid"; import { PutObjectCommand } from "@aws-sdk/client-s3"; import * as fs from "fs/promises"; @@ -20,6 +19,7 @@ import logger from "@server/logger"; import { sendToClient } from "#dynamic/routers/ws"; import { build } from "@server/build"; import { s3Client } from "@server/lib/s3"; +import cache from "@server/lib/cache"; interface StripeEvent { identifier?: string; @@ -43,7 +43,6 @@ export function noop() { } export class UsageService { - private cache: NodeCache; private bucketName: string | undefined; private currentEventFile: string | null = null; private currentFileStartTime: number = 0; @@ -51,7 +50,6 @@ export class UsageService { private uploadingFiles: Set = new Set(); constructor() { - this.cache = new NodeCache({ stdTTL: 300 }); // 5 minute TTL if (noop()) { return; } @@ -399,7 +397,7 @@ export class UsageService { featureId: FeatureId ): Promise { const cacheKey = `customer_${orgId}_${featureId}`; - const cached = this.cache.get(cacheKey); + const cached = cache.get(cacheKey); if (cached) { return cached; @@ -422,7 +420,7 @@ export class UsageService { const customerId = customer.customerId; // Cache the result - this.cache.set(cacheKey, customerId); + cache.set(cacheKey, customerId, 300); // 5 minute TTL return customerId; } catch (error) { @@ -700,10 +698,6 @@ export class UsageService { await this.uploadFileToS3(); } - public clearCache(): void { - this.cache.flushAll(); - } - /** * Scan the events directory for files older than 1 minute and upload them if not empty. */ diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index a31cfb9d..37b69761 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -527,7 +527,7 @@ export async function updateProxyResources( if ( existingRule.action !== getRuleAction(rule.action) || existingRule.match !== rule.match.toUpperCase() || - existingRule.value !== rule.value + existingRule.value !== rule.value.toUpperCase() ) { validateRule(rule); await trx @@ -535,7 +535,7 @@ export async function updateProxyResources( .set({ action: getRuleAction(rule.action), match: rule.match.toUpperCase(), - value: rule.value + value: rule.value.toUpperCase(), }) .where( eq(resourceRules.ruleId, existingRule.ruleId) @@ -547,7 +547,7 @@ export async function updateProxyResources( resourceId: existingResource.resourceId, action: getRuleAction(rule.action), match: rule.match.toUpperCase(), - value: rule.value, + value: rule.value.toUpperCase(), priority: index + 1 // start priorities at 1 }); } @@ -705,7 +705,7 @@ export async function updateProxyResources( resourceId: newResource.resourceId, action: getRuleAction(rule.action), match: rule.match.toUpperCase(), - value: rule.value, + value: rule.value.toUpperCase(), priority: index + 1 // start priorities at 1 }); } diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 02f83f9d..de5c8a70 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -275,24 +275,26 @@ export const ConfigSchema = z } ) .refine( - // Enforce proxy-port uniqueness within proxy-resources + // Enforce proxy-port uniqueness within proxy-resources per protocol (config) => { - const proxyPortMap = new Map(); + const protocolPortMap = new Map(); Object.entries(config["proxy-resources"]).forEach( ([resourceKey, resource]) => { const proxyPort = resource["proxy-port"]; - if (proxyPort !== undefined) { - if (!proxyPortMap.has(proxyPort)) { - proxyPortMap.set(proxyPort, []); + const protocol = resource.protocol; + if (proxyPort !== undefined && protocol !== undefined) { + const key = `${protocol}:${proxyPort}`; + if (!protocolPortMap.has(key)) { + protocolPortMap.set(key, []); } - proxyPortMap.get(proxyPort)!.push(resourceKey); + protocolPortMap.get(key)!.push(resourceKey); } } ); // Find duplicates - const duplicates = Array.from(proxyPortMap.entries()).filter( + const duplicates = Array.from(protocolPortMap.entries()).filter( ([_, resourceKeys]) => resourceKeys.length > 1 ); @@ -300,25 +302,29 @@ export const ConfigSchema = z }, (config) => { // Extract duplicates for error message - const proxyPortMap = new Map(); + const protocolPortMap = new Map(); Object.entries(config["proxy-resources"]).forEach( ([resourceKey, resource]) => { const proxyPort = resource["proxy-port"]; - if (proxyPort !== undefined) { - if (!proxyPortMap.has(proxyPort)) { - proxyPortMap.set(proxyPort, []); + const protocol = resource.protocol; + if (proxyPort !== undefined && protocol !== undefined) { + const key = `${protocol}:${proxyPort}`; + if (!protocolPortMap.has(key)) { + protocolPortMap.set(key, []); } - proxyPortMap.get(proxyPort)!.push(resourceKey); + protocolPortMap.get(key)!.push(resourceKey); } } ); - const duplicates = Array.from(proxyPortMap.entries()) + const duplicates = Array.from(protocolPortMap.entries()) .filter(([_, resourceKeys]) => resourceKeys.length > 1) .map( - ([proxyPort, resourceKeys]) => - `port ${proxyPort} used by proxy-resources: ${resourceKeys.join(", ")}` + ([protocolPort, resourceKeys]) => { + const [protocol, port] = protocolPort.split(':'); + return `${protocol.toUpperCase()} port ${port} used by proxy-resources: ${resourceKeys.join(", ")}`; + } ) .join("; "); diff --git a/server/lib/cache.ts b/server/lib/cache.ts new file mode 100644 index 00000000..efa7d201 --- /dev/null +++ b/server/lib/cache.ts @@ -0,0 +1,5 @@ +import NodeCache from "node-cache"; + +export const cache = new NodeCache({ stdTTL: 3600, checkperiod: 120 }); + +export default cache; \ No newline at end of file diff --git a/server/lib/checkOrgAccessPolicy.ts b/server/lib/checkOrgAccessPolicy.ts new file mode 100644 index 00000000..d08d2af9 --- /dev/null +++ b/server/lib/checkOrgAccessPolicy.ts @@ -0,0 +1,41 @@ +import { Org, ResourceSession, Session, User } from "@server/db"; + +export type CheckOrgAccessPolicyProps = { + orgId?: string; + org?: Org; + userId?: string; + user?: User; + sessionId?: string; + session?: Session; +}; + +export type CheckOrgAccessPolicyResult = { + allowed: boolean; + error?: string; + policies?: { + requiredTwoFactor?: boolean; + maxSessionLength?: { + compliant: boolean; + maxSessionLengthHours: number; + sessionAgeHours: number; + }; + passwordAge?: { + compliant: boolean; + maxPasswordAgeDays: number; + passwordAgeDays: number; + }; + }; +}; + +export async function enforceResourceSessionLength( + resourceSession: ResourceSession, + org: Org +): Promise<{ valid: boolean; error?: string }> { + return { valid: true }; +} + +export async function checkOrgAccessPolicy( + props: CheckOrgAccessPolicyProps +): Promise { + return { allowed: true }; +} diff --git a/server/lib/cleanupLogs.ts b/server/lib/cleanupLogs.ts new file mode 100644 index 00000000..847e5d5d --- /dev/null +++ b/server/lib/cleanupLogs.ts @@ -0,0 +1,62 @@ +import { db, orgs } from "@server/db"; +import { cleanUpOldLogs as cleanUpOldAccessLogs } from "#dynamic/lib/logAccessAudit"; +import { cleanUpOldLogs as cleanUpOldActionLogs } from "#dynamic/middlewares/logActionAudit"; +import { cleanUpOldLogs as cleanUpOldRequestLogs } from "@server/routers/badger/logRequestAudit"; +import { gt, or } from "drizzle-orm"; + +export function initLogCleanupInterval() { + return setInterval( + async () => { + const orgsToClean = await db + .select({ + orgId: orgs.orgId, + settingsLogRetentionDaysAction: + orgs.settingsLogRetentionDaysAction, + settingsLogRetentionDaysAccess: + orgs.settingsLogRetentionDaysAccess, + settingsLogRetentionDaysRequest: + orgs.settingsLogRetentionDaysRequest + }) + .from(orgs) + .where( + or( + gt(orgs.settingsLogRetentionDaysAction, 0), + gt(orgs.settingsLogRetentionDaysAccess, 0), + gt(orgs.settingsLogRetentionDaysRequest, 0) + ) + ); + + for (const org of orgsToClean) { + const { + orgId, + settingsLogRetentionDaysAction, + settingsLogRetentionDaysAccess, + settingsLogRetentionDaysRequest + } = org; + + if (settingsLogRetentionDaysAction > 0) { + await cleanUpOldActionLogs( + orgId, + settingsLogRetentionDaysRequest + ); + } + + if (settingsLogRetentionDaysAccess > 0) { + await cleanUpOldAccessLogs( + orgId, + settingsLogRetentionDaysRequest + ); + } + + if (settingsLogRetentionDaysRequest > 0) { + await cleanUpOldRequestLogs( + orgId, + settingsLogRetentionDaysRequest + ); + } + } + }, + // 3 * 60 * 60 * 1000 + 60 * 1000 // for testing + ); // every 3 hours +} diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 8ad98167..25dfdcfe 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.11.0"; +export const APP_VERSION = "1.12.0"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/lib/geoip.ts b/server/lib/geoip.ts index ac739fa3..5bc29ef9 100644 --- a/server/lib/geoip.ts +++ b/server/lib/geoip.ts @@ -6,7 +6,7 @@ export async function getCountryCodeForIp( ): Promise { try { if (!maxmindLookup) { - logger.warn( + logger.debug( "MaxMind DB path not configured, cannot perform GeoIP lookup" ); return; diff --git a/server/lib/logAccessAudit.ts b/server/lib/logAccessAudit.ts new file mode 100644 index 00000000..82ddda67 --- /dev/null +++ b/server/lib/logAccessAudit.ts @@ -0,0 +1,17 @@ +export async function cleanUpOldLogs(orgId: string, retentionDays: number) { + return; +} + +export async function logAccessAudit(data: { + action: boolean; + type: string; + orgId: string; + resourceId?: number; + user?: { username: string; userId: string }; + apiKey?: { name: string | null; apiKeyId: string }; + metadata?: any; + userAgent?: string; + requestIp?: string; +}) { + return; +} \ No newline at end of file diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 9aee8531..d37a03d8 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -50,7 +50,7 @@ export const configSchema = z .string() .nonempty("base_domain must not be empty") .transform((url) => url.toLowerCase()), - cert_resolver: z.string().optional().default("letsencrypt"), + cert_resolver: z.string().optional(), // null falls back to traefik.cert_resolver prefer_wildcard_cert: z.boolean().optional().default(false) }) ) diff --git a/server/lib/resend.ts b/server/lib/resend.ts index 7dd130c8..0af039bb 100644 --- a/server/lib/resend.ts +++ b/server/lib/resend.ts @@ -1,7 +1,8 @@ export enum AudienceIds { - General = "", - Subscribed = "", - Churned = "" + SignUps = "", + Subscribed = "", + Churned = "", + Newsletter = "" } let resend; @@ -12,4 +13,4 @@ export async function moveEmailToAudience( audienceId: AudienceIds ) { return; -} \ No newline at end of file +} diff --git a/server/lib/serverIpService.ts b/server/lib/serverIpService.ts new file mode 100644 index 00000000..8c16fd43 --- /dev/null +++ b/server/lib/serverIpService.ts @@ -0,0 +1,29 @@ +import logger from "@server/logger"; +import axios from "axios"; + +let serverIp: string | null = null; + +const services = [ + "https://checkip.amazonaws.com", + "https://ifconfig.io/ip", + "https://api.ipify.org", +]; + +export async function fetchServerIp() { + for (const url of services) { + try { + const response = await axios.get(url, { timeout: 5000 }); + serverIp = response.data.trim(); + logger.debug("Detected public IP: " + serverIp); + return; + } catch (err: any) { + console.warn(`Failed to fetch server IP from ${url}: ${err.message || err.code}`); + } + } + + console.error("All attempts to fetch server IP failed."); +} + +export function getServerIp() { + return serverIp; +} diff --git a/server/lib/telemetry.ts b/server/lib/telemetry.ts index 56d54d7c..d3b0b9d6 100644 --- a/server/lib/telemetry.ts +++ b/server/lib/telemetry.ts @@ -200,10 +200,7 @@ class TelemetryClient { event: "supporter_status", properties: { valid: stats.supporterStatus.valid, - tier: stats.supporterStatus.tier, - github_username: stats.supporterStatus.githubUsername - ? this.anon(stats.supporterStatus.githubUsername) - : "None" + tier: stats.supporterStatus.tier } }); } @@ -217,21 +214,6 @@ class TelemetryClient { install_timestamp: hostMeta.createdAt } }); - - for (const email of stats.adminUsers) { - // There should only be on admin user, but just in case - if (email) { - this.client.capture({ - distinctId: this.anon(email), - event: "admin_user", - properties: { - host_id: hostMeta.hostMetaId, - app_version: stats.appVersion, - hashed_email: this.anon(email) - } - }); - } - } } private async collectAndSendAnalytics() { @@ -262,19 +244,38 @@ class TelemetryClient { num_clients: stats.numClients, num_identity_providers: stats.numIdentityProviders, num_sites_online: stats.numSitesOnline, - resources: stats.resources.map((r) => ({ - name: this.anon(r.name), - sso_enabled: r.sso, - protocol: r.protocol, - http_enabled: r.http - })), - sites: stats.sites.map((s) => ({ - site_name: this.anon(s.siteName), - megabytes_in: s.megabytesIn, - megabytes_out: s.megabytesOut, - type: s.type, - online: s.online - })), + num_resources_sso_enabled: stats.resources.filter( + (r) => r.sso + ).length, + num_resources_non_http: stats.resources.filter( + (r) => !r.http + ).length, + num_newt_sites: stats.sites.filter((s) => s.type === "newt") + .length, + num_local_sites: stats.sites.filter( + (s) => s.type === "local" + ).length, + num_wg_sites: stats.sites.filter( + (s) => s.type === "wireguard" + ).length, + avg_megabytes_in: + stats.sites.length > 0 + ? Math.round( + stats.sites.reduce( + (sum, s) => sum + (s.megabytesIn ?? 0), + 0 + ) / stats.sites.length + ) + : 0, + avg_megabytes_out: + stats.sites.length > 0 + ? Math.round( + stats.sites.reduce( + (sum, s) => sum + (s.megabytesOut ?? 0), + 0 + ) / stats.sites.length + ) + : 0, num_api_keys: stats.numApiKeys, num_custom_roles: stats.numCustomRoles } diff --git a/server/lib/traefik/TraefikConfigManager.ts b/server/lib/traefik/TraefikConfigManager.ts index ec4e25f4..56648559 100644 --- a/server/lib/traefik/TraefikConfigManager.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -309,10 +309,7 @@ export class TraefikConfigManager { this.lastActiveDomains = new Set(domains); } - if ( - process.env.USE_PANGOLIN_DNS === "true" && - build != "oss" - ) { + if (process.env.USE_PANGOLIN_DNS === "true" && build != "oss") { // Scan current local certificate state this.lastLocalCertificateState = await this.scanLocalCertificateState(); @@ -450,7 +447,8 @@ export class TraefikConfigManager { currentExitNode, config.getRawConfig().traefik.site_types, build == "oss", // filter out the namespace domains in open source - build != "oss" // generate the login pages on the cloud and hybrid + build != "oss", // generate the login pages on the cloud and hybrid, + build == "saas" ? false : config.getRawConfig().traefik.allow_raw_resources // dont allow raw resources on saas otherwise use config ); const domains = new Set(); @@ -502,6 +500,25 @@ export class TraefikConfigManager { }; } + // tcp: + // serversTransports: + // pp-transport-v1: + // proxyProtocol: + // version: 1 + // pp-transport-v2: + // proxyProtocol: + // version: 2 + + if (build != "saas") { + // add the serversTransports section if not present + if (traefikConfig.tcp && !traefikConfig.tcp.serversTransports) { + traefikConfig.tcp.serversTransports = { + "pp-transport-v1": { proxyProtocol: { version: 1 } }, + "pp-transport-v2": { proxyProtocol: { version: 2 } } + }; + } + } + return { domains, traefikConfig }; } catch (error) { // pull data out of the axios error to log diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 75ea907f..4352173b 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -1,4 +1,4 @@ -import { db, targetHealthCheck } from "@server/db"; +import { db, targetHealthCheck, domains } from "@server/db"; import { and, eq, @@ -23,7 +23,8 @@ export async function getTraefikConfig( exitNodeId: number, siteTypes: string[], filterOutNamespaceDomains = false, - generateLoginPageRouters = false + generateLoginPageRouters = false, + allowRawResources = true ): Promise { // Define extended target type with site information type TargetWithSite = Target & { @@ -56,6 +57,8 @@ export async function getTraefikConfig( setHostHeader: resources.setHostHeader, enableProxy: resources.enableProxy, headers: resources.headers, + proxyProtocol: resources.proxyProtocol, + proxyProtocolVersion: resources.proxyProtocolVersion, // Target fields targetId: targets.targetId, targetEnabled: targets.enabled, @@ -75,11 +78,14 @@ export async function getTraefikConfig( siteType: sites.type, siteOnline: sites.online, subnet: sites.subnet, - exitNodeId: sites.exitNodeId + exitNodeId: sites.exitNodeId, + // Domain cert resolver fields + domainCertResolver: domains.certResolver }) .from(sites) .innerJoin(targets, eq(targets.siteId, sites.siteId)) .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) + .leftJoin(domains, eq(domains.domainId, resources.domainId)) .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) @@ -88,13 +94,20 @@ export async function getTraefikConfig( and( eq(targets.enabled, true), eq(resources.enabled, true), - eq(sites.exitNodeId, exitNodeId), + or( + eq(sites.exitNodeId, exitNodeId), + and( + isNull(sites.exitNodeId), + sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`, // only allow local sites if "local" is in siteTypes + eq(sites.type, "local") + ) + ), or( ne(targetHealthCheck.hcHealth, "unhealthy"), // Exclude unhealthy targets isNull(targetHealthCheck.hcHealth) // Include targets with no health check record ), inArray(sites.type, siteTypes), - config.getRawConfig().traefik.allow_raw_resources + allowRawResources ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true : eq(resources.http, true) ) @@ -157,11 +170,15 @@ export async function getTraefikConfig( enableProxy: row.enableProxy, targets: [], headers: row.headers, + proxyProtocol: row.proxyProtocol, + proxyProtocolVersion: row.proxyProtocolVersion ?? 1, path: row.path, // the targets will all have the same path pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType rewritePath: row.rewritePath, rewritePathType: row.rewritePathType, - priority: priority // may be null, we fallback later + priority: priority, + // Store domain cert resolver fields + domainCertResolver: row.domainCertResolver }); } @@ -240,30 +257,45 @@ export async function getTraefikConfig( wildCard = resource.fullDomain; } - const configDomain = config.getDomain(resource.domainId); + const globalDefaultResolver = + config.getRawConfig().traefik.cert_resolver; + const globalDefaultPreferWildcard = + config.getRawConfig().traefik.prefer_wildcard_cert; - let certResolver: string, preferWildcardCert: boolean; - if (!configDomain) { - certResolver = config.getRawConfig().traefik.cert_resolver; - preferWildcardCert = - config.getRawConfig().traefik.prefer_wildcard_cert; - } else { - certResolver = configDomain.cert_resolver; - preferWildcardCert = configDomain.prefer_wildcard_cert; - } + const domainCertResolver = resource.domainCertResolver; + const preferWildcardCert = resource.preferWildcardCert; - const tls = { - certResolver: certResolver, - ...(preferWildcardCert - ? { - domains: [ - { - main: wildCard - } - ] - } - : {}) - }; + let resolverName: string | undefined; + let preferWildcard: boolean | undefined; + // Handle both letsencrypt & custom cases + if (domainCertResolver) { + resolverName = domainCertResolver.trim(); + } else { + resolverName = globalDefaultResolver; + } + + if ( + preferWildcardCert !== undefined && + preferWildcardCert !== null + ) { + preferWildcard = preferWildcardCert; + } else { + preferWildcard = globalDefaultPreferWildcard; + } + + const tls = { + certResolver: resolverName, + ...(preferWildcard + ? { + domains: [ + { + main: wildCard + } + ] + } + : {}) + }; + const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; @@ -502,14 +534,14 @@ export async function getTraefikConfig( })(), ...(resource.stickySession ? { - sticky: { - cookie: { - name: "p_sticky", // TODO: make this configurable via config.yml like other cookies - secure: resource.ssl, - httpOnly: true - } - } - } + sticky: { + cookie: { + name: "p_sticky", // TODO: make this configurable via config.yml like other cookies + secure: resource.ssl, + httpOnly: true + } + } + } : {}) } }; @@ -608,15 +640,20 @@ export async function getTraefikConfig( } }); })(), + ...(resource.proxyProtocol && protocol == "tcp" + ? { + serversTransport: `pp-transport-v${resource.proxyProtocolVersion || 1}` + } + : {}), ...(resource.stickySession ? { - sticky: { - ipStrategy: { - depth: 0, - sourcePort: true - } - } - } + sticky: { + ipStrategy: { + depth: 0, + sourcePort: true + } + } + } : {}) } }; diff --git a/server/license/license.ts b/server/license/license.ts index 919fdb03..cfa45d7c 100644 --- a/server/license/license.ts +++ b/server/license/license.ts @@ -40,6 +40,10 @@ export class License { public setServerSecret(secret: string) { this.serverSecret = secret; } + + public async isUnlocked() { + return false; + } } await setHostMeta(); diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index 629cafe9..66a92809 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -27,3 +27,4 @@ export * from "./verifyDomainAccess"; export * from "./verifyClientsEnabled"; export * from "./verifyUserIsOrgOwner"; export * from "./verifySiteResourceAccess"; +export * from "./logActionAudit"; \ No newline at end of file diff --git a/server/middlewares/logActionAudit.ts b/server/middlewares/logActionAudit.ts new file mode 100644 index 00000000..a735f44c --- /dev/null +++ b/server/middlewares/logActionAudit.ts @@ -0,0 +1,16 @@ +import { ActionsEnum } from "@server/auth/actions"; +import { Request, Response, NextFunction } from "express"; + +export function logActionAudit(action: ActionsEnum) { + return async function ( + req: Request, + res: Response, + next: NextFunction + ): Promise { + next(); + }; +} + +export async function cleanUpOldLogs(orgId: string, retentionDays: number) { + return; +} diff --git a/server/middlewares/verifyOrgAccess.ts b/server/middlewares/verifyOrgAccess.ts index 521f1002..441dd126 100644 --- a/server/middlewares/verifyOrgAccess.ts +++ b/server/middlewares/verifyOrgAccess.ts @@ -1,9 +1,11 @@ import { Request, Response, NextFunction } from "express"; -import { db } from "@server/db"; +import { db, orgs } from "@server/db"; import { userOrgs } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import logger from "@server/logger"; export async function verifyOrgAccess( req: Request, @@ -43,12 +45,30 @@ export async function verifyOrgAccess( "User does not have access to this organization" ) ); - } else { - // User has access, attach the user's role to the request for potential future use - req.userOrgRoleId = req.userOrg.roleId; - req.userOrgId = orgId; - return next(); } + + const policyCheck = await checkOrgAccessPolicy({ + orgId, + userId, + session: req.session + }); + + logger.debug("Org check policy result", { policyCheck }); + + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (policyCheck.error || "Unknown error") + ) + ); + } + + // User has access, attach the user's role to the request for potential future use + req.userOrgRoleId = req.userOrg.roleId; + req.userOrgId = orgId; + return next(); } catch (e) { return next( createHttpError( diff --git a/server/private/lib/certificates.ts b/server/private/lib/certificates.ts index 2ca967be..ec4b73ee 100644 --- a/server/private/lib/certificates.ts +++ b/server/private/lib/certificates.ts @@ -16,8 +16,8 @@ import { certificates, db } from "@server/db"; import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm"; import { decryptData } from "@server/lib/encryption"; import * as fs from "fs"; -import NodeCache from "node-cache"; import logger from "@server/logger"; +import cache from "@server/lib/cache"; let encryptionKeyPath = ""; let encryptionKeyHex = ""; @@ -51,9 +51,6 @@ export type CertificateResult = { updatedAt?: number | null; }; -// --- In-Memory Cache Implementation --- -const certificateCache = new NodeCache({ stdTTL: 180 }); // Cache for 3 minutes (180 seconds) - export async function getValidCertificatesForDomains( domains: Set, useCache: boolean = true @@ -67,7 +64,8 @@ export async function getValidCertificatesForDomains( // 1. Check cache first if enabled if (useCache) { for (const domain of domains) { - const cachedCert = certificateCache.get(domain); + const cacheKey = `cert:${domain}`; + const cachedCert = cache.get(cacheKey); if (cachedCert) { finalResults.push(cachedCert); // Valid cache hit } else { @@ -180,7 +178,8 @@ export async function getValidCertificatesForDomains( // Add to cache for future requests, using the *requested domain* as the key if (useCache) { - certificateCache.set(domain, resultCert); + const cacheKey = `cert:${domain}`; + cache.set(cacheKey, resultCert, 180); } } } diff --git a/server/private/lib/checkOrgAccessPolicy.ts b/server/private/lib/checkOrgAccessPolicy.ts new file mode 100644 index 00000000..2137cd72 --- /dev/null +++ b/server/private/lib/checkOrgAccessPolicy.ts @@ -0,0 +1,201 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { build } from "@server/build"; +import { + db, + Org, + orgs, + ResourceSession, + sessions, + users +} from "@server/db"; +import { getOrgTierData } from "#private/lib/billing"; +import { TierId } from "@server/lib/billing/tiers"; +import license from "#private/license/license"; +import { eq } from "drizzle-orm"; +import { + CheckOrgAccessPolicyProps, + CheckOrgAccessPolicyResult +} from "@server/lib/checkOrgAccessPolicy"; +import { UserType } from "@server/types/UserTypes"; + +export async function enforceResourceSessionLength( + resourceSession: ResourceSession, + org: Org +): Promise<{ valid: boolean; error?: string }> { + if (org.maxSessionLengthHours) { + const sessionIssuedAt = resourceSession.issuedAt; // may be null + const maxSessionLengthHours = org.maxSessionLengthHours; + + if (sessionIssuedAt) { + const maxSessionLengthMs = maxSessionLengthHours * 60 * 60 * 1000; + const sessionAgeMs = Date.now() - sessionIssuedAt; + + if (sessionAgeMs > maxSessionLengthMs) { + return { + valid: false, + error: `Resource session has expired due to organization policy (max session length: ${maxSessionLengthHours} hours)` + }; + } + } else { + return { + valid: false, + error: `Resource session is invalid due to organization policy (max session length: ${maxSessionLengthHours} hours)` + }; + } + } + + return { valid: true }; +} + +export async function checkOrgAccessPolicy( + props: CheckOrgAccessPolicyProps +): Promise { + const userId = props.userId || props.user?.userId; + const orgId = props.orgId || props.org?.orgId; + const sessionId = props.sessionId || props.session?.sessionId; + + if (!orgId) { + return { + allowed: false, + error: "Organization ID is required" + }; + } + if (!userId) { + return { allowed: false, error: "User ID is required" }; + } + if (!sessionId) { + return { allowed: false, error: "Session ID is required" }; + } + + if (build === "enterprise") { + const isUnlocked = await license.isUnlocked(); + // if not licensed, don't check the policies + if (!isUnlocked) { + return { allowed: true }; + } + } + + // get the needed data + + if (!props.org) { + const [orgQuery] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)); + props.org = orgQuery; + if (!props.org) { + return { allowed: false, error: "Organization not found" }; + } + } + + if (!props.user) { + const [userQuery] = await db + .select() + .from(users) + .where(eq(users.userId, userId)); + props.user = userQuery; + if (!props.user) { + return { allowed: false, error: "User not found" }; + } + } + + if (!props.session) { + const [sessionQuery] = await db + .select() + .from(sessions) + .where(eq(sessions.sessionId, sessionId)); + props.session = sessionQuery; + if (!props.session) { + return { allowed: false, error: "Session not found" }; + } + } + + if (props.session.userId !== props.user.userId) { + return { + allowed: false, + error: "Session does not belong to the user" + }; + } + + // now check the policies + const policies: CheckOrgAccessPolicyResult["policies"] = {}; + + // only applies to internal users; oidc users 2fa is managed by the IDP + if (props.user.type === UserType.Internal && props.org.requireTwoFactor) { + policies.requiredTwoFactor = props.user.twoFactorEnabled || false; + } + + // applies to all users + if (props.org.maxSessionLengthHours) { + const sessionIssuedAt = props.session.issuedAt; // may be null + const maxSessionLengthHours = props.org.maxSessionLengthHours; + + if (sessionIssuedAt) { + const maxSessionLengthMs = maxSessionLengthHours * 60 * 60 * 1000; + const sessionAgeMs = Date.now() - sessionIssuedAt; + policies.maxSessionLength = { + compliant: sessionAgeMs <= maxSessionLengthMs, + maxSessionLengthHours, + sessionAgeHours: sessionAgeMs / (60 * 60 * 1000) + }; + } else { + policies.maxSessionLength = { + compliant: false, + maxSessionLengthHours, + sessionAgeHours: maxSessionLengthHours + }; + } + } + + // only applies to internal users; oidc users don't have passwords + if (props.user.type === UserType.Internal && props.org.passwordExpiryDays) { + if (props.user.lastPasswordChange) { + const passwordExpiryDays = props.org.passwordExpiryDays; + const passwordAgeMs = Date.now() - props.user.lastPasswordChange; + const passwordAgeDays = passwordAgeMs / (24 * 60 * 60 * 1000); + + policies.passwordAge = { + compliant: passwordAgeDays <= passwordExpiryDays, + maxPasswordAgeDays: passwordExpiryDays, + passwordAgeDays: passwordAgeDays + }; + } else { + policies.passwordAge = { + compliant: false, + maxPasswordAgeDays: props.org.passwordExpiryDays, + passwordAgeDays: props.org.passwordExpiryDays // Treat as expired + }; + } + } + + let allowed = true; + if (policies.requiredTwoFactor === false) { + allowed = false; + } + if ( + policies.maxSessionLength && + policies.maxSessionLength.compliant === false + ) { + allowed = false; + } + if (policies.passwordAge && policies.passwordAge.compliant === false) { + allowed = false; + } + + return { + allowed, + policies + }; +} diff --git a/server/private/lib/logAccessAudit.ts b/server/private/lib/logAccessAudit.ts new file mode 100644 index 00000000..5c9cf94f --- /dev/null +++ b/server/private/lib/logAccessAudit.ts @@ -0,0 +1,170 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { accessAuditLog, db, orgs } from "@server/db"; +import { getCountryCodeForIp } from "@server/lib/geoip"; +import logger from "@server/logger"; +import { and, eq, lt } from "drizzle-orm"; +import cache from "@server/lib/cache"; + +async function getAccessDays(orgId: string): Promise { + // check cache first + const cached = cache.get(`org_${orgId}_accessDays`); + if (cached !== undefined) { + return cached; + } + + const [org] = await db + .select({ + settingsLogRetentionDaysAction: orgs.settingsLogRetentionDaysAction + }) + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + return 0; + } + + // store the result in cache + cache.set( + `org_${orgId}_accessDays`, + org.settingsLogRetentionDaysAction, + 300 + ); + + return org.settingsLogRetentionDaysAction; +} + +export async function cleanUpOldLogs(orgId: string, retentionDays: number) { + const now = Math.floor(Date.now() / 1000); + + const cutoffTimestamp = now - retentionDays * 24 * 60 * 60; + + try { + const deleteResult = await db + .delete(accessAuditLog) + .where( + and( + lt(accessAuditLog.timestamp, cutoffTimestamp), + eq(accessAuditLog.orgId, orgId) + ) + ); + + logger.info( + `Cleaned up ${deleteResult.changes} access audit logs older than ${retentionDays} days` + ); + } catch (error) { + logger.error("Error cleaning up old action audit logs:", error); + } +} + +export async function logAccessAudit(data: { + action: boolean; + type: string; + orgId: string; + resourceId?: number; + user?: { username: string; userId: string }; + apiKey?: { name: string | null; apiKeyId: string }; + metadata?: any; + userAgent?: string; + requestIp?: string; +}) { + try { + const retentionDays = await getAccessDays(data.orgId); + if (retentionDays === 0) { + // do not log + return; + } + + let actorType: string | undefined; + let actor: string | undefined; + let actorId: string | undefined; + + const user = data.user; + if (user) { + actorType = "user"; + actor = user.username; + actorId = user.userId; + } + const apiKey = data.apiKey; + if (apiKey) { + actorType = "apiKey"; + actor = apiKey.name || apiKey.apiKeyId; + actorId = apiKey.apiKeyId; + } + + // if (!actorType || !actor || !actorId) { + // logger.warn("logRequestAudit: Incomplete actor information"); + // return; + // } + + const timestamp = Math.floor(Date.now() / 1000); + + let metadata = null; + if (metadata) { + metadata = JSON.stringify(metadata); + } + + const clientIp = data.requestIp + ? (() => { + if ( + data.requestIp.startsWith("[") && + data.requestIp.includes("]") + ) { + // if brackets are found, extract the IPv6 address from between the brackets + const ipv6Match = data.requestIp.match(/\[(.*?)\]/); + if (ipv6Match) { + return ipv6Match[1]; + } + } + return data.requestIp; + })() + : undefined; + + const countryCode = data.requestIp + ? await getCountryCodeFromIp(data.requestIp) + : undefined; + + await db.insert(accessAuditLog).values({ + timestamp: timestamp, + orgId: data.orgId, + actorType, + actor, + actorId, + action: data.action, + type: data.type, + metadata, + resourceId: data.resourceId, + userAgent: data.userAgent, + ip: clientIp, + location: countryCode + }); + } catch (error) { + logger.error(error); + } +} + +async function getCountryCodeFromIp(ip: string): Promise { + const geoIpCacheKey = `geoip_access:${ip}`; + + let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey); + + if (!cachedCountryCode) { + cachedCountryCode = await getCountryCodeForIp(ip); // do it locally + // Cache for longer since IP geolocation doesn't change frequently + cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes + } + + return cachedCountryCode; +} diff --git a/server/private/lib/resend.ts b/server/private/lib/resend.ts index 1aac3d07..56198e0b 100644 --- a/server/private/lib/resend.ts +++ b/server/private/lib/resend.ts @@ -16,9 +16,10 @@ import privateConfig from "#private/lib/config"; import logger from "@server/logger"; export enum AudienceIds { - General = "5cfbf99b-c592-40a9-9b8a-577a4681c158", - Subscribed = "870b43fd-387f-44de-8fc1-707335f30b20", - Churned = "f3ae92bd-2fdb-4d77-8746-2118afd62549" + SignUps = "5cfbf99b-c592-40a9-9b8a-577a4681c158", + Subscribed = "870b43fd-387f-44de-8fc1-707335f30b20", + Churned = "f3ae92bd-2fdb-4d77-8746-2118afd62549", + Newsletter = "5500c431-191c-42f0-a5d4-8b6d445b4ea0" } const resend = new Resend( diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 5e919fda..bbbdbcfd 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -15,6 +15,7 @@ import { certificates, db, domainNamespaces, + domains, exitNodes, loginPage, targetHealthCheck @@ -40,6 +41,7 @@ import { CertificateResult, getValidCertificatesForDomains } from "#private/lib/certificates"; +import { build } from "@server/build"; const redirectHttpsMiddlewareName = "redirect-to-https"; const redirectToRootMiddlewareName = "redirect-to-root"; @@ -49,7 +51,8 @@ export async function getTraefikConfig( exitNodeId: number, siteTypes: string[], filterOutNamespaceDomains = false, - generateLoginPageRouters = false + generateLoginPageRouters = false, + allowRawResources = true ): Promise { // Define extended target type with site information type TargetWithSite = Target & { @@ -103,11 +106,16 @@ export async function getTraefikConfig( subnet: sites.subnet, exitNodeId: sites.exitNodeId, // Namespace - domainNamespaceId: domainNamespaces.domainNamespaceId + domainNamespaceId: domainNamespaces.domainNamespaceId, + // Certificate + certificateStatus: certificates.status, + domainCertResolver: domains.certResolver, }) .from(sites) .innerJoin(targets, eq(targets.siteId, sites.siteId)) .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) + .leftJoin(certificates, eq(certificates.domainId, resources.domainId)) + .leftJoin(domains, eq(domains.domainId, resources.domainId)) .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) @@ -120,13 +128,21 @@ export async function getTraefikConfig( and( eq(targets.enabled, true), eq(resources.enabled, true), - eq(sites.exitNodeId, exitNodeId), + or( + eq(sites.exitNodeId, exitNodeId), + and( + isNull(sites.exitNodeId), + sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`, // only allow local sites if "local" is in siteTypes + eq(sites.type, "local"), + sql`(${build != "saas" ? 1 : 0} = 1)` // Dont allow undefined local sites in cloud + ) + ), or( ne(targetHealthCheck.hcHealth, "unhealthy"), // Exclude unhealthy targets isNull(targetHealthCheck.hcHealth) // Include targets with no health check record ), inArray(sites.type, siteTypes), - config.getRawConfig().traefik.allow_raw_resources + allowRawResources ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true : eq(resources.http, true) ) @@ -197,7 +213,8 @@ export async function getTraefikConfig( pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType rewritePath: row.rewritePath, rewritePathType: row.rewritePathType, - priority: priority // may be null, we fallback later + priority: priority, // may be null, we fallback later + domainCertResolver: row.domainCertResolver, }); } @@ -285,6 +302,20 @@ export async function getTraefikConfig( config_output.http.services = {}; } + const domainParts = fullDomain.split("."); + let wildCard; + if (domainParts.length <= 2) { + wildCard = `*.${domainParts.join(".")}`; + } else { + wildCard = `*.${domainParts.slice(1).join(".")}`; + } + + if (!resource.subdomain) { + wildCard = resource.fullDomain; + } + + const configDomain = config.getDomain(resource.domainId); + let tls = {}; if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { const domainParts = fullDomain.split("."); @@ -315,13 +346,13 @@ export async function getTraefikConfig( certResolver: certResolver, ...(preferWildcardCert ? { - domains: [ - { - main: wildCard - } - ] - } - : {}) + domains: [ + { + main: wildCard, + }, + ], + } + : {}), }; } else { // find a cert that matches the full domain, if not continue @@ -573,14 +604,14 @@ export async function getTraefikConfig( })(), ...(resource.stickySession ? { - sticky: { - cookie: { - name: "p_sticky", // TODO: make this configurable via config.yml like other cookies - secure: resource.ssl, - httpOnly: true - } - } - } + sticky: { + cookie: { + name: "p_sticky", // TODO: make this configurable via config.yml like other cookies + secure: resource.ssl, + httpOnly: true + } + } + } : {}) } }; @@ -679,15 +710,20 @@ export async function getTraefikConfig( } }); })(), + ...(resource.proxyProtocol && protocol == "tcp" // proxy protocol only works for tcp + ? { + serversTransport: `pp-transport-v${resource.proxyProtocolVersion || 1}` + } + : {}), ...(resource.stickySession ? { - sticky: { - ipStrategy: { - depth: 0, - sourcePort: true - } - } - } + sticky: { + ipStrategy: { + depth: 0, + sourcePort: true + } + } + } : {}) } }; @@ -735,10 +771,9 @@ export async function getTraefikConfig( loadBalancer: { servers: [ { - url: `http://${ - config.getRawConfig().server + url: `http://${config.getRawConfig().server .internal_hostname - }:${config.getRawConfig().server.next_port}` + }:${config.getRawConfig().server.next_port}` } ] } @@ -754,7 +789,7 @@ export async function getTraefikConfig( continue; } - let tls = {}; + const tls = {}; if ( !privateConfig.getRawPrivateConfig().flags.use_pangolin_dns ) { diff --git a/server/private/middlewares/index.ts b/server/private/middlewares/index.ts index c92b0d3d..bb4d9c05 100644 --- a/server/private/middlewares/index.ts +++ b/server/private/middlewares/index.ts @@ -15,4 +15,5 @@ export * from "./verifyCertificateAccess"; export * from "./verifyRemoteExitNodeAccess"; export * from "./verifyIdpAccess"; export * from "./verifyLoginPageAccess"; -export * from "../../lib/corsWithLoginPage"; \ No newline at end of file +export * from "./logActionAudit"; +export * from "./verifySubscription"; \ No newline at end of file diff --git a/server/private/middlewares/logActionAudit.ts b/server/private/middlewares/logActionAudit.ts new file mode 100644 index 00000000..3dd2f084 --- /dev/null +++ b/server/private/middlewares/logActionAudit.ts @@ -0,0 +1,145 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { ActionsEnum } from "@server/auth/actions"; +import { actionAuditLog, db, orgs } from "@server/db"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import { and, eq, lt } from "drizzle-orm"; +import cache from "@server/lib/cache"; + +async function getActionDays(orgId: string): Promise { + // check cache first + const cached = cache.get(`org_${orgId}_actionDays`); + if (cached !== undefined) { + return cached; + } + + const [org] = await db + .select({ + settingsLogRetentionDaysAction: orgs.settingsLogRetentionDaysAction + }) + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + return 0; + } + + // store the result in cache + cache.set(`org_${orgId}_actionDays`, org.settingsLogRetentionDaysAction, 300); + + return org.settingsLogRetentionDaysAction; +} + +export async function cleanUpOldLogs(orgId: string, retentionDays: number) { + const now = Math.floor(Date.now() / 1000); + + const cutoffTimestamp = now - retentionDays * 24 * 60 * 60; + + try { + const deleteResult = await db + .delete(actionAuditLog) + .where( + and( + lt(actionAuditLog.timestamp, cutoffTimestamp), + eq(actionAuditLog.orgId, orgId) + ) + ); + + logger.info( + `Cleaned up ${deleteResult.changes} action audit logs older than ${retentionDays} days` + ); + } catch (error) { + logger.error("Error cleaning up old action audit logs:", error); + } +} + +export function logActionAudit(action: ActionsEnum) { + return async function ( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + let orgId; + let actorType; + let actor; + let actorId; + + const user = req.user; + if (user) { + const userOrg = req.userOrg; + orgId = userOrg?.orgId; + actorType = "user"; + actor = user.username; + actorId = user.userId; + } + const apiKey = req.apiKey; + if (apiKey) { + const apiKeyOrg = req.apiKeyOrg; + orgId = apiKeyOrg?.orgId; + actorType = "apiKey"; + actor = apiKey.name; + actorId = apiKey.apiKeyId; + } + + if (!orgId) { + logger.warn("logActionAudit: No organization context found"); + return next(); + } + + if (!actorType || !actor || !actorId) { + logger.warn("logActionAudit: Incomplete actor information"); + return next(); + } + + const retentionDays = await getActionDays(orgId); + if (retentionDays === 0) { + // do not log + return next(); + } + + const timestamp = Math.floor(Date.now() / 1000); + + let metadata = null; + if (req.params) { + metadata = JSON.stringify(req.params); + } + + await db.insert(actionAuditLog).values({ + timestamp, + orgId, + actorType, + actor, + actorId, + action, + metadata + }); + + return next(); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying logging action" + ) + ); + } + }; +} + diff --git a/server/private/middlewares/verifySubscription.ts b/server/private/middlewares/verifySubscription.ts new file mode 100644 index 00000000..5249c026 --- /dev/null +++ b/server/private/middlewares/verifySubscription.ts @@ -0,0 +1,50 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { build } from "@server/build"; +import { getOrgTierData } from "#private/lib/billing"; + +export async function verifyValidSubscription( + req: Request, + res: Response, + next: NextFunction +) { + try { + if (build != "saas") { + return next(); + } + + const tier = await getOrgTierData(req.params.orgId); + + if (!tier.active) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Organization does not have an active subscription" + ) + ); + } + + return next(); + } catch (e) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying subscription" + ) + ); + } +} diff --git a/server/private/routers/auditLogs/exportAccessAuditLog.ts b/server/private/routers/auditLogs/exportAccessAuditLog.ts new file mode 100644 index 00000000..89aef6cb --- /dev/null +++ b/server/private/routers/auditLogs/exportAccessAuditLog.ts @@ -0,0 +1,81 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { OpenAPITags } from "@server/openApi"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { queryAccessAuditLogsParams, queryAccessAuditLogsQuery, queryAccess } from "./queryAccessAuditLog"; +import { generateCSV } from "@server/routers/auditLogs/generateCSV"; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/access/export", + description: "Export the access audit log for an organization as CSV", + tags: [OpenAPITags.Org], + request: { + query: queryAccessAuditLogsQuery, + params: queryAccessAuditLogsParams + }, + responses: {} +}); + +export async function exportAccessAuditLogs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = queryAccessAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = { ...parsedQuery.data, ...parsedParams.data }; + + const baseQuery = queryAccess(data); + + const log = await baseQuery.limit(data.limit).offset(data.offset); + + const csvData = generateCSV(log); + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="access-audit-logs-${data.orgId}-${Date.now()}.csv"`); + + return res.send(csvData); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/auditLogs/exportActionAuditLog.ts b/server/private/routers/auditLogs/exportActionAuditLog.ts new file mode 100644 index 00000000..12c9ff8b --- /dev/null +++ b/server/private/routers/auditLogs/exportActionAuditLog.ts @@ -0,0 +1,81 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { OpenAPITags } from "@server/openApi"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { queryActionAuditLogsParams, queryActionAuditLogsQuery, queryAction } from "./queryActionAuditLog"; +import { generateCSV } from "@server/routers/auditLogs/generateCSV"; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/action/export", + description: "Export the action audit log for an organization as CSV", + tags: [OpenAPITags.Org], + request: { + query: queryActionAuditLogsQuery, + params: queryActionAuditLogsParams + }, + responses: {} +}); + +export async function exportActionAuditLogs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryActionAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = queryActionAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = { ...parsedQuery.data, ...parsedParams.data }; + + const baseQuery = queryAction(data); + + const log = await baseQuery.limit(data.limit).offset(data.offset); + + const csvData = generateCSV(log); + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="action-audit-logs-${data.orgId}-${Date.now()}.csv"`); + + return res.send(csvData); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/auditLogs/index.ts b/server/private/routers/auditLogs/index.ts new file mode 100644 index 00000000..ac623c4c --- /dev/null +++ b/server/private/routers/auditLogs/index.ts @@ -0,0 +1,17 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./queryActionAuditLog"; +export * from "./exportActionAuditLog"; +export * from "./queryAccessAuditLog"; +export * from "./exportAccessAuditLog"; \ No newline at end of file diff --git a/server/private/routers/auditLogs/queryAccessAuditLog.ts b/server/private/routers/auditLogs/queryAccessAuditLog.ts new file mode 100644 index 00000000..d6c9dea6 --- /dev/null +++ b/server/private/routers/auditLogs/queryAccessAuditLog.ts @@ -0,0 +1,258 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { accessAuditLog, db, resources } from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { eq, gt, lt, and, count } from "drizzle-orm"; +import { OpenAPITags } from "@server/openApi"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import { QueryAccessAuditLogResponse } from "@server/routers/auditLogs/types"; +import response from "@server/lib/response"; +import logger from "@server/logger"; + +export const queryAccessAuditLogsQuery = z.object({ + // iso string just validate its a parseable date + timeStart: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: "timeStart must be a valid ISO date string" + }) + .transform((val) => Math.floor(new Date(val).getTime() / 1000)), + timeEnd: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: "timeEnd must be a valid ISO date string" + }) + .transform((val) => Math.floor(new Date(val).getTime() / 1000)) + .optional() + .default(new Date().toISOString()), + action: z + .union([z.boolean(), z.string()]) + .transform((val) => (typeof val === "string" ? val === "true" : val)) + .optional(), + actorType: z.string().optional(), + actorId: z.string().optional(), + resourceId: z + .string() + .optional() + .transform(Number) + .pipe(z.number().int().positive()) + .optional(), + actor: z.string().optional(), + type: z.string().optional(), + location: z.string().optional(), + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) +}); + +export const queryAccessAuditLogsParams = z.object({ + orgId: z.string() +}); + +export const queryAccessAuditLogsCombined = queryAccessAuditLogsQuery.merge( + queryAccessAuditLogsParams +); +type Q = z.infer; + +function getWhere(data: Q) { + return and( + gt(accessAuditLog.timestamp, data.timeStart), + lt(accessAuditLog.timestamp, data.timeEnd), + eq(accessAuditLog.orgId, data.orgId), + data.resourceId + ? eq(accessAuditLog.resourceId, data.resourceId) + : undefined, + data.actor ? eq(accessAuditLog.actor, data.actor) : undefined, + data.actorType + ? eq(accessAuditLog.actorType, data.actorType) + : undefined, + data.actorId ? eq(accessAuditLog.actorId, data.actorId) : undefined, + data.location ? eq(accessAuditLog.location, data.location) : undefined, + data.type ? eq(accessAuditLog.type, data.type) : undefined, + data.action !== undefined + ? eq(accessAuditLog.action, data.action) + : undefined + ); +} + +export function queryAccess(data: Q) { + return db + .select({ + orgId: accessAuditLog.orgId, + action: accessAuditLog.action, + actorType: accessAuditLog.actorType, + actorId: accessAuditLog.actorId, + resourceId: accessAuditLog.resourceId, + resourceName: resources.name, + resourceNiceId: resources.niceId, + ip: accessAuditLog.ip, + location: accessAuditLog.location, + userAgent: accessAuditLog.userAgent, + metadata: accessAuditLog.metadata, + type: accessAuditLog.type, + timestamp: accessAuditLog.timestamp, + actor: accessAuditLog.actor + }) + .from(accessAuditLog) + .leftJoin( + resources, + eq(accessAuditLog.resourceId, resources.resourceId) + ) + .where(getWhere(data)) + .orderBy(accessAuditLog.timestamp); +} + +export function countAccessQuery(data: Q) { + const countQuery = db + .select({ count: count() }) + .from(accessAuditLog) + .where(getWhere(data)); + return countQuery; +} + +async function queryUniqueFilterAttributes( + timeStart: number, + timeEnd: number, + orgId: string +) { + const baseConditions = and( + gt(accessAuditLog.timestamp, timeStart), + lt(accessAuditLog.timestamp, timeEnd), + eq(accessAuditLog.orgId, orgId) + ); + + // Get unique actors + const uniqueActors = await db + .selectDistinct({ + actor: accessAuditLog.actor + }) + .from(accessAuditLog) + .where(baseConditions); + + // Get unique locations + const uniqueLocations = await db + .selectDistinct({ + locations: accessAuditLog.location + }) + .from(accessAuditLog) + .where(baseConditions); + + // Get unique resources with names + const uniqueResources = await db + .selectDistinct({ + id: accessAuditLog.resourceId, + name: resources.name + }) + .from(accessAuditLog) + .leftJoin( + resources, + eq(accessAuditLog.resourceId, resources.resourceId) + ) + .where(baseConditions); + + return { + actors: uniqueActors.map(row => row.actor).filter((actor): actor is string => actor !== null), + resources: uniqueResources.filter((row): row is { id: number; name: string | null } => row.id !== null), + locations: uniqueLocations.map(row => row.locations).filter((location): location is string => location !== null) + }; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/access", + description: "Query the access audit log for an organization", + tags: [OpenAPITags.Org], + request: { + query: queryAccessAuditLogsQuery, + params: queryAccessAuditLogsParams + }, + responses: {} +}); + +export async function queryAccessAuditLogs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const parsedParams = queryAccessAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = { ...parsedQuery.data, ...parsedParams.data }; + + const baseQuery = queryAccess(data); + + const log = await baseQuery.limit(data.limit).offset(data.offset); + + const totalCountResult = await countAccessQuery(data); + const totalCount = totalCountResult[0].count; + + const filterAttributes = await queryUniqueFilterAttributes( + data.timeStart, + data.timeEnd, + data.orgId + ); + + return response(res, { + data: { + log: log, + pagination: { + total: totalCount, + limit: data.limit, + offset: data.offset + }, + filterAttributes + }, + success: true, + error: false, + message: "Access audit logs retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/auditLogs/queryActionAuditLog.ts b/server/private/routers/auditLogs/queryActionAuditLog.ts new file mode 100644 index 00000000..f9dcbbf5 --- /dev/null +++ b/server/private/routers/auditLogs/queryActionAuditLog.ts @@ -0,0 +1,211 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { actionAuditLog, db } from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { eq, gt, lt, and, count } from "drizzle-orm"; +import { OpenAPITags } from "@server/openApi"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types"; +import response from "@server/lib/response"; +import logger from "@server/logger"; + +export const queryActionAuditLogsQuery = z.object({ + // iso string just validate its a parseable date + timeStart: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: "timeStart must be a valid ISO date string" + }) + .transform((val) => Math.floor(new Date(val).getTime() / 1000)), + timeEnd: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: "timeEnd must be a valid ISO date string" + }) + .transform((val) => Math.floor(new Date(val).getTime() / 1000)) + .optional() + .default(new Date().toISOString()), + action: z.string().optional(), + actorType: z.string().optional(), + actorId: z.string().optional(), + actor: z.string().optional(), + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) +}); + +export const queryActionAuditLogsParams = z.object({ + orgId: z.string() +}); + +export const queryActionAuditLogsCombined = + queryActionAuditLogsQuery.merge(queryActionAuditLogsParams); +type Q = z.infer; + +function getWhere(data: Q) { + return and( + gt(actionAuditLog.timestamp, data.timeStart), + lt(actionAuditLog.timestamp, data.timeEnd), + eq(actionAuditLog.orgId, data.orgId), + data.actor ? eq(actionAuditLog.actor, data.actor) : undefined, + data.actorType ? eq(actionAuditLog.actorType, data.actorType) : undefined, + data.actorId ? eq(actionAuditLog.actorId, data.actorId) : undefined, + data.action ? eq(actionAuditLog.action, data.action) : undefined + ); +} + +export function queryAction(data: Q) { + return db + .select({ + orgId: actionAuditLog.orgId, + action: actionAuditLog.action, + actorType: actionAuditLog.actorType, + metadata: actionAuditLog.metadata, + actorId: actionAuditLog.actorId, + timestamp: actionAuditLog.timestamp, + actor: actionAuditLog.actor + }) + .from(actionAuditLog) + .where(getWhere(data)) + .orderBy(actionAuditLog.timestamp); +} + +export function countActionQuery(data: Q) { + const countQuery = db + .select({ count: count() }) + .from(actionAuditLog) + .where(getWhere(data)); + return countQuery; +} + +async function queryUniqueFilterAttributes( + timeStart: number, + timeEnd: number, + orgId: string +) { + const baseConditions = and( + gt(actionAuditLog.timestamp, timeStart), + lt(actionAuditLog.timestamp, timeEnd), + eq(actionAuditLog.orgId, orgId) + ); + + // Get unique actors + const uniqueActors = await db + .selectDistinct({ + actor: actionAuditLog.actor + }) + .from(actionAuditLog) + .where(baseConditions); + + const uniqueActions = await db + .selectDistinct({ + action: actionAuditLog.action + }) + .from(actionAuditLog) + .where(baseConditions); + + return { + actors: uniqueActors.map(row => row.actor).filter((actor): actor is string => actor !== null), + actions: uniqueActions.map(row => row.action).filter((action): action is string => action !== null), + }; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/action", + description: "Query the action audit log for an organization", + tags: [OpenAPITags.Org], + request: { + query: queryActionAuditLogsQuery, + params: queryActionAuditLogsParams + }, + responses: {} +}); + +export async function queryActionAuditLogs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryActionAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const parsedParams = queryActionAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = { ...parsedQuery.data, ...parsedParams.data }; + + const baseQuery = queryAction(data); + + const log = await baseQuery.limit(data.limit).offset(data.offset); + + const totalCountResult = await countActionQuery(data); + const totalCount = totalCountResult[0].count; + + const filterAttributes = await queryUniqueFilterAttributes( + data.timeStart, + data.timeEnd, + data.orgId + ); + + return response(res, { + data: { + log: log, + pagination: { + total: totalCount, + limit: data.limit, + offset: data.offset + }, + filterAttributes + }, + success: true, + error: false, + message: "Action audit logs retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 74cd6b0c..00ad117f 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -21,20 +21,22 @@ import * as domain from "#private/routers/domain"; import * as auth from "#private/routers/auth"; import * as license from "#private/routers/license"; import * as generateLicense from "./generatedLicense"; +import * as logs from "#private/routers/auditLogs"; +import * as misc from "#private/routers/misc"; -import { Router } from "express"; import { verifyOrgAccess, verifyUserHasAction, - verifyUserIsOrgOwner, verifyUserIsServerAdmin } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import { + logActionAudit, verifyCertificateAccess, verifyIdpAccess, verifyLoginPageAccess, - verifyRemoteExitNodeAccess + verifyRemoteExitNodeAccess, + verifyValidSubscription } from "#private/middlewares"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import createHttpError from "http-errors"; @@ -72,6 +74,7 @@ authenticated.put( verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createIdp), + logActionAudit(ActionsEnum.createIdp), orgIdp.createOrgOidcIdp ); @@ -81,6 +84,7 @@ authenticated.post( verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.updateIdp), + logActionAudit(ActionsEnum.updateIdp), orgIdp.updateOrgOidcIdp ); @@ -90,6 +94,7 @@ authenticated.delete( verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.deleteIdp), + logActionAudit(ActionsEnum.deleteIdp), orgIdp.deleteOrgIdp ); @@ -127,6 +132,7 @@ authenticated.post( verifyOrgAccess, verifyCertificateAccess, verifyUserHasAction(ActionsEnum.restartCertificate), + logActionAudit(ActionsEnum.restartCertificate), certificates.restartCertificate ); @@ -152,6 +158,7 @@ if (build === "saas") { "/org/:orgId/billing/create-checkout-session", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), + logActionAudit(ActionsEnum.billing), billing.createCheckoutSession ); @@ -159,6 +166,7 @@ if (build === "saas") { "/org/:orgId/billing/create-portal-session", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), + logActionAudit(ActionsEnum.billing), billing.createPortalSession ); @@ -187,6 +195,24 @@ if (build === "saas") { verifyOrgAccess, generateLicense.generateNewLicense ); + + authenticated.post( + "/send-support-request", + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 3, + keyGenerator: (req) => + `sendSupportRequest:${req.user?.userId || ipKeyGenerator(req.ip || "")}`, + handler: (req, res, next) => { + const message = `You can only send 3 support requests every 15 minutes. Please try again later.`; + return next( + createHttpError(HttpCode.TOO_MANY_REQUESTS, message) + ); + }, + store: createStore() + }), + misc.sendSupportEmail + ); } authenticated.get( @@ -206,6 +232,7 @@ authenticated.put( verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createRemoteExitNode), + logActionAudit(ActionsEnum.createRemoteExitNode), remoteExitNode.createRemoteExitNode ); @@ -240,6 +267,7 @@ authenticated.delete( verifyOrgAccess, verifyRemoteExitNodeAccess, verifyUserHasAction(ActionsEnum.deleteRemoteExitNode), + logActionAudit(ActionsEnum.deleteRemoteExitNode), remoteExitNode.deleteRemoteExitNode ); @@ -248,6 +276,7 @@ authenticated.put( verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createLoginPage), + logActionAudit(ActionsEnum.createLoginPage), loginPage.createLoginPage ); @@ -257,6 +286,7 @@ authenticated.post( verifyOrgAccess, verifyLoginPageAccess, verifyUserHasAction(ActionsEnum.updateLoginPage), + logActionAudit(ActionsEnum.updateLoginPage), loginPage.updateLoginPage ); @@ -266,6 +296,7 @@ authenticated.delete( verifyOrgAccess, verifyLoginPageAccess, verifyUserHasAction(ActionsEnum.deleteLoginPage), + logActionAudit(ActionsEnum.deleteLoginPage), loginPage.deleteLoginPage ); @@ -334,3 +365,41 @@ authenticated.post( verifyUserIsServerAdmin, license.recheckStatus ); + +authenticated.get( + "/org/:orgId/logs/action", + verifyValidLicense, + verifyValidSubscription, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.exportLogs), + logs.queryActionAuditLogs +); + +authenticated.get( + "/org/:orgId/logs/action/export", + verifyValidLicense, + verifyValidSubscription, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.exportLogs), + logActionAudit(ActionsEnum.exportLogs), + logs.exportActionAuditLogs +); + +authenticated.get( + "/org/:orgId/logs/access", + verifyValidLicense, + verifyValidSubscription, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.exportLogs), + logs.queryAccessAuditLogs +); + +authenticated.get( + "/org/:orgId/logs/access/export", + verifyValidLicense, + verifyValidSubscription, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.exportLogs), + logActionAudit(ActionsEnum.exportLogs), + logs.exportAccessAuditLogs +); diff --git a/server/private/routers/generatedLicense/generateNewLicense.ts b/server/private/routers/generatedLicense/generateNewLicense.ts index e54d9741..56d65a50 100644 --- a/server/private/routers/generatedLicense/generateNewLicense.ts +++ b/server/private/routers/generatedLicense/generateNewLicense.ts @@ -16,7 +16,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { response as sendResponse } from "@server/lib/response"; -import privateConfig from "@server/private/lib/config"; +import privateConfig from "#private/lib/config"; import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types"; async function createNewLicense(orgId: string, licenseData: any): Promise { diff --git a/server/private/routers/generatedLicense/listGeneratedLicenses.ts b/server/private/routers/generatedLicense/listGeneratedLicenses.ts index 97a60a2a..f8da1b5a 100644 --- a/server/private/routers/generatedLicense/listGeneratedLicenses.ts +++ b/server/private/routers/generatedLicense/listGeneratedLicenses.ts @@ -16,7 +16,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { response as sendResponse } from "@server/lib/response"; -import privateConfig from "@server/private/lib/config"; +import privateConfig from "#private/lib/config"; import { GeneratedLicenseKey, ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types"; async function fetchLicenseKeys(orgId: string): Promise { diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index df99df92..2b54ae9d 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -35,7 +35,9 @@ import { loginPageOrg, LoginPage, resourceHeaderAuth, - ResourceHeaderAuth + ResourceHeaderAuth, + orgs, + requestAuditLog } from "@server/db"; import { resources, @@ -73,6 +75,7 @@ import { validateResourceSessionToken } from "@server/auth/sessions/resource"; import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes"; import { maxmindLookup } from "@server/db/maxmind"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; +import semver from "semver"; // Zod schemas for request validation const getResourceByDomainParamsSchema = z @@ -270,7 +273,8 @@ hybridRouter.get( remoteExitNode.exitNodeId, ["newt", "local", "wireguard"], // Allow them to use all the site types true, // But don't allow domain namespace resources - false // Dont include login pages + false, // Dont include login pages, + true // allow raw resources ); return response(res, { @@ -300,7 +304,8 @@ function loadEncryptData() { return; // already loaded } - encryptionKeyPath = privateConfig.getRawPrivateConfig().server.encryption_key_path; + encryptionKeyPath = + privateConfig.getRawPrivateConfig().server.encryption_key_path; if (!fs.existsSync(encryptionKeyPath)) { throw new Error( @@ -1066,11 +1071,20 @@ hybridRouter.get( ); } - const rules = await db + let rules = await db .select() .from(resourceRules) .where(eq(resourceRules.resourceId, resourceId)); + // backward compatibility: COUNTRY -> GEOIP + if ((remoteExitNode.version && semver.lt(remoteExitNode.version, "1.1.0")) || !remoteExitNode.version) { + for (const rule of rules) { + if (rule.match == "COUNTRY") { + rule.match = "GEOIP"; + } + } + } + return response<(typeof resourceRules.$inferSelect)[]>(res, { data: rules, success: true, @@ -1582,3 +1596,193 @@ hybridRouter.post( } } ); + +hybridRouter.get( + "/org/:orgId/get-retention-days", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getOrgLoginPageParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) { + // If the exit node is not allowed for the org, return an error + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Exit node not allowed for this organization" + ) + ); + } + + const [org] = await db + .select({ + settingsLogRetentionDaysRequest: + orgs.settingsLogRetentionDaysRequest + }) + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + return response(res, { + data: { + settingsLogRetentionDaysRequest: + org.settingsLogRetentionDaysRequest + }, + success: true, + error: false, + message: "Log retention days retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred..." + ) + ); + } + } +); + +const batchLogsSchema = z.object({ + logs: z.array( + z.object({ + timestamp: z.number(), + orgId: z.string().optional(), + actorType: z.string().optional(), + actor: z.string().optional(), + actorId: z.string().optional(), + metadata: z.string().nullable(), + action: z.boolean(), + resourceId: z.number().optional(), + reason: z.number(), + location: z.string().optional(), + originalRequestURL: z.string(), + scheme: z.string(), + host: z.string(), + path: z.string(), + method: z.string(), + ip: z.string().optional(), + tls: z.boolean() + }) + ) +}); + +hybridRouter.post( + "/org/:orgId/logs/batch", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getOrgLoginPageParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const parsedBody = batchLogsSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { logs } = parsedBody.data; + + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) { + // If the exit node is not allowed for the org, return an error + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Exit node not allowed for this organization" + ) + ); + } + + // Batch insert all logs in a single query + const logEntries = logs.map((logEntry) => ({ + timestamp: logEntry.timestamp, + orgId: logEntry.orgId, + actorType: logEntry.actorType, + actor: logEntry.actor, + actorId: logEntry.actorId, + metadata: logEntry.metadata, + action: logEntry.action, + resourceId: logEntry.resourceId, + reason: logEntry.reason, + location: logEntry.location, + // userAgent: data.userAgent, // TODO: add this + // headers: data.body.headers, + // query: data.body.query, + originalRequestURL: logEntry.originalRequestURL, + scheme: logEntry.scheme, + host: logEntry.host, + path: logEntry.path, + method: logEntry.method, + ip: logEntry.ip, + tls: logEntry.tls + })); + + await db.insert(requestAuditLog).values(logEntries); + + return response(res, { + data: null, + success: true, + error: false, + message: "Logs saved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred..." + ) + ); + } + } +); diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index d767424a..21c74624 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -23,6 +23,7 @@ import { import { ActionsEnum } from "@server/auth/actions"; import { unauthenticated as ua, authenticated as a } from "@server/routers/integration"; +import { logActionAudit } from "#private/middlewares"; export const unauthenticated = ua; export const authenticated = a; @@ -31,12 +32,14 @@ authenticated.post( `/org/:orgId/send-usage-notification`, verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine verifyApiKeyHasAction(ActionsEnum.sendUsageNotification), - org.sendUsageNotification + logActionAudit(ActionsEnum.sendUsageNotification), + org.sendUsageNotification, ); authenticated.delete( "/idp/:idpId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteIdp), - orgIdp.deleteOrgIdp + logActionAudit(ActionsEnum.deleteIdp), + orgIdp.deleteOrgIdp, ); \ No newline at end of file diff --git a/server/private/routers/loginPage/loadLoginPage.ts b/server/private/routers/loginPage/loadLoginPage.ts index 133336b6..06038201 100644 --- a/server/private/routers/loginPage/loadLoginPage.ts +++ b/server/private/routers/loginPage/loadLoginPage.ts @@ -25,7 +25,7 @@ import { LoadLoginPageResponse } from "@server/routers/loginPage/types"; const querySchema = z.object({ resourceId: z.coerce.number().int().positive().optional(), idpId: z.coerce.number().int().positive().optional(), - orgId: z.coerce.number().int().positive().optional(), + orgId: z.string().min(1).optional(), fullDomain: z.string().min(1) }); @@ -89,7 +89,7 @@ export async function loadLoginPage( const { resourceId, idpId, fullDomain } = parsedQuery.data; - let orgId; + let orgId: string | undefined = undefined; if (resourceId) { const [resource] = await db .select() @@ -118,7 +118,7 @@ export async function loadLoginPage( orgId = idpOrgLink.orgId; } else if (parsedQuery.data.orgId) { - orgId = parsedQuery.data.orgId.toString(); + orgId = parsedQuery.data.orgId; } const loginPage = await query(orgId, fullDomain); diff --git a/server/private/routers/misc/index.ts b/server/private/routers/misc/index.ts new file mode 100644 index 00000000..d8d5f4d3 --- /dev/null +++ b/server/private/routers/misc/index.ts @@ -0,0 +1 @@ +export * from "./sendSupportEmail"; diff --git a/server/private/routers/misc/sendSupportEmail.ts b/server/private/routers/misc/sendSupportEmail.ts new file mode 100644 index 00000000..fef43ef8 --- /dev/null +++ b/server/private/routers/misc/sendSupportEmail.ts @@ -0,0 +1,94 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { response as sendResponse } from "@server/lib/response"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { sendEmail } from "@server/emails"; +import SupportEmail from "@server/emails/templates/SupportEmail"; +import config from "@server/lib/config"; + +const bodySchema = z + .object({ + body: z.string().min(1), + subject: z.string().min(1).max(255) + }) + .strict(); + +export async function sendSupportEmail( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { body, subject } = parsedBody.data; + const user = req.user!; + + if (!user?.email) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User does not have an email associated with their account" + ) + ); + } + + try { + await sendEmail( + SupportEmail({ + username: user.username, + email: user.email, + subject, + body + }), + { + name: req.user?.email || "Support User", + to: "support@pangolin.net", + from: req.user?.email || config.getNoReplyEmail(), + subject: `Support Request: ${subject}` + } + ); + return sendResponse(res, { + data: {}, + success: true, + error: false, + message: "Sent support email successfully", + status: HttpCode.OK + }); + } catch (e) { + logger.error(e); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `${e}`) + ); + } + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/accessToken/listAccessTokens.ts b/server/routers/accessToken/listAccessTokens.ts index 7e9ca087..ab2bf826 100644 --- a/server/routers/accessToken/listAccessTokens.ts +++ b/server/routers/accessToken/listAccessTokens.ts @@ -63,6 +63,7 @@ function queryAccessTokens( description: resourceAccessToken.description, createdAt: resourceAccessToken.createdAt, resourceName: resources.name, + resourceNiceId: resources.niceId, siteName: sites.name }; diff --git a/server/routers/auditLogs/exportRequstAuditLog.ts b/server/routers/auditLogs/exportRequstAuditLog.ts new file mode 100644 index 00000000..89df2d3f --- /dev/null +++ b/server/routers/auditLogs/exportRequstAuditLog.ts @@ -0,0 +1,68 @@ +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { OpenAPITags } from "@server/openApi"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { queryAccessAuditLogsQuery, queryRequestAuditLogsParams, queryRequest } from "./queryRequstAuditLog"; +import { generateCSV } from "./generateCSV"; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/request", + description: "Query the request audit log for an organization", + tags: [OpenAPITags.Org], + request: { + query: queryAccessAuditLogsQuery, + params: queryRequestAuditLogsParams + }, + responses: {} +}); + +export async function exportRequestAuditLogs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = queryRequestAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = { ...parsedQuery.data, ...parsedParams.data }; + + const baseQuery = queryRequest(data); + + const log = await baseQuery.limit(data.limit).offset(data.offset); + + const csvData = generateCSV(log); + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="request-audit-logs-${data.orgId}-${Date.now()}.csv"`); + + return res.send(csvData); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/auditLogs/generateCSV.ts b/server/routers/auditLogs/generateCSV.ts new file mode 100644 index 00000000..8a067069 --- /dev/null +++ b/server/routers/auditLogs/generateCSV.ts @@ -0,0 +1,16 @@ +export function generateCSV(data: any[]): string { + if (data.length === 0) { + return "orgId,action,actorType,timestamp,actor\n"; + } + + const headers = Object.keys(data[0]).join(","); + const rows = data.map(row => + Object.values(row).map(value => + typeof value === 'string' && value.includes(',') + ? `"${value.replace(/"/g, '""')}"` + : value + ).join(",") + ); + + return [headers, ...rows].join("\n"); +} \ No newline at end of file diff --git a/server/routers/auditLogs/index.ts b/server/routers/auditLogs/index.ts new file mode 100644 index 00000000..4823831d --- /dev/null +++ b/server/routers/auditLogs/index.ts @@ -0,0 +1,2 @@ +export * from "./queryRequstAuditLog"; +export * from "./exportRequstAuditLog"; \ No newline at end of file diff --git a/server/routers/auditLogs/queryRequstAuditLog.ts b/server/routers/auditLogs/queryRequstAuditLog.ts new file mode 100644 index 00000000..d41b14b6 --- /dev/null +++ b/server/routers/auditLogs/queryRequstAuditLog.ts @@ -0,0 +1,276 @@ +import { db, requestAuditLog, resources } from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { eq, gt, lt, and, count } from "drizzle-orm"; +import { OpenAPITags } from "@server/openApi"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; +import response from "@server/lib/response"; +import logger from "@server/logger"; + +export const queryAccessAuditLogsQuery = z.object({ + // iso string just validate its a parseable date + timeStart: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: "timeStart must be a valid ISO date string" + }) + .transform((val) => Math.floor(new Date(val).getTime() / 1000)), + timeEnd: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: "timeEnd must be a valid ISO date string" + }) + .transform((val) => Math.floor(new Date(val).getTime() / 1000)) + .optional() + .default(new Date().toISOString()), + action: z + .union([z.boolean(), z.string()]) + .transform((val) => (typeof val === "string" ? val === "true" : val)) + .optional(), + method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).optional(), + reason: z + .string() + .optional() + .transform(Number) + .pipe(z.number().int().positive()) + .optional(), + resourceId: z + .string() + .optional() + .transform(Number) + .pipe(z.number().int().positive()) + .optional(), + actor: z.string().optional(), + location: z.string().optional(), + host: z.string().optional(), + path: z.string().optional(), + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) +}); + +export const queryRequestAuditLogsParams = z.object({ + orgId: z.string() +}); + +export const queryRequestAuditLogsCombined = + queryAccessAuditLogsQuery.merge(queryRequestAuditLogsParams); +type Q = z.infer; + +function getWhere(data: Q) { + return and( + gt(requestAuditLog.timestamp, data.timeStart), + lt(requestAuditLog.timestamp, data.timeEnd), + eq(requestAuditLog.orgId, data.orgId), + data.resourceId + ? eq(requestAuditLog.resourceId, data.resourceId) + : undefined, + data.actor ? eq(requestAuditLog.actor, data.actor) : undefined, + data.method ? eq(requestAuditLog.method, data.method) : undefined, + data.reason ? eq(requestAuditLog.reason, data.reason) : undefined, + data.host ? eq(requestAuditLog.host, data.host) : undefined, + data.location ? eq(requestAuditLog.location, data.location) : undefined, + data.path ? eq(requestAuditLog.path, data.path) : undefined, + data.action !== undefined + ? eq(requestAuditLog.action, data.action) + : undefined + ); +} + +export function queryRequest(data: Q) { + return db + .select({ + timestamp: requestAuditLog.timestamp, + orgId: requestAuditLog.orgId, + action: requestAuditLog.action, + reason: requestAuditLog.reason, + actorType: requestAuditLog.actorType, + actor: requestAuditLog.actor, + actorId: requestAuditLog.actorId, + resourceId: requestAuditLog.resourceId, + ip: requestAuditLog.ip, + location: requestAuditLog.location, + userAgent: requestAuditLog.userAgent, + metadata: requestAuditLog.metadata, + headers: requestAuditLog.headers, + query: requestAuditLog.query, + originalRequestURL: requestAuditLog.originalRequestURL, + scheme: requestAuditLog.scheme, + host: requestAuditLog.host, + path: requestAuditLog.path, + method: requestAuditLog.method, + tls: requestAuditLog.tls, + resourceName: resources.name, + resourceNiceId: resources.niceId + }) + .from(requestAuditLog) + .leftJoin( + resources, + eq(requestAuditLog.resourceId, resources.resourceId) + ) // TODO: Is this efficient? + .where(getWhere(data)) + .orderBy(requestAuditLog.timestamp); +} + +export function countRequestQuery(data: Q) { + const countQuery = db + .select({ count: count() }) + .from(requestAuditLog) + .where(getWhere(data)); + return countQuery; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/request", + description: "Query the request audit log for an organization", + tags: [OpenAPITags.Org], + request: { + query: queryAccessAuditLogsQuery, + params: queryRequestAuditLogsParams + }, + responses: {} +}); + +async function queryUniqueFilterAttributes( + timeStart: number, + timeEnd: number, + orgId: string +) { + const baseConditions = and( + gt(requestAuditLog.timestamp, timeStart), + lt(requestAuditLog.timestamp, timeEnd), + eq(requestAuditLog.orgId, orgId) + ); + + // Get unique actors + const uniqueActors = await db + .selectDistinct({ + actor: requestAuditLog.actor + }) + .from(requestAuditLog) + .where(baseConditions); + + // Get unique locations + const uniqueLocations = await db + .selectDistinct({ + locations: requestAuditLog.location + }) + .from(requestAuditLog) + .where(baseConditions); + + // Get unique actors + const uniqueHosts = await db + .selectDistinct({ + hosts: requestAuditLog.host + }) + .from(requestAuditLog) + .where(baseConditions); + + // Get unique actors + const uniquePaths = await db + .selectDistinct({ + paths: requestAuditLog.path + }) + .from(requestAuditLog) + .where(baseConditions); + + // Get unique resources with names + const uniqueResources = await db + .selectDistinct({ + id: requestAuditLog.resourceId, + name: resources.name + }) + .from(requestAuditLog) + .leftJoin( + resources, + eq(requestAuditLog.resourceId, resources.resourceId) + ) + .where(baseConditions); + + return { + actors: uniqueActors.map(row => row.actor).filter((actor): actor is string => actor !== null), + resources: uniqueResources.filter((row): row is { id: number; name: string | null } => row.id !== null), + locations: uniqueLocations.map(row => row.locations).filter((location): location is string => location !== null), + hosts: uniqueHosts.map(row => row.hosts).filter((host): host is string => host !== null), + paths: uniquePaths.map(row => row.paths).filter((path): path is string => path !== null) + }; +} + +export async function queryRequestAuditLogs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = queryRequestAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = { ...parsedQuery.data, ...parsedParams.data }; + + const baseQuery = queryRequest(data); + + const log = await baseQuery.limit(data.limit).offset(data.offset); + + const totalCountResult = await countRequestQuery(data); + const totalCount = totalCountResult[0].count; + + const filterAttributes = await queryUniqueFilterAttributes( + data.timeStart, + data.timeEnd, + data.orgId + ); + + return response(res, { + data: { + log: log, + pagination: { + total: totalCount, + limit: data.limit, + offset: data.offset + }, + filterAttributes + }, + success: true, + error: false, + message: "Action audit logs retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts new file mode 100644 index 00000000..81cef733 --- /dev/null +++ b/server/routers/auditLogs/types.ts @@ -0,0 +1,93 @@ +export type QueryActionAuditLogResponse = { + log: { + orgId: string; + action: string; + actorType: string; + actorId: string; + metadata: string | null; + timestamp: number; + actor: string; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; + filterAttributes: { + actors: string[]; + }; +}; + +export type QueryRequestAuditLogResponse = { + log: { + timestamp: number; + action: boolean; + reason: number; + orgId: string | null; + actorType: string | null; + actor: string | null; + actorId: string | null; + resourceId: number | null; + resourceNiceId: string | null; + resourceName: string | null; + ip: string | null; + location: string | null; + userAgent: string | null; + metadata: string | null; + headers: string | null; + query: string | null; + originalRequestURL: string | null; + scheme: string | null; + host: string | null; + path: string | null; + method: string | null; + tls: boolean | null; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; + filterAttributes: { + actors: string[]; + resources: { + id: number; + name: string | null; + }[]; + locations: string[]; + hosts: string[]; + paths: string[]; + }; +}; + +export type QueryAccessAuditLogResponse = { + log: { + orgId: string; + action: boolean; + actorType: string | null; + actorId: string | null; + resourceId: number | null; + resourceName: string | null; + resourceNiceId: string | null; + ip: string | null; + location: string | null; + userAgent: string | null; + metadata: string | null; + type: string; + timestamp: number; + actor: string | null; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; + filterAttributes: { + actors: string[]; + resources: { + id: number; + name: string | null; + }[]; + locations: string[]; + }; +}; \ No newline at end of file diff --git a/server/routers/auth/changePassword.ts b/server/routers/auth/changePassword.ts index 64efb696..0164316e 100644 --- a/server/routers/auth/changePassword.ts +++ b/server/routers/auth/changePassword.ts @@ -5,7 +5,6 @@ import { fromError } from "zod-validation-error"; import { z } from "zod"; import { db } from "@server/db"; import { User, users } from "@server/db"; -import { eq } from "drizzle-orm"; import { response } from "@server/lib/response"; import { hashPassword, @@ -15,8 +14,13 @@ import { verifyTotpCode } from "@server/auth/totp"; import logger from "@server/logger"; import { unauthorized } from "@server/auth/unauthorizedResponse"; import { invalidateAllSessions } from "@server/auth/sessions/app"; +import { sessions, resourceSessions } from "@server/db"; +import { and, eq, ne, inArray } from "drizzle-orm"; import { passwordSchema } from "@server/auth/passwordSchema"; import { UserType } from "@server/types/UserTypes"; +import { sendEmail } from "@server/emails"; +import ConfirmPasswordReset from "@server/emails/templates/NotifyResetPassword"; +import config from "@server/lib/config"; export const changePasswordBody = z .object({ @@ -32,6 +36,46 @@ export type ChangePasswordResponse = { codeRequested?: boolean; }; +async function invalidateAllSessionsExceptCurrent( + userId: string, + currentSessionId: string +): Promise { + try { + await db.transaction(async (trx) => { + // Get all user sessions except the current one + const userSessions = await trx + .select() + .from(sessions) + .where( + and( + eq(sessions.userId, userId), + ne(sessions.sessionId, currentSessionId) + ) + ); + + // Delete resource sessions for the sessions we're invalidating + if (userSessions.length > 0) { + await trx.delete(resourceSessions).where( + inArray( + resourceSessions.userSessionId, + userSessions.map((s) => s.sessionId) + ) + ); + } + + // Delete the user sessions (except current) + await trx.delete(sessions).where( + and( + eq(sessions.userId, userId), + ne(sessions.sessionId, currentSessionId) + ) + ); + }); + } catch (e) { + logger.error("Failed to invalidate user sessions except current", e); + } +} + export async function changePassword( req: Request, res: Response, @@ -109,13 +153,24 @@ export async function changePassword( await db .update(users) .set({ - passwordHash: hash + passwordHash: hash, + lastPasswordChange: new Date().getTime() }) .where(eq(users.userId, user.userId)); - await invalidateAllSessions(user.userId); + // Invalidate all sessions except the current one + await invalidateAllSessionsExceptCurrent(user.userId, req.session.sessionId); - // TODO: send email to user confirming password change + try { + const email = user.email!; + await sendEmail(ConfirmPasswordReset({ email }), { + from: config.getNoReplyEmail(), + to: email, + subject: "Password Reset Confirmation" + }); + } catch (e) { + logger.error("Failed to send password reset confirmation email", e); + } return response(res, { data: null, diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index 8dad5a42..418eaaa4 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -3,7 +3,7 @@ import { generateSessionToken, serializeSessionCookie } from "@server/auth/sessions/app"; -import { db } from "@server/db"; +import { db, resources } from "@server/db"; import { users, securityKeys } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; @@ -18,12 +18,14 @@ import logger from "@server/logger"; import { verifyPassword } from "@server/auth/password"; import { verifySession } from "@server/auth/sessions/verifySession"; import { UserType } from "@server/types/UserTypes"; +import { logAccessAudit } from "#dynamic/lib/logAccessAudit"; export const loginBodySchema = z .object({ email: z.string().toLowerCase().email(), password: z.string(), - code: z.string().optional() + code: z.string().optional(), + resourceGuid: z.string().optional() }) .strict(); @@ -52,7 +54,7 @@ export async function login( ); } - const { email, password, code } = parsedBody.data; + const { email, password, code, resourceGuid } = parsedBody.data; try { const { session: existingSession } = await verifySession(req); @@ -66,6 +68,28 @@ export async function login( }); } + let resourceId: number | null = null; + let orgId: string | null = null; + if (resourceGuid) { + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceGuid, resourceGuid)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with GUID ${resourceGuid} not found` + ) + ); + } + + resourceId = resource.resourceId; + orgId = resource.orgId; + } + const existingUserRes = await db .select() .from(users) @@ -78,6 +102,18 @@ export async function login( `Username or password incorrect. Email: ${email}. IP: ${req.ip}.` ); } + + if (resourceId && orgId) { + logAccessAudit({ + orgId: orgId, + resourceId: resourceId, + action: false, + type: "login", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + } + return next( createHttpError( HttpCode.UNAUTHORIZED, @@ -98,6 +134,18 @@ export async function login( `Username or password incorrect. Email: ${email}. IP: ${req.ip}.` ); } + + if (resourceId && orgId) { + logAccessAudit({ + orgId: orgId, + resourceId: resourceId, + action: false, + type: "login", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + } + return next( createHttpError( HttpCode.UNAUTHORIZED, @@ -158,6 +206,18 @@ export async function login( `Two-factor code incorrect. Email: ${email}. IP: ${req.ip}.` ); } + + if (resourceId && orgId) { + logAccessAudit({ + orgId: orgId, + resourceId: resourceId, + action: false, + type: "login", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + } + return next( createHttpError( HttpCode.UNAUTHORIZED, diff --git a/server/routers/auth/resetPassword.ts b/server/routers/auth/resetPassword.ts index 05293727..14b4236b 100644 --- a/server/routers/auth/resetPassword.ts +++ b/server/routers/auth/resetPassword.ts @@ -19,10 +19,7 @@ import { passwordSchema } from "@server/auth/passwordSchema"; export const resetPasswordBody = z .object({ - email: z - .string() - .toLowerCase() - .email(), + email: z.string().toLowerCase().email(), token: z.string(), // reset secret code newPassword: passwordSchema, code: z.string().optional() // 2fa code @@ -152,7 +149,7 @@ export async function resetPassword( await db.transaction(async (trx) => { await trx .update(users) - .set({ passwordHash }) + .set({ passwordHash, lastPasswordChange: new Date().getTime() }) .where(eq(users.userId, resetRequest[0].userId)); await trx diff --git a/server/routers/auth/setServerAdmin.ts b/server/routers/auth/setServerAdmin.ts index 716feca4..307f5504 100644 --- a/server/routers/auth/setServerAdmin.ts +++ b/server/routers/auth/setServerAdmin.ts @@ -98,7 +98,8 @@ export async function setServerAdmin( passwordHash, dateCreated: moment().toISOString(), serverAdmin: true, - emailVerified: true + emailVerified: true, + lastPasswordChange: new Date().getTime() }); }); diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 1f361b79..e836d109 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -23,10 +23,7 @@ import { passwordSchema } from "@server/auth/passwordSchema"; import { UserType } from "@server/types/UserTypes"; import { createUserAccountOrg } from "@server/lib/createUserAccountOrg"; import { build } from "@server/build"; -import resend, { - AudienceIds, - moveEmailToAudience -} from "#dynamic/lib/resend"; +import resend, { AudienceIds, moveEmailToAudience } from "#dynamic/lib/resend"; export const signupBodySchema = z.object({ email: z.string().toLowerCase().email(), @@ -183,7 +180,8 @@ export async function signup( passwordHash, dateCreated: moment().toISOString(), termsAcceptedTimestamp: termsAcceptedTimestamp || null, - termsVersion: "1" + termsVersion: "1", + lastPasswordChange: new Date().getTime() }); // give the user their default permissions: @@ -224,7 +222,7 @@ export async function signup( res.appendHeader("Set-Cookie", cookie); if (build == "saas") { - moveEmailToAudience(email, AudienceIds.General); + moveEmailToAudience(email, AudienceIds.SignUps); } if (config.getRawConfig().flags?.require_email_verification) { diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts new file mode 100644 index 00000000..1a2bba02 --- /dev/null +++ b/server/routers/badger/logRequestAudit.ts @@ -0,0 +1,191 @@ +import { db, orgs, requestAuditLog } from "@server/db"; +import logger from "@server/logger"; +import { and, eq, lt } from "drizzle-orm"; +import cache from "@server/lib/cache"; + +/** + +Reasons: +100 - Allowed by Rule +101 - Allowed No Auth +102 - Valid Access Token +103 - Valid header auth +104 - Valid Pincode +105 - Valid Password +106 - Valid email +107 - Valid SSO + +201 - Resource Not Found +202 - Resource Blocked +203 - Dropped by Rule +204 - No Sessions +205 - Temporary Request Token +299 - No More Auth Methods + + */ + +async function getRetentionDays(orgId: string): Promise { + // check cache first + const cached = cache.get(`org_${orgId}_retentionDays`); + if (cached !== undefined) { + return cached; + } + + const [org] = await db + .select({ + settingsLogRetentionDaysRequest: + orgs.settingsLogRetentionDaysRequest + }) + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + return 0; + } + + // store the result in cache + cache.set( + `org_${orgId}_retentionDays`, + org.settingsLogRetentionDaysRequest, + 300 + ); + + return org.settingsLogRetentionDaysRequest; +} + +export async function cleanUpOldLogs(orgId: string, retentionDays: number) { + const now = Math.floor(Date.now() / 1000); + + const cutoffTimestamp = now - retentionDays * 24 * 60 * 60; + + try { + const deleteResult = await db + .delete(requestAuditLog) + .where( + and( + lt(requestAuditLog.timestamp, cutoffTimestamp), + eq(requestAuditLog.orgId, orgId) + ) + ); + + logger.info( + `Cleaned up ${deleteResult.changes} request audit logs older than ${retentionDays} days` + ); + } catch (error) { + logger.error("Error cleaning up old request audit logs:", error); + } +} + +export async function logRequestAudit( + data: { + action: boolean; + reason: number; + resourceId?: number; + orgId?: string; + location?: string; + user?: { username: string; userId: string }; + apiKey?: { name: string | null; apiKeyId: string }; + metadata?: any; + // userAgent?: string; + }, + body: { + path: string; + originalRequestURL: string; + scheme: string; + host: string; + method: string; + tls: boolean; + sessions?: Record; + headers?: Record; + query?: Record; + requestIp?: string; + } +) { + try { + if (data.orgId) { + const retentionDays = await getRetentionDays(data.orgId); + if (retentionDays == 0) { + // do not log + return; + } + } + + let actorType: string | undefined; + let actor: string | undefined; + let actorId: string | undefined; + + const user = data.user; + if (user) { + actorType = "user"; + actor = user.username; + actorId = user.userId; + } + const apiKey = data.apiKey; + if (apiKey) { + actorType = "apiKey"; + actor = apiKey.name || apiKey.apiKeyId; + actorId = apiKey.apiKeyId; + } + + // if (!actorType || !actor || !actorId) { + // logger.warn("logRequestAudit: Incomplete actor information"); + // return; + // } + + const timestamp = Math.floor(Date.now() / 1000); + + let metadata = null; + if (metadata) { + metadata = JSON.stringify(metadata); + } + + const clientIp = body.requestIp + ? (() => { + if ( + body.requestIp.startsWith("[") && + body.requestIp.includes("]") + ) { + // if brackets are found, extract the IPv6 address from between the brackets + const ipv6Match = body.requestIp.match(/\[(.*?)\]/); + if (ipv6Match) { + return ipv6Match[1]; + } + } + + // ivp4 + // split at last colon + const lastColonIndex = body.requestIp.lastIndexOf(":"); + if (lastColonIndex !== -1) { + return body.requestIp.substring(0, lastColonIndex); + } + return body.requestIp; + })() + : undefined; + + await db.insert(requestAuditLog).values({ + timestamp, + orgId: data.orgId, + actorType, + actor, + actorId, + metadata, + action: data.action, + resourceId: data.resourceId, + reason: data.reason, + location: data.location, + // userAgent: data.userAgent, // TODO: add this + // headers: data.body.headers, + // query: data.body.query, + originalRequestURL: body.originalRequestURL, + scheme: body.scheme, + host: body.host, + path: body.path, + method: body.method, + ip: clientIp, + tls: body.tls + }); + } catch (error) { + logger.error(error); + } +} diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 523163e6..085ad6b6 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -1,9 +1,4 @@ -import { generateSessionToken } from "@server/auth/sessions/app"; -import { - createResourceSession, - serializeResourceSessionCookie, - validateResourceSessionToken -} from "@server/auth/sessions/resource"; +import { validateResourceSessionToken } from "@server/auth/sessions/resource"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import { getResourceByDomain, @@ -16,12 +11,13 @@ import { } from "@server/db/queries/verifySessionQueries"; import { LoginPage, + Org, Resource, - ResourceAccessToken, ResourceHeaderAuth, ResourcePassword, ResourcePincode, - ResourceRule + ResourceRule, + resourceSessions } from "@server/db"; import config from "@server/lib/config"; import { isIpInCidr } from "@server/lib/ip"; @@ -30,18 +26,18 @@ import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; -import NodeCache from "node-cache"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { getCountryCodeForIp } from "@server/lib/geoip"; import { getOrgTierData } from "#dynamic/lib/billing"; import { TierId } from "@server/lib/billing/tiers"; import { verifyPassword } from "@server/auth/password"; - -// We'll see if this speeds anything up -const cache = new NodeCache({ - stdTTL: 5 // seconds -}); +import { + checkOrgAccessPolicy, + enforceResourceSessionLength +} from "#dynamic/lib/checkOrgAccessPolicy"; +import { logRequestAudit } from "./logRequestAudit"; +import cache from "@server/lib/cache"; const verifyResourceSessionSchema = z.object({ sessions: z.record(z.string()).optional(), @@ -127,6 +123,10 @@ export async function verifyResourceSession( logger.debug("Client IP:", { clientIp }); + const ipCC = clientIp + ? await getCountryCodeFromIp(clientIp) + : undefined; + let cleanHost = host; // if the host ends with :port, strip it if (cleanHost.match(/:[0-9]{1,5}$/)) { @@ -141,6 +141,7 @@ export async function verifyResourceSession( pincode: ResourcePincode | null; password: ResourcePassword | null; headerAuth: ResourceHeaderAuth | null; + org: Org; } | undefined = cache.get(resourceCacheKey); @@ -149,17 +150,43 @@ export async function verifyResourceSession( if (!result) { logger.debug(`Resource not found ${cleanHost}`); + + // TODO: we cant log this for now because we dont know the org + // eventually it would be cool to show this for the server admin + + // logRequestAudit( + // { + // action: false, + // reason: 201, //resource not found + // location: ipCC + // }, + // parsedBody.data + // ); + return notAllowed(res); } resourceData = result; - cache.set(resourceCacheKey, resourceData); + cache.set(resourceCacheKey, resourceData, 5); } const { resource, pincode, password, headerAuth } = resourceData; if (!resource) { logger.debug(`Resource not found ${cleanHost}`); + + // TODO: we cant log this for now because we dont know the org + // eventually it would be cool to show this for the server admin + + // logRequestAudit( + // { + // action: false, + // reason: 201, //resource not found + // location: ipCC + // }, + // parsedBody.data + // ); + return notAllowed(res); } @@ -167,6 +194,18 @@ export async function verifyResourceSession( if (blockAccess) { logger.debug("Resource blocked", host); + + logRequestAudit( + { + action: false, + reason: 202, //resource blocked + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return notAllowed(res); } @@ -175,14 +214,40 @@ export async function verifyResourceSession( const action = await checkRules( resource.resourceId, clientIp, - path + path, + ipCC ); if (action == "ACCEPT") { logger.debug("Resource allowed by rule"); + + logRequestAudit( + { + action: true, + reason: 100, // allowed by rule + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return allowed(res); } else if (action == "DROP") { logger.debug("Resource denied by rule"); + + // TODO: add rules type + logRequestAudit( + { + action: false, + reason: 203, // dropped by rules + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return notAllowed(res); } else if (action == "PASS") { logger.debug( @@ -203,6 +268,18 @@ export async function verifyResourceSession( !headerAuth ) { logger.debug("Resource allowed because no auth"); + + logRequestAudit( + { + action: true, + reason: 101, // allowed no auth + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return allowed(res); } @@ -254,6 +331,21 @@ export async function verifyResourceSession( } if (valid && tokenItem) { + logRequestAudit( + { + action: true, + reason: 102, // valid access token + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC, + apiKey: { + name: tokenItem.title, + apiKeyId: tokenItem.accessTokenId + } + }, + parsedBody.data + ); + return allowed(res); } } @@ -290,6 +382,21 @@ export async function verifyResourceSession( } if (valid && tokenItem) { + logRequestAudit( + { + action: true, + reason: 102, // valid access token + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC, + apiKey: { + name: tokenItem.title, + apiKeyId: tokenItem.accessTokenId + } + }, + parsedBody.data + ); + return allowed(res); } } @@ -301,6 +408,18 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because header auth is valid (cached)" ); + + logRequestAudit( + { + action: true, + reason: 103, // valid header auth + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return allowed(res); } else if ( await verifyPassword( @@ -308,17 +427,41 @@ export async function verifyResourceSession( headerAuth.headerAuthHash ) ) { - cache.set(clientHeaderAuthKey, clientHeaderAuth); + cache.set(clientHeaderAuthKey, clientHeaderAuth, 5); logger.debug("Resource allowed because header auth is valid"); + + logRequestAudit( + { + action: true, + reason: 103, // valid header auth + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return allowed(res); } - if ( // we dont want to redirect if this is the only auth method and we did not pass here + if ( + // we dont want to redirect if this is the only auth method and we did not pass here !sso && !pincode && !password && !resource.emailWhitelistEnabled ) { + logRequestAudit( + { + action: false, + reason: 299, // no more auth methods + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return notAllowed(res); } } else if (headerAuth) { @@ -329,6 +472,17 @@ export async function verifyResourceSession( !password && !resource.emailWhitelistEnabled ) { + logRequestAudit( + { + action: false, + reason: 299, // no more auth methods + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return notAllowed(res); } } @@ -341,6 +495,18 @@ export async function verifyResourceSession( }. IP: ${clientIp}.` ); } + + logRequestAudit( + { + action: false, + reason: 204, // no sessions + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return notAllowed(res); } @@ -360,7 +526,7 @@ export async function verifyResourceSession( ); resourceSession = result?.resourceSession; - cache.set(sessionCacheKey, resourceSession); + cache.set(sessionCacheKey, resourceSession, 5); } if (resourceSession?.isRequestToken) { @@ -374,14 +540,52 @@ export async function verifyResourceSession( }. IP: ${clientIp}.` ); } + + logRequestAudit( + { + action: false, + reason: 205, // temporary request token + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return notAllowed(res); } if (resourceSession) { + // only run this check if not SSO sesion; SSO session length is checked later + const accessPolicy = await enforceResourceSessionLength( + resourceSession, + resourceData.org + ); + + if (!accessPolicy.valid) { + logger.debug( + "Resource session invalid due to org policy:", + accessPolicy.error + ); + return notAllowed(res, redirectPath, resource.orgId); + } + if (pincode && resourceSession.pincodeId) { logger.debug( "Resource allowed because pincode session is valid" ); + + logRequestAudit( + { + action: true, + reason: 104, // valid pincode + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return allowed(res); } @@ -389,6 +593,18 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because password session is valid" ); + + logRequestAudit( + { + action: true, + reason: 105, // valid password + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return allowed(res); } @@ -399,6 +615,18 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because whitelist session is valid" ); + + logRequestAudit( + { + action: true, + reason: 106, // valid email + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return allowed(res); } @@ -406,6 +634,22 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because access token session is valid" ); + + logRequestAudit( + { + action: true, + reason: 102, // valid access token + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC, + apiKey: { + name: resourceSession.accessTokenTitle, + apiKeyId: resourceSession.accessTokenId + } + }, + parsedBody.data + ); + return allowed(res); } @@ -420,10 +664,11 @@ export async function verifyResourceSession( if (allowedUserData === undefined) { allowedUserData = await isUserAllowedToAccessResource( resourceSession.userSessionId, - resource + resource, + resourceData.org ); - cache.set(userAccessCacheKey, allowedUserData); + cache.set(userAccessCacheKey, allowedUserData, 5); } if ( @@ -433,6 +678,22 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because user session is valid" ); + + logRequestAudit( + { + action: true, + reason: 107, // valid sso + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC, + user: { + username: allowedUserData.username, + userId: resourceSession.userId + } + }, + parsedBody.data + ); + return allowed(res, allowedUserData); } } @@ -451,6 +712,17 @@ export async function verifyResourceSession( logger.debug(`Redirecting to login at ${redirectPath}`); + logRequestAudit( + { + action: false, + reason: 299, // no more auth methods + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return notAllowed(res, redirectPath, resource.orgId); } catch (e) { console.error(e); @@ -562,7 +834,8 @@ function allowed(res: Response, userData?: BasicUserData) { async function isUserAllowedToAccessResource( userSessionId: string, - resource: Resource + resource: Resource, + org: Org ): Promise { const result = await getUserSessionWithUser(userSessionId); @@ -589,6 +862,18 @@ async function isUserAllowedToAccessResource( return null; } + const accessPolicy = await checkOrgAccessPolicy({ + org, + user, + session + }); + if (!accessPolicy.allowed || accessPolicy.error) { + logger.debug(`User not allowed by org access policy because`, { + accessPolicy + }); + return null; + } + const roleResourceAccess = await getRoleResourceAccess( resource.resourceId, userOrgRole.roleId @@ -621,7 +906,8 @@ async function isUserAllowedToAccessResource( async function checkRules( resourceId: number, clientIp: string | undefined, - path: string | undefined + path: string | undefined, + ipCC?: string ): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> { const ruleCacheKey = `rules:${resourceId}`; @@ -629,7 +915,7 @@ async function checkRules( if (!rules) { rules = await getResourceRules(resourceId); - cache.set(ruleCacheKey, rules); + cache.set(ruleCacheKey, rules, 5); } if (rules.length === 0) { @@ -661,7 +947,7 @@ async function checkRules( return rule.action as any; } else if ( clientIp && - rule.match == "GEOIP" && + rule.match == "COUNTRY" && (await isIpInGeoIP(clientIp, rule.value)) ) { return rule.action as any; @@ -790,11 +1076,20 @@ export function isPathAllowed(pattern: string, path: string): boolean { return result; } -async function isIpInGeoIP(ip: string, countryCode: string): Promise { - if (countryCode == "ALL") { +async function isIpInGeoIP( + ipCountryCode: string, + checkCountryCode: string +): Promise { + if (checkCountryCode == "ALL") { return true; } + logger.debug(`IP ${ipCountryCode} is in country: ${checkCountryCode}`); + + return ipCountryCode?.toUpperCase() === checkCountryCode.toUpperCase(); +} + +async function getCountryCodeFromIp(ip: string): Promise { const geoIpCacheKey = `geoip:${ip}`; let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey); @@ -805,9 +1100,7 @@ async function isIpInGeoIP(ip: string, countryCode: string): Promise { cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes } - logger.debug(`IP ${ip} is in country: ${cachedCountryCode}`); - - return cachedCountryCode?.toUpperCase() === countryCode.toUpperCase(); + return cachedCountryCode; } function extractBasicAuth( diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index ff03b2e0..209b54b4 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -1,4 +1,4 @@ -import { db } from "@server/db"; +import { db, olms } from "@server/db"; import { clients, orgs, @@ -16,6 +16,67 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import NodeCache from "node-cache"; +import semver from "semver"; + +const olmVersionCache = new NodeCache({ stdTTL: 3600 }); + +async function getLatestOlmVersion(): Promise { + try { + const cachedVersion = olmVersionCache.get("latestOlmVersion"); + if (cachedVersion) { + return cachedVersion; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 1500); + + const response = await fetch( + "https://api.github.com/repos/fosrl/olm/tags", + { + signal: controller.signal + } + ); + + clearTimeout(timeoutId); + + if (!response.ok) { + logger.warn( + `Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}` + ); + return null; + } + + const tags = await response.json(); + if (!Array.isArray(tags) || tags.length === 0) { + logger.warn("No tags found for Olm repository"); + return null; + } + + const latestVersion = tags[0].name; + + olmVersionCache.set("latestOlmVersion", latestVersion); + + return latestVersion; + } catch (error: any) { + if (error.name === "AbortError") { + logger.warn( + "Request to fetch latest Olm version timed out (1.5s)" + ); + } else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") { + logger.warn( + "Connection timeout while fetching latest Olm version" + ); + } else { + logger.warn( + "Error fetching latest Olm version:", + error.message || error + ); + } + return null; + } +} + const listClientsParamsSchema = z .object({ @@ -50,10 +111,12 @@ function queryClients(orgId: string, accessibleClientIds: number[]) { megabytesOut: clients.megabytesOut, orgName: orgs.name, type: clients.type, - online: clients.online + online: clients.online, + olmVersion: olms.version }) .from(clients) .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) + .leftJoin(olms, eq(clients.clientId, olms.clientId)) .where( and( inArray(clients.clientId, accessibleClientIds), @@ -77,12 +140,20 @@ async function getSiteAssociations(clientIds: number[]) { .where(inArray(clientSites.clientId, clientIds)); } +type OlmWithUpdateAvailable = Awaited>[0] & { + olmUpdateAvailable?: boolean; +}; + + export type ListClientsResponse = { - clients: Array>[0] & { sites: Array<{ - siteId: number; - siteName: string | null; - siteNiceId: string | null; - }> }>; + clients: Array>[0] & { + sites: Array<{ + siteId: number; + siteName: string | null; + siteNiceId: string | null; + }> + olmUpdateAvailable?: boolean; + }>; pagination: { total: number; limit: number; offset: number }; }; @@ -206,6 +277,43 @@ export async function listClients( sites: sitesByClient[client.clientId] || [] })); + const latestOlVersionPromise = getLatestOlmVersion(); + + const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsWithSites.map( + (client) => { + const OlmWithUpdate: OlmWithUpdateAvailable = { ...client }; + // Initially set to false, will be updated if version check succeeds + OlmWithUpdate.olmUpdateAvailable = false; + return OlmWithUpdate; + } + ); + + // Try to get the latest version, but don't block if it fails + try { + const latestOlVersion = await latestOlVersionPromise; + + if (latestOlVersion) { + olmsWithUpdates.forEach((client) => { + try { + client.olmUpdateAvailable = semver.lt( + client.olmVersion ? client.olmVersion : "", + latestOlVersion + ); + } catch (error) { + client.olmUpdateAvailable = false; + } + + }); + } + } catch (error) { + // Log the error but don't let it block the response + logger.warn( + "Failed to check for OLM updates, continuing without update info:", + error + ); + } + + return response(res, { data: { clients: clientsWithSites, diff --git a/server/routers/domain/createOrgDomain.ts b/server/routers/domain/createOrgDomain.ts index d0e8a72b..4c2451e3 100644 --- a/server/routers/domain/createOrgDomain.ts +++ b/server/routers/domain/createOrgDomain.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, Domain, domains, OrgDomains, orgDomains } from "@server/db"; +import { db, Domain, domains, OrgDomains, orgDomains, dnsRecords } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -24,16 +24,21 @@ const paramsSchema = z const bodySchema = z .object({ type: z.enum(["ns", "cname", "wildcard"]), - baseDomain: subdomainSchema + baseDomain: subdomainSchema, + certResolver: z.string().optional().nullable(), + preferWildcardCert: z.boolean().optional().nullable() // optional, only for wildcard }) .strict(); + export type CreateDomainResponse = { domainId: string; nsRecords?: string[]; cnameRecords?: { baseDomain: string; value: string }[]; aRecords?: { baseDomain: string; value: string }[]; txtRecords?: { baseDomain: string; value: string }[]; + certResolver?: string | null; + preferWildcardCert?: boolean | null; }; // Helper to check if a domain is a subdomain or equal to another domain @@ -71,7 +76,7 @@ export async function createOrgDomain( } const { orgId } = parsedParams.data; - const { type, baseDomain } = parsedBody.data; + const { type, baseDomain, certResolver, preferWildcardCert } = parsedBody.data; if (build == "oss") { if (type !== "wildcard") { @@ -254,7 +259,9 @@ export async function createOrgDomain( domainId, baseDomain, type, - verified: type === "wildcard" ? true : false + verified: type === "wildcard" ? true : false, + certResolver: certResolver || null, + preferWildcardCert: preferWildcardCert || false }) .returning(); @@ -269,9 +276,23 @@ export async function createOrgDomain( }) .returning(); + // Prepare DNS records to insert + const recordsToInsert = []; + // TODO: This needs to be cross region and not hardcoded if (type === "ns") { nsRecords = config.getRawConfig().dns.nameservers as string[]; + + // Save NS records to database + for (const nsValue of nsRecords) { + recordsToInsert.push({ + domainId, + recordType: "NS", + baseDomain: baseDomain, + value: nsValue, + verified: false + }); + } } else if (type === "cname") { cnameRecords = [ { @@ -283,6 +304,17 @@ export async function createOrgDomain( baseDomain: `_acme-challenge.${baseDomain}` } ]; + + // Save CNAME records to database + for (const cnameRecord of cnameRecords) { + recordsToInsert.push({ + domainId, + recordType: "CNAME", + baseDomain: cnameRecord.baseDomain, + value: cnameRecord.value, + verified: false + }); + } } else if (type === "wildcard") { aRecords = [ { @@ -294,6 +326,22 @@ export async function createOrgDomain( baseDomain: `${baseDomain}` } ]; + + // Save A records to database + for (const aRecord of aRecords) { + recordsToInsert.push({ + domainId, + recordType: "A", + baseDomain: aRecord.baseDomain, + value: aRecord.value, + verified: true + }); + } + } + + // Insert all DNS records in batch + if (recordsToInsert.length > 0) { + await trx.insert(dnsRecords).values(recordsToInsert); } numOrgDomains = await trx @@ -325,7 +373,9 @@ export async function createOrgDomain( cnameRecords, txtRecords, nsRecords, - aRecords + aRecords, + certResolver: returned.certResolver, + preferWildcardCert: returned.preferWildcardCert }, success: true, error: false, diff --git a/server/routers/domain/getDNSRecords.ts b/server/routers/domain/getDNSRecords.ts new file mode 100644 index 00000000..c705b4fa --- /dev/null +++ b/server/routers/domain/getDNSRecords.ts @@ -0,0 +1,97 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, dnsRecords } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { getServerIp } from "@server/lib/serverIpService"; // your in-memory IP module + +const getDNSRecordsSchema = z + .object({ + domainId: z.string(), + orgId: z.string() + }) + .strict(); + +async function query(domainId: string) { + const records = await db + .select() + .from(dnsRecords) + .where(eq(dnsRecords.domainId, domainId)); + + return records; +} + +export type GetDNSRecordsResponse = Awaited>; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/domain/{domainId}/dns-records", + description: "Get all DNS records for a domain by domainId.", + tags: [OpenAPITags.Domain], + request: { + params: z.object({ + domainId: z.string(), + orgId: z.string() + }) + }, + responses: {} +}); + +export async function getDNSRecords( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getDNSRecordsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { domainId } = parsedParams.data; + + const records = await query(domainId); + + if (!records || records.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No DNS records found for this domain" + ) + ); + } + + const serverIp = getServerIp(); + + // Override value for type A or wildcard records + const updatedRecords = records.map(record => { + if ((record.recordType === "A" || record.baseDomain === "*") && serverIp) { + return { ...record, value: serverIp }; + } + return record; + }); + + return response(res, { + data: updatedRecords, + success: true, + error: false, + message: "DNS records retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/routers/domain/getDomain.ts b/server/routers/domain/getDomain.ts new file mode 100644 index 00000000..77bd18ae --- /dev/null +++ b/server/routers/domain/getDomain.ts @@ -0,0 +1,86 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, domains } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { domain } from "zod/v4/core/regexes"; + +const getDomainSchema = z + .object({ + domainId: z + .string() + .optional(), + orgId: z.string().optional() + }) + .strict(); + +async function query(domainId?: string, orgId?: string) { + if (domainId) { + const [res] = await db + .select() + .from(domains) + .where(eq(domains.domainId, domainId)) + .limit(1); + return res; + } +} + +export type GetDomainResponse = NonNullable>>; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/domain/{domainId}", + description: "Get a domain by domainId.", + tags: [OpenAPITags.Domain], + request: { + params: z.object({ + domainId: z.string(), + orgId: z.string() + }) + }, + responses: {} +}); + +export async function getDomain( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getDomainSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, domainId } = parsedParams.data; + + const domain = await query(domainId, orgId); + + if (!domain) { + return next(createHttpError(HttpCode.NOT_FOUND, "Domain not found")); + } + + return response(res, { + data: domain, + success: true, + error: false, + message: "Domain retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/domain/index.ts b/server/routers/domain/index.ts index c0cafafe..e7e0b555 100644 --- a/server/routers/domain/index.ts +++ b/server/routers/domain/index.ts @@ -1,4 +1,7 @@ export * from "./listDomains"; export * from "./createOrgDomain"; export * from "./deleteOrgDomain"; -export * from "./restartOrgDomain"; \ No newline at end of file +export * from "./restartOrgDomain"; +export * from "./getDomain"; +export * from "./getDNSRecords"; +export * from "./updateDomain"; \ No newline at end of file diff --git a/server/routers/domain/listDomains.ts b/server/routers/domain/listDomains.ts index fe51cde6..55ea99cb 100644 --- a/server/routers/domain/listDomains.ts +++ b/server/routers/domain/listDomains.ts @@ -42,7 +42,9 @@ async function queryDomains(orgId: string, limit: number, offset: number) { type: domains.type, failed: domains.failed, tries: domains.tries, - configManaged: domains.configManaged + configManaged: domains.configManaged, + certResolver: domains.certResolver, + preferWildcardCert: domains.preferWildcardCert }) .from(orgDomains) .where(eq(orgDomains.orgId, orgId)) diff --git a/server/routers/domain/updateDomain.ts b/server/routers/domain/updateDomain.ts new file mode 100644 index 00000000..c684466e --- /dev/null +++ b/server/routers/domain/updateDomain.ts @@ -0,0 +1,161 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, domains, orgDomains } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z + .object({ + orgId: z.string(), + domainId: z.string() + }) + .strict(); + +const bodySchema = z + .object({ + certResolver: z.string().optional().nullable(), + preferWildcardCert: z.boolean().optional().nullable() + }) + .strict(); + +export type UpdateDomainResponse = { + domainId: string; + certResolver: string | null; + preferWildcardCert: boolean | null; +}; + + +registry.registerPath({ + method: "patch", + path: "/org/{orgId}/domain/{domainId}", + description: "Update a domain by domainId.", + tags: [OpenAPITags.Domain], + request: { + params: z.object({ + domainId: z.string(), + orgId: z.string() + }) + }, + responses: {} +}); + +export async function updateOrgDomain( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId, domainId } = parsedParams.data; + const { certResolver, preferWildcardCert } = parsedBody.data; + + const [orgDomain] = await db + .select() + .from(orgDomains) + .where( + and( + eq(orgDomains.orgId, orgId), + eq(orgDomains.domainId, domainId) + ) + ); + + if (!orgDomain) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Domain not found or does not belong to this organization" + ) + ); + } + + + const [existingDomain] = await db + .select() + .from(domains) + .where(eq(domains.domainId, domainId)); + + if (!existingDomain) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Domain not found") + ); + } + + if (existingDomain.type !== "wildcard") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Domain settings can only be updated for wildcard domains" + ) + ); + } + + const updateData: Partial<{ + certResolver: string | null; + preferWildcardCert: boolean; + }> = {}; + + if (certResolver !== undefined) { + updateData.certResolver = certResolver; + } + + if (preferWildcardCert !== undefined && preferWildcardCert !== null) { + updateData.preferWildcardCert = preferWildcardCert; + } + + const [updatedDomain] = await db + .update(domains) + .set(updateData) + .where(eq(domains.domainId, domainId)) + .returning(); + + if (!updatedDomain) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to update domain" + ) + ); + } + + return response(res, { + data: { + domainId: updatedDomain.domainId, + certResolver: updatedDomain.certResolver, + preferWildcardCert: updatedDomain.preferWildcardCert + }, + success: true, + error: false, + message: "Domain updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/routers/external.ts b/server/routers/external.ts index 3783d7b4..5c235902 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -15,6 +15,7 @@ import * as accessToken from "./accessToken"; import * as idp from "./idp"; import * as blueprints from "./blueprints"; import * as apiKeys from "./apiKeys"; +import * as logs from "./auditLogs"; import HttpCode from "@server/types/HttpCode"; import { verifyAccessTokenAccess, @@ -45,6 +46,8 @@ import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import createHttpError from "http-errors"; import { build } from "@server/build"; import { createStore } from "#dynamic/lib/rateLimitStore"; +import { logActionAudit } from "#dynamic/middlewares"; +import { log } from "console"; // Root routes export const unauthenticated = Router(); @@ -76,7 +79,8 @@ authenticated.post( "/org/:orgId", verifyOrgAccess, verifyUserHasAction(ActionsEnum.updateOrg), - org.updateOrg + logActionAudit(ActionsEnum.updateOrg), + org.updateOrg, ); if (build !== "saas") { @@ -85,7 +89,8 @@ if (build !== "saas") { verifyOrgAccess, verifyUserIsOrgOwner, verifyUserHasAction(ActionsEnum.deleteOrg), - org.deleteOrg + logActionAudit(ActionsEnum.deleteOrg), + org.deleteOrg, ); } @@ -93,6 +98,7 @@ authenticated.put( "/org/:orgId/site", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createSite), + logActionAudit(ActionsEnum.createSite), site.createSite ); authenticated.get( @@ -150,7 +156,8 @@ authenticated.put( verifyClientsEnabled, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createClient), - client.createClient + logActionAudit(ActionsEnum.createClient), + client.createClient, ); authenticated.delete( @@ -158,7 +165,8 @@ authenticated.delete( verifyClientsEnabled, verifyClientAccess, verifyUserHasAction(ActionsEnum.deleteClient), - client.deleteClient + logActionAudit(ActionsEnum.deleteClient), + client.deleteClient, ); authenticated.post( @@ -166,7 +174,8 @@ authenticated.post( verifyClientsEnabled, verifyClientAccess, // this will check if the user has access to the client verifyUserHasAction(ActionsEnum.updateClient), // this will check if the user has permission to update the client - client.updateClient + logActionAudit(ActionsEnum.updateClient), + client.updateClient, ); // authenticated.get( @@ -179,15 +188,18 @@ authenticated.post( "/site/:siteId", verifySiteAccess, verifyUserHasAction(ActionsEnum.updateSite), - site.updateSite + logActionAudit(ActionsEnum.updateSite), + site.updateSite, ); authenticated.delete( "/site/:siteId", verifySiteAccess, verifyUserHasAction(ActionsEnum.deleteSite), - site.deleteSite + logActionAudit(ActionsEnum.deleteSite), + site.deleteSite, ); +// TODO: BREAK OUT THESE ACTIONS SO THEY ARE NOT ALL "getSite" authenticated.get( "/site/:siteId/docker/status", verifySiteAccess, @@ -204,13 +216,13 @@ authenticated.post( "/site/:siteId/docker/check", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), - site.checkDockerSocket + site.checkDockerSocket, ); authenticated.post( "/site/:siteId/docker/trigger", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), - site.triggerFetchContainers + site.triggerFetchContainers, ); authenticated.get( "/site/:siteId/docker/containers", @@ -225,7 +237,8 @@ authenticated.put( verifyOrgAccess, verifySiteAccess, verifyUserHasAction(ActionsEnum.createSiteResource), - siteResource.createSiteResource + logActionAudit(ActionsEnum.createSiteResource), + siteResource.createSiteResource, ); authenticated.get( @@ -258,7 +271,8 @@ authenticated.post( verifySiteAccess, verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.updateSiteResource), - siteResource.updateSiteResource + logActionAudit(ActionsEnum.updateSiteResource), + siteResource.updateSiteResource, ); authenticated.delete( @@ -267,14 +281,16 @@ authenticated.delete( verifySiteAccess, verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.deleteSiteResource), - siteResource.deleteSiteResource + logActionAudit(ActionsEnum.deleteSiteResource), + siteResource.deleteSiteResource, ); authenticated.put( "/org/:orgId/resource", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createResource), - resource.createResource + logActionAudit(ActionsEnum.createResource), + resource.createResource, ); authenticated.get( @@ -303,6 +319,27 @@ authenticated.get( domain.listDomains ); +authenticated.get( + "/org/:orgId/domain/:domainId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getDomain), + domain.getDomain +); + +authenticated.patch( + "/org/:orgId/domain/:domainId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.updateOrgDomain), + domain.updateOrgDomain +); + +authenticated.get( + "/org/:orgId/domain/:domainId/dns-records", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getDNSRecords), + domain.getDNSRecords +); + authenticated.get( "/org/:orgId/invitations", verifyOrgAccess, @@ -314,15 +351,18 @@ authenticated.delete( "/org/:orgId/invitations/:inviteId", verifyOrgAccess, verifyUserHasAction(ActionsEnum.removeInvitation), - user.removeInvitation + logActionAudit(ActionsEnum.removeInvitation), + user.removeInvitation, ); authenticated.post( "/org/:orgId/create-invite", verifyOrgAccess, verifyUserHasAction(ActionsEnum.inviteUser), - user.inviteUser + logActionAudit(ActionsEnum.inviteUser), + user.inviteUser, ); // maybe make this /invite/create instead + unauthenticated.post("/invite/accept", user.acceptInvite); // this is supposed to be unauthenticated authenticated.get( @@ -355,20 +395,23 @@ authenticated.post( "/resource/:resourceId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.updateResource), - resource.updateResource + logActionAudit(ActionsEnum.updateResource), + resource.updateResource, ); authenticated.delete( "/resource/:resourceId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResource), - resource.deleteResource + logActionAudit(ActionsEnum.deleteResource), + resource.deleteResource, ); authenticated.put( "/resource/:resourceId/target", verifyResourceAccess, verifyUserHasAction(ActionsEnum.createTarget), - target.createTarget + logActionAudit(ActionsEnum.createTarget), + target.createTarget, ); authenticated.get( "/resource/:resourceId/targets", @@ -381,7 +424,8 @@ authenticated.put( "/resource/:resourceId/rule", verifyResourceAccess, verifyUserHasAction(ActionsEnum.createResourceRule), - resource.createResourceRule + logActionAudit(ActionsEnum.createResourceRule), + resource.createResourceRule, ); authenticated.get( "/resource/:resourceId/rules", @@ -393,13 +437,15 @@ authenticated.post( "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.updateResourceRule), - resource.updateResourceRule + logActionAudit(ActionsEnum.updateResourceRule), + resource.updateResourceRule, ); authenticated.delete( "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResourceRule), - resource.deleteResourceRule + logActionAudit(ActionsEnum.deleteResourceRule), + resource.deleteResourceRule, ); authenticated.get( @@ -412,20 +458,23 @@ authenticated.post( "/target/:targetId", verifyTargetAccess, verifyUserHasAction(ActionsEnum.updateTarget), - target.updateTarget + logActionAudit(ActionsEnum.updateTarget), + target.updateTarget, ); authenticated.delete( "/target/:targetId", verifyTargetAccess, verifyUserHasAction(ActionsEnum.deleteTarget), - target.deleteTarget + logActionAudit(ActionsEnum.deleteTarget), + target.deleteTarget, ); authenticated.put( "/org/:orgId/role", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createRole), - role.createRole + logActionAudit(ActionsEnum.createRole), + role.createRole, ); authenticated.get( "/org/:orgId/roles", @@ -450,14 +499,16 @@ authenticated.delete( "/role/:roleId", verifyRoleAccess, verifyUserHasAction(ActionsEnum.deleteRole), - role.deleteRole + logActionAudit(ActionsEnum.deleteRole), + role.deleteRole, ); authenticated.post( "/role/:roleId/add/:userId", verifyRoleAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.addUserRole), - user.addUserRole + logActionAudit(ActionsEnum.addUserRole), + user.addUserRole, ); authenticated.post( @@ -465,7 +516,8 @@ authenticated.post( verifyResourceAccess, verifyRoleAccess, verifyUserHasAction(ActionsEnum.setResourceRoles), - resource.setResourceRoles + logActionAudit(ActionsEnum.setResourceRoles), + resource.setResourceRoles, ); authenticated.post( @@ -473,35 +525,40 @@ authenticated.post( verifyResourceAccess, verifySetResourceUsers, verifyUserHasAction(ActionsEnum.setResourceUsers), - resource.setResourceUsers + logActionAudit(ActionsEnum.setResourceUsers), + resource.setResourceUsers, ); authenticated.post( `/resource/:resourceId/password`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourcePassword), - resource.setResourcePassword + logActionAudit(ActionsEnum.setResourcePassword), + resource.setResourcePassword, ); authenticated.post( `/resource/:resourceId/pincode`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourcePincode), - resource.setResourcePincode + logActionAudit(ActionsEnum.setResourcePincode), + resource.setResourcePincode, ); authenticated.post( `/resource/:resourceId/header-auth`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourceHeaderAuth), - resource.setResourceHeaderAuth + logActionAudit(ActionsEnum.setResourceHeaderAuth), + resource.setResourceHeaderAuth, ); authenticated.post( `/resource/:resourceId/whitelist`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourceWhitelist), - resource.setResourceWhitelist + logActionAudit(ActionsEnum.setResourceWhitelist), + resource.setResourceWhitelist, ); authenticated.get( @@ -515,14 +572,16 @@ authenticated.post( `/resource/:resourceId/access-token`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.generateAccessToken), - accessToken.generateAccessToken + logActionAudit(ActionsEnum.generateAccessToken), + accessToken.generateAccessToken, ); authenticated.delete( `/access-token/:accessTokenId`, verifyAccessTokenAccess, verifyUserHasAction(ActionsEnum.deleteAcessToken), - accessToken.deleteAccessToken + logActionAudit(ActionsEnum.deleteAcessToken), + accessToken.deleteAccessToken, ); authenticated.get( @@ -595,7 +654,8 @@ authenticated.put( "/org/:orgId/user", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createOrgUser), - user.createOrgUser + logActionAudit(ActionsEnum.createOrgUser), + user.createOrgUser, ); authenticated.post( @@ -603,10 +663,12 @@ authenticated.post( verifyOrgAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.updateOrgUser), - user.updateOrgUser + logActionAudit(ActionsEnum.updateOrgUser), + user.updateOrgUser, ); authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); +authenticated.get("/org/:orgId/user/:userId/check", org.checkOrgUserAccess); authenticated.post( "/user/:userId/2fa", @@ -625,7 +687,8 @@ authenticated.delete( verifyOrgAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.removeUser), - user.removeUserOrg + logActionAudit(ActionsEnum.removeUser), + user.removeUserOrg, ); // authenticated.put( @@ -755,7 +818,8 @@ authenticated.post( verifyOrgAccess, verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.setApiKeyActions), - apiKeys.setApiKeyActions + logActionAudit(ActionsEnum.setApiKeyActions), + apiKeys.setApiKeyActions, ); authenticated.get( @@ -770,7 +834,8 @@ authenticated.put( `/org/:orgId/api-key`, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createApiKey), - apiKeys.createOrgApiKey + logActionAudit(ActionsEnum.createApiKey), + apiKeys.createOrgApiKey, ); authenticated.delete( @@ -778,7 +843,8 @@ authenticated.delete( verifyOrgAccess, verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.deleteApiKey), - apiKeys.deleteOrgApiKey + logActionAudit(ActionsEnum.deleteApiKey), + apiKeys.deleteOrgApiKey, ); authenticated.get( @@ -793,7 +859,8 @@ authenticated.put( `/org/:orgId/domain`, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createOrgDomain), - domain.createOrgDomain + logActionAudit(ActionsEnum.createOrgDomain), + domain.createOrgDomain, ); authenticated.post( @@ -801,7 +868,8 @@ authenticated.post( verifyOrgAccess, verifyDomainAccess, verifyUserHasAction(ActionsEnum.restartOrgDomain), - domain.restartOrgDomain + logActionAudit(ActionsEnum.restartOrgDomain), + domain.restartOrgDomain, ); authenticated.delete( @@ -809,7 +877,23 @@ authenticated.delete( verifyOrgAccess, verifyDomainAccess, verifyUserHasAction(ActionsEnum.deleteOrgDomain), - domain.deleteAccountDomain + logActionAudit(ActionsEnum.deleteOrgDomain), + domain.deleteAccountDomain, +); + +authenticated.get( + "/org/:orgId/logs/request", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.viewLogs), + logs.queryRequestAuditLogs +); + +authenticated.get( + "/org/:orgId/logs/request/export", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.exportLogs), + logActionAudit(ActionsEnum.exportLogs), + logs.exportRequestAuditLogs ); authenticated.get( @@ -994,11 +1078,11 @@ authRouter.post( auth.requestEmailVerificationCode ); -// authRouter.post( -// "/change-password", -// verifySessionUserMiddleware, -// auth.changePassword -// ); +authRouter.post( + "/change-password", + verifySessionUserMiddleware, + auth.changePassword +); authRouter.post( "/reset-password/request", @@ -1153,4 +1237,4 @@ authRouter.delete( store: createStore() }), auth.deleteSecurityKey -); +); \ No newline at end of file diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 0fd76b4c..c1862120 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -30,7 +30,7 @@ import { import HttpCode from "@server/types/HttpCode"; import { Router } from "express"; import { ActionsEnum } from "@server/auth/actions"; -import { build } from "@server/build"; +import { logActionAudit } from "#dynamic/middlewares"; export const unauthenticated = Router(); @@ -52,7 +52,8 @@ authenticated.put( "/org", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createOrg), - org.createOrg + logActionAudit(ActionsEnum.createOrg), + org.createOrg, ); authenticated.get( @@ -73,21 +74,24 @@ authenticated.post( "/org/:orgId", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.updateOrg), - org.updateOrg + logActionAudit(ActionsEnum.updateOrg), + org.updateOrg, ); authenticated.delete( "/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteOrg), - org.deleteOrg + logActionAudit(ActionsEnum.deleteOrg), + org.deleteOrg, ); authenticated.put( "/org/:orgId/site", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createSite), - site.createSite + logActionAudit(ActionsEnum.createSite), + site.createSite, ); authenticated.get( @@ -122,14 +126,16 @@ authenticated.post( "/site/:siteId", verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.updateSite), - site.updateSite + logActionAudit(ActionsEnum.updateSite), + site.updateSite, ); authenticated.delete( "/site/:siteId", verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.deleteSite), - site.deleteSite + logActionAudit(ActionsEnum.deleteSite), + site.deleteSite, ); authenticated.get( @@ -143,7 +149,8 @@ authenticated.put( verifyApiKeyOrgAccess, verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.createSiteResource), - siteResource.createSiteResource + logActionAudit(ActionsEnum.createSiteResource), + siteResource.createSiteResource, ); authenticated.get( @@ -176,7 +183,8 @@ authenticated.post( verifyApiKeySiteAccess, verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateSiteResource), - siteResource.updateSiteResource + logActionAudit(ActionsEnum.updateSiteResource), + siteResource.updateSiteResource, ); authenticated.delete( @@ -185,21 +193,24 @@ authenticated.delete( verifyApiKeySiteAccess, verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteSiteResource), - siteResource.deleteSiteResource + logActionAudit(ActionsEnum.deleteSiteResource), + siteResource.deleteSiteResource, ); authenticated.put( "/org/:orgId/resource", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createResource), - resource.createResource + logActionAudit(ActionsEnum.createResource), + resource.createResource, ); authenticated.put( "/org/:orgId/site/:siteId/resource", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createResource), - resource.createResource + logActionAudit(ActionsEnum.createResource), + resource.createResource, ); authenticated.get( @@ -234,7 +245,8 @@ authenticated.post( "/org/:orgId/create-invite", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.inviteUser), - user.inviteUser + logActionAudit(ActionsEnum.inviteUser), + user.inviteUser, ); authenticated.get( @@ -262,21 +274,24 @@ authenticated.post( "/resource/:resourceId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateResource), - resource.updateResource + logActionAudit(ActionsEnum.updateResource), + resource.updateResource, ); authenticated.delete( "/resource/:resourceId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteResource), - resource.deleteResource + logActionAudit(ActionsEnum.deleteResource), + resource.deleteResource, ); authenticated.put( "/resource/:resourceId/target", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.createTarget), - target.createTarget + logActionAudit(ActionsEnum.createTarget), + target.createTarget, ); authenticated.get( @@ -290,7 +305,8 @@ authenticated.put( "/resource/:resourceId/rule", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.createResourceRule), - resource.createResourceRule + logActionAudit(ActionsEnum.createResourceRule), + resource.createResourceRule, ); authenticated.get( @@ -304,14 +320,16 @@ authenticated.post( "/resource/:resourceId/rule/:ruleId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateResourceRule), - resource.updateResourceRule + logActionAudit(ActionsEnum.updateResourceRule), + resource.updateResourceRule, ); authenticated.delete( "/resource/:resourceId/rule/:ruleId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteResourceRule), - resource.deleteResourceRule + logActionAudit(ActionsEnum.deleteResourceRule), + resource.deleteResourceRule, ); authenticated.get( @@ -325,21 +343,24 @@ authenticated.post( "/target/:targetId", verifyApiKeyTargetAccess, verifyApiKeyHasAction(ActionsEnum.updateTarget), - target.updateTarget + logActionAudit(ActionsEnum.updateTarget), + target.updateTarget, ); authenticated.delete( "/target/:targetId", verifyApiKeyTargetAccess, verifyApiKeyHasAction(ActionsEnum.deleteTarget), - target.deleteTarget + logActionAudit(ActionsEnum.deleteTarget), + target.deleteTarget, ); authenticated.put( "/org/:orgId/role", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createRole), - role.createRole + logActionAudit(ActionsEnum.createRole), + role.createRole, ); authenticated.get( @@ -353,7 +374,8 @@ authenticated.delete( "/role/:roleId", verifyApiKeyRoleAccess, verifyApiKeyHasAction(ActionsEnum.deleteRole), - role.deleteRole + logActionAudit(ActionsEnum.deleteRole), + role.deleteRole, ); authenticated.get( @@ -368,7 +390,8 @@ authenticated.post( verifyApiKeyRoleAccess, verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.addUserRole), - user.addUserRole + logActionAudit(ActionsEnum.addUserRole), + user.addUserRole, ); authenticated.post( @@ -376,7 +399,8 @@ authenticated.post( verifyApiKeyResourceAccess, verifyApiKeyRoleAccess, verifyApiKeyHasAction(ActionsEnum.setResourceRoles), - resource.setResourceRoles + logActionAudit(ActionsEnum.setResourceRoles), + resource.setResourceRoles, ); authenticated.post( @@ -384,45 +408,50 @@ authenticated.post( verifyApiKeyResourceAccess, verifyApiKeySetResourceUsers, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), - resource.setResourceUsers + logActionAudit(ActionsEnum.setResourceUsers), + resource.setResourceUsers, ); authenticated.post( `/resource/:resourceId/password`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourcePassword), - resource.setResourcePassword + logActionAudit(ActionsEnum.setResourcePassword), + resource.setResourcePassword, ); authenticated.post( `/resource/:resourceId/pincode`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourcePincode), - resource.setResourcePincode + logActionAudit(ActionsEnum.setResourcePincode), + resource.setResourcePincode, ); authenticated.post( `/resource/:resourceId/header-auth`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourceHeaderAuth), - resource.setResourceHeaderAuth + logActionAudit(ActionsEnum.setResourceHeaderAuth), + resource.setResourceHeaderAuth, ); authenticated.post( `/resource/:resourceId/whitelist`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), - resource.setResourceWhitelist + logActionAudit(ActionsEnum.setResourceWhitelist), + resource.setResourceWhitelist, ); -authenticated.get( +authenticated.post( `/resource/:resourceId/whitelist/add`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), resource.addEmailToResourceWhitelist ); -authenticated.get( +authenticated.post( `/resource/:resourceId/whitelist/remove`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), @@ -440,14 +469,16 @@ authenticated.post( `/resource/:resourceId/access-token`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.generateAccessToken), - accessToken.generateAccessToken + logActionAudit(ActionsEnum.generateAccessToken), + accessToken.generateAccessToken, ); authenticated.delete( `/access-token/:accessTokenId`, verifyApiKeyAccessTokenAccess, verifyApiKeyHasAction(ActionsEnum.deleteAcessToken), - accessToken.deleteAccessToken + logActionAudit(ActionsEnum.deleteAcessToken), + accessToken.deleteAccessToken, ); authenticated.get( @@ -475,7 +506,8 @@ authenticated.post( "/user/:userId/2fa", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateUser), - user.updateUser2FA + logActionAudit(ActionsEnum.updateUser), + user.updateUser2FA, ); authenticated.get( @@ -496,7 +528,8 @@ authenticated.put( "/org/:orgId/user", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createOrgUser), - user.createOrgUser + logActionAudit(ActionsEnum.createOrgUser), + user.createOrgUser, ); authenticated.post( @@ -504,7 +537,8 @@ authenticated.post( verifyApiKeyOrgAccess, verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.updateOrgUser), - user.updateOrgUser + logActionAudit(ActionsEnum.updateOrgUser), + user.updateOrgUser, ); authenticated.delete( @@ -512,7 +546,8 @@ authenticated.delete( verifyApiKeyOrgAccess, verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.removeUser), - user.removeUserOrg + logActionAudit(ActionsEnum.removeUser), + user.removeUserOrg, ); // authenticated.put( @@ -532,7 +567,8 @@ authenticated.post( `/org/:orgId/api-key/:apiKeyId/actions`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.setApiKeyActions), - apiKeys.setApiKeyActions + logActionAudit(ActionsEnum.setApiKeyActions), + apiKeys.setApiKeyActions, ); authenticated.get( @@ -546,28 +582,32 @@ authenticated.put( `/org/:orgId/api-key`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createApiKey), - apiKeys.createOrgApiKey + logActionAudit(ActionsEnum.createApiKey), + apiKeys.createOrgApiKey, ); authenticated.delete( `/org/:orgId/api-key/:apiKeyId`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteApiKey), - apiKeys.deleteApiKey + logActionAudit(ActionsEnum.deleteApiKey), + apiKeys.deleteApiKey, ); authenticated.put( "/idp/oidc", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createIdp), - idp.createOidcIdp + logActionAudit(ActionsEnum.createIdp), + idp.createOidcIdp, ); authenticated.post( "/idp/:idpId/oidc", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateIdp), - idp.updateOidcIdp + logActionAudit(ActionsEnum.updateIdp), + idp.updateOidcIdp, ); authenticated.get( @@ -588,21 +628,24 @@ authenticated.put( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createIdpOrg), - idp.createIdpOrgPolicy + logActionAudit(ActionsEnum.createIdpOrg), + idp.createIdpOrgPolicy, ); authenticated.post( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateIdpOrg), - idp.updateIdpOrgPolicy + logActionAudit(ActionsEnum.updateIdpOrg), + idp.updateIdpOrgPolicy, ); authenticated.delete( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteIdpOrg), - idp.deleteIdpOrgPolicy + logActionAudit(ActionsEnum.deleteIdpOrg), + idp.deleteIdpOrgPolicy, ); authenticated.get( @@ -641,7 +684,8 @@ authenticated.put( verifyClientsEnabled, verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createClient), - client.createClient + logActionAudit(ActionsEnum.createClient), + client.createClient, ); authenticated.delete( @@ -649,7 +693,8 @@ authenticated.delete( verifyClientsEnabled, verifyApiKeyClientAccess, verifyApiKeyHasAction(ActionsEnum.deleteClient), - client.deleteClient + logActionAudit(ActionsEnum.deleteClient), + client.deleteClient, ); authenticated.post( @@ -657,7 +702,8 @@ authenticated.post( verifyClientsEnabled, verifyApiKeyClientAccess, verifyApiKeyHasAction(ActionsEnum.updateClient), - client.updateClient + logActionAudit(ActionsEnum.updateClient), + client.updateClient, ); authenticated.put( @@ -665,4 +711,6 @@ authenticated.put( verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.applyBlueprint), blueprints.applyJSONBlueprint + logActionAudit(ActionsEnum.applyBlueprint), + org.applyBlueprint, ); diff --git a/server/routers/newt/dockerSocket.ts b/server/routers/newt/dockerSocket.ts index 3847d9a4..41f36d3a 100644 --- a/server/routers/newt/dockerSocket.ts +++ b/server/routers/newt/dockerSocket.ts @@ -1,10 +1,5 @@ -import NodeCache from "node-cache"; import { sendToClient } from "#dynamic/routers/ws"; -export const dockerSocketCache = new NodeCache({ - stdTTL: 3600 // seconds -}); - export function fetchContainers(newtId: string) { const payload = { type: `newt/socket/fetch`, diff --git a/server/routers/newt/handleSocketMessages.ts b/server/routers/newt/handleSocketMessages.ts index 0491393f..09a473b9 100644 --- a/server/routers/newt/handleSocketMessages.ts +++ b/server/routers/newt/handleSocketMessages.ts @@ -1,8 +1,8 @@ import { MessageHandler } from "@server/routers/ws"; import logger from "@server/logger"; -import { dockerSocketCache } from "./dockerSocket"; import { Newt } from "@server/db"; import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint"; +import cache from "@server/lib/cache"; export const handleDockerStatusMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; @@ -24,8 +24,8 @@ export const handleDockerStatusMessage: MessageHandler = async (context) => { if (available) { logger.info(`Newt ${newt.newtId} has Docker socket access`); - dockerSocketCache.set(`${newt.newtId}:socketPath`, socketPath, 0); - dockerSocketCache.set(`${newt.newtId}:isAvailable`, available, 0); + cache.set(`${newt.newtId}:socketPath`, socketPath, 0); + cache.set(`${newt.newtId}:isAvailable`, available, 0); } else { logger.warn(`Newt ${newt.newtId} does not have Docker socket access`); } @@ -54,7 +54,7 @@ export const handleDockerContainersMessage: MessageHandler = async ( ); if (containers && containers.length > 0) { - dockerSocketCache.set(`${newt.newtId}:dockerContainers`, containers, 0); + cache.set(`${newt.newtId}:dockerContainers`, containers, 0); } else { logger.warn(`Newt ${newt.newtId} does not have Docker containers`); } diff --git a/server/routers/org/checkOrgUserAccess.ts b/server/routers/org/checkOrgUserAccess.ts new file mode 100644 index 00000000..d9f0364e --- /dev/null +++ b/server/routers/org/checkOrgUserAccess.ts @@ -0,0 +1,136 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, idp, idpOidcConfig } from "@server/db"; +import { roles, userOrgs, users } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { OpenAPITags, registry } from "@server/openApi"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { CheckOrgAccessPolicyResult } from "@server/lib/checkOrgAccessPolicy"; + +async function queryUser(orgId: string, userId: string) { + const [user] = await db + .select({ + orgId: userOrgs.orgId, + userId: users.userId, + email: users.email, + username: users.username, + name: users.name, + type: users.type, + roleId: userOrgs.roleId, + roleName: roles.name, + isOwner: userOrgs.isOwner, + isAdmin: roles.isAdmin, + twoFactorEnabled: users.twoFactorEnabled, + autoProvisioned: userOrgs.autoProvisioned, + idpId: users.idpId, + idpName: idp.name, + idpType: idp.type, + idpVariant: idpOidcConfig.variant, + idpAutoProvision: idp.autoProvision + }) + .from(userOrgs) + .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) + .leftJoin(users, eq(userOrgs.userId, users.userId)) + .leftJoin(idp, eq(users.idpId, idp.idpId)) + .leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .limit(1); + return user; +} + +export type CheckOrgUserAccessResponse = CheckOrgAccessPolicyResult; + +const paramsSchema = z.object({ + userId: z.string(), + orgId: z.string() +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/user/{userId}/check", + description: "Check a user's access in an organization.", + tags: [OpenAPITags.Org, OpenAPITags.User], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function checkOrgUserAccess( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, userId } = parsedParams.data; + + if (userId !== req.user?.userId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have permission to check this user's access" + ) + ); + } + + let user; + user = await queryUser(orgId, userId); + + if (!user) { + const [fullUser] = await db + .select() + .from(users) + .where(eq(users.email, userId)) + .limit(1); + + if (fullUser) { + user = await queryUser(orgId, fullUser.userId); + } + } + + if (!user) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `User with ID ${userId} not found in org` + ) + ); + } + + const policyCheck = await checkOrgAccessPolicy({ + orgId, + userId, + session: req.session + }); + + // if we get here, the user has an org join, we just don't know if they pass the policies + return response(res, { + data: policyCheck, + success: true, + error: false, + message: "User access checked successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/org/getOrg.ts b/server/routers/org/getOrg.ts index 89c77f13..2497f9a6 100644 --- a/server/routers/org/getOrg.ts +++ b/server/routers/org/getOrg.ts @@ -17,7 +17,7 @@ const getOrgSchema = z .strict(); export type GetOrgResponse = { - org: Org & { settings: { } | null }; + org: Org; }; registry.registerPath({ @@ -49,13 +49,13 @@ export async function getOrg( const { orgId } = parsedParams.data; - const org = await db + const [org] = await db .select() .from(orgs) .where(eq(orgs.orgId, orgId)) .limit(1); - if (org.length === 0) { + if (!org) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -64,23 +64,9 @@ export async function getOrg( ); } - // Parse settings from JSON string back to object - let parsedSettings = null; - if (org[0].settings) { - try { - parsedSettings = JSON.parse(org[0].settings); - } catch (error) { - // If parsing fails, keep as string for backward compatibility - parsedSettings = org[0].settings; - } - } - return response(res, { data: { - org: { - ...org[0], - settings: parsedSettings - } + org }, success: true, error: false, diff --git a/server/routers/org/index.ts b/server/routers/org/index.ts index c9a44d8d..8ce01e92 100644 --- a/server/routers/org/index.ts +++ b/server/routers/org/index.ts @@ -7,3 +7,5 @@ export * from "./checkId"; export * from "./getOrgOverview"; export * from "./listOrgs"; export * from "./pickOrgDefaults"; +export * from "./applyBlueprint"; +export * from "./checkOrgUserAccess"; diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index 6f30e62c..a1739371 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { orgs } from "@server/db"; +import { orgs, users } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -9,18 +9,36 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { build } from "@server/build"; +import { UserType } from "@server/types/UserTypes"; +import license from "#dynamic/license/license"; +import { getOrgTierData } from "#dynamic/lib/billing"; +import { TierId } from "@server/lib/billing/tiers"; const updateOrgParamsSchema = z .object({ - orgId: z.string(), + orgId: z.string() }) .strict(); const updateOrgBodySchema = z .object({ name: z.string().min(1).max(255).optional(), - settings: z.object({ - }).optional(), + requireTwoFactor: z.boolean().optional(), + maxSessionLengthHours: z.number().nullable().optional(), + passwordExpiryDays: z.number().nullable().optional(), + settingsLogRetentionDaysRequest: z + .number() + .min(build === "saas" ? 0 : -1) + .optional(), + settingsLogRetentionDaysAccess: z + .number() + .min(build === "saas" ? 0 : -1) + .optional(), + settingsLogRetentionDaysAction: z + .number() + .min(build === "saas" ? 0 : -1) + .optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -73,13 +91,40 @@ export async function updateOrg( const { orgId } = parsedParams.data; - const settings = parsedBody.data.settings ? JSON.stringify(parsedBody.data.settings) : undefined; + const isLicensed = await isLicensedOrSubscribed(orgId); + if (!isLicensed) { + parsedBody.data.requireTwoFactor = undefined; + parsedBody.data.maxSessionLengthHours = undefined; + parsedBody.data.passwordExpiryDays = undefined; + } + + const { tier } = await getOrgTierData(orgId); + if ( + tier != TierId.STANDARD && + parsedBody.data.settingsLogRetentionDaysRequest && + parsedBody.data.settingsLogRetentionDaysRequest > 30 + ) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You are not allowed to set log retention days greater than 30 with your current subscription" + ) + ); + } const updatedOrg = await db .update(orgs) .set({ name: parsedBody.data.name, - settings: settings + requireTwoFactor: parsedBody.data.requireTwoFactor, + maxSessionLengthHours: parsedBody.data.maxSessionLengthHours, + passwordExpiryDays: parsedBody.data.passwordExpiryDays, + settingsLogRetentionDaysRequest: + parsedBody.data.settingsLogRetentionDaysRequest, + settingsLogRetentionDaysAccess: + parsedBody.data.settingsLogRetentionDaysAccess, + settingsLogRetentionDaysAction: + parsedBody.data.settingsLogRetentionDaysAction }) .where(eq(orgs.orgId, orgId)) .returning(); @@ -107,3 +152,22 @@ export async function updateOrg( ); } } + +async function isLicensedOrSubscribed(orgId: string): Promise { + if (build === "enterprise") { + const isUnlocked = await license.isUnlocked(); + if (!isUnlocked) { + return false; + } + } + + if (build === "saas") { + const { tier } = await getOrgTierData(orgId); + const subscribed = tier === TierId.STANDARD; + if (!subscribed) { + return false; + } + } + + return true; +} diff --git a/server/routers/resource/authWithAccessToken.ts b/server/routers/resource/authWithAccessToken.ts index 2d7fdf93..bf0a9697 100644 --- a/server/routers/resource/authWithAccessToken.ts +++ b/server/routers/resource/authWithAccessToken.ts @@ -10,11 +10,10 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { createResourceSession } from "@server/auth/sessions/resource"; import logger from "@server/logger"; -import { - verifyResourceAccessToken -} from "@server/auth/verifyResourceAccessToken"; +import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import config from "@server/lib/config"; import stoi from "@server/lib/stoi"; +import { logAccessAudit } from "#dynamic/lib/logAccessAudit"; const authWithAccessTokenBodySchema = z .object({ @@ -131,6 +130,16 @@ export async function authWithAccessToken( `Resource access token invalid. Resource ID: ${resource.resourceId}. IP: ${req.ip}.` ); } + + logAccessAudit({ + orgId: resource.orgId, + resourceId: resource.resourceId, + action: false, + type: "accessToken", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + return next( createHttpError( HttpCode.UNAUTHORIZED, @@ -150,6 +159,15 @@ export async function authWithAccessToken( doNotExtend: true }); + logAccessAudit({ + orgId: resource.orgId, + resourceId: resource.resourceId, + action: true, + type: "accessToken", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + return response(res, { data: { session: token, diff --git a/server/routers/resource/authWithPassword.ts b/server/routers/resource/authWithPassword.ts index 652c4e86..97daea3b 100644 --- a/server/routers/resource/authWithPassword.ts +++ b/server/routers/resource/authWithPassword.ts @@ -13,6 +13,7 @@ import { createResourceSession } from "@server/auth/sessions/resource"; import logger from "@server/logger"; import { verifyPassword } from "@server/auth/password"; import config from "@server/lib/config"; +import { logAccessAudit } from "#dynamic/lib/logAccessAudit"; export const authWithPasswordBodySchema = z .object({ @@ -113,6 +114,16 @@ export async function authWithPassword( `Resource password incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.` ); } + + logAccessAudit({ + orgId: org.orgId, + resourceId: resource.resourceId, + action: false, + type: "password", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + return next( createHttpError(HttpCode.UNAUTHORIZED, "Incorrect password") ); @@ -129,6 +140,15 @@ export async function authWithPassword( doNotExtend: true }); + logAccessAudit({ + orgId: org.orgId, + resourceId: resource.resourceId, + action: true, + type: "password", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + return response(res, { data: { session: token diff --git a/server/routers/resource/authWithPincode.ts b/server/routers/resource/authWithPincode.ts index d8733c18..8ce5c1fe 100644 --- a/server/routers/resource/authWithPincode.ts +++ b/server/routers/resource/authWithPincode.ts @@ -12,6 +12,7 @@ import { createResourceSession } from "@server/auth/sessions/resource"; import logger from "@server/logger"; import { verifyPassword } from "@server/auth/password"; import config from "@server/lib/config"; +import { logAccessAudit } from "#dynamic/lib/logAccessAudit"; export const authWithPincodeBodySchema = z .object({ @@ -112,6 +113,16 @@ export async function authWithPincode( `Resource pin code incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.` ); } + + logAccessAudit({ + orgId: org.orgId, + resourceId: resource.resourceId, + action: false, + type: "pincode", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + return next( createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN") ); @@ -128,6 +139,15 @@ export async function authWithPincode( doNotExtend: true }); + logAccessAudit({ + orgId: org.orgId, + resourceId: resource.resourceId, + action: true, + type: "pincode", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + return response(res, { data: { session: token diff --git a/server/routers/resource/authWithWhitelist.ts b/server/routers/resource/authWithWhitelist.ts index 07662f7f..11e417b6 100644 --- a/server/routers/resource/authWithWhitelist.ts +++ b/server/routers/resource/authWithWhitelist.ts @@ -1,11 +1,6 @@ import { generateSessionToken } from "@server/auth/sessions/app"; import { db } from "@server/db"; -import { - orgs, - resourceOtp, - resources, - resourceWhitelist -} from "@server/db"; +import { orgs, resourceOtp, resources, resourceWhitelist } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq, and } from "drizzle-orm"; @@ -17,13 +12,11 @@ import { createResourceSession } from "@server/auth/sessions/resource"; import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp"; import logger from "@server/logger"; import config from "@server/lib/config"; +import { logAccessAudit } from "#dynamic/lib/logAccessAudit"; const authWithWhitelistBodySchema = z .object({ - email: z - .string() - .toLowerCase() - .email(), + email: z.string().toLowerCase().email(), otp: z.string().optional() }) .strict(); @@ -126,6 +119,19 @@ export async function authWithWhitelist( `Email is not whitelisted. Email: ${email}. IP: ${req.ip}.` ); } + + if (org && resource) { + logAccessAudit({ + orgId: org.orgId, + resourceId: resource.resourceId, + action: false, + type: "whitelistedEmail", + metadata: { email }, + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + } + return next( createHttpError( HttpCode.UNAUTHORIZED, @@ -219,6 +225,16 @@ export async function authWithWhitelist( doNotExtend: true }); + logAccessAudit({ + orgId: org.orgId, + resourceId: resource.resourceId, + action: true, + metadata: { email }, + type: "whitelistedEmail", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + return response(res, { data: { session: token diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts index 7cb83d8b..1a5c07c2 100644 --- a/server/routers/resource/createResourceRule.ts +++ b/server/routers/resource/createResourceRule.ts @@ -18,7 +18,7 @@ import { OpenAPITags, registry } from "@server/openApi"; const createResourceRuleSchema = z .object({ action: z.enum(["ACCEPT", "DROP", "PASS"]), - match: z.enum(["CIDR", "IP", "PATH", "GEOIP"]), + match: z.enum(["CIDR", "IP", "PATH", "COUNTRY"]), value: z.string().min(1), priority: z.number().int(), enabled: z.boolean().optional() diff --git a/server/routers/resource/getExchangeToken.ts b/server/routers/resource/getExchangeToken.ts index 605e5ca6..28975234 100644 --- a/server/routers/resource/getExchangeToken.ts +++ b/server/routers/resource/getExchangeToken.ts @@ -10,11 +10,11 @@ import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { generateSessionToken } from "@server/auth/sessions/app"; import config from "@server/lib/config"; -import { - encodeHexLowerCase -} from "@oslojs/encoding"; +import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { response } from "@server/lib/response"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { logAccessAudit } from "#dynamic/lib/logAccessAudit"; const getExchangeTokenParams = z .object({ @@ -47,13 +47,13 @@ export async function getExchangeToken( const { resourceId } = parsedParams.data; - const resource = await db + const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); - if (resource.length === 0) { + if (!resource) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -74,6 +74,23 @@ export async function getExchangeToken( ); } + // check org policy here + const hasAccess = await checkOrgAccessPolicy({ + orgId: resource.orgId, + userId: req.user!.userId, + session: req.session + }); + + if (!hasAccess.allowed || hasAccess.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (hasAccess.error || "Unknown error") + ) + ); + } + const sessionId = encodeHexLowerCase( sha256(new TextEncoder().encode(ssoSession)) ); @@ -89,6 +106,21 @@ export async function getExchangeToken( doNotExtend: true }); + if (req.user) { + logAccessAudit({ + orgId: resource.orgId, + resourceId: resourceId, + user: { + username: req.user.username, + userId: req.user.userId + }, + action: true, + type: "login", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + } + logger.debug("Request token created successfully"); return response(res, { diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index a9c3b5de..13c5220d 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -99,8 +99,9 @@ const updateRawResourceBodySchema = z name: z.string().min(1).max(255).optional(), proxyPort: z.number().int().min(1).max(65535).optional(), stickySession: z.boolean().optional(), - enabled: z.boolean().optional() - // enableProxy: z.boolean().optional() // always true now + enabled: z.boolean().optional(), + proxyProtocol: z.boolean().optional(), + proxyProtocolVersion: z.number().int().min(1).optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index 06061da9..8df70c0f 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -30,7 +30,7 @@ const updateResourceRuleParamsSchema = z const updateResourceRuleSchema = z .object({ action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(), - match: z.enum(["CIDR", "IP", "PATH", "GEOIP"]).optional(), + match: z.enum(["CIDR", "IP", "PATH", "COUNTRY"]).optional(), value: z.string().min(1).optional(), priority: z.number().int(), enabled: z.boolean().optional() diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index 36e049bc..f98a01dc 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -267,50 +267,10 @@ export async function createSite( }) .returning(); } else if (type == "local") { - let exitNodeIdToCreate = exitNodeId; - if (!exitNodeIdToCreate) { - if (build == "saas") { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Exit node ID of a remote node is required for local sites" - ) - ); - } - - // select the exit node for local sites - // TODO: THIS SHOULD BE CHOSEN IN THE FRONTEND OR SOMETHING BECAUSE - // YOU CAN HAVE MORE THAN ONE NODE IN THE SYSTEM AND YOU SHOULD SELECT - // WHICH GERBIL NODE TO PUT THE SITE ON BUT FOR NOW THIS WILL DO - const [localExitNode] = await trx - .select() - .from(exitNodes) - .where(eq(exitNodes.type, "gerbil")) - .limit(1); - - if (!localExitNode) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "No gerbil exit node found for organization. Please create a gerbil exit node first." - ) - ); - } - - exitNodeIdToCreate = localExitNode.exitNodeId; - } else { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Site type not recognized" - ) - ); - } - [newSite] = await trx .insert(sites) .values({ - exitNodeId: exitNodeIdToCreate, + exitNodeId: exitNodeId || null, orgId, name, niceId, @@ -321,6 +281,13 @@ export async function createSite( subnet: "0.0.0.0/32" }) .returning(); + } else { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Site type not recognized" + ) + ); } const adminRole = await trx diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 694556f7..cddf8c4b 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -9,14 +9,12 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import NodeCache from "node-cache"; import semver from "semver"; - -const newtVersionCache = new NodeCache({ stdTTL: 3600 }); // 1 hours in seconds +import cache from "@server/lib/cache"; async function getLatestNewtVersion(): Promise { try { - const cachedVersion = newtVersionCache.get("latestNewtVersion"); + const cachedVersion = cache.get("latestNewtVersion"); if (cachedVersion) { return cachedVersion; } @@ -48,7 +46,7 @@ async function getLatestNewtVersion(): Promise { const latestVersion = tags[0].name; - newtVersionCache.set("latestNewtVersion", latestVersion); + cache.set("latestNewtVersion", latestVersion); return latestVersion; } catch (error: any) { diff --git a/server/routers/site/socketIntegration.ts b/server/routers/site/socketIntegration.ts index 7b5160cb..3a52dcd2 100644 --- a/server/routers/site/socketIntegration.ts +++ b/server/routers/site/socketIntegration.ts @@ -12,9 +12,9 @@ import stoi from "@server/lib/stoi"; import { sendToClient } from "#dynamic/routers/ws"; import { fetchContainers, - dockerSocketCache, dockerSocket } from "../newt/dockerSocket"; +import cache from "@server/lib/cache"; export interface ContainerNetwork { networkId: string; @@ -157,7 +157,7 @@ async function triggerFetch(siteId: number) { // clear the cache for this Newt ID so that the site has to keep asking for the containers // this is to ensure that the site always gets the latest data - dockerSocketCache.del(`${newt.newtId}:dockerContainers`); + cache.del(`${newt.newtId}:dockerContainers`); return { siteId, newtId: newt.newtId }; } @@ -165,7 +165,7 @@ async function triggerFetch(siteId: number) { async function queryContainers(siteId: number) { const { newt } = await getSiteAndNewt(siteId); - const result = dockerSocketCache.get( + const result = cache.get( `${newt.newtId}:dockerContainers` ) as Container[]; if (!result) { @@ -182,7 +182,7 @@ async function isDockerAvailable(siteId: number): Promise { const { newt } = await getSiteAndNewt(siteId); const key = `${newt.newtId}:isAvailable`; - const isAvailable = dockerSocketCache.get(key); + const isAvailable = cache.get(key); return !!isAvailable; } @@ -196,8 +196,8 @@ async function getDockerStatus( const mappedKeys = keys.map((x) => `${newt.newtId}:${x}`); const result = { - isAvailable: dockerSocketCache.get(mappedKeys[0]) as boolean, - socketPath: dockerSocketCache.get(mappedKeys[1]) as string | undefined + isAvailable: cache.get(mappedKeys[0]) as boolean, + socketPath: cache.get(mappedKeys[1]) as string | undefined }; return result; diff --git a/server/routers/traefik/traefikConfigProvider.ts b/server/routers/traefik/traefikConfigProvider.ts index 6c9404e9..9b12ed8a 100644 --- a/server/routers/traefik/traefikConfigProvider.ts +++ b/server/routers/traefik/traefikConfigProvider.ts @@ -21,7 +21,8 @@ export async function traefikConfigProvider( currentExitNodeId, config.getRawConfig().traefik.site_types, build == "oss", // filter out the namespace domains in open source - build != "oss" // generate the login pages on the cloud and hybrid + build != "oss", // generate the login pages on the cloud and and enterprise, + config.getRawConfig().traefik.allow_raw_resources ); if (traefikConfig?.http?.middlewares) { diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 56098bea..1cae46c9 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -1,8 +1,7 @@ -import NodeCache from "node-cache"; import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { orgs, userInvites, userOrgs, users } from "@server/db"; +import { orgs, roles, userInvites, userOrgs, users } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -20,8 +19,7 @@ import { UserType } from "@server/types/UserTypes"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; import { build } from "@server/build"; - -const regenerateTracker = new NodeCache({ stdTTL: 3600, checkperiod: 600 }); +import cache from "@server/lib/cache"; const inviteUserParamsSchema = z .object({ @@ -111,6 +109,27 @@ export async function inviteUser( ); } + // Validate that the roleId belongs to the target organization + const [role] = await db + .select() + .from(roles) + .where( + and( + eq(roles.roleId, roleId), + eq(roles.orgId, orgId) + ) + ) + .limit(1); + + if (!role) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid role ID or role does not belong to this organization" + ) + ); + } + if (build == "saas") { const usage = await usageService.getUsage(orgId, FeatureId.USERS); if (!usage) { @@ -182,7 +201,7 @@ export async function inviteUser( } if (existingInvite.length) { - const attempts = regenerateTracker.get(email) || 0; + const attempts = cache.get(email) || 0; if (attempts >= 3) { return next( createHttpError( @@ -192,7 +211,7 @@ export async function inviteUser( ); } - regenerateTracker.set(email, attempts + 1); + cache.set(email, attempts + 1); const inviteId = existingInvite[0].inviteId; // Retrieve the original inviteId const token = generateRandomString( diff --git a/server/setup/copyInConfig.ts b/server/setup/copyInConfig.ts index b8c00192..dd829db4 100644 --- a/server/setup/copyInConfig.ts +++ b/server/setup/copyInConfig.ts @@ -1,4 +1,4 @@ -import { db } from "@server/db"; +import { db, dnsRecords } from "@server/db"; import { domains, exitNodes, orgDomains, orgs, resources } from "@server/db"; import config from "@server/lib/config"; import { eq, ne } from "drizzle-orm"; @@ -8,7 +8,10 @@ export async function copyInConfig() { const endpoint = config.getRawConfig().gerbil.base_endpoint; const listenPort = config.getRawConfig().gerbil.start_port; - if (!config.getRawConfig().flags?.disable_config_managed_domains && config.getRawConfig().domains) { + if ( + !config.getRawConfig().flags?.disable_config_managed_domains && + config.getRawConfig().domains + ) { await copyInDomains(); } @@ -37,7 +40,9 @@ async function copyInDomains() { const configDomains = Object.entries(rawDomains).map( ([key, value]) => ({ domainId: key, - baseDomain: value.base_domain.toLowerCase() + baseDomain: value.base_domain.toLowerCase(), + certResolver: value.cert_resolver || null, + preferWildcardCert: value.prefer_wildcard_cert || null }) ); @@ -54,29 +59,79 @@ async function copyInDomains() { if (!configDomainKeys.has(existingDomain.domainId)) { await trx .delete(domains) - .where(eq(domains.domainId, existingDomain.domainId)) - .execute(); + .where(eq(domains.domainId, existingDomain.domainId)); + await trx + .delete(dnsRecords) + .where(eq(dnsRecords.domainId, existingDomain.domainId)); } } - for (const { domainId, baseDomain } of configDomains) { + for (const { + domainId, + baseDomain, + certResolver, + preferWildcardCert + } of configDomains) { if (existingDomainKeys.has(domainId)) { await trx .update(domains) - .set({ baseDomain, verified: true, type: "wildcard" }) - .where(eq(domains.domainId, domainId)) - .execute(); - } else { - await trx - .insert(domains) - .values({ - domainId, + .set({ baseDomain, - configManaged: true, + verified: true, type: "wildcard", - verified: true + certResolver, + preferWildcardCert }) - .execute(); + .where(eq(domains.domainId, domainId)); + + // delete the dns records and add them again to ensure they are correct + await trx + .delete(dnsRecords) + .where(eq(dnsRecords.domainId, domainId)); + + await trx.insert(dnsRecords).values([ + { + domainId, + recordType: "A", + baseDomain, + value: "Server IP Address", + verified: true + }, + { + domainId, + recordType: "A", + baseDomain, + value: "Server IP Address", + verified: true + } + ]); + } else { + await trx.insert(domains).values({ + domainId, + baseDomain, + configManaged: true, + type: "wildcard", + verified: true, + certResolver, + preferWildcardCert + }); + + await trx.insert(dnsRecords).values([ + { + domainId, + recordType: "A", + baseDomain, + value: "Server IP Address", + verified: true + }, + { + domainId, + recordType: "A", + baseDomain, + value: "Server IP Address", + verified: true + } + ]); } } diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index c8e632e0..f3b07bec 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -13,6 +13,7 @@ import m5 from "./scriptsPg/1.10.0"; import m6 from "./scriptsPg/1.10.2"; import m7 from "./scriptsPg/1.11.0"; import m8 from "./scriptsPg/1.11.1"; +import m9 from "./scriptsPg/1.12.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -26,7 +27,8 @@ const migrations = [ { version: "1.10.0", run: m5 }, { version: "1.10.2", run: m6 }, { version: "1.11.0", run: m7 }, - { version: "1.11.1", run: m8 } + { version: "1.11.1", run: m8 }, + { version: "1.12.0", run: m9 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index e65d7436..dd546db2 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -31,6 +31,7 @@ import m26 from "./scriptsSqlite/1.10.1"; import m27 from "./scriptsSqlite/1.10.2"; import m28 from "./scriptsSqlite/1.11.0"; import m29 from "./scriptsSqlite/1.11.1"; +import m30 from "./scriptsSqlite/1.12.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -60,7 +61,8 @@ const migrations = [ { version: "1.10.1", run: m26 }, { version: "1.10.2", run: m27 }, { version: "1.11.0", run: m28 }, - { version: "1.11.1", run: m29 } + { version: "1.11.1", run: m29 }, + { version: "1.12.0", run: m30 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.11.1.ts b/server/setup/scriptsPg/1.11.1.ts index 79f5ff80..4fd5f3ba 100644 --- a/server/setup/scriptsPg/1.11.1.ts +++ b/server/setup/scriptsPg/1.11.1.ts @@ -9,31 +9,6 @@ export default async function migration() { try { await db.execute(sql`BEGIN`); - // Get the first exit node with type 'gerbil' - const exitNodesQuery = await db.execute( - sql`SELECT * FROM "exitNodes" WHERE "type" = 'gerbil' LIMIT 1` - ); - const exitNodes = exitNodesQuery.rows as { - exitNodeId: number; - }[]; - - const exitNodeId = exitNodes.length > 0 ? exitNodes[0].exitNodeId : null; - - // Get all sites with type 'local' - const sitesQuery = await db.execute( - sql`SELECT "siteId" FROM "sites" WHERE "type" = 'local'` - ); - const sites = sitesQuery.rows as { - siteId: number; - }[]; - - // Update sites to use the exit node - for (const site of sites) { - await db.execute(sql` - UPDATE "sites" SET "exitNode" = ${exitNodeId} WHERE "siteId" = ${site.siteId} - `); - } - await db.execute(sql`UPDATE "exitNodes" SET "online" = true`); // Mark exit nodes as online await db.execute(sql`COMMIT`); diff --git a/server/setup/scriptsPg/1.12.0.ts b/server/setup/scriptsPg/1.12.0.ts new file mode 100644 index 00000000..7150a52c --- /dev/null +++ b/server/setup/scriptsPg/1.12.0.ts @@ -0,0 +1,120 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; + +const version = "1.12.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + await db.execute(sql`BEGIN`); + + await db.execute(sql`UPDATE "resourceRules" SET "match" = 'COUNTRY' WHERE "match" = 'GEOIP'`); + + await db.execute(sql` + CREATE TABLE "accessAuditLog" ( + "id" serial PRIMARY KEY NOT NULL, + "timestamp" bigint NOT NULL, + "orgId" varchar NOT NULL, + "actorType" varchar(50), + "actor" varchar(255), + "actorId" varchar(255), + "resourceId" integer, + "ip" varchar(45), + "type" varchar(100) NOT NULL, + "action" boolean NOT NULL, + "location" text, + "userAgent" text, + "metadata" text + ); + `); + + await db.execute(sql` + CREATE TABLE "actionAuditLog" ( + "id" serial PRIMARY KEY NOT NULL, + "timestamp" bigint NOT NULL, + "orgId" varchar NOT NULL, + "actorType" varchar(50) NOT NULL, + "actor" varchar(255) NOT NULL, + "actorId" varchar(255) NOT NULL, + "action" varchar(100) NOT NULL, + "metadata" text + ); + `); + + await db.execute(sql` + CREATE TABLE "dnsRecords" ( + "id" serial PRIMARY KEY NOT NULL, + "domainId" varchar NOT NULL, + "recordType" varchar NOT NULL, + "baseDomain" varchar, + "value" varchar NOT NULL, + "verified" boolean DEFAULT false NOT NULL + ); + `); + + await db.execute(sql` + CREATE TABLE "requestAuditLog" ( + "id" serial PRIMARY KEY NOT NULL, + "timestamp" integer NOT NULL, + "orgId" text, + "action" boolean NOT NULL, + "reason" integer NOT NULL, + "actorType" text, + "actor" text, + "actorId" text, + "resourceId" integer, + "ip" text, + "location" text, + "userAgent" text, + "metadata" text, + "headers" text, + "query" text, + "originalRequestURL" text, + "scheme" text, + "host" text, + "path" text, + "method" text, + "tls" boolean + ); + `); + + await db.execute(sql`ALTER TABLE "resources" DROP CONSTRAINT "resources_skipToIdpId_idp_idpId_fk";`); + await db.execute(sql`ALTER TABLE "domains" ADD COLUMN "certResolver" varchar;`); + await db.execute(sql`ALTER TABLE "domains" ADD COLUMN "customCertResolver" varchar;`); + await db.execute(sql`ALTER TABLE "domains" ADD COLUMN "preferWildcardCert" boolean;`); + await db.execute(sql`ALTER TABLE "orgs" ADD COLUMN "requireTwoFactor" boolean;`); + await db.execute(sql`ALTER TABLE "orgs" ADD COLUMN "maxSessionLengthHours" integer;`); + await db.execute(sql`ALTER TABLE "orgs" ADD COLUMN "passwordExpiryDays" integer;`); + await db.execute(sql`ALTER TABLE "orgs" ADD COLUMN "settingsLogRetentionDaysRequest" integer DEFAULT 7 NOT NULL;`); + await db.execute(sql`ALTER TABLE "orgs" ADD COLUMN "settingsLogRetentionDaysAccess" integer DEFAULT 0 NOT NULL;`); + await db.execute(sql`ALTER TABLE "orgs" ADD COLUMN "settingsLogRetentionDaysAction" integer DEFAULT 0 NOT NULL;`); + await db.execute(sql`ALTER TABLE "resourceSessions" ADD COLUMN "issuedAt" bigint;`); + await db.execute(sql`ALTER TABLE "resources" ADD COLUMN "proxyProtocol" boolean DEFAULT false NOT NULL;`); + await db.execute(sql`ALTER TABLE "resources" ADD COLUMN "proxyProtocolVersion" integer DEFAULT 1;`); + await db.execute(sql`ALTER TABLE "session" ADD COLUMN "issuedAt" bigint;`); + await db.execute(sql`ALTER TABLE "user" ADD COLUMN "lastPasswordChange" bigint;`); + await db.execute(sql`ALTER TABLE "accessAuditLog" ADD CONSTRAINT "accessAuditLog_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;`); + await db.execute(sql`ALTER TABLE "actionAuditLog" ADD CONSTRAINT "actionAuditLog_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;`); + await db.execute(sql`ALTER TABLE "dnsRecords" ADD CONSTRAINT "dnsRecords_domainId_domains_domainId_fk" FOREIGN KEY ("domainId") REFERENCES "public"."domains"("domainId") ON DELETE cascade ON UPDATE no action;`); + await db.execute(sql`ALTER TABLE "requestAuditLog" ADD CONSTRAINT "requestAuditLog_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;`); + await db.execute(sql`CREATE INDEX "idx_identityAuditLog_timestamp" ON "accessAuditLog" USING btree ("timestamp");`); + await db.execute(sql`CREATE INDEX "idx_identityAuditLog_org_timestamp" ON "accessAuditLog" USING btree ("orgId","timestamp");`); + await db.execute(sql`CREATE INDEX "idx_actionAuditLog_timestamp" ON "actionAuditLog" USING btree ("timestamp");`); + await db.execute(sql`CREATE INDEX "idx_actionAuditLog_org_timestamp" ON "actionAuditLog" USING btree ("orgId","timestamp");`); + await db.execute(sql`CREATE INDEX "idx_requestAuditLog_timestamp" ON "requestAuditLog" USING btree ("timestamp");`); + await db.execute(sql`CREATE INDEX "idx_requestAuditLog_org_timestamp" ON "requestAuditLog" USING btree ("orgId","timestamp");`); + await db.execute(sql`ALTER TABLE "resources" ADD CONSTRAINT "resources_skipToIdpId_idp_idpId_fk" FOREIGN KEY ("skipToIdpId") REFERENCES "public"."idp"("idpId") ON DELETE set null ON UPDATE no action;`); + await db.execute(sql`ALTER TABLE "orgs" DROP COLUMN "settings";`); + + await db.execute(sql`COMMIT`); + console.log("Migrated database"); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.11.1.ts b/server/setup/scriptsSqlite/1.11.1.ts index d8b9b0d1..3aa4ec3d 100644 --- a/server/setup/scriptsSqlite/1.11.1.ts +++ b/server/setup/scriptsSqlite/1.11.1.ts @@ -11,32 +11,6 @@ export default async function migration() { const db = new Database(location); db.transaction(() => { - const exitNodes = db - .prepare(`SELECT * FROM exitNodes WHERE type = 'gerbil' LIMIT 1`) - .all() as { - exitNodeId: number; - name: string; - }[]; - - const exitNodeId = - exitNodes.length > 0 ? exitNodes[0].exitNodeId : null; - - // get all of the targets - const sites = db - .prepare(`SELECT * FROM sites WHERE type = 'local'`) - .all() as { - siteId: number; - exitNodeId: number | null; - }[]; - - const defineExitNodeOnSite = db.prepare( - `UPDATE sites SET exitNode = ? WHERE siteId = ?` - ); - - for (const site of sites) { - defineExitNodeOnSite.run(exitNodeId, site.siteId); - } - db.prepare(`UPDATE exitNodes SET online = 1`).run(); // mark exit nodes as online })(); diff --git a/server/setup/scriptsSqlite/1.12.0.ts b/server/setup/scriptsSqlite/1.12.0.ts new file mode 100644 index 00000000..8aa86724 --- /dev/null +++ b/server/setup/scriptsSqlite/1.12.0.ts @@ -0,0 +1,209 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.12.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + + db.transaction(() => { + db.prepare( + `UPDATE 'resourceRules' SET 'match' = 'COUNTRY' WHERE 'match' = 'GEOIP'` + ).run(); + + db.prepare( + ` + CREATE TABLE 'accessAuditLog' ( + 'id' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'timestamp' integer NOT NULL, + 'orgId' text NOT NULL, + 'actorType' text, + 'actor' text, + 'actorId' text, + 'resourceId' integer, + 'ip' text, + 'location' text, + 'type' text NOT NULL, + 'action' integer NOT NULL, + 'userAgent' text, + 'metadata' text, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + + db.prepare( + `CREATE INDEX 'idx_identityAuditLog_timestamp' ON 'accessAuditLog' ('timestamp');` + ).run(); + db.prepare( + `CREATE INDEX 'idx_identityAuditLog_org_timestamp' ON 'accessAuditLog' ('orgId','timestamp');` + ).run(); + + db.prepare( + ` + CREATE TABLE 'actionAuditLog' ( + 'id' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'timestamp' integer NOT NULL, + 'orgId' text NOT NULL, + 'actorType' text NOT NULL, + 'actor' text NOT NULL, + 'actorId' text NOT NULL, + 'action' text NOT NULL, + 'metadata' text, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + + db.prepare( + `CREATE INDEX 'idx_actionAuditLog_timestamp' ON 'actionAuditLog' ('timestamp');` + ).run(); + db.prepare( + `CREATE INDEX 'idx_actionAuditLog_org_timestamp' ON 'actionAuditLog' ('orgId','timestamp');` + ).run(); + + db.prepare( + ` + CREATE TABLE 'dnsRecords' ( + 'id' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'domainId' text NOT NULL, + 'recordType' text NOT NULL, + 'baseDomain' text, + 'value' text NOT NULL, + 'verified' integer DEFAULT false NOT NULL, + FOREIGN KEY ('domainId') REFERENCES 'domains'('domainId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + + db.prepare( + ` + CREATE TABLE 'requestAuditLog' ( + 'id' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'timestamp' integer NOT NULL, + 'orgId' text, + 'action' integer NOT NULL, + 'reason' integer NOT NULL, + 'actorType' text, + 'actor' text, + 'actorId' text, + 'resourceId' integer, + 'ip' text, + 'location' text, + 'userAgent' text, + 'metadata' text, + 'headers' text, + 'query' text, + 'originalRequestURL' text, + 'scheme' text, + 'host' text, + 'path' text, + 'method' text, + 'tls' integer, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + + db.prepare( + `CREATE INDEX 'idx_requestAuditLog_timestamp' ON 'requestAuditLog' ('timestamp');` + ).run(); + db.prepare( + `CREATE INDEX 'idx_requestAuditLog_org_timestamp' ON 'requestAuditLog' ('orgId','timestamp');` + ).run(); + + db.prepare( + ` + CREATE TABLE '__new_resources' ( + 'resourceId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'resourceGuid' text(36) NOT NULL, + 'orgId' text NOT NULL, + 'niceId' text NOT NULL, + 'name' text NOT NULL, + 'subdomain' text, + 'fullDomain' text, + 'domainId' text, + 'ssl' integer DEFAULT false NOT NULL, + 'blockAccess' integer DEFAULT false NOT NULL, + 'sso' integer DEFAULT true NOT NULL, + 'http' integer DEFAULT true NOT NULL, + 'protocol' text NOT NULL, + 'proxyPort' integer, + 'emailWhitelistEnabled' integer DEFAULT false NOT NULL, + 'applyRules' integer DEFAULT false NOT NULL, + 'enabled' integer DEFAULT true NOT NULL, + 'stickySession' integer DEFAULT false NOT NULL, + 'tlsServerName' text, + 'setHostHeader' text, + 'enableProxy' integer DEFAULT true, + 'skipToIdpId' integer, + 'headers' text, + 'proxyProtocol' integer DEFAULT false NOT NULL, + 'proxyProtocolVersion' integer DEFAULT 1, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('domainId') REFERENCES 'domains'('domainId') ON UPDATE no action ON DELETE set null, + FOREIGN KEY ('skipToIdpId') REFERENCES 'idp'('idpId') ON UPDATE no action ON DELETE set null + ); + ` + ).run(); + + db.prepare( + `INSERT INTO '__new_resources'("resourceId", "resourceGuid", "orgId", "niceId", "name", "subdomain", "fullDomain", "domainId", "ssl", "blockAccess", "sso", "http", "protocol", "proxyPort", "emailWhitelistEnabled", "applyRules", "enabled", "stickySession", "tlsServerName", "setHostHeader", "enableProxy", "skipToIdpId", "headers") SELECT "resourceId", "resourceGuid", "orgId", "niceId", "name", "subdomain", "fullDomain", "domainId", "ssl", "blockAccess", "sso", "http", "protocol", "proxyPort", "emailWhitelistEnabled", "applyRules", "enabled", "stickySession", "tlsServerName", "setHostHeader", "enableProxy", "skipToIdpId", "headers" FROM 'resources';` + ).run(); + db.prepare(`DROP TABLE 'resources';`).run(); + db.prepare( + `ALTER TABLE '__new_resources' RENAME TO 'resources';` + ).run(); + + db.prepare( + `CREATE UNIQUE INDEX 'resources_resourceGuid_unique' ON 'resources' ('resourceGuid');` + ).run(); + db.prepare(`ALTER TABLE 'domains' ADD 'certResolver' text;`).run(); + db.prepare( + `ALTER TABLE 'domains' ADD 'preferWildcardCert' integer;` + ).run(); + db.prepare( + `ALTER TABLE 'orgs' ADD 'requireTwoFactor' integer;` + ).run(); + db.prepare( + `ALTER TABLE 'orgs' ADD 'maxSessionLengthHours' integer;` + ).run(); + db.prepare( + `ALTER TABLE 'orgs' ADD 'passwordExpiryDays' integer;` + ).run(); + db.prepare( + `ALTER TABLE 'orgs' ADD 'settingsLogRetentionDaysRequest' integer DEFAULT 7 NOT NULL;` + ).run(); + db.prepare( + `ALTER TABLE 'orgs' ADD 'settingsLogRetentionDaysAccess' integer DEFAULT 0 NOT NULL;` + ).run(); + db.prepare( + `ALTER TABLE 'orgs' ADD 'settingsLogRetentionDaysAction' integer DEFAULT 0 NOT NULL;` + ).run(); + db.prepare(`ALTER TABLE 'orgs' DROP COLUMN 'settings';`).run(); + db.prepare( + `ALTER TABLE 'resourceSessions' ADD 'issuedAt' integer;` + ).run(); + db.prepare(`ALTER TABLE 'session' ADD 'issuedAt' integer;`).run(); + db.prepare( + `ALTER TABLE 'user' ADD 'lastPasswordChange' integer;` + ).run(); + })(); + + db.pragma("foreign_keys = ON"); + + console.log(`Migrated database`); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/src/actions/server.ts b/src/actions/server.ts index b9dc6e55..b75c3ed7 100644 --- a/src/actions/server.ts +++ b/src/actions/server.ts @@ -80,10 +80,12 @@ async function makeApiRequest( const headersList = await reqHeaders(); const host = headersList.get("host"); + const xForwardedFor = headersList.get("x-forwarded-for"); const headers: Record = { "Content-Type": "application/json", "X-CSRF-Token": "x-csrf-protection", + ...(xForwardedFor ? { "X-Forwarded-For": xForwardedFor } : {}), ...(cookieHeader && { Cookie: cookieHeader }), ...additionalHeaders }; @@ -202,6 +204,7 @@ export type LoginRequest = { email: string; password: string; code?: string; + resourceGuid?: string; }; export type LoginResponse = { diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx index 0f67fc83..c307efcb 100644 --- a/src/app/[orgId]/layout.tsx +++ b/src/app/[orgId]/layout.tsx @@ -1,7 +1,11 @@ -import { internal } from "@app/lib/api"; +import { formatAxiosError, internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { verifySession } from "@app/lib/auth/verifySession"; -import { GetOrgResponse } from "@server/routers/org"; +import { + CheckOrgUserAccessResponse, + GetOrgResponse, + ListUserOrgsResponse +} from "@server/routers/org"; import { GetOrgUserResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; @@ -11,6 +15,9 @@ import SubscriptionStatusProvider from "@app/providers/SubscriptionStatusProvide import { GetOrgSubscriptionResponse } from "@server/routers/billing/types"; import { pullEnv } from "@app/lib/pullEnv"; import { build } from "@server/build"; +import OrgPolicyResult from "@app/components/OrgPolicyResult"; +import UserProvider from "@app/providers/UserProvider"; +import { Layout } from "@app/components/Layout"; export default async function OrgLayout(props: { children: React.ReactNode; @@ -32,25 +39,46 @@ export default async function OrgLayout(props: { redirect(`/`); } + let accessRes: CheckOrgUserAccessResponse | null = null; try { - const getOrgUser = cache(() => - internal.get>( - `/org/${orgId}/user/${user.userId}`, + const checkOrgAccess = cache(() => + internal.get>( + `/org/${orgId}/user/${user.userId}/check`, cookie ) ); - const orgUser = await getOrgUser(); - } catch { + const res = await checkOrgAccess(); + accessRes = res.data.data; + } catch (e) { redirect(`/`); } - try { - const getOrg = cache(() => - internal.get>(`/org/${orgId}`, cookie) + if (!accessRes?.allowed) { + // For non-admin users, show the member resources portal + let orgs: ListUserOrgsResponse["orgs"] = []; + try { + const getOrgs = cache(async () => + internal.get>( + `/user/${user.userId}/orgs`, + await authCookieHeader() + ) + ); + const res = await getOrgs(); + if (res && res.data.data.orgs) { + orgs = res.data.data.orgs; + } + } catch (e) {} + return ( + + + + + ); - await getOrg(); - } catch { - redirect(`/`); } let subscriptionStatus = null; diff --git a/src/app/[orgId]/settings/clients/create/page.tsx b/src/app/[orgId]/settings/clients/create/page.tsx index 392c417e..b7194526 100644 --- a/src/app/[orgId]/settings/clients/create/page.tsx +++ b/src/app/[orgId]/settings/clients/create/page.tsx @@ -72,12 +72,11 @@ interface TunnelTypeOption { } type Commands = { - mac: Record; - linux: Record; + unix: Record; windows: Record; }; -const platforms = ["linux", "mac", "windows"] as const; +const platforms = ["unix", "windows"] as const; type Platform = (typeof platforms)[number]; @@ -128,8 +127,8 @@ export default function Page() { number | null >(null); - const [platform, setPlatform] = useState("linux"); - const [architecture, setArchitecture] = useState("amd64"); + const [platform, setPlatform] = useState("unix"); + const [architecture, setArchitecture] = useState("All"); const [commands, setCommands] = useState(null); const [olmId, setOlmId] = useState(""); @@ -148,43 +147,15 @@ export default function Page() { version: string ) => { const commands = { - mac: { - "Apple Silicon (arm64)": [ - `curl -fsSL https://pangolin.net/get-olm.sh | bash`, - `sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}` - ], - "Intel x64 (amd64)": [ - `curl -fsSL https://pangolin.net/get-olm.sh | bash`, - `sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}` - ] - }, - linux: { - amd64: [ - `curl -fsSL https://pangolin.net/get-olm.sh | bash`, - `sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}` - ], - arm64: [ - `curl -fsSL https://pangolin.net/get-olm.sh | bash`, - `sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}` - ], - arm32: [ - `curl -fsSL https://pangolin.net/get-olm.sh | bash`, - `sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}` - ], - arm32v6: [ - `curl -fsSL https://pangolin.net/get-olm.sh | bash`, - `sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}` - ], - riscv64: [ + unix: { + All: [ `curl -fsSL https://pangolin.net/get-olm.sh | bash`, `sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}` ] }, windows: { x64: [ - `# Download and run the installer`, `curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`, - `# Then run olm with your credentials`, `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}` ] } @@ -194,10 +165,8 @@ export default function Page() { const getArchitectures = () => { switch (platform) { - case "linux": - return ["amd64", "arm64", "arm32", "arm32v6", "riscv64"]; - case "mac": - return ["Apple Silicon (arm64)", "Intel x64 (amd64)"]; + case "unix": + return ["All"]; case "windows": return ["x64"]; default: @@ -209,12 +178,12 @@ export default function Page() { switch (platformName) { case "windows": return "Windows"; - case "mac": - return "macOS"; + case "unix": + return "Unix & macOS"; case "docker": return "Docker"; default: - return "Linux"; + return "Unix & macOS"; } }; @@ -249,8 +218,8 @@ export default function Page() { switch (platformName) { case "windows": return ; - case "mac": - return ; + case "unix": + return ; case "docker": return ; case "kubernetes": diff --git a/src/app/[orgId]/settings/clients/page.tsx b/src/app/[orgId]/settings/clients/page.tsx index 0813ad3c..fd73b736 100644 --- a/src/app/[orgId]/settings/clients/page.tsx +++ b/src/app/[orgId]/settings/clients/page.tsx @@ -44,7 +44,9 @@ export default async function ClientsPage(props: ClientsPageProps) { mbIn: formatSize(client.megabytesIn || 0), mbOut: formatSize(client.megabytesOut || 0), orgId: params.orgId, - online: client.online + online: client.online, + olmVersion: client.olmVersion || undefined, + olmUpdateAvailable: client.olmUpdateAvailable || false, }; }); diff --git a/src/app/[orgId]/settings/domains/[domainId]/layout.tsx b/src/app/[orgId]/settings/domains/[domainId]/layout.tsx new file mode 100644 index 00000000..d33d666a --- /dev/null +++ b/src/app/[orgId]/settings/domains/[domainId]/layout.tsx @@ -0,0 +1,32 @@ +import { redirect } from "next/navigation"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { internal } from "@app/lib/api"; +import { GetDomainResponse } from "@server/routers/domain/getDomain"; +import { AxiosResponse } from "axios"; +import DomainProvider from "@app/providers/DomainProvider"; + +interface SettingsLayoutProps { + children: React.ReactNode; + params: Promise<{ domainId: string; orgId: string }>; +} + +export default async function SettingsLayout({ children, params }: SettingsLayoutProps) { + const { domainId, orgId } = await params; + let domain = null; + + try { + const res = await internal.get>( + `/org/${orgId}/domain/${domainId}`, + await authCookieHeader() + ); + domain = res.data.data; + } catch { + redirect(`/${orgId}/settings/domains`); + } + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/domains/[domainId]/page.tsx b/src/app/[orgId]/settings/domains/[domainId]/page.tsx new file mode 100644 index 00000000..d3c6da36 --- /dev/null +++ b/src/app/[orgId]/settings/domains/[domainId]/page.tsx @@ -0,0 +1,109 @@ +"use client"; +import { useState } from "react"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { useRouter } from "next/navigation"; +import { RefreshCw } from "lucide-react"; +import { Button } from "@app/components/ui/button"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import DomainInfoCard from "@app/components/DomainInfoCard"; +import { useDomain } from "@app/contexts/domainContext"; +import { useTranslations } from "next-intl"; + +export default function DomainSettingsPage() { + const { domain, orgId } = useDomain(); + const router = useRouter(); + const api = createApiClient(useEnvContext()); + const [isRefreshing, setIsRefreshing] = useState(false); + const [restartingDomains, setRestartingDomains] = useState>( + new Set() + ); + const t = useTranslations(); + const { env } = useEnvContext(); + + const refreshData = async () => { + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + + const restartDomain = async (domainId: string) => { + setRestartingDomains((prev) => new Set(prev).add(domainId)); + try { + await api.post(`/org/${orgId}/domain/${domainId}/restart`); + toast({ + title: t("success"), + description: t("domainRestartedDescription", { + fallback: "Domain verification restarted successfully" + }) + }); + refreshData(); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setRestartingDomains((prev) => { + const newSet = new Set(prev); + newSet.delete(domainId); + return newSet; + }); + } + }; + + if (!domain) { + return null; + } + + const isRestarting = restartingDomains.has(domain.domainId); + + return ( + <> +
+ + {env.flags.usePangolinDns && ( + + )} +
+
+ +
+ + ); +} diff --git a/src/app/[orgId]/settings/domains/page.tsx b/src/app/[orgId]/settings/domains/page.tsx index cf684ebd..04db84b3 100644 --- a/src/app/[orgId]/settings/domains/page.tsx +++ b/src/app/[orgId]/settings/domains/page.tsx @@ -53,7 +53,7 @@ export default async function DomainsPage(props: Props) { title={t("domains")} description={t("domainsDescription")} /> - + ); diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index b301fd32..70694805 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -19,6 +19,13 @@ import { FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; import { z } from "zod"; import { useForm } from "react-hook-form"; @@ -42,15 +49,62 @@ import { import { useUserContext } from "@app/hooks/useUserContext"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; + +// Session length options in hours +const SESSION_LENGTH_OPTIONS = [ + { value: null, labelKey: "unenforced" }, + { value: 1, labelKey: "1Hour" }, + { value: 3, labelKey: "3Hours" }, + { value: 6, labelKey: "6Hours" }, + { value: 12, labelKey: "12Hours" }, + { value: 24, labelKey: "1DaySession" }, + { value: 72, labelKey: "3Days" }, + { value: 168, labelKey: "7Days" }, + { value: 336, labelKey: "14Days" }, + { value: 720, labelKey: "30DaysSession" }, + { value: 2160, labelKey: "90DaysSession" }, + { value: 4320, labelKey: "180DaysSession" } +]; + +// Password expiry options in days - will be translated in component +const PASSWORD_EXPIRY_OPTIONS = [ + { value: null, labelKey: "neverExpire" }, + { value: 1, labelKey: "1Day" }, + { value: 30, labelKey: "30Days" }, + { value: 60, labelKey: "60Days" }, + { value: 90, labelKey: "90Days" }, + { value: 180, labelKey: "180Days" }, + { value: 365, labelKey: "1Year" } +]; // Schema for general organization settings const GeneralFormSchema = z.object({ name: z.string(), - subnet: z.string().optional() + subnet: z.string().optional(), + requireTwoFactor: z.boolean().optional(), + maxSessionLengthHours: z.number().nullable().optional(), + passwordExpiryDays: z.number().nullable().optional(), + settingsLogRetentionDaysRequest: z.number(), + settingsLogRetentionDaysAccess: z.number(), + settingsLogRetentionDaysAction: z.number() }); type GeneralFormValues = z.infer; +const LOG_RETENTION_OPTIONS = [ + { label: "logRetentionDisabled", value: 0 }, + { label: "logRetention3Days", value: 3 }, + { label: "logRetention7Days", value: 7 }, + { label: "logRetention14Days", value: 14 }, + { label: "logRetention30Days", value: 30 }, + { label: "logRetention90Days", value: 90 }, + ...(build != "saas" ? [{ label: "logRetentionForever", value: -1 }] : []) +]; + export default function GeneralPage() { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const { orgUser } = userOrgUserContext(); @@ -60,20 +114,61 @@ export default function GeneralPage() { const { user } = useUserContext(); const t = useTranslations(); const { env } = useEnvContext(); + const { licenseStatus, isUnlocked } = useLicenseStatusContext(); + const subscription = useSubscriptionStatusContext(); + + // Check if security features are disabled due to licensing/subscription + const isSecurityFeatureDisabled = () => { + const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked(); + const isSaasNotSubscribed = + build === "saas" && !subscription?.isSubscribed(); + return isEnterpriseNotLicensed || isSaasNotSubscribed; + }; const [loadingDelete, setLoadingDelete] = useState(false); const [loadingSave, setLoadingSave] = useState(false); + const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] = + useState(false); const authPageSettingsRef = useRef(null); const form = useForm({ resolver: zodResolver(GeneralFormSchema), defaultValues: { name: org?.org.name, - subnet: org?.org.subnet || "" // Add default value for subnet + subnet: org?.org.subnet || "", // Add default value for subnet + requireTwoFactor: org?.org.requireTwoFactor || false, + maxSessionLengthHours: org?.org.maxSessionLengthHours || null, + passwordExpiryDays: org?.org.passwordExpiryDays || null, + settingsLogRetentionDaysRequest: + org.org.settingsLogRetentionDaysRequest ?? 15, + settingsLogRetentionDaysAccess: + org.org.settingsLogRetentionDaysAccess ?? 15, + settingsLogRetentionDaysAction: + org.org.settingsLogRetentionDaysAction ?? 15 }, mode: "onChange" }); + // Track initial security policy values + const initialSecurityValues = { + requireTwoFactor: org?.org.requireTwoFactor || false, + maxSessionLengthHours: org?.org.maxSessionLengthHours || null, + passwordExpiryDays: org?.org.passwordExpiryDays || null + }; + + // Check if security policies have changed + const hasSecurityPolicyChanged = () => { + const currentValues = form.getValues(); + return ( + currentValues.requireTwoFactor !== + initialSecurityValues.requireTwoFactor || + currentValues.maxSessionLengthHours !== + initialSecurityValues.maxSessionLengthHours || + currentValues.passwordExpiryDays !== + initialSecurityValues.passwordExpiryDays + ); + }; + async function deleteOrg() { setLoadingDelete(true); try { @@ -126,14 +221,36 @@ export default function GeneralPage() { } async function onSubmit(data: GeneralFormValues) { + // Check if security policies have changed + if (hasSecurityPolicyChanged()) { + setIsSecurityPolicyConfirmOpen(true); + return; + } + + await performSave(data); + } + + async function performSave(data: GeneralFormValues) { setLoadingSave(true); try { + const reqData = { + name: data.name, + settingsLogRetentionDaysRequest: + data.settingsLogRetentionDaysRequest, + settingsLogRetentionDaysAccess: + data.settingsLogRetentionDaysAccess, + settingsLogRetentionDaysAction: + data.settingsLogRetentionDaysAction + } as any; + if (build !== "oss") { + reqData.requireTwoFactor = data.requireTwoFactor || false; + reqData.maxSessionLengthHours = data.maxSessionLengthHours; + reqData.passwordExpiryDays = data.passwordExpiryDays; + } + // Update organization - await api.post(`/org/${org?.org.orgId}`, { - name: data.name - // subnet: data.subnet // Include subnet in the API request - }); + await api.post(`/org/${org?.org.orgId}`, reqData); // Also save auth page settings if they have unsaved changes if ( @@ -168,9 +285,7 @@ export default function GeneralPage() { }} dialog={
-

- {t("orgQuestionRemove")} -

+

{t("orgQuestionRemove")}

{t("orgMessageRemove")}

} @@ -179,23 +294,38 @@ export default function GeneralPage() { string={org?.org.name || ""} title={t("orgDelete")} /> - - - - {t("orgGeneralSettings")} - - - {t("orgGeneralSettingsDescription")} - - - - -
- + +

{t("securityPolicyChangeDescription")}

+ + } + buttonText={t("saveSettings")} + onConfirm={() => performSave(form.getValues())} + string={t("securityPolicyChangeConfirmMessage")} + title={t("securityPolicyChangeWarning")} + warningText={t("securityPolicyChangeWarningText")} + /> + + + + + + + {t("orgGeneralSettings")} + + + {t("orgGeneralSettingsDescription")} + + + + )} - - - - - +
+
+
- {(build === "saas") && ( - + + + + {t("logRetention")} + + + {t("logRetentionDescription")} + + + + + ( + + + {t("logRetentionRequestLabel")} + + + + + + + )} + /> + + {build != "oss" && ( + <> + + + { + const isDisabled = + (build == "saas" && + !subscription + ?.subscribed) || + (build == "enterprise" && + !isUnlocked()); + + return ( + + + {t( + "logRetentionAccessLabel" + )} + + + + + + + ); + }} + /> + { + const isDisabled = + (build == "saas" && + !subscription + ?.subscribed) || + (build == "enterprise" && + !isUnlocked()); + + return ( + + + {t( + "logRetentionActionLabel" + )} + + + + + + + ); + }} + /> + + )} + + + + + + + {build !== "oss" && ( + + + + {t("securitySettings")} + + + {t("securitySettingsDescription")} + + + + + + +
+ + { + const isDisabled = + isSecurityFeatureDisabled(); + + return ( + +
+ + { + if ( + !isDisabled + ) { + form.setValue( + "requireTwoFactor", + val + ); + } + }} + /> + +
+ + + {t( + "requireTwoFactorDescription" + )} + +
+ ); + }} + /> + { + const isDisabled = + isSecurityFeatureDisabled(); + + return ( + + + {t("maxSessionLength")} + + + + + + + {t( + "maxSessionLengthDescription" + )} + + + ); + }} + /> + { + const isDisabled = + isSecurityFeatureDisabled(); + + return ( + + + {t( + "passwordExpiryDays" + )} + + + + + + + {t( + "editPasswordExpiryDescription" + )} + + + ); + }} + /> + + +
+
+
)} - {/* Save Button */} -
+ {build === "saas" && } + +
+ {build !== "saas" && ( + + )}
- - {build !== "saas" && ( - - - - {t("orgDangerZone")} - - - {t("orgDangerZoneDescription")} - - - - - - - )} ); } diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx new file mode 100644 index 00000000..56071976 --- /dev/null +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -0,0 +1,662 @@ +"use client"; +import { Button } from "@app/components/ui/button"; +import { toast } from "@app/hooks/useToast"; +import { useState, useRef, useEffect } from "react"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { + getStoredPageSize, + LogDataTable, + setStoredPageSize +} from "@app/components/LogDataTable"; +import { ColumnDef } from "@tanstack/react-table"; +import { DateTimeValue } from "@app/components/DateTimePicker"; +import { ArrowUpRight, Key, User } from "lucide-react"; +import Link from "next/link"; +import { ColumnFilter } from "@app/components/ColumnFilter"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { build } from "@server/build"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; + +export default function GeneralPage() { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const router = useRouter(); + const searchParams = useSearchParams(); + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + const { env } = useEnvContext(); + const { orgId } = useParams(); + const subscription = useSubscriptionStatusContext(); + const { isUnlocked } = useLicenseStatusContext(); + + const [rows, setRows] = useState([]); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const [filterAttributes, setFilterAttributes] = useState<{ + actors: string[]; + resources: { + id: number; + name: string | null; + }[]; + locations: string[]; + }>({ + actors: [], + resources: [], + locations: [] + }); + + // Filter states - unified object for all filters + const [filters, setFilters] = useState<{ + action?: string; + type?: string; + resourceId?: string; + location?: string; + actor?: string; + }>({ + action: searchParams.get("action") || undefined, + type: searchParams.get("type") || undefined, + resourceId: searchParams.get("resourceId") || undefined, + location: searchParams.get("location") || undefined, + actor: searchParams.get("actor") || undefined + }); + + // Pagination state + const [totalCount, setTotalCount] = useState(0); + const [currentPage, setCurrentPage] = useState(0); + const [isLoading, setIsLoading] = useState(false); + + // Initialize page size from storage or default + const [pageSize, setPageSize] = useState(() => { + return getStoredPageSize("access-audit-logs", 20); + }); + + // Set default date range to last 24 hours + const getDefaultDateRange = () => { + // if the time is in the url params, use that instead + const startParam = searchParams.get("start"); + const endParam = searchParams.get("end"); + if (startParam && endParam) { + return { + startDate: { + date: new Date(startParam) + }, + endDate: { + date: new Date(endParam) + } + }; + } + + const now = new Date(); + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + return { + startDate: { + date: yesterday + }, + endDate: { + date: now + } + }; + }; + + const [dateRange, setDateRange] = useState<{ + startDate: DateTimeValue; + endDate: DateTimeValue; + }>(getDefaultDateRange()); + + // Trigger search with default values on component mount + useEffect(() => { + const defaultRange = getDefaultDateRange(); + queryDateTime( + defaultRange.startDate, + defaultRange.endDate, + 0, + pageSize + ); + }, [orgId]); // Re-run if orgId changes + + const handleDateRangeChange = ( + startDate: DateTimeValue, + endDate: DateTimeValue + ) => { + setDateRange({ startDate, endDate }); + setCurrentPage(0); // Reset to first page when filtering + // put the search params in the url for the time + updateUrlParamsForAllFilters({ + start: startDate.date?.toISOString() || "", + end: endDate.date?.toISOString() || "" + }); + + queryDateTime(startDate, endDate, 0, pageSize); + }; + + // Handle page changes + const handlePageChange = (newPage: number) => { + setCurrentPage(newPage); + queryDateTime( + dateRange.startDate, + dateRange.endDate, + newPage, + pageSize + ); + }; + + // Handle page size changes + const handlePageSizeChange = (newPageSize: number) => { + setPageSize(newPageSize); + setStoredPageSize(newPageSize, "access-audit-logs"); + setCurrentPage(0); // Reset to first page when changing page size + queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); + }; + + // Handle filter changes generically + const handleFilterChange = ( + filterType: keyof typeof filters, + value: string | undefined + ) => { + // Create new filters object with updated value + const newFilters = { + ...filters, + [filterType]: value + }; + + setFilters(newFilters); + setCurrentPage(0); // Reset to first page when filtering + + // Update URL params + updateUrlParamsForAllFilters(newFilters); + + // Trigger new query with updated filters (pass directly to avoid async state issues) + queryDateTime( + dateRange.startDate, + dateRange.endDate, + 0, + pageSize, + newFilters + ); + }; + + const updateUrlParamsForAllFilters = ( + newFilters: + | typeof filters + | { + start: string; + end: string; + } + ) => { + const params = new URLSearchParams(searchParams); + Object.entries(newFilters).forEach(([key, value]) => { + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + }); + router.replace(`?${params.toString()}`, { scroll: false }); + }; + + const queryDateTime = async ( + startDate: DateTimeValue, + endDate: DateTimeValue, + page: number = currentPage, + size: number = pageSize, + filtersParam?: { + action?: string; + type?: string; + resourceId?: string; + location?: string; + actor?: string; + } + ) => { + console.log("Date range changed:", { startDate, endDate, page, size }); + if ( + (build == "saas" && !subscription?.subscribed) || + (build == "enterprise" && !isUnlocked()) + ) { + console.log( + "Access denied: subscription inactive or license locked" + ); + return; + } + + setIsLoading(true); + + try { + // Use the provided filters or fall back to current state + const activeFilters = filtersParam || filters; + + // Convert the date/time values to API parameters + const params: any = { + limit: size, + offset: page * size, + ...activeFilters + }; + + if (startDate?.date) { + const startDateTime = new Date(startDate.date); + if (startDate.time) { + const [hours, minutes, seconds] = startDate.time + .split(":") + .map(Number); + startDateTime.setHours(hours, minutes, seconds || 0); + } + params.timeStart = startDateTime.toISOString(); + } + + if (endDate?.date) { + const endDateTime = new Date(endDate.date); + if (endDate.time) { + const [hours, minutes, seconds] = endDate.time + .split(":") + .map(Number); + endDateTime.setHours(hours, minutes, seconds || 0); + } else { + // If no time is specified, set to NOW + const now = new Date(); + endDateTime.setHours( + now.getHours(), + now.getMinutes(), + now.getSeconds(), + now.getMilliseconds() + ); + } + params.timeEnd = endDateTime.toISOString(); + } + + const res = await api.get(`/org/${orgId}/logs/access`, { params }); + if (res.status === 200) { + setRows(res.data.data.log || []); + setTotalCount(res.data.data.pagination?.total || 0); + setFilterAttributes(res.data.data.filterAttributes); + console.log("Fetched logs:", res.data); + } + } catch (error) { + toast({ + title: t("error"), + description: t("Failed to filter logs"), + variant: "destructive" + }); + } finally { + setIsLoading(false); + } + }; + + const refreshData = async () => { + console.log("Data refreshed"); + setIsRefreshing(true); + try { + // Refresh data with current date range and pagination + await queryDateTime( + dateRange.startDate, + dateRange.endDate, + currentPage, + pageSize + ); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + + const exportData = async () => { + try { + setIsExporting(true); + + // Prepare query params for export + const params: any = { + timeStart: dateRange.startDate?.date + ? new Date(dateRange.startDate.date).toISOString() + : undefined, + timeEnd: dateRange.endDate?.date + ? new Date(dateRange.endDate.date).toISOString() + : undefined, + ...filters + }; + + const response = await api.get(`/org/${orgId}/logs/access/export`, { + responseType: "blob", + params + }); + + // Create a URL for the blob and trigger a download + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement("a"); + link.href = url; + const epoch = Math.floor(Date.now() / 1000); + link.setAttribute( + "download", + `access-audit-logs-${orgId}-${epoch}.csv` + ); + document.body.appendChild(link); + link.click(); + link.parentNode?.removeChild(link); + setIsExporting(false); + } catch (error) { + toast({ + title: t("error"), + description: t("exportError"), + variant: "destructive" + }); + } + }; + + const columns: ColumnDef[] = [ + { + accessorKey: "timestamp", + header: ({ column }) => { + return t("timestamp"); + }, + cell: ({ row }) => { + return ( +
+ {new Date( + row.original.timestamp * 1000 + ).toLocaleString()} +
+ ); + } + }, + { + accessorKey: "action", + header: ({ column }) => { + return ( +
+ {t("action")} + + handleFilterChange("action", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.action ? <>Allowed : <>Denied} + + ); + } + }, + { + accessorKey: "ip", + header: ({ column }) => { + return t("ip"); + } + }, + { + accessorKey: "location", + header: ({ column }) => { + return ( +
+ {t("location")} + ({ + value: location, + label: location + }) + )} + selectedValue={filters.location} + onValueChange={(value) => + handleFilterChange("location", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.location ? ( + + {row.original.location} + + ) : ( + + - + + )} + + ); + } + }, + { + accessorKey: "resourceName", + header: ({ column }) => { + return ( +
+ {t("resource")} + ({ + value: res.id.toString(), + label: res.name || "Unnamed Resource" + }))} + selectedValue={filters.resourceId} + onValueChange={(value) => + handleFilterChange("resourceId", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + + + ); + } + }, + { + accessorKey: "type", + header: ({ column }) => { + return ( +
+ {t("type")} + + handleFilterChange("type", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + // should be capitalized first letter + return ( + + {row.original.type.charAt(0).toUpperCase() + + row.original.type.slice(1) || "-"} + + ); + } + }, + { + accessorKey: "actor", + header: ({ column }) => { + return ( +
+ {t("actor")} + ({ + value: actor, + label: actor + }))} + selectedValue={filters.actor} + onValueChange={(value) => + handleFilterChange("actor", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.actor ? ( + <> + {row.original.actorType == "user" ? ( + + ) : ( + + )} + {row.original.actor} + + ) : ( + <>- + )} + + ); + } + }, + { + accessorKey: "actorId", + header: ({ column }) => { + return t("actorId"); + }, + cell: ({ row }) => { + return ( + + {row.original.actorId || "-"} + + ); + } + } + ]; + + const renderExpandedRow = (row: any) => { + return ( +
+
+ {row.userAgent != "node" && ( +
+ User Agent: +

+ {row.userAgent || "N/A"} +

+
+ )} +
+ Metadata: +
+                            {row.metadata
+                                ? JSON.stringify(
+                                      JSON.parse(row.metadata),
+                                      null,
+                                      2
+                                  )
+                                : "N/A"}
+                        
+
+
+
+ ); + }; + + return ( + <> + + + {build == "saas" && !subscription?.subscribed ? ( + + + {t("subscriptionRequiredToUse")} + + + ) : null} + + {build == "enterprise" && !isUnlocked() ? ( + + + {t("licenseRequiredToUse")} + + + ) : null} + + + + ); +} diff --git a/src/app/[orgId]/settings/logs/action/page.tsx b/src/app/[orgId]/settings/logs/action/page.tsx new file mode 100644 index 00000000..b9845afa --- /dev/null +++ b/src/app/[orgId]/settings/logs/action/page.tsx @@ -0,0 +1,515 @@ +"use client"; +import { Button } from "@app/components/ui/button"; +import { toast } from "@app/hooks/useToast"; +import { useState, useRef, useEffect } from "react"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { + getStoredPageSize, + LogDataTable, + setStoredPageSize +} from "@app/components/LogDataTable"; +import { ColumnDef } from "@tanstack/react-table"; +import { DateTimeValue } from "@app/components/DateTimePicker"; +import { Key, User } from "lucide-react"; +import { ColumnFilter } from "@app/components/ColumnFilter"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { build } from "@server/build"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; + +export default function GeneralPage() { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const router = useRouter(); + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + const { env } = useEnvContext(); + const { orgId } = useParams(); + const searchParams = useSearchParams(); + const subscription = useSubscriptionStatusContext(); + const { isUnlocked } = useLicenseStatusContext(); + + const [rows, setRows] = useState([]); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const [filterAttributes, setFilterAttributes] = useState<{ + actors: string[]; + actions: string[]; + }>({ + actors: [], + actions: [] + }); + + // Filter states - unified object for all filters + const [filters, setFilters] = useState<{ + action?: string; + actor?: string; + }>({ + action: searchParams.get("action") || undefined, + actor: searchParams.get("actor") || undefined + }); + + // Pagination state + const [totalCount, setTotalCount] = useState(0); + const [currentPage, setCurrentPage] = useState(0); + const [isLoading, setIsLoading] = useState(false); + + // Initialize page size from storage or default + const [pageSize, setPageSize] = useState(() => { + return getStoredPageSize("action-audit-logs", 20); + }); + + // Set default date range to last 24 hours + const getDefaultDateRange = () => { + // if the time is in the url params, use that instead + const startParam = searchParams.get("start"); + const endParam = searchParams.get("end"); + if (startParam && endParam) { + return { + startDate: { + date: new Date(startParam) + }, + endDate: { + date: new Date(endParam) + } + }; + } + + const now = new Date(); + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + return { + startDate: { + date: yesterday + }, + endDate: { + date: now + } + }; + }; + + const [dateRange, setDateRange] = useState<{ + startDate: DateTimeValue; + endDate: DateTimeValue; + }>(getDefaultDateRange()); + + // Trigger search with default values on component mount + useEffect(() => { + const defaultRange = getDefaultDateRange(); + queryDateTime( + defaultRange.startDate, + defaultRange.endDate, + 0, + pageSize + ); + }, [orgId]); // Re-run if orgId changes + + const handleDateRangeChange = ( + startDate: DateTimeValue, + endDate: DateTimeValue + ) => { + setDateRange({ startDate, endDate }); + setCurrentPage(0); // Reset to first page when filtering + // put the search params in the url for the time + updateUrlParamsForAllFilters({ + start: startDate.date?.toISOString() || "", + end: endDate.date?.toISOString() || "" + }); + + queryDateTime(startDate, endDate, 0, pageSize); + }; + + // Handle page changes + const handlePageChange = (newPage: number) => { + setCurrentPage(newPage); + queryDateTime( + dateRange.startDate, + dateRange.endDate, + newPage, + pageSize + ); + }; + + // Handle page size changes + const handlePageSizeChange = (newPageSize: number) => { + setPageSize(newPageSize); + setStoredPageSize(newPageSize, "action-audit-logs"); + setCurrentPage(0); // Reset to first page when changing page size + queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); + }; + + // Handle filter changes generically + const handleFilterChange = ( + filterType: keyof typeof filters, + value: string | undefined + ) => { + // Create new filters object with updated value + const newFilters = { + ...filters, + [filterType]: value + }; + + setFilters(newFilters); + setCurrentPage(0); // Reset to first page when filtering + + // Update URL params + updateUrlParamsForAllFilters(newFilters); + + // Trigger new query with updated filters (pass directly to avoid async state issues) + queryDateTime( + dateRange.startDate, + dateRange.endDate, + 0, + pageSize, + newFilters + ); + }; + + const updateUrlParamsForAllFilters = ( + newFilters: + | typeof filters + | { + start: string; + end: string; + } + ) => { + const params = new URLSearchParams(searchParams); + Object.entries(newFilters).forEach(([key, value]) => { + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + }); + router.replace(`?${params.toString()}`, { scroll: false }); + }; + + const queryDateTime = async ( + startDate: DateTimeValue, + endDate: DateTimeValue, + page: number = currentPage, + size: number = pageSize, + filtersParam?: { + action?: string; + actor?: string; + } + ) => { + console.log("Date range changed:", { startDate, endDate, page, size }); + if ( + (build == "saas" && !subscription?.subscribed) || + (build == "enterprise" && !isUnlocked()) + ) { + console.log( + "Access denied: subscription inactive or license locked" + ); + return; + } + setIsLoading(true); + + try { + // Use the provided filters or fall back to current state + const activeFilters = filtersParam || filters; + + // Convert the date/time values to API parameters + const params: any = { + limit: size, + offset: page * size, + ...activeFilters + }; + + if (startDate?.date) { + const startDateTime = new Date(startDate.date); + if (startDate.time) { + const [hours, minutes, seconds] = startDate.time + .split(":") + .map(Number); + startDateTime.setHours(hours, minutes, seconds || 0); + } + params.timeStart = startDateTime.toISOString(); + } + + if (endDate?.date) { + const endDateTime = new Date(endDate.date); + if (endDate.time) { + const [hours, minutes, seconds] = endDate.time + .split(":") + .map(Number); + endDateTime.setHours(hours, minutes, seconds || 0); + } else { + // If no time is specified, set to NOW + const now = new Date(); + endDateTime.setHours( + now.getHours(), + now.getMinutes(), + now.getSeconds(), + now.getMilliseconds() + ); + } + params.timeEnd = endDateTime.toISOString(); + } + + const res = await api.get(`/org/${orgId}/logs/action`, { params }); + if (res.status === 200) { + setRows(res.data.data.log || []); + setTotalCount(res.data.data.pagination?.total || 0); + setFilterAttributes(res.data.data.filterAttributes); + console.log("Fetched logs:", res.data); + } + } catch (error) { + toast({ + title: t("error"), + description: t("Failed to filter logs"), + variant: "destructive" + }); + } finally { + setIsLoading(false); + } + }; + + const refreshData = async () => { + console.log("Data refreshed"); + setIsRefreshing(true); + try { + // Refresh data with current date range and pagination + await queryDateTime( + dateRange.startDate, + dateRange.endDate, + currentPage, + pageSize + ); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + + const exportData = async () => { + try { + setIsExporting(true); + + // Prepare query params for export + const params: any = { + timeStart: dateRange.startDate?.date + ? new Date(dateRange.startDate.date).toISOString() + : undefined, + timeEnd: dateRange.endDate?.date + ? new Date(dateRange.endDate.date).toISOString() + : undefined, + ...filters + }; + + const response = await api.get(`/org/${orgId}/logs/action/export`, { + responseType: "blob", + params + }); + + // Create a URL for the blob and trigger a download + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement("a"); + link.href = url; + const epoch = Math.floor(Date.now() / 1000); + link.setAttribute( + "download", + `action-audit-logs-${orgId}-${epoch}.csv` + ); + document.body.appendChild(link); + link.click(); + link.parentNode?.removeChild(link); + setIsExporting(false); + } catch (error) { + toast({ + title: t("error"), + description: t("exportError"), + variant: "destructive" + }); + } + }; + + const columns: ColumnDef[] = [ + { + accessorKey: "timestamp", + header: ({ column }) => { + return t("timestamp"); + }, + cell: ({ row }) => { + return ( +
+ {new Date( + row.original.timestamp * 1000 + ).toLocaleString()} +
+ ); + } + }, + { + accessorKey: "action", + header: ({ column }) => { + return ( +
+ {t("action")} + ({ + label: + action.charAt(0).toUpperCase() + + action.slice(1), + value: action + }))} + selectedValue={filters.action} + onValueChange={(value) => + handleFilterChange("action", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.action.charAt(0).toUpperCase() + + row.original.action.slice(1)} + + ); + } + }, + { + accessorKey: "actor", + header: ({ column }) => { + return ( +
+ {t("actor")} + ({ + value: actor, + label: actor + }))} + selectedValue={filters.actor} + onValueChange={(value) => + handleFilterChange("actor", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.actorType == "user" ? ( + + ) : ( + + )} + {row.original.actor} + + ); + } + }, + { + accessorKey: "actorId", + header: ({ column }) => { + return t("actorId"); + }, + cell: ({ row }) => { + return ( + + {row.original.actorId} + + ); + } + } + ]; + + const renderExpandedRow = (row: any) => { + return ( +
+
+
+ Metadata: +
+                            {row.metadata
+                                ? JSON.stringify(
+                                      JSON.parse(row.metadata),
+                                      null,
+                                      2
+                                  )
+                                : "N/A"}
+                        
+
+
+
+ ); + }; + + return ( + <> + + + {build == "saas" && !subscription?.subscribed ? ( + + + {t("subscriptionRequiredToUse")} + + + ) : null} + + {build == "enterprise" && !isUnlocked() ? ( + + + {t("licenseRequiredToUse")} + + + ) : null} + + + + ); +} diff --git a/src/app/[orgId]/settings/logs/layout.tsx b/src/app/[orgId]/settings/logs/layout.tsx new file mode 100644 index 00000000..96958403 --- /dev/null +++ b/src/app/[orgId]/settings/logs/layout.tsx @@ -0,0 +1,22 @@ +import { verifySession } from "@app/lib/auth/verifySession"; +import { redirect } from "next/navigation"; +import { cache } from "react"; + +type GeneralSettingsProps = { + children: React.ReactNode; + params: Promise<{ orgId: string }>; +}; + +export default async function GeneralSettingsPage({ + children, + params +}: GeneralSettingsProps) { + const getUser = cache(verifySession); + const user = await getUser(); + + if (!user) { + redirect(`/`); + } + + return children; +} diff --git a/src/app/[orgId]/settings/logs/page.tsx b/src/app/[orgId]/settings/logs/page.tsx new file mode 100644 index 00000000..45b5a7de --- /dev/null +++ b/src/app/[orgId]/settings/logs/page.tsx @@ -0,0 +1,54 @@ +"use client"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import AuthPageSettings, { + AuthPageSettingsRef +} from "@app/components/private/AuthPageSettings"; + +import { Button } from "@app/components/ui/button"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; +import { toast } from "@app/hooks/useToast"; +import { useState, useRef } from "react"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { formatAxiosError } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org"; +import { useRouter } from "next/navigation"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm, + SettingsSectionFooter +} from "@app/components/Settings"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { useTranslations } from "next-intl"; +import { build } from "@server/build"; + +export default function GeneralPage() { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const router = useRouter(); + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + const { env } = useEnvContext(); + + return

dfas

; +} diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx new file mode 100644 index 00000000..7aeef772 --- /dev/null +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -0,0 +1,796 @@ +"use client"; +import { Button } from "@app/components/ui/button"; +import { toast } from "@app/hooks/useToast"; +import { useState, useRef, useEffect } from "react"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { getStoredPageSize, LogDataTable, setStoredPageSize } from "@app/components/LogDataTable"; +import { ColumnDef } from "@tanstack/react-table"; +import { DateTimeValue } from "@app/components/DateTimePicker"; +import { Key, RouteOff, User, Lock, Unlock, ArrowUpRight } from "lucide-react"; +import Link from "next/link"; +import { ColumnFilter } from "@app/components/ColumnFilter"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; + +export default function GeneralPage() { + const router = useRouter(); + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + const { env } = useEnvContext(); + const { orgId } = useParams(); + const searchParams = useSearchParams(); + + const [rows, setRows] = useState([]); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isExporting, setIsExporting] = useState(false); + + // Pagination state + const [totalCount, setTotalCount] = useState(0); + const [currentPage, setCurrentPage] = useState(0); + const [isLoading, setIsLoading] = useState(false); + + // Initialize page size from storage or default + const [pageSize, setPageSize] = useState(() => { + return getStoredPageSize("request-audit-logs", 20); + }); + + const [filterAttributes, setFilterAttributes] = useState<{ + actors: string[]; + resources: { + id: number; + name: string | null; + }[]; + locations: string[]; + hosts: string[]; + paths: string[]; + }>({ + actors: [], + resources: [], + locations: [], + hosts: [], + paths: [] + }); + + // Filter states - unified object for all filters + const [filters, setFilters] = useState<{ + action?: string; + resourceId?: string; + host?: string; + location?: string; + actor?: string; + method?: string; + reason?: string; + path?: string; + }>({ + action: searchParams.get("action") || undefined, + host: searchParams.get("host") || undefined, + resourceId: searchParams.get("resourceId") || undefined, + location: searchParams.get("location") || undefined, + actor: searchParams.get("actor") || undefined, + method: searchParams.get("method") || undefined, + reason: searchParams.get("reason") || undefined, + path: searchParams.get("path") || undefined + }); + + // Set default date range to last 24 hours + const getDefaultDateRange = () => { + // if the time is in the url params, use that instead + const startParam = searchParams.get("start"); + const endParam = searchParams.get("end"); + if (startParam && endParam) { + return { + startDate: { + date: new Date(startParam) + }, + endDate: { + date: new Date(endParam) + } + }; + } + + const now = new Date(); + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + return { + startDate: { + date: yesterday + }, + endDate: { + date: now + } + }; + }; + + const [dateRange, setDateRange] = useState<{ + startDate: DateTimeValue; + endDate: DateTimeValue; + }>(getDefaultDateRange()); + + // Trigger search with default values on component mount + useEffect(() => { + const defaultRange = getDefaultDateRange(); + queryDateTime( + defaultRange.startDate, + defaultRange.endDate, + 0, + pageSize + ); + }, [orgId]); // Re-run if orgId changes + + const handleDateRangeChange = ( + startDate: DateTimeValue, + endDate: DateTimeValue + ) => { + setDateRange({ startDate, endDate }); + setCurrentPage(0); // Reset to first page when filtering + // put the search params in the url for the time + updateUrlParamsForAllFilters({ + start: startDate.date?.toISOString() || "", + end: endDate.date?.toISOString() || "" + }); + + queryDateTime(startDate, endDate, 0, pageSize); + }; + + // Handle page changes + const handlePageChange = (newPage: number) => { + setCurrentPage(newPage); + queryDateTime( + dateRange.startDate, + dateRange.endDate, + newPage, + pageSize + ); + }; + + // Handle page size changes + const handlePageSizeChange = (newPageSize: number) => { + setPageSize(newPageSize); + setStoredPageSize(newPageSize, "request-audit-logs"); + setCurrentPage(0); // Reset to first page when changing page size + queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); + }; + + // Handle filter changes generically + const handleFilterChange = ( + filterType: keyof typeof filters, + value: string | undefined + ) => { + console.log(`${filterType} filter changed:`, value); + + // Create new filters object with updated value + const newFilters = { + ...filters, + [filterType]: value + }; + + setFilters(newFilters); + setCurrentPage(0); // Reset to first page when filtering + + // Update URL params + updateUrlParamsForAllFilters(newFilters); + + // Trigger new query with updated filters (pass directly to avoid async state issues) + queryDateTime( + dateRange.startDate, + dateRange.endDate, + 0, + pageSize, + newFilters + ); + }; + + const updateUrlParamsForAllFilters = ( + newFilters: + | typeof filters + | { + start: string; + end: string; + } + ) => { + const params = new URLSearchParams(searchParams); + Object.entries(newFilters).forEach(([key, value]) => { + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + }); + router.replace(`?${params.toString()}`, { scroll: false }); + }; + + const queryDateTime = async ( + startDate: DateTimeValue, + endDate: DateTimeValue, + page: number = currentPage, + size: number = pageSize, + filtersParam?: { + action?: string; + type?: string; + } + ) => { + console.log("Date range changed:", { startDate, endDate, page, size }); + setIsLoading(true); + + try { + // Use the provided filters or fall back to current state + const activeFilters = filtersParam || filters; + + // Convert the date/time values to API parameters + const params: any = { + limit: size, + offset: page * size, + ...activeFilters + }; + + if (startDate?.date) { + const startDateTime = new Date(startDate.date); + if (startDate.time) { + const [hours, minutes, seconds] = startDate.time + .split(":") + .map(Number); + startDateTime.setHours(hours, minutes, seconds || 0); + } + params.timeStart = startDateTime.toISOString(); + } + + if (endDate?.date) { + const endDateTime = new Date(endDate.date); + if (endDate.time) { + const [hours, minutes, seconds] = endDate.time + .split(":") + .map(Number); + endDateTime.setHours(hours, minutes, seconds || 0); + } else { + // If no time is specified, set to NOW + const now = new Date(); + endDateTime.setHours( + now.getHours(), + now.getMinutes(), + now.getSeconds(), + now.getMilliseconds() + ); + } + params.timeEnd = endDateTime.toISOString(); + } + + const res = await api.get(`/org/${orgId}/logs/request`, { params }); + if (res.status === 200) { + setRows(res.data.data.log || []); + setTotalCount(res.data.data.pagination?.total || 0); + setFilterAttributes(res.data.data.filterAttributes); + console.log("Fetched logs:", res.data); + } + } catch (error) { + toast({ + title: t("error"), + description: t("Failed to filter logs"), + variant: "destructive" + }); + } finally { + setIsLoading(false); + } + }; + + const refreshData = async () => { + console.log("Data refreshed"); + setIsRefreshing(true); + try { + // Refresh data with current date range and pagination + await queryDateTime( + dateRange.startDate, + dateRange.endDate, + currentPage, + pageSize + ); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + + const exportData = async () => { + try { + setIsExporting(true); + + // Prepare query params for export + const params: any = { + timeStart: dateRange.startDate?.date + ? new Date(dateRange.startDate.date).toISOString() + : undefined, + timeEnd: dateRange.endDate?.date + ? new Date(dateRange.endDate.date).toISOString() + : undefined, + ...filters + }; + + const response = await api.get( + `/org/${orgId}/logs/request/export`, + { + responseType: "blob", + params + } + ); + + // Create a URL for the blob and trigger a download + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement("a"); + link.href = url; + const epoch = Math.floor(Date.now() / 1000); + link.setAttribute( + "download", + `request-audit-logs-${orgId}-${epoch}.csv` + ); + document.body.appendChild(link); + link.click(); + link.parentNode?.removeChild(link); + setIsExporting(false); + } catch (error) { + toast({ + title: t("error"), + description: t("exportError"), + variant: "destructive" + }); + } + }; + + // 100 - Allowed by Rule + // 101 - Allowed No Auth + // 102 - Valid Access Token + // 103 - Valid header auth + // 104 - Valid Pincode + // 105 - Valid Password + // 106 - Valid email + // 107 - Valid SSO + + // 201 - Resource Not Found + // 202 - Resource Blocked + // 203 - Dropped by Rule + // 204 - No Sessions + // 205 - Temporary Request Token + // 299 - No More Auth Methods + + const reasonMap: any = { + 100: t("allowedByRule"), + 101: t("allowedNoAuth"), + 102: t("validAccessToken"), + 103: t("validHeaderAuth"), + 104: t("validPincode"), + 105: t("validPassword"), + 106: t("validEmail"), + 107: t("validSSO"), + 201: t("resourceNotFound"), + 202: t("resourceBlocked"), + 203: t("droppedByRule"), + 204: t("noSessions"), + 205: t("temporaryRequestToken"), + 299: t("noMoreAuthMethods") + }; + + // resourceId: integer("resourceId"), + // userAgent: text("userAgent"), + // metadata: text("details"), + // headers: text("headers"), // JSON blob + // query: text("query"), // JSON blob + // originalRequestURL: text("originalRequestURL"), + // scheme: text("scheme"), + + const columns: ColumnDef[] = [ + { + accessorKey: "timestamp", + header: ({ column }) => { + return t("timestamp"); + }, + cell: ({ row }) => { + return ( +
+ {new Date( + row.original.timestamp * 1000 + ).toLocaleString()} +
+ ); + } + }, + { + accessorKey: "action", + header: ({ column }) => { + return ( +
+ {t("action")} + + handleFilterChange("action", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.action ? <>Allowed : <>Denied} + + ); + } + }, + { + accessorKey: "ip", + header: ({ column }) => { + return t("ip"); + } + }, + { + accessorKey: "location", + header: ({ column }) => { + return ( +
+ {t("location")} + ({ + value: location, + label: location + }) + )} + selectedValue={filters.location} + onValueChange={(value) => + handleFilterChange("location", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.location ? ( + + {row.original.location} + + ) : ( + + - + + )} + + ); + } + }, + { + accessorKey: "resourceName", + header: ({ column }) => { + return ( +
+ {t("resource")} + ({ + value: res.id.toString(), + label: res.name || "Unnamed Resource" + }))} + selectedValue={filters.resourceId} + onValueChange={(value) => + handleFilterChange("resourceId", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + e.stopPropagation()} + > + + + ); + } + }, + { + accessorKey: "host", + header: ({ column }) => { + return ( +
+ {t("host")} + ({ + value: host, + label: host + }))} + selectedValue={filters.host} + onValueChange={(value) => + handleFilterChange("host", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.tls ? ( + + ) : ( + + )} + {row.original.host} + + ); + } + }, + { + accessorKey: "path", + header: ({ column }) => { + return ( +
+ {t("path")} + ({ + value: path, + label: path + }))} + selectedValue={filters.path} + onValueChange={(value) => + handleFilterChange("path", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + } + }, + + // { + // accessorKey: "scheme", + // header: ({ column }) => { + // return t("scheme"); + // }, + // }, + { + accessorKey: "method", + header: ({ column }) => { + return ( +
+ {t("method")} + + handleFilterChange("method", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + } + }, + { + accessorKey: "reason", + header: ({ column }) => { + return ( +
+ {t("reason")} + + handleFilterChange("reason", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {reasonMap[row.original.reason]} + + ); + } + }, + { + accessorKey: "actor", + header: ({ column }) => { + return ( +
+ {t("actor")} + ({ + value: actor, + label: actor + }))} + selectedValue={filters.actor} + onValueChange={(value) => + handleFilterChange("actor", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.actor ? ( + <> + {row.original.actorType == "user" ? ( + + ) : ( + + )} + {row.original.actor} + + ) : ( + <>- + )} + + ); + } + } + ]; + + const renderExpandedRow = (row: any) => { + return ( +
+
+
+ User Agent: +

+ {row.userAgent || "N/A"} +

+
+
+ Original URL: +

+ {row.originalRequestURL || "N/A"} +

+
+
+ Scheme: +

+ {row.scheme || "N/A"} +

+
+
+ Metadata: +
+                            {row.metadata
+                                ? JSON.stringify(
+                                      JSON.parse(row.metadata),
+                                      null,
+                                      2
+                                  )
+                                : "N/A"}
+                        
+
+ {row.headers && ( +
+ Headers: +
+                                {JSON.stringify(
+                                    JSON.parse(row.headers),
+                                    null,
+                                    2
+                                )}
+                            
+
+ )} + {row.query && ( +
+ Query Parameters: +
+                                {JSON.stringify(JSON.parse(row.query), null, 2)}
+                            
+
+ )} +
+
+ ); + }; + + return ( + <> + + + + + ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx index 21d601ed..28f7754b 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx @@ -56,6 +56,9 @@ import { build } from "@server/build"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; import { DomainRow } from "../../../../../../components/DomainsTable"; import { toASCII, toUnicode } from "punycode"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; +import { useUserContext } from "@app/hooks/useUserContext"; export default function GeneralForm() { const [formKey, setFormKey] = useState(0); @@ -65,6 +68,9 @@ export default function GeneralForm() { const router = useRouter(); const t = useTranslations(); const [editDomainOpen, setEditDomainOpen] = useState(false); + const {licenseStatus } = useLicenseStatusContext(); + const subscriptionStatus = useSubscriptionStatusContext(); + const {user} = useUserContext(); const { env } = useEnvContext(); diff --git a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx index 9588e0c8..5ef2ccd5 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx @@ -77,7 +77,8 @@ import { MoveRight, ArrowUp, Info, - ArrowDown + ArrowDown, + AlertTriangle } from "lucide-react"; import { ContainersSelector } from "@app/components/ContainersSelector"; import { useTranslations } from "next-intl"; @@ -115,6 +116,7 @@ import { TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; const addTargetSchema = z .object({ @@ -288,7 +290,9 @@ export default function ReverseProxyTargets(props: { ), headers: z .array(z.object({ name: z.string(), value: z.string() })) - .nullable() + .nullable(), + proxyProtocol: z.boolean().optional(), + proxyProtocolVersion: z.number().int().min(1).max(2).optional() }); const tlsSettingsSchema = z.object({ @@ -325,7 +329,9 @@ export default function ReverseProxyTargets(props: { resolver: zodResolver(proxySettingsSchema), defaultValues: { setHostHeader: resource.setHostHeader || "", - headers: resource.headers + headers: resource.headers, + proxyProtocol: resource.proxyProtocol || false, + proxyProtocolVersion: resource.proxyProtocolVersion || 1 } }); @@ -549,11 +555,11 @@ export default function ReverseProxyTargets(props: { prev.map((t) => t.targetId === target.targetId ? { - ...t, - targetId: response.data.data.targetId, - new: false, - updated: false - } + ...t, + targetId: response.data.data.targetId, + new: false, + updated: false + } : t ) ); @@ -673,11 +679,11 @@ export default function ReverseProxyTargets(props: { targets.map((target) => target.targetId === targetId ? { - ...target, - ...data, - updated: true, - siteType: site ? site.type : target.siteType - } + ...target, + ...data, + updated: true, + siteType: site ? site.type : target.siteType + } : target ) ); @@ -688,10 +694,10 @@ export default function ReverseProxyTargets(props: { targets.map((target) => target.targetId === targetId ? { - ...target, - ...config, - updated: true - } + ...target, + ...config, + updated: true + } : target ) ); @@ -800,6 +806,22 @@ export default function ReverseProxyTargets(props: { setHostHeader: proxyData.setHostHeader || null, headers: proxyData.headers || null }); + } else { + // For TCP/UDP resources, save proxy protocol settings + const proxyData = proxySettingsForm.getValues(); + + const payload = { + proxyProtocol: proxyData.proxyProtocol || false, + proxyProtocolVersion: proxyData.proxyProtocolVersion || 1 + }; + + await api.post(`/resource/${resource.resourceId}`, payload); + + updateResource({ + ...resource, + proxyProtocol: proxyData.proxyProtocol || false, + proxyProtocolVersion: proxyData.proxyProtocolVersion || 1 + }); } toast({ @@ -1064,7 +1086,7 @@ export default function ReverseProxyTargets(props: { className={cn( "w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent", !row.original.siteId && - "text-muted-foreground" + "text-muted-foreground" )} > @@ -1404,12 +1426,12 @@ export default function ReverseProxyTargets(props: { {header.isPlaceholder ? null : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} + header + .column + .columnDef + .header, + header.getContext() + )} ) )} @@ -1675,6 +1697,102 @@ export default function ReverseProxyTargets(props: { )} + {!resource.http && resource.protocol && ( + + + + {t("proxyProtocol")} + + + {t("proxyProtocolDescription")} + + + + +
+ + ( + + + { + field.onChange(val); + }} + /> + + + )} + /> + + {proxySettingsForm.watch("proxyProtocol") && ( + <> + ( + + {t("proxyProtocolVersion")} + + + + + {t("versionDescription")} + + + )} + /> + + + + + {t("warning")}: {t("proxyProtocolWarning")} + + + + )} + + +
+
+
+ )} +