Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de57df2520 | |||
| 9e392a967d | |||
| 52f7364c35 | |||
| 1bc5fbbf0f | |||
| be0dd65557 | |||
| 11be7b8ee4 | |||
| 9373cc0408 | |||
| e50763c340 | |||
| 8826240548 | |||
| 6d83614482 | |||
| c319b7a65f | |||
| aa7fe7ee0e | |||
| 5fd71df2ae | |||
| c7e1462e46 | |||
| cfb73c9e21 | |||
| 72a9040c2e | |||
| ce53b8c73d | |||
| 6ff2b3e2fe | |||
| 4dada38cb0 | |||
| 732ea034f8 | |||
| 581137c486 | |||
| d75c6da3be | |||
| 11b51202b8 | |||
| c35de18b9b | |||
| 353273a9f2 | |||
| 7c20a292e1 | |||
| 6c13d6e343 | |||
| e641a8ce1b | |||
| a3419f60d6 | |||
| 2db3051a1a | |||
| 3c49b6f3d6 | |||
| 9ad4e36fa9 | |||
| c489ed5724 | |||
| fca3a1e5fa | |||
| b29348d004 | |||
| 337d9a33a3 | |||
| c61e96b1f0 | |||
| 35bc692892 | |||
| 714e7e0ba4 | |||
| 49d5b0ec34 | |||
| e0937a3afa | |||
| 8d7e73afa8 | |||
| a5ce56ea89 | |||
| 2f013335f9 | |||
| b4f6ae74d7 | |||
| f1711ee0b0 | |||
| 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 | |||
| 5bdb12dafe | |||
| f3474dac98 | |||
| 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 |
@@ -34,6 +34,14 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: AI Disclosure
|
||||
description: |
|
||||
If you used AI to help write this issue, please disclose it here. This is important for transparency and helps maintain the integrity of the issue tracking process.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,11 +37,22 @@
|
||||
|
||||
<p align="center">
|
||||
<strong>
|
||||
Get started with Pangolin at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
|
||||
Get started with Pangolin Cloud at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure connectivity to infrastructure anywhere. It combines reverse-proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to private resources with NAT traversal, all with granular access control.
|
||||
Pangolin is an open-source SASE platform, built on WireGuard®, with a simple mission: connect and protect your users, wherever they are. It brings networking and security together as one system including a zero-trust VPN, zero-trust reverse proxy, privileged access management, and an identity-aware AI gateway, all sharing one identity and policy model. It's the same idea behind platforms like Cloudflare One, Zscaler, and Prisma but open, self-hostable, and built to stay light and easy to deploy.
|
||||
|
||||
### Networking and security that's unified, open, and simple
|
||||
|
||||
Legacy SASE platforms got the idea right: connectivity and security belong together. But they delivered it as a heavyweight, closed, cloud-locked stack assembled from years of patchwork. Pangolin exists to do that unification differently, in the open, self-hostable, and simple enough that administrators actually enjoy running it.
|
||||
|
||||
* **Open source, not a black box**: the code is open and auditable, so you can see exactly how your traffic is handled and how access decisions get made, instead of trusting a closed cloud control plane.
|
||||
* **Networking and security as one platform**: sites, reverse proxy, client access, RBAC, and the AI gateway share one identity and policy model, so protecting users and connecting them are executed together.
|
||||
* **Lightweight by design**: the whole platform is built to stay small and fast: easy to self-host on a small server, with a lightweight, user-space connector that goes in your private networks.
|
||||
* **Enjoyable to use**: a clean, modern interface and a setup flow that gets out of your way, so managing access feels simple instead of like fighting a legacy admin console.
|
||||
* **Zero trust from day one**: access is granted per resource, not per network, with identity provider integration, role-based access control, and full audit logging.
|
||||
* **Run it your way**: self-host the Community Edition for free, step up to the Enterprise Edition for advanced features, or use Pangolin Cloud if you'd rather not manage infrastructure at all.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -53,9 +64,9 @@ Pangolin is an open-source, identity-based remote access platform built on WireG
|
||||
|
||||
## Deployment Options
|
||||
|
||||
- **Pangolin Cloud** - Fully managed service - no infrastructure required.
|
||||
- **Self-Host: Community Edition** - Free, open source, and licensed under AGPL-3.
|
||||
- **Self-Host: Enterprise Edition** - Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses making less than \$100K USD gross annual revenue.
|
||||
- **Pangolin Cloud** - Fully managed service with no infrastructure required.
|
||||
- **Self-Host: Community Edition** - Free, open-source, and licensed under AGPL-3.
|
||||
- **Self-Host: Enterprise Edition** - Open-core, and licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses making less than \$100K USD gross annual revenue.
|
||||
|
||||
## Key Features
|
||||
|
||||
@@ -99,6 +110,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"
|
||||
|
||||
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "Научете повече за JMESPath",
|
||||
"idpJmespathLabel": "Идентификатор на пътя",
|
||||
"idpJmespathLabelDescription": "Пътят към идентификатора на потребителя в ID токена",
|
||||
"idpIdentifierChangeTitle": "Предупреждение за промяна на пътя на идентификатора",
|
||||
"idpIdentifierChangeDescription": "Ще промените пътя на идентификатора. Това ще повлияе на начина, по който съществуващите потребители са разпределени. Потребители, които преди са влизали чрез този доставчик на идентичности, може вече да не бъдат разпознавани като същите потребители.",
|
||||
"idpIdentifierChangeConfirmMessage": "Потвърждавам",
|
||||
"idpIdentifierChangeWarningText": "Това ще повлияе на начина, по който съществуващите потребители са разпределени",
|
||||
"idpJmespathEmailPathOptional": "Път за имейл (по избор)",
|
||||
"idpJmespathEmailPathOptionalDescription": "Пътят до имейла на потребителя в ID токена",
|
||||
"idpJmespathNamePathOptional": "Път (по избор) на име",
|
||||
@@ -1573,6 +1577,8 @@
|
||||
"search": "Търси…",
|
||||
"searchPlaceholder": "Търсене...",
|
||||
"emptySearchOptions": "Няма намерени опции",
|
||||
"ipFilterSearchPlaceholder": "Въведете IP адрес…",
|
||||
"ipFilterEmptyMessage": "Въведете IP адрес, за да филтрирате по него",
|
||||
"create": "Създаване",
|
||||
"orgs": "Организации",
|
||||
"loginError": "Възникна неочаквана грешка. Моля, опитайте отново.",
|
||||
@@ -2596,6 +2602,7 @@
|
||||
"createDomainType": "Тип:",
|
||||
"createDomainName": "Име:",
|
||||
"createDomainValue": "Стойност:",
|
||||
"multiSelectFilterCount": "{count} избрани",
|
||||
"createDomainCnameRecords": "CNAME записи",
|
||||
"createDomainARecords": "A записи",
|
||||
"createDomainRecordNumber": "Запис {number}",
|
||||
@@ -2710,6 +2717,7 @@
|
||||
"healthScheme": "Метод",
|
||||
"healthSelectScheme": "Избор на метод",
|
||||
"healthCheckPortInvalid": "Портът трябва да бъде между 1 и 65535",
|
||||
"healthCheckHostnameInvalid": "Името на хоста не трябва да съдържа празни символи",
|
||||
"healthCheckPath": "Път",
|
||||
"healthHostname": "IP / Хост",
|
||||
"healthPort": "Порт",
|
||||
@@ -2993,7 +3001,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Добавете CIDR диапазон (напр. 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Неуспешно зареждане на подмрежи",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Етикети за Предпочитания",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Сайтове с тези етикети ще бъдат принудени да се свържат чрез този отдалечен край.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Сайтовете с тези етикети ще предпочетат да се свържат чрез този отдалечен изходен възел.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Изберете етикети...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Търсене на етикети...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Неуспешно зареждане на етикети",
|
||||
|
||||
@@ -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": "Varování o změně cesty identifikátoru",
|
||||
"idpIdentifierChangeDescription": "Chystáte se změnit cestu identifikátoru. Tímto se změní způsob mapování stávajících uživatelů. Uživatelé, kteří se dříve přihlásili přes tohoto poskytovatele identity, již nemusí být rozpoznáni jako stejní uživatelé.",
|
||||
"idpIdentifierChangeConfirmMessage": "Potvrzuji",
|
||||
"idpIdentifierChangeWarningText": "Toto ovlivní, jak budou mapováni stávající uživatelé",
|
||||
"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": "Zadejte IP adresu…",
|
||||
"ipFilterEmptyMessage": "Zadejte IP adresu pro filtrování",
|
||||
"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} vybráno",
|
||||
"createDomainCnameRecords": "Záznamy CNAME",
|
||||
"createDomainARecords": "Záznamy",
|
||||
"createDomainRecordNumber": "Nahrát {number}",
|
||||
@@ -2710,6 +2717,7 @@
|
||||
"healthScheme": "Způsob",
|
||||
"healthSelectScheme": "Vybrat metodu",
|
||||
"healthCheckPortInvalid": "Port musí být mezi 1 a 65535",
|
||||
"healthCheckHostnameInvalid": "Název hostitele nesmí obsahovat mezery",
|
||||
"healthCheckPath": "Cesta",
|
||||
"healthHostname": "IP / Hostitel",
|
||||
"healthPort": "Přístav",
|
||||
@@ -2993,7 +3001,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": "Stránky s těmito štítky preferují spojení přes tento vzdálený výstupní uzel.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Vyberte názvy...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Hledat názvy...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Nepodařilo se načíst názvy",
|
||||
|
||||
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "Læs mere om JMESPath",
|
||||
"idpJmespathLabel": "Identifikatorsti",
|
||||
"idpJmespathLabelDescription": "Stien til brugeridentifikatoren i ID-tokenet",
|
||||
"idpIdentifierChangeTitle": "Advarsel om ændring af identifikatorsti",
|
||||
"idpIdentifierChangeDescription": "Du er ved at ændre identifikatorstien. Dette vil påvirke, hvordan eksisterende brugere bliver kortlagt. Brugere, der tidligere har logget ind gennem denne identitetsudbyder, genkendes muligvis ikke længere som de samme brugere.",
|
||||
"idpIdentifierChangeConfirmMessage": "Jeg bekræfter",
|
||||
"idpIdentifierChangeWarningText": "Dette vil påvirke, hvordan eksisterende brugere bliver kortlagt",
|
||||
"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": "Indtast en IP-adresse…",
|
||||
"ipFilterEmptyMessage": "Indtast en IP-adresse for at filtrere efter",
|
||||
"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} valgt",
|
||||
"createDomainCnameRecords": "CNAME-poster",
|
||||
"createDomainARecords": "A-poster",
|
||||
"createDomainRecordNumber": "Post {number}",
|
||||
@@ -2710,6 +2717,7 @@
|
||||
"healthScheme": "Metode",
|
||||
"healthSelectScheme": "Vælg metode",
|
||||
"healthCheckPortInvalid": "Porten skal være mellem 1 og 65535",
|
||||
"healthCheckHostnameInvalid": "Værtsnavnet må ikke indeholde mellemrum",
|
||||
"healthCheckPath": "Sti",
|
||||
"healthHostname": "IP / Vært",
|
||||
"healthPort": "Port",
|
||||
@@ -2993,7 +3001,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": "Sider med disse etiketter vil foretrække at forbinde gennem denne fjernudgarnknude.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Vælg etiketter...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Søg efter etiketter...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Kunne ikke indlæse etiketter",
|
||||
|
||||
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "Mehr über JMESPath erfahren",
|
||||
"idpJmespathLabel": "Identifikationspfad",
|
||||
"idpJmespathLabelDescription": "Der JMESPath zum Benutzeridentifikator im ID-Token",
|
||||
"idpIdentifierChangeTitle": "Warnung zur Änderung des Identifikatorpfads",
|
||||
"idpIdentifierChangeDescription": "Sie stehen kurz davor, den Identifikatorpfad zu ändern. Dies wird beeinflussen, wie bestehende Benutzer zugeordnet werden. Benutzer, die sich zuvor über diesen Identitätsanbieter angemeldet haben, werden möglicherweise nicht mehr als dieselben Benutzer erkannt.",
|
||||
"idpIdentifierChangeConfirmMessage": "Ich bestätige",
|
||||
"idpIdentifierChangeWarningText": "Dies wird beeinflussen, wie bestehende Benutzer zugeordnet werden",
|
||||
"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": "Geben Sie eine IP-Adresse ein…",
|
||||
"ipFilterEmptyMessage": "Geben Sie eine IP-Adresse zur Filterung ein",
|
||||
"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} ausgewählt",
|
||||
"createDomainCnameRecords": "CNAME-Einträge",
|
||||
"createDomainARecords": "A-Aufzeichnungen",
|
||||
"createDomainRecordNumber": "Eintrag {number}",
|
||||
@@ -2710,6 +2717,7 @@
|
||||
"healthScheme": "Methode",
|
||||
"healthSelectScheme": "Methode auswählen",
|
||||
"healthCheckPortInvalid": "Der Port muss zwischen 1 und 65535 liegen",
|
||||
"healthCheckHostnameInvalid": "Der Hostname darf keinen Leerraum enthalten",
|
||||
"healthCheckPath": "Pfad",
|
||||
"healthHostname": "IP / Host",
|
||||
"healthPort": "Port",
|
||||
@@ -2993,7 +3001,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": "Standorte mit diesen Labels bevorzugen die Verbindung über diesen Remote-Exit-Knoten.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Etiketten auswählen...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Etiketten suchen...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Fehler beim Laden der Etiketten",
|
||||
|
||||
@@ -465,8 +465,6 @@
|
||||
"apiKeysDelete": "Delete API Key",
|
||||
"apiKeysManage": "Manage API Keys",
|
||||
"apiKeysDescription": "API keys are used to authenticate with the integration API",
|
||||
"orgsManage": "Manage Organizations",
|
||||
"orgsDescription": "View and manage all organizations on this instance",
|
||||
"provisioningKeysTitle": "Provisioning Key",
|
||||
"provisioningKeysManage": "Manage Provisioning Keys",
|
||||
"provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.",
|
||||
@@ -1178,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "Learn more about JMESPath",
|
||||
"idpJmespathLabel": "Identifier Path",
|
||||
"idpJmespathLabelDescription": "The path 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": "Email Path (Optional)",
|
||||
"idpJmespathEmailPathOptionalDescription": "The path to the user's email in the ID token",
|
||||
"idpJmespathNamePathOptional": "Name Path (Optional)",
|
||||
@@ -1575,6 +1577,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.",
|
||||
@@ -2091,7 +2095,6 @@
|
||||
"resourceBudgetSettings": "Budget",
|
||||
"resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits",
|
||||
"sidebarApiKeys": "API Keys",
|
||||
"sidebarOrgs": "Organizations",
|
||||
"sidebarProvisioning": "Provisioning",
|
||||
"sidebarSettings": "Settings",
|
||||
"sidebarAllUsers": "All Users",
|
||||
@@ -2599,6 +2602,7 @@
|
||||
"createDomainType": "Type:",
|
||||
"createDomainName": "Name:",
|
||||
"createDomainValue": "Value:",
|
||||
"multiSelectFilterCount": "{count} selected",
|
||||
"createDomainCnameRecords": "CNAME Records",
|
||||
"createDomainARecords": "A Records",
|
||||
"createDomainRecordNumber": "Record {number}",
|
||||
@@ -2713,6 +2717,7 @@
|
||||
"healthScheme": "Method",
|
||||
"healthSelectScheme": "Select Method",
|
||||
"healthCheckPortInvalid": "Port must be between 1 and 65535",
|
||||
"healthCheckHostnameInvalid": "Hostname must not contain whitespace",
|
||||
"healthCheckPath": "Path",
|
||||
"healthHostname": "IP / Host",
|
||||
"healthPort": "Port",
|
||||
@@ -2996,7 +3001,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Add a CIDR range (e.g. 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Failed to load subnets",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Preference Labels",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will be enforced to connect through this remote exit node.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Select labels...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Search labels...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Failed to load labels",
|
||||
|
||||
@@ -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": "Advertencia de Cambio de Ruta de Identificador",
|
||||
"idpIdentifierChangeDescription": "Está a punto de cambiar la ruta del identificador. Esto afectará cómo se asignan los usuarios existentes. Los usuarios que anteriormente iniciaron sesión a través de este proveedor de identidad pueden ya no ser reconocidos como los mismos usuarios.",
|
||||
"idpIdentifierChangeConfirmMessage": "Confirmo",
|
||||
"idpIdentifierChangeWarningText": "Esto afectará cómo se asignan los usuarios existentes",
|
||||
"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": "Introduzca una dirección IP…",
|
||||
"ipFilterEmptyMessage": "Introduzca una dirección IP para filtrar por",
|
||||
"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} seleccionado",
|
||||
"createDomainCnameRecords": "Registros CNAME",
|
||||
"createDomainARecords": "Registros A",
|
||||
"createDomainRecordNumber": "Registro {number}",
|
||||
@@ -2710,6 +2717,7 @@
|
||||
"healthScheme": "Método",
|
||||
"healthSelectScheme": "Seleccionar método",
|
||||
"healthCheckPortInvalid": "El puerto debe estar entre 1 y 65535",
|
||||
"healthCheckHostnameInvalid": "El nombre de host no debe contener espacios en blanco",
|
||||
"healthCheckPath": "Ruta",
|
||||
"healthHostname": "IP / Nombre del host",
|
||||
"healthPort": "Puerto",
|
||||
@@ -2993,7 +3001,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": "Los sitios con estas etiquetas preferirán conectarse a través de este nodo de salida remoto.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Seleccionar etiquetas...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Buscar etiquetas...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Error al cargar las etiquetas",
|
||||
|
||||
@@ -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": "Avertissement de changement de chemin d'identification",
|
||||
"idpIdentifierChangeDescription": "Vous êtes sur le point de modifier le chemin d'identification. Cela affectera la manière dont les utilisateurs existants sont mappés. Les utilisateurs qui se sont connectés via ce fournisseur d'identité peuvent ne plus être reconnus comme les mêmes utilisateurs.",
|
||||
"idpIdentifierChangeConfirmMessage": "Je confirme",
|
||||
"idpIdentifierChangeWarningText": "Cela affectera la manière dont les utilisateurs existants sont mappés",
|
||||
"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": "Entrez une adresse IP…",
|
||||
"ipFilterEmptyMessage": "Entrez une adresse IP pour filtrer",
|
||||
"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} sélectionné",
|
||||
"createDomainCnameRecords": "Enregistrements CNAME",
|
||||
"createDomainARecords": "Enregistrements A",
|
||||
"createDomainRecordNumber": "Enregistrement {number}",
|
||||
@@ -2710,6 +2717,7 @@
|
||||
"healthScheme": "Méthode",
|
||||
"healthSelectScheme": "Sélectionnez la méthode",
|
||||
"healthCheckPortInvalid": "Le port doit être compris entre 1 et 65535",
|
||||
"healthCheckHostnameInvalid": "Le nom d'hôte ne doit pas contenir d'espaces blancs",
|
||||
"healthCheckPath": "Chemin d'accès",
|
||||
"healthHostname": "IP / Hôte",
|
||||
"healthPort": "Port",
|
||||
@@ -2993,7 +3001,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": "Les sites dotés de ces étiquettes préféreront se connecter via ce nœud de sortie distant.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Sélectionner des étiquettes...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Chercher des étiquettes...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Échec du chargement des étiquettes",
|
||||
|
||||
@@ -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": "Avviso di cambio percorso identificatore",
|
||||
"idpIdentifierChangeDescription": "Stai per cambiare il percorso identificativo. Questo influirà su come gli utenti esistenti sono mappati. Gli utenti che in precedenza hanno effettuato l'accesso attraverso questo provider di identità potrebbero non essere più riconosciuti come gli stessi utenti.",
|
||||
"idpIdentifierChangeConfirmMessage": "Confermo",
|
||||
"idpIdentifierChangeWarningText": "Questo influirà su come gli utenti esistenti sono mappati",
|
||||
"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": "Inserisci un indirizzo IP…",
|
||||
"ipFilterEmptyMessage": "Inserisci un indirizzo IP per filtrare",
|
||||
"create": "Crea",
|
||||
"orgs": "Organizzazioni",
|
||||
"loginError": "Si è verificato un errore imprevisto. Riprova.",
|
||||
@@ -2596,6 +2602,7 @@
|
||||
"createDomainType": "Tipo:",
|
||||
"createDomainName": "Nome:",
|
||||
"createDomainValue": "Valore:",
|
||||
"multiSelectFilterCount": "{count} selezionato",
|
||||
"createDomainCnameRecords": "Record CNAME",
|
||||
"createDomainARecords": "Record A",
|
||||
"createDomainRecordNumber": "Record {number}",
|
||||
@@ -2710,6 +2717,7 @@
|
||||
"healthScheme": "Metodo",
|
||||
"healthSelectScheme": "Seleziona Metodo",
|
||||
"healthCheckPortInvalid": "La porta deve essere compresa tra 1 e 65535",
|
||||
"healthCheckHostnameInvalid": "Il nome dell'host non deve contenere spazi",
|
||||
"healthCheckPath": "Percorso",
|
||||
"healthHostname": "IP / Nome host",
|
||||
"healthPort": "Porta",
|
||||
@@ -2993,7 +3001,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": "I siti con queste etichette preferiranno connettersi tramite questo nodo di uscita remoto.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Seleziona etichette...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Cerca etichette...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Caricamento etichette fallito",
|
||||
|
||||
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "JMESPath에 대해 더 알아보기",
|
||||
"idpJmespathLabel": "식별자 경로",
|
||||
"idpJmespathLabelDescription": "ID 토큰에서 사용자 식별자에 대한 경로",
|
||||
"idpIdentifierChangeTitle": "식별자 경로 변경 경고",
|
||||
"idpIdentifierChangeDescription": "식별자 경로를 변경하려고 합니다. 이는 기존 사용자의 매핑 방법에 영향을 미칩니다. 이전에 이 ID 공급자를 통해 로그인한 사용자는 더 이상 동일한 사용자로 인식되지 않을 수 있습니다. ",
|
||||
"idpIdentifierChangeConfirmMessage": "확인합니다",
|
||||
"idpIdentifierChangeWarningText": "이는 기존 사용자의 매핑 방법에 영향을 미칩니다",
|
||||
"idpJmespathEmailPathOptional": "이메일 경로 (선택 사항)",
|
||||
"idpJmespathEmailPathOptionalDescription": "ID 토큰에서 사용자의 이메일 경로",
|
||||
"idpJmespathNamePathOptional": "이름 경로 (선택 사항)",
|
||||
@@ -1573,6 +1577,8 @@
|
||||
"search": "검색…",
|
||||
"searchPlaceholder": "검색...",
|
||||
"emptySearchOptions": "옵션이 없습니다",
|
||||
"ipFilterSearchPlaceholder": "IP 주소를 입력하세요…",
|
||||
"ipFilterEmptyMessage": "필터링할 IP 주소를 입력하세요",
|
||||
"create": "생성",
|
||||
"orgs": "조직",
|
||||
"loginError": "예기치 않은 오류가 발생했습니다. 다시 시도해주세요.",
|
||||
@@ -2596,6 +2602,7 @@
|
||||
"createDomainType": "유형:",
|
||||
"createDomainName": "이름:",
|
||||
"createDomainValue": "값:",
|
||||
"multiSelectFilterCount": "{count} 선택됨",
|
||||
"createDomainCnameRecords": "CNAME 레코드",
|
||||
"createDomainARecords": "A 레코드",
|
||||
"createDomainRecordNumber": "레코드 {number}",
|
||||
@@ -2710,6 +2717,7 @@
|
||||
"healthScheme": "방법",
|
||||
"healthSelectScheme": "방법 선택",
|
||||
"healthCheckPortInvalid": "포트는 1에서 65535 사이여야 합니다",
|
||||
"healthCheckHostnameInvalid": "호스트 이름에는 공백이 포함될 수 없습니다",
|
||||
"healthCheckPath": "경로",
|
||||
"healthHostname": "IP / 호스트",
|
||||
"healthPort": "포트",
|
||||
@@ -2993,7 +3001,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "CIDR 범위 추가 (예: 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "서브넷 로드 실패",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "우선순위 레이블",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "이 레이블이 있는 사이트는 이 원격 출구 노드를 통해 연결됩니다.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "이 레이블이 있는 사이트는 이 원격 종료 노드를 통해 연결하는 것을 선호합니다.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "레이블 선택...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "레이블 검색...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "레이블 로드 실패",
|
||||
|
||||
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "Lær mer om JMESPath",
|
||||
"idpJmespathLabel": "Identifikatorsti",
|
||||
"idpJmespathLabelDescription": "Stien til brukeridentifikatoren i ID-tokenet",
|
||||
"idpIdentifierChangeTitle": "Advarsel om identifikatorbanendring",
|
||||
"idpIdentifierChangeDescription": "Du er i ferd med å endre identifikatorbanen. Dette vil påvirke hvordan eksisterende brukere kartlegges. Brukere som tidligere logget inn gjennom denne identitetsleverandøren kan ikke lenger bli gjenkjent som de samme brukerne.",
|
||||
"idpIdentifierChangeConfirmMessage": "Jeg bekrefter",
|
||||
"idpIdentifierChangeWarningText": "Dette vil påvirke hvordan eksisterende brukere kartlegges",
|
||||
"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": "Angi en IP-adresse…",
|
||||
"ipFilterEmptyMessage": "Angi en IP-adresse å filtrere etter",
|
||||
"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} valgt",
|
||||
"createDomainCnameRecords": "CNAME-oppføringer",
|
||||
"createDomainARecords": "A-oppføringer",
|
||||
"createDomainRecordNumber": "Oppføring {number}",
|
||||
@@ -2710,6 +2717,7 @@
|
||||
"healthScheme": "Metode",
|
||||
"healthSelectScheme": "Velg metode",
|
||||
"healthCheckPortInvalid": "Porten må være mellom 1 og 65535",
|
||||
"healthCheckHostnameInvalid": "Vertsnavnet må ikke inneholde mellomrom",
|
||||
"healthCheckPath": "Sti",
|
||||
"healthHostname": "IP / Vert",
|
||||
"healthPort": "Port",
|
||||
@@ -2993,7 +3001,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": "Nettsteder med disse etikettene vil foretrekke å koble til gjennom denne eksterne utgangsnoden.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Velg etiketter...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Søk etiketter...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Feil ved lasting av etiketter",
|
||||
|
||||
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "Meer informatie over JMESPath",
|
||||
"idpJmespathLabel": "ID pad",
|
||||
"idpJmespathLabelDescription": "Het pad naar het gebruiker-id in het ID-token",
|
||||
"idpIdentifierChangeTitle": "Waarschuwing voor Wijziging van ID-pad",
|
||||
"idpIdentifierChangeDescription": "U staat op het punt om het ID-pad te wijzigen. Dit zal invloed hebben op hoe bestaande gebruikers worden gemapt. Gebruikers die voorheen via deze identiteitsprovider inlogden, worden mogelijk niet meer als dezelfde gebruikers herkend.",
|
||||
"idpIdentifierChangeConfirmMessage": "Ik bevestig",
|
||||
"idpIdentifierChangeWarningText": "Dit beïnvloedt hoe bestaande gebruikers worden gemapt",
|
||||
"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": "Voer een IP-adres in…",
|
||||
"ipFilterEmptyMessage": "Voer een IP-adres in om op te filteren",
|
||||
"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} geselecteerd",
|
||||
"createDomainCnameRecords": "CNAME-records",
|
||||
"createDomainARecords": "A Records",
|
||||
"createDomainRecordNumber": "Record {number}",
|
||||
@@ -2710,6 +2717,7 @@
|
||||
"healthScheme": "Methode",
|
||||
"healthSelectScheme": "Selecteer methode",
|
||||
"healthCheckPortInvalid": "Poort moet tussen 1 en 65535 zijn",
|
||||
"healthCheckHostnameInvalid": "De hostnaam mag geen witruimtes bevatten",
|
||||
"healthCheckPath": "Pad",
|
||||
"healthHostname": "IP / Hostnaam",
|
||||
"healthPort": "Poort",
|
||||
@@ -2993,7 +3001,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 met deze labels zullen bij voorkeur verbinding maken via deze externe exitnode.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Selecteer labels...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Labels zoeken...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Kon labels niet laden",
|
||||
|
||||
@@ -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": "Ostrzeżenie o zmianie ścieżki identyfikatora",
|
||||
"idpIdentifierChangeDescription": "Zaraz zmienisz ścieżkę identyfikatora. To wpłynie na sposób mapowania istniejących użytkowników. Użytkownicy, którzy wcześniej logowali się przez tego dostawcę tożsamości, mogą nie być już rozpoznawani jako ci sami użytkownicy.",
|
||||
"idpIdentifierChangeConfirmMessage": "Potwierdzam",
|
||||
"idpIdentifierChangeWarningText": "To wpłynie na sposób mapowania istniejących użytkowników",
|
||||
"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": "Wprowadź adres IP…",
|
||||
"ipFilterEmptyMessage": "Wprowadź adres IP, aby filtrować",
|
||||
"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, plural, one {# wybrany} few {# wybrane} many {# wybranych} other {# wybranych}}",
|
||||
"createDomainCnameRecords": "Rekordy CNAME",
|
||||
"createDomainARecords": "Rekordy A",
|
||||
"createDomainRecordNumber": "Rekord {number}",
|
||||
@@ -2710,6 +2717,7 @@
|
||||
"healthScheme": "Metoda",
|
||||
"healthSelectScheme": "Wybierz metodę",
|
||||
"healthCheckPortInvalid": "Port musi być pomiędzy 1 a 65535",
|
||||
"healthCheckHostnameInvalid": "Nazwa hosta nie może zawierać spacji",
|
||||
"healthCheckPath": "Ścieżka",
|
||||
"healthHostname": "IP / Nazwa hosta",
|
||||
"healthPort": "Port",
|
||||
@@ -2993,7 +3001,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": "Strony z tymi etykietami będą preferować połączenie przez ten zdalny węzeł wyjściowy.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Wybierz etykiety...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Szukaj etykiet...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Nie udało się załadować etykiet",
|
||||
|
||||
@@ -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": "Aviso de Mudança no Caminho do Identificador",
|
||||
"idpIdentifierChangeDescription": "Você está prestes a mudar o caminho do identificador. Isso afetará como os usuários existentes são mapeados. Usuários que anteriormente fizeram login através deste provedor de identidade podem não ser mais reconhecidos como os mesmos usuários.",
|
||||
"idpIdentifierChangeConfirmMessage": "Eu confirmo",
|
||||
"idpIdentifierChangeWarningText": "Isto afetará como os usuários existentes são mapeados",
|
||||
"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": "Insira um endereço IP…",
|
||||
"ipFilterEmptyMessage": "Insira um endereço IP para filtrar",
|
||||
"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} selecionado",
|
||||
"createDomainCnameRecords": "Registros CNAME",
|
||||
"createDomainARecords": "Registros A",
|
||||
"createDomainRecordNumber": "Registrar {number}",
|
||||
@@ -2710,6 +2717,7 @@
|
||||
"healthScheme": "Método",
|
||||
"healthSelectScheme": "Selecione o Método",
|
||||
"healthCheckPortInvalid": "A porta deve estar entre 1 e 65535",
|
||||
"healthCheckHostnameInvalid": "O nome do host não deve conter espaços em branco",
|
||||
"healthCheckPath": "Caminho",
|
||||
"healthHostname": "IP / Nome do Host",
|
||||
"healthPort": "Porta",
|
||||
@@ -2993,7 +3001,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 com estas etiquetas preferirão conectar-se por meio deste nó de saída remoto.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Selecionar etiquetas...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Pesquisar etiquetas...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Falha ao carregar etiquetas",
|
||||
|
||||
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "Узнать больше о JMESPath",
|
||||
"idpJmespathLabel": "Путь идентификатора",
|
||||
"idpJmespathLabelDescription": "Путь к идентификатору пользователя в ID токене",
|
||||
"idpIdentifierChangeTitle": "Предупреждение о изменении пути идентификатора",
|
||||
"idpIdentifierChangeDescription": "Вы собираетесь изменить путь идентификатора. Это повлияет на то, как отображаются существующие пользователи. Пользователи, которые ранее входили через этого поставщика идентификации, могут больше не распознаваться как те же пользователи.",
|
||||
"idpIdentifierChangeConfirmMessage": "Я подтверждаю",
|
||||
"idpIdentifierChangeWarningText": "Это повлияет на то, как отображаются существующие пользователи",
|
||||
"idpJmespathEmailPathOptional": "Путь к email (необязательно)",
|
||||
"idpJmespathEmailPathOptionalDescription": "Путь к email пользователя в ID токене",
|
||||
"idpJmespathNamePathOptional": "Путь к имени (необязательно)",
|
||||
@@ -1573,6 +1577,8 @@
|
||||
"search": "Поиск…",
|
||||
"searchPlaceholder": "Поиск...",
|
||||
"emptySearchOptions": "Опции не найдены",
|
||||
"ipFilterSearchPlaceholder": "Введите IP адрес…",
|
||||
"ipFilterEmptyMessage": "Введите IP адрес для фильтрации",
|
||||
"create": "Создать",
|
||||
"orgs": "Организации",
|
||||
"loginError": "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз.",
|
||||
@@ -2596,6 +2602,7 @@
|
||||
"createDomainType": "Тип:",
|
||||
"createDomainName": "Имя:",
|
||||
"createDomainValue": "Значение:",
|
||||
"multiSelectFilterCount": "Выбрано: {count}",
|
||||
"createDomainCnameRecords": "CNAME Записи",
|
||||
"createDomainARecords": "A Записи",
|
||||
"createDomainRecordNumber": "Запись {number}",
|
||||
@@ -2710,6 +2717,7 @@
|
||||
"healthScheme": "Метод",
|
||||
"healthSelectScheme": "Выберите метод",
|
||||
"healthCheckPortInvalid": "Порт должен быть в диапазоне от 1 до 65535",
|
||||
"healthCheckHostnameInvalid": "Имя хоста не должно содержать пробелов",
|
||||
"healthCheckPath": "Путь",
|
||||
"healthHostname": "IP / хост",
|
||||
"healthPort": "Порт",
|
||||
@@ -2993,7 +3001,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Добавить диапазон CIDR (например, 10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "Не удалось загрузить подсети",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "Этикетки предпочтений",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Сайты с этими метками будут обязаны подключаться через этот удаленный узел выхода.",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "Сайты с такими метками предпочтут соединяться через этот удаленный узел выхода.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Выберите метки...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Поиск меток...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Не удалось загрузить метки",
|
||||
|
||||
@@ -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": "Tanımlayıcı Yol Değişikliği Uyarısı",
|
||||
"idpIdentifierChangeDescription": "Tanımlayıcı yolu değiştirmek üzeresiniz. Bu, mevcut kullanıcıların nasıl eşleneceğini etkileyecektir. Bu kimlik sağlayıcı üzerinden daha önce oturum açmış olan kullanıcılar artık aynı kullanıcılar olarak tanınmayabilir.",
|
||||
"idpIdentifierChangeConfirmMessage": "Onaylıyorum",
|
||||
"idpIdentifierChangeWarningText": "Bu, mevcut kullanıcıların nasıl eşleneceğini etkileyecek",
|
||||
"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": "Bir IP adresi girin…",
|
||||
"ipFilterEmptyMessage": "Filtrelemek için bir IP adresi girin",
|
||||
"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} seçildi",
|
||||
"createDomainCnameRecords": "CNAME Kayıtları",
|
||||
"createDomainARecords": "A Kayıtları",
|
||||
"createDomainRecordNumber": "Kayıt {number}",
|
||||
@@ -2710,6 +2717,7 @@
|
||||
"healthScheme": "Yöntem",
|
||||
"healthSelectScheme": "Yöntem Seç",
|
||||
"healthCheckPortInvalid": "Bağlantı noktası 1 ile 65535 arasında olmalıdır",
|
||||
"healthCheckHostnameInvalid": "Ana bilgisayar adı boşluk içermemelidir",
|
||||
"healthCheckPath": "Yol",
|
||||
"healthHostname": "IP / Hostname",
|
||||
"healthPort": "Bağlantı Noktası",
|
||||
@@ -2993,7 +3001,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": "Bu etiketlere sahip siteler, bağlantıyı bu uzak çıkış düğümü üzerinden tercih edecektir.",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "Etiketleri seç...",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Etiketleri ara...",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "Etiketler yüklenemedi",
|
||||
|
||||
@@ -1176,6 +1176,10 @@
|
||||
"idpJmespathAboutDescriptionLink": "了解更多 JMESPath 信息",
|
||||
"idpJmespathLabel": "标识符路径",
|
||||
"idpJmespathLabelDescription": "ID 令牌中用户标识符的路径",
|
||||
"idpIdentifierChangeTitle": "标识符路径更改警告",
|
||||
"idpIdentifierChangeDescription": "您即将更改标识符路径。这将影响现有用户的映射方式。以前通过此身份提供者登录的用户可能将不再被识别为相同用户。",
|
||||
"idpIdentifierChangeConfirmMessage": "我确认",
|
||||
"idpIdentifierChangeWarningText": "这将影响现有用户的映射方式",
|
||||
"idpJmespathEmailPathOptional": "邮箱路径(可选)",
|
||||
"idpJmespathEmailPathOptionalDescription": "ID 令牌中用户邮箱的路径",
|
||||
"idpJmespathNamePathOptional": "用户名路径(可选)",
|
||||
@@ -1573,6 +1577,8 @@
|
||||
"search": "搜索…",
|
||||
"searchPlaceholder": "搜索...",
|
||||
"emptySearchOptions": "未找到选项",
|
||||
"ipFilterSearchPlaceholder": "输入IP地址…",
|
||||
"ipFilterEmptyMessage": "输入要筛选的IP地址",
|
||||
"create": "创建",
|
||||
"orgs": "组织",
|
||||
"loginError": "发生意外错误。请重试。",
|
||||
@@ -2596,6 +2602,7 @@
|
||||
"createDomainType": "类型:",
|
||||
"createDomainName": "名称:",
|
||||
"createDomainValue": "值:",
|
||||
"multiSelectFilterCount": "{count} 已选择",
|
||||
"createDomainCnameRecords": "CNAME 记录",
|
||||
"createDomainARecords": "A记录",
|
||||
"createDomainRecordNumber": "记录 {number}",
|
||||
@@ -2710,6 +2717,7 @@
|
||||
"healthScheme": "方法",
|
||||
"healthSelectScheme": "选择方法",
|
||||
"healthCheckPortInvalid": "端口必须在 1 和 65535 之间",
|
||||
"healthCheckHostnameInvalid": "主机名不得包含空格",
|
||||
"healthCheckPath": "路径",
|
||||
"healthHostname": "IP / 主机",
|
||||
"healthPort": "端口",
|
||||
@@ -2993,7 +3001,7 @@
|
||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "添加CIDR范围(例如10.0.0.0/8)",
|
||||
"remoteExitNodeNetworkingSubnetsLoadError": "无法加载子网",
|
||||
"remoteExitNodeNetworkingLabelsTitle": "首选标签",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "拥有这些标签的站点将强制通过此远程出口节点连接。",
|
||||
"remoteExitNodeNetworkingLabelsDescription": "带有这些标签的站点将优先通过此远程出口节点进行连接。",
|
||||
"remoteExitNodeNetworkingLabelsButtonText": "选择标签……",
|
||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "搜索标签……",
|
||||
"remoteExitNodeNetworkingLabelsLoadError": "无法加载标签",
|
||||
|
||||
@@ -32,49 +32,49 @@
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||
"@aws-sdk/client-s3": "3.1056.0",
|
||||
"@asteasolutions/zod-to-openapi": "9.1.0",
|
||||
"@aws-sdk/client-s3": "3.1121.0",
|
||||
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
|
||||
"@headlessui/react": "2.2.10",
|
||||
"@hookform/resolvers": "5.4.0",
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@node-rs/argon2": "2.0.2",
|
||||
"@node-rs/argon2": "2.2.0",
|
||||
"@novnc/novnc": "^1.7.0",
|
||||
"@oslojs/crypto": "1.0.1",
|
||||
"@oslojs/encoding": "1.1.0",
|
||||
"@radix-ui/react-avatar": "1.1.11",
|
||||
"@radix-ui/react-checkbox": "1.3.3",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.16",
|
||||
"@radix-ui/react-avatar": "1.2.6",
|
||||
"@radix-ui/react-checkbox": "1.3.11",
|
||||
"@radix-ui/react-collapsible": "1.1.20",
|
||||
"@radix-ui/react-dialog": "1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.24",
|
||||
"@radix-ui/react-icons": "1.3.2",
|
||||
"@radix-ui/react-label": "2.1.8",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-progress": "1.1.8",
|
||||
"@radix-ui/react-radio-group": "1.3.8",
|
||||
"@radix-ui/react-scroll-area": "1.2.10",
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-separator": "1.1.8",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-toast": "1.2.15",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@radix-ui/react-label": "2.1.15",
|
||||
"@radix-ui/react-popover": "1.1.23",
|
||||
"@radix-ui/react-progress": "1.1.16",
|
||||
"@radix-ui/react-radio-group": "1.4.7",
|
||||
"@radix-ui/react-scroll-area": "1.2.18",
|
||||
"@radix-ui/react-select": "2.3.7",
|
||||
"@radix-ui/react-separator": "1.1.15",
|
||||
"@radix-ui/react-slot": "1.3.3",
|
||||
"@radix-ui/react-switch": "1.3.7",
|
||||
"@radix-ui/react-tabs": "1.1.21",
|
||||
"@radix-ui/react-toast": "1.2.23",
|
||||
"@radix-ui/react-tooltip": "1.2.16",
|
||||
"@react-email/body": "0.3.0",
|
||||
"@react-email/components": "1.0.12",
|
||||
"@react-email/render": "2.0.8",
|
||||
"@react-email/render": "2.1.0",
|
||||
"@react-email/tailwind": "2.0.7",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@simplewebauthn/server": "13.3.1",
|
||||
"@simplewebauthn/server": "13.3.3",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.100.14",
|
||||
"@tanstack/react-query": "5.102.8",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"arctic": "3.7.0",
|
||||
"axios": "1.18.0",
|
||||
"axios": "1.20.0",
|
||||
"better-sqlite3": "11.9.1",
|
||||
"canvas-confetti": "1.9.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
@@ -86,62 +86,62 @@
|
||||
"d3": "7.9.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "8.5.2",
|
||||
"express-rate-limit": "8.7.0",
|
||||
"glob": "13.0.6",
|
||||
"gpt-tokenizer": "^3.4.0",
|
||||
"helmet": "8.2.0",
|
||||
"gpt-tokenizer": "^4.0.0",
|
||||
"helmet": "8.3.0",
|
||||
"http-errors": "2.0.1",
|
||||
"input-otp": "1.4.2",
|
||||
"ioredis": "5.11.0",
|
||||
"input-otp": "1.5.0",
|
||||
"ioredis": "6.0.0",
|
||||
"jmespath": "0.16.0",
|
||||
"js-yaml": "4.3.0",
|
||||
"js-yaml": "5.4.1",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lucide-react": "1.17.0",
|
||||
"maxmind": "5.0.6",
|
||||
"lucide-react": "1.38.0",
|
||||
"maxmind": "5.0.7",
|
||||
"moment": "2.30.1",
|
||||
"next": "16.2.11",
|
||||
"next-intl": "4.13.0",
|
||||
"next": "16.3.3",
|
||||
"next-intl": "4.14.1",
|
||||
"next-themes": "0.4.6",
|
||||
"nextjs-toploader": "3.9.17",
|
||||
"node-cache": "5.1.2",
|
||||
"nodemailer": "9.0.1",
|
||||
"nodemailer": "9.1.0",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "8.21.0",
|
||||
"posthog-node": "5.35.6",
|
||||
"pg": "8.23.0",
|
||||
"posthog-node": "5.51.4",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.6",
|
||||
"react": "19.2.8",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "19.2.6",
|
||||
"react-dom": "19.2.8",
|
||||
"react-easy-sort": "1.8.0",
|
||||
"react-hook-form": "7.76.1",
|
||||
"react-icons": "5.6.0",
|
||||
"recharts": "3.8.1",
|
||||
"react-hook-form": "7.87.0",
|
||||
"react-icons": "5.7.0",
|
||||
"recharts": "3.10.1",
|
||||
"reodotdev": "1.1.0",
|
||||
"semver": "7.8.1",
|
||||
"semver": "7.8.5",
|
||||
"sshpk": "1.18.0",
|
||||
"stripe": "22.2.0",
|
||||
"stripe": "22.6.0",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"topojson-client": "3.1.0",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"use-debounce": "10.1.1",
|
||||
"uuid": "14.0.0",
|
||||
"uuid": "14.0.2",
|
||||
"vaul": "1.1.2",
|
||||
"visionscarto-world-atlas": "1.0.0",
|
||||
"winston": "3.19.0",
|
||||
"winston-daily-rotate-file": "5.0.0",
|
||||
"ws": "8.21.0",
|
||||
"ws": "8.21.3",
|
||||
"yaml": "2.9.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "4.4.3",
|
||||
"yargs": "18.1.0",
|
||||
"zod": "4.5.4",
|
||||
"zod-validation-error": "5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "1.69.1",
|
||||
"@dotenvx/dotenvx": "2.23.0",
|
||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||
"@react-email/ui": "^6.5.0",
|
||||
"@tailwindcss/postcss": "4.3.0",
|
||||
"@tanstack/react-query-devtools": "5.100.14",
|
||||
"@react-email/ui": "^6.9.3",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@tanstack/react-query-devtools": "5.102.8",
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/cors": "2.8.19",
|
||||
@@ -152,36 +152,36 @@
|
||||
"@types/jmespath": "0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"@types/node": "25.9.1",
|
||||
"@types/nodemailer": "8.0.0",
|
||||
"@types/node": "26.4.0",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/react": "19.2.15",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/sshpk": "1.17.4",
|
||||
"@types/pg": "8.23.1",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.5",
|
||||
"@types/semver": "7.8.0",
|
||||
"@types/sshpk": "1.17.5",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/topojson-client": "3.1.5",
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/yargs": "17.0.35",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"esbuild": "0.28.0",
|
||||
"esbuild-node-externals": "1.22.0",
|
||||
"eslint": "10.4.0",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"postcss": "8.5.15",
|
||||
"prettier": "3.8.3",
|
||||
"react-email": "6.5.0",
|
||||
"tailwindcss": "4.3.0",
|
||||
"tsc-alias": "1.8.17",
|
||||
"tsx": "4.22.3",
|
||||
"esbuild": "0.28.2",
|
||||
"esbuild-node-externals": "2.0.0",
|
||||
"eslint": "10.9.1",
|
||||
"eslint-config-next": "16.3.3",
|
||||
"postcss": "8.5.26",
|
||||
"prettier": "3.9.6",
|
||||
"react-email": "6.9.3",
|
||||
"tailwindcss": "4.3.3",
|
||||
"tsc-alias": "1.9.2",
|
||||
"tsx": "4.23.13",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.60.0"
|
||||
"typescript-eslint": "8.68.0"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "0.28.0",
|
||||
"esbuild": "0.28.2",
|
||||
"dompurify": "3.4.0",
|
||||
"postcss": "8.5.15"
|
||||
"postcss": "8.5.26"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 790 KiB |
|
Before Width: | Height: | Size: 621 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 532 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 621 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 556 KiB After Width: | Height: | Size: 620 KiB |
|
Before Width: | Height: | Size: 574 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 516 KiB After Width: | Height: | Size: 800 KiB |
@@ -26,7 +26,9 @@ import {
|
||||
sites,
|
||||
clients,
|
||||
sessions,
|
||||
labels
|
||||
labels,
|
||||
aiProviders,
|
||||
virtualApiKeys
|
||||
} from "./schema";
|
||||
|
||||
export const dnsChallenge = pgTable("dnsChallenges", {
|
||||
@@ -614,6 +616,87 @@ export const trialNotifications = pgTable("trialNotifications", {
|
||||
sentAt: bigint("sentAt", { mode: "number" }).notNull()
|
||||
});
|
||||
|
||||
// Logs the aggregated prompt + response for a single AI gateway request, for
|
||||
// session replay. One row per request (not per streaming chunk). `sessionId`
|
||||
// is a fresh random id per row for now - no cross-request correlation yet,
|
||||
// but the column exists so a future pass can link multiple rows into a real
|
||||
// multi-turn session.
|
||||
export const aiSessionLog = pgTable(
|
||||
"aiSessionLog",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
sessionId: varchar("sessionId").notNull(),
|
||||
orgId: varchar("orgId").references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
providerId: integer("providerId").references(
|
||||
() => aiProviders.providerId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
capability: varchar("capability").notNull(),
|
||||
resourceId: integer("resourceId").references(
|
||||
() => resources.resourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
siteResourceId: integer("siteResourceId").references(
|
||||
() => siteResources.siteResourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
userId: varchar("userId").references(() => users.userId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
virtualApiKeyId: varchar("virtualApiKeyId").references(
|
||||
() => virtualApiKeys.virtualApiKeyId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
requestedModel: varchar("requestedModel"),
|
||||
isStream: boolean("isStream").notNull().default(false),
|
||||
requestBody: text("requestBody"),
|
||||
responseBody: text("responseBody"),
|
||||
// Capability-agnostic message transcript (JSON-encoded
|
||||
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
|
||||
// computed at write time so search/display never need per-capability
|
||||
// parsing logic. Null when normalization couldn't recognize the
|
||||
// shape - callers fall back to requestBody/responseBody.
|
||||
normalizedRequest: text("normalizedRequest"),
|
||||
normalizedResponse: text("normalizedResponse"),
|
||||
// True if any of the request/response (raw or normalized) fields
|
||||
// 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 seconds
|
||||
},
|
||||
(t) => [
|
||||
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
|
||||
index("idx_ai_session_log_org_provider_created").on(
|
||||
t.orgId,
|
||||
t.providerId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_resource_created").on(
|
||||
t.orgId,
|
||||
t.resourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_site_resource_created").on(
|
||||
t.orgId,
|
||||
t.siteResourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_user_created").on(
|
||||
t.orgId,
|
||||
t.userId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_virtual_api_key_created").on(
|
||||
t.orgId,
|
||||
t.virtualApiKeyId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_session").on(t.sessionId)
|
||||
]
|
||||
);
|
||||
|
||||
export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
@@ -660,3 +743,4 @@ export type AlertEmailRecipients = InferSelectModel<
|
||||
>;
|
||||
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
|
||||
|
||||
@@ -262,16 +262,20 @@ export const resourceAiModels = pgTable(
|
||||
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
|
||||
);
|
||||
|
||||
export const labels = pgTable("labels", {
|
||||
labelId: serial("labelId").primaryKey(),
|
||||
name: varchar("name").notNull(),
|
||||
color: varchar("color").notNull(),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
});
|
||||
export const labels = pgTable(
|
||||
"labels",
|
||||
{
|
||||
labelId: serial("labelId").primaryKey(),
|
||||
name: varchar("name").notNull(),
|
||||
color: varchar("color").notNull(),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
},
|
||||
(t) => [index("idx_labels_orgid").on(t.orgId)]
|
||||
);
|
||||
|
||||
export const launcherViews = pgTable("launcherViews", {
|
||||
viewId: serial("viewId").primaryKey(),
|
||||
@@ -693,15 +697,19 @@ export const twoFactorBackupCodes = pgTable("twoFactorBackupCodes", {
|
||||
codeHash: varchar("codeHash").notNull()
|
||||
});
|
||||
|
||||
export const sessions = pgTable("session", {
|
||||
sessionId: varchar("id").primaryKey(),
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull(),
|
||||
issuedAt: bigint("issuedAt", { mode: "number" }),
|
||||
deviceAuthUsed: boolean("deviceAuthUsed").notNull().default(false)
|
||||
});
|
||||
export const sessions = pgTable(
|
||||
"session",
|
||||
{
|
||||
sessionId: varchar("id").primaryKey(),
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
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,19 +719,26 @@ export const newtSessions = pgTable("newtSession", {
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
||||
});
|
||||
|
||||
export const userOrgs = pgTable("userOrgs", {
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
isOwner: boolean("isOwner").notNull().default(false),
|
||||
autoProvisioned: boolean("autoProvisioned").default(false),
|
||||
pamUsername: varchar("pamUsername") // cleaned username for ssh and such
|
||||
});
|
||||
export const userOrgs = pgTable(
|
||||
"userOrgs",
|
||||
{
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
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,22 +766,26 @@ export const actions = pgTable("actions", {
|
||||
description: varchar("description")
|
||||
});
|
||||
|
||||
export const roles = pgTable("roles", {
|
||||
roleId: serial("roleId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
isAdmin: boolean("isAdmin"),
|
||||
name: varchar("name").notNull(),
|
||||
description: varchar("description"),
|
||||
requireDeviceApproval: boolean("requireDeviceApproval").default(false),
|
||||
sshSudoMode: varchar("sshSudoMode", { length: 32 }).default("full"), // "none" | "full" | "commands"
|
||||
sshSudoCommands: text("sshSudoCommands").default("[]"),
|
||||
sshCreateHomeDir: boolean("sshCreateHomeDir").default(true),
|
||||
sshUnixGroups: text("sshUnixGroups").default("[]")
|
||||
});
|
||||
export const roles = pgTable(
|
||||
"roles",
|
||||
{
|
||||
roleId: serial("roleId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
isAdmin: boolean("isAdmin"),
|
||||
name: varchar("name").notNull(),
|
||||
description: varchar("description"),
|
||||
requireDeviceApproval: boolean("requireDeviceApproval").default(false),
|
||||
sshSudoMode: varchar("sshSudoMode", { length: 32 }).default("full"), // "none" | "full" | "commands"
|
||||
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", {
|
||||
@@ -1936,87 +1958,6 @@ export const aiBudgetBreachEvents = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
// Logs the aggregated prompt + response for a single AI gateway request, for
|
||||
// session replay. One row per request (not per streaming chunk). `sessionId`
|
||||
// is a fresh random id per row for now - no cross-request correlation yet,
|
||||
// but the column exists so a future pass can link multiple rows into a real
|
||||
// multi-turn session.
|
||||
export const aiSessionLog = pgTable(
|
||||
"aiSessionLog",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
sessionId: varchar("sessionId").notNull(),
|
||||
orgId: varchar("orgId").references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
providerId: integer("providerId").references(
|
||||
() => aiProviders.providerId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
capability: varchar("capability").notNull(),
|
||||
resourceId: integer("resourceId").references(
|
||||
() => resources.resourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
siteResourceId: integer("siteResourceId").references(
|
||||
() => siteResources.siteResourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
userId: varchar("userId").references(() => users.userId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
virtualApiKeyId: varchar("virtualApiKeyId").references(
|
||||
() => virtualApiKeys.virtualApiKeyId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
requestedModel: varchar("requestedModel"),
|
||||
isStream: boolean("isStream").notNull().default(false),
|
||||
requestBody: text("requestBody"),
|
||||
responseBody: text("responseBody"),
|
||||
// Capability-agnostic message transcript (JSON-encoded
|
||||
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
|
||||
// computed at write time so search/display never need per-capability
|
||||
// parsing logic. Null when normalization couldn't recognize the
|
||||
// shape - callers fall back to requestBody/responseBody.
|
||||
normalizedRequest: text("normalizedRequest"),
|
||||
normalizedResponse: text("normalizedResponse"),
|
||||
// True if any of the request/response (raw or normalized) fields
|
||||
// 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 seconds
|
||||
},
|
||||
(t) => [
|
||||
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
|
||||
index("idx_ai_session_log_org_provider_created").on(
|
||||
t.orgId,
|
||||
t.providerId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_resource_created").on(
|
||||
t.orgId,
|
||||
t.resourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_site_resource_created").on(
|
||||
t.orgId,
|
||||
t.siteResourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_user_created").on(
|
||||
t.orgId,
|
||||
t.userId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_virtual_api_key_created").on(
|
||||
t.orgId,
|
||||
t.virtualApiKeyId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_session").on(t.sessionId)
|
||||
]
|
||||
);
|
||||
|
||||
export const certificates = pgTable("certificates", {
|
||||
certId: serial("certId").primaryKey(),
|
||||
domain: varchar("domain", { length: 255 }).notNull().unique(),
|
||||
@@ -2129,7 +2070,6 @@ export type AiModel = InferSelectModel<typeof aiModels>;
|
||||
export type AiBudget = InferSelectModel<typeof aiBudgets>;
|
||||
export type AiUsageRecord = InferSelectModel<typeof aiUsageRecords>;
|
||||
export type AiBudgetBreachEvent = InferSelectModel<typeof aiBudgetBreachEvents>;
|
||||
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
|
||||
export type ResourceAiProvider = InferSelectModel<typeof resourceAiProviders>;
|
||||
export type SiteResourceAiProvider = InferSelectModel<
|
||||
typeof siteResourceAiProviders
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
uniqueIndex
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import {
|
||||
aiProviders,
|
||||
clients,
|
||||
domains,
|
||||
exitNodes,
|
||||
@@ -20,7 +21,8 @@ import {
|
||||
siteResources,
|
||||
sites,
|
||||
targetHealthCheck,
|
||||
users
|
||||
users,
|
||||
virtualApiKeys
|
||||
} from "./schema";
|
||||
|
||||
export const dnsChallenge = sqliteTable("dnsChallenges", {
|
||||
@@ -609,6 +611,91 @@ export const trialNotifications = sqliteTable("trialNotifications", {
|
||||
sentAt: integer("sentAt").notNull()
|
||||
});
|
||||
|
||||
// Logs the aggregated prompt + response for a single AI gateway request, for
|
||||
// session replay. One row per request (not per streaming chunk). `sessionId`
|
||||
// is a fresh random id per row for now - no cross-request correlation yet,
|
||||
// but the column exists so a future pass can link multiple rows into a real
|
||||
// multi-turn session.
|
||||
export const aiSessionLog = sqliteTable(
|
||||
"aiSessionLog",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
sessionId: text("sessionId").notNull(),
|
||||
orgId: text("orgId").references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
providerId: integer("providerId").references(
|
||||
() => aiProviders.providerId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
capability: text("capability").notNull(),
|
||||
resourceId: integer("resourceId").references(
|
||||
() => resources.resourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
siteResourceId: integer("siteResourceId").references(
|
||||
() => siteResources.siteResourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
virtualApiKeyId: text("virtualApiKeyId").references(
|
||||
() => virtualApiKeys.virtualApiKeyId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
requestedModel: text("requestedModel"),
|
||||
isStream: integer("isStream", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
requestBody: text("requestBody"),
|
||||
responseBody: text("responseBody"),
|
||||
// Capability-agnostic message transcript (JSON-encoded
|
||||
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
|
||||
// computed at write time so search/display never need per-capability
|
||||
// parsing logic. Null when normalization couldn't recognize the
|
||||
// shape - callers fall back to requestBody/responseBody.
|
||||
normalizedRequest: text("normalizedRequest"),
|
||||
normalizedResponse: text("normalizedResponse"),
|
||||
// True if any of the request/response (raw or normalized) fields
|
||||
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
|
||||
truncated: integer("truncated", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
statusCode: integer("statusCode"),
|
||||
createdAt: integer("createdAt").notNull() // epoch seconds
|
||||
},
|
||||
(t) => [
|
||||
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
|
||||
index("idx_ai_session_log_org_provider_created").on(
|
||||
t.orgId,
|
||||
t.providerId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_resource_created").on(
|
||||
t.orgId,
|
||||
t.resourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_site_resource_created").on(
|
||||
t.orgId,
|
||||
t.siteResourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_user_created").on(
|
||||
t.orgId,
|
||||
t.userId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_virtual_api_key_created").on(
|
||||
t.orgId,
|
||||
t.virtualApiKeyId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_session").on(t.sessionId)
|
||||
]
|
||||
);
|
||||
|
||||
export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
@@ -647,3 +734,4 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
|
||||
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
||||
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
|
||||
|
||||
@@ -99,131 +99,149 @@ export const orgDomains = sqliteTable("orgDomains", {
|
||||
.references(() => domains.domainId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const sites = sqliteTable("sites", {
|
||||
siteId: integer("siteId").primaryKey({ autoIncrement: true }),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
niceId: text("niceId").notNull(),
|
||||
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
networkId: integer("networkId").references(() => networks.networkId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
name: text("name").notNull(),
|
||||
pubKey: text("pubKey"),
|
||||
exitNodeSubnet: text("exitNodeSubnet"),
|
||||
megabytesIn: integer("bytesIn").default(0),
|
||||
megabytesOut: integer("bytesOut").default(0),
|
||||
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
||||
type: text("type").notNull(), // "newt" or "wireguard"
|
||||
online: integer("online", { mode: "boolean" }).notNull().default(false),
|
||||
lastPing: integer("lastPing"),
|
||||
export const sites = sqliteTable(
|
||||
"sites",
|
||||
{
|
||||
siteId: integer("siteId").primaryKey({ autoIncrement: true }),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
niceId: text("niceId").notNull(),
|
||||
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
networkId: integer("networkId").references(() => networks.networkId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
name: text("name").notNull(),
|
||||
pubKey: text("pubKey"),
|
||||
exitNodeSubnet: text("exitNodeSubnet"),
|
||||
megabytesIn: integer("bytesIn").default(0),
|
||||
megabytesOut: integer("bytesOut").default(0),
|
||||
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
||||
type: text("type").notNull(), // "newt" or "wireguard"
|
||||
online: integer("online", { mode: "boolean" }).notNull().default(false),
|
||||
lastPing: integer("lastPing"),
|
||||
|
||||
// exit node stuff that is how to connect to the site when it has a wg server
|
||||
address: text("address"), // this is the address of the wireguard interface in newt
|
||||
endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config
|
||||
localEndpoints: text("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to
|
||||
publicKey: text("publicKey"), // TODO: Fix typo in publicKey
|
||||
lastHolePunch: integer("lastHolePunch"),
|
||||
listenPort: integer("listenPort"),
|
||||
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
autoUpdateEnabled: integer("autoUpdateEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
autoUpdateOverrideOrg: integer("autoUpdateOverrideOrg", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
status: text("status").$type<"pending" | "approved">().default("approved")
|
||||
});
|
||||
|
||||
export const resources = sqliteTable("resources", {
|
||||
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
|
||||
resourcePolicyId: integer("resourcePolicyId").references(
|
||||
() => resourcePolicies.resourcePolicyId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
|
||||
() => resourcePolicies.resourcePolicyId,
|
||||
{
|
||||
onDelete: "restrict"
|
||||
}
|
||||
),
|
||||
resourceGuid: text("resourceGuid", { length: 36 })
|
||||
.unique()
|
||||
.notNull()
|
||||
.$defaultFn(() => randomUUID()),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
// exit node stuff that is how to connect to the site when it has a wg server
|
||||
address: text("address"), // this is the address of the wireguard interface in newt
|
||||
endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config
|
||||
localEndpoints: text("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to
|
||||
publicKey: text("publicKey"), // TODO: Fix typo in publicKey
|
||||
lastHolePunch: integer("lastHolePunch"),
|
||||
listenPort: integer("listenPort"),
|
||||
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
autoUpdateEnabled: integer("autoUpdateEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
autoUpdateOverrideOrg: integer("autoUpdateOverrideOrg", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull(),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: text("name").notNull(),
|
||||
subdomain: text("subdomain"),
|
||||
fullDomain: text("fullDomain"),
|
||||
domainId: text("domainId").references(() => domains.domainId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
||||
blockAccess: integer("blockAccess", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
proxyPort: integer("proxyPort"),
|
||||
sso: integer("sso", { mode: "boolean" }),
|
||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", {
|
||||
mode: "boolean"
|
||||
}),
|
||||
applyRules: integer("applyRules", { mode: "boolean" }),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
stickySession: integer("stickySession", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
tlsServerName: text("tlsServerName"),
|
||||
setHostHeader: text("setHostHeader"),
|
||||
enableProxy: integer("enableProxy", { mode: "boolean" }).default(true),
|
||||
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
headers: text("headers"), // comma-separated list of headers to add to the request
|
||||
proxyProtocol: integer("proxyProtocol", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
|
||||
maintenanceModeEnabled: integer("maintenanceModeEnabled", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
maintenanceModeType: text("maintenanceModeType", {
|
||||
enum: ["forced", "automatic"]
|
||||
}).default("forced"), // "forced" = always show, "automatic" = only when down
|
||||
maintenanceTitle: text("maintenanceTitle"),
|
||||
maintenanceMessage: text("maintenanceMessage"),
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
|
||||
mode: text("mode")
|
||||
.default("http")
|
||||
.$type<"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp">()
|
||||
.notNull(), // rdp, ssh, http, vnc, inference
|
||||
pamMode: text("pamMode")
|
||||
.$type<"passthrough" | "push">()
|
||||
.default("passthrough"),
|
||||
authDaemonMode: text("authDaemonMode")
|
||||
.$type<"site" | "remote" | "native">()
|
||||
.default("site"),
|
||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||
status: text("status").$type<"pending" | "approved">().default("approved")
|
||||
});
|
||||
.notNull()
|
||||
.default(false),
|
||||
status: text("status")
|
||||
.$type<"pending" | "approved">()
|
||||
.default("approved")
|
||||
},
|
||||
(table) => [index("idx_sites_orgId").on(table.orgId)]
|
||||
);
|
||||
|
||||
export const resources = sqliteTable(
|
||||
"resources",
|
||||
{
|
||||
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
|
||||
resourcePolicyId: integer("resourcePolicyId").references(
|
||||
() => resourcePolicies.resourcePolicyId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
|
||||
() => resourcePolicies.resourcePolicyId,
|
||||
{
|
||||
onDelete: "restrict"
|
||||
}
|
||||
),
|
||||
resourceGuid: text("resourceGuid", { length: 36 })
|
||||
.unique()
|
||||
.notNull()
|
||||
.$defaultFn(() => randomUUID()),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: text("name").notNull(),
|
||||
subdomain: text("subdomain"),
|
||||
fullDomain: text("fullDomain"),
|
||||
domainId: text("domainId").references(() => domains.domainId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
||||
blockAccess: integer("blockAccess", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
proxyPort: integer("proxyPort"),
|
||||
sso: integer("sso", { mode: "boolean" }),
|
||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", {
|
||||
mode: "boolean"
|
||||
}),
|
||||
applyRules: integer("applyRules", { mode: "boolean" }),
|
||||
enabled: integer("enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
stickySession: integer("stickySession", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
tlsServerName: text("tlsServerName"),
|
||||
setHostHeader: text("setHostHeader"),
|
||||
enableProxy: integer("enableProxy", { mode: "boolean" }).default(true),
|
||||
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
headers: text("headers"), // comma-separated list of headers to add to the request
|
||||
proxyProtocol: integer("proxyProtocol", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
|
||||
maintenanceModeEnabled: integer("maintenanceModeEnabled", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
maintenanceModeType: text("maintenanceModeType", {
|
||||
enum: ["forced", "automatic"]
|
||||
}).default("forced"), // "forced" = always show, "automatic" = only when down
|
||||
maintenanceTitle: text("maintenanceTitle"),
|
||||
maintenanceMessage: text("maintenanceMessage"),
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||
wildcard: integer("wildcard", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
mode: text("mode")
|
||||
.default("http")
|
||||
.$type<
|
||||
"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp"
|
||||
>()
|
||||
.notNull(), // rdp, ssh, http, vnc, inference
|
||||
pamMode: text("pamMode")
|
||||
.$type<"passthrough" | "push">()
|
||||
.default("passthrough"),
|
||||
authDaemonMode: text("authDaemonMode")
|
||||
.$type<"site" | "remote" | "native">()
|
||||
.default("site"),
|
||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||
status: text("status")
|
||||
.$type<"pending" | "approved">()
|
||||
.default("approved")
|
||||
},
|
||||
(table) => [index("idx_resources_orgId").on(table.orgId)]
|
||||
);
|
||||
|
||||
export const resourceAiProviders = sqliteTable(
|
||||
"resourceAiProviders",
|
||||
@@ -260,16 +278,20 @@ export const resourceAiModels = sqliteTable(
|
||||
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
|
||||
);
|
||||
|
||||
export const labels = sqliteTable("labels", {
|
||||
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
color: text("color").notNull(),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
});
|
||||
export const labels = sqliteTable(
|
||||
"labels",
|
||||
{
|
||||
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
color: text("color").notNull(),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
},
|
||||
(table) => [index("idx_labels_orgId").on(table.orgId)]
|
||||
);
|
||||
|
||||
export const launcherViews = sqliteTable("launcherViews", {
|
||||
viewId: integer("viewId").primaryKey({ autoIncrement: true }),
|
||||
@@ -366,35 +388,46 @@ export const clientLabels = sqliteTable(
|
||||
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
|
||||
);
|
||||
|
||||
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"
|
||||
}),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
ip: text("ip").notNull(),
|
||||
method: text("method"),
|
||||
port: integer("port").notNull(),
|
||||
internalPort: integer("internalPort"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
path: text("path"),
|
||||
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
||||
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
||||
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
|
||||
priority: integer("priority").notNull().default(100),
|
||||
mode: text("mode")
|
||||
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
|
||||
.notNull()
|
||||
.default("http"),
|
||||
authToken: text("authToken")
|
||||
});
|
||||
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" }
|
||||
),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
ip: text("ip").notNull(),
|
||||
method: text("method"),
|
||||
port: integer("port").notNull(),
|
||||
internalPort: integer("internalPort"),
|
||||
enabled: integer("enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
path: text("path"),
|
||||
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
||||
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
||||
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
|
||||
priority: integer("priority").notNull().default(100),
|
||||
mode: text("mode")
|
||||
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
|
||||
.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,50 +696,65 @@ export const setupTokens = sqliteTable("setupTokens", {
|
||||
dateUsed: text("dateUsed")
|
||||
});
|
||||
|
||||
export const newts = sqliteTable("newt", {
|
||||
newtId: text("id").primaryKey(),
|
||||
secretHash: text("secretHash").notNull(),
|
||||
dateCreated: text("dateCreated").notNull(),
|
||||
version: text("version"),
|
||||
siteId: integer("siteId").references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const clients = sqliteTable("clients", {
|
||||
clientId: integer("clientId").primaryKey({ autoIncrement: true }),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
export const newts = sqliteTable(
|
||||
"newt",
|
||||
{
|
||||
newtId: text("id").primaryKey(),
|
||||
secretHash: text("secretHash").notNull(),
|
||||
dateCreated: text("dateCreated").notNull(),
|
||||
version: text("version"),
|
||||
siteId: integer("siteId").references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
// optionally tied to a user and in this case delete when the user deletes
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: text("name").notNull(),
|
||||
pubKey: text("pubKey"),
|
||||
olmId: text("olmId"), // to lock it to a specific olm optionally
|
||||
subnet: text("subnet").notNull(),
|
||||
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node
|
||||
megabytesIn: integer("bytesIn"),
|
||||
megabytesOut: integer("bytesOut"),
|
||||
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
||||
lastPing: integer("lastPing"),
|
||||
type: text("type").notNull(), // "olm"
|
||||
online: integer("online", { mode: "boolean" }).notNull().default(false),
|
||||
// endpoint: text("endpoint"),
|
||||
lastHolePunch: integer("lastHolePunch"),
|
||||
archived: integer("archived", { mode: "boolean" }).notNull().default(false),
|
||||
blocked: integer("blocked", { mode: "boolean" }).notNull().default(false),
|
||||
approvalState: text("approvalState").$type<
|
||||
"pending" | "approved" | "denied"
|
||||
>()
|
||||
});
|
||||
},
|
||||
(table) => [index("idx_newts_siteId").on(table.siteId)]
|
||||
);
|
||||
|
||||
export const clients = sqliteTable(
|
||||
"clients",
|
||||
{
|
||||
clientId: integer("clientId").primaryKey({ autoIncrement: true }),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
// optionally tied to a user and in this case delete when the user deletes
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: text("name").notNull(),
|
||||
pubKey: text("pubKey"),
|
||||
olmId: text("olmId"), // to lock it to a specific olm optionally
|
||||
subnet: text("subnet").notNull(),
|
||||
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node
|
||||
megabytesIn: integer("bytesIn"),
|
||||
megabytesOut: integer("bytesOut"),
|
||||
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
||||
lastPing: integer("lastPing"),
|
||||
type: text("type").notNull(), // "olm"
|
||||
online: integer("online", { mode: "boolean" }).notNull().default(false),
|
||||
// endpoint: text("endpoint"),
|
||||
lastHolePunch: integer("lastHolePunch"),
|
||||
archived: integer("archived", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
blocked: integer("blocked", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
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,23 +782,29 @@ export const clientSiteResourcesAssociationsCache = sqliteTable(
|
||||
}
|
||||
);
|
||||
|
||||
export const olms = sqliteTable("olms", {
|
||||
olmId: text("id").primaryKey(),
|
||||
secretHash: text("secretHash").notNull(),
|
||||
dateCreated: text("dateCreated").notNull(),
|
||||
version: text("version"),
|
||||
agent: text("agent"),
|
||||
name: text("name"),
|
||||
clientId: integer("clientId").references(() => clients.clientId, {
|
||||
// we will switch this depending on the current org it wants to connect to
|
||||
onDelete: "set null"
|
||||
}),
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
// optionally tied to a user and in this case delete when the user deletes
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
archived: integer("archived", { mode: "boolean" }).notNull().default(false)
|
||||
});
|
||||
export const olms = sqliteTable(
|
||||
"olms",
|
||||
{
|
||||
olmId: text("id").primaryKey(),
|
||||
secretHash: text("secretHash").notNull(),
|
||||
dateCreated: text("dateCreated").notNull(),
|
||||
version: text("version"),
|
||||
agent: text("agent"),
|
||||
name: text("name"),
|
||||
clientId: integer("clientId").references(() => clients.clientId, {
|
||||
// we will switch this depending on the current org it wants to connect to
|
||||
onDelete: "set null"
|
||||
}),
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
// optionally tied to a user and in this case delete when the user deletes
|
||||
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,17 +966,21 @@ export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", {
|
||||
codeHash: text("codeHash").notNull()
|
||||
});
|
||||
|
||||
export const sessions = sqliteTable("session", {
|
||||
sessionId: text("id").primaryKey(),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
expiresAt: integer("expiresAt").notNull(),
|
||||
issuedAt: integer("issuedAt"),
|
||||
deviceAuthUsed: integer("deviceAuthUsed", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false)
|
||||
});
|
||||
export const sessions = sqliteTable(
|
||||
"session",
|
||||
{
|
||||
sessionId: text("id").primaryKey(),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
expiresAt: integer("expiresAt").notNull(),
|
||||
issuedAt: integer("issuedAt"),
|
||||
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,21 +998,30 @@ export const olmSessions = sqliteTable("clientSession", {
|
||||
expiresAt: integer("expiresAt").notNull()
|
||||
});
|
||||
|
||||
export const userOrgs = sqliteTable("userOrgs", {
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
|
||||
autoProvisioned: integer("autoProvisioned", {
|
||||
mode: "boolean"
|
||||
}).default(false),
|
||||
pamUsername: text("pamUsername") // cleaned username for ssh and such
|
||||
});
|
||||
export const userOrgs = sqliteTable(
|
||||
"userOrgs",
|
||||
{
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
isOwner: integer("isOwner", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
autoProvisioned: integer("autoProvisioned", {
|
||||
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,26 +1049,30 @@ export const actions = sqliteTable("actions", {
|
||||
description: text("description")
|
||||
});
|
||||
|
||||
export const roles = sqliteTable("roles", {
|
||||
roleId: integer("roleId").primaryKey({ autoIncrement: true }),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
isAdmin: integer("isAdmin", { mode: "boolean" }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
requireDeviceApproval: integer("requireDeviceApproval", {
|
||||
mode: "boolean"
|
||||
}).default(false),
|
||||
sshSudoMode: text("sshSudoMode").default("full"), // "none" | "full" | "commands"
|
||||
sshSudoCommands: text("sshSudoCommands").default("[]"),
|
||||
sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default(
|
||||
true
|
||||
),
|
||||
sshUnixGroups: text("sshUnixGroups").default("[]")
|
||||
});
|
||||
export const roles = sqliteTable(
|
||||
"roles",
|
||||
{
|
||||
roleId: integer("roleId").primaryKey({ autoIncrement: true }),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
isAdmin: integer("isAdmin", { mode: "boolean" }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
requireDeviceApproval: integer("requireDeviceApproval", {
|
||||
mode: "boolean"
|
||||
}).default(false),
|
||||
sshSudoMode: text("sshSudoMode").default("full"), // "none" | "full" | "commands"
|
||||
sshSudoCommands: text("sshSudoCommands").default("[]"),
|
||||
sshCreateHomeDir: integer("sshCreateHomeDir", {
|
||||
mode: "boolean"
|
||||
}).default(true),
|
||||
sshUnixGroups: text("sshUnixGroups").default("[]")
|
||||
},
|
||||
(table) => [index("idx_roles_orgId").on(table.orgId)]
|
||||
);
|
||||
|
||||
export const userOrgRoles = sqliteTable(
|
||||
"userOrgRoles",
|
||||
@@ -1928,91 +1999,6 @@ export const aiBudgetBreachEvents = sqliteTable(
|
||||
]
|
||||
);
|
||||
|
||||
// Logs the aggregated prompt + response for a single AI gateway request, for
|
||||
// session replay. One row per request (not per streaming chunk). `sessionId`
|
||||
// is a fresh random id per row for now - no cross-request correlation yet,
|
||||
// but the column exists so a future pass can link multiple rows into a real
|
||||
// multi-turn session.
|
||||
export const aiSessionLog = sqliteTable(
|
||||
"aiSessionLog",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
sessionId: text("sessionId").notNull(),
|
||||
orgId: text("orgId").references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
providerId: integer("providerId").references(
|
||||
() => aiProviders.providerId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
capability: text("capability").notNull(),
|
||||
resourceId: integer("resourceId").references(
|
||||
() => resources.resourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
siteResourceId: integer("siteResourceId").references(
|
||||
() => siteResources.siteResourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
virtualApiKeyId: text("virtualApiKeyId").references(
|
||||
() => virtualApiKeys.virtualApiKeyId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
requestedModel: text("requestedModel"),
|
||||
isStream: integer("isStream", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
requestBody: text("requestBody"),
|
||||
responseBody: text("responseBody"),
|
||||
// Capability-agnostic message transcript (JSON-encoded
|
||||
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
|
||||
// computed at write time so search/display never need per-capability
|
||||
// parsing logic. Null when normalization couldn't recognize the
|
||||
// shape - callers fall back to requestBody/responseBody.
|
||||
normalizedRequest: text("normalizedRequest"),
|
||||
normalizedResponse: text("normalizedResponse"),
|
||||
// True if any of the request/response (raw or normalized) fields
|
||||
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
|
||||
truncated: integer("truncated", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
statusCode: integer("statusCode"),
|
||||
createdAt: integer("createdAt").notNull() // epoch seconds
|
||||
},
|
||||
(t) => [
|
||||
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
|
||||
index("idx_ai_session_log_org_provider_created").on(
|
||||
t.orgId,
|
||||
t.providerId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_resource_created").on(
|
||||
t.orgId,
|
||||
t.resourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_site_resource_created").on(
|
||||
t.orgId,
|
||||
t.siteResourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_user_created").on(
|
||||
t.orgId,
|
||||
t.userId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_virtual_api_key_created").on(
|
||||
t.orgId,
|
||||
t.virtualApiKeyId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_session").on(t.sessionId)
|
||||
]
|
||||
);
|
||||
|
||||
export const certificates = sqliteTable("certificates", {
|
||||
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
||||
domain: text("domain").notNull().unique(),
|
||||
@@ -2123,7 +2109,6 @@ export type AiModel = InferSelectModel<typeof aiModels>;
|
||||
export type AiBudget = InferSelectModel<typeof aiBudgets>;
|
||||
export type AiUsageRecord = InferSelectModel<typeof aiUsageRecords>;
|
||||
export type AiBudgetBreachEvent = InferSelectModel<typeof aiBudgetBreachEvents>;
|
||||
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
|
||||
export type ResourceAiProvider = InferSelectModel<typeof resourceAiProviders>;
|
||||
export type SiteResourceAiProvider = InferSelectModel<
|
||||
typeof siteResourceAiProviders
|
||||
|
||||
@@ -16,7 +16,7 @@ import { registry, openApiTags } from "./openApi";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { APP_PATH } from "./lib/consts";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
import { z } from "zod";
|
||||
|
||||
const dev = process.env.ENVIRONMENT !== "prod";
|
||||
|
||||
@@ -121,25 +121,37 @@ export async function applyBlueprint({
|
||||
(hc) => hc.targetId === target.targetId
|
||||
);
|
||||
|
||||
if (["http", "tcp", "udp"].includes(target.mode)) {
|
||||
await addProxyTargets(
|
||||
site.newt.newtId,
|
||||
[target],
|
||||
matchingHealthcheck
|
||||
? [matchingHealthcheck]
|
||||
: [],
|
||||
result.proxyResource.mode === "udp"
|
||||
? "udp"
|
||||
: "tcp",
|
||||
site.newt.version
|
||||
);
|
||||
} else if (
|
||||
["ssh", "rdp", "vnc"].includes(target.mode)
|
||||
) {
|
||||
await sendBrowserGatewayTargets(
|
||||
site.newt.newtId,
|
||||
[target],
|
||||
site.newt.version
|
||||
// The DB writes for all resources have already committed
|
||||
// by this point, so a push failure for one target (e.g.
|
||||
// a newt rejecting a malformed health check) must not
|
||||
// abort pushing the rest, and must not mark the whole
|
||||
// blueprint as failed when the config was actually
|
||||
// persisted successfully.
|
||||
try {
|
||||
if (["http", "tcp", "udp"].includes(target.mode)) {
|
||||
await addProxyTargets(
|
||||
site.newt.newtId,
|
||||
[target],
|
||||
matchingHealthcheck
|
||||
? [matchingHealthcheck]
|
||||
: [],
|
||||
result.proxyResource.mode === "udp"
|
||||
? "udp"
|
||||
: "tcp",
|
||||
site.newt.version
|
||||
);
|
||||
} else if (
|
||||
["ssh", "rdp", "vnc"].includes(target.mode)
|
||||
) {
|
||||
await sendBrowserGatewayTargets(
|
||||
site.newt.newtId,
|
||||
[target],
|
||||
site.newt.version
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Failed to push target ${target.targetId} to newt on site ${site.sites.siteId}. Error: ${e}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { and, asc, eq, or } from "drizzle-orm";
|
||||
import { Transaction, User, userOrgs, users } from "@server/db";
|
||||
|
||||
export async function findOrgUsersByIdentifier(
|
||||
trx: Transaction,
|
||||
orgId: string,
|
||||
identifier: string
|
||||
): Promise<User[]> {
|
||||
const matches = await trx
|
||||
.select()
|
||||
.from(users)
|
||||
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||
.where(
|
||||
and(
|
||||
or(eq(users.username, identifier), eq(users.email, identifier)),
|
||||
eq(userOrgs.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(users.dateCreated), asc(users.userId));
|
||||
|
||||
return matches.map((match) => match.user);
|
||||
}
|
||||
|
||||
export async function resolveOrgUserIds(
|
||||
trx: Transaction,
|
||||
orgId: string,
|
||||
identifiers: string[]
|
||||
): Promise<string[]> {
|
||||
const userIds = new Set<string>();
|
||||
for (const identifier of identifiers) {
|
||||
const matchedUsers = await findOrgUsersByIdentifier(
|
||||
trx,
|
||||
orgId,
|
||||
identifier
|
||||
);
|
||||
for (const user of matchedUsers) {
|
||||
userIds.add(user.userId);
|
||||
}
|
||||
}
|
||||
return [...userIds];
|
||||
}
|
||||
@@ -11,15 +11,14 @@ import {
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
Transaction,
|
||||
userOrgs,
|
||||
users,
|
||||
userSiteResources,
|
||||
networks
|
||||
} from "@server/db";
|
||||
import { sites } from "@server/db";
|
||||
import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
|
||||
import { eq, and, ne, inArray, isNotNull } from "drizzle-orm";
|
||||
import { Config } from "./types";
|
||||
import { getOrCreateLabelIds, syncSiteResourceLabels } from "./labels";
|
||||
import { resolveOrgUserIds } from "./findOrgUser";
|
||||
import logger from "@server/logger";
|
||||
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
||||
import { getNextAvailableAliasAddress } from "../ip";
|
||||
@@ -389,28 +388,22 @@ export async function updatePrivateResources(
|
||||
.where(eq(userSiteResources.siteResourceId, siteResourceId));
|
||||
|
||||
if (resourceData.users.length > 0) {
|
||||
// get userIds from username
|
||||
const usersToUpdate = await trx
|
||||
.select()
|
||||
.from(users)
|
||||
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
inArray(users.username, resourceData.users),
|
||||
inArray(users.email, resourceData.users)
|
||||
),
|
||||
eq(userOrgs.orgId, orgId)
|
||||
)
|
||||
);
|
||||
const userIds = await resolveOrgUserIds(
|
||||
trx,
|
||||
orgId,
|
||||
resourceData.users
|
||||
);
|
||||
|
||||
const userIds = usersToUpdate.map((user) => user.user.userId);
|
||||
|
||||
await trx
|
||||
.insert(userSiteResources)
|
||||
.values(
|
||||
userIds.map((userId) => ({ userId, siteResourceId }))
|
||||
);
|
||||
if (userIds.length > 0) {
|
||||
await trx
|
||||
.insert(userSiteResources)
|
||||
.values(
|
||||
userIds.map((userId) => ({
|
||||
userId,
|
||||
siteResourceId
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all admin role IDs for this org to exclude from deletion
|
||||
@@ -721,28 +714,22 @@ export async function updatePrivateResources(
|
||||
}
|
||||
|
||||
if (resourceData.users.length > 0) {
|
||||
// get userIds from username
|
||||
const usersToUpdate = await trx
|
||||
.select()
|
||||
.from(users)
|
||||
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
inArray(users.username, resourceData.users),
|
||||
inArray(users.email, resourceData.users)
|
||||
),
|
||||
eq(userOrgs.orgId, orgId)
|
||||
)
|
||||
);
|
||||
const userIds = await resolveOrgUserIds(
|
||||
trx,
|
||||
orgId,
|
||||
resourceData.users
|
||||
);
|
||||
|
||||
const userIds = usersToUpdate.map((user) => user.user.userId);
|
||||
|
||||
await trx
|
||||
.insert(userSiteResources)
|
||||
.values(
|
||||
userIds.map((userId) => ({ userId, siteResourceId }))
|
||||
);
|
||||
if (userIds.length > 0) {
|
||||
await trx
|
||||
.insert(userSiteResources)
|
||||
.values(
|
||||
userIds.map((userId) => ({
|
||||
userId,
|
||||
siteResourceId
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceData.machines.length > 0) {
|
||||
|
||||
@@ -46,11 +46,12 @@ import { encrypt } from "@server/lib/crypto";
|
||||
import logger from "@server/logger";
|
||||
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
||||
import { pickPort } from "@server/routers/target/helpers";
|
||||
import { and, asc, eq, isNotNull, ne, or } from "drizzle-orm";
|
||||
import { and, asc, eq, isNotNull, ne } from "drizzle-orm";
|
||||
import { tierMatrix } from "../billing/tierMatrix";
|
||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
||||
import { Config, isTargetsOnlyResource, TargetData } from "./types";
|
||||
import { getOrCreateLabelIds, syncResourceLabels } from "./labels";
|
||||
import { findOrgUsersByIdentifier } from "./findOrgUser";
|
||||
import { LimitId } from "../billing";
|
||||
import { usageService } from "../billing/usageService";
|
||||
import { syncInferenceAiConfig } from "./aiProviders";
|
||||
@@ -1563,31 +1564,27 @@ async function syncUserResources(
|
||||
.where(eq(userResources.resourceId, resourceId));
|
||||
|
||||
for (const username of ssoUsers) {
|
||||
const [user] = await trx
|
||||
.select()
|
||||
.from(users)
|
||||
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||
.where(
|
||||
and(
|
||||
or(eq(users.username, username), eq(users.email, username)),
|
||||
eq(userOrgs.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
const matchedUsers = await findOrgUsersByIdentifier(
|
||||
trx,
|
||||
orgId,
|
||||
username
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
if (matchedUsers.length === 0) {
|
||||
throw new Error(`User not found: ${username} in org ${orgId}`);
|
||||
}
|
||||
|
||||
const existingUserResource = existingUserResources.find(
|
||||
(rr) => rr.userId === user.user.userId
|
||||
);
|
||||
for (const user of matchedUsers) {
|
||||
const existingUserResource = existingUserResources.find(
|
||||
(rr) => rr.userId === user.userId
|
||||
);
|
||||
|
||||
if (!existingUserResource) {
|
||||
await trx.insert(userResources).values({
|
||||
userId: user.user.userId,
|
||||
resourceId: resourceId
|
||||
});
|
||||
if (!existingUserResource) {
|
||||
await trx.insert(userResources).values({
|
||||
userId: user.userId,
|
||||
resourceId: resourceId
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1955,31 +1952,27 @@ async function syncUserPolicies(
|
||||
.where(eq(userPolicies.resourcePolicyId, policyId));
|
||||
|
||||
for (const username of ssoUsers) {
|
||||
const [user] = await trx
|
||||
.select()
|
||||
.from(users)
|
||||
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||
.where(
|
||||
and(
|
||||
or(eq(users.username, username), eq(users.email, username)),
|
||||
eq(userOrgs.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
const matchedUsers = await findOrgUsersByIdentifier(
|
||||
trx,
|
||||
orgId,
|
||||
username
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
if (matchedUsers.length === 0) {
|
||||
throw new Error(`User not found: ${username} in org ${orgId}`);
|
||||
}
|
||||
|
||||
const existingUserPolicy = existingUserPoliciesList.find(
|
||||
(up) => up.userId === user.user.userId
|
||||
);
|
||||
for (const user of matchedUsers) {
|
||||
const existingUserPolicy = existingUserPoliciesList.find(
|
||||
(up) => up.userId === user.userId
|
||||
);
|
||||
|
||||
if (!existingUserPolicy) {
|
||||
await trx.insert(userPolicies).values({
|
||||
userId: user.user.userId,
|
||||
resourcePolicyId: policyId
|
||||
});
|
||||
if (!existingUserPolicy) {
|
||||
await trx.insert(userPolicies).values({
|
||||
userId: user.userId,
|
||||
resourcePolicyId: policyId
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
userPolicies,
|
||||
users
|
||||
} from "@server/db";
|
||||
import { eq, and, or } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { Config, ResourcePolicyData } from "./types";
|
||||
import logger from "@server/logger";
|
||||
import { getUniqueResourcePolicyName } from "@server/db/names";
|
||||
@@ -22,6 +22,7 @@ import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg";
|
||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "../billing/tierMatrix";
|
||||
import { findOrgUsersByIdentifier } from "./findOrgUser";
|
||||
|
||||
export type ResourcePoliciesResults = {
|
||||
resourcePolicyId: number;
|
||||
@@ -466,34 +467,30 @@ async function syncUserPolicies(
|
||||
.where(eq(userPolicies.resourcePolicyId, policyId));
|
||||
|
||||
for (const username of ssoUsers) {
|
||||
const [user] = await trx
|
||||
.select()
|
||||
.from(users)
|
||||
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||
.where(
|
||||
and(
|
||||
or(eq(users.username, username), eq(users.email, username)),
|
||||
eq(userOrgs.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
const matchedUsers = await findOrgUsersByIdentifier(
|
||||
trx,
|
||||
orgId,
|
||||
username
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
if (matchedUsers.length === 0) {
|
||||
logger.warn(
|
||||
`User '${username}' not found in org '${orgId}', skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const alreadyExists = existingUserPolicies.some(
|
||||
(up) => up.userId === user.user.userId
|
||||
);
|
||||
for (const user of matchedUsers) {
|
||||
const alreadyExists = existingUserPolicies.some(
|
||||
(up) => up.userId === user.userId
|
||||
);
|
||||
|
||||
if (!alreadyExists) {
|
||||
await trx.insert(userPolicies).values({
|
||||
userId: user.user.userId,
|
||||
resourcePolicyId: policyId
|
||||
});
|
||||
if (!alreadyExists) {
|
||||
await trx.insert(userPolicies).values({
|
||||
userId: user.userId,
|
||||
resourcePolicyId: policyId
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,29 +533,25 @@ async function addUserPolicies(
|
||||
trx: Transaction
|
||||
) {
|
||||
for (const username of ssoUsers) {
|
||||
const [user] = await trx
|
||||
.select()
|
||||
.from(users)
|
||||
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||
.where(
|
||||
and(
|
||||
or(eq(users.username, username), eq(users.email, username)),
|
||||
eq(userOrgs.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
const matchedUsers = await findOrgUsersByIdentifier(
|
||||
trx,
|
||||
orgId,
|
||||
username
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
if (matchedUsers.length === 0) {
|
||||
logger.warn(
|
||||
`User '${username}' not found in org '${orgId}', skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await trx.insert(userPolicies).values({
|
||||
userId: user.user.userId,
|
||||
resourcePolicyId: policyId
|
||||
});
|
||||
for (const user of matchedUsers) {
|
||||
await trx.insert(userPolicies).values({
|
||||
userId: user.userId,
|
||||
resourcePolicyId: policyId
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,8 +29,34 @@ export const SiteSchema = z.object({
|
||||
"docker-socket-enabled": z.boolean().optional().default(true)
|
||||
});
|
||||
|
||||
// A malformed hostname (e.g. stray whitespace) is silently accepted here but
|
||||
// fails to parse as a URL when newt builds the health check request, which
|
||||
// takes the target out of the routing pool and breaks the resource entirely
|
||||
// (see #3677). Validate eagerly so blueprints reject it up front instead.
|
||||
const healthCheckHostnameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine((val) => !/\s/.test(val), {
|
||||
message: "Hostname must not contain whitespace"
|
||||
})
|
||||
.refine(
|
||||
(val) => {
|
||||
if (z.union([z.ipv4(), z.ipv6()]).safeParse(val).success) {
|
||||
return true;
|
||||
}
|
||||
const hostnameRegex =
|
||||
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
||||
return hostnameRegex.test(val);
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Hostname must be a valid IP address or hostname (no spaces or invalid characters)"
|
||||
}
|
||||
);
|
||||
|
||||
export const TargetHealthCheckSchema = z.object({
|
||||
hostname: z.string(),
|
||||
hostname: healthCheckHostnameSchema,
|
||||
port: z.int().min(1).max(65535),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
path: z.string().optional().default("/"),
|
||||
@@ -101,7 +127,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 +162,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,7 +55,7 @@ export async function validateAndConstructDomain(
|
||||
};
|
||||
}
|
||||
|
||||
if (!domainRes.orgDomains) {
|
||||
if (!domainRes.orgDomains && !domainRes.domainNamespaces) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Organization does not have access to domain with ID ${domainId}`
|
||||
@@ -83,19 +86,11 @@ 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) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead."
|
||||
};
|
||||
}
|
||||
if (isWildcard && domainRes.domainNamespaces) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead."
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
import { configFilePath1, configFilePath2 } from "./consts";
|
||||
import { z } from "zod";
|
||||
import stoi from "./stoi";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
import { privateConfigFilePath1 } from "@server/lib/consts";
|
||||
import { z } from "zod";
|
||||
import { colorsSchema } from "@server/lib/colorsSchema";
|
||||
|
||||
@@ -158,7 +158,7 @@ class RedisManager {
|
||||
this.writeClient = new Redis({
|
||||
...masterConfig,
|
||||
enableReadyCheck: false,
|
||||
maxRetriesPerRequest: 3,
|
||||
maxRetriesPerRequest: 50,
|
||||
keepAlive: 30000,
|
||||
connectTimeout: this.connectionTimeout,
|
||||
commandTimeout: this.commandTimeout
|
||||
@@ -169,7 +169,7 @@ class RedisManager {
|
||||
this.readClient = new Redis({
|
||||
...replicaConfig!,
|
||||
enableReadyCheck: false,
|
||||
maxRetriesPerRequest: 3,
|
||||
maxRetriesPerRequest: 50,
|
||||
keepAlive: 30000,
|
||||
connectTimeout: this.connectionTimeout,
|
||||
commandTimeout: this.commandTimeout
|
||||
@@ -186,7 +186,7 @@ class RedisManager {
|
||||
this.publisher = new Redis({
|
||||
...masterConfig,
|
||||
enableReadyCheck: false,
|
||||
maxRetriesPerRequest: 3,
|
||||
maxRetriesPerRequest: 50,
|
||||
keepAlive: 30000,
|
||||
connectTimeout: this.connectionTimeout,
|
||||
commandTimeout: this.commandTimeout
|
||||
@@ -196,7 +196,7 @@ class RedisManager {
|
||||
this.subscriber = new Redis({
|
||||
...(this.hasReplicas ? replicaConfig! : masterConfig),
|
||||
enableReadyCheck: false,
|
||||
maxRetriesPerRequest: 3,
|
||||
maxRetriesPerRequest: 50,
|
||||
keepAlive: 30000,
|
||||
connectTimeout: this.connectionTimeout,
|
||||
commandTimeout: this.commandTimeout
|
||||
@@ -901,7 +901,9 @@ class RegionalRedisManager {
|
||||
// if the configured host doesn't match that pattern (e.g. local dev),
|
||||
// in which case callers should fall back to the primary for reads.
|
||||
private getReplicaHost(primaryHost: string): string | null {
|
||||
const match = primaryHost.match(/^redis\.([^.]+)\.svc\.cluster\.local$/);
|
||||
const match = primaryHost.match(
|
||||
/^redis\.([^.]+)\.svc\.cluster\.local$/
|
||||
);
|
||||
if (!match) return null;
|
||||
const namespace = match[1];
|
||||
return `redis-1.redis-headless.${namespace}.svc.cluster.local`;
|
||||
@@ -912,7 +914,7 @@ class RegionalRedisManager {
|
||||
const baseOpts = {
|
||||
...cfg,
|
||||
enableReadyCheck: false,
|
||||
maxRetriesPerRequest: 3,
|
||||
maxRetriesPerRequest: 50,
|
||||
keepAlive: 10000,
|
||||
connectTimeout: this.connectionTimeout,
|
||||
commandTimeout: this.commandTimeout
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -561,7 +557,7 @@ LQIDAQAB
|
||||
// Calculate exponential backoff delay
|
||||
const retryDelay = Math.floor(
|
||||
initialRetryDelay *
|
||||
Math.pow(exponentialFactor, attempt - 1)
|
||||
Math.pow(exponentialFactor, attempt - 1)
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -87,12 +87,6 @@ authenticated.get("/org/checkId", org.checkId);
|
||||
authenticated.put("/org", getUserOrgs, org.createOrg);
|
||||
|
||||
authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs);
|
||||
authenticated.get("/admin/orgs", verifyUserIsServerAdmin, org.adminListOrgs);
|
||||
authenticated.delete(
|
||||
"/admin/org/:orgId",
|
||||
verifyUserIsServerAdmin,
|
||||
org.adminDeleteOrg
|
||||
);
|
||||
authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);
|
||||
|
||||
authenticated.get(
|
||||
|
||||
@@ -984,7 +984,7 @@ async function listLabelGroups(
|
||||
).length;
|
||||
}
|
||||
|
||||
let groups: LauncherGroup[] = Array.from(labelCountMap.values()).map(
|
||||
const groups: LauncherGroup[] = Array.from(labelCountMap.values()).map(
|
||||
(row) => ({
|
||||
groupKey: String(row.labelId),
|
||||
name: row.name,
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
);
|
||||
|
||||
const siteResourcesBySiteId = new Map<number, SiteResource[]>();
|
||||
let siteResourcesForExitNode = [];
|
||||
const siteResourcesForExitNode = [];
|
||||
for (const row of allClientSiteResources) {
|
||||
if (row.siteResource.requiresExitNodeConnection) {
|
||||
siteResourcesForExitNode.push(row.siteResource);
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg";
|
||||
import { db, orgs } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const adminDeleteOrgSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
export type AdminDeleteOrgResponse = {};
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/admin/org/{orgId}",
|
||||
description: "Delete any organization in the system (server admin).",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: adminDeleteOrgSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
status: z.number()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function adminDeleteOrg(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = adminDeleteOrgSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Organization with ID ${orgId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const result = await deleteOrgById(orgId);
|
||||
sendTerminationMessages(result);
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Organization deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
if (createHttpError.isHttpError(error)) {
|
||||
return next(error);
|
||||
}
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred..."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, users } from "@server/db";
|
||||
import { orgs, resources, sites, userOrgs } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
|
||||
const adminListOrgsSchema = z.strictObject({
|
||||
pageSize: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
}),
|
||||
query: z.string().optional(),
|
||||
sort_by: z
|
||||
.enum(["name", "createdAt"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["name", "createdAt"],
|
||||
description: "Field to sort by"
|
||||
}),
|
||||
order: z
|
||||
.enum(["asc", "desc"])
|
||||
.optional()
|
||||
.default("asc")
|
||||
.catch("asc")
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["asc", "desc"],
|
||||
default: "asc",
|
||||
description: "Sort order"
|
||||
})
|
||||
});
|
||||
|
||||
export type AdminOrgRow = {
|
||||
orgId: string;
|
||||
name: string;
|
||||
subnet: string | null;
|
||||
utilitySubnet: string | null;
|
||||
createdAt: string | null;
|
||||
userCount: number;
|
||||
siteCount: number;
|
||||
resourceCount: number;
|
||||
owner: {
|
||||
userId: string;
|
||||
username: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type AdminListOrgsResponse = PaginatedResponse<{
|
||||
orgs: AdminOrgRow[];
|
||||
}>;
|
||||
|
||||
const AdminListOrgsResponseDataSchema = z.object({
|
||||
orgs: z.array(
|
||||
z.object({
|
||||
orgId: z.string(),
|
||||
name: z.string(),
|
||||
subnet: z.string().nullable(),
|
||||
createdAt: z.string().nullable(),
|
||||
userCount: z.number(),
|
||||
siteCount: z.number(),
|
||||
resourceCount: z.number()
|
||||
})
|
||||
),
|
||||
pagination: z.object({
|
||||
total: z.number(),
|
||||
page: z.number(),
|
||||
pageSize: z.number()
|
||||
})
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/admin/orgs",
|
||||
description:
|
||||
"List all organizations in the system with usage counts (server admin).",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
query: adminListOrgsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: createApiResponseSchema(
|
||||
AdminListOrgsResponseDataSchema
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function adminListOrgs(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = adminListOrgsSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { pageSize, page, query, sort_by, order } = parsedQuery.data;
|
||||
|
||||
let conditions: (SQL<unknown> | undefined)[] = [];
|
||||
if (query) {
|
||||
const q = "%" + query.toLowerCase() + "%";
|
||||
conditions.push(
|
||||
or(
|
||||
like(sql`LOWER(${orgs.name})`, q),
|
||||
like(sql`LOWER(${orgs.orgId})`, q),
|
||||
like(sql`LOWER(${orgs.subnet})`, q)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const sortColumns = {
|
||||
name: orgs.name,
|
||||
createdAt: orgs.createdAt
|
||||
} as const;
|
||||
|
||||
const orderBy = sort_by
|
||||
? order === "asc"
|
||||
? asc(sortColumns[sort_by])
|
||||
: desc(sortColumns[sort_by])
|
||||
: asc(orgs.name);
|
||||
|
||||
// Drizzle renders bare column references in the select list without their
|
||||
// table prefix, which would make a correlated subquery compare a column to
|
||||
// itself, so the outer `orgs` side is qualified explicitly.
|
||||
const orgIdRef = sql`${sql.identifier("orgs")}.${sql.identifier("orgId")}`;
|
||||
|
||||
const [countRows, rows] = await Promise.all([
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(orgs)
|
||||
.where(and(...conditions)),
|
||||
db
|
||||
.selectDistinct({
|
||||
orgId: orgs.orgId,
|
||||
name: orgs.name,
|
||||
subnet: orgs.subnet,
|
||||
utilitySubnet: orgs.utilitySubnet,
|
||||
createdAt: orgs.createdAt,
|
||||
userCount: sql<number>`(
|
||||
SELECT COUNT(*)
|
||||
FROM ${userOrgs}
|
||||
WHERE ${userOrgs.orgId} = ${orgIdRef}
|
||||
)`.as("userCount"),
|
||||
siteCount: sql<number>`(
|
||||
SELECT COUNT(*)
|
||||
FROM ${sites}
|
||||
WHERE ${sites.orgId} = ${orgIdRef}
|
||||
)`.as("siteCount"),
|
||||
resourceCount: sql<number>`(
|
||||
SELECT COUNT(*)
|
||||
FROM ${resources}
|
||||
WHERE ${resources.orgId} = ${orgIdRef}
|
||||
)`.as("resourceCount"),
|
||||
owner: {
|
||||
userId: users.userId,
|
||||
username: users.username
|
||||
}
|
||||
})
|
||||
.from(orgs)
|
||||
.where(and(...conditions, eq(userOrgs.isOwner, true)))
|
||||
.leftJoin(userOrgs, eq(userOrgs.orgId, orgs.orgId))
|
||||
.leftJoin(users, eq(userOrgs.userId, users.userId))
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(orderBy)
|
||||
]);
|
||||
|
||||
const totalCount = Number(countRows[0]?.count ?? 0);
|
||||
|
||||
return response<AdminListOrgsResponse>(res, {
|
||||
data: {
|
||||
orgs: rows.map((row) => ({
|
||||
...row,
|
||||
userCount: Number(row.userCount ?? 0),
|
||||
siteCount: Number(row.siteCount ?? 0),
|
||||
resourceCount: Number(row.resourceCount ?? 0)
|
||||
})),
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Organizations retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred..."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,3 @@ export * from "./listOrgs";
|
||||
export * from "./pickOrgDefaults";
|
||||
export * from "./checkOrgUserAccess";
|
||||
export * from "./resetOrgBandwidth";
|
||||
export * from "./adminListOrgs";
|
||||
export * from "./adminDeleteOrg";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import { generateCA } from "@server/lib/sshCA";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
const version = "1.16.0";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { db } from "@server/db/pg/driver";
|
||||
import { APP_PATH, __DIRNAME } from "@server/lib/consts";
|
||||
import { sql } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
import path, { join } from "path";
|
||||
import z from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { db } from "@server/db/pg/driver";
|
||||
import { APP_PATH } from "@server/lib/consts";
|
||||
import { sql } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
import path from "path";
|
||||
import z from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { db } from "@server/db/pg/driver";
|
||||
import { APP_PATH } from "@server/lib/consts";
|
||||
import { sql } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
import path from "path";
|
||||
import z from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
@@ -454,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(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { db } from "@server/db/pg/driver";
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import { sql } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
const version = "1.6.0";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
export default async function migration() {
|
||||
console.log("Running setup script 1.0.0-beta.10...");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { db } from "../../db/sqlite";
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import { sql } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
export default async function migration() {
|
||||
console.log("Running setup script 1.0.0-beta.12...");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from "../../db/sqlite";
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { domains, orgDomains, resources } from "@server/db";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
export default async function migration() {
|
||||
console.log("Running setup script 1.0.0-beta.2...");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
export default async function migration() {
|
||||
console.log("Running setup script 1.0.0-beta.3...");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
import path from "path";
|
||||
import { z } from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
export default async function migration() {
|
||||
console.log("Running setup script 1.0.0-beta.6...");
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
import path from "path";
|
||||
import { z } from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { APP_PATH } from "@server/lib/consts";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
import path from "path";
|
||||
import { z } from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { generateCA } from "@server/lib/sshCA";
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
const version = "1.16.0";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import Database from "better-sqlite3";
|
||||
import z from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
import path, { join } from "path";
|
||||
|
||||
const version = "1.19.0";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { db } from "../../db/sqlite";
|
||||
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import { sql } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
import path from "path";
|
||||
import { z } from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { APP_PATH } from "@server/lib/consts";
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
import path from "path";
|
||||
import z from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { build } from "@server/build";
|
||||
import { APP_PATH } from "@server/lib/consts";
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
import path from "path";
|
||||
import z from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
@@ -459,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(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
import { encodeBase32LowerCaseNoPadding } from "@oslojs/encoding";
|
||||
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
const version = "1.5.0";
|
||||
const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
import path from "path";
|
||||
|
||||
const version = "1.6.0";
|
||||
|
||||
@@ -46,6 +46,7 @@ import { AxiosResponse } from "axios";
|
||||
import { ListRolesResponse } from "@server/routers/role";
|
||||
import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget";
|
||||
import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription";
|
||||
import IdpIdentifierChangeDialog from "@app/components/IdpIdentifierChangeDialog";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import {
|
||||
@@ -75,6 +76,12 @@ export default function GeneralPage() {
|
||||
>([createMappingBuilderRule()]);
|
||||
const [rawRoleExpression, setRawRoleExpression] = useState("");
|
||||
const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc");
|
||||
const [originalIdentifierPath, setOriginalIdentifierPath] = useState("");
|
||||
const [identifierConfirmOpen, setIdentifierConfirmOpen] = useState(false);
|
||||
const [pendingPayload, setPendingPayload] = useState<Record<
|
||||
string,
|
||||
unknown
|
||||
> | null>(null);
|
||||
|
||||
const dashboardRedirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
|
||||
const [redirectUrl, setRedirectUrl] = useState(
|
||||
@@ -184,6 +191,9 @@ export default function GeneralPage() {
|
||||
const data = res.data.data;
|
||||
const roleMapping = data.idpOrg.roleMapping;
|
||||
const idpVariant = data.idpOidcConfig?.variant || "oidc";
|
||||
setOriginalIdentifierPath(
|
||||
data.idpOidcConfig?.identifierPath ?? "sub"
|
||||
);
|
||||
setRedirectUrl(res.data.data.redirectUrl);
|
||||
|
||||
// Set the variant
|
||||
@@ -378,18 +388,56 @@ export default function GeneralPage() {
|
||||
};
|
||||
}
|
||||
|
||||
const res = await api.post(
|
||||
`/org/${orgId}/idp/${idpId}/oidc`,
|
||||
payload
|
||||
);
|
||||
const nextIdentifierPath =
|
||||
variant === "oidc"
|
||||
? (data as OidcFormValues).identifierPath
|
||||
: undefined;
|
||||
|
||||
if (res.status === 200) {
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("idpUpdatedDescription")
|
||||
});
|
||||
router.refresh();
|
||||
if (
|
||||
typeof nextIdentifierPath === "string" &&
|
||||
nextIdentifierPath !== originalIdentifierPath
|
||||
) {
|
||||
setPendingPayload(payload);
|
||||
setIdentifierConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await persistIdp(payload);
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistIdp(payload: Record<string, unknown>) {
|
||||
const res = await api.post(`/org/${orgId}/idp/${idpId}/oidc`, payload);
|
||||
|
||||
if (res.status === 200) {
|
||||
if (typeof payload.identifierPath === "string") {
|
||||
setOriginalIdentifierPath(payload.identifierPath);
|
||||
}
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("idpUpdatedDescription")
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmIdentifierChange() {
|
||||
if (!pendingPayload) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await persistIdp(pendingPayload);
|
||||
setPendingPayload(null);
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
@@ -407,6 +455,16 @@ export default function GeneralPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<IdpIdentifierChangeDialog
|
||||
open={identifierConfirmOpen}
|
||||
setOpen={(open) => {
|
||||
setIdentifierConfirmOpen(open);
|
||||
if (!open) {
|
||||
setPendingPayload(null);
|
||||
}
|
||||
}}
|
||||
onConfirm={confirmIdentifierChange}
|
||||
/>
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
|
||||
@@ -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.delete(key);
|
||||
if (typeof value === "string") {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
} 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`, {
|
||||
responseType: "blob",
|
||||
params
|
||||
});
|
||||
// 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.delete(key);
|
||||
if (typeof value === "string") {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
} 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)) {
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from "@app/components/InfoSection";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import IdpTypeBadge from "@app/components/IdpTypeBadge";
|
||||
import IdpIdentifierChangeDialog from "@app/components/IdpIdentifierChangeDialog";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function GeneralPage() {
|
||||
@@ -51,6 +52,12 @@ export default function GeneralPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc");
|
||||
const [originalIdentifierPath, setOriginalIdentifierPath] = useState("");
|
||||
const [identifierConfirmOpen, setIdentifierConfirmOpen] = useState(false);
|
||||
const [pendingPayload, setPendingPayload] = useState<Record<
|
||||
string,
|
||||
unknown
|
||||
> | null>(null);
|
||||
|
||||
const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
|
||||
const t = useTranslations();
|
||||
@@ -141,6 +148,9 @@ export default function GeneralPage() {
|
||||
| "google"
|
||||
| "azure") || "oidc";
|
||||
setVariant(idpVariant);
|
||||
setOriginalIdentifierPath(
|
||||
data.idpOidcConfig?.identifierPath ?? "sub"
|
||||
);
|
||||
|
||||
let tenantId = "";
|
||||
if (idpVariant === "azure" && data.idpOidcConfig?.authUrl) {
|
||||
@@ -258,15 +268,56 @@ export default function GeneralPage() {
|
||||
};
|
||||
}
|
||||
|
||||
const res = await api.post(`/idp/${idpId}/oidc`, payload);
|
||||
const nextIdentifierPath =
|
||||
variant === "oidc"
|
||||
? (data as OidcFormValues).identifierPath
|
||||
: undefined;
|
||||
|
||||
if (res.status === 200) {
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("idpUpdatedDescription")
|
||||
});
|
||||
router.refresh();
|
||||
if (
|
||||
typeof nextIdentifierPath === "string" &&
|
||||
nextIdentifierPath !== originalIdentifierPath
|
||||
) {
|
||||
setPendingPayload(payload);
|
||||
setIdentifierConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await persistIdp(payload);
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistIdp(payload: Record<string, unknown>) {
|
||||
const res = await api.post(`/idp/${idpId}/oidc`, payload);
|
||||
|
||||
if (res.status === 200) {
|
||||
if (typeof payload.identifierPath === "string") {
|
||||
setOriginalIdentifierPath(payload.identifierPath);
|
||||
}
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("idpUpdatedDescription")
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmIdentifierChange() {
|
||||
if (!pendingPayload) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await persistIdp(pendingPayload);
|
||||
setPendingPayload(null);
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
@@ -284,6 +335,16 @@ export default function GeneralPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<IdpIdentifierChangeDialog
|
||||
open={identifierConfirmOpen}
|
||||
setOpen={(open) => {
|
||||
setIdentifierConfirmOpen(open);
|
||||
if (!open) {
|
||||
setPendingPayload(null);
|
||||
}
|
||||
}}
|
||||
onConfirm={confirmIdentifierChange}
|
||||
/>
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import OrgsTable from "@app/components/OrgsTable";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import type { AdminListOrgsResponse } from "@server/routers/org";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Organizations"
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type OrganizationsPageProps = {
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
};
|
||||
|
||||
export default async function OrganizationsPage(props: OrganizationsPageProps) {
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
|
||||
let orgs: AdminListOrgsResponse["orgs"] = [];
|
||||
let pagination: AdminListOrgsResponse["pagination"] = {
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<AdminListOrgsResponse>>(
|
||||
`/admin/orgs?${searchParams.toString()}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
const responseData = res.data.data;
|
||||
orgs = responseData.orgs;
|
||||
pagination = responseData.pagination;
|
||||
} catch (e) {}
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("orgsManage")}
|
||||
description={t("orgsDescription")}
|
||||
/>
|
||||
|
||||
<OrgsTable
|
||||
orgs={orgs}
|
||||
rowCount={pagination.total}
|
||||
pagination={{
|
||||
pageIndex: pagination.page - 1,
|
||||
pageSize: pagination.pageSize
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Bot,
|
||||
Boxes,
|
||||
Building2,
|
||||
Building2Icon,
|
||||
Cable,
|
||||
ChartLine,
|
||||
Coins,
|
||||
@@ -378,11 +377,6 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
|
||||
href: "/admin/api-keys",
|
||||
icon: <KeyRound className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarOrgs",
|
||||
href: "/admin/organizations",
|
||||
icon: <Building2Icon className="size-4 flex-none" />
|
||||
},
|
||||
...(build === "oss" ||
|
||||
env?.app.identityProviderMode === "global" ||
|
||||
env?.app.identityProviderMode === undefined
|
||||
@@ -394,7 +388,7 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(build === "enterprise"
|
||||
...(build == "enterprise"
|
||||
? [
|
||||
{
|
||||
title: "sidebarLicense",
|
||||
|
||||
@@ -378,7 +378,6 @@ function EditAttachmentCredenza({
|
||||
setModelSearch("");
|
||||
pendingSeedRef.current = false;
|
||||
// Only re-init when opening or switching which attachment is edited.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, attachment.providerId]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -172,7 +172,6 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
|
||||
.nullable()
|
||||
.optional(),
|
||||
hcScheme: z.string().optional(),
|
||||
hcHostname: z.string(),
|
||||
hcPort: z
|
||||
.string()
|
||||
.min(1, { message: t("healthCheckPortInvalid") })
|
||||
@@ -184,6 +183,11 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
|
||||
{ message: t("healthCheckPortInvalid") }
|
||||
),
|
||||
hcFollowRedirects: z.boolean(),
|
||||
hcHostname: z
|
||||
.string()
|
||||
.refine((val) => !/\s/.test(val), {
|
||||
message: t("healthCheckHostnameInvalid")
|
||||
}),
|
||||
hcMode: z.string(),
|
||||
hcUnhealthyInterval: z.int().positive().min(5),
|
||||
hcTlsServerName: z.string(),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type IdpIdentifierChangeDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
};
|
||||
|
||||
export default function IdpIdentifierChangeDialog({
|
||||
open,
|
||||
setOpen,
|
||||
onConfirm
|
||||
}: IdpIdentifierChangeDialogProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<ConfirmDeleteDialog
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("idpIdentifierChangeDescription")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("saveGeneralSettings")}
|
||||
onConfirm={onConfirm}
|
||||
string={t("idpIdentifierChangeConfirmMessage")}
|
||||
title={t("idpIdentifierChangeTitle")}
|
||||
warningText={t("idpIdentifierChangeWarningText")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -319,7 +319,6 @@ export function LogDataTable<TData, TValue>({
|
||||
const pageIndex = table.getState().pagination.pageIndex;
|
||||
useEffect(() => {
|
||||
setExpandedRows(new Set());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageIndex]);
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
ControlledDataTable,
|
||||
type ExtendedColumnDef
|
||||
} from "@app/components/ui/controlled-data-table";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import type { AdminOrgRow } from "@server/routers/org";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { type PaginationState } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowUp10Icon,
|
||||
ArrowUpRight,
|
||||
ChevronsUpDownIcon
|
||||
} from "lucide-react";
|
||||
import moment from "moment";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
|
||||
|
||||
type OrgTableProps = {
|
||||
orgs: AdminOrgRow[];
|
||||
pagination: PaginationState;
|
||||
rowCount: number;
|
||||
};
|
||||
|
||||
export default function OrgsTable({
|
||||
orgs,
|
||||
pagination,
|
||||
rowCount
|
||||
}: OrgTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const {
|
||||
navigate: filter,
|
||||
isNavigating: isFiltering,
|
||||
searchParams
|
||||
} = useNavigationContext();
|
||||
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedOrg, setSelectedOrg] = useState<AdminOrgRow | null>();
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
function refreshData() {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSort(column: string) {
|
||||
const newSearch = getNextSortOrder(column, searchParams);
|
||||
|
||||
filter({
|
||||
searchParams: newSearch
|
||||
});
|
||||
}
|
||||
|
||||
function sortableHeader(column: string, label: string) {
|
||||
const sortOrder = getSortDirection(column, searchParams);
|
||||
const Icon =
|
||||
sortOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: sortOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-3"
|
||||
onClick={() => toggleSort(column)}
|
||||
>
|
||||
{label}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const columns = useMemo<ExtendedColumnDef<AdminOrgRow>[]>(() => {
|
||||
return [
|
||||
{
|
||||
accessorKey: "name",
|
||||
friendlyName: t("name"),
|
||||
enableHiding: false,
|
||||
header: () => sortableHeader("name", t("name"))
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
friendlyName: t("createdAt"),
|
||||
header: () => sortableHeader("createdAt", t("createdAt")),
|
||||
cell: ({ row }) => {
|
||||
const createdAt = row.original.createdAt;
|
||||
return (
|
||||
<span>
|
||||
{createdAt ? moment(createdAt).format("lll") : "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "owner",
|
||||
friendlyName: t("accessRoleOwner"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("accessRoleOwner")}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const owner = row.original.owner;
|
||||
return owner ? (
|
||||
<Button
|
||||
className="tabular-nums"
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Link href={`/admin/users/${owner.userId}`}>
|
||||
{owner.username}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<code>-</code>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "orgId",
|
||||
friendlyName: t("orgId"),
|
||||
header: () => <span className="p-3">{t("orgId")}</span>
|
||||
},
|
||||
{
|
||||
accessorKey: "subnet",
|
||||
friendlyName: t("subnet"),
|
||||
header: () => <span className="p-3">{t("subnet")}</span>,
|
||||
cell: ({ row }) => <span>{row.original.subnet || "-"}</span>
|
||||
},
|
||||
{
|
||||
accessorKey: "utilitySubnet",
|
||||
friendlyName: t("utilitySubnet"),
|
||||
header: () => <span className="p-3">{t("utilitySubnet")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<span>{row.original.utilitySubnet || "-"}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "userCount",
|
||||
friendlyName: t("users"),
|
||||
header: () => <span className="p-3">{t("users")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">
|
||||
{row.original.userCount}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "siteCount",
|
||||
friendlyName: t("sites"),
|
||||
header: () => <span className="p-3">{t("sites")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">
|
||||
{row.original.siteCount}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "resourceCount",
|
||||
friendlyName: t("resources"),
|
||||
header: () => <span className="p-3">{t("resources")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">
|
||||
{row.original.resourceCount}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3"></span>,
|
||||
cell: ({ row }) => {
|
||||
const orgRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedOrg(orgRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
variant="outline"
|
||||
className="text-red-400 focus:text-destructive "
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
}, [t, searchParams]);
|
||||
|
||||
const handlePaginationChange = (newPage: PaginationState) => {
|
||||
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||
searchParams.set("query", query);
|
||||
searchParams.delete("page");
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
}, 300);
|
||||
|
||||
async function deleteOrg(orgId: string) {
|
||||
try {
|
||||
const res = await api.delete(`/admin/org/${orgId}`);
|
||||
toast({
|
||||
title: t("orgDeleted"),
|
||||
description: t("orgDeletedMessage")
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("orgErrorDelete"),
|
||||
description: formatAxiosError(err, t("orgErrorDeleteMessage"))
|
||||
});
|
||||
} finally {
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedOrg && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedOrg(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("orgQuestionRemove")}</p>
|
||||
<p>{t("orgMessageRemove")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("orgDeleteConfirm")}
|
||||
onConfirm={async () => {
|
||||
startTransition(() => deleteOrg(selectedOrg.orgId));
|
||||
}}
|
||||
string={selectedOrg.name}
|
||||
title={t("orgDelete")}
|
||||
/>
|
||||
)}
|
||||
<ControlledDataTable
|
||||
columns={columns}
|
||||
rows={orgs}
|
||||
tableId="admin-orgs-table"
|
||||
searchPlaceholder={t("orgSearch")}
|
||||
pagination={pagination}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
searchQuery={searchParams.get("query")?.toString()}
|
||||
onSearch={handleSearchChange}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing || isFiltering}
|
||||
rowCount={rowCount}
|
||||
columnVisibility={{
|
||||
subnet: false,
|
||||
utilitySubnet: false,
|
||||
orgId: false
|
||||
}}
|
||||
enableColumnVisibility
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -21,12 +21,14 @@ import { Switch } from "@app/components/ui/switch";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
||||
import { build } from "@server/build";
|
||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||