Compare commits

..

6 Commits

Author SHA1 Message Date
Fred KISSIE 9853122a51 delete org 2026-09-02 00:25:49 +02:00
Fred KISSIE c411a1a5b9 🚧 wip 2026-08-28 22:49:51 +02:00
Fred KISSIE a4d9365563 ♻️ Only show the username instead of the name+username 2026-08-28 22:09:01 +02:00
Fred KISSIE e9f7678b90 List of orgs, with all columns 2026-08-28 22:00:06 +02:00
Fred KISSIE a904c915d8 List orgs in server 2026-08-28 21:42:20 +02:00
Fred KISSIE cb84c2954b 🚧 WIP 2026-08-27 00:03:56 +02:00
129 changed files with 6159 additions and 6187 deletions
-8
View File
@@ -34,14 +34,6 @@ body:
validations: validations:
required: true 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 - type: textarea
attributes: attributes:
label: Expected Behavior label: Expected Behavior
+4 -4
View File
@@ -77,7 +77,7 @@ jobs:
fi fi
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with: with:
registry: docker.io registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -149,7 +149,7 @@ jobs:
fi fi
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with: with:
registry: docker.io registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -204,7 +204,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with: with:
registry: docker.io registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -407,7 +407,7 @@ jobs:
shell: bash shell: bash
- name: Login to GitHub Container Registry (for cosign) - name: Login to GitHub Container Registry (for cosign)
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
with: with:
days-before-stale: 14 days-before-stale: 14
days-before-close: 14 days-before-close: 14
+5 -29
View File
@@ -37,22 +37,11 @@
<p align="center"> <p align="center">
<strong> <strong>
Get started with Pangolin Cloud at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a> Get started with Pangolin at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
</strong> </strong>
</p> </p>
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. 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.
### 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 ## Installation
@@ -64,9 +53,9 @@ Legacy SASE platforms got the idea right: connectivity and security belong toget
## Deployment Options ## Deployment Options
- **Pangolin Cloud** - Fully managed service with no infrastructure required. - **Pangolin Cloud** - Fully managed service - no infrastructure required.
- **Self-Host: Community Edition** - Free, open-source, and licensed under AGPL-3. - **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. - **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.
## Key Features ## Key Features
@@ -110,19 +99,6 @@ Access private resources like SSH servers, databases, RDP, and entire network ra
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" /> <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 ### 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. 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.
+1 -1
View File
@@ -5,7 +5,7 @@ import { encrypt } from "@server/lib/crypto";
import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { generateCA } from "@server/lib/sshCA"; import { generateCA } from "@server/lib/sshCA";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
type GenerateOrgCaKeysArgs = { type GenerateOrgCaKeysArgs = {
orgId: string; orgId: string;
+1 -1
View File
@@ -4,7 +4,7 @@ import { encrypt, decrypt } from "@server/lib/crypto";
import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
type RotateServerSecretArgs = { type RotateServerSecretArgs = {
"old-secret": string; "old-secret": string;
+1 -1
View File
@@ -16,7 +16,7 @@ experimental:
version: "{{.BadgerVersion}}" version: "{{.BadgerVersion}}"
crowdsec: # CrowdSec plugin configuration added crowdsec: # CrowdSec plugin configuration added
moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
version: "v1.7.1" version: "v1.4.4"
log: log:
level: "INFO" level: "INFO"
+1 -9
View File
@@ -1176,10 +1176,6 @@
"idpJmespathAboutDescriptionLink": "Научете повече за JMESPath", "idpJmespathAboutDescriptionLink": "Научете повече за JMESPath",
"idpJmespathLabel": "Идентификатор на пътя", "idpJmespathLabel": "Идентификатор на пътя",
"idpJmespathLabelDescription": "Пътят към идентификатора на потребителя в ID токена", "idpJmespathLabelDescription": "Пътят към идентификатора на потребителя в ID токена",
"idpIdentifierChangeTitle": "Предупреждение за промяна на пътя на идентификатора",
"idpIdentifierChangeDescription": "Ще промените пътя на идентификатора. Това ще повлияе на начина, по който съществуващите потребители са разпределени. Потребители, които преди са влизали чрез този доставчик на идентичности, може вече да не бъдат разпознавани като същите потребители.",
"idpIdentifierChangeConfirmMessage": "Потвърждавам",
"idpIdentifierChangeWarningText": "Това ще повлияе на начина, по който съществуващите потребители са разпределени",
"idpJmespathEmailPathOptional": "Път за имейл (по избор)", "idpJmespathEmailPathOptional": "Път за имейл (по избор)",
"idpJmespathEmailPathOptionalDescription": "Пътят до имейла на потребителя в ID токена", "idpJmespathEmailPathOptionalDescription": "Пътят до имейла на потребителя в ID токена",
"idpJmespathNamePathOptional": "Път (по избор) на име", "idpJmespathNamePathOptional": "Път (по избор) на име",
@@ -1577,8 +1573,6 @@
"search": "Търси…", "search": "Търси…",
"searchPlaceholder": "Търсене...", "searchPlaceholder": "Търсене...",
"emptySearchOptions": "Няма намерени опции", "emptySearchOptions": "Няма намерени опции",
"ipFilterSearchPlaceholder": "Въведете IP адрес…",
"ipFilterEmptyMessage": "Въведете IP адрес, за да филтрирате по него",
"create": "Създаване", "create": "Създаване",
"orgs": "Организации", "orgs": "Организации",
"loginError": "Възникна неочаквана грешка. Моля, опитайте отново.", "loginError": "Възникна неочаквана грешка. Моля, опитайте отново.",
@@ -2602,7 +2596,6 @@
"createDomainType": "Тип:", "createDomainType": "Тип:",
"createDomainName": "Име:", "createDomainName": "Име:",
"createDomainValue": "Стойност:", "createDomainValue": "Стойност:",
"multiSelectFilterCount": "{count} избрани",
"createDomainCnameRecords": "CNAME записи", "createDomainCnameRecords": "CNAME записи",
"createDomainARecords": "A записи", "createDomainARecords": "A записи",
"createDomainRecordNumber": "Запис {number}", "createDomainRecordNumber": "Запис {number}",
@@ -2717,7 +2710,6 @@
"healthScheme": "Метод", "healthScheme": "Метод",
"healthSelectScheme": "Избор на метод", "healthSelectScheme": "Избор на метод",
"healthCheckPortInvalid": "Портът трябва да бъде между 1 и 65535", "healthCheckPortInvalid": "Портът трябва да бъде между 1 и 65535",
"healthCheckHostnameInvalid": "Името на хоста не трябва да съдържа празни символи",
"healthCheckPath": "Път", "healthCheckPath": "Път",
"healthHostname": "IP / Хост", "healthHostname": "IP / Хост",
"healthPort": "Порт", "healthPort": "Порт",
@@ -3001,7 +2993,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Добавете CIDR диапазон (напр. 10.0.0.0/8)", "remoteExitNodeNetworkingSubnetsPlaceholder": "Добавете CIDR диапазон (напр. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Неуспешно зареждане на подмрежи", "remoteExitNodeNetworkingSubnetsLoadError": "Неуспешно зареждане на подмрежи",
"remoteExitNodeNetworkingLabelsTitle": "Етикети за Предпочитания", "remoteExitNodeNetworkingLabelsTitle": "Етикети за Предпочитания",
"remoteExitNodeNetworkingLabelsDescription": "Сайтовете с тези етикети ще предпочетат да се свържат чрез този отдалечен изходен възел.", "remoteExitNodeNetworkingLabelsDescription": "Сайтове с тези етикети ще бъдат принудени да се свържат чрез този отдалечен край.",
"remoteExitNodeNetworkingLabelsButtonText": "Изберете етикети...", "remoteExitNodeNetworkingLabelsButtonText": "Изберете етикети...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Търсене на етикети...", "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Търсене на етикети...",
"remoteExitNodeNetworkingLabelsLoadError": "Неуспешно зареждане на етикети", "remoteExitNodeNetworkingLabelsLoadError": "Неуспешно зареждане на етикети",
+1 -9
View File
@@ -1176,10 +1176,6 @@
"idpJmespathAboutDescriptionLink": "Další informace o cestě JMESPath", "idpJmespathAboutDescriptionLink": "Další informace o cestě JMESPath",
"idpJmespathLabel": "Cesta identifikátoru", "idpJmespathLabel": "Cesta identifikátoru",
"idpJmespathLabelDescription": "Cesta k identifikátoru uživatele v tokenu ID", "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é)", "idpJmespathEmailPathOptional": "Cesta e-mailu (volitelné)",
"idpJmespathEmailPathOptionalDescription": "Cesta k e-mailu uživatele v ID tokenu", "idpJmespathEmailPathOptionalDescription": "Cesta k e-mailu uživatele v ID tokenu",
"idpJmespathNamePathOptional": "Cesta k názvu (volitelné)", "idpJmespathNamePathOptional": "Cesta k názvu (volitelné)",
@@ -1577,8 +1573,6 @@
"search": "Vyhledávání…", "search": "Vyhledávání…",
"searchPlaceholder": "Hledat...", "searchPlaceholder": "Hledat...",
"emptySearchOptions": "Nebyly nalezeny žádné možnosti", "emptySearchOptions": "Nebyly nalezeny žádné možnosti",
"ipFilterSearchPlaceholder": "Zadejte IP adresu…",
"ipFilterEmptyMessage": "Zadejte IP adresu pro filtrování",
"create": "Vytvořit", "create": "Vytvořit",
"orgs": "Organizace", "orgs": "Organizace",
"loginError": "Došlo k neočekávané chybě. Zkuste to prosím znovu.", "loginError": "Došlo k neočekávané chybě. Zkuste to prosím znovu.",
@@ -2602,7 +2596,6 @@
"createDomainType": "Typ:", "createDomainType": "Typ:",
"createDomainName": "Jméno:", "createDomainName": "Jméno:",
"createDomainValue": "Hodnota:", "createDomainValue": "Hodnota:",
"multiSelectFilterCount": "{count} vybráno",
"createDomainCnameRecords": "Záznamy CNAME", "createDomainCnameRecords": "Záznamy CNAME",
"createDomainARecords": "Záznamy", "createDomainARecords": "Záznamy",
"createDomainRecordNumber": "Nahrát {number}", "createDomainRecordNumber": "Nahrát {number}",
@@ -2717,7 +2710,6 @@
"healthScheme": "Způsob", "healthScheme": "Způsob",
"healthSelectScheme": "Vybrat metodu", "healthSelectScheme": "Vybrat metodu",
"healthCheckPortInvalid": "Port musí být mezi 1 a 65535", "healthCheckPortInvalid": "Port musí být mezi 1 a 65535",
"healthCheckHostnameInvalid": "Název hostitele nesmí obsahovat mezery",
"healthCheckPath": "Cesta", "healthCheckPath": "Cesta",
"healthHostname": "IP / Hostitel", "healthHostname": "IP / Hostitel",
"healthPort": "Přístav", "healthPort": "Přístav",
@@ -3001,7 +2993,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Přidejte rozsah CIDR (např. 10.0.0.0/8)", "remoteExitNodeNetworkingSubnetsPlaceholder": "Přidejte rozsah CIDR (např. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Nepodařilo se načíst podsítě", "remoteExitNodeNetworkingSubnetsLoadError": "Nepodařilo se načíst podsítě",
"remoteExitNodeNetworkingLabelsTitle": "Názvy preferencí", "remoteExitNodeNetworkingLabelsTitle": "Názvy preferencí",
"remoteExitNodeNetworkingLabelsDescription": "Stránky s těmito štítky preferují spojeníes tento vzdálený výstupní uzel.", "remoteExitNodeNetworkingLabelsDescription": "Weby s těmito názvy budou nucenyipojit se tímto vzdáleným výstupním uzlem.",
"remoteExitNodeNetworkingLabelsButtonText": "Vyberte názvy...", "remoteExitNodeNetworkingLabelsButtonText": "Vyberte názvy...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Hledat názvy...", "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Hledat názvy...",
"remoteExitNodeNetworkingLabelsLoadError": "Nepodařilo se načíst názvy", "remoteExitNodeNetworkingLabelsLoadError": "Nepodařilo se načíst názvy",
+1 -9
View File
@@ -1176,10 +1176,6 @@
"idpJmespathAboutDescriptionLink": "Læs mere om JMESPath", "idpJmespathAboutDescriptionLink": "Læs mere om JMESPath",
"idpJmespathLabel": "Identifikatorsti", "idpJmespathLabel": "Identifikatorsti",
"idpJmespathLabelDescription": "Stien til brugeridentifikatoren i ID-tokenet", "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)", "idpJmespathEmailPathOptional": "E-mailsti (Valgfrit)",
"idpJmespathEmailPathOptionalDescription": "Stien til brugerens e-mailadresse i ID-tokenet", "idpJmespathEmailPathOptionalDescription": "Stien til brugerens e-mailadresse i ID-tokenet",
"idpJmespathNamePathOptional": "Navn Sti (Valgfrit)", "idpJmespathNamePathOptional": "Navn Sti (Valgfrit)",
@@ -1577,8 +1573,6 @@
"search": "Søg…", "search": "Søg…",
"searchPlaceholder": "Søg...", "searchPlaceholder": "Søg...",
"emptySearchOptions": "Ingen valg fundet", "emptySearchOptions": "Ingen valg fundet",
"ipFilterSearchPlaceholder": "Indtast en IP-adresse…",
"ipFilterEmptyMessage": "Indtast en IP-adresse for at filtrere efter",
"create": "Opret", "create": "Opret",
"orgs": "Organisationer", "orgs": "Organisationer",
"loginError": "Der opstod en uventet fejl. Prøv venligst igen.", "loginError": "Der opstod en uventet fejl. Prøv venligst igen.",
@@ -2602,7 +2596,6 @@
"createDomainType": "Type:", "createDomainType": "Type:",
"createDomainName": "Navn:", "createDomainName": "Navn:",
"createDomainValue": "Værdi:", "createDomainValue": "Værdi:",
"multiSelectFilterCount": "{count} valgt",
"createDomainCnameRecords": "CNAME-poster", "createDomainCnameRecords": "CNAME-poster",
"createDomainARecords": "A-poster", "createDomainARecords": "A-poster",
"createDomainRecordNumber": "Post {number}", "createDomainRecordNumber": "Post {number}",
@@ -2717,7 +2710,6 @@
"healthScheme": "Metode", "healthScheme": "Metode",
"healthSelectScheme": "Vælg metode", "healthSelectScheme": "Vælg metode",
"healthCheckPortInvalid": "Porten skal være mellem 1 og 65535", "healthCheckPortInvalid": "Porten skal være mellem 1 og 65535",
"healthCheckHostnameInvalid": "Værtsnavnet må ikke indeholde mellemrum",
"healthCheckPath": "Sti", "healthCheckPath": "Sti",
"healthHostname": "IP / Vært", "healthHostname": "IP / Vært",
"healthPort": "Port", "healthPort": "Port",
@@ -3001,7 +2993,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Tilføj et CIDR-område (f.eks. 10.0.0.0/8)", "remoteExitNodeNetworkingSubnetsPlaceholder": "Tilføj et CIDR-område (f.eks. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Kunne ikke indlæse subnets", "remoteExitNodeNetworkingSubnetsLoadError": "Kunne ikke indlæse subnets",
"remoteExitNodeNetworkingLabelsTitle": "Præference Etiketter", "remoteExitNodeNetworkingLabelsTitle": "Præference Etiketter",
"remoteExitNodeNetworkingLabelsDescription": "Sider med disse etiketter vil foretrække at forbinde gennem denne fjernudgarnknude.", "remoteExitNodeNetworkingLabelsDescription": "Sites med disse etiketter vil blive tvunget til at oprette forbindelse gennem denne fjerne exit-node.",
"remoteExitNodeNetworkingLabelsButtonText": "Vælg etiketter...", "remoteExitNodeNetworkingLabelsButtonText": "Vælg etiketter...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Søg efter etiketter...", "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Søg efter etiketter...",
"remoteExitNodeNetworkingLabelsLoadError": "Kunne ikke indlæse etiketter", "remoteExitNodeNetworkingLabelsLoadError": "Kunne ikke indlæse etiketter",
+1 -9
View File
@@ -1176,10 +1176,6 @@
"idpJmespathAboutDescriptionLink": "Mehr über JMESPath erfahren", "idpJmespathAboutDescriptionLink": "Mehr über JMESPath erfahren",
"idpJmespathLabel": "Identifikationspfad", "idpJmespathLabel": "Identifikationspfad",
"idpJmespathLabelDescription": "Der JMESPath zum Benutzeridentifikator im ID-Token", "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)", "idpJmespathEmailPathOptional": "E-Mail-Pfad (Optional)",
"idpJmespathEmailPathOptionalDescription": "Der JMESPath zur E-Mail-Adresse des Benutzers im ID-Token", "idpJmespathEmailPathOptionalDescription": "Der JMESPath zur E-Mail-Adresse des Benutzers im ID-Token",
"idpJmespathNamePathOptional": "Namenspfad (Optional)", "idpJmespathNamePathOptional": "Namenspfad (Optional)",
@@ -1577,8 +1573,6 @@
"search": "Suche…", "search": "Suche…",
"searchPlaceholder": "Suche...", "searchPlaceholder": "Suche...",
"emptySearchOptions": "Keine Optionen gefunden", "emptySearchOptions": "Keine Optionen gefunden",
"ipFilterSearchPlaceholder": "Geben Sie eine IP-Adresse ein…",
"ipFilterEmptyMessage": "Geben Sie eine IP-Adresse zur Filterung ein",
"create": "Erstellen", "create": "Erstellen",
"orgs": "Organisationen", "orgs": "Organisationen",
"loginError": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut.", "loginError": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut.",
@@ -2602,7 +2596,6 @@
"createDomainType": "Typ:", "createDomainType": "Typ:",
"createDomainName": "Name:", "createDomainName": "Name:",
"createDomainValue": "Wert:", "createDomainValue": "Wert:",
"multiSelectFilterCount": "{count} ausgewählt",
"createDomainCnameRecords": "CNAME-Einträge", "createDomainCnameRecords": "CNAME-Einträge",
"createDomainARecords": "A-Aufzeichnungen", "createDomainARecords": "A-Aufzeichnungen",
"createDomainRecordNumber": "Eintrag {number}", "createDomainRecordNumber": "Eintrag {number}",
@@ -2717,7 +2710,6 @@
"healthScheme": "Methode", "healthScheme": "Methode",
"healthSelectScheme": "Methode auswählen", "healthSelectScheme": "Methode auswählen",
"healthCheckPortInvalid": "Der Port muss zwischen 1 und 65535 liegen", "healthCheckPortInvalid": "Der Port muss zwischen 1 und 65535 liegen",
"healthCheckHostnameInvalid": "Der Hostname darf keinen Leerraum enthalten",
"healthCheckPath": "Pfad", "healthCheckPath": "Pfad",
"healthHostname": "IP / Host", "healthHostname": "IP / Host",
"healthPort": "Port", "healthPort": "Port",
@@ -3001,7 +2993,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Fügen Sie einen CIDR-Bereich hinzu (z.B. 10.0.0.0/8)", "remoteExitNodeNetworkingSubnetsPlaceholder": "Fügen Sie einen CIDR-Bereich hinzu (z.B. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Fehler beim Laden der Subnetze", "remoteExitNodeNetworkingSubnetsLoadError": "Fehler beim Laden der Subnetze",
"remoteExitNodeNetworkingLabelsTitle": "Präferenzetiketten", "remoteExitNodeNetworkingLabelsTitle": "Präferenzetiketten",
"remoteExitNodeNetworkingLabelsDescription": "Standorte mit diesen Labels bevorzugen die Verbindung über diesen Remote-Exit-Knoten.", "remoteExitNodeNetworkingLabelsDescription": "Standorte mit diesen Etiketten werden gezwungen, über diesen Remote Exit Node zu verbinden.",
"remoteExitNodeNetworkingLabelsButtonText": "Etiketten auswählen...", "remoteExitNodeNetworkingLabelsButtonText": "Etiketten auswählen...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Etiketten suchen...", "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Etiketten suchen...",
"remoteExitNodeNetworkingLabelsLoadError": "Fehler beim Laden der Etiketten", "remoteExitNodeNetworkingLabelsLoadError": "Fehler beim Laden der Etiketten",
+4 -9
View File
@@ -465,6 +465,8 @@
"apiKeysDelete": "Delete API Key", "apiKeysDelete": "Delete API Key",
"apiKeysManage": "Manage API Keys", "apiKeysManage": "Manage API Keys",
"apiKeysDescription": "API keys are used to authenticate with the integration API", "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", "provisioningKeysTitle": "Provisioning Key",
"provisioningKeysManage": "Manage Provisioning Keys", "provisioningKeysManage": "Manage Provisioning Keys",
"provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.", "provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.",
@@ -1176,10 +1178,6 @@
"idpJmespathAboutDescriptionLink": "Learn more about JMESPath", "idpJmespathAboutDescriptionLink": "Learn more about JMESPath",
"idpJmespathLabel": "Identifier Path", "idpJmespathLabel": "Identifier Path",
"idpJmespathLabelDescription": "The path to the user identifier in the ID token", "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)", "idpJmespathEmailPathOptional": "Email Path (Optional)",
"idpJmespathEmailPathOptionalDescription": "The path to the user's email in the ID token", "idpJmespathEmailPathOptionalDescription": "The path to the user's email in the ID token",
"idpJmespathNamePathOptional": "Name Path (Optional)", "idpJmespathNamePathOptional": "Name Path (Optional)",
@@ -1577,8 +1575,6 @@
"search": "Search…", "search": "Search…",
"searchPlaceholder": "Search...", "searchPlaceholder": "Search...",
"emptySearchOptions": "No options found", "emptySearchOptions": "No options found",
"ipFilterSearchPlaceholder": "Enter an IP address…",
"ipFilterEmptyMessage": "Enter an IP address to filter by",
"create": "Create", "create": "Create",
"orgs": "Organizations", "orgs": "Organizations",
"loginError": "An unexpected error occurred. Please try again.", "loginError": "An unexpected error occurred. Please try again.",
@@ -2095,6 +2091,7 @@
"resourceBudgetSettings": "Budget", "resourceBudgetSettings": "Budget",
"resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits", "resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits",
"sidebarApiKeys": "API Keys", "sidebarApiKeys": "API Keys",
"sidebarOrgs": "Organizations",
"sidebarProvisioning": "Provisioning", "sidebarProvisioning": "Provisioning",
"sidebarSettings": "Settings", "sidebarSettings": "Settings",
"sidebarAllUsers": "All Users", "sidebarAllUsers": "All Users",
@@ -2602,7 +2599,6 @@
"createDomainType": "Type:", "createDomainType": "Type:",
"createDomainName": "Name:", "createDomainName": "Name:",
"createDomainValue": "Value:", "createDomainValue": "Value:",
"multiSelectFilterCount": "{count} selected",
"createDomainCnameRecords": "CNAME Records", "createDomainCnameRecords": "CNAME Records",
"createDomainARecords": "A Records", "createDomainARecords": "A Records",
"createDomainRecordNumber": "Record {number}", "createDomainRecordNumber": "Record {number}",
@@ -2717,7 +2713,6 @@
"healthScheme": "Method", "healthScheme": "Method",
"healthSelectScheme": "Select Method", "healthSelectScheme": "Select Method",
"healthCheckPortInvalid": "Port must be between 1 and 65535", "healthCheckPortInvalid": "Port must be between 1 and 65535",
"healthCheckHostnameInvalid": "Hostname must not contain whitespace",
"healthCheckPath": "Path", "healthCheckPath": "Path",
"healthHostname": "IP / Host", "healthHostname": "IP / Host",
"healthPort": "Port", "healthPort": "Port",
@@ -3001,7 +2996,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Add a CIDR range (e.g. 10.0.0.0/8)", "remoteExitNodeNetworkingSubnetsPlaceholder": "Add a CIDR range (e.g. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Failed to load subnets", "remoteExitNodeNetworkingSubnetsLoadError": "Failed to load subnets",
"remoteExitNodeNetworkingLabelsTitle": "Preference Labels", "remoteExitNodeNetworkingLabelsTitle": "Preference Labels",
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.", "remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will be enforced to connect through this remote exit node.",
"remoteExitNodeNetworkingLabelsButtonText": "Select labels...", "remoteExitNodeNetworkingLabelsButtonText": "Select labels...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Search labels...", "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Search labels...",
"remoteExitNodeNetworkingLabelsLoadError": "Failed to load labels", "remoteExitNodeNetworkingLabelsLoadError": "Failed to load labels",
+1 -9
View File
@@ -1176,10 +1176,6 @@
"idpJmespathAboutDescriptionLink": "Más información sobre JMESPath", "idpJmespathAboutDescriptionLink": "Más información sobre JMESPath",
"idpJmespathLabel": "Ruta del identificador", "idpJmespathLabel": "Ruta del identificador",
"idpJmespathLabelDescription": "La ruta al identificador de usuario en el token de ID", "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)", "idpJmespathEmailPathOptional": "Ruta de correo (opcional)",
"idpJmespathEmailPathOptionalDescription": "La ruta al correo electrónico del usuario en el token de ID", "idpJmespathEmailPathOptionalDescription": "La ruta al correo electrónico del usuario en el token de ID",
"idpJmespathNamePathOptional": "Ruta del nombre (opcional)", "idpJmespathNamePathOptional": "Ruta del nombre (opcional)",
@@ -1577,8 +1573,6 @@
"search": "Buscar…", "search": "Buscar…",
"searchPlaceholder": "Buscar...", "searchPlaceholder": "Buscar...",
"emptySearchOptions": "No se encontraron opciones", "emptySearchOptions": "No se encontraron opciones",
"ipFilterSearchPlaceholder": "Introduzca una dirección IP…",
"ipFilterEmptyMessage": "Introduzca una dirección IP para filtrar por",
"create": "Crear", "create": "Crear",
"orgs": "Organizaciones", "orgs": "Organizaciones",
"loginError": "Ocurrió un error inesperado. Por favor, inténtelo de nuevo.", "loginError": "Ocurrió un error inesperado. Por favor, inténtelo de nuevo.",
@@ -2602,7 +2596,6 @@
"createDomainType": "Tipo:", "createDomainType": "Tipo:",
"createDomainName": "Nombre:", "createDomainName": "Nombre:",
"createDomainValue": "Valor:", "createDomainValue": "Valor:",
"multiSelectFilterCount": "{count} seleccionado",
"createDomainCnameRecords": "Registros CNAME", "createDomainCnameRecords": "Registros CNAME",
"createDomainARecords": "Registros A", "createDomainARecords": "Registros A",
"createDomainRecordNumber": "Registro {number}", "createDomainRecordNumber": "Registro {number}",
@@ -2717,7 +2710,6 @@
"healthScheme": "Método", "healthScheme": "Método",
"healthSelectScheme": "Seleccionar método", "healthSelectScheme": "Seleccionar método",
"healthCheckPortInvalid": "El puerto debe estar entre 1 y 65535", "healthCheckPortInvalid": "El puerto debe estar entre 1 y 65535",
"healthCheckHostnameInvalid": "El nombre de host no debe contener espacios en blanco",
"healthCheckPath": "Ruta", "healthCheckPath": "Ruta",
"healthHostname": "IP / Nombre del host", "healthHostname": "IP / Nombre del host",
"healthPort": "Puerto", "healthPort": "Puerto",
@@ -3001,7 +2993,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Añadir un rango CIDR (e.g. 10.0.0.0/8)", "remoteExitNodeNetworkingSubnetsPlaceholder": "Añadir un rango CIDR (e.g. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Error al cargar las subredes", "remoteExitNodeNetworkingSubnetsLoadError": "Error al cargar las subredes",
"remoteExitNodeNetworkingLabelsTitle": "Etiquetas de Preferencias", "remoteExitNodeNetworkingLabelsTitle": "Etiquetas de Preferencias",
"remoteExitNodeNetworkingLabelsDescription": "Los sitios con estas etiquetas preferirán conectarse a través de este nodo de salida remoto.", "remoteExitNodeNetworkingLabelsDescription": "Los sitios con estas etiquetas se verán obligados a conectarse a través de este nodo de salida remoto.",
"remoteExitNodeNetworkingLabelsButtonText": "Seleccionar etiquetas...", "remoteExitNodeNetworkingLabelsButtonText": "Seleccionar etiquetas...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Buscar etiquetas...", "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Buscar etiquetas...",
"remoteExitNodeNetworkingLabelsLoadError": "Error al cargar las etiquetas", "remoteExitNodeNetworkingLabelsLoadError": "Error al cargar las etiquetas",
+1 -9
View File
@@ -1176,10 +1176,6 @@
"idpJmespathAboutDescriptionLink": "En savoir plus sur JMESPath", "idpJmespathAboutDescriptionLink": "En savoir plus sur JMESPath",
"idpJmespathLabel": "Chemin d'identification", "idpJmespathLabel": "Chemin d'identification",
"idpJmespathLabelDescription": "Le JMESPath vers l'identifiant de l'utilisateur dans le jeton ID", "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)", "idpJmespathEmailPathOptional": "Chemin de l'email (Optionnel)",
"idpJmespathEmailPathOptionalDescription": "Le JMESPath vers l'email de l'utilisateur dans le jeton ID", "idpJmespathEmailPathOptionalDescription": "Le JMESPath vers l'email de l'utilisateur dans le jeton ID",
"idpJmespathNamePathOptional": "Chemin du nom (Optionnel)", "idpJmespathNamePathOptional": "Chemin du nom (Optionnel)",
@@ -1577,8 +1573,6 @@
"search": "Rechercher…", "search": "Rechercher…",
"searchPlaceholder": "Recherche...", "searchPlaceholder": "Recherche...",
"emptySearchOptions": "Aucune option trouvée", "emptySearchOptions": "Aucune option trouvée",
"ipFilterSearchPlaceholder": "Entrez une adresse IP…",
"ipFilterEmptyMessage": "Entrez une adresse IP pour filtrer",
"create": "Créer", "create": "Créer",
"orgs": "Organisations", "orgs": "Organisations",
"loginError": "Une erreur inattendue s'est produite. Veuillez réessayer.", "loginError": "Une erreur inattendue s'est produite. Veuillez réessayer.",
@@ -2602,7 +2596,6 @@
"createDomainType": "Type :", "createDomainType": "Type :",
"createDomainName": "Nom :", "createDomainName": "Nom :",
"createDomainValue": "Valeur :", "createDomainValue": "Valeur :",
"multiSelectFilterCount": "{count} sélectionné",
"createDomainCnameRecords": "Enregistrements CNAME", "createDomainCnameRecords": "Enregistrements CNAME",
"createDomainARecords": "Enregistrements A", "createDomainARecords": "Enregistrements A",
"createDomainRecordNumber": "Enregistrement {number}", "createDomainRecordNumber": "Enregistrement {number}",
@@ -2717,7 +2710,6 @@
"healthScheme": "Méthode", "healthScheme": "Méthode",
"healthSelectScheme": "Sélectionnez la méthode", "healthSelectScheme": "Sélectionnez la méthode",
"healthCheckPortInvalid": "Le port doit être compris entre 1 et 65535", "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", "healthCheckPath": "Chemin d'accès",
"healthHostname": "IP / Hôte", "healthHostname": "IP / Hôte",
"healthPort": "Port", "healthPort": "Port",
@@ -3001,7 +2993,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Ajouter une plage CIDR (par exemple 10.0.0.0/8)", "remoteExitNodeNetworkingSubnetsPlaceholder": "Ajouter une plage CIDR (par exemple 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Échec du chargement des sous-réseaux", "remoteExitNodeNetworkingSubnetsLoadError": "Échec du chargement des sous-réseaux",
"remoteExitNodeNetworkingLabelsTitle": "Étiquettes de préférences", "remoteExitNodeNetworkingLabelsTitle": "Étiquettes de préférences",
"remoteExitNodeNetworkingLabelsDescription": "Les sites dotés de ces étiquettes préféreront se connecter via ce nœud de sortie distant.", "remoteExitNodeNetworkingLabelsDescription": "Les sites avec ces étiquettes devront se connecter via ce nœud de sortie distant.",
"remoteExitNodeNetworkingLabelsButtonText": "Sélectionner des étiquettes...", "remoteExitNodeNetworkingLabelsButtonText": "Sélectionner des étiquettes...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Chercher des étiquettes...", "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Chercher des étiquettes...",
"remoteExitNodeNetworkingLabelsLoadError": "Échec du chargement des étiquettes", "remoteExitNodeNetworkingLabelsLoadError": "Échec du chargement des étiquettes",
+1 -9
View File
@@ -1176,10 +1176,6 @@
"idpJmespathAboutDescriptionLink": "Scopri di più su JMESPath", "idpJmespathAboutDescriptionLink": "Scopri di più su JMESPath",
"idpJmespathLabel": "Percorso Identificativo", "idpJmespathLabel": "Percorso Identificativo",
"idpJmespathLabelDescription": "Il JMESPath per l'identificatore dell'utente nel token ID", "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)", "idpJmespathEmailPathOptional": "Percorso Email (Opzionale)",
"idpJmespathEmailPathOptionalDescription": "Il JMESPath per l'email dell'utente nel token ID", "idpJmespathEmailPathOptionalDescription": "Il JMESPath per l'email dell'utente nel token ID",
"idpJmespathNamePathOptional": "Percorso Nome (Opzionale)", "idpJmespathNamePathOptional": "Percorso Nome (Opzionale)",
@@ -1577,8 +1573,6 @@
"search": "Cerca…", "search": "Cerca…",
"searchPlaceholder": "Cerca...", "searchPlaceholder": "Cerca...",
"emptySearchOptions": "Nessuna opzione trovata", "emptySearchOptions": "Nessuna opzione trovata",
"ipFilterSearchPlaceholder": "Inserisci un indirizzo IP…",
"ipFilterEmptyMessage": "Inserisci un indirizzo IP per filtrare",
"create": "Crea", "create": "Crea",
"orgs": "Organizzazioni", "orgs": "Organizzazioni",
"loginError": "Si è verificato un errore imprevisto. Riprova.", "loginError": "Si è verificato un errore imprevisto. Riprova.",
@@ -2602,7 +2596,6 @@
"createDomainType": "Tipo:", "createDomainType": "Tipo:",
"createDomainName": "Nome:", "createDomainName": "Nome:",
"createDomainValue": "Valore:", "createDomainValue": "Valore:",
"multiSelectFilterCount": "{count} selezionato",
"createDomainCnameRecords": "Record CNAME", "createDomainCnameRecords": "Record CNAME",
"createDomainARecords": "Record A", "createDomainARecords": "Record A",
"createDomainRecordNumber": "Record {number}", "createDomainRecordNumber": "Record {number}",
@@ -2717,7 +2710,6 @@
"healthScheme": "Metodo", "healthScheme": "Metodo",
"healthSelectScheme": "Seleziona Metodo", "healthSelectScheme": "Seleziona Metodo",
"healthCheckPortInvalid": "La porta deve essere compresa tra 1 e 65535", "healthCheckPortInvalid": "La porta deve essere compresa tra 1 e 65535",
"healthCheckHostnameInvalid": "Il nome dell'host non deve contenere spazi",
"healthCheckPath": "Percorso", "healthCheckPath": "Percorso",
"healthHostname": "IP / Nome host", "healthHostname": "IP / Nome host",
"healthPort": "Porta", "healthPort": "Porta",
@@ -3001,7 +2993,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Aggiungi un intervallo CIDR (ad esempio 10.0.0.0/8)", "remoteExitNodeNetworkingSubnetsPlaceholder": "Aggiungi un intervallo CIDR (ad esempio 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Caricamento sottoreti fallito", "remoteExitNodeNetworkingSubnetsLoadError": "Caricamento sottoreti fallito",
"remoteExitNodeNetworkingLabelsTitle": "Etichette Preferenze", "remoteExitNodeNetworkingLabelsTitle": "Etichette Preferenze",
"remoteExitNodeNetworkingLabelsDescription": "I siti con queste etichette preferiranno connettersi tramite questo nodo di uscita remoto.", "remoteExitNodeNetworkingLabelsDescription": "I siti con queste etichette saranno collegati attraverso questo nodo di uscita remoto.",
"remoteExitNodeNetworkingLabelsButtonText": "Seleziona etichette...", "remoteExitNodeNetworkingLabelsButtonText": "Seleziona etichette...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Cerca etichette...", "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Cerca etichette...",
"remoteExitNodeNetworkingLabelsLoadError": "Caricamento etichette fallito", "remoteExitNodeNetworkingLabelsLoadError": "Caricamento etichette fallito",
+1 -9
View File
@@ -1176,10 +1176,6 @@
"idpJmespathAboutDescriptionLink": "JMESPath에 대해 더 알아보기", "idpJmespathAboutDescriptionLink": "JMESPath에 대해 더 알아보기",
"idpJmespathLabel": "식별자 경로", "idpJmespathLabel": "식별자 경로",
"idpJmespathLabelDescription": "ID 토큰에서 사용자 식별자에 대한 경로", "idpJmespathLabelDescription": "ID 토큰에서 사용자 식별자에 대한 경로",
"idpIdentifierChangeTitle": "식별자 경로 변경 경고",
"idpIdentifierChangeDescription": "식별자 경로를 변경하려고 합니다. 이는 기존 사용자의 매핑 방법에 영향을 미칩니다. 이전에 이 ID 공급자를 통해 로그인한 사용자는 더 이상 동일한 사용자로 인식되지 않을 수 있습니다. ",
"idpIdentifierChangeConfirmMessage": "확인합니다",
"idpIdentifierChangeWarningText": "이는 기존 사용자의 매핑 방법에 영향을 미칩니다",
"idpJmespathEmailPathOptional": "이메일 경로 (선택 사항)", "idpJmespathEmailPathOptional": "이메일 경로 (선택 사항)",
"idpJmespathEmailPathOptionalDescription": "ID 토큰에서 사용자의 이메일 경로", "idpJmespathEmailPathOptionalDescription": "ID 토큰에서 사용자의 이메일 경로",
"idpJmespathNamePathOptional": "이름 경로 (선택 사항)", "idpJmespathNamePathOptional": "이름 경로 (선택 사항)",
@@ -1577,8 +1573,6 @@
"search": "검색…", "search": "검색…",
"searchPlaceholder": "검색...", "searchPlaceholder": "검색...",
"emptySearchOptions": "옵션이 없습니다", "emptySearchOptions": "옵션이 없습니다",
"ipFilterSearchPlaceholder": "IP 주소를 입력하세요…",
"ipFilterEmptyMessage": "필터링할 IP 주소를 입력하세요",
"create": "생성", "create": "생성",
"orgs": "조직", "orgs": "조직",
"loginError": "예기치 않은 오류가 발생했습니다. 다시 시도해주세요.", "loginError": "예기치 않은 오류가 발생했습니다. 다시 시도해주세요.",
@@ -2602,7 +2596,6 @@
"createDomainType": "유형:", "createDomainType": "유형:",
"createDomainName": "이름:", "createDomainName": "이름:",
"createDomainValue": "값:", "createDomainValue": "값:",
"multiSelectFilterCount": "{count} 선택됨",
"createDomainCnameRecords": "CNAME 레코드", "createDomainCnameRecords": "CNAME 레코드",
"createDomainARecords": "A 레코드", "createDomainARecords": "A 레코드",
"createDomainRecordNumber": "레코드 {number}", "createDomainRecordNumber": "레코드 {number}",
@@ -2717,7 +2710,6 @@
"healthScheme": "방법", "healthScheme": "방법",
"healthSelectScheme": "방법 선택", "healthSelectScheme": "방법 선택",
"healthCheckPortInvalid": "포트는 1에서 65535 사이여야 합니다", "healthCheckPortInvalid": "포트는 1에서 65535 사이여야 합니다",
"healthCheckHostnameInvalid": "호스트 이름에는 공백이 포함될 수 없습니다",
"healthCheckPath": "경로", "healthCheckPath": "경로",
"healthHostname": "IP / 호스트", "healthHostname": "IP / 호스트",
"healthPort": "포트", "healthPort": "포트",
@@ -3001,7 +2993,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "CIDR 범위 추가 (예: 10.0.0.0/8)", "remoteExitNodeNetworkingSubnetsPlaceholder": "CIDR 범위 추가 (예: 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "서브넷 로드 실패", "remoteExitNodeNetworkingSubnetsLoadError": "서브넷 로드 실패",
"remoteExitNodeNetworkingLabelsTitle": "우선순위 레이블", "remoteExitNodeNetworkingLabelsTitle": "우선순위 레이블",
"remoteExitNodeNetworkingLabelsDescription": "이 레이블이 있는 사이트는 이 원격 종료 노드를 통해 연결하는 것을 선호합니다.", "remoteExitNodeNetworkingLabelsDescription": "이 레이블이 있는 사이트는 이 원격 출구 노드를 통해 연결니다.",
"remoteExitNodeNetworkingLabelsButtonText": "레이블 선택...", "remoteExitNodeNetworkingLabelsButtonText": "레이블 선택...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "레이블 검색...", "remoteExitNodeNetworkingLabelsSearchPlaceholder": "레이블 검색...",
"remoteExitNodeNetworkingLabelsLoadError": "레이블 로드 실패", "remoteExitNodeNetworkingLabelsLoadError": "레이블 로드 실패",
+1 -9
View File
@@ -1176,10 +1176,6 @@
"idpJmespathAboutDescriptionLink": "Lær mer om JMESPath", "idpJmespathAboutDescriptionLink": "Lær mer om JMESPath",
"idpJmespathLabel": "Identifikatorsti", "idpJmespathLabel": "Identifikatorsti",
"idpJmespathLabelDescription": "Stien til brukeridentifikatoren i ID-tokenet", "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)", "idpJmespathEmailPathOptional": "E-poststi (Valgfritt)",
"idpJmespathEmailPathOptionalDescription": "Stien til brukerens e-postadresse i ID-tokenet", "idpJmespathEmailPathOptionalDescription": "Stien til brukerens e-postadresse i ID-tokenet",
"idpJmespathNamePathOptional": "Navn Sti (Valgfritt)", "idpJmespathNamePathOptional": "Navn Sti (Valgfritt)",
@@ -1577,8 +1573,6 @@
"search": "Søk…", "search": "Søk…",
"searchPlaceholder": "Søk...", "searchPlaceholder": "Søk...",
"emptySearchOptions": "Ingen valg funnet", "emptySearchOptions": "Ingen valg funnet",
"ipFilterSearchPlaceholder": "Angi en IP-adresse…",
"ipFilterEmptyMessage": "Angi en IP-adresse å filtrere etter",
"create": "Opprett", "create": "Opprett",
"orgs": "Organisasjoner", "orgs": "Organisasjoner",
"loginError": "En uventet feil oppstod. Vennligst prøv igjen.", "loginError": "En uventet feil oppstod. Vennligst prøv igjen.",
@@ -2602,7 +2596,6 @@
"createDomainType": "Type:", "createDomainType": "Type:",
"createDomainName": "Navn:", "createDomainName": "Navn:",
"createDomainValue": "Verdi:", "createDomainValue": "Verdi:",
"multiSelectFilterCount": "{count} valgt",
"createDomainCnameRecords": "CNAME-oppføringer", "createDomainCnameRecords": "CNAME-oppføringer",
"createDomainARecords": "A-oppføringer", "createDomainARecords": "A-oppføringer",
"createDomainRecordNumber": "Oppføring {number}", "createDomainRecordNumber": "Oppføring {number}",
@@ -2717,7 +2710,6 @@
"healthScheme": "Metode", "healthScheme": "Metode",
"healthSelectScheme": "Velg metode", "healthSelectScheme": "Velg metode",
"healthCheckPortInvalid": "Porten må være mellom 1 og 65535", "healthCheckPortInvalid": "Porten må være mellom 1 og 65535",
"healthCheckHostnameInvalid": "Vertsnavnet må ikke inneholde mellomrom",
"healthCheckPath": "Sti", "healthCheckPath": "Sti",
"healthHostname": "IP / Vert", "healthHostname": "IP / Vert",
"healthPort": "Port", "healthPort": "Port",
@@ -3001,7 +2993,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Legg til et CIDR-område (f.eks. 10.0.0.0/8)", "remoteExitNodeNetworkingSubnetsPlaceholder": "Legg til et CIDR-område (f.eks. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Feil ved lasting av subnett", "remoteExitNodeNetworkingSubnetsLoadError": "Feil ved lasting av subnett",
"remoteExitNodeNetworkingLabelsTitle": "Preferanseetiketter", "remoteExitNodeNetworkingLabelsTitle": "Preferanseetiketter",
"remoteExitNodeNetworkingLabelsDescription": "Nettsteder med disse etikettene vil foretrekke å koble til gjennom denne eksterne utgangsnoden.", "remoteExitNodeNetworkingLabelsDescription": "Områder med disse etikettene vil bli tvunget til å koble gjennom denne fjerne utgangsnoden.",
"remoteExitNodeNetworkingLabelsButtonText": "Velg etiketter...", "remoteExitNodeNetworkingLabelsButtonText": "Velg etiketter...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Søk etiketter...", "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Søk etiketter...",
"remoteExitNodeNetworkingLabelsLoadError": "Feil ved lasting av etiketter", "remoteExitNodeNetworkingLabelsLoadError": "Feil ved lasting av etiketter",
+1 -9
View File
@@ -1176,10 +1176,6 @@
"idpJmespathAboutDescriptionLink": "Meer informatie over JMESPath", "idpJmespathAboutDescriptionLink": "Meer informatie over JMESPath",
"idpJmespathLabel": "ID pad", "idpJmespathLabel": "ID pad",
"idpJmespathLabelDescription": "Het pad naar het gebruiker-id in het ID-token", "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)", "idpJmespathEmailPathOptional": "E-mail pad (optioneel)",
"idpJmespathEmailPathOptionalDescription": "Het pad naar het e-mailadres van de gebruiker in het ID-token", "idpJmespathEmailPathOptionalDescription": "Het pad naar het e-mailadres van de gebruiker in het ID-token",
"idpJmespathNamePathOptional": "Naam pad (optioneel)", "idpJmespathNamePathOptional": "Naam pad (optioneel)",
@@ -1577,8 +1573,6 @@
"search": "Zoeken…", "search": "Zoeken…",
"searchPlaceholder": "Zoeken...", "searchPlaceholder": "Zoeken...",
"emptySearchOptions": "Geen opties gevonden", "emptySearchOptions": "Geen opties gevonden",
"ipFilterSearchPlaceholder": "Voer een IP-adres in…",
"ipFilterEmptyMessage": "Voer een IP-adres in om op te filteren",
"create": "Aanmaken", "create": "Aanmaken",
"orgs": "Organisaties", "orgs": "Organisaties",
"loginError": "Er is een onverwachte fout opgetreden. Probeer het opnieuw.", "loginError": "Er is een onverwachte fout opgetreden. Probeer het opnieuw.",
@@ -2602,7 +2596,6 @@
"createDomainType": "Type:", "createDomainType": "Type:",
"createDomainName": "Naam:", "createDomainName": "Naam:",
"createDomainValue": "Waarde:", "createDomainValue": "Waarde:",
"multiSelectFilterCount": "{count} geselecteerd",
"createDomainCnameRecords": "CNAME-records", "createDomainCnameRecords": "CNAME-records",
"createDomainARecords": "A Records", "createDomainARecords": "A Records",
"createDomainRecordNumber": "Record {number}", "createDomainRecordNumber": "Record {number}",
@@ -2717,7 +2710,6 @@
"healthScheme": "Methode", "healthScheme": "Methode",
"healthSelectScheme": "Selecteer methode", "healthSelectScheme": "Selecteer methode",
"healthCheckPortInvalid": "Poort moet tussen 1 en 65535 zijn", "healthCheckPortInvalid": "Poort moet tussen 1 en 65535 zijn",
"healthCheckHostnameInvalid": "De hostnaam mag geen witruimtes bevatten",
"healthCheckPath": "Pad", "healthCheckPath": "Pad",
"healthHostname": "IP / Hostnaam", "healthHostname": "IP / Hostnaam",
"healthPort": "Poort", "healthPort": "Poort",
@@ -3001,7 +2993,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Voeg een CIDR-bereik toe (bijv. 10.0.0.0/8)", "remoteExitNodeNetworkingSubnetsPlaceholder": "Voeg een CIDR-bereik toe (bijv. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Kon subnets niet laden", "remoteExitNodeNetworkingSubnetsLoadError": "Kon subnets niet laden",
"remoteExitNodeNetworkingLabelsTitle": "Voorkeurslabels", "remoteExitNodeNetworkingLabelsTitle": "Voorkeurslabels",
"remoteExitNodeNetworkingLabelsDescription": "Sites met deze labels zullen bij voorkeur verbinding maken via deze externe exitnode.", "remoteExitNodeNetworkingLabelsDescription": "Sites met deze labels worden verplicht om verbinding te maken via dit externe exit-knooppunt.",
"remoteExitNodeNetworkingLabelsButtonText": "Selecteer labels...", "remoteExitNodeNetworkingLabelsButtonText": "Selecteer labels...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Labels zoeken...", "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Labels zoeken...",
"remoteExitNodeNetworkingLabelsLoadError": "Kon labels niet laden", "remoteExitNodeNetworkingLabelsLoadError": "Kon labels niet laden",
+1 -9
View File
@@ -1176,10 +1176,6 @@
"idpJmespathAboutDescriptionLink": "Dowiedz się więcej o JMESPath", "idpJmespathAboutDescriptionLink": "Dowiedz się więcej o JMESPath",
"idpJmespathLabel": "Ścieżka identyfikatora", "idpJmespathLabel": "Ścieżka identyfikatora",
"idpJmespathLabelDescription": "JMESPath do identyfikatora użytkownika w tokenie ID", "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)", "idpJmespathEmailPathOptional": "Ścieżka email (Opcjonalnie)",
"idpJmespathEmailPathOptionalDescription": "JMESPath do emaila użytkownika w tokenie ID", "idpJmespathEmailPathOptionalDescription": "JMESPath do emaila użytkownika w tokenie ID",
"idpJmespathNamePathOptional": "Ścieżka nazwy (Opcjonalnie)", "idpJmespathNamePathOptional": "Ścieżka nazwy (Opcjonalnie)",
@@ -1577,8 +1573,6 @@
"search": "Szukaj…", "search": "Szukaj…",
"searchPlaceholder": "Szukaj...", "searchPlaceholder": "Szukaj...",
"emptySearchOptions": "Nie znaleziono opcji", "emptySearchOptions": "Nie znaleziono opcji",
"ipFilterSearchPlaceholder": "Wprowadź adres IP…",
"ipFilterEmptyMessage": "Wprowadź adres IP, aby filtrować",
"create": "Utwórz", "create": "Utwórz",
"orgs": "Organizacje", "orgs": "Organizacje",
"loginError": "Wystąpił nieoczekiwany błąd. Spróbuj ponownie.", "loginError": "Wystąpił nieoczekiwany błąd. Spróbuj ponownie.",
@@ -2602,7 +2596,6 @@
"createDomainType": "Typ:", "createDomainType": "Typ:",
"createDomainName": "Nazwa:", "createDomainName": "Nazwa:",
"createDomainValue": "Wartość:", "createDomainValue": "Wartość:",
"multiSelectFilterCount": "{count, plural, one {# wybrany} few {# wybrane} many {# wybranych} other {# wybranych}}",
"createDomainCnameRecords": "Rekordy CNAME", "createDomainCnameRecords": "Rekordy CNAME",
"createDomainARecords": "Rekordy A", "createDomainARecords": "Rekordy A",
"createDomainRecordNumber": "Rekord {number}", "createDomainRecordNumber": "Rekord {number}",
@@ -2717,7 +2710,6 @@
"healthScheme": "Metoda", "healthScheme": "Metoda",
"healthSelectScheme": "Wybierz metodę", "healthSelectScheme": "Wybierz metodę",
"healthCheckPortInvalid": "Port musi być pomiędzy 1 a 65535", "healthCheckPortInvalid": "Port musi być pomiędzy 1 a 65535",
"healthCheckHostnameInvalid": "Nazwa hosta nie może zawierać spacji",
"healthCheckPath": "Ścieżka", "healthCheckPath": "Ścieżka",
"healthHostname": "IP / Nazwa hosta", "healthHostname": "IP / Nazwa hosta",
"healthPort": "Port", "healthPort": "Port",
@@ -3001,7 +2993,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Dodaj zakres CIDR (np. 10.0.0.0/8)", "remoteExitNodeNetworkingSubnetsPlaceholder": "Dodaj zakres CIDR (np. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Nie udało się załadować podsieci", "remoteExitNodeNetworkingSubnetsLoadError": "Nie udało się załadować podsieci",
"remoteExitNodeNetworkingLabelsTitle": "Etykiety preferencji", "remoteExitNodeNetworkingLabelsTitle": "Etykiety preferencji",
"remoteExitNodeNetworkingLabelsDescription": "Strony z tymi etykietami będą preferować połączenie przez ten zdalny węzeł wyjściowy.", "remoteExitNodeNetworkingLabelsDescription": "Strony z tymi etykietami będą zmuszone do połączenia się przez ten zdalny węzeł wyjściowy.",
"remoteExitNodeNetworkingLabelsButtonText": "Wybierz etykiety...", "remoteExitNodeNetworkingLabelsButtonText": "Wybierz etykiety...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Szukaj etykiet...", "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Szukaj etykiet...",
"remoteExitNodeNetworkingLabelsLoadError": "Nie udało się załadować etykiet", "remoteExitNodeNetworkingLabelsLoadError": "Nie udało się załadować etykiet",
+1 -9
View File
@@ -1176,10 +1176,6 @@
"idpJmespathAboutDescriptionLink": "Saiba mais sobre JMESPath", "idpJmespathAboutDescriptionLink": "Saiba mais sobre JMESPath",
"idpJmespathLabel": "Caminho do Identificador", "idpJmespathLabel": "Caminho do Identificador",
"idpJmespathLabelDescription": "O JMESPath para o identificador do utilizador no token ID", "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)", "idpJmespathEmailPathOptional": "Caminho do Email (Opcional)",
"idpJmespathEmailPathOptionalDescription": "O JMESPath para o email do utilizador no token ID", "idpJmespathEmailPathOptionalDescription": "O JMESPath para o email do utilizador no token ID",
"idpJmespathNamePathOptional": "Caminho do Nome (Opcional)", "idpJmespathNamePathOptional": "Caminho do Nome (Opcional)",
@@ -1577,8 +1573,6 @@
"search": "Pesquisar…", "search": "Pesquisar…",
"searchPlaceholder": "Buscar...", "searchPlaceholder": "Buscar...",
"emptySearchOptions": "Nenhuma opção encontrada", "emptySearchOptions": "Nenhuma opção encontrada",
"ipFilterSearchPlaceholder": "Insira um endereço IP…",
"ipFilterEmptyMessage": "Insira um endereço IP para filtrar",
"create": "Criar", "create": "Criar",
"orgs": "Organizações", "orgs": "Organizações",
"loginError": "Ocorreu um erro inesperado. Por favor, tente novamente.", "loginError": "Ocorreu um erro inesperado. Por favor, tente novamente.",
@@ -2602,7 +2596,6 @@
"createDomainType": "Tipo:", "createDomainType": "Tipo:",
"createDomainName": "Nome:", "createDomainName": "Nome:",
"createDomainValue": "Valor:", "createDomainValue": "Valor:",
"multiSelectFilterCount": "{count} selecionado",
"createDomainCnameRecords": "Registros CNAME", "createDomainCnameRecords": "Registros CNAME",
"createDomainARecords": "Registros A", "createDomainARecords": "Registros A",
"createDomainRecordNumber": "Registrar {number}", "createDomainRecordNumber": "Registrar {number}",
@@ -2717,7 +2710,6 @@
"healthScheme": "Método", "healthScheme": "Método",
"healthSelectScheme": "Selecione o Método", "healthSelectScheme": "Selecione o Método",
"healthCheckPortInvalid": "A porta deve estar entre 1 e 65535", "healthCheckPortInvalid": "A porta deve estar entre 1 e 65535",
"healthCheckHostnameInvalid": "O nome do host não deve conter espaços em branco",
"healthCheckPath": "Caminho", "healthCheckPath": "Caminho",
"healthHostname": "IP / Nome do Host", "healthHostname": "IP / Nome do Host",
"healthPort": "Porta", "healthPort": "Porta",
@@ -3001,7 +2993,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Adicione um intervalo de CIDR (por exemplo, 10.0.0.0/8)", "remoteExitNodeNetworkingSubnetsPlaceholder": "Adicione um intervalo de CIDR (por exemplo, 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Falha ao carregar sub-redes", "remoteExitNodeNetworkingSubnetsLoadError": "Falha ao carregar sub-redes",
"remoteExitNodeNetworkingLabelsTitle": "Etiquetas de Preferência", "remoteExitNodeNetworkingLabelsTitle": "Etiquetas de Preferência",
"remoteExitNodeNetworkingLabelsDescription": "Sites com estas etiquetas preferirão conectar-se por meio deste nó de saída remoto.", "remoteExitNodeNetworkingLabelsDescription": "Os sites com essas etiquetas serão forçados a se conectar através deste nó de saída remoto.",
"remoteExitNodeNetworkingLabelsButtonText": "Selecionar etiquetas...", "remoteExitNodeNetworkingLabelsButtonText": "Selecionar etiquetas...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Pesquisar etiquetas...", "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Pesquisar etiquetas...",
"remoteExitNodeNetworkingLabelsLoadError": "Falha ao carregar etiquetas", "remoteExitNodeNetworkingLabelsLoadError": "Falha ao carregar etiquetas",
+1 -9
View File
@@ -1176,10 +1176,6 @@
"idpJmespathAboutDescriptionLink": "Узнать больше о JMESPath", "idpJmespathAboutDescriptionLink": "Узнать больше о JMESPath",
"idpJmespathLabel": "Путь идентификатора", "idpJmespathLabel": "Путь идентификатора",
"idpJmespathLabelDescription": "Путь к идентификатору пользователя в ID токене", "idpJmespathLabelDescription": "Путь к идентификатору пользователя в ID токене",
"idpIdentifierChangeTitle": "Предупреждение о изменении пути идентификатора",
"idpIdentifierChangeDescription": "Вы собираетесь изменить путь идентификатора. Это повлияет на то, как отображаются существующие пользователи. Пользователи, которые ранее входили через этого поставщика идентификации, могут больше не распознаваться как те же пользователи.",
"idpIdentifierChangeConfirmMessage": "Я подтверждаю",
"idpIdentifierChangeWarningText": "Это повлияет на то, как отображаются существующие пользователи",
"idpJmespathEmailPathOptional": "Путь к email (необязательно)", "idpJmespathEmailPathOptional": "Путь к email (необязательно)",
"idpJmespathEmailPathOptionalDescription": "Путь к email пользователя в ID токене", "idpJmespathEmailPathOptionalDescription": "Путь к email пользователя в ID токене",
"idpJmespathNamePathOptional": "Путь к имени (необязательно)", "idpJmespathNamePathOptional": "Путь к имени (необязательно)",
@@ -1577,8 +1573,6 @@
"search": "Поиск…", "search": "Поиск…",
"searchPlaceholder": "Поиск...", "searchPlaceholder": "Поиск...",
"emptySearchOptions": "Опции не найдены", "emptySearchOptions": "Опции не найдены",
"ipFilterSearchPlaceholder": "Введите IP адрес…",
"ipFilterEmptyMessage": "Введите IP адрес для фильтрации",
"create": "Создать", "create": "Создать",
"orgs": "Организации", "orgs": "Организации",
"loginError": "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз.", "loginError": "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз.",
@@ -2602,7 +2596,6 @@
"createDomainType": "Тип:", "createDomainType": "Тип:",
"createDomainName": "Имя:", "createDomainName": "Имя:",
"createDomainValue": "Значение:", "createDomainValue": "Значение:",
"multiSelectFilterCount": "Выбрано: {count}",
"createDomainCnameRecords": "CNAME Записи", "createDomainCnameRecords": "CNAME Записи",
"createDomainARecords": "A Записи", "createDomainARecords": "A Записи",
"createDomainRecordNumber": "Запись {number}", "createDomainRecordNumber": "Запись {number}",
@@ -2717,7 +2710,6 @@
"healthScheme": "Метод", "healthScheme": "Метод",
"healthSelectScheme": "Выберите метод", "healthSelectScheme": "Выберите метод",
"healthCheckPortInvalid": "Порт должен быть в диапазоне от 1 до 65535", "healthCheckPortInvalid": "Порт должен быть в диапазоне от 1 до 65535",
"healthCheckHostnameInvalid": "Имя хоста не должно содержать пробелов",
"healthCheckPath": "Путь", "healthCheckPath": "Путь",
"healthHostname": "IP / хост", "healthHostname": "IP / хост",
"healthPort": "Порт", "healthPort": "Порт",
@@ -3001,7 +2993,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Добавить диапазон CIDR (например, 10.0.0.0/8)", "remoteExitNodeNetworkingSubnetsPlaceholder": "Добавить диапазон CIDR (например, 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Не удалось загрузить подсети", "remoteExitNodeNetworkingSubnetsLoadError": "Не удалось загрузить подсети",
"remoteExitNodeNetworkingLabelsTitle": "Этикетки предпочтений", "remoteExitNodeNetworkingLabelsTitle": "Этикетки предпочтений",
"remoteExitNodeNetworkingLabelsDescription": "Сайты с такими метками предпочтут соединяться через этот удаленный узел выхода.", "remoteExitNodeNetworkingLabelsDescription": "Сайты с этими метками будут обязаны подключаться через этот удаленный узел выхода.",
"remoteExitNodeNetworkingLabelsButtonText": "Выберите метки...", "remoteExitNodeNetworkingLabelsButtonText": "Выберите метки...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Поиск меток...", "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Поиск меток...",
"remoteExitNodeNetworkingLabelsLoadError": "Не удалось загрузить метки", "remoteExitNodeNetworkingLabelsLoadError": "Не удалось загрузить метки",
+1 -9
View File
@@ -1176,10 +1176,6 @@
"idpJmespathAboutDescriptionLink": "JMESPath hakkında daha fazla bilgi edinin", "idpJmespathAboutDescriptionLink": "JMESPath hakkında daha fazla bilgi edinin",
"idpJmespathLabel": "Tanımlayıcı Yolu", "idpJmespathLabel": "Tanımlayıcı Yolu",
"idpJmespathLabelDescription": "The JMESPath to the user identifier in the ID token", "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ı)", "idpJmespathEmailPathOptional": "E-posta Yolu (İsteğe Bağlı)",
"idpJmespathEmailPathOptionalDescription": "The JMESPath to the user's email in the ID token", "idpJmespathEmailPathOptionalDescription": "The JMESPath to the user's email in the ID token",
"idpJmespathNamePathOptional": "Ad Yolu (İsteğe Bağlı)", "idpJmespathNamePathOptional": "Ad Yolu (İsteğe Bağlı)",
@@ -1577,8 +1573,6 @@
"search": "Ara…", "search": "Ara…",
"searchPlaceholder": "Ara...", "searchPlaceholder": "Ara...",
"emptySearchOptions": "Seçenek bulunamadı", "emptySearchOptions": "Seçenek bulunamadı",
"ipFilterSearchPlaceholder": "Bir IP adresi girin…",
"ipFilterEmptyMessage": "Filtrelemek için bir IP adresi girin",
"create": "Oluştur", "create": "Oluştur",
"orgs": "Organizasyonlar", "orgs": "Organizasyonlar",
"loginError": "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.", "loginError": "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.",
@@ -2602,7 +2596,6 @@
"createDomainType": "Tür:", "createDomainType": "Tür:",
"createDomainName": "Ad:", "createDomainName": "Ad:",
"createDomainValue": "Değer:", "createDomainValue": "Değer:",
"multiSelectFilterCount": "{count} seçildi",
"createDomainCnameRecords": "CNAME Kayıtları", "createDomainCnameRecords": "CNAME Kayıtları",
"createDomainARecords": "A Kayıtları", "createDomainARecords": "A Kayıtları",
"createDomainRecordNumber": "Kayıt {number}", "createDomainRecordNumber": "Kayıt {number}",
@@ -2717,7 +2710,6 @@
"healthScheme": "Yöntem", "healthScheme": "Yöntem",
"healthSelectScheme": "Yöntem Seç", "healthSelectScheme": "Yöntem Seç",
"healthCheckPortInvalid": "Bağlantı noktası 1 ile 65535 arasında olmalıdır", "healthCheckPortInvalid": "Bağlantı noktası 1 ile 65535 arasında olmalıdır",
"healthCheckHostnameInvalid": "Ana bilgisayar adı boşluk içermemelidir",
"healthCheckPath": "Yol", "healthCheckPath": "Yol",
"healthHostname": "IP / Hostname", "healthHostname": "IP / Hostname",
"healthPort": "Bağlantı Noktası", "healthPort": "Bağlantı Noktası",
@@ -3001,7 +2993,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Bir CIDR aralığı ekle (örneğin, 10.0.0.0/8)", "remoteExitNodeNetworkingSubnetsPlaceholder": "Bir CIDR aralığı ekle (örneğin, 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Alt ağlar yüklenemedi", "remoteExitNodeNetworkingSubnetsLoadError": "Alt ağlar yüklenemedi",
"remoteExitNodeNetworkingLabelsTitle": "Tercih Etiketleri", "remoteExitNodeNetworkingLabelsTitle": "Tercih Etiketleri",
"remoteExitNodeNetworkingLabelsDescription": "Bu etiketlere sahip siteler, bağlantıyı bu uzak çıkış düğümü üzerinden tercih edecektir.", "remoteExitNodeNetworkingLabelsDescription": "Bu etiketlere sahip siteler, bu uzak çıkış düğümü üzerinden bağlantı kurmaya zorlanacaktır.",
"remoteExitNodeNetworkingLabelsButtonText": "Etiketleri seç...", "remoteExitNodeNetworkingLabelsButtonText": "Etiketleri seç...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Etiketleri ara...", "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Etiketleri ara...",
"remoteExitNodeNetworkingLabelsLoadError": "Etiketler yüklenemedi", "remoteExitNodeNetworkingLabelsLoadError": "Etiketler yüklenemedi",
+1 -9
View File
@@ -1176,10 +1176,6 @@
"idpJmespathAboutDescriptionLink": "了解更多 JMESPath 信息", "idpJmespathAboutDescriptionLink": "了解更多 JMESPath 信息",
"idpJmespathLabel": "标识符路径", "idpJmespathLabel": "标识符路径",
"idpJmespathLabelDescription": "ID 令牌中用户标识符的路径", "idpJmespathLabelDescription": "ID 令牌中用户标识符的路径",
"idpIdentifierChangeTitle": "标识符路径更改警告",
"idpIdentifierChangeDescription": "您即将更改标识符路径。这将影响现有用户的映射方式。以前通过此身份提供者登录的用户可能将不再被识别为相同用户。",
"idpIdentifierChangeConfirmMessage": "我确认",
"idpIdentifierChangeWarningText": "这将影响现有用户的映射方式",
"idpJmespathEmailPathOptional": "邮箱路径(可选)", "idpJmespathEmailPathOptional": "邮箱路径(可选)",
"idpJmespathEmailPathOptionalDescription": "ID 令牌中用户邮箱的路径", "idpJmespathEmailPathOptionalDescription": "ID 令牌中用户邮箱的路径",
"idpJmespathNamePathOptional": "用户名路径(可选)", "idpJmespathNamePathOptional": "用户名路径(可选)",
@@ -1577,8 +1573,6 @@
"search": "搜索…", "search": "搜索…",
"searchPlaceholder": "搜索...", "searchPlaceholder": "搜索...",
"emptySearchOptions": "未找到选项", "emptySearchOptions": "未找到选项",
"ipFilterSearchPlaceholder": "输入IP地址…",
"ipFilterEmptyMessage": "输入要筛选的IP地址",
"create": "创建", "create": "创建",
"orgs": "组织", "orgs": "组织",
"loginError": "发生意外错误。请重试。", "loginError": "发生意外错误。请重试。",
@@ -2602,7 +2596,6 @@
"createDomainType": "类型:", "createDomainType": "类型:",
"createDomainName": "名称:", "createDomainName": "名称:",
"createDomainValue": "值:", "createDomainValue": "值:",
"multiSelectFilterCount": "{count} 已选择",
"createDomainCnameRecords": "CNAME 记录", "createDomainCnameRecords": "CNAME 记录",
"createDomainARecords": "A记录", "createDomainARecords": "A记录",
"createDomainRecordNumber": "记录 {number}", "createDomainRecordNumber": "记录 {number}",
@@ -2717,7 +2710,6 @@
"healthScheme": "方法", "healthScheme": "方法",
"healthSelectScheme": "选择方法", "healthSelectScheme": "选择方法",
"healthCheckPortInvalid": "端口必须在 1 和 65535 之间", "healthCheckPortInvalid": "端口必须在 1 和 65535 之间",
"healthCheckHostnameInvalid": "主机名不得包含空格",
"healthCheckPath": "路径", "healthCheckPath": "路径",
"healthHostname": "IP / 主机", "healthHostname": "IP / 主机",
"healthPort": "端口", "healthPort": "端口",
@@ -3001,7 +2993,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "添加CIDR范围(例如10.0.0.0/8", "remoteExitNodeNetworkingSubnetsPlaceholder": "添加CIDR范围(例如10.0.0.0/8",
"remoteExitNodeNetworkingSubnetsLoadError": "无法加载子网", "remoteExitNodeNetworkingSubnetsLoadError": "无法加载子网",
"remoteExitNodeNetworkingLabelsTitle": "首选标签", "remoteExitNodeNetworkingLabelsTitle": "首选标签",
"remoteExitNodeNetworkingLabelsDescription": "有这些标签的站点将优先通过此远程出口节点进行连接。", "remoteExitNodeNetworkingLabelsDescription": "有这些标签的站点将强制通过此远程出口节点连接。",
"remoteExitNodeNetworkingLabelsButtonText": "选择标签……", "remoteExitNodeNetworkingLabelsButtonText": "选择标签……",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "搜索标签……", "remoteExitNodeNetworkingLabelsSearchPlaceholder": "搜索标签……",
"remoteExitNodeNetworkingLabelsLoadError": "无法加载标签", "remoteExitNodeNetworkingLabelsLoadError": "无法加载标签",
+788 -2213
View File
File diff suppressed because it is too large Load Diff
+3675 -2051
View File
File diff suppressed because it is too large Load Diff
+73 -73
View File
@@ -32,49 +32,49 @@
"format": "prettier --write ." "format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@asteasolutions/zod-to-openapi": "9.1.0", "@asteasolutions/zod-to-openapi": "8.5.0",
"@aws-sdk/client-s3": "3.1121.0", "@aws-sdk/client-s3": "3.1056.0",
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz", "@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", "@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
"@headlessui/react": "2.2.10", "@headlessui/react": "2.2.10",
"@hookform/resolvers": "5.9.1", "@hookform/resolvers": "5.4.0",
"@monaco-editor/react": "4.7.0", "@monaco-editor/react": "4.7.0",
"@node-rs/argon2": "2.2.0", "@node-rs/argon2": "2.0.2",
"@novnc/novnc": "^1.7.0", "@novnc/novnc": "^1.7.0",
"@oslojs/crypto": "1.0.1", "@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0", "@oslojs/encoding": "1.1.0",
"@radix-ui/react-avatar": "1.2.6", "@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.11", "@radix-ui/react-checkbox": "1.3.3",
"@radix-ui/react-collapsible": "1.1.20", "@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-dialog": "1.1.23", "@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-dropdown-menu": "2.1.24", "@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-icons": "1.3.2", "@radix-ui/react-icons": "1.3.2",
"@radix-ui/react-label": "2.1.15", "@radix-ui/react-label": "2.1.8",
"@radix-ui/react-popover": "1.1.23", "@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-progress": "1.1.16", "@radix-ui/react-progress": "1.1.8",
"@radix-ui/react-radio-group": "1.4.7", "@radix-ui/react-radio-group": "1.3.8",
"@radix-ui/react-scroll-area": "1.2.18", "@radix-ui/react-scroll-area": "1.2.10",
"@radix-ui/react-select": "2.3.7", "@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.15", "@radix-ui/react-separator": "1.1.8",
"@radix-ui/react-slot": "1.3.3", "@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "1.3.7", "@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.21", "@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toast": "1.2.23", "@radix-ui/react-toast": "1.2.15",
"@radix-ui/react-tooltip": "1.2.16", "@radix-ui/react-tooltip": "1.2.8",
"@react-email/body": "0.3.0", "@react-email/body": "0.3.0",
"@react-email/components": "1.0.12", "@react-email/components": "1.0.12",
"@react-email/render": "2.1.0", "@react-email/render": "2.0.8",
"@react-email/tailwind": "2.0.7", "@react-email/tailwind": "2.0.7",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.3", "@simplewebauthn/server": "13.3.1",
"@tailwindcss/forms": "0.5.11", "@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.102.8", "@tanstack/react-query": "5.100.14",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0", "@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"arctic": "3.7.0", "arctic": "3.7.0",
"axios": "1.20.0", "axios": "1.18.0",
"better-sqlite3": "11.9.1", "better-sqlite3": "11.9.1",
"canvas-confetti": "1.9.4", "canvas-confetti": "1.9.4",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
@@ -86,62 +86,62 @@
"d3": "7.9.0", "d3": "7.9.0",
"drizzle-orm": "0.45.2", "drizzle-orm": "0.45.2",
"express": "5.2.1", "express": "5.2.1",
"express-rate-limit": "8.7.0", "express-rate-limit": "8.5.2",
"glob": "13.0.6", "glob": "13.0.6",
"gpt-tokenizer": "^4.0.0", "gpt-tokenizer": "^3.4.0",
"helmet": "8.3.0", "helmet": "8.2.0",
"http-errors": "2.0.1", "http-errors": "2.0.1",
"input-otp": "1.5.0", "input-otp": "1.4.2",
"ioredis": "6.0.0", "ioredis": "5.11.0",
"jmespath": "0.16.0", "jmespath": "0.16.0",
"js-yaml": "5.4.1", "js-yaml": "4.3.0",
"jsonwebtoken": "9.0.3", "jsonwebtoken": "9.0.3",
"lucide-react": "1.38.0", "lucide-react": "1.17.0",
"maxmind": "5.0.7", "maxmind": "5.0.6",
"moment": "2.30.1", "moment": "2.30.1",
"next": "16.3.3", "next": "16.2.11",
"next-intl": "4.14.1", "next-intl": "4.13.0",
"next-themes": "0.4.6", "next-themes": "0.4.6",
"nextjs-toploader": "3.9.17", "nextjs-toploader": "3.9.17",
"node-cache": "5.1.2", "node-cache": "5.1.2",
"nodemailer": "9.1.0", "nodemailer": "9.0.1",
"oslo": "1.2.1", "oslo": "1.2.1",
"pg": "8.23.0", "pg": "8.21.0",
"posthog-node": "5.51.4", "posthog-node": "5.35.6",
"qrcode.react": "4.2.0", "qrcode.react": "4.2.0",
"react": "19.2.8", "react": "19.2.6",
"react-day-picker": "9.14.0", "react-day-picker": "9.14.0",
"react-dom": "19.2.8", "react-dom": "19.2.6",
"react-easy-sort": "1.8.0", "react-easy-sort": "1.8.0",
"react-hook-form": "7.87.0", "react-hook-form": "7.76.1",
"react-icons": "5.7.0", "react-icons": "5.6.0",
"recharts": "3.10.1", "recharts": "3.8.1",
"reodotdev": "1.1.0", "reodotdev": "1.1.0",
"semver": "7.8.5", "semver": "7.8.1",
"sshpk": "1.18.0", "sshpk": "1.18.0",
"stripe": "22.6.0", "stripe": "22.2.0",
"swagger-ui-express": "5.0.1", "swagger-ui-express": "5.0.1",
"tailwind-merge": "3.6.0", "tailwind-merge": "3.6.0",
"topojson-client": "3.1.0", "topojson-client": "3.1.0",
"tw-animate-css": "1.4.0", "tw-animate-css": "1.4.0",
"use-debounce": "10.1.1", "use-debounce": "10.1.1",
"uuid": "14.0.2", "uuid": "14.0.0",
"vaul": "1.1.2", "vaul": "1.1.2",
"visionscarto-world-atlas": "1.0.0", "visionscarto-world-atlas": "1.0.0",
"winston": "3.19.0", "winston": "3.19.0",
"winston-daily-rotate-file": "5.0.0", "winston-daily-rotate-file": "5.0.0",
"ws": "8.21.3", "ws": "8.21.0",
"yaml": "2.9.0", "yaml": "2.9.0",
"yargs": "18.1.0", "yargs": "18.0.0",
"zod": "4.5.4", "zod": "4.4.3",
"zod-validation-error": "5.0.0" "zod-validation-error": "5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@dotenvx/dotenvx": "2.23.0", "@dotenvx/dotenvx": "1.69.1",
"@esbuild-plugins/tsconfig-paths": "0.1.2", "@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/ui": "^6.9.3", "@react-email/ui": "^6.5.0",
"@tailwindcss/postcss": "4.3.3", "@tailwindcss/postcss": "4.3.0",
"@tanstack/react-query-devtools": "5.102.8", "@tanstack/react-query-devtools": "5.100.14",
"@types/better-sqlite3": "7.6.13", "@types/better-sqlite3": "7.6.13",
"@types/cookie-parser": "1.4.10", "@types/cookie-parser": "1.4.10",
"@types/cors": "2.8.19", "@types/cors": "2.8.19",
@@ -152,36 +152,36 @@
"@types/jmespath": "0.15.2", "@types/jmespath": "0.15.2",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "9.0.10", "@types/jsonwebtoken": "9.0.10",
"@types/node": "26.4.0", "@types/node": "25.9.1",
"@types/nodemailer": "8.0.1", "@types/nodemailer": "8.0.0",
"@types/nprogress": "0.2.3", "@types/nprogress": "0.2.3",
"@types/pg": "8.23.1", "@types/pg": "8.20.0",
"@types/react": "19.2.18", "@types/react": "19.2.15",
"@types/react-dom": "19.2.5", "@types/react-dom": "19.2.3",
"@types/semver": "7.8.0", "@types/semver": "7.7.1",
"@types/sshpk": "1.17.5", "@types/sshpk": "1.17.4",
"@types/swagger-ui-express": "4.1.8", "@types/swagger-ui-express": "4.1.8",
"@types/topojson-client": "3.1.5", "@types/topojson-client": "3.1.5",
"@types/ws": "8.18.1", "@types/ws": "8.18.1",
"@types/yargs": "17.0.35", "@types/yargs": "17.0.35",
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "1.0.0",
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"esbuild": "0.28.2", "esbuild": "0.28.0",
"esbuild-node-externals": "2.0.0", "esbuild-node-externals": "1.22.0",
"eslint": "10.9.1", "eslint": "10.4.0",
"eslint-config-next": "16.3.3", "eslint-config-next": "16.2.6",
"postcss": "8.5.26", "postcss": "8.5.15",
"prettier": "3.9.6", "prettier": "3.8.3",
"react-email": "6.9.3", "react-email": "6.5.0",
"tailwindcss": "4.3.3", "tailwindcss": "4.3.0",
"tsc-alias": "1.9.2", "tsc-alias": "1.8.17",
"tsx": "4.23.13", "tsx": "4.22.3",
"typescript": "6.0.3", "typescript": "6.0.3",
"typescript-eslint": "8.68.0" "typescript-eslint": "8.60.0"
}, },
"overrides": { "overrides": {
"esbuild": "0.28.2", "esbuild": "0.28.0",
"dompurify": "3.4.0", "dompurify": "3.4.0",
"postcss": "8.5.26" "postcss": "8.5.15"
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 790 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 621 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 621 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 KiB

After

Width:  |  Height:  |  Size: 556 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 800 KiB

After

Width:  |  Height:  |  Size: 516 KiB

+1 -85
View File
@@ -26,9 +26,7 @@ import {
sites, sites,
clients, clients,
sessions, sessions,
labels, labels
aiProviders,
virtualApiKeys
} from "./schema"; } from "./schema";
export const dnsChallenge = pgTable("dnsChallenges", { export const dnsChallenge = pgTable("dnsChallenges", {
@@ -616,87 +614,6 @@ export const trialNotifications = pgTable("trialNotifications", {
sentAt: bigint("sentAt", { mode: "number" }).notNull() 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 Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;
@@ -743,4 +660,3 @@ export type AlertEmailRecipients = InferSelectModel<
>; >;
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>; export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>; export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
+91 -31
View File
@@ -262,9 +262,7 @@ export const resourceAiModels = pgTable(
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })] (t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
); );
export const labels = pgTable( export const labels = pgTable("labels", {
"labels",
{
labelId: serial("labelId").primaryKey(), labelId: serial("labelId").primaryKey(),
name: varchar("name").notNull(), name: varchar("name").notNull(),
color: varchar("color").notNull(), color: varchar("color").notNull(),
@@ -273,9 +271,7 @@ export const labels = pgTable(
onDelete: "cascade" onDelete: "cascade"
}) })
.notNull() .notNull()
}, });
(t) => [index("idx_labels_orgid").on(t.orgId)]
);
export const launcherViews = pgTable("launcherViews", { export const launcherViews = pgTable("launcherViews", {
viewId: serial("viewId").primaryKey(), viewId: serial("viewId").primaryKey(),
@@ -697,9 +693,7 @@ export const twoFactorBackupCodes = pgTable("twoFactorBackupCodes", {
codeHash: varchar("codeHash").notNull() codeHash: varchar("codeHash").notNull()
}); });
export const sessions = pgTable( export const sessions = pgTable("session", {
"session",
{
sessionId: varchar("id").primaryKey(), sessionId: varchar("id").primaryKey(),
userId: varchar("userId") userId: varchar("userId")
.notNull() .notNull()
@@ -707,9 +701,7 @@ export const sessions = pgTable(
expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), expiresAt: bigint("expiresAt", { mode: "number" }).notNull(),
issuedAt: bigint("issuedAt", { mode: "number" }), issuedAt: bigint("issuedAt", { mode: "number" }),
deviceAuthUsed: boolean("deviceAuthUsed").notNull().default(false) deviceAuthUsed: boolean("deviceAuthUsed").notNull().default(false)
}, });
(t) => [index("idx_sessions_userid").on(t.userId)]
);
export const newtSessions = pgTable("newtSession", { export const newtSessions = pgTable("newtSession", {
sessionId: varchar("id").primaryKey(), sessionId: varchar("id").primaryKey(),
@@ -719,9 +711,7 @@ export const newtSessions = pgTable("newtSession", {
expiresAt: bigint("expiresAt", { mode: "number" }).notNull() expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
}); });
export const userOrgs = pgTable( export const userOrgs = pgTable("userOrgs", {
"userOrgs",
{
userId: varchar("userId") userId: varchar("userId")
.notNull() .notNull()
.references(() => users.userId, { onDelete: "cascade" }), .references(() => users.userId, { onDelete: "cascade" }),
@@ -733,12 +723,7 @@ export const userOrgs = pgTable(
isOwner: boolean("isOwner").notNull().default(false), isOwner: boolean("isOwner").notNull().default(false),
autoProvisioned: boolean("autoProvisioned").default(false), autoProvisioned: boolean("autoProvisioned").default(false),
pamUsername: varchar("pamUsername") // cleaned username for ssh and such 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", { export const emailVerificationCodes = pgTable("emailVerificationCodes", {
codeId: serial("id").primaryKey(), codeId: serial("id").primaryKey(),
@@ -766,9 +751,7 @@ export const actions = pgTable("actions", {
description: varchar("description") description: varchar("description")
}); });
export const roles = pgTable( export const roles = pgTable("roles", {
"roles",
{
roleId: serial("roleId").primaryKey(), roleId: serial("roleId").primaryKey(),
orgId: varchar("orgId") orgId: varchar("orgId")
.references(() => orgs.orgId, { .references(() => orgs.orgId, {
@@ -783,9 +766,7 @@ export const roles = pgTable(
sshSudoCommands: text("sshSudoCommands").default("[]"), sshSudoCommands: text("sshSudoCommands").default("[]"),
sshCreateHomeDir: boolean("sshCreateHomeDir").default(true), sshCreateHomeDir: boolean("sshCreateHomeDir").default(true),
sshUnixGroups: text("sshUnixGroups").default("[]") sshUnixGroups: text("sshUnixGroups").default("[]")
}, });
(t) => [index("idx_roles_orgid").on(t.orgId)]
);
export const userOrgRoles = pgTable( export const userOrgRoles = pgTable(
"userOrgRoles", "userOrgRoles",
@@ -1428,10 +1409,7 @@ export const olms = pgTable(
}), }),
archived: boolean("archived").notNull().default(false) archived: boolean("archived").notNull().default(false)
}, },
(t) => [ (t) => [index("idx_olms_clientid").on(t.clientId)]
index("idx_olms_clientid").on(t.clientId),
index("idx_olms_userid").on(t.userId)
]
); );
export const currentFingerprint = pgTable("currentFingerprint", { export const currentFingerprint = pgTable("currentFingerprint", {
@@ -1958,6 +1936,87 @@ 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", { export const certificates = pgTable("certificates", {
certId: serial("certId").primaryKey(), certId: serial("certId").primaryKey(),
domain: varchar("domain", { length: 255 }).notNull().unique(), domain: varchar("domain", { length: 255 }).notNull().unique(),
@@ -2070,6 +2129,7 @@ export type AiModel = InferSelectModel<typeof aiModels>;
export type AiBudget = InferSelectModel<typeof aiBudgets>; export type AiBudget = InferSelectModel<typeof aiBudgets>;
export type AiUsageRecord = InferSelectModel<typeof aiUsageRecords>; export type AiUsageRecord = InferSelectModel<typeof aiUsageRecords>;
export type AiBudgetBreachEvent = InferSelectModel<typeof aiBudgetBreachEvents>; export type AiBudgetBreachEvent = InferSelectModel<typeof aiBudgetBreachEvents>;
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
export type ResourceAiProvider = InferSelectModel<typeof resourceAiProviders>; export type ResourceAiProvider = InferSelectModel<typeof resourceAiProviders>;
export type SiteResourceAiProvider = InferSelectModel< export type SiteResourceAiProvider = InferSelectModel<
typeof siteResourceAiProviders typeof siteResourceAiProviders
+1 -89
View File
@@ -9,7 +9,6 @@ import {
uniqueIndex uniqueIndex
} from "drizzle-orm/sqlite-core"; } from "drizzle-orm/sqlite-core";
import { import {
aiProviders,
clients, clients,
domains, domains,
exitNodes, exitNodes,
@@ -21,8 +20,7 @@ import {
siteResources, siteResources,
sites, sites,
targetHealthCheck, targetHealthCheck,
users, users
virtualApiKeys
} from "./schema"; } from "./schema";
export const dnsChallenge = sqliteTable("dnsChallenges", { export const dnsChallenge = sqliteTable("dnsChallenges", {
@@ -611,91 +609,6 @@ export const trialNotifications = sqliteTable("trialNotifications", {
sentAt: integer("sentAt").notNull() 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 Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;
@@ -734,4 +647,3 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>; export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>; export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>; export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
+125 -110
View File
@@ -99,9 +99,7 @@ export const orgDomains = sqliteTable("orgDomains", {
.references(() => domains.domainId, { onDelete: "cascade" }) .references(() => domains.domainId, { onDelete: "cascade" })
}); });
export const sites = sqliteTable( export const sites = sqliteTable("sites", {
"sites",
{
siteId: integer("siteId").primaryKey({ autoIncrement: true }), siteId: integer("siteId").primaryKey({ autoIncrement: true }),
orgId: text("orgId") orgId: text("orgId")
.references(() => orgs.orgId, { .references(() => orgs.orgId, {
@@ -143,16 +141,10 @@ export const sites = sqliteTable(
}) })
.notNull() .notNull()
.default(false), .default(false),
status: text("status") status: text("status").$type<"pending" | "approved">().default("approved")
.$type<"pending" | "approved">() });
.default("approved")
},
(table) => [index("idx_sites_orgId").on(table.orgId)]
);
export const resources = sqliteTable( export const resources = sqliteTable("resources", {
"resources",
{
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }), resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
resourcePolicyId: integer("resourcePolicyId").references( resourcePolicyId: integer("resourcePolicyId").references(
() => resourcePolicies.resourcePolicyId, () => resourcePolicies.resourcePolicyId,
@@ -190,9 +182,7 @@ export const resources = sqliteTable(
mode: "boolean" mode: "boolean"
}), }),
applyRules: integer("applyRules", { mode: "boolean" }), applyRules: integer("applyRules", { mode: "boolean" }),
enabled: integer("enabled", { mode: "boolean" }) enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
.notNull()
.default(true),
stickySession: integer("stickySession", { mode: "boolean" }) stickySession: integer("stickySession", { mode: "boolean" })
.notNull() .notNull()
.default(false), .default(false),
@@ -220,14 +210,10 @@ export const resources = sqliteTable(
maintenanceEstimatedTime: text("maintenanceEstimatedTime"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"), postAuthPath: text("postAuthPath"),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown" health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: integer("wildcard", { mode: "boolean" }) wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
.notNull()
.default(false),
mode: text("mode") mode: text("mode")
.default("http") .default("http")
.$type< .$type<"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp">()
"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp"
>()
.notNull(), // rdp, ssh, http, vnc, inference .notNull(), // rdp, ssh, http, vnc, inference
pamMode: text("pamMode") pamMode: text("pamMode")
.$type<"passthrough" | "push">() .$type<"passthrough" | "push">()
@@ -236,12 +222,8 @@ export const resources = sqliteTable(
.$type<"site" | "remote" | "native">() .$type<"site" | "remote" | "native">()
.default("site"), .default("site"),
authDaemonPort: integer("authDaemonPort").default(22123), authDaemonPort: integer("authDaemonPort").default(22123),
status: text("status") status: text("status").$type<"pending" | "approved">().default("approved")
.$type<"pending" | "approved">() });
.default("approved")
},
(table) => [index("idx_resources_orgId").on(table.orgId)]
);
export const resourceAiProviders = sqliteTable( export const resourceAiProviders = sqliteTable(
"resourceAiProviders", "resourceAiProviders",
@@ -278,9 +260,7 @@ export const resourceAiModels = sqliteTable(
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })] (t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
); );
export const labels = sqliteTable( export const labels = sqliteTable("labels", {
"labels",
{
labelId: integer("labelId").primaryKey({ autoIncrement: true }), labelId: integer("labelId").primaryKey({ autoIncrement: true }),
name: text("name").notNull(), name: text("name").notNull(),
color: text("color").notNull(), color: text("color").notNull(),
@@ -289,9 +269,7 @@ export const labels = sqliteTable(
onDelete: "cascade" onDelete: "cascade"
}) })
.notNull() .notNull()
}, });
(table) => [index("idx_labels_orgId").on(table.orgId)]
);
export const launcherViews = sqliteTable("launcherViews", { export const launcherViews = sqliteTable("launcherViews", {
viewId: integer("viewId").primaryKey({ autoIncrement: true }), viewId: integer("viewId").primaryKey({ autoIncrement: true }),
@@ -388,18 +366,14 @@ export const clientLabels = sqliteTable(
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)] (t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
); );
export const targets = sqliteTable( export const targets = sqliteTable("targets", {
"targets",
{
targetId: integer("targetId").primaryKey({ autoIncrement: true }), targetId: integer("targetId").primaryKey({ autoIncrement: true }),
resourceId: integer("resourceId").references( resourceId: integer("resourceId").references(() => resources.resourceId, {
() => resources.resourceId, onDelete: "cascade"
{ onDelete: "cascade" } }),
), providerId: integer("providerId").references(() => aiProviders.providerId, {
providerId: integer("providerId").references( onDelete: "cascade"
() => aiProviders.providerId, }),
{ onDelete: "cascade" }
),
siteId: integer("siteId") siteId: integer("siteId")
.references(() => sites.siteId, { .references(() => sites.siteId, {
onDelete: "cascade" onDelete: "cascade"
@@ -409,9 +383,7 @@ export const targets = sqliteTable(
method: text("method"), method: text("method"),
port: integer("port").notNull(), port: integer("port").notNull(),
internalPort: integer("internalPort"), internalPort: integer("internalPort"),
enabled: integer("enabled", { mode: "boolean" }) enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
.notNull()
.default(true),
path: text("path"), path: text("path"),
pathMatchType: text("pathMatchType"), // exact, prefix, regex pathMatchType: text("pathMatchType"), // exact, prefix, regex
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
@@ -422,12 +394,7 @@ export const targets = sqliteTable(
.notNull() .notNull()
.default("http"), .default("http"),
authToken: text("authToken") authToken: text("authToken")
}, });
(table) => [
index("idx_targets_resourceId").on(table.resourceId),
index("idx_targets_siteId").on(table.siteId)
]
);
export const targetHealthCheck = sqliteTable("targetHealthCheck", { export const targetHealthCheck = sqliteTable("targetHealthCheck", {
targetHealthCheckId: integer("targetHealthCheckId").primaryKey({ targetHealthCheckId: integer("targetHealthCheckId").primaryKey({
@@ -696,9 +663,7 @@ export const setupTokens = sqliteTable("setupTokens", {
dateUsed: text("dateUsed") dateUsed: text("dateUsed")
}); });
export const newts = sqliteTable( export const newts = sqliteTable("newt", {
"newt",
{
newtId: text("id").primaryKey(), newtId: text("id").primaryKey(),
secretHash: text("secretHash").notNull(), secretHash: text("secretHash").notNull(),
dateCreated: text("dateCreated").notNull(), dateCreated: text("dateCreated").notNull(),
@@ -706,13 +671,9 @@ export const newts = sqliteTable(
siteId: integer("siteId").references(() => sites.siteId, { siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade" onDelete: "cascade"
}) })
}, });
(table) => [index("idx_newts_siteId").on(table.siteId)]
);
export const clients = sqliteTable( export const clients = sqliteTable("clients", {
"clients",
{
clientId: integer("clientId").primaryKey({ autoIncrement: true }), clientId: integer("clientId").primaryKey({ autoIncrement: true }),
orgId: text("orgId") orgId: text("orgId")
.references(() => orgs.orgId, { .references(() => orgs.orgId, {
@@ -740,21 +701,12 @@ export const clients = sqliteTable(
online: integer("online", { mode: "boolean" }).notNull().default(false), online: integer("online", { mode: "boolean" }).notNull().default(false),
// endpoint: text("endpoint"), // endpoint: text("endpoint"),
lastHolePunch: integer("lastHolePunch"), lastHolePunch: integer("lastHolePunch"),
archived: integer("archived", { mode: "boolean" }) archived: integer("archived", { mode: "boolean" }).notNull().default(false),
.notNull() blocked: integer("blocked", { mode: "boolean" }).notNull().default(false),
.default(false),
blocked: integer("blocked", { mode: "boolean" })
.notNull()
.default(false),
approvalState: text("approvalState").$type< approvalState: text("approvalState").$type<
"pending" | "approved" | "denied" "pending" | "approved" | "denied"
>() >()
}, });
(table) => [
index("idx_clients_orgId").on(table.orgId),
index("idx_clients_userId").on(table.userId)
]
);
export const clientSitesAssociationsCache = sqliteTable( export const clientSitesAssociationsCache = sqliteTable(
"clientSitesAssociationsCache", "clientSitesAssociationsCache",
@@ -782,9 +734,7 @@ export const clientSiteResourcesAssociationsCache = sqliteTable(
} }
); );
export const olms = sqliteTable( export const olms = sqliteTable("olms", {
"olms",
{
olmId: text("id").primaryKey(), olmId: text("id").primaryKey(),
secretHash: text("secretHash").notNull(), secretHash: text("secretHash").notNull(),
dateCreated: text("dateCreated").notNull(), dateCreated: text("dateCreated").notNull(),
@@ -799,12 +749,8 @@ export const olms = sqliteTable(
// optionally tied to a user and in this case delete when the user deletes // optionally tied to a user and in this case delete when the user deletes
onDelete: "cascade" onDelete: "cascade"
}), }),
archived: integer("archived", { mode: "boolean" }) archived: integer("archived", { mode: "boolean" }).notNull().default(false)
.notNull() });
.default(false)
},
(table) => [index("idx_olms_userId").on(table.userId)]
);
export const currentFingerprint = sqliteTable("currentFingerprint", { export const currentFingerprint = sqliteTable("currentFingerprint", {
fingerprintId: integer("id").primaryKey({ autoIncrement: true }), fingerprintId: integer("id").primaryKey({ autoIncrement: true }),
@@ -966,9 +912,7 @@ export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", {
codeHash: text("codeHash").notNull() codeHash: text("codeHash").notNull()
}); });
export const sessions = sqliteTable( export const sessions = sqliteTable("session", {
"session",
{
sessionId: text("id").primaryKey(), sessionId: text("id").primaryKey(),
userId: text("userId") userId: text("userId")
.notNull() .notNull()
@@ -978,9 +922,7 @@ export const sessions = sqliteTable(
deviceAuthUsed: integer("deviceAuthUsed", { mode: "boolean" }) deviceAuthUsed: integer("deviceAuthUsed", { mode: "boolean" })
.notNull() .notNull()
.default(false) .default(false)
}, });
(table) => [index("idx_sessions_userId").on(table.userId)]
);
export const newtSessions = sqliteTable("newtSession", { export const newtSessions = sqliteTable("newtSession", {
sessionId: text("id").primaryKey(), sessionId: text("id").primaryKey(),
@@ -998,9 +940,7 @@ export const olmSessions = sqliteTable("clientSession", {
expiresAt: integer("expiresAt").notNull() expiresAt: integer("expiresAt").notNull()
}); });
export const userOrgs = sqliteTable( export const userOrgs = sqliteTable("userOrgs", {
"userOrgs",
{
userId: text("userId") userId: text("userId")
.notNull() .notNull()
.references(() => users.userId, { onDelete: "cascade" }), .references(() => users.userId, { onDelete: "cascade" }),
@@ -1009,19 +949,12 @@ export const userOrgs = sqliteTable(
onDelete: "cascade" onDelete: "cascade"
}) })
.notNull(), .notNull(),
isOwner: integer("isOwner", { mode: "boolean" }) isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
.notNull()
.default(false),
autoProvisioned: integer("autoProvisioned", { autoProvisioned: integer("autoProvisioned", {
mode: "boolean" mode: "boolean"
}).default(false), }).default(false),
pamUsername: text("pamUsername") // cleaned username for ssh and such 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", { export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
codeId: integer("id").primaryKey({ autoIncrement: true }), codeId: integer("id").primaryKey({ autoIncrement: true }),
@@ -1049,9 +982,7 @@ export const actions = sqliteTable("actions", {
description: text("description") description: text("description")
}); });
export const roles = sqliteTable( export const roles = sqliteTable("roles", {
"roles",
{
roleId: integer("roleId").primaryKey({ autoIncrement: true }), roleId: integer("roleId").primaryKey({ autoIncrement: true }),
orgId: text("orgId") orgId: text("orgId")
.references(() => orgs.orgId, { .references(() => orgs.orgId, {
@@ -1066,13 +997,11 @@ export const roles = sqliteTable(
}).default(false), }).default(false),
sshSudoMode: text("sshSudoMode").default("full"), // "none" | "full" | "commands" sshSudoMode: text("sshSudoMode").default("full"), // "none" | "full" | "commands"
sshSudoCommands: text("sshSudoCommands").default("[]"), sshSudoCommands: text("sshSudoCommands").default("[]"),
sshCreateHomeDir: integer("sshCreateHomeDir", { sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default(
mode: "boolean" true
}).default(true), ),
sshUnixGroups: text("sshUnixGroups").default("[]") sshUnixGroups: text("sshUnixGroups").default("[]")
}, });
(table) => [index("idx_roles_orgId").on(table.orgId)]
);
export const userOrgRoles = sqliteTable( export const userOrgRoles = sqliteTable(
"userOrgRoles", "userOrgRoles",
@@ -1999,6 +1928,91 @@ 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", { export const certificates = sqliteTable("certificates", {
certId: integer("certId").primaryKey({ autoIncrement: true }), certId: integer("certId").primaryKey({ autoIncrement: true }),
domain: text("domain").notNull().unique(), domain: text("domain").notNull().unique(),
@@ -2109,6 +2123,7 @@ export type AiModel = InferSelectModel<typeof aiModels>;
export type AiBudget = InferSelectModel<typeof aiBudgets>; export type AiBudget = InferSelectModel<typeof aiBudgets>;
export type AiUsageRecord = InferSelectModel<typeof aiUsageRecords>; export type AiUsageRecord = InferSelectModel<typeof aiUsageRecords>;
export type AiBudgetBreachEvent = InferSelectModel<typeof aiBudgetBreachEvents>; export type AiBudgetBreachEvent = InferSelectModel<typeof aiBudgetBreachEvents>;
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
export type ResourceAiProvider = InferSelectModel<typeof resourceAiProviders>; export type ResourceAiProvider = InferSelectModel<typeof resourceAiProviders>;
export type SiteResourceAiProvider = InferSelectModel< export type SiteResourceAiProvider = InferSelectModel<
typeof siteResourceAiProviders typeof siteResourceAiProviders
+1 -1
View File
@@ -16,7 +16,7 @@ import { registry, openApiTags } from "./openApi";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { APP_PATH } from "./lib/consts"; import { APP_PATH } from "./lib/consts";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
import { z } from "zod"; import { z } from "zod";
const dev = process.env.ENVIRONMENT !== "prod"; const dev = process.env.ENVIRONMENT !== "prod";
-12
View File
@@ -121,13 +121,6 @@ export async function applyBlueprint({
(hc) => hc.targetId === target.targetId (hc) => hc.targetId === target.targetId
); );
// 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)) { if (["http", "tcp", "udp"].includes(target.mode)) {
await addProxyTargets( await addProxyTargets(
site.newt.newtId, site.newt.newtId,
@@ -149,11 +142,6 @@ export async function applyBlueprint({
site.newt.version site.newt.version
); );
} }
} catch (e) {
logger.error(
`Failed to push target ${target.targetId} to newt on site ${site.sites.siteId}. Error: ${e}`
);
}
} }
} }
} }
-41
View File
@@ -1,41 +0,0 @@
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];
}
+35 -22
View File
@@ -11,14 +11,15 @@ import {
siteNetworks, siteNetworks,
siteResources, siteResources,
Transaction, Transaction,
userOrgs,
users,
userSiteResources, userSiteResources,
networks networks
} from "@server/db"; } from "@server/db";
import { sites } from "@server/db"; import { sites } from "@server/db";
import { eq, and, ne, inArray, isNotNull } from "drizzle-orm"; import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
import { Config } from "./types"; import { Config } from "./types";
import { getOrCreateLabelIds, syncSiteResourceLabels } from "./labels"; import { getOrCreateLabelIds, syncSiteResourceLabels } from "./labels";
import { resolveOrgUserIds } from "./findOrgUser";
import logger from "@server/logger"; import logger from "@server/logger";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole"; import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { getNextAvailableAliasAddress } from "../ip"; import { getNextAvailableAliasAddress } from "../ip";
@@ -388,23 +389,29 @@ export async function updatePrivateResources(
.where(eq(userSiteResources.siteResourceId, siteResourceId)); .where(eq(userSiteResources.siteResourceId, siteResourceId));
if (resourceData.users.length > 0) { if (resourceData.users.length > 0) {
const userIds = await resolveOrgUserIds( // get userIds from username
trx, const usersToUpdate = await trx
orgId, .select()
resourceData.users .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)
)
); );
if (userIds.length > 0) { const userIds = usersToUpdate.map((user) => user.user.userId);
await trx await trx
.insert(userSiteResources) .insert(userSiteResources)
.values( .values(
userIds.map((userId) => ({ userIds.map((userId) => ({ userId, siteResourceId }))
userId,
siteResourceId
}))
); );
} }
}
// Get all admin role IDs for this org to exclude from deletion // Get all admin role IDs for this org to exclude from deletion
const adminRoles = await trx const adminRoles = await trx
@@ -714,23 +721,29 @@ export async function updatePrivateResources(
} }
if (resourceData.users.length > 0) { if (resourceData.users.length > 0) {
const userIds = await resolveOrgUserIds( // get userIds from username
trx, const usersToUpdate = await trx
orgId, .select()
resourceData.users .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)
)
); );
if (userIds.length > 0) { const userIds = usersToUpdate.map((user) => user.user.userId);
await trx await trx
.insert(userSiteResources) .insert(userSiteResources)
.values( .values(
userIds.map((userId) => ({ userIds.map((userId) => ({ userId, siteResourceId }))
userId,
siteResourceId
}))
); );
} }
}
if (resourceData.machines.length > 0) { if (resourceData.machines.length > 0) {
// get clientIds from niceIds // get clientIds from niceIds
+29 -22
View File
@@ -46,12 +46,11 @@ import { encrypt } from "@server/lib/crypto";
import logger from "@server/logger"; import logger from "@server/logger";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole"; import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { pickPort } from "@server/routers/target/helpers"; import { pickPort } from "@server/routers/target/helpers";
import { and, asc, eq, isNotNull, ne } from "drizzle-orm"; import { and, asc, eq, isNotNull, ne, or } from "drizzle-orm";
import { tierMatrix } from "../billing/tierMatrix"; import { tierMatrix } from "../billing/tierMatrix";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { Config, isTargetsOnlyResource, TargetData } from "./types"; import { Config, isTargetsOnlyResource, TargetData } from "./types";
import { getOrCreateLabelIds, syncResourceLabels } from "./labels"; import { getOrCreateLabelIds, syncResourceLabels } from "./labels";
import { findOrgUsersByIdentifier } from "./findOrgUser";
import { LimitId } from "../billing"; import { LimitId } from "../billing";
import { usageService } from "../billing/usageService"; import { usageService } from "../billing/usageService";
import { syncInferenceAiConfig } from "./aiProviders"; import { syncInferenceAiConfig } from "./aiProviders";
@@ -1564,29 +1563,33 @@ async function syncUserResources(
.where(eq(userResources.resourceId, resourceId)); .where(eq(userResources.resourceId, resourceId));
for (const username of ssoUsers) { for (const username of ssoUsers) {
const matchedUsers = await findOrgUsersByIdentifier( const [user] = await trx
trx, .select()
orgId, .from(users)
username .innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
); .where(
and(
or(eq(users.username, username), eq(users.email, username)),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
if (matchedUsers.length === 0) { if (!user) {
throw new Error(`User not found: ${username} in org ${orgId}`); throw new Error(`User not found: ${username} in org ${orgId}`);
} }
for (const user of matchedUsers) {
const existingUserResource = existingUserResources.find( const existingUserResource = existingUserResources.find(
(rr) => rr.userId === user.userId (rr) => rr.userId === user.user.userId
); );
if (!existingUserResource) { if (!existingUserResource) {
await trx.insert(userResources).values({ await trx.insert(userResources).values({
userId: user.userId, userId: user.user.userId,
resourceId: resourceId resourceId: resourceId
}); });
} }
} }
}
for (const existingUserResource of existingUserResources) { for (const existingUserResource of existingUserResources) {
const [user] = await trx const [user] = await trx
@@ -1952,29 +1955,33 @@ async function syncUserPolicies(
.where(eq(userPolicies.resourcePolicyId, policyId)); .where(eq(userPolicies.resourcePolicyId, policyId));
for (const username of ssoUsers) { for (const username of ssoUsers) {
const matchedUsers = await findOrgUsersByIdentifier( const [user] = await trx
trx, .select()
orgId, .from(users)
username .innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
); .where(
and(
or(eq(users.username, username), eq(users.email, username)),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
if (matchedUsers.length === 0) { if (!user) {
throw new Error(`User not found: ${username} in org ${orgId}`); throw new Error(`User not found: ${username} in org ${orgId}`);
} }
for (const user of matchedUsers) {
const existingUserPolicy = existingUserPoliciesList.find( const existingUserPolicy = existingUserPoliciesList.find(
(up) => up.userId === user.userId (up) => up.userId === user.user.userId
); );
if (!existingUserPolicy) { if (!existingUserPolicy) {
await trx.insert(userPolicies).values({ await trx.insert(userPolicies).values({
userId: user.userId, userId: user.user.userId,
resourcePolicyId: policyId resourcePolicyId: policyId
}); });
} }
} }
}
for (const existingUserPolicy of existingUserPoliciesList) { for (const existingUserPolicy of existingUserPoliciesList) {
const [user] = await trx const [user] = await trx
+28 -21
View File
@@ -13,7 +13,7 @@ import {
userPolicies, userPolicies,
users users
} from "@server/db"; } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and, or } from "drizzle-orm";
import { Config, ResourcePolicyData } from "./types"; import { Config, ResourcePolicyData } from "./types";
import logger from "@server/logger"; import logger from "@server/logger";
import { getUniqueResourcePolicyName } from "@server/db/names"; import { getUniqueResourcePolicyName } from "@server/db/names";
@@ -22,7 +22,6 @@ import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix"; import { tierMatrix } from "../billing/tierMatrix";
import { findOrgUsersByIdentifier } from "./findOrgUser";
export type ResourcePoliciesResults = { export type ResourcePoliciesResults = {
resourcePolicyId: number; resourcePolicyId: number;
@@ -467,32 +466,36 @@ async function syncUserPolicies(
.where(eq(userPolicies.resourcePolicyId, policyId)); .where(eq(userPolicies.resourcePolicyId, policyId));
for (const username of ssoUsers) { for (const username of ssoUsers) {
const matchedUsers = await findOrgUsersByIdentifier( const [user] = await trx
trx, .select()
orgId, .from(users)
username .innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
); .where(
and(
or(eq(users.username, username), eq(users.email, username)),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
if (matchedUsers.length === 0) { if (!user) {
logger.warn( logger.warn(
`User '${username}' not found in org '${orgId}', skipping` `User '${username}' not found in org '${orgId}', skipping`
); );
continue; continue;
} }
for (const user of matchedUsers) {
const alreadyExists = existingUserPolicies.some( const alreadyExists = existingUserPolicies.some(
(up) => up.userId === user.userId (up) => up.userId === user.user.userId
); );
if (!alreadyExists) { if (!alreadyExists) {
await trx.insert(userPolicies).values({ await trx.insert(userPolicies).values({
userId: user.userId, userId: user.user.userId,
resourcePolicyId: policyId resourcePolicyId: policyId
}); });
} }
} }
}
// Remove users no longer in the list // Remove users no longer in the list
for (const existingUserPolicy of existingUserPolicies) { for (const existingUserPolicy of existingUserPolicies) {
@@ -533,27 +536,31 @@ async function addUserPolicies(
trx: Transaction trx: Transaction
) { ) {
for (const username of ssoUsers) { for (const username of ssoUsers) {
const matchedUsers = await findOrgUsersByIdentifier( const [user] = await trx
trx, .select()
orgId, .from(users)
username .innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
); .where(
and(
or(eq(users.username, username), eq(users.email, username)),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
if (matchedUsers.length === 0) { if (!user) {
logger.warn( logger.warn(
`User '${username}' not found in org '${orgId}', skipping` `User '${username}' not found in org '${orgId}', skipping`
); );
continue; continue;
} }
for (const user of matchedUsers) {
await trx.insert(userPolicies).values({ await trx.insert(userPolicies).values({
userId: user.userId, userId: user.user.userId,
resourcePolicyId: policyId resourcePolicyId: policyId
}); });
} }
} }
}
async function syncWhitelistPolicyUsers( async function syncWhitelistPolicyUsers(
policyId: number, policyId: number,
+3 -29
View File
@@ -29,34 +29,8 @@ export const SiteSchema = z.object({
"docker-socket-enabled": z.boolean().optional().default(true) "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({ export const TargetHealthCheckSchema = z.object({
hostname: healthCheckHostnameSchema, hostname: z.string(),
port: z.int().min(1).max(65535), port: z.int().min(1).max(65535),
enabled: z.boolean().optional().default(true), enabled: z.boolean().optional().default(true),
path: z.string().optional().default("/"), path: z.string().optional().default("/"),
@@ -127,7 +101,7 @@ export const AuthSchema = z.object({
export const RuleSchema = z export const RuleSchema = z
.object({ .object({
action: z.enum(["allow", "deny", "pass"]), action: z.enum(["allow", "deny", "pass"]),
match: z.enum(["cidr", "path", "ip", "country", "country_is_not", "asn", "region"]), match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
value: z.coerce.string(), value: z.coerce.string(),
priority: z.int().optional(), priority: z.int().optional(),
enabled: z.boolean().optional().default(true) enabled: z.boolean().optional().default(true)
@@ -162,7 +136,7 @@ export const RuleSchema = z
) )
.refine( .refine(
(rule) => { (rule) => {
if (rule.match === "country" || rule.match === "country_is_not") { if (rule.match === "country") {
if (!hasMaxmindCountryDb) { if (!hasMaxmindCountryDb) {
return false; return false;
} }
-5
View File
@@ -112,11 +112,6 @@ export class Config {
? "true" ? "true"
: "false"; : "false";
process.env.FLAGS_DISABLE_VIRTUAL_API_KEYS_UI = parsedConfig.flags
?.disable_virtual_api_keys_ui
? "true"
: "false";
this.rawConfig = parsedConfig; this.rawConfig = parsedConfig;
} }
+11 -6
View File
@@ -31,6 +31,7 @@ export async function validateAndConstructDomain(
subdomain?: string | null subdomain?: string | null
): Promise<DomainValidationResult> { ): Promise<DomainValidationResult> {
try { try {
// Query domain with organization access check
const [domainRes] = await db const [domainRes] = await db
.select() .select()
.from(domains) .from(domains)
@@ -41,10 +42,6 @@ export async function validateAndConstructDomain(
eq(orgDomains.orgId, orgId), eq(orgDomains.orgId, orgId),
eq(orgDomains.domainId, domainId) eq(orgDomains.domainId, domainId)
) )
)
.leftJoin(
domainNamespaces,
eq(domainNamespaces.domainId, domainId)
); );
// Check if domain exists // Check if domain exists
@@ -55,7 +52,7 @@ export async function validateAndConstructDomain(
}; };
} }
if (!domainRes.orgDomains && !domainRes.domainNamespaces) { if (!domainRes.orgDomains) {
return { return {
success: false, success: false,
error: `Organization does not have access to domain with ID ${domainId}` error: `Organization does not have access to domain with ID ${domainId}`
@@ -86,12 +83,20 @@ export async function validateAndConstructDomain(
} }
// Wildcard subdomains are not allowed on namespace (provided/free) domains // Wildcard subdomains are not allowed on namespace (provided/free) domains
if (isWildcard && domainRes.domainNamespaces) { if (isWildcard) {
const [namespaceDomain] = await db
.select()
.from(domainNamespaces)
.where(eq(domainNamespaces.domainId, domainId))
.limit(1);
if (namespaceDomain) {
return { return {
success: false, success: false,
error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead." error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead."
}; };
} }
}
if ( if (
isWildcard && isWildcard &&
+1 -2
View File
@@ -1,5 +1,5 @@
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
import { configFilePath1, configFilePath2 } from "./consts"; import { configFilePath1, configFilePath2 } from "./consts";
import { z } from "zod"; import { z } from "zod";
import stoi from "./stoi"; import stoi from "./stoi";
@@ -442,7 +442,6 @@ export const configSchema = z
disable_config_managed_domains: z.boolean().optional(), disable_config_managed_domains: z.boolean().optional(),
disable_product_help_banners: z.boolean().optional(), disable_product_help_banners: z.boolean().optional(),
disable_enterprise_features: z.boolean().optional(), disable_enterprise_features: z.boolean().optional(),
disable_virtual_api_keys_ui: z.boolean().optional(),
enable_acme_cert_sync: z.boolean().optional().default(true), enable_acme_cert_sync: z.boolean().optional().default(true),
disable_private_http_placeholder: z disable_private_http_placeholder: z
.boolean() .boolean()
+1 -50
View File
@@ -3,8 +3,6 @@ import config from "./config";
import { getHostMeta } from "./hostMeta"; import { getHostMeta } from "./hostMeta";
import logger from "@server/logger"; import logger from "@server/logger";
import { import {
aiProviders,
aiUsageRecords,
alertRules, alertRules,
apiKeys, apiKeys,
blueprints, blueprints,
@@ -13,16 +11,7 @@ import {
siteResources siteResources
} from "@server/db"; } from "@server/db";
import { sites, users, orgs, resources, clients, idp } from "@server/db"; import { sites, users, orgs, resources, clients, idp } from "@server/db";
import { import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm";
eq,
count,
countDistinct,
notInArray,
and,
isNotNull,
isNull,
gte
} from "drizzle-orm";
import { APP_VERSION } from "./consts"; import { APP_VERSION } from "./consts";
import crypto from "crypto"; import crypto from "crypto";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
@@ -183,25 +172,6 @@ class TelemetryClient {
.select({ count: count() }) .select({ count: count() })
.from(blueprints); .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 supporterKey = config.getSupporterData();
const allPrivateResources = await db.select().from(siteResources); const allPrivateResources = await db.select().from(siteResources);
@@ -212,7 +182,6 @@ class TelemetryClient {
let numPrivResourceCidr = 0; let numPrivResourceCidr = 0;
let numPrivResourceHttp = 0; let numPrivResourceHttp = 0;
let numPrivResourceSsh = 0; let numPrivResourceSsh = 0;
let numPrivResourceInference = 0;
for (const res of allPrivateResources) { for (const res of allPrivateResources) {
if (res.mode === "host") { if (res.mode === "host") {
numPrivResourceHosts += 1; numPrivResourceHosts += 1;
@@ -222,8 +191,6 @@ class TelemetryClient {
numPrivResourceHttp += 1; numPrivResourceHttp += 1;
} else if (res.mode === "ssh") { } else if (res.mode === "ssh") {
numPrivResourceSsh += 1; numPrivResourceSsh += 1;
} else if (res.mode === "inference") {
numPrivResourceInference += 1;
} }
if (res.alias) { if (res.alias) {
@@ -244,11 +211,6 @@ class TelemetryClient {
numPrivateResourceCidr: numPrivResourceCidr, numPrivateResourceCidr: numPrivResourceCidr,
numPrivateResourceHttp: numPrivResourceHttp, numPrivateResourceHttp: numPrivResourceHttp,
numPrivateResourceSsh: numPrivResourceSsh, numPrivateResourceSsh: numPrivResourceSsh,
numPrivateResourceInference: numPrivResourceInference,
numAiProviders: aiProvidersCount.count,
numOrgsWithAiProviders: orgsWithAiProviders.count,
numAiUsageRecordsRecent: aiUsageRecordsRecent.count,
numOrgsWithRecentAiUsage: orgsWithRecentAiUsage.count,
numAlertRules: numAlertRules.count, numAlertRules: numAlertRules.count,
numUserDevices: userDevicesCount.count, numUserDevices: userDevicesCount.count,
numMachineClients: machineClients.count, numMachineClients: machineClients.count,
@@ -361,17 +323,6 @@ class TelemetryClient {
num_resources_non_http: stats.resources.filter( num_resources_non_http: stats.resources.filter(
(r) => r.mode !== "http" (r) => r.mode !== "http"
).length, ).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") num_newt_sites: stats.sites.filter((s) => s.type === "newt")
.length, .length,
num_local_sites: stats.sites.filter( num_local_sites: stats.sites.filter(
@@ -68,11 +68,6 @@ export async function verifyApiKeyAccessTokenAccess(
); );
} }
if (apiKey.isRoot) {
// Root keys can access any access token in any org
return next();
}
if (!resource.orgId) { if (!resource.orgId) {
return next( return next(
createHttpError( createHttpError(
+1 -1
View File
@@ -12,7 +12,7 @@
*/ */
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
import { privateConfigFilePath1 } from "@server/lib/consts"; import { privateConfigFilePath1 } from "@server/lib/consts";
import { z } from "zod"; import { z } from "zod";
import { colorsSchema } from "@server/lib/colorsSchema"; import { colorsSchema } from "@server/lib/colorsSchema";
+6 -8
View File
@@ -158,7 +158,7 @@ class RedisManager {
this.writeClient = new Redis({ this.writeClient = new Redis({
...masterConfig, ...masterConfig,
enableReadyCheck: false, enableReadyCheck: false,
maxRetriesPerRequest: 50, maxRetriesPerRequest: 3,
keepAlive: 30000, keepAlive: 30000,
connectTimeout: this.connectionTimeout, connectTimeout: this.connectionTimeout,
commandTimeout: this.commandTimeout commandTimeout: this.commandTimeout
@@ -169,7 +169,7 @@ class RedisManager {
this.readClient = new Redis({ this.readClient = new Redis({
...replicaConfig!, ...replicaConfig!,
enableReadyCheck: false, enableReadyCheck: false,
maxRetriesPerRequest: 50, maxRetriesPerRequest: 3,
keepAlive: 30000, keepAlive: 30000,
connectTimeout: this.connectionTimeout, connectTimeout: this.connectionTimeout,
commandTimeout: this.commandTimeout commandTimeout: this.commandTimeout
@@ -186,7 +186,7 @@ class RedisManager {
this.publisher = new Redis({ this.publisher = new Redis({
...masterConfig, ...masterConfig,
enableReadyCheck: false, enableReadyCheck: false,
maxRetriesPerRequest: 50, maxRetriesPerRequest: 3,
keepAlive: 30000, keepAlive: 30000,
connectTimeout: this.connectionTimeout, connectTimeout: this.connectionTimeout,
commandTimeout: this.commandTimeout commandTimeout: this.commandTimeout
@@ -196,7 +196,7 @@ class RedisManager {
this.subscriber = new Redis({ this.subscriber = new Redis({
...(this.hasReplicas ? replicaConfig! : masterConfig), ...(this.hasReplicas ? replicaConfig! : masterConfig),
enableReadyCheck: false, enableReadyCheck: false,
maxRetriesPerRequest: 50, maxRetriesPerRequest: 3,
keepAlive: 30000, keepAlive: 30000,
connectTimeout: this.connectionTimeout, connectTimeout: this.connectionTimeout,
commandTimeout: this.commandTimeout commandTimeout: this.commandTimeout
@@ -901,9 +901,7 @@ class RegionalRedisManager {
// if the configured host doesn't match that pattern (e.g. local dev), // 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. // in which case callers should fall back to the primary for reads.
private getReplicaHost(primaryHost: string): string | null { private getReplicaHost(primaryHost: string): string | null {
const match = primaryHost.match( const match = primaryHost.match(/^redis\.([^.]+)\.svc\.cluster\.local$/);
/^redis\.([^.]+)\.svc\.cluster\.local$/
);
if (!match) return null; if (!match) return null;
const namespace = match[1]; const namespace = match[1];
return `redis-1.redis-headless.${namespace}.svc.cluster.local`; return `redis-1.redis-headless.${namespace}.svc.cluster.local`;
@@ -914,7 +912,7 @@ class RegionalRedisManager {
const baseOpts = { const baseOpts = {
...cfg, ...cfg,
enableReadyCheck: false, enableReadyCheck: false,
maxRetriesPerRequest: 50, maxRetriesPerRequest: 3,
keepAlive: 10000, keepAlive: 10000,
connectTimeout: this.connectionTimeout, connectTimeout: this.connectionTimeout,
commandTimeout: this.commandTimeout commandTimeout: this.commandTimeout
+13 -9
View File
@@ -149,8 +149,12 @@ LQIDAQAB
} }
// Count used sites and users for license comparison // Count used sites and users for license comparison
const [siteCountRes] = await db.select({ value: count() }).from(sites); const [siteCountRes] = await db
const [userCountRes] = await db.select({ value: count() }).from(users); .select({ value: count() })
.from(sites);
const [userCountRes] = await db
.select({ value: count() })
.from(users);
const status: LicenseStatus = { const status: LicenseStatus = {
hostId: this.hostMeta.hostMetaId, hostId: this.hostMeta.hostMetaId,
@@ -272,13 +276,10 @@ LQIDAQAB
logger.error( logger.error(
`Allowing failure. Will retry one more time at next run interval.` `Allowing failure. Will retry one more time at next run interval.`
); );
// Fall back to last known good status if we have // return last known good status
// one cached; otherwise return the freshly built return this.statusCache.get(
// status (with defaults) rather than undefined.
const lastKnownStatus = this.statusCache.get(
this.statusKey this.statusKey
) as LicenseStatus | undefined; ) as LicenseStatus;
return lastKnownStatus ?? status;
} else { } else {
// Subsequent failures: fail abruptly // Subsequent failures: fail abruptly
throw e; throw e;
@@ -367,7 +368,10 @@ LQIDAQAB
} }
// Only consider quantity if defined and >= 0 (quantity = users, quantity_2 = sites) // 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 =
(status.maxSites ?? 0) + cached.quantity_2; (status.maxSites ?? 0) + cached.quantity_2;
} }
@@ -88,27 +88,7 @@ export const queryAccessAuditLogsQuery = z.object({
.optional() .optional()
.default("0") .default("0")
.transform(Number) .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({ export const queryAccessAuditLogsParams = z.object({
@@ -154,9 +134,6 @@ function getWhere(data: Q) {
data.type ? eq(accessAuditLog.type, data.type) : undefined, data.type ? eq(accessAuditLog.type, data.type) : undefined,
data.action !== undefined data.action !== undefined
? eq(accessAuditLog.action, data.action) ? eq(accessAuditLog.action, data.action)
: undefined,
data.ip && data.ip.length > 0
? inArray(accessAuditLog.ip, data.ip)
: undefined : undefined
); );
} }
+4 -2
View File
@@ -16,10 +16,12 @@ import {
handleRemoteExitNodePingMessage handleRemoteExitNodePingMessage
} from "#private/routers/remoteExitNode"; } from "#private/routers/remoteExitNode";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { handleConnectionLogMessage } from "#private/routers/newt"; import {
handleConnectionLogMessage,
} from "#private/routers/newt";
export const messageHandlers: Record<string, MessageHandler> = { export const messageHandlers: Record<string, MessageHandler> = {
"remoteExitNode/register": handleRemoteExitNodeRegisterMessage, "remoteExitNode/register": handleRemoteExitNodeRegisterMessage,
"remoteExitNode/ping": handleRemoteExitNodePingMessage, "remoteExitNode/ping": handleRemoteExitNodePingMessage,
"newt/access-log": handleConnectionLogMessage "newt/access-log": handleConnectionLogMessage,
}; };
+1 -2
View File
@@ -104,8 +104,7 @@ const processMessage = async (
const handler = messageHandlers[message.type]; const handler = messageHandlers[message.type];
if (!handler) { if (!handler) {
logger.debug(`No handler found for message type: ${message.type}`); throw new Error(`Unsupported message type: ${message.type}`);
return;
} }
const response = await handler({ const response = await handler({
@@ -81,27 +81,7 @@ export const queryAccessAuditLogsQuery = z.strictObject({
.optional() .optional()
.default("0") .default("0")
.transform(Number) .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({ export const queryRequestAuditLogsParams = z.object({
@@ -146,9 +126,6 @@ function getWhere(data: Q) {
data.path ? eq(requestAuditLog.path, data.path) : undefined, data.path ? eq(requestAuditLog.path, data.path) : undefined,
data.action !== undefined data.action !== undefined
? eq(requestAuditLog.action, data.action) ? eq(requestAuditLog.action, data.action)
: undefined,
data.ip && data.ip.length > 0
? inArray(requestAuditLog.ip, data.ip)
: undefined : undefined
); );
} }
+2 -7
View File
@@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode";
import { response } from "@server/lib/response"; import { response } from "@server/lib/response";
import { db } from "@server/db"; import { db } from "@server/db";
import { passwordResetTokens, users } from "@server/db"; import { passwordResetTokens, users } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { alphabet, generateRandomString, sha256 } from "oslo/crypto"; import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
import { createDate } from "oslo"; import { createDate } from "oslo";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -49,12 +49,7 @@ export async function requestPasswordReset(
const existingUser = await db const existingUser = await db
.select() .select()
.from(users) .from(users)
.where( .where(eq(users.email, email));
and(
eq(users.email, email),
eq(users.type, UserType.Internal)
)
);
if (!existingUser || !existingUser.length) { if (!existingUser || !existingUser.length) {
await randomDelay(2000); await randomDelay(2000);
+4 -9
View File
@@ -533,23 +533,18 @@ export async function startAuthentication(
// If email is provided, get security keys for that specific user // If email is provided, get security keys for that specific user
if (email) { if (email) {
const matchingUsers = await db const [user] = await db
.select() .select()
.from(users) .from(users)
.where( .where(eq(users.email, email))
and( .limit(1);
eq(users.email, email.toLowerCase()),
eq(users.type, UserType.Internal)
)
);
if (matchingUsers.length !== 1) { if (!user || user.type !== UserType.Internal) {
return next( return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid credentials") createHttpError(HttpCode.BAD_REQUEST, "Invalid credentials")
); );
} }
const user = matchingUsers[0];
userId = user.userId; userId = user.userId;
const userSecurityKeys = await db const userSecurityKeys = await db
+29 -40
View File
@@ -42,62 +42,54 @@ export async function setServerAdmin(
const { email, password, setupToken } = parsedBody.data; const { email, password, setupToken } = parsedBody.data;
const passwordHash = await hashPassword(password); // Validate setup token
const userId = generateId(15); const [validToken] = await db
.select()
await db.transaction(async (trx) => { .from(setupTokens)
const consumed = await trx
.update(setupTokens)
.set({
used: true,
dateUsed: moment().toISOString()
})
.where( .where(
and( and(
eq(setupTokens.token, setupToken), eq(setupTokens.token, setupToken),
eq(setupTokens.used, false) eq(setupTokens.used, false)
) )
) );
.returning({ tokenId: setupTokens.tokenId });
if (!consumed.length) { if (!validToken) {
throw createHttpError( return next(
createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
"Invalid setup token" "Invalid or expired setup token"
)
); );
} }
const [existingAdmin] = await trx const [existing] = await db
.select({ userId: users.userId }) .select()
.from(users) .from(users)
.where(eq(users.serverAdmin, true)) .where(eq(users.serverAdmin, true));
.limit(1);
if (existingAdmin) { if (existing) {
throw createHttpError( return next(
createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
"Server admin already exists" "Server admin already exists"
)
); );
} }
const [existingUser] = await trx const passwordHash = await hashPassword(password);
.select({ userId: users.userId }) const userId = generateId(15);
.from(users)
.where(
and(
eq(users.email, email),
eq(users.type, UserType.Internal)
)
)
.limit(1);
if (existingUser) { await db.transaction(async (trx) => {
throw createHttpError( // Mark the token as used
HttpCode.BAD_REQUEST, await trx
"A user with that email address already exists" .update(setupTokens)
); .set({
} used: true,
dateUsed: moment().toISOString()
})
.where(eq(setupTokens.tokenId, validToken.tokenId));
// Create the server admin user
await trx.insert(users).values({ await trx.insert(users).values({
userId: userId, userId: userId,
email: email, email: email,
@@ -119,9 +111,6 @@ export async function setServerAdmin(
status: HttpCode.OK status: HttpCode.OK
}); });
} catch (e) { } catch (e) {
if (createHttpError.isHttpError(e)) {
return next(e);
}
logger.error(e); logger.error(e);
return next( return next(
createHttpError( createHttpError(
+1 -1
View File
@@ -48,7 +48,7 @@ export async function validateSetupToken(
return response<ValidateSetupTokenResponse>(res, { return response<ValidateSetupTokenResponse>(res, {
data: { data: {
valid: false, valid: false,
message: "Invalid setup token" message: "Invalid or expired setup token"
}, },
success: true, success: true,
error: false, error: false,
+6
View File
@@ -87,6 +87,12 @@ authenticated.get("/org/checkId", org.checkId);
authenticated.put("/org", getUserOrgs, org.createOrg); authenticated.put("/org", getUserOrgs, org.createOrg);
authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs); 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("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);
authenticated.get( authenticated.get(
@@ -984,7 +984,7 @@ async function listLabelGroups(
).length; ).length;
} }
const groups: LauncherGroup[] = Array.from(labelCountMap.values()).map( let groups: LauncherGroup[] = Array.from(labelCountMap.values()).map(
(row) => ({ (row) => ({
groupKey: String(row.labelId), groupKey: String(row.labelId),
name: row.name, name: row.name,
+1 -1
View File
@@ -80,7 +80,7 @@ export async function buildSiteConfigurationForOlmClient(
); );
const siteResourcesBySiteId = new Map<number, SiteResource[]>(); const siteResourcesBySiteId = new Map<number, SiteResource[]>();
const siteResourcesForExitNode = []; let siteResourcesForExitNode = [];
for (const row of allClientSiteResources) { for (const row of allClientSiteResources) {
if (row.siteResource.requiresExitNodeConnection) { if (row.siteResource.requiresExitNodeConnection) {
siteResourcesForExitNode.push(row.siteResource); siteResourcesForExitNode.push(row.siteResource);
-20
View File
@@ -1,20 +0,0 @@
import config from "@server/lib/config";
// Mirrors the optional fields on the olm client's TunnelConfig - any field
// present here overrides the value the olm client is otherwise locally
// configured with; an absent field leaves the client's own config alone.
export type OlmDnsConfig = {
upstreamDns?: string[];
overrideDns?: boolean;
tunnelDns?: boolean;
matchDomains?: string[];
};
export function buildOlmDnsConfig(): OlmDnsConfig | undefined {
return {
upstreamDns: undefined,
overrideDns: undefined,
tunnelDns: undefined,
matchDomains: undefined
};
}
@@ -15,7 +15,6 @@ import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2"; import { sha256 } from "@oslojs/crypto/sha2";
import { getUserDeviceName } from "@server/db/names"; import { getUserDeviceName } from "@server/db/names";
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration"; import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
import { buildOlmDnsConfig } from "./dnsConfig";
import { OlmErrorCodes, sendOlmError } from "./error"; import { OlmErrorCodes, sendOlmError } from "./error";
import { handleFingerprintInsertion } from "./fingerprintingUtils"; import { handleFingerprintInsertion } from "./fingerprintingUtils";
import { build } from "@server/build"; import { build } from "@server/build";
@@ -513,7 +512,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
tunnelIP: `${clientSubnet.split("/")[0]}/${exitNode.address.split("/")[1]}` // we need to use the exit node's subnet mask here because the client will be using the exit node's subnet mask for its routing table so we can address it tunnelIP: `${clientSubnet.split("/")[0]}/${exitNode.address.split("/")[1]}` // we need to use the exit node's subnet mask here because the client will be using the exit node's subnet mask for its routing table so we can address it
} }
: undefined, : undefined,
dnsConfig: buildOlmDnsConfig(),
chainId: chainId chainId: chainId
} }
}, },
+98
View File
@@ -0,0 +1,98 @@
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..."
)
);
}
}
+241
View File
@@ -0,0 +1,241 @@
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..."
)
);
}
}
+2
View File
@@ -9,3 +9,5 @@ export * from "./listOrgs";
export * from "./pickOrgDefaults"; export * from "./pickOrgDefaults";
export * from "./checkOrgUserAccess"; export * from "./checkOrgUserAccess";
export * from "./resetOrgBandwidth"; export * from "./resetOrgBandwidth";
export * from "./adminListOrgs";
export * from "./adminDeleteOrg";
+1 -4
View File
@@ -353,10 +353,7 @@ const setupConnection = async (
const handler = messageHandlers[message.type]; const handler = messageHandlers[message.type];
if (!handler) { if (!handler) {
logger.debug( throw new Error(`Unsupported message type: ${message.type}`);
`No handler found for message type: ${message.type}`
);
return;
} }
const response = await handler({ const response = await handler({
+1 -1
View File
@@ -4,7 +4,7 @@ import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { encrypt } from "@server/lib/crypto"; import { encrypt } from "@server/lib/crypto";
import { generateCA } from "@server/lib/sshCA"; import { generateCA } from "@server/lib/sshCA";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
const version = "1.16.0"; const version = "1.16.0";
+1 -1
View File
@@ -2,7 +2,7 @@ import { db } from "@server/db/pg/driver";
import { APP_PATH, __DIRNAME } from "@server/lib/consts"; import { APP_PATH, __DIRNAME } from "@server/lib/consts";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
import path, { join } from "path"; import path, { join } from "path";
import z from "zod"; import z from "zod";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
+1 -1
View File
@@ -2,7 +2,7 @@ import { db } from "@server/db/pg/driver";
import { APP_PATH } from "@server/lib/consts"; import { APP_PATH } from "@server/lib/consts";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
import path from "path"; import path from "path";
import z from "zod"; import z from "zod";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
+3 -3
View File
@@ -3,7 +3,7 @@ import { db } from "@server/db/pg/driver";
import { APP_PATH } from "@server/lib/consts"; import { APP_PATH } from "@server/lib/consts";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
import path from "path"; import path from "path";
import z from "zod"; import z from "zod";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
@@ -454,14 +454,14 @@ export default async function migration() {
throw new Error(fromZodError(parsedConfig.error).toString()); throw new Error(fromZodError(parsedConfig.error).toString());
} }
traefikConfig.experimental.plugins.badger.version = "v1.7.0"; traefikConfig.experimental.plugins.badger.version = "v1.6.1";
const updatedTraefikYaml = yaml.dump(traefikConfig); const updatedTraefikYaml = yaml.dump(traefikConfig);
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8"); fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
console.log( console.log(
"Updated the version of Badger in your Traefik configuration to v1.7.0" "Updated the version of Badger in your Traefik configuration to v1.6.1"
); );
} catch (e) { } catch (e) {
console.log( console.log(
+1 -1
View File
@@ -2,7 +2,7 @@ import { db } from "@server/db/pg/driver";
import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
const version = "1.6.0"; const version = "1.6.0";
+1 -1
View File
@@ -1,6 +1,6 @@
import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
export default async function migration() { export default async function migration() {
console.log("Running setup script 1.0.0-beta.10..."); console.log("Running setup script 1.0.0-beta.10...");
+1 -1
View File
@@ -2,7 +2,7 @@ import { db } from "../../db/sqlite";
import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
export default async function migration() { export default async function migration() {
console.log("Running setup script 1.0.0-beta.12..."); console.log("Running setup script 1.0.0-beta.12...");
+1 -1
View File
@@ -1,7 +1,7 @@
import { db } from "../../db/sqlite"; import { db } from "../../db/sqlite";
import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { domains, orgDomains, resources } from "@server/db"; import { domains, orgDomains, resources } from "@server/db";
+1 -1
View File
@@ -1,6 +1,6 @@
import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
export default async function migration() { export default async function migration() {
console.log("Running setup script 1.0.0-beta.2..."); console.log("Running setup script 1.0.0-beta.2...");
+1 -1
View File
@@ -1,6 +1,6 @@
import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
export default async function migration() { export default async function migration() {
console.log("Running setup script 1.0.0-beta.3..."); console.log("Running setup script 1.0.0-beta.3...");
+1 -1
View File
@@ -1,6 +1,6 @@
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
import path from "path"; import path from "path";
import { z } from "zod"; import { z } from "zod";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
+1 -1
View File
@@ -1,6 +1,6 @@
import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
export default async function migration() { export default async function migration() {
console.log("Running setup script 1.0.0-beta.6..."); console.log("Running setup script 1.0.0-beta.6...");
+1 -1
View File
@@ -12,7 +12,7 @@ import {
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
import path from "path"; import path from "path";
import { z } from "zod"; import { z } from "zod";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
+1 -1
View File
@@ -1,6 +1,6 @@
import { APP_PATH } from "@server/lib/consts"; import { APP_PATH } from "@server/lib/consts";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
import path from "path"; import path from "path";
import { z } from "zod"; import { z } from "zod";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
+1 -1
View File
@@ -4,7 +4,7 @@ import { generateCA } from "@server/lib/sshCA";
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
const version = "1.16.0"; const version = "1.16.0";
+1 -1
View File
@@ -3,7 +3,7 @@ import Database from "better-sqlite3";
import z from "zod"; import z from "zod";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
import path, { join } from "path"; import path, { join } from "path";
const version = "1.19.0"; const version = "1.19.0";
+1 -1
View File
@@ -2,7 +2,7 @@ import { db } from "../../db/sqlite";
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
import path from "path"; import path from "path";
import { z } from "zod"; import { z } from "zod";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
+1 -1
View File
@@ -1,7 +1,7 @@
import { APP_PATH } from "@server/lib/consts"; import { APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
import path from "path"; import path from "path";
import z from "zod"; import z from "zod";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
+3 -3
View File
@@ -2,7 +2,7 @@ import { build } from "@server/build";
import { APP_PATH } from "@server/lib/consts"; import { APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
import path from "path"; import path from "path";
import z from "zod"; import z from "zod";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
@@ -459,14 +459,14 @@ export default async function migration() {
throw new Error(fromZodError(parsedConfig.error).toString()); throw new Error(fromZodError(parsedConfig.error).toString());
} }
traefikConfig.experimental.plugins.badger.version = "v1.7.0"; traefikConfig.experimental.plugins.badger.version = "v1.6.1";
const updatedTraefikYaml = yaml.dump(traefikConfig); const updatedTraefikYaml = yaml.dump(traefikConfig);
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8"); fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
console.log( console.log(
"Updated the version of Badger in your Traefik configuration to v1.7.0" "Updated the version of Badger in your Traefik configuration to v1.6.1"
); );
} catch (e) { } catch (e) {
console.log( console.log(
+1 -1
View File
@@ -1,7 +1,7 @@
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
import { encodeBase32LowerCaseNoPadding } from "@oslojs/encoding"; import { encodeBase32LowerCaseNoPadding } from "@oslojs/encoding";
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
+1 -1
View File
@@ -2,7 +2,7 @@ import Database from "better-sqlite3";
import path from "path"; import path from "path";
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
const version = "1.5.0"; const version = "1.5.0";
const location = path.join(APP_PATH, "db", "db.sqlite"); const location = path.join(APP_PATH, "db", "db.sqlite");
+1 -1
View File
@@ -1,7 +1,7 @@
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import yaml from "js-yaml";
import path from "path"; import path from "path";
const version = "1.6.0"; const version = "1.6.0";
+1 -6
View File
@@ -76,11 +76,6 @@ export default async function KeysPage(props: KeysPageProps) {
redirect("/"); redirect("/");
} }
const env = pullEnv();
if (env.flags.disableVirtualApiKeysUi) {
redirect(`/${orgId}`);
}
let keysData: ListMyVirtualApiKeysResponse | null = null; let keysData: ListMyVirtualApiKeysResponse | null = null;
try { try {
const res = await internal.get< const res = await internal.get<
@@ -95,6 +90,7 @@ export default async function KeysPage(props: KeysPageProps) {
redirect(`/${orgId}`); redirect(`/${orgId}`);
} }
const env = pullEnv();
const primaryOrg = orgs.find((o) => o.orgId === orgId)?.isPrimaryOrg; const primaryOrg = orgs.find((o) => o.orgId === orgId)?.isPrimaryOrg;
const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner); const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner);
@@ -110,7 +106,6 @@ export default async function KeysPage(props: KeysPageProps) {
showSidebar={false} showSidebar={false}
launcherMode launcherMode
showViewAsAdmin={isAdminOrOwner} showViewAsAdmin={isAdminOrOwner}
env={env}
> >
<UserVirtualApiKeys orgId={orgId} initialData={keysData} /> <UserVirtualApiKeys orgId={orgId} initialData={keysData} />
</Layout> </Layout>
-1
View File
@@ -83,7 +83,6 @@ export default async function OrgPage(props: OrgPageProps) {
showSidebar={false} showSidebar={false}
launcherMode launcherMode
showViewAsAdmin={isAdminOrOwner} showViewAsAdmin={isAdminOrOwner}
env={env}
> >
{overview && launcherData ? ( {overview && launcherData ? (
<ResourceLauncher <ResourceLauncher
@@ -46,7 +46,6 @@ import { AxiosResponse } from "axios";
import { ListRolesResponse } from "@server/routers/role"; import { ListRolesResponse } from "@server/routers/role";
import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget"; import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget";
import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription"; import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription";
import IdpIdentifierChangeDialog from "@app/components/IdpIdentifierChangeDialog";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { import {
@@ -76,12 +75,6 @@ export default function GeneralPage() {
>([createMappingBuilderRule()]); >([createMappingBuilderRule()]);
const [rawRoleExpression, setRawRoleExpression] = useState(""); const [rawRoleExpression, setRawRoleExpression] = useState("");
const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc"); 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 dashboardRedirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
const [redirectUrl, setRedirectUrl] = useState( const [redirectUrl, setRedirectUrl] = useState(
@@ -191,9 +184,6 @@ export default function GeneralPage() {
const data = res.data.data; const data = res.data.data;
const roleMapping = data.idpOrg.roleMapping; const roleMapping = data.idpOrg.roleMapping;
const idpVariant = data.idpOidcConfig?.variant || "oidc"; const idpVariant = data.idpOidcConfig?.variant || "oidc";
setOriginalIdentifierPath(
data.idpOidcConfig?.identifierPath ?? "sub"
);
setRedirectUrl(res.data.data.redirectUrl); setRedirectUrl(res.data.data.redirectUrl);
// Set the variant // Set the variant
@@ -388,56 +378,18 @@ export default function GeneralPage() {
}; };
} }
const nextIdentifierPath = const res = await api.post(
variant === "oidc" `/org/${orgId}/idp/${idpId}/oidc`,
? (data as OidcFormValues).identifierPath payload
: undefined; );
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 (res.status === 200) {
if (typeof payload.identifierPath === "string") {
setOriginalIdentifierPath(payload.identifierPath);
}
toast({ toast({
title: t("success"), title: t("success"),
description: t("idpUpdatedDescription") description: t("idpUpdatedDescription")
}); });
router.refresh(); router.refresh();
} }
}
async function confirmIdentifierChange() {
if (!pendingPayload) {
return;
}
setLoading(true);
try {
await persistIdp(pendingPayload);
setPendingPayload(null);
} catch (e) { } catch (e) {
toast({ toast({
title: t("error"), title: t("error"),
@@ -455,16 +407,6 @@ export default function GeneralPage() {
return ( return (
<> <>
<IdpIdentifierChangeDialog
open={identifierConfirmOpen}
setOpen={(open) => {
setIdentifierConfirmOpen(open);
if (!open) {
setPendingPayload(null);
}
}}
onConfirm={confirmIdentifierChange}
/>
<SettingsContainer> <SettingsContainer>
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
@@ -13,12 +13,7 @@ import { StrategyOption, StrategySelect } from "@app/components/StrategySelect";
import HeaderTitle from "@app/components/SettingsSectionTitle"; import HeaderTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { import { useActionState, useRef, useState } from "react";
useActionState,
useRef,
useState,
startTransition
} from "react";
import { import {
Form, Form,
FormControl, FormControl,
@@ -523,12 +518,9 @@ export default function Page() {
<SettingsSectionForm> <SettingsSectionForm>
<Form {...internalForm}> <Form {...internalForm}>
<form <form
onSubmit={(e) => { action={
e.preventDefault(); submitInternalAction
startTransition(() => { }
submitInternalAction();
});
}}
className="space-y-4" className="space-y-4"
id="create-user-form" id="create-user-form"
> >
+2 -12
View File
@@ -3,12 +3,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { import { useState, useTransition, useActionState } from "react";
useState,
useTransition,
useActionState,
startTransition
} from "react";
import { import {
Form, Form,
FormControl, FormControl,
@@ -248,12 +243,7 @@ function GeneralSectionForm({ org }: SectionFormProps) {
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={(e) => { action={formAction}
e.preventDefault();
startTransition(() => {
formAction();
});
}}
className="grid gap-4" className="grid gap-4"
id="org-general-settings-form" id="org-general-settings-form"
> >
@@ -3,13 +3,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { import { useState, useRef, useActionState, type ComponentRef } from "react";
useState,
useRef,
useActionState,
startTransition,
type ComponentRef
} from "react";
import { import {
Form, Form,
FormControl, FormControl,
@@ -123,7 +117,6 @@ export default function SecurityPage() {
} }
function LogRetentionSectionForm({ org }: SectionFormProps) { function LogRetentionSectionForm({ org }: SectionFormProps) {
const { updateOrg } = useOrgContext();
const form = useForm({ const form = useForm({
resolver: zodResolver( resolver: zodResolver(
SecurityFormSchema.pick({ SecurityFormSchema.pick({
@@ -180,11 +173,6 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
// Update organization // Update organization
await api.post(`/org/${org.orgId}`, reqData); await api.post(`/org/${org.orgId}`, reqData);
// Update the org context immediately so the dropdowns reflect
// the saved values without waiting on a re-fetch that could
// race a lagging read replica
updateOrg(reqData);
toast({ toast({
title: t("orgUpdated"), title: t("orgUpdated"),
description: t("orgUpdatedDescription") description: t("orgUpdatedDescription")
@@ -211,12 +199,7 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={(e) => { action={formAction}
e.preventDefault();
startTransition(() => {
formAction();
});
}}
className="grid gap-4" className="grid gap-4"
id="org-log-retention-settings-form" id="org-log-retention-settings-form"
> >
@@ -844,7 +827,6 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
function SecuritySettingsSectionForm({ org }: SectionFormProps) { function SecuritySettingsSectionForm({ org }: SectionFormProps) {
const router = useRouter(); const router = useRouter();
const { updateOrg } = useOrgContext();
const form = useForm({ const form = useForm({
resolver: zodResolver( resolver: zodResolver(
SecurityFormSchema.pick({ SecurityFormSchema.pick({
@@ -917,11 +899,6 @@ function SecuritySettingsSectionForm({ org }: SectionFormProps) {
// Update organization // Update organization
await api.post(`/org/${org.orgId}`, reqData); await api.post(`/org/${org.orgId}`, reqData);
// Update the org context immediately so the dropdowns reflect
// the saved values without waiting on a re-fetch that could
// race a lagging read replica
updateOrg(reqData);
toast({ toast({
title: t("orgUpdated"), title: t("orgUpdated"),
description: t("orgUpdatedDescription") description: t("orgUpdatedDescription")
@@ -965,12 +942,7 @@ function SecuritySettingsSectionForm({ org }: SectionFormProps) {
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={(e) => { action={formAction}
e.preventDefault();
startTransition(() => {
formAction();
});
}}
ref={formRef} ref={formRef}
id="security-settings-section-form" id="security-settings-section-form"
className="space-y-4" className="space-y-4"
+11 -44
View File
@@ -12,7 +12,6 @@ import { DateTimeValue } from "@app/components/DateTimePicker";
import { ArrowUpRight, Key, User } from "lucide-react"; import { ArrowUpRight, Key, User } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { ColumnFilterButton } from "@app/components/ColumnFilterButton"; import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { ColumnMultiFilterButton } from "@app/components/ColumnMultiFilterButton";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { build } from "@server/build"; import { build } from "@server/build";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
@@ -27,7 +26,6 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { logQueries } from "@app/lib/queries"; import { logQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { QueryAccessAuditLogResponse } from "@server/routers/auditLogs/types"; import type { QueryAccessAuditLogResponse } from "@server/routers/auditLogs/types";
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
export default function GeneralPage() { export default function GeneralPage() {
const router = useRouter(); const router = useRouter();
@@ -47,14 +45,12 @@ export default function GeneralPage() {
resourceId?: string; resourceId?: string;
location?: string; location?: string;
actor?: string; actor?: string;
ip?: string[];
}>({ }>({
action: searchParams.get("action") || undefined, action: searchParams.get("action") || undefined,
type: searchParams.get("type") || undefined, type: searchParams.get("type") || undefined,
resourceId: searchParams.get("resourceId") || undefined, resourceId: searchParams.get("resourceId") || undefined,
location: searchParams.get("location") || 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); const [currentPage, setCurrentPage] = useState<number>(0);
@@ -180,7 +176,7 @@ export default function GeneralPage() {
const handleFilterChange = ( const handleFilterChange = (
filterType: keyof typeof filters, filterType: keyof typeof filters,
value: string | string[] | undefined value: string | undefined
) => { ) => {
const newFilters = { ...filters, [filterType]: value }; const newFilters = { ...filters, [filterType]: value };
setFilters(newFilters); setFilters(newFilters);
@@ -198,13 +194,10 @@ export default function GeneralPage() {
) => { ) => {
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
Object.entries(newFilters).forEach(([key, value]) => { Object.entries(newFilters).forEach(([key, value]) => {
params.delete(key); if (value) {
if (typeof value === "string") {
params.set(key, value); params.set(key, value);
} else if (typeof value !== "undefined" && "length" in value) { } else {
for (const element of value) { params.delete(key);
params.append(key, element);
}
} }
}); });
router.replace(`?${params.toString()}`, { scroll: false }); router.replace(`?${params.toString()}`, { scroll: false });
@@ -212,7 +205,6 @@ export default function GeneralPage() {
const exportData = async () => { const exportData = async () => {
try { try {
const { ip, ...restFilters } = filters;
const params: any = { const params: any = {
timeStart: dateRange.startDate?.date timeStart: dateRange.startDate?.date
? new Date(dateRange.startDate.date).toISOString() ? new Date(dateRange.startDate.date).toISOString()
@@ -220,20 +212,13 @@ export default function GeneralPage() {
timeEnd: dateRange.endDate?.date timeEnd: dateRange.endDate?.date
? new Date(dateRange.endDate.date).toISOString() ? new Date(dateRange.endDate.date).toISOString()
: undefined, : undefined,
...restFilters ...filters
}; };
// axios serializes arrays as `ip[]=…`, which express's query const response = await api.get(`/org/${orgId}/logs/access/export`, {
// 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", responseType: "blob",
params params
} });
);
const url = window.URL.createObjectURL(new Blob([response.data])); const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a"); const link = document.createElement("a");
@@ -312,24 +297,7 @@ export default function GeneralPage() {
}, },
{ {
accessorKey: "ip", accessorKey: "ip",
header: () => ( header: () => <span className="px-2">{t("ip")}</span>,
<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 }) => { cell: ({ row }) => {
return row.original.ip ? ( return row.original.ip ? (
row.original.ip row.original.ip
@@ -347,7 +315,7 @@ export default function GeneralPage() {
options={filterAttributes.locations.map( options={filterAttributes.locations.map(
(location) => ({ (location) => ({
value: location, value: location,
label: `${location} ${countryCodeToFlagEmoji(location)}` label: location
}) })
)} )}
label={t("location")} label={t("location")}
@@ -366,8 +334,7 @@ export default function GeneralPage() {
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
{row.original.location ? ( {row.original.location ? (
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{row.original.location}{" "} {row.original.location}
{countryCodeToFlagEmoji(row.original.location)}
</span> </span>
) : ( ) : (
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
+3 -5
View File
@@ -276,9 +276,7 @@ export default function AiSessionLogsPage() {
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<div className="whitespace-nowrap"> <div className="whitespace-nowrap">
{new Date( {new Date(row.original.createdAt).toLocaleString()}
row.original.createdAt * 1000
).toLocaleString()}
</div> </div>
); );
} }
@@ -717,8 +715,8 @@ function generateSampleAiSessionLogs(): QueryAiSessionLogResponse["log"] {
null null
]; ];
const now = Math.floor(Date.now() / 1000); const now = Date.now();
const sevenDaysAgoMs = now - 7 * 24 * 60 * 60; const sevenDaysAgoMs = now - 7 * 24 * 60 * 60 * 1000;
return Array.from({ length: 10 }, (_, i) => { return Array.from({ length: 10 }, (_, i) => {
const provider = const provider =
+10 -40
View File
@@ -23,8 +23,6 @@ import { useMemo, useState, useTransition } from "react";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
import { ColumnFilterButton } from "@app/components/ColumnFilterButton"; import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
import { ColumnMultiFilterButton } from "@app/components/ColumnMultiFilterButton";
export default function GeneralPage() { export default function GeneralPage() {
const router = useRouter(); const router = useRouter();
@@ -49,7 +47,6 @@ export default function GeneralPage() {
method?: string; method?: string;
reason?: string; reason?: string;
path?: string; path?: string;
ip?: string[];
}>({ }>({
action: searchParams.get("action") || undefined, action: searchParams.get("action") || undefined,
host: searchParams.get("host") || undefined, host: searchParams.get("host") || undefined,
@@ -58,8 +55,7 @@ export default function GeneralPage() {
actor: searchParams.get("actor") || undefined, actor: searchParams.get("actor") || undefined,
method: searchParams.get("method") || undefined, method: searchParams.get("method") || undefined,
reason: searchParams.get("reason") || undefined, reason: searchParams.get("reason") || undefined,
path: searchParams.get("path") || undefined, path: searchParams.get("path") || undefined
ip: searchParams.getAll("ip") || undefined
}); });
const getDefaultDateRange = () => { const getDefaultDateRange = () => {
@@ -183,7 +179,7 @@ export default function GeneralPage() {
const handleFilterChange = ( const handleFilterChange = (
filterType: keyof typeof filters, filterType: keyof typeof filters,
value: string | string[] | undefined value: string | undefined
) => { ) => {
const newFilters = { ...filters, [filterType]: value }; const newFilters = { ...filters, [filterType]: value };
setFilters(newFilters); setFilters(newFilters);
@@ -201,13 +197,10 @@ export default function GeneralPage() {
) => { ) => {
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
Object.entries(newFilters).forEach(([key, value]) => { Object.entries(newFilters).forEach(([key, value]) => {
params.delete(key); if (value) {
if (typeof value === "string") {
params.set(key, value); params.set(key, value);
} else if (typeof value !== "undefined" && "length" in value) { } else {
for (const element of value) { params.delete(key);
params.append(key, element);
}
} }
}); });
router.replace(`?${params.toString()}`, { scroll: false }); router.replace(`?${params.toString()}`, { scroll: false });
@@ -216,7 +209,6 @@ export default function GeneralPage() {
const exportData = async () => { const exportData = async () => {
try { try {
// Prepare query params for export // Prepare query params for export
const { ip, ...restFilters } = filters;
const params: any = { const params: any = {
timeStart: dateRange.startDate?.date timeStart: dateRange.startDate?.date
? new Date(dateRange.startDate.date).toISOString() ? new Date(dateRange.startDate.date).toISOString()
@@ -224,15 +216,11 @@ export default function GeneralPage() {
timeEnd: dateRange.endDate?.date timeEnd: dateRange.endDate?.date
? new Date(dateRange.endDate.date).toISOString() ? new Date(dateRange.endDate.date).toISOString()
: undefined, : undefined,
...restFilters ...filters
}; };
// 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( const response = await api.get(
`/org/${orgId}/logs/request/export?${sp.toString()}`, `/org/${orgId}/logs/request/export`,
{ {
responseType: "blob", responseType: "blob",
params params
@@ -363,24 +351,7 @@ export default function GeneralPage() {
}, },
{ {
accessorKey: "ip", accessorKey: "ip",
header: ({ column }) => ( header: ({ column }) => <span className="px-2">{t("ip")}</span>,
<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 }) => { cell: ({ row }) => {
return row.original.ip ? ( return row.original.ip ? (
row.original.ip row.original.ip
@@ -398,7 +369,7 @@ export default function GeneralPage() {
options={filterAttributes.locations.map( options={filterAttributes.locations.map(
(location) => ({ (location) => ({
value: location, value: location,
label: `${location} ${countryCodeToFlagEmoji(location)}` label: location
}) })
)} )}
selectedValue={filters.location} selectedValue={filters.location}
@@ -418,8 +389,7 @@ export default function GeneralPage() {
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
{row.original.location ? ( {row.original.location ? (
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{row.original.location}{" "} {row.original.location}
{countryCodeToFlagEmoji(row.original.location)}
</span> </span>
) : ( ) : (
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">

Some files were not shown because too many files have changed in this diff Show More