mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-02 01:09:03 +02:00
Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c162fee910 | |||
| 55e1c6c270 | |||
| d33c7565bb | |||
| 4ad978d68a | |||
| 6f8a235630 | |||
| f26ba15b37 | |||
| 5d7c6449e1 | |||
| 1d0b65571a | |||
| a94107ccd1 | |||
| 72979d362a | |||
| c119104b6e | |||
| 1f0365eb27 | |||
| e51d4c98ac | |||
| be44c0606b | |||
| 140b08098b | |||
| 4c4c2fc249 | |||
| 0b92108f41 | |||
| 30321774e7 | |||
| 60ecc56841 | |||
| 261bd5fc29 | |||
| d54973ab4b | |||
| d126c73a51 | |||
| 487ff70ec6 | |||
| cb0fba5cad | |||
| 6cd818038c | |||
| 26c9d6941f | |||
| 0e17e5be97 | |||
| 8675ac44b7 | |||
| 1c6b62673b | |||
| 6eb5c75be1 | |||
| cda9ff0b4e | |||
| 5f38178154 | |||
| ab55e0a87f | |||
| 41f4cf66f8 | |||
| 6c8247b6c0 | |||
| 07c09ec617 | |||
| 573922c0b6 | |||
| ae516e57bf | |||
| c9b4776a07 | |||
| a0f0228d10 | |||
| a1066709c4 | |||
| 46a9ccc979 | |||
| 180b8236a6 | |||
| 96ca6c2d20 | |||
| 47c34f88b4 | |||
| 780d767a65 | |||
| d6d923e972 | |||
| 41139f2fd0 | |||
| ebcdeab414 | |||
| 2cc7d03ace | |||
| c36cf698c1 | |||
| 34671c6b13 | |||
| 8dfc95347f | |||
| 22db0319c2 | |||
| 39722d30af | |||
| 8aef14cf9f | |||
| 0dece5fef1 | |||
| e7f38c089f | |||
| dd0a5a359a | |||
| 1650ece0c3 | |||
| 7f94d99455 | |||
| 1f9e99219d | |||
| bc56a2bed0 | |||
| 49dcc590ce | |||
| da3e3ff33f | |||
| 69d539f107 | |||
| 872e0f9ae1 | |||
| 5b3713a72f | |||
| f02be1fdbf | |||
| 0bf04cf0cd | |||
| 7cda28d685 | |||
| 60bc74c4df | |||
| 48ab6c501f | |||
| 7a95e543d8 | |||
| b87b7c7e80 | |||
| a47a68d8e1 | |||
| ed0d6fb6b9 | |||
| a02d16fd58 | |||
| 331fee24d4 | |||
| 5bdb12dafe | |||
| e57826d6e0 | |||
| 3d4e143c1f | |||
| 10a25c184d | |||
| 906099d1e1 | |||
| 9a5824900d | |||
| f3474dac98 | |||
| 72d2c79793 | |||
| 23764feb4f | |||
| d2809fbfd1 | |||
| d00b9478a2 | |||
| 4ddf36ebcc | |||
| 7319bf84f7 | |||
| 9ec9908ed7 | |||
| 28b32fe6f7 | |||
| adfb6003d9 | |||
| 6a5ecab013 | |||
| 2c197fab9f | |||
| 65e4fe91b9 | |||
| fd0a0818c1 | |||
| 929acc5b1c | |||
| 71348f45b2 | |||
| 21eb4d2876 | |||
| 52c078a489 | |||
| 18270381c1 | |||
| 195f67c6eb | |||
| 8e938a2723 | |||
| 197f8f7ba5 | |||
| 10b528642d | |||
| 60d6fff085 | |||
| c664b3da91 | |||
| 492282e758 | |||
| 47f4aefc25 | |||
| 7c0ff9ede7 | |||
| 44e81ea979 | |||
| 56dc10330a | |||
| eb8ad6a181 | |||
| 4edd2e4d32 | |||
| 813c3abe54 | |||
| 668a04bcd2 | |||
| 048e4fc73c | |||
| 923371e5b4 | |||
| 81430ba3d3 | |||
| e4aaadc9f9 | |||
| d04740fede | |||
| 4677a0d501 | |||
| b4463f0e1a | |||
| b7c0669c38 | |||
| 152d2fb1d6 | |||
| d374b4f66e | |||
| cb3f0b49a8 | |||
| 192542629f | |||
| 84d5a4b86c |
@@ -77,7 +77,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -407,7 +407,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Login to GitHub Container Registry (for cosign)
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
days-before-stale: 14
|
||||
days-before-close: 14
|
||||
|
||||
@@ -99,6 +99,19 @@ Access private resources like SSH servers, databases, RDP, and entire network ra
|
||||
|
||||
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
|
||||
|
||||
### Identity-aware AI gateway
|
||||
|
||||
Put an identity-aware proxy in front of public cloud (OpenAI, Anthropic, Gemini, etc.) and self-hosted model servers (Ollama, vLLM, Mistral, etc.) so coding agents and AI clients call a single Pangolin URL. Publish it as a public resource with personal API keys, or keep it private on a client tunnel where the connected client is the credential for keyless access. Budgets, session history, and usage analytics sit in front of every call.
|
||||
|
||||
* Access self-hosted models (vLLM, Ollama, etc) alongside cloud models (OpenAI, Anthropic, etc) in one place
|
||||
* Keyless access by authenticating users with the Pangolin desktop client
|
||||
* Or, provide users with personal API keys
|
||||
* Control costs and token usage by setting budgets
|
||||
* Audit with detailed session history and analytics
|
||||
* Integrate AI clients and coding agents (Claude Code, Codex, OpenCode, etc)
|
||||
|
||||
<img src="public/screenshots/expanded-session-logs.png" alt="AI Session Logs" width="100%" />
|
||||
|
||||
### Give users and roles access to resources
|
||||
|
||||
Use Pangolin's built-in users or bring your own identity provider and set up role-based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
|
||||
|
||||
@@ -16,7 +16,7 @@ experimental:
|
||||
version: "{{.BadgerVersion}}"
|
||||
crowdsec: # CrowdSec plugin configuration added
|
||||
moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
version: "v1.4.4"
|
||||
version: "v1.7.1"
|
||||
|
||||
log:
|
||||
level: "INFO"
|
||||
|
||||
+8
-1
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "Научете повече за JMESPath",
|
||||
"idpJmespathLabel": "Идентификатор на пътя",
|
||||
"idpJmespathLabelDescription": "Пътят към идентификатора на потребителя в ID токена",
|
||||
"idpIdentifierChangeTitle": "Identifier Path Change Warning",
|
||||
"idpIdentifierChangeDescription": "You are about to change the identifier path. This will affect how existing users are mapped. Users who previously signed in through this identity provider may no longer be recognized as the same users.",
|
||||
"idpIdentifierChangeConfirmMessage": "I confirm",
|
||||
"idpIdentifierChangeWarningText": "This will affect how existing users are mapped",
|
||||
"idpJmespathEmailPathOptional": "Път за имейл (по избор)",
|
||||
"idpJmespathEmailPathOptionalDescription": "Пътят до имейла на потребителя в ID токена",
|
||||
"idpJmespathNamePathOptional": "Път (по избор) на име",
|
||||
@@ -1573,6 +1577,8 @@
|
||||
"search": "Търси…",
|
||||
"searchPlaceholder": "Търсене...",
|
||||
"emptySearchOptions": "Няма намерени опции",
|
||||
"ipFilterSearchPlaceholder": "Enter an IP address…",
|
||||
"ipFilterEmptyMessage": "Enter an IP address to filter by",
|
||||
"create": "Създаване",
|
||||
"orgs": "Организации",
|
||||
"loginError": "Възникна неочаквана грешка. Моля, опитайте отново.",
|
||||
@@ -2596,6 +2602,7 @@
|
||||
"createDomainType": "Тип:",
|
||||
"createDomainName": "Име:",
|
||||
"createDomainValue": "Стойност:",
|
||||
"multiSelectFilterCount": "{count} selected",
|
||||
"createDomainCnameRecords": "CNAME записи",
|
||||
"createDomainARecords": "A записи",
|
||||
"createDomainRecordNumber": "Запис {number}",
|
||||
@@ -2993,7 +3000,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Добавете CIDR диапазон (напр. 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Неуспешно зареждане на подмрежи",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Етикети за Предпочитания",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Сайтове с тези етикети ще бъдат принудени да се свържат чрез този отдалечен край.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Изберете етикети...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Търсене на етикети...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Неуспешно зареждане на етикети",
|
||||
|
||||
+8
-1
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "Další informace o cestě JMESPath",
|
||||
"idpJmespathLabel": "Cesta identifikátoru",
|
||||
"idpJmespathLabelDescription": "Cesta k identifikátoru uživatele v tokenu ID",
|
||||
"idpIdentifierChangeTitle": "Identifier Path Change Warning",
|
||||
"idpIdentifierChangeDescription": "You are about to change the identifier path. This will affect how existing users are mapped. Users who previously signed in through this identity provider may no longer be recognized as the same users.",
|
||||
"idpIdentifierChangeConfirmMessage": "I confirm",
|
||||
"idpIdentifierChangeWarningText": "This will affect how existing users are mapped",
|
||||
"idpJmespathEmailPathOptional": "Cesta e-mailu (volitelné)",
|
||||
"idpJmespathEmailPathOptionalDescription": "Cesta k e-mailu uživatele v ID tokenu",
|
||||
"idpJmespathNamePathOptional": "Cesta k názvu (volitelné)",
|
||||
@@ -1573,6 +1577,8 @@
|
||||
"search": "Vyhledávání…",
|
||||
"searchPlaceholder": "Hledat...",
|
||||
"emptySearchOptions": "Nebyly nalezeny žádné možnosti",
|
||||
"ipFilterSearchPlaceholder": "Enter an IP address…",
|
||||
"ipFilterEmptyMessage": "Enter an IP address to filter by",
|
||||
"create": "Vytvořit",
|
||||
"orgs": "Organizace",
|
||||
"loginError": "Došlo k neočekávané chybě. Zkuste to prosím znovu.",
|
||||
@@ -2596,6 +2602,7 @@
|
||||
"createDomainType": "Typ:",
|
||||
"createDomainName": "Jméno:",
|
||||
"createDomainValue": "Hodnota:",
|
||||
"multiSelectFilterCount": "{count} selected",
|
||||
"createDomainCnameRecords": "Záznamy CNAME",
|
||||
"createDomainARecords": "Záznamy",
|
||||
"createDomainRecordNumber": "Nahrát {number}",
|
||||
@@ -2993,7 +3000,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Přidejte rozsah CIDR (např. 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Nepodařilo se načíst podsítě",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Názvy preferencí",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Weby s těmito názvy budou nuceny připojit se tímto vzdáleným výstupním uzlem.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Vyberte názvy...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Hledat názvy...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Nepodařilo se načíst názvy",
|
||||
|
||||
+8
-1
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "Læs mere om JMESPath",
|
||||
"idpJmespathLabel": "Identifikatorsti",
|
||||
"idpJmespathLabelDescription": "Stien til brugeridentifikatoren i ID-tokenet",
|
||||
"idpIdentifierChangeTitle": "Identifier Path Change Warning",
|
||||
"idpIdentifierChangeDescription": "You are about to change the identifier path. This will affect how existing users are mapped. Users who previously signed in through this identity provider may no longer be recognized as the same users.",
|
||||
"idpIdentifierChangeConfirmMessage": "I confirm",
|
||||
"idpIdentifierChangeWarningText": "This will affect how existing users are mapped",
|
||||
"idpJmespathEmailPathOptional": "E-mailsti (Valgfrit)",
|
||||
"idpJmespathEmailPathOptionalDescription": "Stien til brugerens e-mailadresse i ID-tokenet",
|
||||
"idpJmespathNamePathOptional": "Navn Sti (Valgfrit)",
|
||||
@@ -1573,6 +1577,8 @@
|
||||
"search": "Søg…",
|
||||
"searchPlaceholder": "Søg...",
|
||||
"emptySearchOptions": "Ingen valg fundet",
|
||||
"ipFilterSearchPlaceholder": "Enter an IP address…",
|
||||
"ipFilterEmptyMessage": "Enter an IP address to filter by",
|
||||
"create": "Opret",
|
||||
"orgs": "Organisationer",
|
||||
"loginError": "Der opstod en uventet fejl. Prøv venligst igen.",
|
||||
@@ -2596,6 +2602,7 @@
|
||||
"createDomainType": "Type:",
|
||||
"createDomainName": "Navn:",
|
||||
"createDomainValue": "Værdi:",
|
||||
"multiSelectFilterCount": "{count} selected",
|
||||
"createDomainCnameRecords": "CNAME-poster",
|
||||
"createDomainARecords": "A-poster",
|
||||
"createDomainRecordNumber": "Post {number}",
|
||||
@@ -2993,7 +3000,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Tilføj et CIDR-område (f.eks. 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Kunne ikke indlæse subnets",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Præference Etiketter",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites med disse etiketter vil blive tvunget til at oprette forbindelse gennem denne fjerne exit-node.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Vælg etiketter...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Søg efter etiketter...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Kunne ikke indlæse etiketter",
|
||||
|
||||
+8
-1
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "Mehr über JMESPath erfahren",
|
||||
"idpJmespathLabel": "Identifikationspfad",
|
||||
"idpJmespathLabelDescription": "Der JMESPath zum Benutzeridentifikator im ID-Token",
|
||||
"idpIdentifierChangeTitle": "Identifier Path Change Warning",
|
||||
"idpIdentifierChangeDescription": "You are about to change the identifier path. This will affect how existing users are mapped. Users who previously signed in through this identity provider may no longer be recognized as the same users.",
|
||||
"idpIdentifierChangeConfirmMessage": "I confirm",
|
||||
"idpIdentifierChangeWarningText": "This will affect how existing users are mapped",
|
||||
"idpJmespathEmailPathOptional": "E-Mail-Pfad (Optional)",
|
||||
"idpJmespathEmailPathOptionalDescription": "Der JMESPath zur E-Mail-Adresse des Benutzers im ID-Token",
|
||||
"idpJmespathNamePathOptional": "Namenspfad (Optional)",
|
||||
@@ -1573,6 +1577,8 @@
|
||||
"search": "Suche…",
|
||||
"searchPlaceholder": "Suche...",
|
||||
"emptySearchOptions": "Keine Optionen gefunden",
|
||||
"ipFilterSearchPlaceholder": "Enter an IP address…",
|
||||
"ipFilterEmptyMessage": "Enter an IP address to filter by",
|
||||
"create": "Erstellen",
|
||||
"orgs": "Organisationen",
|
||||
"loginError": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut.",
|
||||
@@ -2596,6 +2602,7 @@
|
||||
"createDomainType": "Typ:",
|
||||
"createDomainName": "Name:",
|
||||
"createDomainValue": "Wert:",
|
||||
"multiSelectFilterCount": "{count} selected",
|
||||
"createDomainCnameRecords": "CNAME-Einträge",
|
||||
"createDomainARecords": "A-Aufzeichnungen",
|
||||
"createDomainRecordNumber": "Eintrag {number}",
|
||||
@@ -2993,7 +3000,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Fügen Sie einen CIDR-Bereich hinzu (z.B. 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Fehler beim Laden der Subnetze",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Präferenzetiketten",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Standorte mit diesen Etiketten werden gezwungen, über diesen Remote Exit Node zu verbinden.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Etiketten auswählen...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Etiketten suchen...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Fehler beim Laden der Etiketten",
|
||||
|
||||
@@ -1573,6 +1573,8 @@
|
||||
"search": "Search…",
|
||||
"searchPlaceholder": "Search...",
|
||||
"emptySearchOptions": "No options found",
|
||||
"ipFilterSearchPlaceholder": "Enter an IP address…",
|
||||
"ipFilterEmptyMessage": "Enter an IP address to filter by",
|
||||
"create": "Create",
|
||||
"orgs": "Organizations",
|
||||
"loginError": "An unexpected error occurred. Please try again.",
|
||||
@@ -2596,6 +2598,7 @@
|
||||
"createDomainType": "Type:",
|
||||
"createDomainName": "Name:",
|
||||
"createDomainValue": "Value:",
|
||||
"multiSelectFilterCount": "{count} selected",
|
||||
"createDomainCnameRecords": "CNAME Records",
|
||||
"createDomainARecords": "A Records",
|
||||
"createDomainRecordNumber": "Record {number}",
|
||||
|
||||
+8
-1
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "Más información sobre JMESPath",
|
||||
"idpJmespathLabel": "Ruta del identificador",
|
||||
"idpJmespathLabelDescription": "La ruta al identificador de usuario en el token de ID",
|
||||
"idpIdentifierChangeTitle": "Identifier Path Change Warning",
|
||||
"idpIdentifierChangeDescription": "You are about to change the identifier path. This will affect how existing users are mapped. Users who previously signed in through this identity provider may no longer be recognized as the same users.",
|
||||
"idpIdentifierChangeConfirmMessage": "I confirm",
|
||||
"idpIdentifierChangeWarningText": "This will affect how existing users are mapped",
|
||||
"idpJmespathEmailPathOptional": "Ruta de correo (opcional)",
|
||||
"idpJmespathEmailPathOptionalDescription": "La ruta al correo electrónico del usuario en el token de ID",
|
||||
"idpJmespathNamePathOptional": "Ruta del nombre (opcional)",
|
||||
@@ -1573,6 +1577,8 @@
|
||||
"search": "Buscar…",
|
||||
"searchPlaceholder": "Buscar...",
|
||||
"emptySearchOptions": "No se encontraron opciones",
|
||||
"ipFilterSearchPlaceholder": "Enter an IP address…",
|
||||
"ipFilterEmptyMessage": "Enter an IP address to filter by",
|
||||
"create": "Crear",
|
||||
"orgs": "Organizaciones",
|
||||
"loginError": "Ocurrió un error inesperado. Por favor, inténtelo de nuevo.",
|
||||
@@ -2596,6 +2602,7 @@
|
||||
"createDomainType": "Tipo:",
|
||||
"createDomainName": "Nombre:",
|
||||
"createDomainValue": "Valor:",
|
||||
"multiSelectFilterCount": "{count} selected",
|
||||
"createDomainCnameRecords": "Registros CNAME",
|
||||
"createDomainARecords": "Registros A",
|
||||
"createDomainRecordNumber": "Registro {number}",
|
||||
@@ -2993,7 +3000,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Añadir un rango CIDR (e.g. 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Error al cargar las subredes",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Etiquetas de Preferencias",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Los sitios con estas etiquetas se verán obligados a conectarse a través de este nodo de salida remoto.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Seleccionar etiquetas...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Buscar etiquetas...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Error al cargar las etiquetas",
|
||||
|
||||
+8
-1
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "En savoir plus sur JMESPath",
|
||||
"idpJmespathLabel": "Chemin d'identification",
|
||||
"idpJmespathLabelDescription": "Le JMESPath vers l'identifiant de l'utilisateur dans le jeton ID",
|
||||
"idpIdentifierChangeTitle": "Identifier Path Change Warning",
|
||||
"idpIdentifierChangeDescription": "You are about to change the identifier path. This will affect how existing users are mapped. Users who previously signed in through this identity provider may no longer be recognized as the same users.",
|
||||
"idpIdentifierChangeConfirmMessage": "I confirm",
|
||||
"idpIdentifierChangeWarningText": "This will affect how existing users are mapped",
|
||||
"idpJmespathEmailPathOptional": "Chemin de l'email (Optionnel)",
|
||||
"idpJmespathEmailPathOptionalDescription": "Le JMESPath vers l'email de l'utilisateur dans le jeton ID",
|
||||
"idpJmespathNamePathOptional": "Chemin du nom (Optionnel)",
|
||||
@@ -1573,6 +1577,8 @@
|
||||
"search": "Rechercher…",
|
||||
"searchPlaceholder": "Recherche...",
|
||||
"emptySearchOptions": "Aucune option trouvée",
|
||||
"ipFilterSearchPlaceholder": "Enter an IP address…",
|
||||
"ipFilterEmptyMessage": "Enter an IP address to filter by",
|
||||
"create": "Créer",
|
||||
"orgs": "Organisations",
|
||||
"loginError": "Une erreur inattendue s'est produite. Veuillez réessayer.",
|
||||
@@ -2596,6 +2602,7 @@
|
||||
"createDomainType": "Type :",
|
||||
"createDomainName": "Nom :",
|
||||
"createDomainValue": "Valeur :",
|
||||
"multiSelectFilterCount": "{count} selected",
|
||||
"createDomainCnameRecords": "Enregistrements CNAME",
|
||||
"createDomainARecords": "Enregistrements A",
|
||||
"createDomainRecordNumber": "Enregistrement {number}",
|
||||
@@ -2993,7 +3000,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Ajouter une plage CIDR (par exemple 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Échec du chargement des sous-réseaux",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Étiquettes de préférences",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Les sites avec ces étiquettes devront se connecter via ce nœud de sortie distant.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Sélectionner des étiquettes...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Chercher des étiquettes...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Échec du chargement des étiquettes",
|
||||
|
||||
+8
-1
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "Scopri di più su JMESPath",
|
||||
"idpJmespathLabel": "Percorso Identificativo",
|
||||
"idpJmespathLabelDescription": "Il JMESPath per l'identificatore dell'utente nel token ID",
|
||||
"idpIdentifierChangeTitle": "Identifier Path Change Warning",
|
||||
"idpIdentifierChangeDescription": "You are about to change the identifier path. This will affect how existing users are mapped. Users who previously signed in through this identity provider may no longer be recognized as the same users.",
|
||||
"idpIdentifierChangeConfirmMessage": "I confirm",
|
||||
"idpIdentifierChangeWarningText": "This will affect how existing users are mapped",
|
||||
"idpJmespathEmailPathOptional": "Percorso Email (Opzionale)",
|
||||
"idpJmespathEmailPathOptionalDescription": "Il JMESPath per l'email dell'utente nel token ID",
|
||||
"idpJmespathNamePathOptional": "Percorso Nome (Opzionale)",
|
||||
@@ -1573,6 +1577,8 @@
|
||||
"search": "Cerca…",
|
||||
"searchPlaceholder": "Cerca...",
|
||||
"emptySearchOptions": "Nessuna opzione trovata",
|
||||
"ipFilterSearchPlaceholder": "Enter an IP address…",
|
||||
"ipFilterEmptyMessage": "Enter an IP address to filter by",
|
||||
"create": "Crea",
|
||||
"orgs": "Organizzazioni",
|
||||
"loginError": "Si è verificato un errore imprevisto. Riprova.",
|
||||
@@ -2596,6 +2602,7 @@
|
||||
"createDomainType": "Tipo:",
|
||||
"createDomainName": "Nome:",
|
||||
"createDomainValue": "Valore:",
|
||||
"multiSelectFilterCount": "{count} selected",
|
||||
"createDomainCnameRecords": "Record CNAME",
|
||||
"createDomainARecords": "Record A",
|
||||
"createDomainRecordNumber": "Record {number}",
|
||||
@@ -2993,7 +3000,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Aggiungi un intervallo CIDR (ad esempio 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Caricamento sottoreti fallito",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Etichette Preferenze",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "I siti con queste etichette saranno collegati attraverso questo nodo di uscita remoto.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Seleziona etichette...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Cerca etichette...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Caricamento etichette fallito",
|
||||
|
||||
+8
-1
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "JMESPath에 대해 더 알아보기",
|
||||
"idpJmespathLabel": "식별자 경로",
|
||||
"idpJmespathLabelDescription": "ID 토큰에서 사용자 식별자에 대한 경로",
|
||||
"idpIdentifierChangeTitle": "Identifier Path Change Warning",
|
||||
"idpIdentifierChangeDescription": "You are about to change the identifier path. This will affect how existing users are mapped. Users who previously signed in through this identity provider may no longer be recognized as the same users.",
|
||||
"idpIdentifierChangeConfirmMessage": "I confirm",
|
||||
"idpIdentifierChangeWarningText": "This will affect how existing users are mapped",
|
||||
"idpJmespathEmailPathOptional": "이메일 경로 (선택 사항)",
|
||||
"idpJmespathEmailPathOptionalDescription": "ID 토큰에서 사용자의 이메일 경로",
|
||||
"idpJmespathNamePathOptional": "이름 경로 (선택 사항)",
|
||||
@@ -1573,6 +1577,8 @@
|
||||
"search": "검색…",
|
||||
"searchPlaceholder": "검색...",
|
||||
"emptySearchOptions": "옵션이 없습니다",
|
||||
"ipFilterSearchPlaceholder": "Enter an IP address…",
|
||||
"ipFilterEmptyMessage": "Enter an IP address to filter by",
|
||||
"create": "생성",
|
||||
"orgs": "조직",
|
||||
"loginError": "예기치 않은 오류가 발생했습니다. 다시 시도해주세요.",
|
||||
@@ -2596,6 +2602,7 @@
|
||||
"createDomainType": "유형:",
|
||||
"createDomainName": "이름:",
|
||||
"createDomainValue": "값:",
|
||||
"multiSelectFilterCount": "{count} selected",
|
||||
"createDomainCnameRecords": "CNAME 레코드",
|
||||
"createDomainARecords": "A 레코드",
|
||||
"createDomainRecordNumber": "레코드 {number}",
|
||||
@@ -2993,7 +3000,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "CIDR 범위 추가 (예: 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "서브넷 로드 실패",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "우선순위 레이블",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "이 레이블이 있는 사이트는 이 원격 출구 노드를 통해 연결됩니다.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "레이블 선택...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "레이블 검색...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "레이블 로드 실패",
|
||||
|
||||
+8
-1
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "Lær mer om JMESPath",
|
||||
"idpJmespathLabel": "Identifikatorsti",
|
||||
"idpJmespathLabelDescription": "Stien til brukeridentifikatoren i ID-tokenet",
|
||||
"idpIdentifierChangeTitle": "Identifier Path Change Warning",
|
||||
"idpIdentifierChangeDescription": "You are about to change the identifier path. This will affect how existing users are mapped. Users who previously signed in through this identity provider may no longer be recognized as the same users.",
|
||||
"idpIdentifierChangeConfirmMessage": "I confirm",
|
||||
"idpIdentifierChangeWarningText": "This will affect how existing users are mapped",
|
||||
"idpJmespathEmailPathOptional": "E-poststi (Valgfritt)",
|
||||
"idpJmespathEmailPathOptionalDescription": "Stien til brukerens e-postadresse i ID-tokenet",
|
||||
"idpJmespathNamePathOptional": "Navn Sti (Valgfritt)",
|
||||
@@ -1573,6 +1577,8 @@
|
||||
"search": "Søk…",
|
||||
"searchPlaceholder": "Søk...",
|
||||
"emptySearchOptions": "Ingen valg funnet",
|
||||
"ipFilterSearchPlaceholder": "Enter an IP address…",
|
||||
"ipFilterEmptyMessage": "Enter an IP address to filter by",
|
||||
"create": "Opprett",
|
||||
"orgs": "Organisasjoner",
|
||||
"loginError": "En uventet feil oppstod. Vennligst prøv igjen.",
|
||||
@@ -2596,6 +2602,7 @@
|
||||
"createDomainType": "Type:",
|
||||
"createDomainName": "Navn:",
|
||||
"createDomainValue": "Verdi:",
|
||||
"multiSelectFilterCount": "{count} selected",
|
||||
"createDomainCnameRecords": "CNAME-oppføringer",
|
||||
"createDomainARecords": "A-oppføringer",
|
||||
"createDomainRecordNumber": "Oppføring {number}",
|
||||
@@ -2993,7 +3000,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Legg til et CIDR-område (f.eks. 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Feil ved lasting av subnett",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Preferanseetiketter",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Områder med disse etikettene vil bli tvunget til å koble gjennom denne fjerne utgangsnoden.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Velg etiketter...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Søk etiketter...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Feil ved lasting av etiketter",
|
||||
|
||||
+8
-1
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "Meer informatie over JMESPath",
|
||||
"idpJmespathLabel": "ID pad",
|
||||
"idpJmespathLabelDescription": "Het pad naar het gebruiker-id in het ID-token",
|
||||
"idpIdentifierChangeTitle": "Identifier Path Change Warning",
|
||||
"idpIdentifierChangeDescription": "You are about to change the identifier path. This will affect how existing users are mapped. Users who previously signed in through this identity provider may no longer be recognized as the same users.",
|
||||
"idpIdentifierChangeConfirmMessage": "I confirm",
|
||||
"idpIdentifierChangeWarningText": "This will affect how existing users are mapped",
|
||||
"idpJmespathEmailPathOptional": "E-mail pad (optioneel)",
|
||||
"idpJmespathEmailPathOptionalDescription": "Het pad naar het e-mailadres van de gebruiker in het ID-token",
|
||||
"idpJmespathNamePathOptional": "Naam pad (optioneel)",
|
||||
@@ -1573,6 +1577,8 @@
|
||||
"search": "Zoeken…",
|
||||
"searchPlaceholder": "Zoeken...",
|
||||
"emptySearchOptions": "Geen opties gevonden",
|
||||
"ipFilterSearchPlaceholder": "Enter an IP address…",
|
||||
"ipFilterEmptyMessage": "Enter an IP address to filter by",
|
||||
"create": "Aanmaken",
|
||||
"orgs": "Organisaties",
|
||||
"loginError": "Er is een onverwachte fout opgetreden. Probeer het opnieuw.",
|
||||
@@ -2596,6 +2602,7 @@
|
||||
"createDomainType": "Type:",
|
||||
"createDomainName": "Naam:",
|
||||
"createDomainValue": "Waarde:",
|
||||
"multiSelectFilterCount": "{count} selected",
|
||||
"createDomainCnameRecords": "CNAME-records",
|
||||
"createDomainARecords": "A Records",
|
||||
"createDomainRecordNumber": "Record {number}",
|
||||
@@ -2993,7 +3000,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Voeg een CIDR-bereik toe (bijv. 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Kon subnets niet laden",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Voorkeurslabels",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites met deze labels worden verplicht om verbinding te maken via dit externe exit-knooppunt.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Selecteer labels...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Labels zoeken...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Kon labels niet laden",
|
||||
|
||||
+8
-1
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "Dowiedz się więcej o JMESPath",
|
||||
"idpJmespathLabel": "Ścieżka identyfikatora",
|
||||
"idpJmespathLabelDescription": "JMESPath do identyfikatora użytkownika w tokenie ID",
|
||||
"idpIdentifierChangeTitle": "Identifier Path Change Warning",
|
||||
"idpIdentifierChangeDescription": "You are about to change the identifier path. This will affect how existing users are mapped. Users who previously signed in through this identity provider may no longer be recognized as the same users.",
|
||||
"idpIdentifierChangeConfirmMessage": "I confirm",
|
||||
"idpIdentifierChangeWarningText": "This will affect how existing users are mapped",
|
||||
"idpJmespathEmailPathOptional": "Ścieżka email (Opcjonalnie)",
|
||||
"idpJmespathEmailPathOptionalDescription": "JMESPath do emaila użytkownika w tokenie ID",
|
||||
"idpJmespathNamePathOptional": "Ścieżka nazwy (Opcjonalnie)",
|
||||
@@ -1573,6 +1577,8 @@
|
||||
"search": "Szukaj…",
|
||||
"searchPlaceholder": "Szukaj...",
|
||||
"emptySearchOptions": "Nie znaleziono opcji",
|
||||
"ipFilterSearchPlaceholder": "Enter an IP address…",
|
||||
"ipFilterEmptyMessage": "Enter an IP address to filter by",
|
||||
"create": "Utwórz",
|
||||
"orgs": "Organizacje",
|
||||
"loginError": "Wystąpił nieoczekiwany błąd. Spróbuj ponownie.",
|
||||
@@ -2596,6 +2602,7 @@
|
||||
"createDomainType": "Typ:",
|
||||
"createDomainName": "Nazwa:",
|
||||
"createDomainValue": "Wartość:",
|
||||
"multiSelectFilterCount": "{count} selected",
|
||||
"createDomainCnameRecords": "Rekordy CNAME",
|
||||
"createDomainARecords": "Rekordy A",
|
||||
"createDomainRecordNumber": "Rekord {number}",
|
||||
@@ -2993,7 +3000,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Dodaj zakres CIDR (np. 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Nie udało się załadować podsieci",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Etykiety preferencji",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Strony z tymi etykietami będą zmuszone do połączenia się przez ten zdalny węzeł wyjściowy.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Wybierz etykiety...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Szukaj etykiet...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Nie udało się załadować etykiet",
|
||||
|
||||
+8
-1
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "Saiba mais sobre JMESPath",
|
||||
"idpJmespathLabel": "Caminho do Identificador",
|
||||
"idpJmespathLabelDescription": "O JMESPath para o identificador do utilizador no token ID",
|
||||
"idpIdentifierChangeTitle": "Identifier Path Change Warning",
|
||||
"idpIdentifierChangeDescription": "You are about to change the identifier path. This will affect how existing users are mapped. Users who previously signed in through this identity provider may no longer be recognized as the same users.",
|
||||
"idpIdentifierChangeConfirmMessage": "I confirm",
|
||||
"idpIdentifierChangeWarningText": "This will affect how existing users are mapped",
|
||||
"idpJmespathEmailPathOptional": "Caminho do Email (Opcional)",
|
||||
"idpJmespathEmailPathOptionalDescription": "O JMESPath para o email do utilizador no token ID",
|
||||
"idpJmespathNamePathOptional": "Caminho do Nome (Opcional)",
|
||||
@@ -1573,6 +1577,8 @@
|
||||
"search": "Pesquisar…",
|
||||
"searchPlaceholder": "Buscar...",
|
||||
"emptySearchOptions": "Nenhuma opção encontrada",
|
||||
"ipFilterSearchPlaceholder": "Enter an IP address…",
|
||||
"ipFilterEmptyMessage": "Enter an IP address to filter by",
|
||||
"create": "Criar",
|
||||
"orgs": "Organizações",
|
||||
"loginError": "Ocorreu um erro inesperado. Por favor, tente novamente.",
|
||||
@@ -2596,6 +2602,7 @@
|
||||
"createDomainType": "Tipo:",
|
||||
"createDomainName": "Nome:",
|
||||
"createDomainValue": "Valor:",
|
||||
"multiSelectFilterCount": "{count} selected",
|
||||
"createDomainCnameRecords": "Registros CNAME",
|
||||
"createDomainARecords": "Registros A",
|
||||
"createDomainRecordNumber": "Registrar {number}",
|
||||
@@ -2993,7 +3000,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Adicione um intervalo de CIDR (por exemplo, 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Falha ao carregar sub-redes",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Etiquetas de Preferência",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Os sites com essas etiquetas serão forçados a se conectar através deste nó de saída remoto.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Selecionar etiquetas...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Pesquisar etiquetas...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Falha ao carregar etiquetas",
|
||||
|
||||
+8
-1
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "Узнать больше о JMESPath",
|
||||
"idpJmespathLabel": "Путь идентификатора",
|
||||
"idpJmespathLabelDescription": "Путь к идентификатору пользователя в ID токене",
|
||||
"idpIdentifierChangeTitle": "Identifier Path Change Warning",
|
||||
"idpIdentifierChangeDescription": "You are about to change the identifier path. This will affect how existing users are mapped. Users who previously signed in through this identity provider may no longer be recognized as the same users.",
|
||||
"idpIdentifierChangeConfirmMessage": "I confirm",
|
||||
"idpIdentifierChangeWarningText": "This will affect how existing users are mapped",
|
||||
"idpJmespathEmailPathOptional": "Путь к email (необязательно)",
|
||||
"idpJmespathEmailPathOptionalDescription": "Путь к email пользователя в ID токене",
|
||||
"idpJmespathNamePathOptional": "Путь к имени (необязательно)",
|
||||
@@ -1573,6 +1577,8 @@
|
||||
"search": "Поиск…",
|
||||
"searchPlaceholder": "Поиск...",
|
||||
"emptySearchOptions": "Опции не найдены",
|
||||
"ipFilterSearchPlaceholder": "Enter an IP address…",
|
||||
"ipFilterEmptyMessage": "Enter an IP address to filter by",
|
||||
"create": "Создать",
|
||||
"orgs": "Организации",
|
||||
"loginError": "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз.",
|
||||
@@ -2596,6 +2602,7 @@
|
||||
"createDomainType": "Тип:",
|
||||
"createDomainName": "Имя:",
|
||||
"createDomainValue": "Значение:",
|
||||
"multiSelectFilterCount": "{count} selected",
|
||||
"createDomainCnameRecords": "CNAME Записи",
|
||||
"createDomainARecords": "A Записи",
|
||||
"createDomainRecordNumber": "Запись {number}",
|
||||
@@ -2993,7 +3000,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Добавить диапазон CIDR (например, 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Не удалось загрузить подсети",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Этикетки предпочтений",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Сайты с этими метками будут обязаны подключаться через этот удаленный узел выхода.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Выберите метки...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Поиск меток...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Не удалось загрузить метки",
|
||||
|
||||
+8
-1
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "JMESPath hakkında daha fazla bilgi edinin",
|
||||
"idpJmespathLabel": "Tanımlayıcı Yolu",
|
||||
"idpJmespathLabelDescription": "The JMESPath to the user identifier in the ID token",
|
||||
"idpIdentifierChangeTitle": "Identifier Path Change Warning",
|
||||
"idpIdentifierChangeDescription": "You are about to change the identifier path. This will affect how existing users are mapped. Users who previously signed in through this identity provider may no longer be recognized as the same users.",
|
||||
"idpIdentifierChangeConfirmMessage": "I confirm",
|
||||
"idpIdentifierChangeWarningText": "This will affect how existing users are mapped",
|
||||
"idpJmespathEmailPathOptional": "E-posta Yolu (İsteğe Bağlı)",
|
||||
"idpJmespathEmailPathOptionalDescription": "The JMESPath to the user's email in the ID token",
|
||||
"idpJmespathNamePathOptional": "Ad Yolu (İsteğe Bağlı)",
|
||||
@@ -1573,6 +1577,8 @@
|
||||
"search": "Ara…",
|
||||
"searchPlaceholder": "Ara...",
|
||||
"emptySearchOptions": "Seçenek bulunamadı",
|
||||
"ipFilterSearchPlaceholder": "Enter an IP address…",
|
||||
"ipFilterEmptyMessage": "Enter an IP address to filter by",
|
||||
"create": "Oluştur",
|
||||
"orgs": "Organizasyonlar",
|
||||
"loginError": "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.",
|
||||
@@ -2596,6 +2602,7 @@
|
||||
"createDomainType": "Tür:",
|
||||
"createDomainName": "Ad:",
|
||||
"createDomainValue": "Değer:",
|
||||
"multiSelectFilterCount": "{count} selected",
|
||||
"createDomainCnameRecords": "CNAME Kayıtları",
|
||||
"createDomainARecords": "A Kayıtları",
|
||||
"createDomainRecordNumber": "Kayıt {number}",
|
||||
@@ -2993,7 +3000,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Bir CIDR aralığı ekle (örneğin, 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Alt ağlar yüklenemedi",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Tercih Etiketleri",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Bu etiketlere sahip siteler, bu uzak çıkış düğümü üzerinden bağlantı kurmaya zorlanacaktır.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Etiketleri seç...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Etiketleri ara...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Etiketler yüklenemedi",
|
||||
|
||||
+8
-1
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "了解更多 JMESPath 信息",
|
||||
"idpJmespathLabel": "标识符路径",
|
||||
"idpJmespathLabelDescription": "ID 令牌中用户标识符的路径",
|
||||
"idpIdentifierChangeTitle": "Identifier Path Change Warning",
|
||||
"idpIdentifierChangeDescription": "You are about to change the identifier path. This will affect how existing users are mapped. Users who previously signed in through this identity provider may no longer be recognized as the same users.",
|
||||
"idpIdentifierChangeConfirmMessage": "I confirm",
|
||||
"idpIdentifierChangeWarningText": "This will affect how existing users are mapped",
|
||||
"idpJmespathEmailPathOptional": "邮箱路径(可选)",
|
||||
"idpJmespathEmailPathOptionalDescription": "ID 令牌中用户邮箱的路径",
|
||||
"idpJmespathNamePathOptional": "用户名路径(可选)",
|
||||
@@ -1573,6 +1577,8 @@
|
||||
"search": "搜索…",
|
||||
"searchPlaceholder": "搜索...",
|
||||
"emptySearchOptions": "未找到选项",
|
||||
"ipFilterSearchPlaceholder": "Enter an IP address…",
|
||||
"ipFilterEmptyMessage": "Enter an IP address to filter by",
|
||||
"create": "创建",
|
||||
"orgs": "组织",
|
||||
"loginError": "发生意外错误。请重试。",
|
||||
@@ -2596,6 +2602,7 @@
|
||||
"createDomainType": "类型:",
|
||||
"createDomainName": "名称:",
|
||||
"createDomainValue": "值:",
|
||||
"multiSelectFilterCount": "{count} selected",
|
||||
"createDomainCnameRecords": "CNAME 记录",
|
||||
"createDomainARecords": "A记录",
|
||||
"createDomainRecordNumber": "记录 {number}",
|
||||
@@ -2993,7 +3000,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "添加CIDR范围(例如10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "无法加载子网",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "首选标签",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "拥有这些标签的站点将强制通过此远程出口节点连接。",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "选择标签……",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "搜索标签……",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "无法加载标签",
|
||||
|
||||
+2213
-788
File diff suppressed because it is too large
Load Diff
Generated
+394
-307
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -94,12 +94,12 @@
|
||||
"input-otp": "1.4.2",
|
||||
"ioredis": "5.11.0",
|
||||
"jmespath": "0.16.0",
|
||||
"js-yaml": "4.3.0",
|
||||
"js-yaml": "4.3.1",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lucide-react": "1.17.0",
|
||||
"maxmind": "5.0.6",
|
||||
"moment": "2.30.1",
|
||||
"next": "16.2.11",
|
||||
"next": "16.3.1",
|
||||
"next-intl": "4.13.0",
|
||||
"next-themes": "0.4.6",
|
||||
"nextjs-toploader": "3.9.17",
|
||||
@@ -139,7 +139,7 @@
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "1.69.1",
|
||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||
"@react-email/ui": "^6.5.0",
|
||||
"@react-email/ui": "^6.9.2",
|
||||
"@tailwindcss/postcss": "4.3.0",
|
||||
"@tanstack/react-query-devtools": "5.100.14",
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
@@ -170,7 +170,7 @@
|
||||
"esbuild-node-externals": "1.22.0",
|
||||
"eslint": "10.4.0",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"postcss": "8.5.15",
|
||||
"postcss": "8.5.23",
|
||||
"prettier": "3.8.3",
|
||||
"react-email": "6.5.0",
|
||||
"tailwindcss": "4.3.0",
|
||||
@@ -182,6 +182,6 @@
|
||||
"overrides": {
|
||||
"esbuild": "0.28.0",
|
||||
"dompurify": "3.4.0",
|
||||
"postcss": "8.5.15"
|
||||
"postcss": "8.5.23"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 790 KiB |
@@ -262,7 +262,9 @@ export const resourceAiModels = pgTable(
|
||||
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
|
||||
);
|
||||
|
||||
export const labels = pgTable("labels", {
|
||||
export const labels = pgTable(
|
||||
"labels",
|
||||
{
|
||||
labelId: serial("labelId").primaryKey(),
|
||||
name: varchar("name").notNull(),
|
||||
color: varchar("color").notNull(),
|
||||
@@ -271,7 +273,9 @@ export const labels = pgTable("labels", {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
});
|
||||
},
|
||||
(t) => [index("idx_labels_orgid").on(t.orgId)]
|
||||
);
|
||||
|
||||
export const launcherViews = pgTable("launcherViews", {
|
||||
viewId: serial("viewId").primaryKey(),
|
||||
@@ -693,7 +697,9 @@ export const twoFactorBackupCodes = pgTable("twoFactorBackupCodes", {
|
||||
codeHash: varchar("codeHash").notNull()
|
||||
});
|
||||
|
||||
export const sessions = pgTable("session", {
|
||||
export const sessions = pgTable(
|
||||
"session",
|
||||
{
|
||||
sessionId: varchar("id").primaryKey(),
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
@@ -701,7 +707,9 @@ export const sessions = pgTable("session", {
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull(),
|
||||
issuedAt: bigint("issuedAt", { mode: "number" }),
|
||||
deviceAuthUsed: boolean("deviceAuthUsed").notNull().default(false)
|
||||
});
|
||||
},
|
||||
(t) => [index("idx_sessions_userid").on(t.userId)]
|
||||
);
|
||||
|
||||
export const newtSessions = pgTable("newtSession", {
|
||||
sessionId: varchar("id").primaryKey(),
|
||||
@@ -711,7 +719,9 @@ export const newtSessions = pgTable("newtSession", {
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
||||
});
|
||||
|
||||
export const userOrgs = pgTable("userOrgs", {
|
||||
export const userOrgs = pgTable(
|
||||
"userOrgs",
|
||||
{
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
@@ -723,7 +733,12 @@ export const userOrgs = pgTable("userOrgs", {
|
||||
isOwner: boolean("isOwner").notNull().default(false),
|
||||
autoProvisioned: boolean("autoProvisioned").default(false),
|
||||
pamUsername: varchar("pamUsername") // cleaned username for ssh and such
|
||||
});
|
||||
},
|
||||
(t) => [
|
||||
index("idx_userOrgs_userid").on(t.userId),
|
||||
index("idx_userOrgs_orgid").on(t.orgId)
|
||||
]
|
||||
);
|
||||
|
||||
export const emailVerificationCodes = pgTable("emailVerificationCodes", {
|
||||
codeId: serial("id").primaryKey(),
|
||||
@@ -751,7 +766,9 @@ export const actions = pgTable("actions", {
|
||||
description: varchar("description")
|
||||
});
|
||||
|
||||
export const roles = pgTable("roles", {
|
||||
export const roles = pgTable(
|
||||
"roles",
|
||||
{
|
||||
roleId: serial("roleId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
@@ -766,7 +783,9 @@ export const roles = pgTable("roles", {
|
||||
sshSudoCommands: text("sshSudoCommands").default("[]"),
|
||||
sshCreateHomeDir: boolean("sshCreateHomeDir").default(true),
|
||||
sshUnixGroups: text("sshUnixGroups").default("[]")
|
||||
});
|
||||
},
|
||||
(t) => [index("idx_roles_orgid").on(t.orgId)]
|
||||
);
|
||||
|
||||
export const userOrgRoles = pgTable(
|
||||
"userOrgRoles",
|
||||
@@ -1409,7 +1428,10 @@ export const olms = pgTable(
|
||||
}),
|
||||
archived: boolean("archived").notNull().default(false)
|
||||
},
|
||||
(t) => [index("idx_olms_clientid").on(t.clientId)]
|
||||
(t) => [
|
||||
index("idx_olms_clientid").on(t.clientId),
|
||||
index("idx_olms_userid").on(t.userId)
|
||||
]
|
||||
);
|
||||
|
||||
export const currentFingerprint = pgTable("currentFingerprint", {
|
||||
@@ -1984,7 +2006,7 @@ export const aiSessionLog = pgTable(
|
||||
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
|
||||
truncated: boolean("truncated").notNull().default(false),
|
||||
statusCode: integer("statusCode"),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch ms
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch seconds
|
||||
},
|
||||
(t) => [
|
||||
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
|
||||
|
||||
@@ -99,7 +99,9 @@ export const orgDomains = sqliteTable("orgDomains", {
|
||||
.references(() => domains.domainId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const sites = sqliteTable("sites", {
|
||||
export const sites = sqliteTable(
|
||||
"sites",
|
||||
{
|
||||
siteId: integer("siteId").primaryKey({ autoIncrement: true }),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
@@ -141,10 +143,18 @@ export const sites = sqliteTable("sites", {
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
status: text("status").$type<"pending" | "approved">().default("approved")
|
||||
});
|
||||
status: text("status")
|
||||
.$type<"pending" | "approved">()
|
||||
.default("approved")
|
||||
},
|
||||
(table) => [
|
||||
index("idx_sites_orgId").on(table.orgId)
|
||||
]
|
||||
);
|
||||
|
||||
export const resources = sqliteTable("resources", {
|
||||
export const resources = sqliteTable(
|
||||
"resources",
|
||||
{
|
||||
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
|
||||
resourcePolicyId: integer("resourcePolicyId").references(
|
||||
() => resourcePolicies.resourcePolicyId,
|
||||
@@ -222,8 +232,14 @@ export const resources = sqliteTable("resources", {
|
||||
.$type<"site" | "remote" | "native">()
|
||||
.default("site"),
|
||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||
status: text("status").$type<"pending" | "approved">().default("approved")
|
||||
});
|
||||
status: text("status")
|
||||
.$type<"pending" | "approved">()
|
||||
.default("approved")
|
||||
},
|
||||
(table) => [
|
||||
index("idx_resources_orgId").on(table.orgId)
|
||||
]
|
||||
);
|
||||
|
||||
export const resourceAiProviders = sqliteTable(
|
||||
"resourceAiProviders",
|
||||
@@ -260,7 +276,9 @@ export const resourceAiModels = sqliteTable(
|
||||
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
|
||||
);
|
||||
|
||||
export const labels = sqliteTable("labels", {
|
||||
export const labels = sqliteTable(
|
||||
"labels",
|
||||
{
|
||||
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
color: text("color").notNull(),
|
||||
@@ -269,7 +287,11 @@ export const labels = sqliteTable("labels", {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
});
|
||||
},
|
||||
(table) => [
|
||||
index("idx_labels_orgId").on(table.orgId)
|
||||
]
|
||||
);
|
||||
|
||||
export const launcherViews = sqliteTable("launcherViews", {
|
||||
viewId: integer("viewId").primaryKey({ autoIncrement: true }),
|
||||
@@ -366,14 +388,18 @@ export const clientLabels = sqliteTable(
|
||||
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
|
||||
);
|
||||
|
||||
export const targets = sqliteTable("targets", {
|
||||
export const targets = sqliteTable(
|
||||
"targets",
|
||||
{
|
||||
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
|
||||
resourceId: integer("resourceId").references(() => resources.resourceId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
providerId: integer("providerId").references(() => aiProviders.providerId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
resourceId: integer("resourceId").references(
|
||||
() => resources.resourceId,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
providerId: integer("providerId").references(
|
||||
() => aiProviders.providerId,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
@@ -394,7 +420,12 @@ export const targets = sqliteTable("targets", {
|
||||
.notNull()
|
||||
.default("http"),
|
||||
authToken: text("authToken")
|
||||
});
|
||||
},
|
||||
(table) => [
|
||||
index("idx_targets_resourceId").on(table.resourceId),
|
||||
index("idx_targets_siteId").on(table.siteId)
|
||||
]
|
||||
);
|
||||
|
||||
export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
||||
targetHealthCheckId: integer("targetHealthCheckId").primaryKey({
|
||||
@@ -663,7 +694,9 @@ export const setupTokens = sqliteTable("setupTokens", {
|
||||
dateUsed: text("dateUsed")
|
||||
});
|
||||
|
||||
export const newts = sqliteTable("newt", {
|
||||
export const newts = sqliteTable(
|
||||
"newt",
|
||||
{
|
||||
newtId: text("id").primaryKey(),
|
||||
secretHash: text("secretHash").notNull(),
|
||||
dateCreated: text("dateCreated").notNull(),
|
||||
@@ -671,9 +704,15 @@ export const newts = sqliteTable("newt", {
|
||||
siteId: integer("siteId").references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
},
|
||||
(table) => [
|
||||
index("idx_newts_siteId").on(table.siteId)
|
||||
]
|
||||
);
|
||||
|
||||
export const clients = sqliteTable("clients", {
|
||||
export const clients = sqliteTable(
|
||||
"clients",
|
||||
{
|
||||
clientId: integer("clientId").primaryKey({ autoIncrement: true }),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
@@ -706,7 +745,12 @@ export const clients = sqliteTable("clients", {
|
||||
approvalState: text("approvalState").$type<
|
||||
"pending" | "approved" | "denied"
|
||||
>()
|
||||
});
|
||||
},
|
||||
(table) => [
|
||||
index("idx_clients_orgId").on(table.orgId),
|
||||
index("idx_clients_userId").on(table.userId)
|
||||
]
|
||||
);
|
||||
|
||||
export const clientSitesAssociationsCache = sqliteTable(
|
||||
"clientSitesAssociationsCache",
|
||||
@@ -734,7 +778,9 @@ export const clientSiteResourcesAssociationsCache = sqliteTable(
|
||||
}
|
||||
);
|
||||
|
||||
export const olms = sqliteTable("olms", {
|
||||
export const olms = sqliteTable(
|
||||
"olms",
|
||||
{
|
||||
olmId: text("id").primaryKey(),
|
||||
secretHash: text("secretHash").notNull(),
|
||||
dateCreated: text("dateCreated").notNull(),
|
||||
@@ -750,7 +796,11 @@ export const olms = sqliteTable("olms", {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
archived: integer("archived", { mode: "boolean" }).notNull().default(false)
|
||||
});
|
||||
},
|
||||
(table) => [
|
||||
index("idx_olms_userId").on(table.userId)
|
||||
]
|
||||
);
|
||||
|
||||
export const currentFingerprint = sqliteTable("currentFingerprint", {
|
||||
fingerprintId: integer("id").primaryKey({ autoIncrement: true }),
|
||||
@@ -912,7 +962,9 @@ export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", {
|
||||
codeHash: text("codeHash").notNull()
|
||||
});
|
||||
|
||||
export const sessions = sqliteTable("session", {
|
||||
export const sessions = sqliteTable(
|
||||
"session",
|
||||
{
|
||||
sessionId: text("id").primaryKey(),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
@@ -922,7 +974,11 @@ export const sessions = sqliteTable("session", {
|
||||
deviceAuthUsed: integer("deviceAuthUsed", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false)
|
||||
});
|
||||
},
|
||||
(table) => [
|
||||
index("idx_sessions_userId").on(table.userId)
|
||||
]
|
||||
);
|
||||
|
||||
export const newtSessions = sqliteTable("newtSession", {
|
||||
sessionId: text("id").primaryKey(),
|
||||
@@ -940,7 +996,9 @@ export const olmSessions = sqliteTable("clientSession", {
|
||||
expiresAt: integer("expiresAt").notNull()
|
||||
});
|
||||
|
||||
export const userOrgs = sqliteTable("userOrgs", {
|
||||
export const userOrgs = sqliteTable(
|
||||
"userOrgs",
|
||||
{
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
@@ -954,7 +1012,12 @@ export const userOrgs = sqliteTable("userOrgs", {
|
||||
mode: "boolean"
|
||||
}).default(false),
|
||||
pamUsername: text("pamUsername") // cleaned username for ssh and such
|
||||
});
|
||||
},
|
||||
(table) => [
|
||||
index("idx_userOrgs_userId").on(table.userId),
|
||||
index("idx_userOrgs_orgId").on(table.orgId)
|
||||
]
|
||||
);
|
||||
|
||||
export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
|
||||
codeId: integer("id").primaryKey({ autoIncrement: true }),
|
||||
@@ -982,7 +1045,9 @@ export const actions = sqliteTable("actions", {
|
||||
description: text("description")
|
||||
});
|
||||
|
||||
export const roles = sqliteTable("roles", {
|
||||
export const roles = sqliteTable(
|
||||
"roles",
|
||||
{
|
||||
roleId: integer("roleId").primaryKey({ autoIncrement: true }),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
@@ -1001,7 +1066,11 @@ export const roles = sqliteTable("roles", {
|
||||
true
|
||||
),
|
||||
sshUnixGroups: text("sshUnixGroups").default("[]")
|
||||
});
|
||||
},
|
||||
(table) => [
|
||||
index("idx_roles_orgId").on(table.orgId)
|
||||
]
|
||||
);
|
||||
|
||||
export const userOrgRoles = sqliteTable(
|
||||
"userOrgRoles",
|
||||
@@ -1980,7 +2049,7 @@ export const aiSessionLog = sqliteTable(
|
||||
.notNull()
|
||||
.default(false),
|
||||
statusCode: integer("statusCode"),
|
||||
createdAt: integer("createdAt").notNull() // epoch ms
|
||||
createdAt: integer("createdAt").notNull() // epoch seconds
|
||||
},
|
||||
(t) => [
|
||||
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
|
||||
|
||||
@@ -580,6 +580,8 @@ export async function recordUsage(input: UsageRecordInput): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
usageRecordBuffer.push({
|
||||
orgId: input.orgId,
|
||||
providerId: input.providerId,
|
||||
@@ -597,7 +599,7 @@ export async function recordUsage(input: UsageRecordInput): Promise<void> {
|
||||
totalTokens,
|
||||
costUsd: input.costUsd,
|
||||
estimated: usage.estimated,
|
||||
createdAt: input.createdAt ?? Date.now()
|
||||
createdAt: input.createdAt ?? timestamp
|
||||
});
|
||||
|
||||
// Flush immediately if buffer is full, otherwise schedule a flush
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
db,
|
||||
primaryDb,
|
||||
newts,
|
||||
blueprints,
|
||||
Blueprint,
|
||||
@@ -80,11 +81,22 @@ export async function applyBlueprint({
|
||||
trx,
|
||||
siteId
|
||||
);
|
||||
});
|
||||
|
||||
// Push updates to newts/clients only after the transaction has
|
||||
// committed. Doing this while the transaction is still open can
|
||||
// race with the writes (e.g. newts requesting config before the
|
||||
// new targets/resources are actually visible), leaving them out
|
||||
// of sync until manually toggled.
|
||||
|
||||
// We need to update the targets on the newts from the successfully updated information
|
||||
for (const result of publicResourcesResults) {
|
||||
for (const target of result.targetsToUpdate) {
|
||||
const [site] = await trx
|
||||
// read from the primary: this determines whether/how we push
|
||||
// the just-created target to the newt, so a lagging replica
|
||||
// returning stale or missing data here would silently skip
|
||||
// the push
|
||||
const [site] = await primaryDb
|
||||
.select()
|
||||
.from(sites)
|
||||
.innerJoin(newts, eq(sites.siteId, newts.siteId))
|
||||
@@ -166,7 +178,6 @@ export async function applyBlueprint({
|
||||
logger.debug(
|
||||
`Successfully updated private resources for org ${orgId}: ${JSON.stringify(privateResourcesResults)}`
|
||||
);
|
||||
});
|
||||
|
||||
blueprintSucceeded = true;
|
||||
blueprintMessage = "Blueprint applied successfully";
|
||||
|
||||
@@ -101,7 +101,7 @@ export const AuthSchema = z.object({
|
||||
export const RuleSchema = z
|
||||
.object({
|
||||
action: z.enum(["allow", "deny", "pass"]),
|
||||
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
|
||||
match: z.enum(["cidr", "path", "ip", "country", "country_is_not", "asn", "region"]),
|
||||
value: z.coerce.string(),
|
||||
priority: z.int().optional(),
|
||||
enabled: z.boolean().optional().default(true)
|
||||
@@ -136,7 +136,7 @@ export const RuleSchema = z
|
||||
)
|
||||
.refine(
|
||||
(rule) => {
|
||||
if (rule.match === "country") {
|
||||
if (rule.match === "country" || rule.match === "country_is_not") {
|
||||
if (!hasMaxmindCountryDb) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ export async function validateAndConstructDomain(
|
||||
subdomain?: string | null
|
||||
): Promise<DomainValidationResult> {
|
||||
try {
|
||||
// Query domain with organization access check
|
||||
const [domainRes] = await db
|
||||
.select()
|
||||
.from(domains)
|
||||
@@ -42,6 +41,10 @@ export async function validateAndConstructDomain(
|
||||
eq(orgDomains.orgId, orgId),
|
||||
eq(orgDomains.domainId, domainId)
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
domainNamespaces,
|
||||
eq(domainNamespaces.domainId, domainId)
|
||||
);
|
||||
|
||||
// Check if domain exists
|
||||
@@ -52,8 +55,7 @@ export async function validateAndConstructDomain(
|
||||
};
|
||||
}
|
||||
|
||||
// Check if organization has access to domain
|
||||
if (domainRes.orgDomains && domainRes.orgDomains.orgId !== orgId) {
|
||||
if (!domainRes.orgDomains && !domainRes.domainNamespaces) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Organization does not have access to domain with ID ${domainId}`
|
||||
@@ -84,20 +86,12 @@ export async function validateAndConstructDomain(
|
||||
}
|
||||
|
||||
// Wildcard subdomains are not allowed on namespace (provided/free) domains
|
||||
if (isWildcard) {
|
||||
const [namespaceDomain] = await db
|
||||
.select()
|
||||
.from(domainNamespaces)
|
||||
.where(eq(domainNamespaces.domainId, domainId))
|
||||
.limit(1);
|
||||
|
||||
if (namespaceDomain) {
|
||||
if (isWildcard && domainRes.domainNamespaces) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isWildcard &&
|
||||
|
||||
+50
-1
@@ -3,6 +3,8 @@ import config from "./config";
|
||||
import { getHostMeta } from "./hostMeta";
|
||||
import logger from "@server/logger";
|
||||
import {
|
||||
aiProviders,
|
||||
aiUsageRecords,
|
||||
alertRules,
|
||||
apiKeys,
|
||||
blueprints,
|
||||
@@ -11,7 +13,16 @@ import {
|
||||
siteResources
|
||||
} from "@server/db";
|
||||
import { sites, users, orgs, resources, clients, idp } from "@server/db";
|
||||
import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm";
|
||||
import {
|
||||
eq,
|
||||
count,
|
||||
countDistinct,
|
||||
notInArray,
|
||||
and,
|
||||
isNotNull,
|
||||
isNull,
|
||||
gte
|
||||
} from "drizzle-orm";
|
||||
import { APP_VERSION } from "./consts";
|
||||
import crypto from "crypto";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
@@ -172,6 +183,25 @@ class TelemetryClient {
|
||||
.select({ count: count() })
|
||||
.from(blueprints);
|
||||
|
||||
const [aiProvidersCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(aiProviders);
|
||||
const [orgsWithAiProviders] = await db
|
||||
.select({ count: countDistinct(aiProviders.orgId) })
|
||||
.from(aiProviders);
|
||||
|
||||
const usageWindowStart =
|
||||
Math.floor(Date.now() / 1000) -
|
||||
this.collectionIntervalDays * 24 * 60 * 60;
|
||||
const [aiUsageRecordsRecent] = await db
|
||||
.select({ count: count() })
|
||||
.from(aiUsageRecords)
|
||||
.where(gte(aiUsageRecords.createdAt, usageWindowStart));
|
||||
const [orgsWithRecentAiUsage] = await db
|
||||
.select({ count: countDistinct(aiUsageRecords.orgId) })
|
||||
.from(aiUsageRecords)
|
||||
.where(gte(aiUsageRecords.createdAt, usageWindowStart));
|
||||
|
||||
const supporterKey = config.getSupporterData();
|
||||
|
||||
const allPrivateResources = await db.select().from(siteResources);
|
||||
@@ -182,6 +212,7 @@ class TelemetryClient {
|
||||
let numPrivResourceCidr = 0;
|
||||
let numPrivResourceHttp = 0;
|
||||
let numPrivResourceSsh = 0;
|
||||
let numPrivResourceInference = 0;
|
||||
for (const res of allPrivateResources) {
|
||||
if (res.mode === "host") {
|
||||
numPrivResourceHosts += 1;
|
||||
@@ -191,6 +222,8 @@ class TelemetryClient {
|
||||
numPrivResourceHttp += 1;
|
||||
} else if (res.mode === "ssh") {
|
||||
numPrivResourceSsh += 1;
|
||||
} else if (res.mode === "inference") {
|
||||
numPrivResourceInference += 1;
|
||||
}
|
||||
|
||||
if (res.alias) {
|
||||
@@ -211,6 +244,11 @@ class TelemetryClient {
|
||||
numPrivateResourceCidr: numPrivResourceCidr,
|
||||
numPrivateResourceHttp: numPrivResourceHttp,
|
||||
numPrivateResourceSsh: numPrivResourceSsh,
|
||||
numPrivateResourceInference: numPrivResourceInference,
|
||||
numAiProviders: aiProvidersCount.count,
|
||||
numOrgsWithAiProviders: orgsWithAiProviders.count,
|
||||
numAiUsageRecordsRecent: aiUsageRecordsRecent.count,
|
||||
numOrgsWithRecentAiUsage: orgsWithRecentAiUsage.count,
|
||||
numAlertRules: numAlertRules.count,
|
||||
numUserDevices: userDevicesCount.count,
|
||||
numMachineClients: machineClients.count,
|
||||
@@ -323,6 +361,17 @@ class TelemetryClient {
|
||||
num_resources_non_http: stats.resources.filter(
|
||||
(r) => r.mode !== "http"
|
||||
).length,
|
||||
num_resources_ai_gateway: stats.resources.filter(
|
||||
(r) => r.mode === "inference"
|
||||
).length,
|
||||
num_private_resources_ai_gateway:
|
||||
stats.numPrivateResourceInference,
|
||||
num_ai_providers: stats.numAiProviders,
|
||||
num_orgs_with_ai_providers: stats.numOrgsWithAiProviders,
|
||||
num_ai_usage_records_recent:
|
||||
stats.numAiUsageRecordsRecent,
|
||||
num_orgs_with_recent_ai_usage:
|
||||
stats.numOrgsWithRecentAiUsage,
|
||||
num_newt_sites: stats.sites.filter((s) => s.type === "newt")
|
||||
.length,
|
||||
num_local_sites: stats.sites.filter(
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { gzipSync, gunzipSync } from "zlib";
|
||||
|
||||
/**
|
||||
* Gzip a string and return it as base64 so it can be stored in a TEXT column.
|
||||
*/
|
||||
export function compressText(value: string): string {
|
||||
return gzipSync(Buffer.from(value, "utf8")).toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse of compressText - base64-decode and gunzip back to the original string.
|
||||
*/
|
||||
export function decompressText(value: string): string {
|
||||
return gunzipSync(Buffer.from(value, "base64")).toString("utf8");
|
||||
}
|
||||
@@ -68,6 +68,11 @@ export async function verifyApiKeyAccessTokenAccess(
|
||||
);
|
||||
}
|
||||
|
||||
if (apiKey.isRoot) {
|
||||
// Root keys can access any access token in any org
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!resource.orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import logger from "@server/logger";
|
||||
import { and, eq, gt, desc, max, sql } from "drizzle-orm";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import { decompressText } from "@server/lib/textCompression";
|
||||
import config from "@server/lib/config";
|
||||
import {
|
||||
LogType,
|
||||
@@ -680,8 +681,8 @@ export class LogStreamingManager {
|
||||
Record<string, unknown> & { id: number }
|
||||
>;
|
||||
|
||||
case "aiSession":
|
||||
return (await logsDb
|
||||
case "aiSession": {
|
||||
const rows = (await logsDb
|
||||
.select()
|
||||
.from(aiSessionLog)
|
||||
.where(
|
||||
@@ -694,6 +695,33 @@ export class LogStreamingManager {
|
||||
.limit(limit)) as Array<
|
||||
Record<string, unknown> & { id: number }
|
||||
>;
|
||||
|
||||
const compressedFields = [
|
||||
"requestBody",
|
||||
"responseBody",
|
||||
"normalizedRequest",
|
||||
"normalizedResponse"
|
||||
] as const;
|
||||
|
||||
for (const row of rows) {
|
||||
for (const field of compressedFields) {
|
||||
const value = row[field];
|
||||
if (typeof value !== "string") {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
row[field] = decompressText(value);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to decompress AI session log field ${field}`,
|
||||
{ error }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -149,12 +149,8 @@ LQIDAQAB
|
||||
}
|
||||
|
||||
// Count used sites and users for license comparison
|
||||
const [siteCountRes] = await db
|
||||
.select({ value: count() })
|
||||
.from(sites);
|
||||
const [userCountRes] = await db
|
||||
.select({ value: count() })
|
||||
.from(users);
|
||||
const [siteCountRes] = await db.select({ value: count() }).from(sites);
|
||||
const [userCountRes] = await db.select({ value: count() }).from(users);
|
||||
|
||||
const status: LicenseStatus = {
|
||||
hostId: this.hostMeta.hostMetaId,
|
||||
@@ -276,10 +272,13 @@ LQIDAQAB
|
||||
logger.error(
|
||||
`Allowing failure. Will retry one more time at next run interval.`
|
||||
);
|
||||
// return last known good status
|
||||
return this.statusCache.get(
|
||||
// Fall back to last known good status if we have
|
||||
// one cached; otherwise return the freshly built
|
||||
// status (with defaults) rather than undefined.
|
||||
const lastKnownStatus = this.statusCache.get(
|
||||
this.statusKey
|
||||
) as LicenseStatus;
|
||||
) as LicenseStatus | undefined;
|
||||
return lastKnownStatus ?? status;
|
||||
} else {
|
||||
// Subsequent failures: fail abruptly
|
||||
throw e;
|
||||
@@ -368,10 +367,7 @@ LQIDAQAB
|
||||
}
|
||||
|
||||
// Only consider quantity if defined and >= 0 (quantity = users, quantity_2 = sites)
|
||||
if (
|
||||
cached.quantity_2 !== undefined &&
|
||||
cached.quantity_2 >= 0
|
||||
) {
|
||||
if (cached.quantity_2 !== undefined && cached.quantity_2 >= 0) {
|
||||
status.maxSites =
|
||||
(status.maxSites ?? 0) + cached.quantity_2;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { and, eq, lt } from "drizzle-orm";
|
||||
import cache from "#private/lib/cache";
|
||||
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
||||
import { sanitizeString } from "@server/lib/sanitize";
|
||||
import { compressText } from "@server/lib/textCompression";
|
||||
import type { AiCapability } from "@server/lib/aiCapabilities";
|
||||
import {
|
||||
normalizeAiRequest,
|
||||
@@ -151,17 +152,14 @@ async function getRetentionDays(orgId: string): Promise<number> {
|
||||
}
|
||||
|
||||
export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
|
||||
// calculateCutoffTimestamp returns a seconds-epoch cutoff (built for
|
||||
// requestAuditLog.timestamp), but aiSessionLog.createdAt is ms-epoch to
|
||||
// match aiUsageRecords - convert before comparing.
|
||||
const cutoffTimestampMs = calculateCutoffTimestamp(retentionDays) * 1000;
|
||||
const cutoffTimestamp = calculateCutoffTimestamp(retentionDays);
|
||||
|
||||
try {
|
||||
await logsDb
|
||||
.delete(aiSessionLog)
|
||||
.where(
|
||||
and(
|
||||
lt(aiSessionLog.createdAt, cutoffTimestampMs),
|
||||
lt(aiSessionLog.createdAt, cutoffTimestamp),
|
||||
eq(aiSessionLog.orgId, orgId)
|
||||
)
|
||||
);
|
||||
@@ -243,6 +241,8 @@ export function logAiSession(data: {
|
||||
);
|
||||
}
|
||||
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
sessionLogBuffer.push({
|
||||
sessionId: data.sessionId,
|
||||
orgId: sanitizeString(data.orgId),
|
||||
@@ -256,13 +256,19 @@ export function logAiSession(data: {
|
||||
),
|
||||
requestedModel: sanitizeString(data.requestedModel),
|
||||
isStream: data.isStream,
|
||||
requestBody: sanitizeString(requestBodyText.value),
|
||||
responseBody: sanitizeString(responseBodyText.value),
|
||||
requestBody: compressText(
|
||||
sanitizeString(requestBodyText.value)
|
||||
),
|
||||
responseBody: compressText(
|
||||
sanitizeString(responseBodyText.value)
|
||||
),
|
||||
normalizedRequest: normalizedRequestText
|
||||
? sanitizeString(normalizedRequestText.value)
|
||||
? compressText(sanitizeString(normalizedRequestText.value))
|
||||
: undefined,
|
||||
normalizedResponse: normalizedResponseText
|
||||
? sanitizeString(normalizedResponseText.value)
|
||||
? compressText(
|
||||
sanitizeString(normalizedResponseText.value)
|
||||
)
|
||||
: undefined,
|
||||
truncated:
|
||||
requestBodyText.truncated ||
|
||||
@@ -270,7 +276,7 @@ export function logAiSession(data: {
|
||||
(normalizedRequestText?.truncated ?? false) ||
|
||||
(normalizedResponseText?.truncated ?? false),
|
||||
statusCode: data.statusCode,
|
||||
createdAt: Date.now()
|
||||
createdAt: timestamp
|
||||
});
|
||||
|
||||
// Flush immediately if buffer is full, otherwise schedule a flush
|
||||
|
||||
@@ -88,7 +88,27 @@ export const queryAccessAuditLogsQuery = z.object({
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative())
|
||||
.pipe(z.int().nonnegative()),
|
||||
ip: z
|
||||
.preprocess((val) => {
|
||||
if (val === undefined || val === null || val === "") {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(val)) {
|
||||
return val;
|
||||
}
|
||||
// the array is returned as this
|
||||
if (typeof val === "string") {
|
||||
return val.split(",");
|
||||
}
|
||||
return undefined;
|
||||
}, z.array(z.string()))
|
||||
.optional()
|
||||
.catch([])
|
||||
.openapi({
|
||||
type: "array",
|
||||
description: "Filter by IP adresses"
|
||||
})
|
||||
});
|
||||
|
||||
export const queryAccessAuditLogsParams = z.object({
|
||||
@@ -134,6 +154,9 @@ function getWhere(data: Q) {
|
||||
data.type ? eq(accessAuditLog.type, data.type) : undefined,
|
||||
data.action !== undefined
|
||||
? eq(accessAuditLog.action, data.action)
|
||||
: undefined,
|
||||
data.ip && data.ip.length > 0
|
||||
? inArray(accessAuditLog.ip, data.ip)
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,12 +16,10 @@ import {
|
||||
handleRemoteExitNodePingMessage
|
||||
} from "#private/routers/remoteExitNode";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import {
|
||||
handleConnectionLogMessage,
|
||||
} from "#private/routers/newt";
|
||||
import { handleConnectionLogMessage } from "#private/routers/newt";
|
||||
|
||||
export const messageHandlers: Record<string, MessageHandler> = {
|
||||
"remoteExitNode/register": handleRemoteExitNodeRegisterMessage,
|
||||
"remoteExitNode/ping": handleRemoteExitNodePingMessage,
|
||||
"newt/access-log": handleConnectionLogMessage,
|
||||
"newt/access-log": handleConnectionLogMessage
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ export const aiUsageAnalyticsFiltersQuery = z.object({
|
||||
.refine((val) => !isNaN(Date.parse(val)), {
|
||||
error: "timeStart must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => new Date(val).getTime())
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||
.prefault(() => getSevenDaysAgo().toISOString())
|
||||
.openapi({
|
||||
type: "string",
|
||||
@@ -31,7 +31,7 @@ export const aiUsageAnalyticsFiltersQuery = z.object({
|
||||
.refine((val) => !isNaN(Date.parse(val)), {
|
||||
error: "timeEnd must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => new Date(val).getTime())
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||
.prefault(() => new Date().toISOString())
|
||||
.openapi({
|
||||
type: "string",
|
||||
@@ -122,12 +122,12 @@ export function buildAiUsageWhere(
|
||||
);
|
||||
}
|
||||
|
||||
// Buckets createdAt (epoch ms) down to a per-day string, dialect-aware, same
|
||||
// approach as the DATE_TRUNC/DATE branch in queryRequestAnalytics.ts.
|
||||
// Buckets createdAt (epoch seconds) down to a per-day string, dialect-aware,
|
||||
// same approach as the DATE_TRUNC/DATE branch in queryRequestAnalytics.ts.
|
||||
export function dayBucketExpr() {
|
||||
return driver === "pg"
|
||||
? sql<string>`DATE_TRUNC('day', TO_TIMESTAMP(${aiUsageRecords.createdAt} / 1000.0))`
|
||||
: sql<string>`DATE(${aiUsageRecords.createdAt} / 1000, 'unixepoch')`;
|
||||
? sql<string>`DATE_TRUNC('day', TO_TIMESTAMP(${aiUsageRecords.createdAt}))`
|
||||
: sql<string>`DATE(${aiUsageRecords.createdAt}, 'unixepoch')`;
|
||||
}
|
||||
|
||||
export type DailyMetricRow<K extends string> = {
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
queryAiSessionLogsQuery,
|
||||
queryAiSessionLogsParams,
|
||||
queryAiSession,
|
||||
countAiSessionQuery
|
||||
countAiSessionQuery,
|
||||
decompressAiSessionLogRow
|
||||
} from "./queryAiSessionLog";
|
||||
import { generateCSV } from "./generateCSV";
|
||||
|
||||
@@ -87,7 +88,9 @@ export async function exportAiSessionLogs(
|
||||
|
||||
const baseQuery = queryAiSession(data);
|
||||
|
||||
const log = await baseQuery.limit(MAX_EXPORT_LIMIT);
|
||||
const log = (await baseQuery.limit(MAX_EXPORT_LIMIT)).map(
|
||||
decompressAiSessionLogRow
|
||||
);
|
||||
|
||||
const csvData = generateCSV(log);
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { AI_CAPABILITIES } from "@server/lib/aiCapabilities";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { decompressText } from "@server/lib/textCompression";
|
||||
|
||||
export const queryAiSessionLogsQuery = z.strictObject({
|
||||
// iso string just validate its a parseable date
|
||||
@@ -32,7 +33,7 @@ export const queryAiSessionLogsQuery = z.strictObject({
|
||||
.refine((val) => !isNaN(Date.parse(val)), {
|
||||
error: "timeStart must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => new Date(val).getTime())
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||
.prefault(() => getSevenDaysAgo().toISOString())
|
||||
.openapi({
|
||||
type: "string",
|
||||
@@ -45,7 +46,7 @@ export const queryAiSessionLogsQuery = z.strictObject({
|
||||
.refine((val) => !isNaN(Date.parse(val)), {
|
||||
error: "timeEnd must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => new Date(val).getTime())
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||
.optional()
|
||||
.prefault(() => new Date().toISOString())
|
||||
.openapi({
|
||||
@@ -166,6 +167,35 @@ export function queryAiSession(data: Q) {
|
||||
.orderBy(desc(aiSessionLog.createdAt));
|
||||
}
|
||||
|
||||
function decompressField(value: string | null): string | null {
|
||||
if (value == null) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return decompressText(value);
|
||||
} catch (error) {
|
||||
logger.error("Failed to decompress AI session log field", { error });
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function decompressAiSessionLogRow<
|
||||
T extends {
|
||||
requestBody: string | null;
|
||||
responseBody: string | null;
|
||||
normalizedRequest: string | null;
|
||||
normalizedResponse: string | null;
|
||||
}
|
||||
>(row: T): T {
|
||||
return {
|
||||
...row,
|
||||
requestBody: decompressField(row.requestBody),
|
||||
responseBody: decompressField(row.responseBody),
|
||||
normalizedRequest: decompressField(row.normalizedRequest),
|
||||
normalizedResponse: decompressField(row.normalizedResponse)
|
||||
};
|
||||
}
|
||||
|
||||
async function enrichWithDetails(
|
||||
logs: Awaited<ReturnType<typeof queryAiSession>>
|
||||
) {
|
||||
@@ -620,7 +650,9 @@ export async function queryAiSessionLogs(
|
||||
|
||||
const baseQuery = queryAiSession(data);
|
||||
|
||||
const logsRaw = await baseQuery.limit(data.limit).offset(data.offset);
|
||||
const logsRaw = (
|
||||
await baseQuery.limit(data.limit).offset(data.offset)
|
||||
).map(decompressAiSessionLogRow);
|
||||
|
||||
const log = await enrichWithDetails(logsRaw);
|
||||
|
||||
|
||||
@@ -30,14 +30,14 @@ const queryAiUsageFilterOptionsQuery = z.object({
|
||||
.refine((val) => !isNaN(Date.parse(val)), {
|
||||
error: "timeStart must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => new Date(val).getTime())
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||
.prefault(() => getSevenDaysAgo().toISOString()),
|
||||
timeEnd: z
|
||||
.string()
|
||||
.refine((val) => !isNaN(Date.parse(val)), {
|
||||
error: "timeEnd must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => new Date(val).getTime())
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||
.prefault(() => new Date().toISOString())
|
||||
});
|
||||
|
||||
|
||||
@@ -81,7 +81,27 @@ export const queryAccessAuditLogsQuery = z.strictObject({
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative())
|
||||
.pipe(z.int().nonnegative()),
|
||||
ip: z
|
||||
.preprocess((val) => {
|
||||
if (val === undefined || val === null || val === "") {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(val)) {
|
||||
return val;
|
||||
}
|
||||
// the array is returned as this
|
||||
if (typeof val === "string") {
|
||||
return val.split(",");
|
||||
}
|
||||
return undefined;
|
||||
}, z.array(z.string()))
|
||||
.optional()
|
||||
.catch([])
|
||||
.openapi({
|
||||
type: "array",
|
||||
description: "Filter by IP adresses"
|
||||
})
|
||||
});
|
||||
|
||||
export const queryRequestAuditLogsParams = z.object({
|
||||
@@ -126,6 +146,9 @@ function getWhere(data: Q) {
|
||||
data.path ? eq(requestAuditLog.path, data.path) : undefined,
|
||||
data.action !== undefined
|
||||
? eq(requestAuditLog.action, data.action)
|
||||
: undefined,
|
||||
data.ip && data.ip.length > 0
|
||||
? inArray(requestAuditLog.ip, data.ip)
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode";
|
||||
import { response } from "@server/lib/response";
|
||||
import { db } from "@server/db";
|
||||
import { passwordResetTokens, users } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
|
||||
import { createDate } from "oslo";
|
||||
import logger from "@server/logger";
|
||||
@@ -49,7 +49,12 @@ export async function requestPasswordReset(
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email));
|
||||
.where(
|
||||
and(
|
||||
eq(users.email, email),
|
||||
eq(users.type, UserType.Internal)
|
||||
)
|
||||
);
|
||||
|
||||
if (!existingUser || !existingUser.length) {
|
||||
await randomDelay(2000);
|
||||
|
||||
@@ -533,18 +533,23 @@ export async function startAuthentication(
|
||||
|
||||
// If email is provided, get security keys for that specific user
|
||||
if (email) {
|
||||
const [user] = await db
|
||||
const matchingUsers = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
.where(
|
||||
and(
|
||||
eq(users.email, email.toLowerCase()),
|
||||
eq(users.type, UserType.Internal)
|
||||
)
|
||||
);
|
||||
|
||||
if (!user || user.type !== UserType.Internal) {
|
||||
if (matchingUsers.length !== 1) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid credentials")
|
||||
);
|
||||
}
|
||||
|
||||
const user = matchingUsers[0];
|
||||
userId = user.userId;
|
||||
|
||||
const userSecurityKeys = await db
|
||||
|
||||
@@ -42,54 +42,62 @@ export async function setServerAdmin(
|
||||
|
||||
const { email, password, setupToken } = parsedBody.data;
|
||||
|
||||
// Validate setup token
|
||||
const [validToken] = await db
|
||||
.select()
|
||||
.from(setupTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(setupTokens.token, setupToken),
|
||||
eq(setupTokens.used, false)
|
||||
)
|
||||
);
|
||||
|
||||
if (!validToken) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid or expired setup token"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.serverAdmin, true));
|
||||
|
||||
if (existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Server admin already exists"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const userId = generateId(15);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
// Mark the token as used
|
||||
await trx
|
||||
const consumed = await trx
|
||||
.update(setupTokens)
|
||||
.set({
|
||||
used: true,
|
||||
dateUsed: moment().toISOString()
|
||||
})
|
||||
.where(eq(setupTokens.tokenId, validToken.tokenId));
|
||||
.where(
|
||||
and(
|
||||
eq(setupTokens.token, setupToken),
|
||||
eq(setupTokens.used, false)
|
||||
)
|
||||
)
|
||||
.returning({ tokenId: setupTokens.tokenId });
|
||||
|
||||
if (!consumed.length) {
|
||||
throw createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid setup token"
|
||||
);
|
||||
}
|
||||
|
||||
const [existingAdmin] = await trx
|
||||
.select({ userId: users.userId })
|
||||
.from(users)
|
||||
.where(eq(users.serverAdmin, true))
|
||||
.limit(1);
|
||||
|
||||
if (existingAdmin) {
|
||||
throw createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Server admin already exists"
|
||||
);
|
||||
}
|
||||
|
||||
const [existingUser] = await trx
|
||||
.select({ userId: users.userId })
|
||||
.from(users)
|
||||
.where(
|
||||
and(
|
||||
eq(users.email, email),
|
||||
eq(users.type, UserType.Internal)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingUser) {
|
||||
throw createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"A user with that email address already exists"
|
||||
);
|
||||
}
|
||||
|
||||
// Create the server admin user
|
||||
await trx.insert(users).values({
|
||||
userId: userId,
|
||||
email: email,
|
||||
@@ -111,6 +119,9 @@ export async function setServerAdmin(
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (e) {
|
||||
if (createHttpError.isHttpError(e)) {
|
||||
return next(e);
|
||||
}
|
||||
logger.error(e);
|
||||
return next(
|
||||
createHttpError(
|
||||
|
||||
@@ -48,7 +48,7 @@ export async function validateSetupToken(
|
||||
return response<ValidateSetupTokenResponse>(res, {
|
||||
data: {
|
||||
valid: false,
|
||||
message: "Invalid or expired setup token"
|
||||
message: "Invalid setup token"
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
||||
+15
-11
@@ -66,6 +66,10 @@ import * as aiBudget from "@server/routers/aiBudget";
|
||||
import * as virtualApiKey from "@server/routers/virtualApiKey";
|
||||
import * as certificates from "@server/routers/certificates";
|
||||
|
||||
function rateLimitIdentityKey(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
// Root routes
|
||||
export const unauthenticated = Router();
|
||||
|
||||
@@ -1927,7 +1931,7 @@ authRouter.put(
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 15,
|
||||
keyGenerator: (req) =>
|
||||
`signup:${ipKeyGenerator(req.ip || "")}:${req.body.email}`,
|
||||
`signup:${ipKeyGenerator(req.ip || "")}:${rateLimitIdentityKey(req.body.email)}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only sign up ${15} times every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
@@ -1942,7 +1946,7 @@ authRouter.post(
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 15,
|
||||
keyGenerator: (req) =>
|
||||
`login:${req.body.email || ipKeyGenerator(req.ip || "")}`,
|
||||
`login:${rateLimitIdentityKey(req.body.email) || ipKeyGenerator(req.ip || "")}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only log in ${15} times every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
@@ -1959,7 +1963,7 @@ authRouter.post(
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 15,
|
||||
keyGenerator: (req) =>
|
||||
`lookupUser:${req.body.identifier || ipKeyGenerator(req.ip || "")}`,
|
||||
`lookupUser:${rateLimitIdentityKey(req.body.identifier) || ipKeyGenerator(req.ip || "")}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only lookup users ${15} times every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
@@ -2037,7 +2041,7 @@ authRouter.post(
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 15,
|
||||
keyGenerator: (req) => {
|
||||
return `signup:${req.body.email || req.user?.userId || ipKeyGenerator(req.ip || "")}`;
|
||||
return `signup:${rateLimitIdentityKey(req.body.email) || req.user?.userId || ipKeyGenerator(req.ip || "")}`;
|
||||
},
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only enable 2FA ${15} times every ${15} minutes. Please try again later.`;
|
||||
@@ -2053,7 +2057,7 @@ authRouter.post(
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 15,
|
||||
keyGenerator: (req) => {
|
||||
return `signup:${req.body.email || req.user?.userId || ipKeyGenerator(req.ip || "")}`;
|
||||
return `signup:${rateLimitIdentityKey(req.body.email) || req.user?.userId || ipKeyGenerator(req.ip || "")}`;
|
||||
},
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only request a 2FA code ${15} times every ${15} minutes. Please try again later.`;
|
||||
@@ -2085,7 +2089,7 @@ authRouter.post(
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 15,
|
||||
keyGenerator: (req) =>
|
||||
`signup:${req.body.email || ipKeyGenerator(req.ip || "")}`,
|
||||
`signup:${rateLimitIdentityKey(req.body.email) || ipKeyGenerator(req.ip || "")}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only sign up ${15} times every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
@@ -2103,7 +2107,7 @@ authRouter.post(
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 15,
|
||||
keyGenerator: (req) =>
|
||||
`requestEmailVerificationCode:${req.user?.email || ipKeyGenerator(req.ip || "")}`,
|
||||
`requestEmailVerificationCode:${rateLimitIdentityKey(req.user?.email) || ipKeyGenerator(req.ip || "")}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only request an email verification code ${15} times every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
@@ -2125,7 +2129,7 @@ authRouter.post(
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 15,
|
||||
keyGenerator: (req) =>
|
||||
`requestPasswordReset:${req.body.email || ipKeyGenerator(req.ip || "")}`,
|
||||
`requestPasswordReset:${rateLimitIdentityKey(req.body.email) || ipKeyGenerator(req.ip || "")}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only request a password reset ${15} times every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
@@ -2141,7 +2145,7 @@ authRouter.post(
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 15,
|
||||
keyGenerator: (req) =>
|
||||
`resetPassword:${req.body.email || ipKeyGenerator(req.ip || "")}`,
|
||||
`resetPassword:${rateLimitIdentityKey(req.body.email) || ipKeyGenerator(req.ip || "")}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only request a password reset ${15} times every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
@@ -2188,7 +2192,7 @@ authRouter.post(
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 15,
|
||||
keyGenerator: (req) =>
|
||||
`authWithWhitelist:${ipKeyGenerator(req.ip || "")}:${req.body.email}:${req.params.resourceId}`,
|
||||
`authWithWhitelist:${ipKeyGenerator(req.ip || "")}:${rateLimitIdentityKey(req.body.email)}:${req.params.resourceId}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only request an email OTP ${15} times every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
@@ -2240,7 +2244,7 @@ authRouter.post(
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 10, // Allow 10 authentication attempts per 15 minutes per IP
|
||||
keyGenerator: (req) => {
|
||||
return `securityKeyAuth:${req.body.email || ipKeyGenerator(req.ip || "")}`;
|
||||
return `securityKeyAuth:${rateLimitIdentityKey(req.body.email) || ipKeyGenerator(req.ip || "")}`;
|
||||
},
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only attempt security key authentication ${10} times every ${15} minutes. Please try again later.`;
|
||||
|
||||
@@ -16,13 +16,12 @@ const getOrgSchema = z.strictObject({
|
||||
});
|
||||
|
||||
export type GetOrgResponse = {
|
||||
org: Org;
|
||||
org: Omit<Org, "sshCaPrivateKey">;
|
||||
};
|
||||
const GetOrgResponseDataSchema = z.object({
|
||||
org: z.object({}).passthrough()
|
||||
});
|
||||
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}",
|
||||
@@ -76,9 +75,12 @@ export async function getOrg(
|
||||
);
|
||||
}
|
||||
|
||||
// sshCaPrivateKey is encrypted anyway but just to be safe
|
||||
const { sshCaPrivateKey: _, ...orgWithoutPrivateKey } = org;
|
||||
|
||||
return response<GetOrgResponse>(res, {
|
||||
data: {
|
||||
org
|
||||
org: orgWithoutPrivateKey
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
||||
@@ -18,7 +18,7 @@ const getSiteResourceParamsSchema = z.strictObject({
|
||||
.pipe(z.int().positive().optional())
|
||||
.optional(),
|
||||
niceId: z.string().optional(),
|
||||
orgId: z.string()
|
||||
orgId: z.string().optional()
|
||||
});
|
||||
|
||||
async function query(siteResourceId?: number, niceId?: string, orgId?: string) {
|
||||
@@ -34,6 +34,13 @@ async function query(siteResourceId?: number, niceId?: string, orgId?: string) {
|
||||
)
|
||||
.limit(1);
|
||||
return siteResource;
|
||||
} else if (siteResourceId) {
|
||||
const [siteResource] = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(eq(siteResources.siteResourceId, siteResourceId))
|
||||
.limit(1);
|
||||
return siteResource;
|
||||
} else if (niceId && orgId) {
|
||||
const [siteResource] = await db
|
||||
.select()
|
||||
@@ -60,9 +67,7 @@ registry.registerPath({
|
||||
tags: [OpenAPITags.PrivateResourceLegacy],
|
||||
request: {
|
||||
params: z.object({
|
||||
siteResourceId: z.number(),
|
||||
siteId: z.number(),
|
||||
orgId: z.string()
|
||||
siteResourceId: z.number()
|
||||
})
|
||||
},
|
||||
responses: {
|
||||
@@ -90,9 +95,7 @@ registry.registerPath({
|
||||
tags: [OpenAPITags.PrivateResource],
|
||||
request: {
|
||||
params: z.object({
|
||||
siteResourceId: z.number(),
|
||||
siteId: z.number(),
|
||||
orgId: z.string()
|
||||
siteResourceId: z.number()
|
||||
})
|
||||
},
|
||||
responses: {
|
||||
|
||||
@@ -223,7 +223,7 @@ export default async function migration() {
|
||||
sql`ALTER TABLE "subscriptions" ADD COLUMN "override" boolean DEFAULT false;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "orgs" ADD COLUMN "settingsLogRetentionDaysAISessions" integer DEFAULT 7 NOT NULL;`
|
||||
sql`ALTER TABLE "orgs" ADD COLUMN "settingsLogRetentionDaysAISessions" integer DEFAULT 0 NOT NULL;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "siteResources" ADD COLUMN "requiresExitNodeConnection" boolean DEFAULT false NOT NULL;`
|
||||
@@ -345,6 +345,9 @@ export default async function migration() {
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "virtualApiKeys" ADD CONSTRAINT "virtualApiKeys_createdByUserId_user_id_fk" FOREIGN KEY ("createdByUserId") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "eventStreamingDestinations" ADD "sendAISessionLogs" boolean DEFAULT false NOT NULL;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`CREATE INDEX "idx_ai_budget_breach_events_budget_created" ON "aiBudgetBreachEvents" USING btree ("budgetId","createdAt");`
|
||||
);
|
||||
@@ -451,14 +454,14 @@ export default async function migration() {
|
||||
throw new Error(fromZodError(parsedConfig.error).toString());
|
||||
}
|
||||
|
||||
traefikConfig.experimental.plugins.badger.version = "v1.6.1";
|
||||
traefikConfig.experimental.plugins.badger.version = "v1.7.0";
|
||||
|
||||
const updatedTraefikYaml = yaml.dump(traefikConfig);
|
||||
|
||||
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
|
||||
|
||||
console.log(
|
||||
"Updated the version of Badger in your Traefik configuration to v1.6.1"
|
||||
"Updated the version of Badger in your Traefik configuration to v1.7.0"
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(
|
||||
|
||||
@@ -397,11 +397,14 @@ export default async function migration() {
|
||||
`ALTER TABLE 'clients' ADD 'exitNodeSubnet' text;`
|
||||
).run();
|
||||
db.prepare(
|
||||
`ALTER TABLE 'orgs' ADD 'settingsLogRetentionDaysAISessions' integer DEFAULT 7 NOT NULL;`
|
||||
`ALTER TABLE 'orgs' ADD 'settingsLogRetentionDaysAISessions' integer DEFAULT 0 NOT NULL;`
|
||||
).run();
|
||||
db.prepare(
|
||||
`ALTER TABLE 'siteResources' ADD 'requiresExitNodeConnection' integer DEFAULT false NOT NULL;`
|
||||
).run();
|
||||
db.prepare(
|
||||
`ALTER TABLE 'eventStreamingDestinations' ADD 'sendAISessionLogs' integer DEFAULT false NOT NULL;`
|
||||
).run();
|
||||
|
||||
const insertRoleAction = db.prepare(`
|
||||
INSERT INTO 'roleActions' ("roleId", "actionId", "orgId")
|
||||
@@ -456,14 +459,14 @@ export default async function migration() {
|
||||
throw new Error(fromZodError(parsedConfig.error).toString());
|
||||
}
|
||||
|
||||
traefikConfig.experimental.plugins.badger.version = "v1.6.1";
|
||||
traefikConfig.experimental.plugins.badger.version = "v1.7.0";
|
||||
|
||||
const updatedTraefikYaml = yaml.dump(traefikConfig);
|
||||
|
||||
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
|
||||
|
||||
console.log(
|
||||
"Updated the version of Badger in your Traefik configuration to v1.6.1"
|
||||
"Updated the version of Badger in your Traefik configuration to v1.7.0"
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(
|
||||
|
||||
@@ -12,6 +12,7 @@ import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||
import { ArrowUpRight, Key, User } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
|
||||
import { ColumnMultiFilterButton } from "@app/components/ColumnMultiFilterButton";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { build } from "@server/build";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
@@ -26,6 +27,7 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { logQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { QueryAccessAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const router = useRouter();
|
||||
@@ -45,12 +47,14 @@ export default function GeneralPage() {
|
||||
resourceId?: string;
|
||||
location?: string;
|
||||
actor?: string;
|
||||
ip?: 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
|
||||
actor: searchParams.get("actor") || undefined,
|
||||
ip: searchParams.getAll("ip") || undefined
|
||||
});
|
||||
|
||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||
@@ -176,7 +180,7 @@ export default function GeneralPage() {
|
||||
|
||||
const handleFilterChange = (
|
||||
filterType: keyof typeof filters,
|
||||
value: string | undefined
|
||||
value: string | string[] | undefined
|
||||
) => {
|
||||
const newFilters = { ...filters, [filterType]: value };
|
||||
setFilters(newFilters);
|
||||
@@ -194,10 +198,13 @@ export default function GeneralPage() {
|
||||
) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
Object.entries(newFilters).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
if (typeof value === "string") {
|
||||
params.set(key, value);
|
||||
} else if (typeof value !== "undefined" && "length" in value) {
|
||||
for (const element of value) {
|
||||
params.append(key, element);
|
||||
}
|
||||
}
|
||||
});
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
@@ -205,6 +212,7 @@ export default function GeneralPage() {
|
||||
|
||||
const exportData = async () => {
|
||||
try {
|
||||
const { ip, ...restFilters } = filters;
|
||||
const params: any = {
|
||||
timeStart: dateRange.startDate?.date
|
||||
? new Date(dateRange.startDate.date).toISOString()
|
||||
@@ -212,13 +220,20 @@ export default function GeneralPage() {
|
||||
timeEnd: dateRange.endDate?.date
|
||||
? new Date(dateRange.endDate.date).toISOString()
|
||||
: undefined,
|
||||
...filters
|
||||
...restFilters
|
||||
};
|
||||
|
||||
const response = await api.get(`/org/${orgId}/logs/access/export`, {
|
||||
// axios serializes arrays as `ip[]=…`, which express's query
|
||||
// parser does not read back as `ip`, so pass them in the URL
|
||||
const sp = new URLSearchParams((ip ?? []).map((ip) => ["ip", ip]));
|
||||
|
||||
const response = await api.get(
|
||||
`/org/${orgId}/logs/access/export?${sp.toString()}`,
|
||||
{
|
||||
responseType: "blob",
|
||||
params
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement("a");
|
||||
@@ -297,7 +312,24 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: () => <span className="px-2">{t("ip")}</span>,
|
||||
header: () => (
|
||||
<span className="px-2">
|
||||
<ColumnMultiFilterButton
|
||||
options={(filters.ip ?? []).map((ip) => ({
|
||||
label: ip,
|
||||
value: ip
|
||||
}))}
|
||||
label={t("ip")}
|
||||
allowArbitraryValues
|
||||
searchPlaceholder={t("ipFilterSearchPlaceholder")}
|
||||
emptyMessage={t("ipFilterEmptyMessage")}
|
||||
selectedValues={filters.ip ?? []}
|
||||
onSelectedValuesChange={(value) =>
|
||||
handleFilterChange("ip", value)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return row.original.ip ? (
|
||||
row.original.ip
|
||||
@@ -315,7 +347,7 @@ export default function GeneralPage() {
|
||||
options={filterAttributes.locations.map(
|
||||
(location) => ({
|
||||
value: location,
|
||||
label: location
|
||||
label: `${location} ${countryCodeToFlagEmoji(location)}`
|
||||
})
|
||||
)}
|
||||
label={t("location")}
|
||||
@@ -334,7 +366,8 @@ export default function GeneralPage() {
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.location ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{row.original.location}
|
||||
{row.original.location}{" "}
|
||||
{countryCodeToFlagEmoji(row.original.location)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
|
||||
@@ -276,7 +276,9 @@ export default function AiSessionLogsPage() {
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="whitespace-nowrap">
|
||||
{new Date(row.original.createdAt).toLocaleString()}
|
||||
{new Date(
|
||||
row.original.createdAt * 1000
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -715,8 +717,8 @@ function generateSampleAiSessionLogs(): QueryAiSessionLogResponse["log"] {
|
||||
null
|
||||
];
|
||||
|
||||
const now = Date.now();
|
||||
const sevenDaysAgoMs = now - 7 * 24 * 60 * 60 * 1000;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const sevenDaysAgoMs = now - 7 * 24 * 60 * 60;
|
||||
|
||||
return Array.from({ length: 10 }, (_, i) => {
|
||||
const provider =
|
||||
|
||||
@@ -23,6 +23,8 @@ import { useMemo, useState, useTransition } from "react";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
|
||||
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
|
||||
import { ColumnMultiFilterButton } from "@app/components/ColumnMultiFilterButton";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const router = useRouter();
|
||||
@@ -47,6 +49,7 @@ export default function GeneralPage() {
|
||||
method?: string;
|
||||
reason?: string;
|
||||
path?: string;
|
||||
ip?: string[];
|
||||
}>({
|
||||
action: searchParams.get("action") || undefined,
|
||||
host: searchParams.get("host") || undefined,
|
||||
@@ -55,7 +58,8 @@ export default function GeneralPage() {
|
||||
actor: searchParams.get("actor") || undefined,
|
||||
method: searchParams.get("method") || undefined,
|
||||
reason: searchParams.get("reason") || undefined,
|
||||
path: searchParams.get("path") || undefined
|
||||
path: searchParams.get("path") || undefined,
|
||||
ip: searchParams.getAll("ip") || undefined
|
||||
});
|
||||
|
||||
const getDefaultDateRange = () => {
|
||||
@@ -179,7 +183,7 @@ export default function GeneralPage() {
|
||||
|
||||
const handleFilterChange = (
|
||||
filterType: keyof typeof filters,
|
||||
value: string | undefined
|
||||
value: string | string[] | undefined
|
||||
) => {
|
||||
const newFilters = { ...filters, [filterType]: value };
|
||||
setFilters(newFilters);
|
||||
@@ -197,10 +201,13 @@ export default function GeneralPage() {
|
||||
) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
Object.entries(newFilters).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
if (typeof value === "string") {
|
||||
params.set(key, value);
|
||||
} else if (typeof value !== "undefined" && "length" in value) {
|
||||
for (const element of value) {
|
||||
params.append(key, element);
|
||||
}
|
||||
}
|
||||
});
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
@@ -209,6 +216,7 @@ export default function GeneralPage() {
|
||||
const exportData = async () => {
|
||||
try {
|
||||
// Prepare query params for export
|
||||
const { ip, ...restFilters } = filters;
|
||||
const params: any = {
|
||||
timeStart: dateRange.startDate?.date
|
||||
? new Date(dateRange.startDate.date).toISOString()
|
||||
@@ -216,11 +224,15 @@ export default function GeneralPage() {
|
||||
timeEnd: dateRange.endDate?.date
|
||||
? new Date(dateRange.endDate.date).toISOString()
|
||||
: undefined,
|
||||
...filters
|
||||
...restFilters
|
||||
};
|
||||
|
||||
// axios serializes arrays as `ip[]=…`, which express's query
|
||||
// parser does not read back as `ip`, so pass them in the URL
|
||||
const sp = new URLSearchParams((ip ?? []).map((ip) => ["ip", ip]));
|
||||
|
||||
const response = await api.get(
|
||||
`/org/${orgId}/logs/request/export`,
|
||||
`/org/${orgId}/logs/request/export?${sp.toString()}`,
|
||||
{
|
||||
responseType: "blob",
|
||||
params
|
||||
@@ -351,7 +363,24 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: ({ column }) => <span className="px-2">{t("ip")}</span>,
|
||||
header: ({ column }) => (
|
||||
<span className="px-2">
|
||||
<ColumnMultiFilterButton
|
||||
options={(filters.ip ?? []).map((ip) => ({
|
||||
label: ip,
|
||||
value: ip
|
||||
}))}
|
||||
label={t("ip")}
|
||||
allowArbitraryValues
|
||||
searchPlaceholder={t("ipFilterSearchPlaceholder")}
|
||||
emptyMessage={t("ipFilterEmptyMessage")}
|
||||
selectedValues={filters.ip ?? []}
|
||||
onSelectedValuesChange={(value) =>
|
||||
handleFilterChange("ip", value)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return row.original.ip ? (
|
||||
row.original.ip
|
||||
@@ -369,7 +398,7 @@ export default function GeneralPage() {
|
||||
options={filterAttributes.locations.map(
|
||||
(location) => ({
|
||||
value: location,
|
||||
label: location
|
||||
label: `${location} ${countryCodeToFlagEmoji(location)}`
|
||||
})
|
||||
)}
|
||||
selectedValue={filters.location}
|
||||
@@ -389,7 +418,8 @@ export default function GeneralPage() {
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.location ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{row.original.location}
|
||||
{row.original.location}{" "}
|
||||
{countryCodeToFlagEmoji(row.original.location)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
|
||||
@@ -50,8 +50,6 @@ import {
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import {
|
||||
createBrowserGatewayTargetFormSchema,
|
||||
@@ -59,7 +57,6 @@ import {
|
||||
selectedSiteSchema,
|
||||
type SshSettingsFormValues
|
||||
} from "@app/lib/browserGatewayTargetFormSchema";
|
||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -328,19 +325,20 @@ export default function Page() {
|
||||
const rawResourcesAllowed =
|
||||
env.flags.allowRawResources &&
|
||||
(build !== "saas" || remoteExitNodes.length > 0);
|
||||
const enterpriseModesAllowed =
|
||||
!env.flags.disableEnterpriseFeatures;
|
||||
|
||||
const availableTypes = useMemo((): NewResourceType[] => {
|
||||
const base: NewResourceType[] = ["http", "inference"];
|
||||
if (enterpriseModesAllowed) {
|
||||
base.push("ssh", "rdp", "vnc");
|
||||
}
|
||||
const base: NewResourceType[] = [
|
||||
"http",
|
||||
"inference",
|
||||
"ssh",
|
||||
"rdp",
|
||||
"vnc"
|
||||
];
|
||||
if (rawResourcesAllowed) {
|
||||
base.push("tcp", "udp");
|
||||
}
|
||||
return base;
|
||||
}, [enterpriseModesAllowed, rawResourcesAllowed]);
|
||||
}, [rawResourcesAllowed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!availableTypes.includes(resourceType)) {
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useTranslations } from "next-intl";
|
||||
|
||||
interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
label: React.ReactNode;
|
||||
}
|
||||
|
||||
interface ColumnFilterButtonProps {
|
||||
@@ -32,6 +32,7 @@ interface ColumnFilterButtonProps {
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
label: string;
|
||||
allowArbitraryValues?: boolean;
|
||||
}
|
||||
|
||||
export function ColumnFilterButton({
|
||||
@@ -41,7 +42,8 @@ export function ColumnFilterButton({
|
||||
searchPlaceholder = "Search...",
|
||||
emptyMessage = "No options found",
|
||||
className,
|
||||
label
|
||||
label,
|
||||
allowArbitraryValues
|
||||
}: ColumnFilterButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -101,7 +103,7 @@ export function ColumnFilterButton({
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
value={option.value}
|
||||
onSelect={() => {
|
||||
onValueChange(
|
||||
selectedValue === option.value
|
||||
|
||||
@@ -35,6 +35,7 @@ type ColumnMultiFilterButtonProps = {
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
label: string;
|
||||
allowArbitraryValues?: boolean;
|
||||
};
|
||||
|
||||
export function ColumnMultiFilterButton({
|
||||
@@ -44,11 +45,26 @@ export function ColumnMultiFilterButton({
|
||||
searchPlaceholder = "Search...",
|
||||
emptyMessage = "No options found",
|
||||
className,
|
||||
label
|
||||
label,
|
||||
allowArbitraryValues
|
||||
}: ColumnMultiFilterButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const t = useTranslations();
|
||||
|
||||
const visibleOptions = useMemo<FilterOption[]>(() => {
|
||||
const newOptions = [...options];
|
||||
|
||||
if (allowArbitraryValues && searchQuery.trim().length > 0) {
|
||||
newOptions.push({
|
||||
label: searchQuery,
|
||||
value: searchQuery
|
||||
});
|
||||
}
|
||||
|
||||
return newOptions;
|
||||
}, [options, allowArbitraryValues, searchQuery]);
|
||||
|
||||
const selectedSet = useMemo(
|
||||
() => new Set(selectedValues),
|
||||
[selectedValues]
|
||||
@@ -64,7 +80,7 @@ export function ColumnMultiFilterButton({
|
||||
selectedValues[0]
|
||||
);
|
||||
}
|
||||
return t("accessUsersRoleFilterCount", {
|
||||
return t("multiSelectFilterCount", {
|
||||
count: selectedValues.length
|
||||
});
|
||||
}, [selectedValues, options, t]);
|
||||
@@ -108,7 +124,11 @@ export function ColumnMultiFilterButton({
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={searchPlaceholder} />
|
||||
<CommandInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
@@ -123,7 +143,7 @@ export function ColumnMultiFilterButton({
|
||||
{t("accessFilterClear")}
|
||||
</CommandItem>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
{visibleOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { assertEquals } from "@test/assert";
|
||||
import { detectLocale } from "./detectLocale";
|
||||
|
||||
function runTests() {
|
||||
assertEquals(
|
||||
detectLocale("zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7,ja;q=0.6"),
|
||||
"zh-TW",
|
||||
"An exact regional match should take precedence over a language fallback"
|
||||
);
|
||||
assertEquals(
|
||||
detectLocale("ZH-tw"),
|
||||
"zh-TW",
|
||||
"Locale matching should be case-insensitive"
|
||||
);
|
||||
assertEquals(
|
||||
detectLocale(" zh-TW ; q=1 , zh-CN;q=0.8 "),
|
||||
"zh-TW",
|
||||
"Whitespace and quality parameters should not prevent an exact match"
|
||||
);
|
||||
assertEquals(
|
||||
detectLocale("zh-CN,zh-TW;q=0.9"),
|
||||
"zh-CN",
|
||||
"Simplified Chinese should still match exactly"
|
||||
);
|
||||
assertEquals(
|
||||
detectLocale("zh"),
|
||||
"zh-CN",
|
||||
"A generic Chinese preference should retain the existing fallback"
|
||||
);
|
||||
assertEquals(
|
||||
detectLocale("en-GB,en;q=0.9"),
|
||||
"en-US",
|
||||
"An unsupported region should fall back to a supported locale for the language"
|
||||
);
|
||||
assertEquals(
|
||||
detectLocale("ja-JP,zh-TW;q=0.9"),
|
||||
"zh-TW",
|
||||
"The next preference should be used when a language is unsupported"
|
||||
);
|
||||
assertEquals(
|
||||
detectLocale("zh-CN;q=0.5,zh-TW;q=0.9"),
|
||||
"zh-TW",
|
||||
"Preferences should be evaluated by quality"
|
||||
);
|
||||
assertEquals(
|
||||
detectLocale("zh-TW;q=0,fr-FR;q=0.8"),
|
||||
"fr-FR",
|
||||
"Locales with zero quality should be excluded"
|
||||
);
|
||||
assertEquals(
|
||||
detectLocale("*,de-DE;q=0.8"),
|
||||
"de-DE",
|
||||
"A wildcard should not obscure a supported preference"
|
||||
);
|
||||
assertEquals(
|
||||
detectLocale("ja-JP"),
|
||||
undefined,
|
||||
"An unsupported language should not match"
|
||||
);
|
||||
assertEquals(
|
||||
detectLocale(""),
|
||||
undefined,
|
||||
"An empty Accept-Language header should not match"
|
||||
);
|
||||
|
||||
console.log("All locale detection tests passed!");
|
||||
}
|
||||
|
||||
try {
|
||||
runTests();
|
||||
} catch (error) {
|
||||
console.error("Locale detection test failed:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Locale, locales } from "./config";
|
||||
|
||||
export function detectLocale(acceptLanguage: string): Locale | undefined {
|
||||
const browserLocales = acceptLanguage
|
||||
.split(",")
|
||||
.map((entry, index) => {
|
||||
const [locale, ...parameters] = entry.trim().split(";");
|
||||
const qualityParameter = parameters.find((parameter) =>
|
||||
parameter.trim().toLowerCase().startsWith("q=")
|
||||
);
|
||||
const quality = qualityParameter
|
||||
? Number(qualityParameter.trim().slice(2))
|
||||
: 1;
|
||||
|
||||
return {
|
||||
locale: locale.trim().toLowerCase(),
|
||||
quality,
|
||||
index
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
({ locale, quality }) =>
|
||||
locale && locale !== "*" && quality > 0 && quality <= 1
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.quality - left.quality || left.index - right.index
|
||||
);
|
||||
|
||||
for (const { locale: browserLocale } of browserLocales) {
|
||||
const exactMatch = locales.find(
|
||||
(locale) => locale.toLowerCase() === browserLocale
|
||||
);
|
||||
if (exactMatch) {
|
||||
return exactMatch;
|
||||
}
|
||||
|
||||
const browserLanguage = browserLocale.split("-")[0];
|
||||
const languageMatch = locales.find(
|
||||
(locale) => locale.split("-")[0].toLowerCase() === browserLanguage
|
||||
);
|
||||
if (languageMatch) {
|
||||
return languageMatch;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
+30
-22
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
getAiBudgetScopeListPath,
|
||||
type AiBudgetScope
|
||||
} from "@app/lib/aiBudgetScope";
|
||||
import type { AiProviderType } from "@app/lib/aiProviderDefaults";
|
||||
import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
|
||||
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
|
||||
import { build } from "@server/build";
|
||||
@@ -5,15 +10,21 @@ import {
|
||||
StatusHistoryResponse,
|
||||
type BatchedStatusHistoryResponse
|
||||
} from "@server/lib/statusHistory";
|
||||
import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types";
|
||||
import type {
|
||||
ListAiModelsResponse,
|
||||
ListAiProvidersResponse,
|
||||
ListCatalogModelsResponse
|
||||
} from "@server/routers/aiProvider/types";
|
||||
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
|
||||
import type {
|
||||
QueryRequestAnalyticsResponse,
|
||||
QueryAiUsageFilterOptionsResponse,
|
||||
QueryAiUsageOverviewResponse,
|
||||
QueryAiUsageProvidersResponse,
|
||||
QueryAiUsageResourcesResponse,
|
||||
QueryAiUsageUsersRolesResponse,
|
||||
QueryAiUsageVirtualApiKeysResponse
|
||||
QueryAiUsageVirtualApiKeysResponse,
|
||||
QueryRequestAnalyticsResponse
|
||||
} from "@server/routers/auditLogs";
|
||||
import type {
|
||||
QueryAccessAuditLogResponse,
|
||||
@@ -34,6 +45,7 @@ import type {
|
||||
import type { GetDomainResponse } from "@server/routers/domain/getDomain";
|
||||
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
||||
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
||||
import type { ListLauncherAiModelsResponse } from "@server/routers/launcher/listLauncherAiModels";
|
||||
import type {
|
||||
LauncherResource,
|
||||
ListLauncherGroupsResponse,
|
||||
@@ -43,9 +55,8 @@ import type {
|
||||
ListLauncherSitesResponse,
|
||||
ListLauncherViewsResponse
|
||||
} from "@server/routers/launcher/types";
|
||||
import type { ListLauncherAiModelsResponse } from "@server/routers/launcher/listLauncherAiModels";
|
||||
import type { ListMyVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types";
|
||||
import type { GetResourcePolicyResponse } from "@server/routers/policy";
|
||||
import type { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
|
||||
import type {
|
||||
GetResourcePoliciesResponse,
|
||||
GetResourceWhitelistResponse,
|
||||
@@ -59,7 +70,6 @@ import type {
|
||||
import type { GetResourceResponse } from "@server/routers/resource/getResource";
|
||||
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
|
||||
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
|
||||
import type { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
|
||||
import type { ListRolesResponse } from "@server/routers/role";
|
||||
import type { ListSitesResponse } from "@server/routers/site";
|
||||
import type {
|
||||
@@ -71,18 +81,8 @@ import type {
|
||||
} from "@server/routers/siteResource";
|
||||
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
|
||||
import type { ListTargetsResponse } from "@server/routers/target";
|
||||
import type {
|
||||
ListAiModelsResponse,
|
||||
ListAiProvidersResponse,
|
||||
ListCatalogModelsResponse
|
||||
} from "@server/routers/aiProvider/types";
|
||||
import type { AiProviderType } from "@app/lib/aiProviderDefaults";
|
||||
import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types";
|
||||
import {
|
||||
getAiBudgetScopeListPath,
|
||||
type AiBudgetScope
|
||||
} from "@app/lib/aiBudgetScope";
|
||||
import type { ListUsersResponse } from "@server/routers/user";
|
||||
import type { ListMyVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types";
|
||||
import type ResponseT from "@server/types/Response";
|
||||
import {
|
||||
infiniteQueryOptions,
|
||||
@@ -1000,7 +1000,8 @@ export const httpLogsFiltersSchema = z.object({
|
||||
actor: z.string().optional().catch(undefined),
|
||||
method: z.string().optional().catch(undefined),
|
||||
reason: z.string().optional().catch(undefined),
|
||||
path: z.string().optional().catch(undefined)
|
||||
path: z.string().optional().catch(undefined),
|
||||
ip: z.array(z.string()).optional().catch(undefined)
|
||||
});
|
||||
|
||||
export type HttpLogFilters = z.output<typeof httpLogsFiltersSchema>;
|
||||
@@ -1026,7 +1027,8 @@ export const accessLogsFiltersSchema = z.object({
|
||||
action: z.string().optional().catch(undefined),
|
||||
location: z.string().optional().catch(undefined),
|
||||
actor: z.string().optional().catch(undefined),
|
||||
type: z.string().optional().catch(undefined)
|
||||
type: z.string().optional().catch(undefined),
|
||||
ip: z.array(z.string()).optional().catch(undefined)
|
||||
});
|
||||
|
||||
export type AccessLogFilters = z.output<typeof accessLogsFiltersSchema>;
|
||||
@@ -1139,10 +1141,13 @@ export const logQueries = {
|
||||
queryOptions({
|
||||
queryKey: ["REQUEST_LOGS", orgId, "ALL", filters] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const { page, pageSize, ...rest } = filters;
|
||||
const { page, pageSize, ip, ...rest } = filters;
|
||||
const sp = new URLSearchParams(
|
||||
(ip ?? []).map((ip) => ["ip", ip])
|
||||
);
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<QueryRequestAuditLogResponse>
|
||||
>(`/org/${orgId}/logs/request`, {
|
||||
>(`/org/${orgId}/logs/request?${sp.toString()}`, {
|
||||
params: {
|
||||
...rest,
|
||||
limit: pageSize,
|
||||
@@ -1164,10 +1169,13 @@ export const logQueries = {
|
||||
queryOptions({
|
||||
queryKey: ["ACCESS_LOGS", orgId, "ALL", filters] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const { page, pageSize, ...rest } = filters;
|
||||
const { page, pageSize, ip, ...rest } = filters;
|
||||
const sp = new URLSearchParams(
|
||||
(ip ?? []).map((ip) => ["ip", ip])
|
||||
);
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<QueryAccessAuditLogResponse>
|
||||
>(`/org/${orgId}/logs/access`, {
|
||||
>(`/org/${orgId}/logs/access?${sp.toString()}`, {
|
||||
params: {
|
||||
...rest,
|
||||
limit: pageSize,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { Locale, defaultLocale, locales } from "@/i18n/config";
|
||||
import { detectLocale } from "@/i18n/detectLocale";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
|
||||
@@ -47,12 +48,7 @@ export async function getUserLocale(): Promise<Locale> {
|
||||
const acceptLang = headerList.get("accept-language");
|
||||
|
||||
if (acceptLang) {
|
||||
const browserLang = acceptLang.split(",")[0];
|
||||
const matched = locales.find((locale) =>
|
||||
browserLang
|
||||
.toLowerCase()
|
||||
.startsWith(locale.split("-")[0].toLowerCase())
|
||||
);
|
||||
const matched = detectLocale(acceptLang);
|
||||
if (matched) {
|
||||
return matched;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user