diff --git a/.dockerignore b/.dockerignore index 74bedb17..a883e89c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -27,3 +27,5 @@ bruno/ LICENSE CONTRIBUTING.md dist +.git +config/ \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 802c003f..196676e9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -33,3 +33,30 @@ updates: minor-updates: update-types: - "minor" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "gomod" + directory: "/install" + schedule: + interval: "daily" + groups: + dev-patch-updates: + dependency-type: "development" + update-types: + - "patch" + dev-minor-updates: + dependency-type: "development" + update-types: + - "minor" + prod-patch-updates: + dependency-type: "production" + update-types: + - "patch" + prod-minor-updates: + dependency-type: "production" + update-types: + - "minor" \ No newline at end of file diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index bc581582..5be89da3 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -12,13 +12,13 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} @@ -28,9 +28,9 @@ jobs: run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV - name: Install Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: - go-version: 1.23.0 + go-version: 1.24 - name: Update version in package.json run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d2612cf5..426e90bc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,6 +23,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Create database index.ts + run: echo 'export * from "./sqlite";' > server/db/index.ts + - name: Generate database migrations run: npm run db:sqlite:generate @@ -45,5 +48,8 @@ jobs: echo "App failed to start" exit 1 - - name: Build Docker image - run: make build + - name: Build Docker image sqlite + run: make build-sqlite + + - name: Build Docker image pg + run: make build-pg diff --git a/.gitignore b/.gitignore index 04c4b7ef..167b4a91 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ yarn-error.log* next-env.d.ts *.db *.sqlite +!Dockerfile.sqlite *.sqlite3 *.log .machinelogs*.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44acedb1..9bd2bc67 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,11 +4,7 @@ Contributions are welcome! Please see the contribution and local development guide on the docs page before getting started: -https://docs.fossorial.io/development - -For ideas about what features to work on and our future plans, please see the roadmap: - -https://docs.fossorial.io/roadmap +https://docs.digpangolin.com/development/contributing ### Licensing Considerations diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 00000000..141cfd10 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,14 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Use tsx watch for development with hot reload +CMD ["npm", "run", "dev"] diff --git a/Dockerfile b/Dockerfile.sqlite similarity index 100% rename from Dockerfile rename to Dockerfile.sqlite diff --git a/Makefile b/Makefile index c2c01b9a..0e0394b4 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,12 @@ -.PHONY: build build-release build-arm build-x86 test clean +.PHONY: build build-pg build-release build-arm build-x86 test clean build-release: @if [ -z "$(tag)" ]; then \ echo "Error: tag is required. Usage: make build-release tag="; \ exit 1; \ fi - docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:latest -f Dockerfile --push . - docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:$(tag) -f Dockerfile --push . + docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:latest -f Dockerfile.sqlite --push . + docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:$(tag) -f Dockerfile.sqlite --push . docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:postgresql-latest -f Dockerfile.pg --push . docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:postgresql-$(tag) -f Dockerfile.pg --push . @@ -16,8 +16,11 @@ build-arm: build-x86: docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest . -build: - docker build -t fosrl/pangolin:latest . +build-sqlite: + docker build -t fosrl/pangolin:latest -f Dockerfile.sqlite . + +build-pg: + docker build -t fosrl/pangolin:postgresql-latest -f Dockerfile.pg . test: docker run -it -p 3000:3000 -p 3001:3001 -p 3002:3002 -v ./config:/app/config fosrl/pangolin:latest diff --git a/README.md b/README.md index 8723542c..ee888428 100644 --- a/README.md +++ b/README.md @@ -7,20 +7,20 @@ -

Tunneled Reverse Proxy Server with Access Control

+

Secure gateway to your private networks

-_Your own self-hosted zero trust tunnel._ +_Pangolin tunnels your services to the internet so you can access anything from anywhere._
- + Website | - + Install Guide | @@ -36,22 +36,31 @@ _Your own self-hosted zero trust tunnel._
+

+ + Start testing Pangolin at pangolin.fossorial.io + +

+ Pangolin is a self-hosted tunneled reverse proxy server with identity and access control, designed to securely expose private resources on distributed networks. Acting as a central hub, it connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports. Preview -_Resources page of Pangolin dashboard (dark mode) showing multiple resources available to connect._ +![gif](public/clip.gif) ## Key Features ### Reverse Proxy Through WireGuard Tunnel - Expose private resources on your network **without opening ports** (firewall punching). -- Secure and easy to configure site-to-site connectivity via a custom **user space WireGuard client**, [Newt](https://github.com/fosrl/newt). +- Secure and easy to configure private connectivity via a custom **user space WireGuard client**, [Newt](https://github.com/fosrl/newt). - Built-in support for any WireGuard client. - Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/). - Support for HTTP/HTTPS and **raw TCP/UDP services**. - Load balancing. +- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin) and [Geoblock](https://github.com/PascalMinder/geoblock). + - **Automatically install and configure Crowdsec via Pangolin's installer script.** +- Attach as many sites to the central server as you wish. ### Identity & Access Management @@ -65,89 +74,73 @@ _Resources page of Pangolin dashboard (dark mode) showing multiple resources ava - **Temporary, self-destructing share links.** - Resource specific pin codes. - Resource specific passwords. + - Passkeys - External identity provider (IdP) support with OAuth2/OIDC, such as Authentik, Keycloak, Okta, and others. - Auto-provision users and roles from your IdP. -### Simple Dashboard UI +Auth and diagram -- Manage sites, users, and roles with a clean and intuitive UI. -- Monitor site usage and connectivity. -- Light and dark mode options. -- Mobile friendly. +## Use Cases -### Easy Deployment +### Manage Access to Internal Apps -- Run on any cloud provider or on-premises. -- **Docker Compose based setup** for simplified deployment. -- Future-proof installation script for streamlined setup and feature additions. -- Use any WireGuard client to connect, or use **Newt, our custom user space client** for the best experience. -- Use the API to create custom integrations and scripts. - - Fine-grained access control to the API via scoped API keys. - - Comprehensive Swagger documentation for the API. +- Grant users access to your apps from anywhere using just a web browser. No client software required. -### Modular Design +### Developers and DevOps -- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin) and [Geoblock](https://github.com/PascalMinder/geoblock). - - **Automatically install and configure Crowdsec via Pangolin's installer script.** -- Attach as many sites to the central server as you wish. +- Expose and test internal tools and dashboards like **Grafana**. Bring localhost or private IPs online for easy access. -Collage +### Secure API Gateway -## Deployment and Usage Example +- One application load balancer across multiple clouds and on-premises. -1. **Deploy the Central Server**: +### IoT and Edge Devices - - Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like RackNerd, Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs. +- Easily expose **IoT devices**, **edge servers**, or **Raspberry Pi** to the internet for field equipment monitoring. + +Sites + +## Deployment Options + +### Fully Self Hosted + +Host the full application on your own server or on the cloud with a VPS. Take a look at the [documentation](https://docs.digpangolin.com/self-host/quick-install) to get started. -> [!TIP] > Many of our users have had a great experience with [RackNerd](https://my.racknerd.com/aff.php?aff=13788). Depending on promotions, you can get a [**VPS with 1 vCPU, 1GB RAM, and ~20GB SSD for just around $12/year**](https://my.racknerd.com/aff.php?aff=13788&pid=912). That's a great deal! -> We are part of the [RackNerd](https://my.racknerd.com/aff.php?aff=13788) affiliate program, so if you purchase through [our link](https://my.racknerd.com/aff.php?aff=13788), we receive a small commission which helps us maintain the project and keep it free for everyone. -1. **Domain Configuration**: +### Pangolin Cloud - - Point your domain name to the VPS and configure Pangolin with your preferred settings. +Easy to use with simple [pay as you go pricing](https://digpangolin.com/pricing). [Check it out here](https://pangolin.fossorial.io/auth/signup). -2. **Connect Private Sites**: +- Everything you get with self hosted Pangolin, but fully managed for you. - - Install Newt or use another WireGuard client on private sites. - - Automatically establish a connection from these sites to the central server. +### Hybrid & High Availability -3. **Expose Resources**: +Managed control plane, your infrastructure - - Add resources to the central server and configure access control rules. - - Access these resources securely from anywhere. +- We manage database and control plane. +- You self-host lightweight exit-node. +- Traffic flows through your infra. +- We coordinate failover between your nodes or to Cloud when things go bad. -**Use Case Example - Bypassing Port Restrictions in Home Lab**: - Imagine private sites where the ISP restricts port forwarding. By connecting these sites to Pangolin via WireGuard, you can securely expose HTTP and HTTPS resources on the private network without any networking complexity. +If interested, [contact us](mailto:numbat@fossorial.io). -**Use Case Example - Deploying Services For Your Business**: -You can use Pangolin as an easy way to expose your business applications to your users behind a safe authentication portal you can integrate into your IdP solution. Expose resources on prem and on the cloud. +### Full Enterprise On-Premises -**Use Case Example - IoT Networks**: - IoT networks are often fragmented and difficult to manage. By deploying Pangolin on a central server, you can connect all your IoT sites via Newt or another WireGuard client. This creates a simple, secure, and centralized way to access IoT resources without the need for intricate networking setups. - -## Similar Projects and Inspirations - -**Cloudflare Tunnels**: - A similar approach to proxying private resources securely, but Pangolin is a self-hosted alternative, giving you full control over your infrastructure. - -**Authelia**: - This inspired Pangolin’s centralized authentication system for proxies, enabling robust user and role management. +[Contact us](mailto:numbat@fossorial.io) for a full distributed and enterprise deployments on your infrastructure controlled by your team. ## Project Development / Roadmap -> [!NOTE] -> Pangolin is under heavy development. The roadmap is subject to change as we fix bugs, add new features, and make improvements. - -View the [project board](https://github.com/orgs/fosrl/projects/1) for more detailed info. +We want to hear your feature requests! Add them to the [discussion board](https://github.com/orgs/fosrl/discussions/categories/feature-requests). ## Licensing -Pangolin is dual licensed under the AGPL-3 and the Fossorial Commercial license. Please see the [LICENSE](./LICENSE) file in the repository for details. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io). +Pangolin is dual licensed under the AGPL-3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io). ## Contributions +Looking for something to contribute? Take a look at issues marked with [help wanted](https://github.com/fosrl/pangolin/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22help%20wanted%22). Also take a look through the freature requests in Discussions - any are available and some are marked as a good first issue. + Please see [CONTRIBUTING](./CONTRIBUTING.md) in the repository for guidelines and best practices. Please post bug reports and other functional issues in the [Issues](https://github.com/fosrl/pangolin/issues) section of the repository. -For all feature requests, or other ideas, please use the [Discussions](https://github.com/orgs/fosrl/discussions) section. diff --git a/bruno/Clients/createClient.bru b/bruno/Clients/createClient.bru new file mode 100644 index 00000000..7577bb28 --- /dev/null +++ b/bruno/Clients/createClient.bru @@ -0,0 +1,22 @@ +meta { + name: createClient + type: http + seq: 1 +} + +put { + url: http://localhost:3000/api/v1/site/1/client + body: json + auth: none +} + +body:json { + { + "siteId": 1, + "name": "test", + "type": "olm", + "subnet": "100.90.129.4/30", + "olmId": "029yzunhx6nh3y5", + "secret": "l0ymp075y3d4rccb25l6sqpgar52k09etunui970qq5gj7x6" + } +} diff --git a/bruno/Clients/pickClientDefaults.bru b/bruno/Clients/pickClientDefaults.bru new file mode 100644 index 00000000..61509c11 --- /dev/null +++ b/bruno/Clients/pickClientDefaults.bru @@ -0,0 +1,11 @@ +meta { + name: pickClientDefaults + type: http + seq: 2 +} + +get { + url: http://localhost:3000/api/v1/site/1/pick-client-defaults + body: none + auth: none +} diff --git a/config/config.example.yml b/config/config.example.yml index 33ed9370..c5f70641 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -1,5 +1,5 @@ # To see all available options, please visit the docs: -# https://docs.fossorial.io/Pangolin/Configuration/config +# https://docs.digpangolin.com/self-host/advanced/config-file app: dashboard_url: "http://localhost:3002" @@ -46,4 +46,3 @@ flags: disable_signup_without_invite: true disable_user_create_org: true allow_raw_resources: true - allow_base_domain_resources: true diff --git a/config/db/db.sqlite.bak b/config/db/db.sqlite.bak new file mode 100644 index 00000000..9d0b3db3 Binary files /dev/null and b/config/db/db.sqlite.bak differ diff --git a/docker-compose.example.yml b/docker-compose.example.yml index e6c78453..703c47c6 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -31,11 +31,12 @@ services: - SYS_MODULE ports: - 51820:51820/udp + - 21820:21820/udp - 443:443 # Port for traefik because of the network_mode - 80:80 # Port for traefik because of the network_mode traefik: - image: traefik:v3.4.0 + image: traefik:v3.5 container_name: traefik restart: unless-stopped network_mode: service:gerbil # Ports appear on the gerbil service @@ -51,4 +52,5 @@ services: networks: default: driver: bridge - name: pangolin \ No newline at end of file + name: pangolin + enable_ipv6: true \ No newline at end of file diff --git a/docker-compose.pg.yml b/docker-compose.pg.yml new file mode 100644 index 00000000..aeffc2cf --- /dev/null +++ b/docker-compose.pg.yml @@ -0,0 +1,12 @@ +services: + # PostgreSQL Service + db: + image: postgres:17 # Use the PostgreSQL 17 image + container_name: dev_postgres # Name your PostgreSQL container + environment: + POSTGRES_DB: postgres # Default database name + POSTGRES_USER: postgres # Default user + POSTGRES_PASSWORD: password # Default password (change for production!) + ports: + - "5432:5432" # Map host port 5432 to container port 5432 + restart: no \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..09b150d7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + # Development application service + app: + build: + context: . + dockerfile: Dockerfile.dev + container_name: dev_pangolin + ports: + - "3000:3000" + - "3001:3001" + - "3002:3002" + - "3003:3003" + environment: + - NODE_ENV=development + - ENVIRONMENT=dev + - DB_TYPE=pg + volumes: + # Mount source code for hot reload + - ./src:/app/src + - ./server:/app/server + - ./public:/app/public + - ./messages:/app/messages + - ./components.json:/app/components.json + - ./next.config.mjs:/app/next.config.mjs + - ./tsconfig.json:/app/tsconfig.json + - ./tailwind.config.js:/app/tailwind.config.js + - ./postcss.config.mjs:/app/postcss.config.mjs + - ./eslint.config.js:/app/eslint.config.js + - ./config:/app/config + restart: no diff --git a/drizzle.pg.config.ts b/drizzle.pg.config.ts index 14aeba5b..4d1f1e43 100644 --- a/drizzle.pg.config.ts +++ b/drizzle.pg.config.ts @@ -3,7 +3,7 @@ import path from "path"; export default defineConfig({ dialect: "postgresql", - schema: path.join("server", "db", "pg", "schema.ts"), + schema: [path.join("server", "db", "pg", "schema.ts")], out: path.join("server", "migrations"), verbose: true, dbCredentials: { diff --git a/install/Makefile b/install/Makefile index 644e4a35..8b65cadd 100644 --- a/install/Makefile +++ b/install/Makefile @@ -1,4 +1,5 @@ all: update-versions go-build-release put-back +dev-all: dev-update-versions dev-build dev-clean go-build-release: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/installer_linux_amd64 @@ -11,6 +12,12 @@ clean: update-versions: @echo "Fetching latest versions..." cp main.go main.go.bak && \ + $(MAKE) dev-update-versions + +put-back: + mv main.go.bak main.go + +dev-update-versions: PANGOLIN_VERSION=$$(curl -s https://api.github.com/repos/fosrl/pangolin/tags | jq -r '.[0].name') && \ GERBIL_VERSION=$$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') && \ BADGER_VERSION=$$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') && \ @@ -20,5 +27,11 @@ update-versions: sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$$BADGER_VERSION\"/" main.go && \ echo "Updated main.go with latest versions" -put-back: - mv main.go.bak main.go +dev-build: go-build-release + +dev-clean: + @echo "Restoring version values ..." + sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"replaceme\"/" main.go && \ + sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"replaceme\"/" main.go && \ + sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"replaceme\"/" main.go + @echo "Restored version strings in main.go" diff --git a/install/config/config.yml b/install/config/config.yml index db6b2b87..2928b425 100644 --- a/install/config/config.yml +++ b/install/config/config.yml @@ -1,5 +1,5 @@ # To see all available options, please visit the docs: -# https://docs.fossorial.io/Pangolin/Configuration/config +# https://docs.digpangolin.com/self-host/dns-and-networking app: dashboard_url: "https://{{.DashboardDomain}}" @@ -36,4 +36,3 @@ flags: disable_signup_without_invite: true disable_user_create_org: false allow_raw_resources: true - allow_base_domain_resources: true diff --git a/install/config/crowdsec/docker-compose.yml b/install/config/crowdsec/docker-compose.yml index 28470d14..17289ef2 100644 --- a/install/config/crowdsec/docker-compose.yml +++ b/install/config/crowdsec/docker-compose.yml @@ -1,6 +1,6 @@ services: crowdsec: - image: crowdsecurity/crowdsec:latest + image: docker.io/crowdsecurity/crowdsec:latest container_name: crowdsec environment: GID: "1000" diff --git a/install/config/crowdsec/profiles.yaml b/install/config/crowdsec/profiles.yaml index 3796b47f..5781cf62 100644 --- a/install/config/crowdsec/profiles.yaml +++ b/install/config/crowdsec/profiles.yaml @@ -22,4 +22,4 @@ filters: decisions: - type: ban duration: 4h -on_success: break \ No newline at end of file +on_success: break diff --git a/install/config/crowdsec/traefik_config.yml b/install/config/crowdsec/traefik_config.yml index 7ccfd7cf..198693ef 100644 --- a/install/config/crowdsec/traefik_config.yml +++ b/install/config/crowdsec/traefik_config.yml @@ -16,7 +16,7 @@ experimental: version: "{{.BadgerVersion}}" crowdsec: # CrowdSec plugin configuration added moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - version: "v1.4.2" + version: "v1.4.4" log: level: "INFO" diff --git a/install/config/docker-compose.yml b/install/config/docker-compose.yml index 90349b7a..70a4602f 100644 --- a/install/config/docker-compose.yml +++ b/install/config/docker-compose.yml @@ -1,7 +1,7 @@ name: pangolin services: pangolin: - image: fosrl/pangolin:{{.PangolinVersion}} + image: docker.io/fosrl/pangolin:{{.PangolinVersion}} container_name: pangolin restart: unless-stopped volumes: @@ -13,7 +13,7 @@ services: retries: 15 {{if .InstallGerbil}} gerbil: - image: fosrl/gerbil:{{.GerbilVersion}} + image: docker.io/fosrl/gerbil:{{.GerbilVersion}} container_name: gerbil restart: unless-stopped depends_on: @@ -31,11 +31,12 @@ services: - SYS_MODULE ports: - 51820:51820/udp + - 21820:21820/udp - 443:443 # Port for traefik because of the network_mode - 80:80 # Port for traefik because of the network_mode {{end}} traefik: - image: traefik:v3.4.1 + image: docker.io/traefik:v3.5 container_name: traefik restart: unless-stopped {{if .InstallGerbil}} @@ -59,3 +60,4 @@ networks: default: driver: bridge name: pangolin +{{if .EnableIPv6}} enable_ipv6: true{{end}} diff --git a/install/crowdsec.go b/install/crowdsec.go index c17bf540..2e388e92 100644 --- a/install/crowdsec.go +++ b/install/crowdsec.go @@ -13,7 +13,7 @@ import ( func installCrowdsec(config Config) error { - if err := stopContainers(); err != nil { + if err := stopContainers(config.InstallationContainerType); err != nil { return fmt.Errorf("failed to stop containers: %v", err) } @@ -72,12 +72,12 @@ func installCrowdsec(config Config) error { os.Exit(1) } - if err := startContainers(); err != nil { + if err := startContainers(config.InstallationContainerType); err != nil { return fmt.Errorf("failed to start containers: %v", err) } // get API key - apiKey, err := GetCrowdSecAPIKey() + apiKey, err := GetCrowdSecAPIKey(config.InstallationContainerType) if err != nil { return fmt.Errorf("failed to get API key: %v", err) } @@ -87,7 +87,7 @@ func installCrowdsec(config Config) error { return fmt.Errorf("failed to replace bouncer key: %v", err) } - if err := restartContainer("traefik"); err != nil { + if err := restartContainer("traefik", config.InstallationContainerType); err != nil { return fmt.Errorf("failed to restart containers: %v", err) } @@ -110,9 +110,9 @@ func checkIsCrowdsecInstalledInCompose() bool { return bytes.Contains(content, []byte("crowdsec:")) } -func GetCrowdSecAPIKey() (string, error) { +func GetCrowdSecAPIKey(containerType SupportedContainer) (string, error) { // First, ensure the container is running - if err := waitForContainer("crowdsec"); err != nil { + if err := waitForContainer("crowdsec", containerType); err != nil { return "", fmt.Errorf("waiting for container: %w", err) } diff --git a/install/go.mod b/install/go.mod index 1d12aa12..37b815e8 100644 --- a/install/go.mod +++ b/install/go.mod @@ -1,10 +1,10 @@ module installer -go 1.23.0 +go 1.24 require ( - golang.org/x/term v0.28.0 + golang.org/x/term v0.33.0 gopkg.in/yaml.v3 v3.0.1 ) -require golang.org/x/sys v0.29.0 // indirect +require golang.org/x/sys v0.34.0 // indirect diff --git a/install/go.sum b/install/go.sum index 169165e4..71a81b94 100644 --- a/install/go.sum +++ b/install/go.sum @@ -1,7 +1,7 @@ -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= -golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= +golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/install/input.txt b/install/input.txt index 9bca8081..12df39d7 100644 --- a/install/input.txt +++ b/install/input.txt @@ -1,5 +1,7 @@ +docker example.com pangolin.example.com +yes admin@example.com yes admin@example.com diff --git a/install/main.go b/install/main.go index 38aa6f63..2a361c70 100644 --- a/install/main.go +++ b/install/main.go @@ -7,17 +7,17 @@ import ( "fmt" "io" "io/fs" + "math/rand" "os" "os/exec" "os/user" "path/filepath" "runtime" + "strconv" "strings" "syscall" "text/template" "time" - "math/rand" - "strconv" "golang.org/x/term" ) @@ -33,43 +33,115 @@ func loadVersions(config *Config) { var configFiles embed.FS type Config struct { - PangolinVersion string - GerbilVersion string - BadgerVersion string - BaseDomain string - DashboardDomain string - LetsEncryptEmail string - EnableEmail bool - EmailSMTPHost string - EmailSMTPPort int - EmailSMTPUser string - EmailSMTPPass string - EmailNoReply string - InstallGerbil bool - TraefikBouncerKey string - DoCrowdsecInstall bool - Secret string + InstallationContainerType SupportedContainer + PangolinVersion string + GerbilVersion string + BadgerVersion string + BaseDomain string + DashboardDomain string + EnableIPv6 bool + LetsEncryptEmail string + EnableEmail bool + EmailSMTPHost string + EmailSMTPPort int + EmailSMTPUser string + EmailSMTPPass string + EmailNoReply string + InstallGerbil bool + TraefikBouncerKey string + DoCrowdsecInstall bool + Secret string } -func main() { - reader := bufio.NewReader(os.Stdin) +type SupportedContainer string - // check if docker is not installed and the user is root - if !isDockerInstalled() { - if os.Geteuid() != 0 { - fmt.Println("Docker is not installed. Please install Docker manually or run this installer as root.") - os.Exit(1) - } +const ( + Docker SupportedContainer = "docker" + Podman SupportedContainer = "podman" +) + +func main() { + + // print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking + + fmt.Println("Welcome to the Pangolin installer!") + fmt.Println("This installer will help you set up Pangolin on your server.") + fmt.Println("") + fmt.Println("Please make sure you have the following prerequisites:") + fmt.Println("- Open TCP ports 80 and 443 and UDP ports 51820 and 21820 on your VPS and firewall.") + fmt.Println("- Point your domain to the VPS IP with A records.") + fmt.Println("") + fmt.Println("https://docs.digpangolin.com/self-host/dns-and-networking") + fmt.Println("") + fmt.Println("Lets get started!") + fmt.Println("") + + reader := bufio.NewReader(os.Stdin) + inputContainer := readString(reader, "Would you like to run Pangolin as Docker or Podman containers?", "docker") + + chosenContainer := Docker + if strings.EqualFold(inputContainer, "docker") { + chosenContainer = Docker + } else if strings.EqualFold(inputContainer, "podman") { + chosenContainer = Podman + } else { + fmt.Printf("Unrecognized container type: %s. Valid options are 'docker' or 'podman'.\n", inputContainer) + os.Exit(1) } - // check if the user is in the docker group (linux only) - if !isUserInDockerGroup() { - fmt.Println("You are not in the docker group.") - fmt.Println("The installer will not be able to run docker commands without running it as root.") + if chosenContainer == Podman { + if !isPodmanInstalled() { + fmt.Println("Podman or podman-compose is not installed. Please install both manually. Automated installation will be available in a later release.") + os.Exit(1) + } + + if err := exec.Command("bash", "-c", "cat /etc/sysctl.conf | grep 'net.ipv4.ip_unprivileged_port_start='").Run(); err != nil { + fmt.Println("Would you like to configure ports >= 80 as unprivileged ports? This enables podman containers to listen on low-range ports.") + fmt.Println("Pangolin will experience startup issues if this is not configured, because it needs to listen on port 80/443 by default.") + approved := readBool(reader, "The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' >> /etc/sysctl.conf && sysctl -p\". Approve?", true) + if approved { + if os.Geteuid() != 0 { + fmt.Println("You need to run the installer as root for such a configuration.") + os.Exit(1) + } + + // Podman containers are not able to listen on privileged ports. The official recommendation is to + // container low-range ports as unprivileged ports. + // Linux only. + + if err := run("bash", "-c", "echo 'net.ipv4.ip_unprivileged_port_start=80' >> /etc/sysctl.conf && sysctl -p"); err != nil { + fmt.Sprintf("failed to configure unprivileged ports: %v.\n", err) + os.Exit(1) + } + } else { + fmt.Println("You need to configure port forwarding or adjust the listening ports before running pangolin.") + } + } else { + fmt.Println("Unprivileged ports have been configured.") + } + + } else if chosenContainer == Docker { + // check if docker is not installed and the user is root + if !isDockerInstalled() { + if os.Geteuid() != 0 { + fmt.Println("Docker is not installed. Please install Docker manually or run this installer as root.") + os.Exit(1) + } + } + + // check if the user is in the docker group (linux only) + if !isUserInDockerGroup() { + fmt.Println("You are not in the docker group.") + fmt.Println("The installer will not be able to run docker commands without running it as root.") + os.Exit(1) + } + } else { + // This shouldn't happen unless there's a third container runtime. os.Exit(1) } var config Config + config.InstallationContainerType = chosenContainer // check if there is already a config file if _, err := os.Stat("config/config.yml"); err != nil { @@ -86,7 +158,7 @@ func main() { moveFile("config/docker-compose.yml", "docker-compose.yml") - if !isDockerInstalled() && runtime.GOOS == "linux" { + if !isDockerInstalled() && runtime.GOOS == "linux" && chosenContainer == Docker { if readBool(reader, "Docker is not installed. Would you like to install it?", true) { installDocker() // try to start docker service but ignore errors @@ -115,14 +187,15 @@ func main() { fmt.Println("\n=== Starting installation ===") - if isDockerInstalled() { + if (isDockerInstalled() && chosenContainer == Docker) || + (isPodmanInstalled() && chosenContainer == Podman) { if readBool(reader, "Would you like to install and start the containers?", true) { - if err := pullContainers(); err != nil { + if err := pullContainers(chosenContainer); err != nil { fmt.Println("Error: ", err) return } - if err := startContainers(); err != nil { + if err := startContainers(chosenContainer); err != nil { fmt.Println("Error: ", err) return } @@ -137,6 +210,8 @@ func main() { // check if crowdsec is installed if readBool(reader, "Would you like to install CrowdSec?", false) { fmt.Println("This installer constitutes a minimal viable CrowdSec deployment. CrowdSec will add extra complexity to your Pangolin installation and may not work to the best of its abilities out of the box. Users are expected to implement configuration adjustments on their own to achieve the best security posture. Consult the CrowdSec documentation for detailed configuration instructions.") + + // BUG: crowdsec installation will be skipped if the user chooses to install on the first installation. if readBool(reader, "Are you willing to manage CrowdSec?", false) { if config.DashboardDomain == "" { traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml", "config/traefik/dynamic_config.yml") @@ -229,6 +304,7 @@ func collectUserInput(reader *bufio.Reader) Config { fmt.Println("\n=== Basic Configuration ===") config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "") config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", "pangolin."+config.BaseDomain) + config.EnableIPv6 = readBool(reader, "Is your server IPv6 capable?", true) config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "") config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunneled connections", true) @@ -240,7 +316,7 @@ func collectUserInput(reader *bufio.Reader) Config { config.EmailSMTPHost = readString(reader, "Enter SMTP host", "") config.EmailSMTPPort = readInt(reader, "Enter SMTP port (default 587)", 587) config.EmailSMTPUser = readString(reader, "Enter SMTP username", "") - config.EmailSMTPPass = readString(reader, "Enter SMTP password", "") + config.EmailSMTPPass = readString(reader, "Enter SMTP password", "") // Should this be readPassword? config.EmailNoReply = readString(reader, "Enter no-reply email address", "") } @@ -330,7 +406,6 @@ func createConfigFiles(config Config) error { return nil }) - if err != nil { return fmt.Errorf("error walking config files: %v", err) } @@ -456,7 +531,15 @@ func startDockerService() error { } func isDockerInstalled() bool { - cmd := exec.Command("docker", "--version") + return isContainerInstalled("docker") +} + +func isPodmanInstalled() bool { + return isContainerInstalled("podman") && isContainerInstalled("podman-compose") +} + +func isContainerInstalled(container string) bool { + cmd := exec.Command(container, "--version") if err := cmd.Run(); err != nil { return false } @@ -527,52 +610,98 @@ func executeDockerComposeCommandWithArgs(args ...string) error { cmd = exec.Command("docker-compose", args...) } - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() } // pullContainers pulls the containers using the appropriate command. -func pullContainers() error { +func pullContainers(containerType SupportedContainer) error { fmt.Println("Pulling the container images...") + if containerType == Podman { + if err := run("podman-compose", "-f", "docker-compose.yml", "pull"); err != nil { + return fmt.Errorf("failed to pull the containers: %v", err) + } - if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "pull", "--policy", "always"); err != nil { - return fmt.Errorf("failed to pull the containers: %v", err) + return nil } - return nil + if containerType == Docker { + if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "pull", "--policy", "always"); err != nil { + return fmt.Errorf("failed to pull the containers: %v", err) + } + + return nil + } + + return fmt.Errorf("Unsupported container type: %s", containerType) } // startContainers starts the containers using the appropriate command. -func startContainers() error { +func startContainers(containerType SupportedContainer) error { fmt.Println("Starting containers...") - if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "up", "-d", "--force-recreate"); err != nil { - return fmt.Errorf("failed to start containers: %v", err) + + if containerType == Podman { + if err := run("podman-compose", "-f", "docker-compose.yml", "up", "-d", "--force-recreate"); err != nil { + return fmt.Errorf("failed start containers: %v", err) + } + + return nil } - return nil + if containerType == Docker { + if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "up", "-d", "--force-recreate"); err != nil { + return fmt.Errorf("failed to start containers: %v", err) + } + + return nil + } + + return fmt.Errorf("Unsupported container type: %s", containerType) } // stopContainers stops the containers using the appropriate command. -func stopContainers() error { +func stopContainers(containerType SupportedContainer) error { fmt.Println("Stopping containers...") + if containerType == Podman { + if err := run("podman-compose", "-f", "docker-compose.yml", "down"); err != nil { + return fmt.Errorf("failed to stop containers: %v", err) + } - if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "down"); err != nil { - return fmt.Errorf("failed to stop containers: %v", err) + return nil } - return nil + if containerType == Docker { + if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "down"); err != nil { + return fmt.Errorf("failed to stop containers: %v", err) + } + + return nil + } + + return fmt.Errorf("Unsupported container type: %s", containerType) } // restartContainer restarts a specific container using the appropriate command. -func restartContainer(container string) error { +func restartContainer(container string, containerType SupportedContainer) error { fmt.Println("Restarting containers...") + if containerType == Podman { + if err := run("podman-compose", "-f", "docker-compose.yml", "restart"); err != nil { + return fmt.Errorf("failed to stop the container \"%s\": %v", container, err) + } - if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "restart", container); err != nil { - return fmt.Errorf("failed to stop the container \"%s\": %v", container, err) + return nil } - return nil + if containerType == Docker { + if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "restart", container); err != nil { + return fmt.Errorf("failed to stop the container \"%s\": %v", container, err) + } + + return nil + } + + return fmt.Errorf("Unsupported container type: %s", containerType) } func copyFile(src, dst string) error { @@ -600,13 +729,13 @@ func moveFile(src, dst string) error { return os.Remove(src) } -func waitForContainer(containerName string) error { +func waitForContainer(containerName string, containerType SupportedContainer) error { maxAttempts := 30 retryInterval := time.Second * 2 for attempt := 0; attempt < maxAttempts; attempt++ { // Check if container is running - cmd := exec.Command("docker", "container", "inspect", "-f", "{{.State.Running}}", containerName) + cmd := exec.Command(string(containerType), "container", "inspect", "-f", "{{.State.Running}}", containerName) var out bytes.Buffer cmd.Stdout = &out @@ -641,3 +770,11 @@ func generateRandomSecretKey() string { } return string(b) } + +// Run external commands with stdio/stderr attached. +func run(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/messages/bg-BG.json b/messages/bg-BG.json new file mode 100644 index 00000000..bf786a24 --- /dev/null +++ b/messages/bg-BG.json @@ -0,0 +1,1327 @@ +{ + "setupCreate": "Създайте своя организация, сайт и ресурси", + "setupNewOrg": "Нова организация", + "setupCreateOrg": "Създай организация", + "setupCreateResources": "Създай ресурси", + "setupOrgName": "Име на организацията", + "orgDisplayName": "Това е публичното име на вашата организация.", + "orgId": "Идентификатор на организация", + "setupIdentifierMessage": "Това е уникалният идентификатор на вашата организация. Това е различно от публичното ѝ име.", + "setupErrorIdentifier": "Идентификаторът на организация вече е зает. Моля, изберете друг.", + "componentsErrorNoMemberCreate": "В момента не сте част от организация. Създайте организация, за да продължите.", + "componentsErrorNoMember": "В момента не сте част от организация.", + "welcome": "Добре дошли!", + "welcomeTo": "Добре дошли в", + "componentsCreateOrg": "Създай организация", + "componentsMember": "You're a member of {count, plural, =0 {no organization} one {one organization} other {# organizations}}.", + "componentsInvalidKey": "Invalid or expired license keys detected. Follow license terms to continue using all features.", + "dismiss": "Dismiss", + "componentsLicenseViolation": "License Violation: This server is using {usedSites} sites which exceeds its licensed limit of {maxSites} sites. Follow license terms to continue using all features.", + "componentsSupporterMessage": "Thank you for supporting Pangolin as a {tier}!", + "inviteErrorNotValid": "We're sorry, but it looks like the invite you're trying to access has not been accepted or is no longer valid.", + "inviteErrorUser": "We're sorry, but it looks like the invite you're trying to access is not for this user.", + "inviteLoginUser": "Please make sure you're logged in as the correct user.", + "inviteErrorNoUser": "We're sorry, but it looks like the invite you're trying to access is not for a user that exists.", + "inviteCreateUser": "Please create an account first.", + "goHome": "Go Home", + "inviteLogInOtherUser": "Log In as a Different User", + "createAnAccount": "Create an Account", + "inviteNotAccepted": "Invite Not Accepted", + "authCreateAccount": "Create an account to get started", + "authNoAccount": "Don't have an account?", + "email": "Email", + "password": "Password", + "confirmPassword": "Confirm Password", + "createAccount": "Create Account", + "viewSettings": "View settings", + "delete": "Delete", + "name": "Name", + "online": "Online", + "offline": "Offline", + "site": "Site", + "dataIn": "Data In", + "dataOut": "Data Out", + "connectionType": "Connection Type", + "tunnelType": "Tunnel Type", + "local": "Local", + "edit": "Edit", + "siteConfirmDelete": "Confirm Delete Site", + "siteDelete": "Delete Site", + "siteMessageRemove": "Once removed, the site will no longer be accessible. All resources and targets associated with the site will also be removed.", + "siteMessageConfirm": "To confirm, please type the name of the site below.", + "siteQuestionRemove": "Are you sure you want to remove the site {selectedSite} from the organization?", + "siteManageSites": "Manage Sites", + "siteDescription": "Allow connectivity to your network through secure tunnels", + "siteCreate": "Create Site", + "siteCreateDescription2": "Follow the steps below to create and connect a new site", + "siteCreateDescription": "Create a new site to start connecting your resources", + "close": "Close", + "siteErrorCreate": "Error creating site", + "siteErrorCreateKeyPair": "Key pair or site defaults not found", + "siteErrorCreateDefaults": "Site defaults not found", + "method": "Method", + "siteMethodDescription": "This is how you will expose connections.", + "siteLearnNewt": "Learn how to install Newt on your system", + "siteSeeConfigOnce": "You will only be able to see the configuration once.", + "siteLoadWGConfig": "Loading WireGuard configuration...", + "siteDocker": "Expand for Docker Deployment Details", + "toggle": "Toggle", + "dockerCompose": "Docker Compose", + "dockerRun": "Docker Run", + "siteLearnLocal": "Local sites do not tunnel, learn more", + "siteConfirmCopy": "I have copied the config", + "searchSitesProgress": "Search sites...", + "siteAdd": "Add Site", + "siteInstallNewt": "Install Newt", + "siteInstallNewtDescription": "Get Newt running on your system", + "WgConfiguration": "WireGuard Configuration", + "WgConfigurationDescription": "Use the following configuration to connect to your network", + "operatingSystem": "Operating System", + "commands": "Commands", + "recommended": "Recommended", + "siteNewtDescription": "For the best user experience, use Newt. It uses WireGuard under the hood and allows you to address your private resources by their LAN address on your private network from within the Pangolin dashboard.", + "siteRunsInDocker": "Runs in Docker", + "siteRunsInShell": "Runs in shell on macOS, Linux, and Windows", + "siteErrorDelete": "Error deleting site", + "siteErrorUpdate": "Failed to update site", + "siteErrorUpdateDescription": "An error occurred while updating the site.", + "siteUpdated": "Site updated", + "siteUpdatedDescription": "The site has been updated.", + "siteGeneralDescription": "Configure the general settings for this site", + "siteSettingDescription": "Configure the settings on your site", + "siteSetting": "{siteName} Settings", + "siteNewtTunnel": "Newt Tunnel (Recommended)", + "siteNewtTunnelDescription": "Easiest way to create an entrypoint into your network. No extra setup.", + "siteWg": "Basic WireGuard", + "siteWgDescription": "Use any WireGuard client to establish a tunnel. Manual NAT setup required.", + "siteLocalDescription": "Local resources only. No tunneling.", + "siteSeeAll": "See All Sites", + "siteTunnelDescription": "Determine how you want to connect to your site", + "siteNewtCredentials": "Newt Credentials", + "siteNewtCredentialsDescription": "This is how Newt will authenticate with the server", + "siteCredentialsSave": "Save Your Credentials", + "siteCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.", + "siteInfo": "Site Information", + "status": "Status", + "shareTitle": "Manage Share Links", + "shareDescription": "Create shareable links to grant temporary or permanent access to your resources", + "shareSearch": "Search share links...", + "shareCreate": "Create Share Link", + "shareErrorDelete": "Failed to delete link", + "shareErrorDeleteMessage": "An error occurred deleting link", + "shareDeleted": "Link deleted", + "shareDeletedDescription": "The link has been deleted", + "shareTokenDescription": "Your access token can be passed in two ways: as a query parameter or in the request headers. These must be passed from the client on every request for authenticated access.", + "accessToken": "Access Token", + "usageExamples": "Usage Examples", + "tokenId": "Token ID", + "requestHeades": "Request Headers", + "queryParameter": "Query Parameter", + "importantNote": "Important Note", + "shareImportantDescription": "For security reasons, using headers is recommended over query parameters when possible, as query parameters may be logged in server logs or browser history.", + "token": "Token", + "shareTokenSecurety": "Keep your access token secure. Do not share it in publicly accessible areas or client-side code.", + "shareErrorFetchResource": "Failed to fetch resources", + "shareErrorFetchResourceDescription": "An error occurred while fetching the resources", + "shareErrorCreate": "Failed to create share link", + "shareErrorCreateDescription": "An error occurred while creating the share link", + "shareCreateDescription": "Anyone with this link can access the resource", + "shareTitleOptional": "Title (optional)", + "expireIn": "Expire In", + "neverExpire": "Never expire", + "shareExpireDescription": "Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource.", + "shareSeeOnce": "You will only be able to see this linkonce. Make sure to copy it.", + "shareAccessHint": "Anyone with this link can access the resource. Share it with care.", + "shareTokenUsage": "See Access Token Usage", + "createLink": "Create Link", + "resourcesNotFound": "No resources found", + "resourceSearch": "Search resources", + "openMenu": "Open menu", + "resource": "Resource", + "title": "Title", + "created": "Created", + "expires": "Expires", + "never": "Never", + "shareErrorSelectResource": "Please select a resource", + "resourceTitle": "Manage Resources", + "resourceDescription": "Create secure proxies to your private applications", + "resourcesSearch": "Search resources...", + "resourceAdd": "Add Resource", + "resourceErrorDelte": "Error deleting resource", + "authentication": "Authentication", + "protected": "Protected", + "notProtected": "Not Protected", + "resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.", + "resourceMessageConfirm": "To confirm, please type the name of the resource below.", + "resourceQuestionRemove": "Are you sure you want to remove the resource {selectedResource} from the organization?", + "resourceHTTP": "HTTPS Resource", + "resourceHTTPDescription": "Proxy requests to your app over HTTPS using a subdomain or base domain.", + "resourceRaw": "Raw TCP/UDP Resource", + "resourceRawDescription": "Proxy requests to your app over TCP/UDP using a port number.", + "resourceCreate": "Create Resource", + "resourceCreateDescription": "Follow the steps below to create a new resource", + "resourceSeeAll": "See All Resources", + "resourceInfo": "Resource Information", + "resourceNameDescription": "This is the display name for the resource.", + "siteSelect": "Select site", + "siteSearch": "Search site", + "siteNotFound": "No site found.", + "siteSelectionDescription": "This site will provide connectivity to the resource.", + "resourceType": "Resource Type", + "resourceTypeDescription": "Determine how you want to access your resource", + "resourceHTTPSSettings": "HTTPS Settings", + "resourceHTTPSSettingsDescription": "Configure how your resource will be accessed over HTTPS", + "domainType": "Domain Type", + "subdomain": "Subdomain", + "baseDomain": "Base Domain", + "subdomnainDescription": "The subdomain where your resource will be accessible.", + "resourceRawSettings": "TCP/UDP Settings", + "resourceRawSettingsDescription": "Configure how your resource will be accessed over TCP/UDP", + "protocol": "Protocol", + "protocolSelect": "Select a protocol", + "resourcePortNumber": "Port Number", + "resourcePortNumberDescription": "The external port number to proxy requests.", + "cancel": "Cancel", + "resourceConfig": "Configuration Snippets", + "resourceConfigDescription": "Copy and paste these configuration snippets to set up your TCP/UDP resource", + "resourceAddEntrypoints": "Traefik: Add Entrypoints", + "resourceExposePorts": "Gerbil: Expose Ports in Docker Compose", + "resourceLearnRaw": "Learn how to configure TCP/UDP resources", + "resourceBack": "Back to Resources", + "resourceGoTo": "Go to Resource", + "resourceDelete": "Delete Resource", + "resourceDeleteConfirm": "Confirm Delete Resource", + "visibility": "Visibility", + "enabled": "Enabled", + "disabled": "Disabled", + "general": "General", + "generalSettings": "General Settings", + "proxy": "Proxy", + "rules": "Rules", + "resourceSettingDescription": "Configure the settings on your resource", + "resourceSetting": "{resourceName} Settings", + "alwaysAllow": "Always Allow", + "alwaysDeny": "Always Deny", + "orgSettingsDescription": "Configure your organization's general settings", + "orgGeneralSettings": "Organization Settings", + "orgGeneralSettingsDescription": "Manage your organization details and configuration", + "saveGeneralSettings": "Save General Settings", + "saveSettings": "Save Settings", + "orgDangerZone": "Danger Zone", + "orgDangerZoneDescription": "Once you delete this org, there is no going back. Please be certain.", + "orgDelete": "Delete Organization", + "orgDeleteConfirm": "Confirm Delete Organization", + "orgMessageRemove": "This action is irreversible and will delete all associated data.", + "orgMessageConfirm": "To confirm, please type the name of the organization below.", + "orgQuestionRemove": "Are you sure you want to remove the organization {selectedOrg}?", + "orgUpdated": "Organization updated", + "orgUpdatedDescription": "The organization has been updated.", + "orgErrorUpdate": "Failed to update organization", + "orgErrorUpdateMessage": "An error occurred while updating the organization.", + "orgErrorFetch": "Failed to fetch organizations", + "orgErrorFetchMessage": "An error occurred while listing your organizations", + "orgErrorDelete": "Failed to delete organization", + "orgErrorDeleteMessage": "An error occurred while deleting the organization.", + "orgDeleted": "Organization deleted", + "orgDeletedMessage": "The organization and its data has been deleted.", + "orgMissing": "Organization ID Missing", + "orgMissingMessage": "Unable to regenerate invitation without an organization ID.", + "accessUsersManage": "Manage Users", + "accessUsersDescription": "Invite users and add them to roles to manage access to your organization", + "accessUsersSearch": "Search users...", + "accessUserCreate": "Create User", + "accessUserRemove": "Remove User", + "username": "Username", + "identityProvider": "Identity Provider", + "role": "Role", + "nameRequired": "Name is required", + "accessRolesManage": "Manage Roles", + "accessRolesDescription": "Configure roles to manage access to your organization", + "accessRolesSearch": "Search roles...", + "accessRolesAdd": "Add Role", + "accessRoleDelete": "Delete Role", + "description": "Description", + "inviteTitle": "Open Invitations", + "inviteDescription": "Manage your invitations to other users", + "inviteSearch": "Search invitations...", + "minutes": "Minutes", + "hours": "Hours", + "days": "Days", + "weeks": "Weeks", + "months": "Months", + "years": "Years", + "day": "{count, plural, one {# day} other {# days}}", + "apiKeysTitle": "API Key Information", + "apiKeysConfirmCopy2": "You must confirm that you have copied the API key.", + "apiKeysErrorCreate": "Error creating API key", + "apiKeysErrorSetPermission": "Error setting permissions", + "apiKeysCreate": "Generate API Key", + "apiKeysCreateDescription": "Generate a new API key for your organization", + "apiKeysGeneralSettings": "Permissions", + "apiKeysGeneralSettingsDescription": "Determine what this API key can do", + "apiKeysList": "Your API Key", + "apiKeysSave": "Save Your API Key", + "apiKeysSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.", + "apiKeysInfo": "Your API key is:", + "apiKeysConfirmCopy": "I have copied the API key", + "generate": "Generate", + "done": "Done", + "apiKeysSeeAll": "See All API Keys", + "apiKeysPermissionsErrorLoadingActions": "Error loading API key actions", + "apiKeysPermissionsErrorUpdate": "Error setting permissions", + "apiKeysPermissionsUpdated": "Permissions updated", + "apiKeysPermissionsUpdatedDescription": "The permissions have been updated.", + "apiKeysPermissionsGeneralSettings": "Permissions", + "apiKeysPermissionsGeneralSettingsDescription": "Determine what this API key can do", + "apiKeysPermissionsSave": "Save Permissions", + "apiKeysPermissionsTitle": "Permissions", + "apiKeys": "API Keys", + "searchApiKeys": "Search API keys...", + "apiKeysAdd": "Generate API Key", + "apiKeysErrorDelete": "Error deleting API key", + "apiKeysErrorDeleteMessage": "Error deleting API key", + "apiKeysQuestionRemove": "Are you sure you want to remove the API key {selectedApiKey} from the organization?", + "apiKeysMessageRemove": "Once removed, the API key will no longer be able to be used.", + "apiKeysMessageConfirm": "To confirm, please type the name of the API key below.", + "apiKeysDeleteConfirm": "Confirm Delete API Key", + "apiKeysDelete": "Delete API Key", + "apiKeysManage": "Manage API Keys", + "apiKeysDescription": "API keys are used to authenticate with the integration API", + "apiKeysSettings": "{apiKeyName} Settings", + "userTitle": "Manage All Users", + "userDescription": "View and manage all users in the system", + "userAbount": "About User Management", + "userAbountDescription": "This table displays all root user objects in the system. Each user may belong to multiple organizations. Removing a user from an organization does not delete their root user object - they will remain in the system. To completely remove a user from the system, you must delete their root user object using the delete action in this table.", + "userServer": "Server Users", + "userSearch": "Search server users...", + "userErrorDelete": "Error deleting user", + "userDeleteConfirm": "Confirm Delete User", + "userDeleteServer": "Delete User from Server", + "userMessageRemove": "The user will be removed from all organizations and be completely removed from the server.", + "userMessageConfirm": "To confirm, please type the name of the user below.", + "userQuestionRemove": "Are you sure you want to permanently delete {selectedUser} from the server?", + "licenseKey": "License Key", + "valid": "Valid", + "numberOfSites": "Number of Sites", + "licenseKeySearch": "Search license keys...", + "licenseKeyAdd": "Add License Key", + "type": "Type", + "licenseKeyRequired": "License key is required", + "licenseTermsAgree": "You must agree to the license terms", + "licenseErrorKeyLoad": "Failed to load license keys", + "licenseErrorKeyLoadDescription": "An error occurred loading license keys.", + "licenseErrorKeyDelete": "Failed to delete license key", + "licenseErrorKeyDeleteDescription": "An error occurred deleting license key.", + "licenseKeyDeleted": "License key deleted", + "licenseKeyDeletedDescription": "The license key has been deleted.", + "licenseErrorKeyActivate": "Failed to activate license key", + "licenseErrorKeyActivateDescription": "An error occurred while activating the license key.", + "licenseAbout": "About Licensing", + "communityEdition": "Community Edition", + "licenseAboutDescription": "This is for business and enterprise users who are using Pangolin in a commercial environment. If you are using Pangolin for personal use, you can ignore this section.", + "licenseKeyActivated": "License key activated", + "licenseKeyActivatedDescription": "The license key has been successfully activated.", + "licenseErrorKeyRecheck": "Failed to recheck license keys", + "licenseErrorKeyRecheckDescription": "An error occurred rechecking license keys.", + "licenseErrorKeyRechecked": "License keys rechecked", + "licenseErrorKeyRecheckedDescription": "All license keys have been rechecked", + "licenseActivateKey": "Activate License Key", + "licenseActivateKeyDescription": "Enter a license key to activate it.", + "licenseActivate": "Activate License", + "licenseAgreement": "By checking this box, you confirm that you have read and agree to the license terms corresponding to the tier associated with your license key.", + "fossorialLicense": "View Fossorial Commercial License & Subscription Terms", + "licenseMessageRemove": "This will remove the license key and all associated permissions granted by it.", + "licenseMessageConfirm": "To confirm, please type the license key below.", + "licenseQuestionRemove": "Are you sure you want to delete the license key {selectedKey} ?", + "licenseKeyDelete": "Delete License Key", + "licenseKeyDeleteConfirm": "Confirm Delete License Key", + "licenseTitle": "Manage License Status", + "licenseTitleDescription": "View and manage license keys in the system", + "licenseHost": "Host License", + "licenseHostDescription": "Manage the main license key for the host.", + "licensedNot": "Not Licensed", + "hostId": "Host ID", + "licenseReckeckAll": "Recheck All Keys", + "licenseSiteUsage": "Sites Usage", + "licenseSiteUsageDecsription": "View the number of sites using this license.", + "licenseNoSiteLimit": "There is no limit on the number of sites using an unlicensed host.", + "licensePurchase": "Purchase License", + "licensePurchaseSites": "Purchase Additional Sites", + "licenseSitesUsedMax": "{usedSites} of {maxSites} sites used", + "licenseSitesUsed": "{count, plural, =0 {# sites} one {# site} other {# sites}} in system.", + "licensePurchaseDescription": "Choose how many sites you want to {selectedMode, select, license {purchase a license for. You can always add more sites later.} other {add to your existing license.}}", + "licenseFee": "License fee", + "licensePriceSite": "Price per site", + "total": "Total", + "licenseContinuePayment": "Continue to Payment", + "pricingPage": "pricing page", + "pricingPortal": "See Purchase Portal", + "licensePricingPage": "For the most up-to-date pricing and discounts, please visit the ", + "invite": "Invitations", + "inviteRegenerate": "Regenerate Invitation", + "inviteRegenerateDescription": "Revoke previous invitation and create a new one", + "inviteRemove": "Remove Invitation", + "inviteRemoveError": "Failed to remove invitation", + "inviteRemoveErrorDescription": "An error occurred while removing the invitation.", + "inviteRemoved": "Invitation removed", + "inviteRemovedDescription": "The invitation for {email} has been removed.", + "inviteQuestionRemove": "Are you sure you want to remove the invitation {email}?", + "inviteMessageRemove": "Once removed, this invitation will no longer be valid. You can always re-invite the user later.", + "inviteMessageConfirm": "To confirm, please type the email address of the invitation below.", + "inviteQuestionRegenerate": "Are you sure you want to regenerate the invitation for {email}? This will revoke the previous invitation.", + "inviteRemoveConfirm": "Confirm Remove Invitation", + "inviteRegenerated": "Invitation Regenerated", + "inviteSent": "A new invitation has been sent to {email}.", + "inviteSentEmail": "Send email notification to the user", + "inviteGenerate": "A new invitation has been generated for {email}.", + "inviteDuplicateError": "Duplicate Invite", + "inviteDuplicateErrorDescription": "An invitation for this user already exists.", + "inviteRateLimitError": "Rate Limit Exceeded", + "inviteRateLimitErrorDescription": "You have exceeded the limit of 3 regenerations per hour. Please try again later.", + "inviteRegenerateError": "Failed to Regenerate Invitation", + "inviteRegenerateErrorDescription": "An error occurred while regenerating the invitation.", + "inviteValidityPeriod": "Validity Period", + "inviteValidityPeriodSelect": "Select validity period", + "inviteRegenerateMessage": "The invitation has been regenerated. The user must access the link below to accept the invitation.", + "inviteRegenerateButton": "Regenerate", + "expiresAt": "Expires At", + "accessRoleUnknown": "Unknown Role", + "placeholder": "Placeholder", + "userErrorOrgRemove": "Failed to remove user", + "userErrorOrgRemoveDescription": "An error occurred while removing the user.", + "userOrgRemoved": "User removed", + "userOrgRemovedDescription": "The user {email} has been removed from the organization.", + "userQuestionOrgRemove": "Are you sure you want to remove {email} from the organization?", + "userMessageOrgRemove": "Once removed, this user will no longer have access to the organization. You can always re-invite them later, but they will need to accept the invitation again.", + "userMessageOrgConfirm": "To confirm, please type the name of the of the user below.", + "userRemoveOrgConfirm": "Confirm Remove User", + "userRemoveOrg": "Remove User from Organization", + "users": "Users", + "accessRoleMember": "Member", + "accessRoleOwner": "Owner", + "userConfirmed": "Confirmed", + "idpNameInternal": "Internal", + "emailInvalid": "Invalid email address", + "inviteValidityDuration": "Please select a duration", + "accessRoleSelectPlease": "Please select a role", + "usernameRequired": "Username is required", + "idpSelectPlease": "Please select an identity provider", + "idpGenericOidc": "Generic OAuth2/OIDC provider.", + "accessRoleErrorFetch": "Failed to fetch roles", + "accessRoleErrorFetchDescription": "An error occurred while fetching the roles", + "idpErrorFetch": "Failed to fetch identity providers", + "idpErrorFetchDescription": "An error occurred while fetching identity providers", + "userErrorExists": "User Already Exists", + "userErrorExistsDescription": "This user is already a member of the organization.", + "inviteError": "Failed to invite user", + "inviteErrorDescription": "An error occurred while inviting the user", + "userInvited": "User invited", + "userInvitedDescription": "The user has been successfully invited.", + "userErrorCreate": "Failed to create user", + "userErrorCreateDescription": "An error occurred while creating the user", + "userCreated": "User created", + "userCreatedDescription": "The user has been successfully created.", + "userTypeInternal": "Internal User", + "userTypeInternalDescription": "Invite a user to join your organization directly.", + "userTypeExternal": "External User", + "userTypeExternalDescription": "Create a user with an external identity provider.", + "accessUserCreateDescription": "Follow the steps below to create a new user", + "userSeeAll": "See All Users", + "userTypeTitle": "User Type", + "userTypeDescription": "Determine how you want to create the user", + "userSettings": "User Information", + "userSettingsDescription": "Enter the details for the new user", + "inviteEmailSent": "Send invite email to user", + "inviteValid": "Valid For", + "selectDuration": "Select duration", + "accessRoleSelect": "Select role", + "inviteEmailSentDescription": "An email has been sent to the user with the access link below. They must access the link to accept the invitation.", + "inviteSentDescription": "The user has been invited. They must access the link below to accept the invitation.", + "inviteExpiresIn": "The invite will expire in {days, plural, one {# day} other {# days}}.", + "idpTitle": "Identity Provider", + "idpSelect": "Select the identity provider for the external user", + "idpNotConfigured": "No identity providers are configured. Please configure an identity provider before creating external users.", + "usernameUniq": "This must match the unique username that exists in the selected identity provider.", + "emailOptional": "Email (Optional)", + "nameOptional": "Name (Optional)", + "accessControls": "Access Controls", + "userDescription2": "Manage the settings on this user", + "accessRoleErrorAdd": "Failed to add user to role", + "accessRoleErrorAddDescription": "An error occurred while adding user to the role.", + "userSaved": "User saved", + "userSavedDescription": "The user has been updated.", + "accessControlsDescription": "Manage what this user can access and do in the organization", + "accessControlsSubmit": "Save Access Controls", + "roles": "Roles", + "accessUsersRoles": "Manage Users & Roles", + "accessUsersRolesDescription": "Invite users and add them to roles to manage access to your organization", + "key": "Key", + "createdAt": "Created At", + "proxyErrorInvalidHeader": "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header.", + "proxyErrorTls": "Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name.", + "proxyEnableSSL": "Enable SSL (https)", + "targetErrorFetch": "Failed to fetch targets", + "targetErrorFetchDescription": "An error occurred while fetching targets", + "siteErrorFetch": "Failed to fetch resource", + "siteErrorFetchDescription": "An error occurred while fetching resource", + "targetErrorDuplicate": "Duplicate target", + "targetErrorDuplicateDescription": "A target with these settings already exists", + "targetWireGuardErrorInvalidIp": "Invalid target IP", + "targetWireGuardErrorInvalidIpDescription": "Target IP must be within the site subnet", + "targetsUpdated": "Targets updated", + "targetsUpdatedDescription": "Targets and settings updated successfully", + "targetsErrorUpdate": "Failed to update targets", + "targetsErrorUpdateDescription": "An error occurred while updating targets", + "targetTlsUpdate": "TLS settings updated", + "targetTlsUpdateDescription": "Your TLS settings have been updated successfully", + "targetErrorTlsUpdate": "Failed to update TLS settings", + "targetErrorTlsUpdateDescription": "An error occurred while updating TLS settings", + "proxyUpdated": "Proxy settings updated", + "proxyUpdatedDescription": "Your proxy settings have been updated successfully", + "proxyErrorUpdate": "Failed to update proxy settings", + "proxyErrorUpdateDescription": "An error occurred while updating proxy settings", + "targetAddr": "IP / Hostname", + "targetPort": "Port", + "targetProtocol": "Protocol", + "targetTlsSettings": "Secure Connection Configuration", + "targetTlsSettingsDescription": "Configure SSL/TLS settings for your resource", + "targetTlsSettingsAdvanced": "Advanced TLS Settings", + "targetTlsSni": "TLS Server Name (SNI)", + "targetTlsSniDescription": "The TLS Server Name to use for SNI. Leave empty to use the default.", + "targetTlsSubmit": "Save Settings", + "targets": "Targets Configuration", + "targetsDescription": "Set up targets to route traffic to your services", + "targetStickySessions": "Enable Sticky Sessions", + "targetStickySessionsDescription": "Keep connections on the same backend target for their entire session.", + "methodSelect": "Select method", + "targetSubmit": "Add Target", + "targetNoOne": "No targets. Add a target using the form.", + "targetNoOneDescription": "Adding more than one target above will enable load balancing.", + "targetsSubmit": "Save Targets", + "proxyAdditional": "Additional Proxy Settings", + "proxyAdditionalDescription": "Configure how your resource handles proxy settings", + "proxyCustomHeader": "Custom Host Header", + "proxyCustomHeaderDescription": "The host header to set when proxying requests. Leave empty to use the default.", + "proxyAdditionalSubmit": "Save Proxy Settings", + "subnetMaskErrorInvalid": "Invalid subnet mask. Must be between 0 and 32.", + "ipAddressErrorInvalidFormat": "Invalid IP address format", + "ipAddressErrorInvalidOctet": "Invalid IP address octet", + "path": "Path", + "ipAddressRange": "IP Range", + "rulesErrorFetch": "Failed to fetch rules", + "rulesErrorFetchDescription": "An error occurred while fetching rules", + "rulesErrorDuplicate": "Duplicate rule", + "rulesErrorDuplicateDescription": "A rule with these settings already exists", + "rulesErrorInvalidIpAddressRange": "Invalid CIDR", + "rulesErrorInvalidIpAddressRangeDescription": "Please enter a valid CIDR value", + "rulesErrorInvalidUrl": "Invalid URL path", + "rulesErrorInvalidUrlDescription": "Please enter a valid URL path value", + "rulesErrorInvalidIpAddress": "Invalid IP", + "rulesErrorInvalidIpAddressDescription": "Please enter a valid IP address", + "rulesErrorUpdate": "Failed to update rules", + "rulesErrorUpdateDescription": "An error occurred while updating rules", + "rulesUpdated": "Enable Rules", + "rulesUpdatedDescription": "Rule evaluation has been updated", + "rulesMatchIpAddressRangeDescription": "Enter an address in CIDR format (e.g., 103.21.244.0/22)", + "rulesMatchIpAddress": "Enter an IP address (e.g., 103.21.244.12)", + "rulesMatchUrl": "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)", + "rulesErrorInvalidPriority": "Invalid Priority", + "rulesErrorInvalidPriorityDescription": "Please enter a valid priority", + "rulesErrorDuplicatePriority": "Duplicate Priorities", + "rulesErrorDuplicatePriorityDescription": "Please enter unique priorities", + "ruleUpdated": "Rules updated", + "ruleUpdatedDescription": "Rules updated successfully", + "ruleErrorUpdate": "Operation failed", + "ruleErrorUpdateDescription": "An error occurred during the save operation", + "rulesPriority": "Priority", + "rulesAction": "Action", + "rulesMatchType": "Match Type", + "value": "Value", + "rulesAbout": "About Rules", + "rulesAboutDescription": "Rules allow you to control access to your resource based on a set of criteria. You can create rules to allow or deny access based on IP address or URL path.", + "rulesActions": "Actions", + "rulesActionAlwaysAllow": "Always Allow: Bypass all authentication methods", + "rulesActionAlwaysDeny": "Always Deny: Block all requests; no authentication can be attempted", + "rulesMatchCriteria": "Matching Criteria", + "rulesMatchCriteriaIpAddress": "Match a specific IP address", + "rulesMatchCriteriaIpAddressRange": "Match a range of IP addresses in CIDR notation", + "rulesMatchCriteriaUrl": "Match a URL path or pattern", + "rulesEnable": "Enable Rules", + "rulesEnableDescription": "Enable or disable rule evaluation for this resource", + "rulesResource": "Resource Rules Configuration", + "rulesResourceDescription": "Configure rules to control access to your resource", + "ruleSubmit": "Add Rule", + "rulesNoOne": "No rules. Add a rule using the form.", + "rulesOrder": "Rules are evaluated by priority in ascending order.", + "rulesSubmit": "Save Rules", + "resourceErrorCreate": "Error creating resource", + "resourceErrorCreateDescription": "An error occurred when creating the resource", + "resourceErrorCreateMessage": "Error creating resource:", + "resourceErrorCreateMessageDescription": "An unexpected error occurred", + "sitesErrorFetch": "Error fetching sites", + "sitesErrorFetchDescription": "An error occurred when fetching the sites", + "domainsErrorFetch": "Error fetching domains", + "domainsErrorFetchDescription": "An error occurred when fetching the domains", + "none": "None", + "unknown": "Unknown", + "resources": "Resources", + "resourcesDescription": "Resources are proxies to applications running on your private network. Create a resource for any HTTP/HTTPS or raw TCP/UDP service on your private network. Each resource must be connected to a site to enable private, secure connectivity through an encrypted WireGuard tunnel.", + "resourcesWireGuardConnect": "Secure connectivity with WireGuard encryption", + "resourcesMultipleAuthenticationMethods": "Configure multiple authentication methods", + "resourcesUsersRolesAccess": "User and role-based access control", + "resourcesErrorUpdate": "Failed to toggle resource", + "resourcesErrorUpdateDescription": "An error occurred while updating the resource", + "access": "Access", + "shareLink": "{resource} Share Link", + "resourceSelect": "Select resource", + "shareLinks": "Share Links", + "share": "Shareable Links", + "shareDescription2": "Create shareable links to your resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one.", + "shareEasyCreate": "Easy to create and share", + "shareConfigurableExpirationDuration": "Configurable expiration duration", + "shareSecureAndRevocable": "Secure and revocable", + "nameMin": "Name must be at least {len} characters.", + "nameMax": "Name must not be longer than {len} characters.", + "sitesConfirmCopy": "Please confirm that you have copied the config.", + "unknownCommand": "Unknown command", + "newtErrorFetchReleases": "Failed to fetch release info: {err}", + "newtErrorFetchLatest": "Error fetching latest release: {err}", + "newtEndpoint": "Newt Endpoint", + "newtId": "Newt ID", + "newtSecretKey": "Newt Secret Key", + "architecture": "Architecture", + "sites": "Sites", + "siteWgAnyClients": "Use any WireGuard client to connect. You will have to address your internal resources using the peer IP.", + "siteWgCompatibleAllClients": "Compatible with all WireGuard clients", + "siteWgManualConfigurationRequired": "Manual configuration required", + "userErrorNotAdminOrOwner": "User is not an admin or owner", + "pangolinSettings": "Settings - Pangolin", + "accessRoleYour": "Your role:", + "accessRoleSelect2": "Select a role", + "accessUserSelect": "Select a user", + "otpEmailEnter": "Enter an email", + "otpEmailEnterDescription": "Press enter to add an email after typing it in the input field.", + "otpEmailErrorInvalid": "Invalid email address. Wildcard (*) must be the entire local part.", + "otpEmailSmtpRequired": "SMTP Required", + "otpEmailSmtpRequiredDescription": "SMTP must be enabled on the server to use one-time password authentication.", + "otpEmailTitle": "One-time Passwords", + "otpEmailTitleDescription": "Require email-based authentication for resource access", + "otpEmailWhitelist": "Email Whitelist", + "otpEmailWhitelistList": "Whitelisted Emails", + "otpEmailWhitelistListDescription": "Only users with these email addresses will be able to access this resource. They will be prompted to enter a one-time password sent to their email. Wildcards (*@example.com) can be used to allow any email address from a domain.", + "otpEmailWhitelistSave": "Save Whitelist", + "passwordAdd": "Add Password", + "passwordRemove": "Remove Password", + "pincodeAdd": "Add PIN Code", + "pincodeRemove": "Remove PIN Code", + "resourceAuthMethods": "Authentication Methods", + "resourceAuthMethodsDescriptions": "Allow access to the resource via additional auth methods", + "resourceAuthSettingsSave": "Saved successfully", + "resourceAuthSettingsSaveDescription": "Authentication settings have been saved", + "resourceErrorAuthFetch": "Failed to fetch data", + "resourceErrorAuthFetchDescription": "An error occurred while fetching the data", + "resourceErrorPasswordRemove": "Error removing resource password", + "resourceErrorPasswordRemoveDescription": "An error occurred while removing the resource password", + "resourceErrorPasswordSetup": "Error setting resource password", + "resourceErrorPasswordSetupDescription": "An error occurred while setting the resource password", + "resourceErrorPincodeRemove": "Error removing resource pincode", + "resourceErrorPincodeRemoveDescription": "An error occurred while removing the resource pincode", + "resourceErrorPincodeSetup": "Error setting resource PIN code", + "resourceErrorPincodeSetupDescription": "An error occurred while setting the resource PIN code", + "resourceErrorUsersRolesSave": "Failed to set roles", + "resourceErrorUsersRolesSaveDescription": "An error occurred while setting the roles", + "resourceErrorWhitelistSave": "Failed to save whitelist", + "resourceErrorWhitelistSaveDescription": "An error occurred while saving the whitelist", + "resourcePasswordSubmit": "Enable Password Protection", + "resourcePasswordProtection": "Password Protection {status}", + "resourcePasswordRemove": "Resource password removed", + "resourcePasswordRemoveDescription": "The resource password has been removed successfully", + "resourcePasswordSetup": "Resource password set", + "resourcePasswordSetupDescription": "The resource password has been set successfully", + "resourcePasswordSetupTitle": "Set Password", + "resourcePasswordSetupTitleDescription": "Set a password to protect this resource", + "resourcePincode": "PIN Code", + "resourcePincodeSubmit": "Enable PIN Code Protection", + "resourcePincodeProtection": "PIN Code Protection {status}", + "resourcePincodeRemove": "Resource pincode removed", + "resourcePincodeRemoveDescription": "The resource password has been removed successfully", + "resourcePincodeSetup": "Resource PIN code set", + "resourcePincodeSetupDescription": "The resource pincode has been set successfully", + "resourcePincodeSetupTitle": "Set Pincode", + "resourcePincodeSetupTitleDescription": "Set a pincode to protect this resource", + "resourceRoleDescription": "Admins can always access this resource.", + "resourceUsersRoles": "Users & Roles", + "resourceUsersRolesDescription": "Configure which users and roles can visit this resource", + "resourceUsersRolesSubmit": "Save Users & Roles", + "resourceWhitelistSave": "Saved successfully", + "resourceWhitelistSaveDescription": "Whitelist settings have been saved", + "ssoUse": "Use Platform SSO", + "ssoUseDescription": "Existing users will only have to log in once for all resources that have this enabled.", + "proxyErrorInvalidPort": "Invalid port number", + "subdomainErrorInvalid": "Invalid subdomain", + "domainErrorFetch": "Error fetching domains", + "domainErrorFetchDescription": "An error occurred when fetching the domains", + "resourceErrorUpdate": "Failed to update resource", + "resourceErrorUpdateDescription": "An error occurred while updating the resource", + "resourceUpdated": "Resource updated", + "resourceUpdatedDescription": "The resource has been updated successfully", + "resourceErrorTransfer": "Failed to transfer resource", + "resourceErrorTransferDescription": "An error occurred while transferring the resource", + "resourceTransferred": "Resource transferred", + "resourceTransferredDescription": "The resource has been transferred successfully", + "resourceErrorToggle": "Failed to toggle resource", + "resourceErrorToggleDescription": "An error occurred while updating the resource", + "resourceVisibilityTitle": "Visibility", + "resourceVisibilityTitleDescription": "Completely enable or disable resource visibility", + "resourceGeneral": "General Settings", + "resourceGeneralDescription": "Configure the general settings for this resource", + "resourceEnable": "Enable Resource", + "resourceTransfer": "Transfer Resource", + "resourceTransferDescription": "Transfer this resource to a different site", + "resourceTransferSubmit": "Transfer Resource", + "siteDestination": "Destination Site", + "searchSites": "Search sites", + "accessRoleCreate": "Create Role", + "accessRoleCreateDescription": "Create a new role to group users and manage their permissions.", + "accessRoleCreateSubmit": "Create Role", + "accessRoleCreated": "Role created", + "accessRoleCreatedDescription": "The role has been successfully created.", + "accessRoleErrorCreate": "Failed to create role", + "accessRoleErrorCreateDescription": "An error occurred while creating the role.", + "accessRoleErrorNewRequired": "New role is required", + "accessRoleErrorRemove": "Failed to remove role", + "accessRoleErrorRemoveDescription": "An error occurred while removing the role.", + "accessRoleName": "Role Name", + "accessRoleQuestionRemove": "You're about to delete the {name} role. You cannot undo this action.", + "accessRoleRemove": "Remove Role", + "accessRoleRemoveDescription": "Remove a role from the organization", + "accessRoleRemoveSubmit": "Remove Role", + "accessRoleRemoved": "Role removed", + "accessRoleRemovedDescription": "The role has been successfully removed.", + "accessRoleRequiredRemove": "Before deleting this role, please select a new role to transfer existing members to.", + "manage": "Manage", + "sitesNotFound": "No sites found.", + "pangolinServerAdmin": "Server Admin - Pangolin", + "licenseTierProfessional": "Professional License", + "licenseTierEnterprise": "Enterprise License", + "licenseTierCommercial": "Commercial License", + "licensed": "Licensed", + "yes": "Yes", + "no": "No", + "sitesAdditional": "Additional Sites", + "licenseKeys": "License Keys", + "sitestCountDecrease": "Decrease site count", + "sitestCountIncrease": "Increase site count", + "idpManage": "Manage Identity Providers", + "idpManageDescription": "View and manage identity providers in the system", + "idpDeletedDescription": "Identity provider deleted successfully", + "idpOidc": "OAuth2/OIDC", + "idpQuestionRemove": "Are you sure you want to permanently delete the identity provider {name}?", + "idpMessageRemove": "This will remove the identity provider and all associated configurations. Users who authenticate through this provider will no longer be able to log in.", + "idpMessageConfirm": "To confirm, please type the name of the identity provider below.", + "idpConfirmDelete": "Confirm Delete Identity Provider", + "idpDelete": "Delete Identity Provider", + "idp": "Identity Providers", + "idpSearch": "Search identity providers...", + "idpAdd": "Add Identity Provider", + "idpClientIdRequired": "Client ID is required.", + "idpClientSecretRequired": "Client Secret is required.", + "idpErrorAuthUrlInvalid": "Auth URL must be a valid URL.", + "idpErrorTokenUrlInvalid": "Token URL must be a valid URL.", + "idpPathRequired": "Identifier Path is required.", + "idpScopeRequired": "Scopes are required.", + "idpOidcDescription": "Configure an OpenID Connect identity provider", + "idpCreatedDescription": "Identity provider created successfully", + "idpCreate": "Create Identity Provider", + "idpCreateDescription": "Configure a new identity provider for user authentication", + "idpSeeAll": "See All Identity Providers", + "idpSettingsDescription": "Configure the basic information for your identity provider", + "idpDisplayName": "A display name for this identity provider", + "idpAutoProvisionUsers": "Auto Provision Users", + "idpAutoProvisionUsersDescription": "When enabled, users will be automatically created in the system upon first login with the ability to map users to roles and organizations.", + "licenseBadge": "Professional", + "idpType": "Provider Type", + "idpTypeDescription": "Select the type of identity provider you want to configure", + "idpOidcConfigure": "OAuth2/OIDC Configuration", + "idpOidcConfigureDescription": "Configure the OAuth2/OIDC provider endpoints and credentials", + "idpClientId": "Client ID", + "idpClientIdDescription": "The OAuth2 client ID from your identity provider", + "idpClientSecret": "Client Secret", + "idpClientSecretDescription": "The OAuth2 client secret from your identity provider", + "idpAuthUrl": "Authorization URL", + "idpAuthUrlDescription": "The OAuth2 authorization endpoint URL", + "idpTokenUrl": "Token URL", + "idpTokenUrlDescription": "The OAuth2 token endpoint URL", + "idpOidcConfigureAlert": "Important Information", + "idpOidcConfigureAlertDescription": "After creating the identity provider, you will need to configure the callback URL in your identity provider's settings. The callback URL will be provided after successful creation.", + "idpToken": "Token Configuration", + "idpTokenDescription": "Configure how to extract user information from the ID token", + "idpJmespathAbout": "About JMESPath", + "idpJmespathAboutDescription": "The paths below use JMESPath syntax to extract values from the ID token.", + "idpJmespathAboutDescriptionLink": "Learn more about JMESPath", + "idpJmespathLabel": "Identifier Path", + "idpJmespathLabelDescription": "The path to the user identifier in the ID token", + "idpJmespathEmailPathOptional": "Email Path (Optional)", + "idpJmespathEmailPathOptionalDescription": "The path to the user's email in the ID token", + "idpJmespathNamePathOptional": "Name Path (Optional)", + "idpJmespathNamePathOptionalDescription": "The path to the user's name in the ID token", + "idpOidcConfigureScopes": "Scopes", + "idpOidcConfigureScopesDescription": "Space-separated list of OAuth2 scopes to request", + "idpSubmit": "Create Identity Provider", + "orgPolicies": "Organization Policies", + "idpSettings": "{idpName} Settings", + "idpCreateSettingsDescription": "Configure the settings for your identity provider", + "roleMapping": "Role Mapping", + "orgMapping": "Organization Mapping", + "orgPoliciesSearch": "Search organization policies...", + "orgPoliciesAdd": "Add Organization Policy", + "orgRequired": "Organization is required", + "error": "Error", + "success": "Success", + "orgPolicyAddedDescription": "Policy added successfully", + "orgPolicyUpdatedDescription": "Policy updated successfully", + "orgPolicyDeletedDescription": "Policy deleted successfully", + "defaultMappingsUpdatedDescription": "Default mappings updated successfully", + "orgPoliciesAbout": "About Organization Policies", + "orgPoliciesAboutDescription": "Organization policies are used to control access to organizations based on the user's ID token. You can specify JMESPath expressions to extract role and organization information from the ID token.", + "orgPoliciesAboutDescriptionLink": "See documentation, for more information.", + "defaultMappingsOptional": "Default Mappings (Optional)", + "defaultMappingsOptionalDescription": "The default mappings are used when when there is not an organization policy defined for an organization. You can specify the default role and organization mappings to fall back to here.", + "defaultMappingsRole": "Default Role Mapping", + "defaultMappingsRoleDescription": "The result of this expression must return the role name as defined in the organization as a string.", + "defaultMappingsOrg": "Default Organization Mapping", + "defaultMappingsOrgDescription": "This expression must return the org ID or true for the user to be allowed to access the organization.", + "defaultMappingsSubmit": "Save Default Mappings", + "orgPoliciesEdit": "Edit Organization Policy", + "org": "Organization", + "orgSelect": "Select organization", + "orgSearch": "Search org", + "orgNotFound": "No org found.", + "roleMappingPathOptional": "Role Mapping Path (Optional)", + "orgMappingPathOptional": "Organization Mapping Path (Optional)", + "orgPolicyUpdate": "Update Policy", + "orgPolicyAdd": "Add Policy", + "orgPolicyConfig": "Configure access for an organization", + "idpUpdatedDescription": "Identity provider updated successfully", + "redirectUrl": "Redirect URL", + "redirectUrlAbout": "About Redirect URL", + "redirectUrlAboutDescription": "This is the URL to which users will be redirected after authentication. You need to configure this URL in your identity provider settings.", + "pangolinAuth": "Auth - Pangolin", + "verificationCodeLengthRequirements": "Your verification code must be 8 characters.", + "errorOccurred": "An error occurred", + "emailErrorVerify": "Failed to verify email:", + "emailVerified": "Email successfully verified! Redirecting you...", + "verificationCodeErrorResend": "Failed to resend verification code:", + "verificationCodeResend": "Verification code resent", + "verificationCodeResendDescription": "We've resent a verification code to your email address. Please check your inbox.", + "emailVerify": "Verify Email", + "emailVerifyDescription": "Enter the verification code sent to your email address.", + "verificationCode": "Verification Code", + "verificationCodeEmailSent": "We sent a verification code to your email address.", + "submit": "Submit", + "emailVerifyResendProgress": "Resending...", + "emailVerifyResend": "Didn't receive a code? Click here to resend", + "passwordNotMatch": "Passwords do not match", + "signupError": "An error occurred while signing up", + "pangolinLogoAlt": "Pangolin Logo", + "inviteAlready": "Looks like you've been invited!", + "inviteAlreadyDescription": "To accept the invite, you must log in or create an account.", + "signupQuestion": "Already have an account?", + "login": "Log in", + "resourceNotFound": "Resource Not Found", + "resourceNotFoundDescription": "The resource you're trying to access does not exist.", + "pincodeRequirementsLength": "PIN must be exactly 6 digits", + "pincodeRequirementsChars": "PIN must only contain numbers", + "passwordRequirementsLength": "Password must be at least 1 character long", + "otpEmailRequirementsLength": "OTP must be at least 1 character long", + "otpEmailSent": "OTP Sent", + "otpEmailSentDescription": "An OTP has been sent to your email", + "otpEmailErrorAuthenticate": "Failed to authenticate with email", + "pincodeErrorAuthenticate": "Failed to authenticate with pincode", + "passwordErrorAuthenticate": "Failed to authenticate with password", + "poweredBy": "Powered by", + "authenticationRequired": "Authentication Required", + "authenticationMethodChoose": "Choose your preferred method to access {name}", + "authenticationRequest": "You must authenticate to access {name}", + "user": "User", + "pincodeInput": "6-digit PIN Code", + "pincodeSubmit": "Log in with PIN", + "passwordSubmit": "Log In with Password", + "otpEmailDescription": "A one-time code will be sent to this email.", + "otpEmailSend": "Send One-time Code", + "otpEmail": "One-Time Password (OTP)", + "otpEmailSubmit": "Submit OTP", + "backToEmail": "Back to Email", + "noSupportKey": "Server is running without a supporter key. Consider supporting the project!", + "accessDenied": "Access Denied", + "accessDeniedDescription": "You're not allowed to access this resource. If this is a mistake, please contact the administrator.", + "accessTokenError": "Error checking access token", + "accessGranted": "Access Granted", + "accessUrlInvalid": "Access URL Invalid", + "accessGrantedDescription": "You have been granted access to this resource. Redirecting you...", + "accessUrlInvalidDescription": "This shared access URL is invalid. Please contact the resource owner for a new URL.", + "tokenInvalid": "Invalid token", + "pincodeInvalid": "Invalid code", + "passwordErrorRequestReset": "Failed to request reset:", + "passwordErrorReset": "Failed to reset password:", + "passwordResetSuccess": "Password reset successfully! Back to log in...", + "passwordReset": "Reset Password", + "passwordResetDescription": "Follow the steps to reset your password", + "passwordResetSent": "We'll send a password reset code to this email address.", + "passwordResetCode": "Reset Code", + "passwordResetCodeDescription": "Check your email for the reset code.", + "passwordNew": "New Password", + "passwordNewConfirm": "Confirm New Password", + "pincodeAuth": "Authenticator Code", + "pincodeSubmit2": "Submit Code", + "passwordResetSubmit": "Request Reset", + "passwordBack": "Back to Password", + "loginBack": "Go back to log in", + "signup": "Sign up", + "loginStart": "Log in to get started", + "idpOidcTokenValidating": "Validating OIDC token", + "idpOidcTokenResponse": "Validate OIDC token response", + "idpErrorOidcTokenValidating": "Error validating OIDC token", + "idpConnectingTo": "Connecting to {name}", + "idpConnectingToDescription": "Validating your identity", + "idpConnectingToProcess": "Connecting...", + "idpConnectingToFinished": "Connected", + "idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.", + "idpErrorNotFound": "IdP not found", + "inviteInvalid": "Invalid Invite", + "inviteInvalidDescription": "The invite link is invalid.", + "inviteErrorWrongUser": "Invite is not for this user", + "inviteErrorUserNotExists": "User does not exist. Please create an account first.", + "inviteErrorLoginRequired": "You must be logged in to accept an invite", + "inviteErrorExpired": "The invite may have expired", + "inviteErrorRevoked": "The invite might have been revoked", + "inviteErrorTypo": "There could be a typo in the invite link", + "pangolinSetup": "Setup - Pangolin", + "orgNameRequired": "Organization name is required", + "orgIdRequired": "Organization ID is required", + "orgErrorCreate": "An error occurred while creating org", + "pageNotFound": "Page Not Found", + "pageNotFoundDescription": "Oops! The page you're looking for doesn't exist.", + "overview": "Overview", + "home": "Home", + "accessControl": "Access Control", + "settings": "Settings", + "usersAll": "All Users", + "license": "License", + "pangolinDashboard": "Dashboard - Pangolin", + "noResults": "No results found.", + "terabytes": "{count} TB", + "gigabytes": "{count} GB", + "megabytes": "{count} MB", + "tagsEntered": "Entered Tags", + "tagsEnteredDescription": "These are the tags you`ve entered.", + "tagsWarnCannotBeLessThanZero": "maxTags and minTags cannot be less than 0", + "tagsWarnNotAllowedAutocompleteOptions": "Tag not allowed as per autocomplete options", + "tagsWarnInvalid": "Invalid tag as per validateTag", + "tagWarnTooShort": "Tag {tagText} is too short", + "tagWarnTooLong": "Tag {tagText} is too long", + "tagsWarnReachedMaxNumber": "Reached the maximum number of tags allowed", + "tagWarnDuplicate": "Duplicate tag {tagText} not added", + "supportKeyInvalid": "Invalid Key", + "supportKeyInvalidDescription": "Your supporter key is invalid.", + "supportKeyValid": "Valid Key", + "supportKeyValidDescription": "Your supporter key has been validated. Thank you for your support!", + "supportKeyErrorValidationDescription": "Failed to validate supporter key.", + "supportKey": "Support Development and Adopt a Pangolin!", + "supportKeyDescription": "Purchase a supporter key to help us continue developing Pangolin for the community. Your contribution allows us to commit more time to maintain and add new features to the application for everyone. We will never use this to paywall features. This is separate from any Commercial Edition.", + "supportKeyPet": "You will also get to adopt and meet your very own pet Pangolin!", + "supportKeyPurchase": "Payments are processed via GitHub. Afterward, you can retrieve your key on", + "supportKeyPurchaseLink": "our website", + "supportKeyPurchase2": "and redeem it here.", + "supportKeyLearnMore": "Learn more.", + "supportKeyOptions": "Please select the option that best suits you.", + "supportKetOptionFull": "Full Supporter", + "forWholeServer": "For the whole server", + "lifetimePurchase": "Lifetime purchase", + "supporterStatus": "Supporter status", + "buy": "Buy", + "supportKeyOptionLimited": "Limited Supporter", + "forFiveUsers": "For 5 or less users", + "supportKeyRedeem": "Redeem Supporter Key", + "supportKeyHideSevenDays": "Hide for 7 days", + "supportKeyEnter": "Enter Supporter Key", + "supportKeyEnterDescription": "Meet your very own pet Pangolin!", + "githubUsername": "GitHub Username", + "supportKeyInput": "Supporter Key", + "supportKeyBuy": "Buy Supporter Key", + "logoutError": "Error logging out", + "signingAs": "Signed in as", + "serverAdmin": "Server Admin", + "otpEnable": "Enable Two-factor", + "otpDisable": "Disable Two-factor", + "logout": "Log Out", + "licenseTierProfessionalRequired": "Professional Edition Required", + "licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.", + "actionGetOrg": "Get Organization", + "actionUpdateOrg": "Update Organization", + "actionUpdateUser": "Update User", + "actionGetUser": "Get User", + "actionGetOrgUser": "Get Organization User", + "actionListOrgDomains": "List Organization Domains", + "actionCreateSite": "Create Site", + "actionDeleteSite": "Delete Site", + "actionGetSite": "Get Site", + "actionListSites": "List Sites", + "actionUpdateSite": "Update Site", + "actionListSiteRoles": "List Allowed Site Roles", + "actionCreateResource": "Create Resource", + "actionDeleteResource": "Delete Resource", + "actionGetResource": "Get Resource", + "actionListResource": "List Resources", + "actionUpdateResource": "Update Resource", + "actionListResourceUsers": "List Resource Users", + "actionSetResourceUsers": "Set Resource Users", + "actionSetAllowedResourceRoles": "Set Allowed Resource Roles", + "actionListAllowedResourceRoles": "List Allowed Resource Roles", + "actionSetResourcePassword": "Set Resource Password", + "actionSetResourcePincode": "Set Resource Pincode", + "actionSetResourceEmailWhitelist": "Set Resource Email Whitelist", + "actionGetResourceEmailWhitelist": "Get Resource Email Whitelist", + "actionCreateTarget": "Create Target", + "actionDeleteTarget": "Delete Target", + "actionGetTarget": "Get Target", + "actionListTargets": "List Targets", + "actionUpdateTarget": "Update Target", + "actionCreateRole": "Create Role", + "actionDeleteRole": "Delete Role", + "actionGetRole": "Get Role", + "actionListRole": "List Roles", + "actionUpdateRole": "Update Role", + "actionListAllowedRoleResources": "List Allowed Role Resources", + "actionInviteUser": "Invite User", + "actionRemoveUser": "Remove User", + "actionListUsers": "List Users", + "actionAddUserRole": "Add User Role", + "actionGenerateAccessToken": "Generate Access Token", + "actionDeleteAccessToken": "Delete Access Token", + "actionListAccessTokens": "List Access Tokens", + "actionCreateResourceRule": "Create Resource Rule", + "actionDeleteResourceRule": "Delete Resource Rule", + "actionListResourceRules": "List Resource Rules", + "actionUpdateResourceRule": "Update Resource Rule", + "actionListOrgs": "List Organizations", + "actionCheckOrgId": "Check ID", + "actionCreateOrg": "Create Organization", + "actionDeleteOrg": "Delete Organization", + "actionListApiKeys": "List API Keys", + "actionListApiKeyActions": "List API Key Actions", + "actionSetApiKeyActions": "Set API Key Allowed Actions", + "actionCreateApiKey": "Create API Key", + "actionDeleteApiKey": "Delete API Key", + "actionCreateIdp": "Create IDP", + "actionUpdateIdp": "Update IDP", + "actionDeleteIdp": "Delete IDP", + "actionListIdps": "List IDP", + "actionGetIdp": "Get IDP", + "actionCreateIdpOrg": "Create IDP Org Policy", + "actionDeleteIdpOrg": "Delete IDP Org Policy", + "actionListIdpOrgs": "List IDP Orgs", + "actionUpdateIdpOrg": "Update IDP Org", + "actionCreateClient": "Create Client", + "actionDeleteClient": "Delete Client", + "actionUpdateClient": "Update Client", + "actionListClients": "List Clients", + "actionGetClient": "Get Client", + "noneSelected": "None selected", + "orgNotFound2": "No organizations found.", + "searchProgress": "Search...", + "create": "Create", + "orgs": "Organizations", + "loginError": "An error occurred while logging in", + "passwordForgot": "Forgot your password?", + "otpAuth": "Two-Factor Authentication", + "otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.", + "otpAuthSubmit": "Submit Code", + "idpContinue": "Or continue with", + "otpAuthBack": "Back to Log In", + "navbar": "Navigation Menu", + "navbarDescription": "Main navigation menu for the application", + "navbarDocsLink": "Documentation", + "commercialEdition": "Commercial Edition", + "otpErrorEnable": "Unable to enable 2FA", + "otpErrorEnableDescription": "An error occurred while enabling 2FA", + "otpSetupCheckCode": "Please enter a 6-digit code", + "otpSetupCheckCodeRetry": "Invalid code. Please try again.", + "otpSetup": "Enable Two-factor Authentication", + "otpSetupDescription": "Secure your account with an extra layer of protection", + "otpSetupScanQr": "Scan this QR code with your authenticator app or enter the secret key manually:", + "otpSetupSecretCode": "Authenticator Code", + "otpSetupSuccess": "Two-Factor Authentication Enabled", + "otpSetupSuccessStoreBackupCodes": "Your account is now more secure. Don't forget to save your backup codes.", + "otpErrorDisable": "Unable to disable 2FA", + "otpErrorDisableDescription": "An error occurred while disabling 2FA", + "otpRemove": "Disable Two-factor Authentication", + "otpRemoveDescription": "Disable two-factor authentication for your account", + "otpRemoveSuccess": "Two-Factor Authentication Disabled", + "otpRemoveSuccessMessage": "Two-factor authentication has been disabled for your account. You can enable it again at any time.", + "otpRemoveSubmit": "Disable 2FA", + "paginator": "Page {current} of {last}", + "paginatorToFirst": "Go to first page", + "paginatorToPrevious": "Go to previous page", + "paginatorToNext": "Go to next page", + "paginatorToLast": "Go to last page", + "copyText": "Copy text", + "copyTextFailed": "Failed to copy text: ", + "copyTextClipboard": "Copy to clipboard", + "inviteErrorInvalidConfirmation": "Invalid confirmation", + "passwordRequired": "Password is required", + "allowAll": "Allow All", + "permissionsAllowAll": "Allow All Permissions", + "githubUsernameRequired": "GitHub username is required", + "supportKeyRequired": "Supporter key is required", + "passwordRequirementsChars": "Password must be at least 8 characters", + "language": "Language", + "verificationCodeRequired": "Code is required", + "userErrorNoUpdate": "No user to update", + "siteErrorNoUpdate": "No site to update", + "resourceErrorNoUpdate": "No resource to update", + "authErrorNoUpdate": "No auth info to update", + "orgErrorNoUpdate": "No org to update", + "orgErrorNoProvided": "No org provided", + "apiKeysErrorNoUpdate": "No API key to update", + "sidebarOverview": "Overview", + "sidebarHome": "Home", + "sidebarSites": "Sites", + "sidebarResources": "Resources", + "sidebarAccessControl": "Access Control", + "sidebarUsers": "Users", + "sidebarInvitations": "Invitations", + "sidebarRoles": "Roles", + "sidebarShareableLinks": "Shareable Links", + "sidebarApiKeys": "API Keys", + "sidebarSettings": "Settings", + "sidebarAllUsers": "All Users", + "sidebarIdentityProviders": "Identity Providers", + "sidebarLicense": "License", + "sidebarClients": "Clients (Beta)", + "sidebarDomains": "Domains", + "enableDockerSocket": "Enable Docker Socket", + "enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.", + "enableDockerSocketLink": "Learn More", + "viewDockerContainers": "View Docker Containers", + "containersIn": "Containers in {siteName}", + "selectContainerDescription": "Select any container to use as a hostname for this target. Click a port to use a port.", + "containerName": "Name", + "containerImage": "Image", + "containerState": "State", + "containerNetworks": "Networks", + "containerHostnameIp": "Hostname/IP", + "containerLabels": "Labels", + "containerLabelsCount": "{count, plural, one {# label} other {# labels}}", + "containerLabelsTitle": "Container Labels", + "containerLabelEmpty": "", + "containerPorts": "Ports", + "containerPortsMore": "+{count} more", + "containerActions": "Actions", + "select": "Select", + "noContainersMatchingFilters": "No containers found matching the current filters.", + "showContainersWithoutPorts": "Show containers without ports", + "showStoppedContainers": "Show stopped containers", + "noContainersFound": "No containers found. Make sure Docker containers are running.", + "searchContainersPlaceholder": "Search across {count} containers...", + "searchResultsCount": "{count, plural, one {# result} other {# results}}", + "filters": "Filters", + "filterOptions": "Filter Options", + "filterPorts": "Ports", + "filterStopped": "Stopped", + "clearAllFilters": "Clear all filters", + "columns": "Columns", + "toggleColumns": "Toggle Columns", + "refreshContainersList": "Refresh containers list", + "searching": "Searching...", + "noContainersFoundMatching": "No containers found matching \"{filter}\".", + "light": "light", + "dark": "dark", + "system": "system", + "theme": "Theme", + "subnetRequired": "Subnet is required", + "initialSetupTitle": "Initial Server Setup", + "initialSetupDescription": "Create the intial server admin account. Only one server admin can exist. You can always change these credentials later.", + "createAdminAccount": "Create Admin Account", + "setupErrorCreateAdmin": "An error occurred while creating the server admin account.", + "certificateStatus": "Certificate Status", + "loading": "Loading", + "restart": "Restart", + "domains": "Domains", + "domainsDescription": "Manage domains for your organization", + "domainsSearch": "Search domains...", + "domainAdd": "Add Domain", + "domainAddDescription": "Register a new domain with your organization", + "domainCreate": "Create Domain", + "domainCreatedDescription": "Domain created successfully", + "domainDeletedDescription": "Domain deleted successfully", + "domainQuestionRemove": "Are you sure you want to remove the domain {domain} from your account?", + "domainMessageRemove": "Once removed, the domain will no longer be associated with your account.", + "domainMessageConfirm": "To confirm, please type the domain name below.", + "domainConfirmDelete": "Confirm Delete Domain", + "domainDelete": "Delete Domain", + "domain": "Domain", + "selectDomainTypeNsName": "Domain Delegation (NS)", + "selectDomainTypeNsDescription": "This domain and all its subdomains. Use this when you want to control an entire domain zone.", + "selectDomainTypeCnameName": "Single Domain (CNAME)", + "selectDomainTypeCnameDescription": "Just this specific domain. Use this for individual subdomains or specific domain entries.", + "selectDomainTypeWildcardName": "Wildcard Domain", + "selectDomainTypeWildcardDescription": "This domain and its subdomains.", + "domainDelegation": "Single Domain", + "selectType": "Select a type", + "actions": "Actions", + "refresh": "Refresh", + "refreshError": "Failed to refresh data", + "verified": "Verified", + "pending": "Pending", + "sidebarBilling": "Billing", + "billing": "Billing", + "orgBillingDescription": "Manage your billing information and subscriptions", + "github": "GitHub", + "pangolinHosted": "Pangolin Hosted", + "fossorial": "Fossorial", + "completeAccountSetup": "Complete Account Setup", + "completeAccountSetupDescription": "Set your password to get started", + "accountSetupSent": "We'll send an account setup code to this email address.", + "accountSetupCode": "Setup Code", + "accountSetupCodeDescription": "Check your email for the setup code.", + "passwordCreate": "Create Password", + "passwordCreateConfirm": "Confirm Password", + "accountSetupSubmit": "Send Setup Code", + "completeSetup": "Complete Setup", + "accountSetupSuccess": "Account setup completed! Welcome to Pangolin!", + "documentation": "Documentation", + "saveAllSettings": "Save All Settings", + "settingsUpdated": "Settings updated", + "settingsUpdatedDescription": "All settings have been updated successfully", + "settingsErrorUpdate": "Failed to update settings", + "settingsErrorUpdateDescription": "An error occurred while updating settings", + "sidebarCollapse": "Collapse", + "sidebarExpand": "Expand", + "newtUpdateAvailable": "Update Available", + "newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", + "domainPickerEnterDomain": "Domain", + "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp", + "domainPickerDescription": "Enter the full domain of the resource to see available options.", + "domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options", + "domainPickerTabAll": "All", + "domainPickerTabOrganization": "Organization", + "domainPickerTabProvided": "Provided", + "domainPickerSortAsc": "A-Z", + "domainPickerSortDesc": "Z-A", + "domainPickerCheckingAvailability": "Checking availability...", + "domainPickerNoMatchingDomains": "No matching domains found. Try a different domain or check your organization's domain settings.", + "domainPickerOrganizationDomains": "Organization Domains", + "domainPickerProvidedDomains": "Provided Domains", + "domainPickerSubdomain": "Subdomain: {subdomain}", + "domainPickerNamespace": "Namespace: {namespace}", + "domainPickerShowMore": "Show More", + "domainNotFound": "Domain Not Found", + "domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.", + "failed": "Failed", + "createNewOrgDescription": "Create a new organization", + "organization": "Organization", + "port": "Port", + "securityKeyManage": "Manage Security Keys", + "securityKeyDescription": "Add or remove security keys for passwordless authentication", + "securityKeyRegister": "Register New Security Key", + "securityKeyList": "Your Security Keys", + "securityKeyNone": "No security keys registered yet", + "securityKeyNameRequired": "Name is required", + "securityKeyRemove": "Remove", + "securityKeyLastUsed": "Last used: {date}", + "securityKeyNameLabel": "Security Key Name", + "securityKeyRegisterSuccess": "Security key registered successfully", + "securityKeyRegisterError": "Failed to register security key", + "securityKeyRemoveSuccess": "Security key removed successfully", + "securityKeyRemoveError": "Failed to remove security key", + "securityKeyLoadError": "Failed to load security keys", + "securityKeyLogin": "Continue with security key", + "securityKeyAuthError": "Failed to authenticate with security key", + "securityKeyRecommendation": "Register a backup security key on another device to ensure you always have access to your account.", + "registering": "Registering...", + "securityKeyPrompt": "Please verify your identity using your security key. Make sure your security key is connected and ready.", + "securityKeyBrowserNotSupported": "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari.", + "securityKeyPermissionDenied": "Please allow access to your security key to continue signing in.", + "securityKeyRemovedTooQuickly": "Please keep your security key connected until the sign-in process completes.", + "securityKeyNotSupported": "Your security key may not be compatible. Please try a different security key.", + "securityKeyUnknownError": "There was a problem using your security key. Please try again.", + "twoFactorRequired": "Two-factor authentication is required to register a security key.", + "twoFactor": "Two-Factor Authentication", + "adminEnabled2FaOnYourAccount": "Your administrator has enabled two-factor authentication for {email}. Please complete the setup process to continue.", + "continueToApplication": "Continue to Application", + "securityKeyAdd": "Add Security Key", + "securityKeyRegisterTitle": "Register New Security Key", + "securityKeyRegisterDescription": "Connect your security key and enter a name to identify it", + "securityKeyTwoFactorRequired": "Two-Factor Authentication Required", + "securityKeyTwoFactorDescription": "Please enter your two-factor authentication code to register the security key", + "securityKeyTwoFactorRemoveDescription": "Please enter your two-factor authentication code to remove the security key", + "securityKeyTwoFactorCode": "Two-Factor Code", + "securityKeyRemoveTitle": "Remove Security Key", + "securityKeyRemoveDescription": "Enter your password to remove the security key \"{name}\"", + "securityKeyNoKeysRegistered": "No security keys registered", + "securityKeyNoKeysDescription": "Add a security key to enhance your account security", + "createDomainRequired": "Domain is required", + "createDomainAddDnsRecords": "Add DNS Records", + "createDomainAddDnsRecordsDescription": "Add the following DNS records to your domain provider to complete the setup.", + "createDomainNsRecords": "NS Records", + "createDomainRecord": "Record", + "createDomainType": "Type:", + "createDomainName": "Name:", + "createDomainValue": "Value:", + "createDomainCnameRecords": "CNAME Records", + "createDomainARecords": "A Records", + "createDomainRecordNumber": "Record {number}", + "createDomainTxtRecords": "TXT Records", + "createDomainSaveTheseRecords": "Save These Records", + "createDomainSaveTheseRecordsDescription": "Make sure to save these DNS records as you will not see them again.", + "createDomainDnsPropagation": "DNS Propagation", + "createDomainDnsPropagationDescription": "DNS changes may take some time to propagate across the internet. This can take anywhere from a few minutes to 48 hours, depending on your DNS provider and TTL settings.", + "resourcePortRequired": "Port number is required for non-HTTP resources", + "resourcePortNotAllowed": "Port number should not be set for HTTP resources", + "signUpTerms": { + "IAgreeToThe": "I agree to the", + "termsOfService": "terms of service", + "and": "and", + "privacyPolicy": "privacy policy" + }, + "siteRequired": "Site is required.", + "olmTunnel": "Olm Tunnel", + "olmTunnelDescription": "Use Olm for client connectivity", + "errorCreatingClient": "Error creating client", + "clientDefaultsNotFound": "Client defaults not found", + "createClient": "Create Client", + "createClientDescription": "Create a new client for connecting to your sites", + "seeAllClients": "See All Clients", + "clientInformation": "Client Information", + "clientNamePlaceholder": "Client name", + "address": "Address", + "subnetPlaceholder": "Subnet", + "addressDescription": "The address that this client will use for connectivity", + "selectSites": "Select sites", + "sitesDescription": "The client will have connectivity to the selected sites", + "clientInstallOlm": "Install Olm", + "clientInstallOlmDescription": "Get Olm running on your system", + "clientOlmCredentials": "Olm Credentials", + "clientOlmCredentialsDescription": "This is how Olm will authenticate with the server", + "olmEndpoint": "Olm Endpoint", + "olmId": "Olm ID", + "olmSecretKey": "Olm Secret Key", + "clientCredentialsSave": "Save Your Credentials", + "clientCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.", + "generalSettingsDescription": "Configure the general settings for this client", + "clientUpdated": "Client updated", + "clientUpdatedDescription": "The client has been updated.", + "clientUpdateFailed": "Failed to update client", + "clientUpdateError": "An error occurred while updating the client.", + "sitesFetchFailed": "Failed to fetch sites", + "sitesFetchError": "An error occurred while fetching sites.", + "olmErrorFetchReleases": "An error occurred while fetching Olm releases.", + "olmErrorFetchLatest": "An error occurred while fetching the latest Olm release.", + "remoteSubnets": "Remote Subnets", + "enterCidrRange": "Enter CIDR range", + "remoteSubnetsDescription": "Add CIDR ranges that can access this site remotely. Use format like 10.0.0.0/24 or 192.168.1.0/24.", + "resourceEnableProxy": "Enable Public Proxy", + "resourceEnableProxyDescription": "Enable public proxying to this resource. This allows access to the resource from outside the network through the cloud on an open port. Requires Traefik config.", + "externalProxyEnabled": "External Proxy Enabled" +} diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json new file mode 100644 index 00000000..b2152580 --- /dev/null +++ b/messages/cs-CZ.json @@ -0,0 +1,1327 @@ +{ + "setupCreate": "Vytvořte si organizaci, lokalitu a služby", + "setupNewOrg": "Nová organizace", + "setupCreateOrg": "Vytvořit organizaci", + "setupCreateResources": "Vytvořit zdroje", + "setupOrgName": "Název organizace", + "orgDisplayName": "Toto je zobrazovaný název vaší organizace.", + "orgId": "ID organizace", + "setupIdentifierMessage": "Toto je jedinečný identifikátor vaší organizace. Nemusí odpovídat názvu organizace.", + "setupErrorIdentifier": "ID organizace je již použito. Zvolte prosím jiné.", + "componentsErrorNoMemberCreate": "Zatím nejste členem žádné organizace. Abyste mohli začít, vytvořte si organizaci.", + "componentsErrorNoMember": "Zatím nejste členem žádných organizací.", + "welcome": "Welcome!", + "welcomeTo": "Welcome to", + "componentsCreateOrg": "Vytvořte organizaci", + "componentsMember": "Jste členem {count, plural, =0 {0 organizací} one {1 organizace} other {# organizací}}.", + "componentsInvalidKey": "Byly nalezeny neplatné nebo propadlé licenční klíče. Pokud chcete nadále používat všechny funkce, postupujte podle licenčních podmínek.", + "dismiss": "Zavřít", + "componentsLicenseViolation": "Porušení licenčních podmínek: Tento server používá {usedSites} stránek, což překračuje limit {maxSites} licencovaných stránek. Pokud chcete nadále používat všechny funkce, postupujte podle licenčních podmínek.", + "componentsSupporterMessage": "Děkujeme, že podporujete Pangolin jako {tier}!", + "inviteErrorNotValid": "Je nám líto, ale vypadá to, že pozvánka, ke které se snažíte získat přístup, nebyla přijata nebo již není platná.", + "inviteErrorUser": "Je nám líto, ale vypadá to, že pozvánka, ke které se snažíte získat přístup, není pro tohoto uživatele.", + "inviteLoginUser": "Prosím ujistěte se, že jste přihlášeni jako správný uživatel.", + "inviteErrorNoUser": "Je nám líto, ale vypadá to, že pozvánka, ke které se snažíte získat přístup, není pro existujícího uživatele.", + "inviteCreateUser": "Nejprve si prosím vytvořte účet.", + "goHome": "Přejít na hlavní stránku", + "inviteLogInOtherUser": "Přihlásit se jako jiný uživatel", + "createAnAccount": "Vytvořit účet", + "inviteNotAccepted": "Pozvánka nebyla přijata", + "authCreateAccount": "Vytvořte si účet, abyste mohli začít", + "authNoAccount": "Nemáte účet?", + "email": "Email", + "password": "Heslo", + "confirmPassword": "Potvrďte heslo", + "createAccount": "Vytvořit účet", + "viewSettings": "Zobrazit nastavení", + "delete": "Odstranit", + "name": "Jméno", + "online": "Online", + "offline": "Offline", + "site": "Lokalita", + "dataIn": "Přijatá data", + "dataOut": "Odeslaná data", + "connectionType": "Typ připojení", + "tunnelType": "Typ tunelu", + "local": "Místní", + "edit": "Upravit", + "siteConfirmDelete": "Potvrdit odstranění lokality", + "siteDelete": "Odstranění lokality", + "siteMessageRemove": "Jakmile lokalitu odstraníte, nebude dostupná. Všechny související služby a cíle budou také odstraněny.", + "siteMessageConfirm": "Pro potvrzení zadejte prosím název lokality.", + "siteQuestionRemove": "Opravdu chcete odstranit lokalitu {selectedSite} z organizace?", + "siteManageSites": "Správa lokalit", + "siteDescription": "Umožní připojení k vaší síti prostřednictvím zabezpečených tunelů", + "siteCreate": "Vytvořit lokalitu", + "siteCreateDescription2": "Postupujte podle níže uvedených kroků, abyste vytvořili a připojili novou lokalitu", + "siteCreateDescription": "Vytvořte novou lokalitu, abyste mohli začít připojovat služby", + "close": "Zavřít", + "siteErrorCreate": "Chyba při vytváření lokality", + "siteErrorCreateKeyPair": "Nebyly nalezeny klíče nebo výchozí nastavení lokality", + "siteErrorCreateDefaults": "Výchozí nastavení lokality nenalezeno", + "method": "Způsob", + "siteMethodDescription": "Tímto způsobem budete vystavovat spojení.", + "siteLearnNewt": "Naučte se, jak nainstalovat Newt na svůj systém", + "siteSeeConfigOnce": "You will only be able to see the configuration once.", + "siteLoadWGConfig": "Loading WireGuard configuration...", + "siteDocker": "Expand for Docker Deployment Details", + "toggle": "Toggle", + "dockerCompose": "Docker Compose", + "dockerRun": "Docker Run", + "siteLearnLocal": "Local sites do not tunnel, learn more", + "siteConfirmCopy": "I have copied the config", + "searchSitesProgress": "Search sites...", + "siteAdd": "Add Site", + "siteInstallNewt": "Install Newt", + "siteInstallNewtDescription": "Get Newt running on your system", + "WgConfiguration": "WireGuard Configuration", + "WgConfigurationDescription": "Use the following configuration to connect to your network", + "operatingSystem": "Operating System", + "commands": "Commands", + "recommended": "Recommended", + "siteNewtDescription": "For the best user experience, use Newt. It uses WireGuard under the hood and allows you to address your private resources by their LAN address on your private network from within the Pangolin dashboard.", + "siteRunsInDocker": "Runs in Docker", + "siteRunsInShell": "Runs in shell on macOS, Linux, and Windows", + "siteErrorDelete": "Error deleting site", + "siteErrorUpdate": "Failed to update site", + "siteErrorUpdateDescription": "An error occurred while updating the site.", + "siteUpdated": "Site updated", + "siteUpdatedDescription": "The site has been updated.", + "siteGeneralDescription": "Configure the general settings for this site", + "siteSettingDescription": "Configure the settings on your site", + "siteSetting": "{siteName} Settings", + "siteNewtTunnel": "Newt Tunnel (Recommended)", + "siteNewtTunnelDescription": "Easiest way to create an entrypoint into your network. No extra setup.", + "siteWg": "Basic WireGuard", + "siteWgDescription": "Use any WireGuard client to establish a tunnel. Manual NAT setup required.", + "siteLocalDescription": "Local resources only. No tunneling.", + "siteSeeAll": "See All Sites", + "siteTunnelDescription": "Determine how you want to connect to your site", + "siteNewtCredentials": "Newt Credentials", + "siteNewtCredentialsDescription": "This is how Newt will authenticate with the server", + "siteCredentialsSave": "Save Your Credentials", + "siteCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.", + "siteInfo": "Site Information", + "status": "Status", + "shareTitle": "Manage Share Links", + "shareDescription": "Create shareable links to grant temporary or permanent access to your resources", + "shareSearch": "Search share links...", + "shareCreate": "Create Share Link", + "shareErrorDelete": "Failed to delete link", + "shareErrorDeleteMessage": "An error occurred deleting link", + "shareDeleted": "Link deleted", + "shareDeletedDescription": "The link has been deleted", + "shareTokenDescription": "Your access token can be passed in two ways: as a query parameter or in the request headers. These must be passed from the client on every request for authenticated access.", + "accessToken": "Access Token", + "usageExamples": "Usage Examples", + "tokenId": "Token ID", + "requestHeades": "Request Headers", + "queryParameter": "Query Parameter", + "importantNote": "Important Note", + "shareImportantDescription": "For security reasons, using headers is recommended over query parameters when possible, as query parameters may be logged in server logs or browser history.", + "token": "Token", + "shareTokenSecurety": "Keep your access token secure. Do not share it in publicly accessible areas or client-side code.", + "shareErrorFetchResource": "Failed to fetch resources", + "shareErrorFetchResourceDescription": "An error occurred while fetching the resources", + "shareErrorCreate": "Failed to create share link", + "shareErrorCreateDescription": "An error occurred while creating the share link", + "shareCreateDescription": "Anyone with this link can access the resource", + "shareTitleOptional": "Title (optional)", + "expireIn": "Expire In", + "neverExpire": "Never expire", + "shareExpireDescription": "Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource.", + "shareSeeOnce": "You will only be able to see this linkonce. Make sure to copy it.", + "shareAccessHint": "Anyone with this link can access the resource. Share it with care.", + "shareTokenUsage": "See Access Token Usage", + "createLink": "Create Link", + "resourcesNotFound": "No resources found", + "resourceSearch": "Search resources", + "openMenu": "Open menu", + "resource": "Resource", + "title": "Title", + "created": "Created", + "expires": "Expires", + "never": "Never", + "shareErrorSelectResource": "Please select a resource", + "resourceTitle": "Manage Resources", + "resourceDescription": "Create secure proxies to your private applications", + "resourcesSearch": "Search resources...", + "resourceAdd": "Add Resource", + "resourceErrorDelte": "Error deleting resource", + "authentication": "Authentication", + "protected": "Protected", + "notProtected": "Not Protected", + "resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.", + "resourceMessageConfirm": "To confirm, please type the name of the resource below.", + "resourceQuestionRemove": "Are you sure you want to remove the resource {selectedResource} from the organization?", + "resourceHTTP": "HTTPS Resource", + "resourceHTTPDescription": "Proxy requests to your app over HTTPS using a subdomain or base domain.", + "resourceRaw": "Raw TCP/UDP Resource", + "resourceRawDescription": "Proxy requests to your app over TCP/UDP using a port number.", + "resourceCreate": "Create Resource", + "resourceCreateDescription": "Follow the steps below to create a new resource", + "resourceSeeAll": "See All Resources", + "resourceInfo": "Resource Information", + "resourceNameDescription": "This is the display name for the resource.", + "siteSelect": "Select site", + "siteSearch": "Search site", + "siteNotFound": "No site found.", + "siteSelectionDescription": "This site will provide connectivity to the resource.", + "resourceType": "Resource Type", + "resourceTypeDescription": "Determine how you want to access your resource", + "resourceHTTPSSettings": "HTTPS Settings", + "resourceHTTPSSettingsDescription": "Configure how your resource will be accessed over HTTPS", + "domainType": "Domain Type", + "subdomain": "Subdomain", + "baseDomain": "Base Domain", + "subdomnainDescription": "The subdomain where your resource will be accessible.", + "resourceRawSettings": "TCP/UDP Settings", + "resourceRawSettingsDescription": "Configure how your resource will be accessed over TCP/UDP", + "protocol": "Protocol", + "protocolSelect": "Select a protocol", + "resourcePortNumber": "Port Number", + "resourcePortNumberDescription": "The external port number to proxy requests.", + "cancel": "Cancel", + "resourceConfig": "Configuration Snippets", + "resourceConfigDescription": "Copy and paste these configuration snippets to set up your TCP/UDP resource", + "resourceAddEntrypoints": "Traefik: Add Entrypoints", + "resourceExposePorts": "Gerbil: Expose Ports in Docker Compose", + "resourceLearnRaw": "Learn how to configure TCP/UDP resources", + "resourceBack": "Back to Resources", + "resourceGoTo": "Go to Resource", + "resourceDelete": "Delete Resource", + "resourceDeleteConfirm": "Confirm Delete Resource", + "visibility": "Visibility", + "enabled": "Enabled", + "disabled": "Disabled", + "general": "General", + "generalSettings": "General Settings", + "proxy": "Proxy", + "rules": "Rules", + "resourceSettingDescription": "Configure the settings on your resource", + "resourceSetting": "{resourceName} Settings", + "alwaysAllow": "Always Allow", + "alwaysDeny": "Always Deny", + "orgSettingsDescription": "Configure your organization's general settings", + "orgGeneralSettings": "Organization Settings", + "orgGeneralSettingsDescription": "Manage your organization details and configuration", + "saveGeneralSettings": "Save General Settings", + "saveSettings": "Save Settings", + "orgDangerZone": "Danger Zone", + "orgDangerZoneDescription": "Once you delete this org, there is no going back. Please be certain.", + "orgDelete": "Delete Organization", + "orgDeleteConfirm": "Confirm Delete Organization", + "orgMessageRemove": "This action is irreversible and will delete all associated data.", + "orgMessageConfirm": "To confirm, please type the name of the organization below.", + "orgQuestionRemove": "Are you sure you want to remove the organization {selectedOrg}?", + "orgUpdated": "Organization updated", + "orgUpdatedDescription": "The organization has been updated.", + "orgErrorUpdate": "Failed to update organization", + "orgErrorUpdateMessage": "An error occurred while updating the organization.", + "orgErrorFetch": "Failed to fetch organizations", + "orgErrorFetchMessage": "An error occurred while listing your organizations", + "orgErrorDelete": "Failed to delete organization", + "orgErrorDeleteMessage": "An error occurred while deleting the organization.", + "orgDeleted": "Organization deleted", + "orgDeletedMessage": "The organization and its data has been deleted.", + "orgMissing": "Organization ID Missing", + "orgMissingMessage": "Unable to regenerate invitation without an organization ID.", + "accessUsersManage": "Manage Users", + "accessUsersDescription": "Invite users and add them to roles to manage access to your organization", + "accessUsersSearch": "Search users...", + "accessUserCreate": "Create User", + "accessUserRemove": "Remove User", + "username": "Username", + "identityProvider": "Identity Provider", + "role": "Role", + "nameRequired": "Name is required", + "accessRolesManage": "Manage Roles", + "accessRolesDescription": "Configure roles to manage access to your organization", + "accessRolesSearch": "Search roles...", + "accessRolesAdd": "Add Role", + "accessRoleDelete": "Delete Role", + "description": "Description", + "inviteTitle": "Open Invitations", + "inviteDescription": "Manage your invitations to other users", + "inviteSearch": "Search invitations...", + "minutes": "Minutes", + "hours": "Hours", + "days": "Days", + "weeks": "Weeks", + "months": "Months", + "years": "Years", + "day": "{count, plural, one {# day} other {# days}}", + "apiKeysTitle": "API Key Information", + "apiKeysConfirmCopy2": "You must confirm that you have copied the API key.", + "apiKeysErrorCreate": "Error creating API key", + "apiKeysErrorSetPermission": "Error setting permissions", + "apiKeysCreate": "Generate API Key", + "apiKeysCreateDescription": "Generate a new API key for your organization", + "apiKeysGeneralSettings": "Permissions", + "apiKeysGeneralSettingsDescription": "Determine what this API key can do", + "apiKeysList": "Your API Key", + "apiKeysSave": "Save Your API Key", + "apiKeysSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.", + "apiKeysInfo": "Your API key is:", + "apiKeysConfirmCopy": "I have copied the API key", + "generate": "Generate", + "done": "Done", + "apiKeysSeeAll": "See All API Keys", + "apiKeysPermissionsErrorLoadingActions": "Error loading API key actions", + "apiKeysPermissionsErrorUpdate": "Error setting permissions", + "apiKeysPermissionsUpdated": "Permissions updated", + "apiKeysPermissionsUpdatedDescription": "The permissions have been updated.", + "apiKeysPermissionsGeneralSettings": "Permissions", + "apiKeysPermissionsGeneralSettingsDescription": "Determine what this API key can do", + "apiKeysPermissionsSave": "Save Permissions", + "apiKeysPermissionsTitle": "Permissions", + "apiKeys": "API Keys", + "searchApiKeys": "Search API keys...", + "apiKeysAdd": "Generate API Key", + "apiKeysErrorDelete": "Error deleting API key", + "apiKeysErrorDeleteMessage": "Error deleting API key", + "apiKeysQuestionRemove": "Are you sure you want to remove the API key {selectedApiKey} from the organization?", + "apiKeysMessageRemove": "Once removed, the API key will no longer be able to be used.", + "apiKeysMessageConfirm": "To confirm, please type the name of the API key below.", + "apiKeysDeleteConfirm": "Confirm Delete API Key", + "apiKeysDelete": "Delete API Key", + "apiKeysManage": "Manage API Keys", + "apiKeysDescription": "API keys are used to authenticate with the integration API", + "apiKeysSettings": "{apiKeyName} Settings", + "userTitle": "Manage All Users", + "userDescription": "View and manage all users in the system", + "userAbount": "About User Management", + "userAbountDescription": "This table displays all root user objects in the system. Each user may belong to multiple organizations. Removing a user from an organization does not delete their root user object - they will remain in the system. To completely remove a user from the system, you must delete their root user object using the delete action in this table.", + "userServer": "Server Users", + "userSearch": "Search server users...", + "userErrorDelete": "Error deleting user", + "userDeleteConfirm": "Confirm Delete User", + "userDeleteServer": "Delete User from Server", + "userMessageRemove": "The user will be removed from all organizations and be completely removed from the server.", + "userMessageConfirm": "To confirm, please type the name of the user below.", + "userQuestionRemove": "Are you sure you want to permanently delete {selectedUser} from the server?", + "licenseKey": "License Key", + "valid": "Valid", + "numberOfSites": "Number of Sites", + "licenseKeySearch": "Search license keys...", + "licenseKeyAdd": "Add License Key", + "type": "Type", + "licenseKeyRequired": "License key is required", + "licenseTermsAgree": "You must agree to the license terms", + "licenseErrorKeyLoad": "Failed to load license keys", + "licenseErrorKeyLoadDescription": "An error occurred loading license keys.", + "licenseErrorKeyDelete": "Failed to delete license key", + "licenseErrorKeyDeleteDescription": "An error occurred deleting license key.", + "licenseKeyDeleted": "License key deleted", + "licenseKeyDeletedDescription": "The license key has been deleted.", + "licenseErrorKeyActivate": "Failed to activate license key", + "licenseErrorKeyActivateDescription": "An error occurred while activating the license key.", + "licenseAbout": "About Licensing", + "communityEdition": "Community Edition", + "licenseAboutDescription": "This is for business and enterprise users who are using Pangolin in a commercial environment. If you are using Pangolin for personal use, you can ignore this section.", + "licenseKeyActivated": "License key activated", + "licenseKeyActivatedDescription": "The license key has been successfully activated.", + "licenseErrorKeyRecheck": "Failed to recheck license keys", + "licenseErrorKeyRecheckDescription": "An error occurred rechecking license keys.", + "licenseErrorKeyRechecked": "License keys rechecked", + "licenseErrorKeyRecheckedDescription": "All license keys have been rechecked", + "licenseActivateKey": "Activate License Key", + "licenseActivateKeyDescription": "Enter a license key to activate it.", + "licenseActivate": "Activate License", + "licenseAgreement": "By checking this box, you confirm that you have read and agree to the license terms corresponding to the tier associated with your license key.", + "fossorialLicense": "View Fossorial Commercial License & Subscription Terms", + "licenseMessageRemove": "This will remove the license key and all associated permissions granted by it.", + "licenseMessageConfirm": "To confirm, please type the license key below.", + "licenseQuestionRemove": "Are you sure you want to delete the license key {selectedKey} ?", + "licenseKeyDelete": "Delete License Key", + "licenseKeyDeleteConfirm": "Confirm Delete License Key", + "licenseTitle": "Manage License Status", + "licenseTitleDescription": "View and manage license keys in the system", + "licenseHost": "Host License", + "licenseHostDescription": "Manage the main license key for the host.", + "licensedNot": "Not Licensed", + "hostId": "Host ID", + "licenseReckeckAll": "Recheck All Keys", + "licenseSiteUsage": "Sites Usage", + "licenseSiteUsageDecsription": "View the number of sites using this license.", + "licenseNoSiteLimit": "There is no limit on the number of sites using an unlicensed host.", + "licensePurchase": "Purchase License", + "licensePurchaseSites": "Purchase Additional Sites", + "licenseSitesUsedMax": "{usedSites} of {maxSites} sites used", + "licenseSitesUsed": "{count, plural, =0 {# sites} one {# site} other {# sites}} in system.", + "licensePurchaseDescription": "Choose how many sites you want to {selectedMode, select, license {purchase a license for. You can always add more sites later.} other {add to your existing license.}}", + "licenseFee": "License fee", + "licensePriceSite": "Price per site", + "total": "Total", + "licenseContinuePayment": "Continue to Payment", + "pricingPage": "pricing page", + "pricingPortal": "See Purchase Portal", + "licensePricingPage": "For the most up-to-date pricing and discounts, please visit the ", + "invite": "Invitations", + "inviteRegenerate": "Regenerate Invitation", + "inviteRegenerateDescription": "Revoke previous invitation and create a new one", + "inviteRemove": "Remove Invitation", + "inviteRemoveError": "Failed to remove invitation", + "inviteRemoveErrorDescription": "An error occurred while removing the invitation.", + "inviteRemoved": "Invitation removed", + "inviteRemovedDescription": "The invitation for {email} has been removed.", + "inviteQuestionRemove": "Are you sure you want to remove the invitation {email}?", + "inviteMessageRemove": "Once removed, this invitation will no longer be valid. You can always re-invite the user later.", + "inviteMessageConfirm": "To confirm, please type the email address of the invitation below.", + "inviteQuestionRegenerate": "Are you sure you want to regenerate the invitation for {email}? This will revoke the previous invitation.", + "inviteRemoveConfirm": "Confirm Remove Invitation", + "inviteRegenerated": "Invitation Regenerated", + "inviteSent": "A new invitation has been sent to {email}.", + "inviteSentEmail": "Send email notification to the user", + "inviteGenerate": "A new invitation has been generated for {email}.", + "inviteDuplicateError": "Duplicate Invite", + "inviteDuplicateErrorDescription": "An invitation for this user already exists.", + "inviteRateLimitError": "Rate Limit Exceeded", + "inviteRateLimitErrorDescription": "You have exceeded the limit of 3 regenerations per hour. Please try again later.", + "inviteRegenerateError": "Failed to Regenerate Invitation", + "inviteRegenerateErrorDescription": "An error occurred while regenerating the invitation.", + "inviteValidityPeriod": "Validity Period", + "inviteValidityPeriodSelect": "Select validity period", + "inviteRegenerateMessage": "The invitation has been regenerated. The user must access the link below to accept the invitation.", + "inviteRegenerateButton": "Regenerate", + "expiresAt": "Expires At", + "accessRoleUnknown": "Unknown Role", + "placeholder": "Placeholder", + "userErrorOrgRemove": "Failed to remove user", + "userErrorOrgRemoveDescription": "An error occurred while removing the user.", + "userOrgRemoved": "User removed", + "userOrgRemovedDescription": "The user {email} has been removed from the organization.", + "userQuestionOrgRemove": "Are you sure you want to remove {email} from the organization?", + "userMessageOrgRemove": "Once removed, this user will no longer have access to the organization. You can always re-invite them later, but they will need to accept the invitation again.", + "userMessageOrgConfirm": "To confirm, please type the name of the of the user below.", + "userRemoveOrgConfirm": "Confirm Remove User", + "userRemoveOrg": "Remove User from Organization", + "users": "Users", + "accessRoleMember": "Member", + "accessRoleOwner": "Owner", + "userConfirmed": "Confirmed", + "idpNameInternal": "Internal", + "emailInvalid": "Invalid email address", + "inviteValidityDuration": "Please select a duration", + "accessRoleSelectPlease": "Please select a role", + "usernameRequired": "Username is required", + "idpSelectPlease": "Please select an identity provider", + "idpGenericOidc": "Generic OAuth2/OIDC provider.", + "accessRoleErrorFetch": "Failed to fetch roles", + "accessRoleErrorFetchDescription": "An error occurred while fetching the roles", + "idpErrorFetch": "Failed to fetch identity providers", + "idpErrorFetchDescription": "An error occurred while fetching identity providers", + "userErrorExists": "User Already Exists", + "userErrorExistsDescription": "This user is already a member of the organization.", + "inviteError": "Failed to invite user", + "inviteErrorDescription": "An error occurred while inviting the user", + "userInvited": "User invited", + "userInvitedDescription": "The user has been successfully invited.", + "userErrorCreate": "Failed to create user", + "userErrorCreateDescription": "An error occurred while creating the user", + "userCreated": "User created", + "userCreatedDescription": "The user has been successfully created.", + "userTypeInternal": "Internal User", + "userTypeInternalDescription": "Invite a user to join your organization directly.", + "userTypeExternal": "External User", + "userTypeExternalDescription": "Create a user with an external identity provider.", + "accessUserCreateDescription": "Follow the steps below to create a new user", + "userSeeAll": "See All Users", + "userTypeTitle": "User Type", + "userTypeDescription": "Determine how you want to create the user", + "userSettings": "User Information", + "userSettingsDescription": "Enter the details for the new user", + "inviteEmailSent": "Send invite email to user", + "inviteValid": "Valid For", + "selectDuration": "Select duration", + "accessRoleSelect": "Select role", + "inviteEmailSentDescription": "An email has been sent to the user with the access link below. They must access the link to accept the invitation.", + "inviteSentDescription": "The user has been invited. They must access the link below to accept the invitation.", + "inviteExpiresIn": "The invite will expire in {days, plural, one {# day} other {# days}}.", + "idpTitle": "Identity Provider", + "idpSelect": "Select the identity provider for the external user", + "idpNotConfigured": "No identity providers are configured. Please configure an identity provider before creating external users.", + "usernameUniq": "This must match the unique username that exists in the selected identity provider.", + "emailOptional": "Email (Optional)", + "nameOptional": "Name (Optional)", + "accessControls": "Access Controls", + "userDescription2": "Manage the settings on this user", + "accessRoleErrorAdd": "Failed to add user to role", + "accessRoleErrorAddDescription": "An error occurred while adding user to the role.", + "userSaved": "User saved", + "userSavedDescription": "The user has been updated.", + "accessControlsDescription": "Manage what this user can access and do in the organization", + "accessControlsSubmit": "Save Access Controls", + "roles": "Roles", + "accessUsersRoles": "Manage Users & Roles", + "accessUsersRolesDescription": "Invite users and add them to roles to manage access to your organization", + "key": "Key", + "createdAt": "Created At", + "proxyErrorInvalidHeader": "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header.", + "proxyErrorTls": "Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name.", + "proxyEnableSSL": "Enable SSL (https)", + "targetErrorFetch": "Failed to fetch targets", + "targetErrorFetchDescription": "An error occurred while fetching targets", + "siteErrorFetch": "Failed to fetch resource", + "siteErrorFetchDescription": "An error occurred while fetching resource", + "targetErrorDuplicate": "Duplicate target", + "targetErrorDuplicateDescription": "A target with these settings already exists", + "targetWireGuardErrorInvalidIp": "Invalid target IP", + "targetWireGuardErrorInvalidIpDescription": "Target IP must be within the site subnet", + "targetsUpdated": "Targets updated", + "targetsUpdatedDescription": "Targets and settings updated successfully", + "targetsErrorUpdate": "Failed to update targets", + "targetsErrorUpdateDescription": "An error occurred while updating targets", + "targetTlsUpdate": "TLS settings updated", + "targetTlsUpdateDescription": "Your TLS settings have been updated successfully", + "targetErrorTlsUpdate": "Failed to update TLS settings", + "targetErrorTlsUpdateDescription": "An error occurred while updating TLS settings", + "proxyUpdated": "Proxy settings updated", + "proxyUpdatedDescription": "Your proxy settings have been updated successfully", + "proxyErrorUpdate": "Failed to update proxy settings", + "proxyErrorUpdateDescription": "An error occurred while updating proxy settings", + "targetAddr": "IP / Hostname", + "targetPort": "Port", + "targetProtocol": "Protocol", + "targetTlsSettings": "Secure Connection Configuration", + "targetTlsSettingsDescription": "Configure SSL/TLS settings for your resource", + "targetTlsSettingsAdvanced": "Advanced TLS Settings", + "targetTlsSni": "TLS Server Name (SNI)", + "targetTlsSniDescription": "The TLS Server Name to use for SNI. Leave empty to use the default.", + "targetTlsSubmit": "Save Settings", + "targets": "Targets Configuration", + "targetsDescription": "Set up targets to route traffic to your services", + "targetStickySessions": "Enable Sticky Sessions", + "targetStickySessionsDescription": "Keep connections on the same backend target for their entire session.", + "methodSelect": "Select method", + "targetSubmit": "Add Target", + "targetNoOne": "No targets. Add a target using the form.", + "targetNoOneDescription": "Adding more than one target above will enable load balancing.", + "targetsSubmit": "Save Targets", + "proxyAdditional": "Additional Proxy Settings", + "proxyAdditionalDescription": "Configure how your resource handles proxy settings", + "proxyCustomHeader": "Custom Host Header", + "proxyCustomHeaderDescription": "The host header to set when proxying requests. Leave empty to use the default.", + "proxyAdditionalSubmit": "Save Proxy Settings", + "subnetMaskErrorInvalid": "Invalid subnet mask. Must be between 0 and 32.", + "ipAddressErrorInvalidFormat": "Invalid IP address format", + "ipAddressErrorInvalidOctet": "Invalid IP address octet", + "path": "Path", + "ipAddressRange": "IP Range", + "rulesErrorFetch": "Failed to fetch rules", + "rulesErrorFetchDescription": "An error occurred while fetching rules", + "rulesErrorDuplicate": "Duplicate rule", + "rulesErrorDuplicateDescription": "A rule with these settings already exists", + "rulesErrorInvalidIpAddressRange": "Invalid CIDR", + "rulesErrorInvalidIpAddressRangeDescription": "Please enter a valid CIDR value", + "rulesErrorInvalidUrl": "Invalid URL path", + "rulesErrorInvalidUrlDescription": "Please enter a valid URL path value", + "rulesErrorInvalidIpAddress": "Invalid IP", + "rulesErrorInvalidIpAddressDescription": "Please enter a valid IP address", + "rulesErrorUpdate": "Failed to update rules", + "rulesErrorUpdateDescription": "An error occurred while updating rules", + "rulesUpdated": "Enable Rules", + "rulesUpdatedDescription": "Rule evaluation has been updated", + "rulesMatchIpAddressRangeDescription": "Enter an address in CIDR format (e.g., 103.21.244.0/22)", + "rulesMatchIpAddress": "Enter an IP address (e.g., 103.21.244.12)", + "rulesMatchUrl": "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)", + "rulesErrorInvalidPriority": "Invalid Priority", + "rulesErrorInvalidPriorityDescription": "Please enter a valid priority", + "rulesErrorDuplicatePriority": "Duplicate Priorities", + "rulesErrorDuplicatePriorityDescription": "Please enter unique priorities", + "ruleUpdated": "Rules updated", + "ruleUpdatedDescription": "Rules updated successfully", + "ruleErrorUpdate": "Operation failed", + "ruleErrorUpdateDescription": "An error occurred during the save operation", + "rulesPriority": "Priority", + "rulesAction": "Action", + "rulesMatchType": "Match Type", + "value": "Value", + "rulesAbout": "About Rules", + "rulesAboutDescription": "Rules allow you to control access to your resource based on a set of criteria. You can create rules to allow or deny access based on IP address or URL path.", + "rulesActions": "Actions", + "rulesActionAlwaysAllow": "Always Allow: Bypass all authentication methods", + "rulesActionAlwaysDeny": "Always Deny: Block all requests; no authentication can be attempted", + "rulesMatchCriteria": "Matching Criteria", + "rulesMatchCriteriaIpAddress": "Match a specific IP address", + "rulesMatchCriteriaIpAddressRange": "Match a range of IP addresses in CIDR notation", + "rulesMatchCriteriaUrl": "Match a URL path or pattern", + "rulesEnable": "Enable Rules", + "rulesEnableDescription": "Enable or disable rule evaluation for this resource", + "rulesResource": "Resource Rules Configuration", + "rulesResourceDescription": "Configure rules to control access to your resource", + "ruleSubmit": "Add Rule", + "rulesNoOne": "No rules. Add a rule using the form.", + "rulesOrder": "Rules are evaluated by priority in ascending order.", + "rulesSubmit": "Save Rules", + "resourceErrorCreate": "Error creating resource", + "resourceErrorCreateDescription": "An error occurred when creating the resource", + "resourceErrorCreateMessage": "Error creating resource:", + "resourceErrorCreateMessageDescription": "An unexpected error occurred", + "sitesErrorFetch": "Error fetching sites", + "sitesErrorFetchDescription": "An error occurred when fetching the sites", + "domainsErrorFetch": "Error fetching domains", + "domainsErrorFetchDescription": "An error occurred when fetching the domains", + "none": "None", + "unknown": "Unknown", + "resources": "Resources", + "resourcesDescription": "Resources are proxies to applications running on your private network. Create a resource for any HTTP/HTTPS or raw TCP/UDP service on your private network. Each resource must be connected to a site to enable private, secure connectivity through an encrypted WireGuard tunnel.", + "resourcesWireGuardConnect": "Secure connectivity with WireGuard encryption", + "resourcesMultipleAuthenticationMethods": "Configure multiple authentication methods", + "resourcesUsersRolesAccess": "User and role-based access control", + "resourcesErrorUpdate": "Failed to toggle resource", + "resourcesErrorUpdateDescription": "An error occurred while updating the resource", + "access": "Access", + "shareLink": "{resource} Share Link", + "resourceSelect": "Select resource", + "shareLinks": "Share Links", + "share": "Shareable Links", + "shareDescription2": "Create shareable links to your resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one.", + "shareEasyCreate": "Easy to create and share", + "shareConfigurableExpirationDuration": "Configurable expiration duration", + "shareSecureAndRevocable": "Secure and revocable", + "nameMin": "Name must be at least {len} characters.", + "nameMax": "Name must not be longer than {len} characters.", + "sitesConfirmCopy": "Please confirm that you have copied the config.", + "unknownCommand": "Unknown command", + "newtErrorFetchReleases": "Failed to fetch release info: {err}", + "newtErrorFetchLatest": "Error fetching latest release: {err}", + "newtEndpoint": "Newt Endpoint", + "newtId": "Newt ID", + "newtSecretKey": "Newt Secret Key", + "architecture": "Architecture", + "sites": "Sites", + "siteWgAnyClients": "Use any WireGuard client to connect. You will have to address your internal resources using the peer IP.", + "siteWgCompatibleAllClients": "Compatible with all WireGuard clients", + "siteWgManualConfigurationRequired": "Manual configuration required", + "userErrorNotAdminOrOwner": "User is not an admin or owner", + "pangolinSettings": "Settings - Pangolin", + "accessRoleYour": "Your role:", + "accessRoleSelect2": "Select a role", + "accessUserSelect": "Select a user", + "otpEmailEnter": "Enter an email", + "otpEmailEnterDescription": "Press enter to add an email after typing it in the input field.", + "otpEmailErrorInvalid": "Invalid email address. Wildcard (*) must be the entire local part.", + "otpEmailSmtpRequired": "SMTP Required", + "otpEmailSmtpRequiredDescription": "SMTP must be enabled on the server to use one-time password authentication.", + "otpEmailTitle": "One-time Passwords", + "otpEmailTitleDescription": "Require email-based authentication for resource access", + "otpEmailWhitelist": "Email Whitelist", + "otpEmailWhitelistList": "Whitelisted Emails", + "otpEmailWhitelistListDescription": "Only users with these email addresses will be able to access this resource. They will be prompted to enter a one-time password sent to their email. Wildcards (*@example.com) can be used to allow any email address from a domain.", + "otpEmailWhitelistSave": "Save Whitelist", + "passwordAdd": "Add Password", + "passwordRemove": "Remove Password", + "pincodeAdd": "Add PIN Code", + "pincodeRemove": "Remove PIN Code", + "resourceAuthMethods": "Authentication Methods", + "resourceAuthMethodsDescriptions": "Allow access to the resource via additional auth methods", + "resourceAuthSettingsSave": "Saved successfully", + "resourceAuthSettingsSaveDescription": "Authentication settings have been saved", + "resourceErrorAuthFetch": "Failed to fetch data", + "resourceErrorAuthFetchDescription": "An error occurred while fetching the data", + "resourceErrorPasswordRemove": "Error removing resource password", + "resourceErrorPasswordRemoveDescription": "An error occurred while removing the resource password", + "resourceErrorPasswordSetup": "Error setting resource password", + "resourceErrorPasswordSetupDescription": "An error occurred while setting the resource password", + "resourceErrorPincodeRemove": "Error removing resource pincode", + "resourceErrorPincodeRemoveDescription": "An error occurred while removing the resource pincode", + "resourceErrorPincodeSetup": "Error setting resource PIN code", + "resourceErrorPincodeSetupDescription": "An error occurred while setting the resource PIN code", + "resourceErrorUsersRolesSave": "Failed to set roles", + "resourceErrorUsersRolesSaveDescription": "An error occurred while setting the roles", + "resourceErrorWhitelistSave": "Failed to save whitelist", + "resourceErrorWhitelistSaveDescription": "An error occurred while saving the whitelist", + "resourcePasswordSubmit": "Enable Password Protection", + "resourcePasswordProtection": "Password Protection {status}", + "resourcePasswordRemove": "Resource password removed", + "resourcePasswordRemoveDescription": "The resource password has been removed successfully", + "resourcePasswordSetup": "Resource password set", + "resourcePasswordSetupDescription": "The resource password has been set successfully", + "resourcePasswordSetupTitle": "Set Password", + "resourcePasswordSetupTitleDescription": "Set a password to protect this resource", + "resourcePincode": "PIN Code", + "resourcePincodeSubmit": "Enable PIN Code Protection", + "resourcePincodeProtection": "PIN Code Protection {status}", + "resourcePincodeRemove": "Resource pincode removed", + "resourcePincodeRemoveDescription": "The resource password has been removed successfully", + "resourcePincodeSetup": "Resource PIN code set", + "resourcePincodeSetupDescription": "The resource pincode has been set successfully", + "resourcePincodeSetupTitle": "Set Pincode", + "resourcePincodeSetupTitleDescription": "Set a pincode to protect this resource", + "resourceRoleDescription": "Admins can always access this resource.", + "resourceUsersRoles": "Users & Roles", + "resourceUsersRolesDescription": "Configure which users and roles can visit this resource", + "resourceUsersRolesSubmit": "Save Users & Roles", + "resourceWhitelistSave": "Saved successfully", + "resourceWhitelistSaveDescription": "Whitelist settings have been saved", + "ssoUse": "Use Platform SSO", + "ssoUseDescription": "Existing users will only have to log in once for all resources that have this enabled.", + "proxyErrorInvalidPort": "Invalid port number", + "subdomainErrorInvalid": "Invalid subdomain", + "domainErrorFetch": "Error fetching domains", + "domainErrorFetchDescription": "An error occurred when fetching the domains", + "resourceErrorUpdate": "Failed to update resource", + "resourceErrorUpdateDescription": "An error occurred while updating the resource", + "resourceUpdated": "Resource updated", + "resourceUpdatedDescription": "The resource has been updated successfully", + "resourceErrorTransfer": "Failed to transfer resource", + "resourceErrorTransferDescription": "An error occurred while transferring the resource", + "resourceTransferred": "Resource transferred", + "resourceTransferredDescription": "The resource has been transferred successfully", + "resourceErrorToggle": "Failed to toggle resource", + "resourceErrorToggleDescription": "An error occurred while updating the resource", + "resourceVisibilityTitle": "Visibility", + "resourceVisibilityTitleDescription": "Completely enable or disable resource visibility", + "resourceGeneral": "General Settings", + "resourceGeneralDescription": "Configure the general settings for this resource", + "resourceEnable": "Enable Resource", + "resourceTransfer": "Transfer Resource", + "resourceTransferDescription": "Transfer this resource to a different site", + "resourceTransferSubmit": "Transfer Resource", + "siteDestination": "Destination Site", + "searchSites": "Search sites", + "accessRoleCreate": "Create Role", + "accessRoleCreateDescription": "Create a new role to group users and manage their permissions.", + "accessRoleCreateSubmit": "Create Role", + "accessRoleCreated": "Role created", + "accessRoleCreatedDescription": "The role has been successfully created.", + "accessRoleErrorCreate": "Failed to create role", + "accessRoleErrorCreateDescription": "An error occurred while creating the role.", + "accessRoleErrorNewRequired": "New role is required", + "accessRoleErrorRemove": "Failed to remove role", + "accessRoleErrorRemoveDescription": "An error occurred while removing the role.", + "accessRoleName": "Role Name", + "accessRoleQuestionRemove": "You're about to delete the {name} role. You cannot undo this action.", + "accessRoleRemove": "Remove Role", + "accessRoleRemoveDescription": "Remove a role from the organization", + "accessRoleRemoveSubmit": "Remove Role", + "accessRoleRemoved": "Role removed", + "accessRoleRemovedDescription": "The role has been successfully removed.", + "accessRoleRequiredRemove": "Before deleting this role, please select a new role to transfer existing members to.", + "manage": "Manage", + "sitesNotFound": "No sites found.", + "pangolinServerAdmin": "Server Admin - Pangolin", + "licenseTierProfessional": "Professional License", + "licenseTierEnterprise": "Enterprise License", + "licenseTierCommercial": "Commercial License", + "licensed": "Licensed", + "yes": "Yes", + "no": "No", + "sitesAdditional": "Additional Sites", + "licenseKeys": "License Keys", + "sitestCountDecrease": "Decrease site count", + "sitestCountIncrease": "Increase site count", + "idpManage": "Manage Identity Providers", + "idpManageDescription": "View and manage identity providers in the system", + "idpDeletedDescription": "Identity provider deleted successfully", + "idpOidc": "OAuth2/OIDC", + "idpQuestionRemove": "Are you sure you want to permanently delete the identity provider {name}?", + "idpMessageRemove": "This will remove the identity provider and all associated configurations. Users who authenticate through this provider will no longer be able to log in.", + "idpMessageConfirm": "To confirm, please type the name of the identity provider below.", + "idpConfirmDelete": "Confirm Delete Identity Provider", + "idpDelete": "Delete Identity Provider", + "idp": "Identity Providers", + "idpSearch": "Search identity providers...", + "idpAdd": "Add Identity Provider", + "idpClientIdRequired": "Client ID is required.", + "idpClientSecretRequired": "Client Secret is required.", + "idpErrorAuthUrlInvalid": "Auth URL must be a valid URL.", + "idpErrorTokenUrlInvalid": "Token URL must be a valid URL.", + "idpPathRequired": "Identifier Path is required.", + "idpScopeRequired": "Scopes are required.", + "idpOidcDescription": "Configure an OpenID Connect identity provider", + "idpCreatedDescription": "Identity provider created successfully", + "idpCreate": "Create Identity Provider", + "idpCreateDescription": "Configure a new identity provider for user authentication", + "idpSeeAll": "See All Identity Providers", + "idpSettingsDescription": "Configure the basic information for your identity provider", + "idpDisplayName": "A display name for this identity provider", + "idpAutoProvisionUsers": "Auto Provision Users", + "idpAutoProvisionUsersDescription": "When enabled, users will be automatically created in the system upon first login with the ability to map users to roles and organizations.", + "licenseBadge": "Professional", + "idpType": "Provider Type", + "idpTypeDescription": "Select the type of identity provider you want to configure", + "idpOidcConfigure": "OAuth2/OIDC Configuration", + "idpOidcConfigureDescription": "Configure the OAuth2/OIDC provider endpoints and credentials", + "idpClientId": "Client ID", + "idpClientIdDescription": "The OAuth2 client ID from your identity provider", + "idpClientSecret": "Client Secret", + "idpClientSecretDescription": "The OAuth2 client secret from your identity provider", + "idpAuthUrl": "Authorization URL", + "idpAuthUrlDescription": "The OAuth2 authorization endpoint URL", + "idpTokenUrl": "Token URL", + "idpTokenUrlDescription": "The OAuth2 token endpoint URL", + "idpOidcConfigureAlert": "Important Information", + "idpOidcConfigureAlertDescription": "After creating the identity provider, you will need to configure the callback URL in your identity provider's settings. The callback URL will be provided after successful creation.", + "idpToken": "Token Configuration", + "idpTokenDescription": "Configure how to extract user information from the ID token", + "idpJmespathAbout": "About JMESPath", + "idpJmespathAboutDescription": "The paths below use JMESPath syntax to extract values from the ID token.", + "idpJmespathAboutDescriptionLink": "Learn more about JMESPath", + "idpJmespathLabel": "Identifier Path", + "idpJmespathLabelDescription": "The path to the user identifier in the ID token", + "idpJmespathEmailPathOptional": "Email Path (Optional)", + "idpJmespathEmailPathOptionalDescription": "The path to the user's email in the ID token", + "idpJmespathNamePathOptional": "Name Path (Optional)", + "idpJmespathNamePathOptionalDescription": "The path to the user's name in the ID token", + "idpOidcConfigureScopes": "Scopes", + "idpOidcConfigureScopesDescription": "Space-separated list of OAuth2 scopes to request", + "idpSubmit": "Create Identity Provider", + "orgPolicies": "Organization Policies", + "idpSettings": "{idpName} Settings", + "idpCreateSettingsDescription": "Configure the settings for your identity provider", + "roleMapping": "Role Mapping", + "orgMapping": "Organization Mapping", + "orgPoliciesSearch": "Search organization policies...", + "orgPoliciesAdd": "Add Organization Policy", + "orgRequired": "Organization is required", + "error": "Error", + "success": "Success", + "orgPolicyAddedDescription": "Policy added successfully", + "orgPolicyUpdatedDescription": "Policy updated successfully", + "orgPolicyDeletedDescription": "Policy deleted successfully", + "defaultMappingsUpdatedDescription": "Default mappings updated successfully", + "orgPoliciesAbout": "About Organization Policies", + "orgPoliciesAboutDescription": "Organization policies are used to control access to organizations based on the user's ID token. You can specify JMESPath expressions to extract role and organization information from the ID token.", + "orgPoliciesAboutDescriptionLink": "See documentation, for more information.", + "defaultMappingsOptional": "Default Mappings (Optional)", + "defaultMappingsOptionalDescription": "The default mappings are used when when there is not an organization policy defined for an organization. You can specify the default role and organization mappings to fall back to here.", + "defaultMappingsRole": "Default Role Mapping", + "defaultMappingsRoleDescription": "The result of this expression must return the role name as defined in the organization as a string.", + "defaultMappingsOrg": "Default Organization Mapping", + "defaultMappingsOrgDescription": "This expression must return the org ID or true for the user to be allowed to access the organization.", + "defaultMappingsSubmit": "Save Default Mappings", + "orgPoliciesEdit": "Edit Organization Policy", + "org": "Organization", + "orgSelect": "Select organization", + "orgSearch": "Search org", + "orgNotFound": "No org found.", + "roleMappingPathOptional": "Role Mapping Path (Optional)", + "orgMappingPathOptional": "Organization Mapping Path (Optional)", + "orgPolicyUpdate": "Update Policy", + "orgPolicyAdd": "Add Policy", + "orgPolicyConfig": "Configure access for an organization", + "idpUpdatedDescription": "Identity provider updated successfully", + "redirectUrl": "Redirect URL", + "redirectUrlAbout": "About Redirect URL", + "redirectUrlAboutDescription": "This is the URL to which users will be redirected after authentication. You need to configure this URL in your identity provider settings.", + "pangolinAuth": "Auth - Pangolin", + "verificationCodeLengthRequirements": "Your verification code must be 8 characters.", + "errorOccurred": "An error occurred", + "emailErrorVerify": "Failed to verify email:", + "emailVerified": "Email successfully verified! Redirecting you...", + "verificationCodeErrorResend": "Failed to resend verification code:", + "verificationCodeResend": "Verification code resent", + "verificationCodeResendDescription": "We've resent a verification code to your email address. Please check your inbox.", + "emailVerify": "Verify Email", + "emailVerifyDescription": "Enter the verification code sent to your email address.", + "verificationCode": "Verification Code", + "verificationCodeEmailSent": "We sent a verification code to your email address.", + "submit": "Submit", + "emailVerifyResendProgress": "Resending...", + "emailVerifyResend": "Didn't receive a code? Click here to resend", + "passwordNotMatch": "Passwords do not match", + "signupError": "An error occurred while signing up", + "pangolinLogoAlt": "Pangolin Logo", + "inviteAlready": "Looks like you've been invited!", + "inviteAlreadyDescription": "To accept the invite, you must log in or create an account.", + "signupQuestion": "Already have an account?", + "login": "Log in", + "resourceNotFound": "Resource Not Found", + "resourceNotFoundDescription": "The resource you're trying to access does not exist.", + "pincodeRequirementsLength": "PIN must be exactly 6 digits", + "pincodeRequirementsChars": "PIN must only contain numbers", + "passwordRequirementsLength": "Password must be at least 1 character long", + "otpEmailRequirementsLength": "OTP must be at least 1 character long", + "otpEmailSent": "OTP Sent", + "otpEmailSentDescription": "An OTP has been sent to your email", + "otpEmailErrorAuthenticate": "Failed to authenticate with email", + "pincodeErrorAuthenticate": "Failed to authenticate with pincode", + "passwordErrorAuthenticate": "Failed to authenticate with password", + "poweredBy": "Powered by", + "authenticationRequired": "Authentication Required", + "authenticationMethodChoose": "Choose your preferred method to access {name}", + "authenticationRequest": "You must authenticate to access {name}", + "user": "User", + "pincodeInput": "6-digit PIN Code", + "pincodeSubmit": "Log in with PIN", + "passwordSubmit": "Log In with Password", + "otpEmailDescription": "A one-time code will be sent to this email.", + "otpEmailSend": "Send One-time Code", + "otpEmail": "One-Time Password (OTP)", + "otpEmailSubmit": "Submit OTP", + "backToEmail": "Back to Email", + "noSupportKey": "Server is running without a supporter key. Consider supporting the project!", + "accessDenied": "Access Denied", + "accessDeniedDescription": "You're not allowed to access this resource. If this is a mistake, please contact the administrator.", + "accessTokenError": "Error checking access token", + "accessGranted": "Access Granted", + "accessUrlInvalid": "Access URL Invalid", + "accessGrantedDescription": "You have been granted access to this resource. Redirecting you...", + "accessUrlInvalidDescription": "This shared access URL is invalid. Please contact the resource owner for a new URL.", + "tokenInvalid": "Invalid token", + "pincodeInvalid": "Invalid code", + "passwordErrorRequestReset": "Failed to request reset:", + "passwordErrorReset": "Failed to reset password:", + "passwordResetSuccess": "Password reset successfully! Back to log in...", + "passwordReset": "Reset Password", + "passwordResetDescription": "Follow the steps to reset your password", + "passwordResetSent": "We'll send a password reset code to this email address.", + "passwordResetCode": "Reset Code", + "passwordResetCodeDescription": "Check your email for the reset code.", + "passwordNew": "New Password", + "passwordNewConfirm": "Confirm New Password", + "pincodeAuth": "Authenticator Code", + "pincodeSubmit2": "Submit Code", + "passwordResetSubmit": "Request Reset", + "passwordBack": "Back to Password", + "loginBack": "Go back to log in", + "signup": "Sign up", + "loginStart": "Log in to get started", + "idpOidcTokenValidating": "Validating OIDC token", + "idpOidcTokenResponse": "Validate OIDC token response", + "idpErrorOidcTokenValidating": "Error validating OIDC token", + "idpConnectingTo": "Connecting to {name}", + "idpConnectingToDescription": "Validating your identity", + "idpConnectingToProcess": "Connecting...", + "idpConnectingToFinished": "Connected", + "idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.", + "idpErrorNotFound": "IdP not found", + "inviteInvalid": "Invalid Invite", + "inviteInvalidDescription": "The invite link is invalid.", + "inviteErrorWrongUser": "Invite is not for this user", + "inviteErrorUserNotExists": "User does not exist. Please create an account first.", + "inviteErrorLoginRequired": "You must be logged in to accept an invite", + "inviteErrorExpired": "The invite may have expired", + "inviteErrorRevoked": "The invite might have been revoked", + "inviteErrorTypo": "There could be a typo in the invite link", + "pangolinSetup": "Setup - Pangolin", + "orgNameRequired": "Organization name is required", + "orgIdRequired": "Organization ID is required", + "orgErrorCreate": "An error occurred while creating org", + "pageNotFound": "Page Not Found", + "pageNotFoundDescription": "Oops! The page you're looking for doesn't exist.", + "overview": "Overview", + "home": "Home", + "accessControl": "Access Control", + "settings": "Settings", + "usersAll": "All Users", + "license": "License", + "pangolinDashboard": "Dashboard - Pangolin", + "noResults": "No results found.", + "terabytes": "{count} TB", + "gigabytes": "{count} GB", + "megabytes": "{count} MB", + "tagsEntered": "Entered Tags", + "tagsEnteredDescription": "These are the tags you`ve entered.", + "tagsWarnCannotBeLessThanZero": "maxTags and minTags cannot be less than 0", + "tagsWarnNotAllowedAutocompleteOptions": "Tag not allowed as per autocomplete options", + "tagsWarnInvalid": "Invalid tag as per validateTag", + "tagWarnTooShort": "Tag {tagText} is too short", + "tagWarnTooLong": "Tag {tagText} is too long", + "tagsWarnReachedMaxNumber": "Reached the maximum number of tags allowed", + "tagWarnDuplicate": "Duplicate tag {tagText} not added", + "supportKeyInvalid": "Invalid Key", + "supportKeyInvalidDescription": "Your supporter key is invalid.", + "supportKeyValid": "Valid Key", + "supportKeyValidDescription": "Your supporter key has been validated. Thank you for your support!", + "supportKeyErrorValidationDescription": "Failed to validate supporter key.", + "supportKey": "Support Development and Adopt a Pangolin!", + "supportKeyDescription": "Purchase a supporter key to help us continue developing Pangolin for the community. Your contribution allows us to commit more time to maintain and add new features to the application for everyone. We will never use this to paywall features. This is separate from any Commercial Edition.", + "supportKeyPet": "You will also get to adopt and meet your very own pet Pangolin!", + "supportKeyPurchase": "Payments are processed via GitHub. Afterward, you can retrieve your key on", + "supportKeyPurchaseLink": "our website", + "supportKeyPurchase2": "and redeem it here.", + "supportKeyLearnMore": "Learn more.", + "supportKeyOptions": "Please select the option that best suits you.", + "supportKetOptionFull": "Full Supporter", + "forWholeServer": "For the whole server", + "lifetimePurchase": "Lifetime purchase", + "supporterStatus": "Supporter status", + "buy": "Buy", + "supportKeyOptionLimited": "Limited Supporter", + "forFiveUsers": "For 5 or less users", + "supportKeyRedeem": "Redeem Supporter Key", + "supportKeyHideSevenDays": "Hide for 7 days", + "supportKeyEnter": "Enter Supporter Key", + "supportKeyEnterDescription": "Meet your very own pet Pangolin!", + "githubUsername": "GitHub Username", + "supportKeyInput": "Supporter Key", + "supportKeyBuy": "Buy Supporter Key", + "logoutError": "Error logging out", + "signingAs": "Signed in as", + "serverAdmin": "Server Admin", + "otpEnable": "Enable Two-factor", + "otpDisable": "Disable Two-factor", + "logout": "Log Out", + "licenseTierProfessionalRequired": "Professional Edition Required", + "licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.", + "actionGetOrg": "Get Organization", + "actionUpdateOrg": "Update Organization", + "actionUpdateUser": "Update User", + "actionGetUser": "Get User", + "actionGetOrgUser": "Get Organization User", + "actionListOrgDomains": "List Organization Domains", + "actionCreateSite": "Create Site", + "actionDeleteSite": "Delete Site", + "actionGetSite": "Get Site", + "actionListSites": "List Sites", + "actionUpdateSite": "Update Site", + "actionListSiteRoles": "List Allowed Site Roles", + "actionCreateResource": "Create Resource", + "actionDeleteResource": "Delete Resource", + "actionGetResource": "Get Resource", + "actionListResource": "List Resources", + "actionUpdateResource": "Update Resource", + "actionListResourceUsers": "List Resource Users", + "actionSetResourceUsers": "Set Resource Users", + "actionSetAllowedResourceRoles": "Set Allowed Resource Roles", + "actionListAllowedResourceRoles": "List Allowed Resource Roles", + "actionSetResourcePassword": "Set Resource Password", + "actionSetResourcePincode": "Set Resource Pincode", + "actionSetResourceEmailWhitelist": "Set Resource Email Whitelist", + "actionGetResourceEmailWhitelist": "Get Resource Email Whitelist", + "actionCreateTarget": "Create Target", + "actionDeleteTarget": "Delete Target", + "actionGetTarget": "Get Target", + "actionListTargets": "List Targets", + "actionUpdateTarget": "Update Target", + "actionCreateRole": "Create Role", + "actionDeleteRole": "Delete Role", + "actionGetRole": "Get Role", + "actionListRole": "List Roles", + "actionUpdateRole": "Update Role", + "actionListAllowedRoleResources": "List Allowed Role Resources", + "actionInviteUser": "Invite User", + "actionRemoveUser": "Remove User", + "actionListUsers": "List Users", + "actionAddUserRole": "Add User Role", + "actionGenerateAccessToken": "Generate Access Token", + "actionDeleteAccessToken": "Delete Access Token", + "actionListAccessTokens": "List Access Tokens", + "actionCreateResourceRule": "Create Resource Rule", + "actionDeleteResourceRule": "Delete Resource Rule", + "actionListResourceRules": "List Resource Rules", + "actionUpdateResourceRule": "Update Resource Rule", + "actionListOrgs": "List Organizations", + "actionCheckOrgId": "Check ID", + "actionCreateOrg": "Create Organization", + "actionDeleteOrg": "Delete Organization", + "actionListApiKeys": "List API Keys", + "actionListApiKeyActions": "List API Key Actions", + "actionSetApiKeyActions": "Set API Key Allowed Actions", + "actionCreateApiKey": "Create API Key", + "actionDeleteApiKey": "Delete API Key", + "actionCreateIdp": "Create IDP", + "actionUpdateIdp": "Update IDP", + "actionDeleteIdp": "Delete IDP", + "actionListIdps": "List IDP", + "actionGetIdp": "Get IDP", + "actionCreateIdpOrg": "Create IDP Org Policy", + "actionDeleteIdpOrg": "Delete IDP Org Policy", + "actionListIdpOrgs": "List IDP Orgs", + "actionUpdateIdpOrg": "Update IDP Org", + "actionCreateClient": "Create Client", + "actionDeleteClient": "Delete Client", + "actionUpdateClient": "Update Client", + "actionListClients": "List Clients", + "actionGetClient": "Get Client", + "noneSelected": "None selected", + "orgNotFound2": "No organizations found.", + "searchProgress": "Search...", + "create": "Create", + "orgs": "Organizations", + "loginError": "An error occurred while logging in", + "passwordForgot": "Forgot your password?", + "otpAuth": "Two-Factor Authentication", + "otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.", + "otpAuthSubmit": "Submit Code", + "idpContinue": "Or continue with", + "otpAuthBack": "Back to Log In", + "navbar": "Navigation Menu", + "navbarDescription": "Main navigation menu for the application", + "navbarDocsLink": "Documentation", + "commercialEdition": "Commercial Edition", + "otpErrorEnable": "Unable to enable 2FA", + "otpErrorEnableDescription": "An error occurred while enabling 2FA", + "otpSetupCheckCode": "Please enter a 6-digit code", + "otpSetupCheckCodeRetry": "Invalid code. Please try again.", + "otpSetup": "Enable Two-factor Authentication", + "otpSetupDescription": "Secure your account with an extra layer of protection", + "otpSetupScanQr": "Scan this QR code with your authenticator app or enter the secret key manually:", + "otpSetupSecretCode": "Authenticator Code", + "otpSetupSuccess": "Two-Factor Authentication Enabled", + "otpSetupSuccessStoreBackupCodes": "Your account is now more secure. Don't forget to save your backup codes.", + "otpErrorDisable": "Unable to disable 2FA", + "otpErrorDisableDescription": "An error occurred while disabling 2FA", + "otpRemove": "Disable Two-factor Authentication", + "otpRemoveDescription": "Disable two-factor authentication for your account", + "otpRemoveSuccess": "Two-Factor Authentication Disabled", + "otpRemoveSuccessMessage": "Two-factor authentication has been disabled for your account. You can enable it again at any time.", + "otpRemoveSubmit": "Disable 2FA", + "paginator": "Page {current} of {last}", + "paginatorToFirst": "Go to first page", + "paginatorToPrevious": "Go to previous page", + "paginatorToNext": "Go to next page", + "paginatorToLast": "Go to last page", + "copyText": "Copy text", + "copyTextFailed": "Failed to copy text: ", + "copyTextClipboard": "Copy to clipboard", + "inviteErrorInvalidConfirmation": "Invalid confirmation", + "passwordRequired": "Password is required", + "allowAll": "Allow All", + "permissionsAllowAll": "Allow All Permissions", + "githubUsernameRequired": "GitHub username is required", + "supportKeyRequired": "Supporter key is required", + "passwordRequirementsChars": "Password must be at least 8 characters", + "language": "Language", + "verificationCodeRequired": "Code is required", + "userErrorNoUpdate": "No user to update", + "siteErrorNoUpdate": "No site to update", + "resourceErrorNoUpdate": "No resource to update", + "authErrorNoUpdate": "No auth info to update", + "orgErrorNoUpdate": "No org to update", + "orgErrorNoProvided": "No org provided", + "apiKeysErrorNoUpdate": "No API key to update", + "sidebarOverview": "Overview", + "sidebarHome": "Home", + "sidebarSites": "Sites", + "sidebarResources": "Resources", + "sidebarAccessControl": "Access Control", + "sidebarUsers": "Users", + "sidebarInvitations": "Invitations", + "sidebarRoles": "Roles", + "sidebarShareableLinks": "Shareable Links", + "sidebarApiKeys": "API Keys", + "sidebarSettings": "Settings", + "sidebarAllUsers": "All Users", + "sidebarIdentityProviders": "Identity Providers", + "sidebarLicense": "License", + "sidebarClients": "Clients (Beta)", + "sidebarDomains": "Domains", + "enableDockerSocket": "Enable Docker Socket", + "enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.", + "enableDockerSocketLink": "Learn More", + "viewDockerContainers": "View Docker Containers", + "containersIn": "Containers in {siteName}", + "selectContainerDescription": "Select any container to use as a hostname for this target. Click a port to use a port.", + "containerName": "Name", + "containerImage": "Image", + "containerState": "State", + "containerNetworks": "Networks", + "containerHostnameIp": "Hostname/IP", + "containerLabels": "Labels", + "containerLabelsCount": "{count, plural, one {# label} other {# labels}}", + "containerLabelsTitle": "Container Labels", + "containerLabelEmpty": "", + "containerPorts": "Ports", + "containerPortsMore": "+{count} more", + "containerActions": "Actions", + "select": "Select", + "noContainersMatchingFilters": "No containers found matching the current filters.", + "showContainersWithoutPorts": "Show containers without ports", + "showStoppedContainers": "Show stopped containers", + "noContainersFound": "No containers found. Make sure Docker containers are running.", + "searchContainersPlaceholder": "Search across {count} containers...", + "searchResultsCount": "{count, plural, one {# result} other {# results}}", + "filters": "Filters", + "filterOptions": "Filter Options", + "filterPorts": "Ports", + "filterStopped": "Stopped", + "clearAllFilters": "Clear all filters", + "columns": "Columns", + "toggleColumns": "Toggle Columns", + "refreshContainersList": "Refresh containers list", + "searching": "Searching...", + "noContainersFoundMatching": "No containers found matching \"{filter}\".", + "light": "light", + "dark": "dark", + "system": "system", + "theme": "Theme", + "subnetRequired": "Subnet is required", + "initialSetupTitle": "Initial Server Setup", + "initialSetupDescription": "Create the intial server admin account. Only one server admin can exist. You can always change these credentials later.", + "createAdminAccount": "Create Admin Account", + "setupErrorCreateAdmin": "An error occurred while creating the server admin account.", + "certificateStatus": "Certificate Status", + "loading": "Loading", + "restart": "Restart", + "domains": "Domains", + "domainsDescription": "Manage domains for your organization", + "domainsSearch": "Search domains...", + "domainAdd": "Add Domain", + "domainAddDescription": "Register a new domain with your organization", + "domainCreate": "Create Domain", + "domainCreatedDescription": "Domain created successfully", + "domainDeletedDescription": "Domain deleted successfully", + "domainQuestionRemove": "Are you sure you want to remove the domain {domain} from your account?", + "domainMessageRemove": "Once removed, the domain will no longer be associated with your account.", + "domainMessageConfirm": "To confirm, please type the domain name below.", + "domainConfirmDelete": "Confirm Delete Domain", + "domainDelete": "Delete Domain", + "domain": "Domain", + "selectDomainTypeNsName": "Domain Delegation (NS)", + "selectDomainTypeNsDescription": "This domain and all its subdomains. Use this when you want to control an entire domain zone.", + "selectDomainTypeCnameName": "Single Domain (CNAME)", + "selectDomainTypeCnameDescription": "Just this specific domain. Use this for individual subdomains or specific domain entries.", + "selectDomainTypeWildcardName": "Wildcard Domain", + "selectDomainTypeWildcardDescription": "This domain and its subdomains.", + "domainDelegation": "Single Domain", + "selectType": "Select a type", + "actions": "Actions", + "refresh": "Refresh", + "refreshError": "Failed to refresh data", + "verified": "Verified", + "pending": "Pending", + "sidebarBilling": "Billing", + "billing": "Billing", + "orgBillingDescription": "Manage your billing information and subscriptions", + "github": "GitHub", + "pangolinHosted": "Pangolin Hosted", + "fossorial": "Fossorial", + "completeAccountSetup": "Complete Account Setup", + "completeAccountSetupDescription": "Set your password to get started", + "accountSetupSent": "We'll send an account setup code to this email address.", + "accountSetupCode": "Setup Code", + "accountSetupCodeDescription": "Check your email for the setup code.", + "passwordCreate": "Create Password", + "passwordCreateConfirm": "Confirm Password", + "accountSetupSubmit": "Send Setup Code", + "completeSetup": "Complete Setup", + "accountSetupSuccess": "Account setup completed! Welcome to Pangolin!", + "documentation": "Documentation", + "saveAllSettings": "Save All Settings", + "settingsUpdated": "Settings updated", + "settingsUpdatedDescription": "All settings have been updated successfully", + "settingsErrorUpdate": "Failed to update settings", + "settingsErrorUpdateDescription": "An error occurred while updating settings", + "sidebarCollapse": "Collapse", + "sidebarExpand": "Expand", + "newtUpdateAvailable": "Update Available", + "newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", + "domainPickerEnterDomain": "Domain", + "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp", + "domainPickerDescription": "Enter the full domain of the resource to see available options.", + "domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options", + "domainPickerTabAll": "All", + "domainPickerTabOrganization": "Organization", + "domainPickerTabProvided": "Provided", + "domainPickerSortAsc": "A-Z", + "domainPickerSortDesc": "Z-A", + "domainPickerCheckingAvailability": "Checking availability...", + "domainPickerNoMatchingDomains": "No matching domains found. Try a different domain or check your organization's domain settings.", + "domainPickerOrganizationDomains": "Organization Domains", + "domainPickerProvidedDomains": "Provided Domains", + "domainPickerSubdomain": "Subdomain: {subdomain}", + "domainPickerNamespace": "Namespace: {namespace}", + "domainPickerShowMore": "Show More", + "domainNotFound": "Domain Not Found", + "domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.", + "failed": "Failed", + "createNewOrgDescription": "Create a new organization", + "organization": "Organization", + "port": "Port", + "securityKeyManage": "Manage Security Keys", + "securityKeyDescription": "Add or remove security keys for passwordless authentication", + "securityKeyRegister": "Register New Security Key", + "securityKeyList": "Your Security Keys", + "securityKeyNone": "No security keys registered yet", + "securityKeyNameRequired": "Name is required", + "securityKeyRemove": "Remove", + "securityKeyLastUsed": "Last used: {date}", + "securityKeyNameLabel": "Security Key Name", + "securityKeyRegisterSuccess": "Security key registered successfully", + "securityKeyRegisterError": "Failed to register security key", + "securityKeyRemoveSuccess": "Security key removed successfully", + "securityKeyRemoveError": "Failed to remove security key", + "securityKeyLoadError": "Failed to load security keys", + "securityKeyLogin": "Continue with security key", + "securityKeyAuthError": "Failed to authenticate with security key", + "securityKeyRecommendation": "Register a backup security key on another device to ensure you always have access to your account.", + "registering": "Registering...", + "securityKeyPrompt": "Please verify your identity using your security key. Make sure your security key is connected and ready.", + "securityKeyBrowserNotSupported": "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari.", + "securityKeyPermissionDenied": "Please allow access to your security key to continue signing in.", + "securityKeyRemovedTooQuickly": "Please keep your security key connected until the sign-in process completes.", + "securityKeyNotSupported": "Your security key may not be compatible. Please try a different security key.", + "securityKeyUnknownError": "There was a problem using your security key. Please try again.", + "twoFactorRequired": "Two-factor authentication is required to register a security key.", + "twoFactor": "Two-Factor Authentication", + "adminEnabled2FaOnYourAccount": "Your administrator has enabled two-factor authentication for {email}. Please complete the setup process to continue.", + "continueToApplication": "Continue to Application", + "securityKeyAdd": "Add Security Key", + "securityKeyRegisterTitle": "Register New Security Key", + "securityKeyRegisterDescription": "Connect your security key and enter a name to identify it", + "securityKeyTwoFactorRequired": "Two-Factor Authentication Required", + "securityKeyTwoFactorDescription": "Please enter your two-factor authentication code to register the security key", + "securityKeyTwoFactorRemoveDescription": "Please enter your two-factor authentication code to remove the security key", + "securityKeyTwoFactorCode": "Two-Factor Code", + "securityKeyRemoveTitle": "Remove Security Key", + "securityKeyRemoveDescription": "Enter your password to remove the security key \"{name}\"", + "securityKeyNoKeysRegistered": "No security keys registered", + "securityKeyNoKeysDescription": "Add a security key to enhance your account security", + "createDomainRequired": "Domain is required", + "createDomainAddDnsRecords": "Add DNS Records", + "createDomainAddDnsRecordsDescription": "Add the following DNS records to your domain provider to complete the setup.", + "createDomainNsRecords": "NS Records", + "createDomainRecord": "Record", + "createDomainType": "Type:", + "createDomainName": "Name:", + "createDomainValue": "Value:", + "createDomainCnameRecords": "CNAME Records", + "createDomainARecords": "A Records", + "createDomainRecordNumber": "Record {number}", + "createDomainTxtRecords": "TXT Records", + "createDomainSaveTheseRecords": "Save These Records", + "createDomainSaveTheseRecordsDescription": "Make sure to save these DNS records as you will not see them again.", + "createDomainDnsPropagation": "DNS Propagation", + "createDomainDnsPropagationDescription": "DNS changes may take some time to propagate across the internet. This can take anywhere from a few minutes to 48 hours, depending on your DNS provider and TTL settings.", + "resourcePortRequired": "Port number is required for non-HTTP resources", + "resourcePortNotAllowed": "Port number should not be set for HTTP resources", + "signUpTerms": { + "IAgreeToThe": "I agree to the", + "termsOfService": "terms of service", + "and": "and", + "privacyPolicy": "privacy policy" + }, + "siteRequired": "Site is required.", + "olmTunnel": "Olm Tunnel", + "olmTunnelDescription": "Use Olm for client connectivity", + "errorCreatingClient": "Error creating client", + "clientDefaultsNotFound": "Client defaults not found", + "createClient": "Create Client", + "createClientDescription": "Create a new client for connecting to your sites", + "seeAllClients": "See All Clients", + "clientInformation": "Client Information", + "clientNamePlaceholder": "Client name", + "address": "Address", + "subnetPlaceholder": "Subnet", + "addressDescription": "The address that this client will use for connectivity", + "selectSites": "Select sites", + "sitesDescription": "The client will have connectivity to the selected sites", + "clientInstallOlm": "Install Olm", + "clientInstallOlmDescription": "Get Olm running on your system", + "clientOlmCredentials": "Olm Credentials", + "clientOlmCredentialsDescription": "This is how Olm will authenticate with the server", + "olmEndpoint": "Olm Endpoint", + "olmId": "Olm ID", + "olmSecretKey": "Olm Secret Key", + "clientCredentialsSave": "Save Your Credentials", + "clientCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.", + "generalSettingsDescription": "Configure the general settings for this client", + "clientUpdated": "Client updated", + "clientUpdatedDescription": "The client has been updated.", + "clientUpdateFailed": "Failed to update client", + "clientUpdateError": "An error occurred while updating the client.", + "sitesFetchFailed": "Failed to fetch sites", + "sitesFetchError": "An error occurred while fetching sites.", + "olmErrorFetchReleases": "An error occurred while fetching Olm releases.", + "olmErrorFetchLatest": "An error occurred while fetching the latest Olm release.", + "remoteSubnets": "Remote Subnets", + "enterCidrRange": "Enter CIDR range", + "remoteSubnetsDescription": "Add CIDR ranges that can access this site remotely. Use format like 10.0.0.0/24 or 192.168.1.0/24.", + "resourceEnableProxy": "Enable Public Proxy", + "resourceEnableProxyDescription": "Enable public proxying to this resource. This allows access to the resource from outside the network through the cloud on an open port. Requires Traefik config.", + "externalProxyEnabled": "External Proxy Enabled" +} diff --git a/messages/de-DE.json b/messages/de-DE.json index 09276f72..50aac219 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1,5 +1,5 @@ { - "setupCreate": "Erstelle eine Organisation, Site und Ressourcen", + "setupCreate": "Erstelle eine Organisation, einen Standort und Ressourcen", "setupNewOrg": "Neue Organisation", "setupCreateOrg": "Organisation erstellen", "setupCreateResources": "Ressource erstellen", @@ -11,11 +11,12 @@ "componentsErrorNoMemberCreate": "Du bist derzeit kein Mitglied einer Organisation. Erstelle eine Organisation, um zu starten.", "componentsErrorNoMember": "Du bist aktuell kein Mitglied einer Organisation.", "welcome": "Willkommen zu Pangolin", + "welcomeTo": "Willkommen bei", "componentsCreateOrg": "Erstelle eine Organisation", - "componentsMember": "Du bist Mitglied von {count, plural, =0 {keiner Organisation} =1 {einer Organisation} other {# Organisationen}}.", + "componentsMember": "Du bist Mitglied von {count, plural, =0 {keiner Organisation} one {einer Organisation} other {# Organisationen}}.", "componentsInvalidKey": "Ungültige oder abgelaufene Lizenzschlüssel erkannt. Beachte die Lizenzbedingungen, um alle Funktionen weiterhin zu nutzen.", "dismiss": "Verwerfen", - "componentsLicenseViolation": "Lizenzverstoß: Dieser Server benutzt {usedSites} Sites, die das Lizenzlimit der {maxSites} Sites überschreiten. Beachte die Lizenzbedingungen, um alle Funktionen weiterhin zu nutzen.", + "componentsLicenseViolation": "Lizenzverstoß: Dieser Server benutzt {usedSites} Standorte, was das Lizenzlimit von {maxSites} Standorten überschreitet. Beachte die Lizenzbedingungen, um alle Funktionen weiterhin zu nutzen.", "componentsSupporterMessage": "Vielen Dank für die Unterstützung von Pangolin als {tier}!", "inviteErrorNotValid": "Es tut uns leid, aber es sieht so aus, als wäre die Einladung, auf die du zugreifen möchtest, entweder nicht angenommen worden oder nicht mehr gültig.", "inviteErrorUser": "Es tut uns leid, aber es scheint, als sei die Einladung, auf die du zugreifen möchtest, nicht für diesen Benutzer bestimmt.", @@ -37,28 +38,27 @@ "name": "Name", "online": "Online", "offline": "Offline", - "site": "Seite", + "site": "Standort", "dataIn": "Daten eingehend", "dataOut": "Daten ausgehend", "connectionType": "Verbindungstyp", "tunnelType": "Tunneltyp", "local": "Lokal", "edit": "Bearbeiten", - "siteConfirmDelete": "Site löschen bestätigen", - "siteDelete": "Site löschen", - "siteMessageRemove": "Sobald diese Seite entfernt ist, wird sie nicht mehr zugänglich sein. Alle Ressourcen und Ziele, die mit der Site verbunden sind, werden ebenfalls entfernt.", - "siteMessageConfirm": "Um zu bestätigen, gib den Namen der Site ein.", - "siteQuestionRemove": "Bist du sicher, dass Sie die Site {selectedSite} aus der Organisation entfernt werden soll?", - "siteManageSites": "Sites verwalten", + "siteConfirmDelete": "Standort löschen bestätigen", + "siteDelete": "Standort löschen", + "siteMessageRemove": "Sobald dieser Standort entfernt ist, wird er nicht mehr zugänglich sein. Alle Ressourcen und Ziele, die mit diesem Standort verbunden sind, werden ebenfalls entfernt.", + "siteMessageConfirm": "Um zu bestätigen, gib den Namen des Standortes unten ein.", + "siteQuestionRemove": "Bist du sicher, dass der Standort {selectedSite} aus der Organisation entfernt werden soll?", + "siteManageSites": "Standorte verwalten", "siteDescription": "Verbindung zum Netzwerk durch sichere Tunnel erlauben", - "siteCreate": "Site erstellen", - "siteCreateDescription2": "Folge den nachfolgenden Schritten, um eine neue Site zu erstellen und zu verbinden", - "siteCreateDescription": "Erstelle eine neue Site, um Ressourcen zu verbinden", + "siteCreate": "Standort erstellen", + "siteCreateDescription2": "Folge den nachfolgenden Schritten, um einen neuen Standort zu erstellen und zu verbinden", + "siteCreateDescription": "Erstelle einen neuen Standort, um Ressourcen zu verbinden", "close": "Schließen", - "siteErrorCreate": "Fehler beim Erstellen der Site", + "siteErrorCreate": "Fehler beim Erstellen des Standortes", "siteErrorCreateKeyPair": "Schlüsselpaar oder Standardwerte nicht gefunden", "siteErrorCreateDefaults": "Standardwerte der Site nicht gefunden", - "siteNameDescription": "Dies ist der Anzeigename für die Site.", "method": "Methode", "siteMethodDescription": "So werden Verbindungen freigegeben.", "siteLearnNewt": "Wie du Newt auf deinem System installieren kannst", @@ -70,8 +70,8 @@ "dockerRun": "Docker Run", "siteLearnLocal": "Mehr Infos zu lokalen Sites", "siteConfirmCopy": "Ich habe die Konfiguration kopiert", - "searchSitesProgress": "Sites durchsuchen...", - "siteAdd": "Site hinzufügen", + "searchSitesProgress": "Standorte durchsuchen...", + "siteAdd": "Standort hinzufügen", "siteInstallNewt": "Newt installieren", "siteInstallNewtDescription": "Installiere Newt auf deinem System.", "WgConfiguration": "WireGuard Konfiguration", @@ -82,26 +82,26 @@ "siteNewtDescription": "Nutze Newt für die beste Benutzererfahrung. Newt verwendet WireGuard as Basis und erlaubt Ihnen, Ihre privaten Ressourcen über ihre LAN-Adresse in Ihrem privaten Netzwerk aus dem Pangolin-Dashboard heraus zu adressieren.", "siteRunsInDocker": "Läuft in Docker", "siteRunsInShell": "Läuft in der Konsole auf macOS, Linux und Windows", - "siteErrorDelete": "Fehler beim Löschen der Site", - "siteErrorUpdate": "Fehler beim Aktualisieren der Site", - "siteErrorUpdateDescription": "Beim Aktualisieren der Site ist ein Fehler aufgetreten.", - "siteUpdated": "Site aktualisiert", - "siteUpdatedDescription": "Die Site wurde aktualisiert.", - "siteGeneralDescription": "Allgemeine Einstellungen für diese Site konfigurieren", - "siteSettingDescription": "Konfigurieren der Site Einstellungen", + "siteErrorDelete": "Fehler beim Löschen des Standortes", + "siteErrorUpdate": "Fehler beim Aktualisieren des Standortes", + "siteErrorUpdateDescription": "Beim Aktualisieren des Standortes ist ein Fehler aufgetreten.", + "siteUpdated": "Standort aktualisiert", + "siteUpdatedDescription": "Der Standort wurde aktualisiert.", + "siteGeneralDescription": "Allgemeine Einstellungen für diesen Standort konfigurieren", + "siteSettingDescription": "Konfigurieren der Standort Einstellungen", "siteSetting": "{siteName} Einstellungen", "siteNewtTunnel": "Newt-Tunnel (empfohlen)", "siteNewtTunnelDescription": "Einfachster Weg, einen Zugriffspunkt zu deinem Netzwerk zu erstellen. Keine zusätzliche Einrichtung erforderlich.", "siteWg": "Einfacher WireGuard Tunnel", "siteWgDescription": "Verwende jeden WireGuard-Client, um einen Tunnel einzurichten. Manuelles NAT-Setup erforderlich.", "siteLocalDescription": "Nur lokale Ressourcen. Kein Tunneling.", - "siteSeeAll": "Alle Sites anzeigen", - "siteTunnelDescription": "Lege fest, wie du dich mit deiner Site verbinden möchtest", + "siteSeeAll": "Alle Standorte anzeigen", + "siteTunnelDescription": "Lege fest, wie du dich mit deinem Standort verbinden möchtest", "siteNewtCredentials": "Neue Newt Zugangsdaten", "siteNewtCredentialsDescription": "So wird sich Newt mit dem Server authentifizieren", "siteCredentialsSave": "Ihre Zugangsdaten speichern", "siteCredentialsSaveDescription": "Du kannst das nur einmal sehen. Stelle sicher, dass du es an einen sicheren Ort kopierst.", - "siteInfo": "Site-Informationen", + "siteInfo": "Standort-Informationen", "status": "Status", "shareTitle": "Links zum Teilen verwalten", "shareDescription": "Erstellen Sie teilbare Links, um temporären oder permanenten Zugriff auf Ihre Ressourcen zu gewähren", @@ -163,10 +163,10 @@ "resourceSeeAll": "Alle Ressourcen anzeigen", "resourceInfo": "Ressourcen-Informationen", "resourceNameDescription": "Dies ist der Anzeigename für die Ressource.", - "siteSelect": "Site auswählen", - "siteSearch": "Website durchsuchen", - "siteNotFound": "Keine Site gefunden.", - "siteSelectionDescription": "Diese Seite wird die Verbindung zu der Ressource herstellen.", + "siteSelect": "Standort auswählen", + "siteSearch": "Standorte durchsuchen", + "siteNotFound": "Keinen Standort gefunden.", + "siteSelectionDescription": "Dieser Standort wird die Verbindung zu der Ressource herstellen.", "resourceType": "Ressourcentyp", "resourceTypeDescription": "Legen Sie fest, wie Sie auf Ihre Ressource zugreifen möchten", "resourceHTTPSSettings": "HTTPS-Einstellungen", @@ -206,6 +206,7 @@ "orgGeneralSettings": "Organisations-Einstellungen", "orgGeneralSettingsDescription": "Organisationsdetails und Konfiguration verwalten", "saveGeneralSettings": "Allgemeine Einstellungen speichern", + "saveSettings": "Einstellungen speichern", "orgDangerZone": "Gefahrenzone", "orgDangerZoneDescription": "Sobald Sie diesen Org löschen, gibt es kein Zurück mehr. Bitte seien Sie vorsichtig.", "orgDelete": "Organisation löschen", @@ -249,7 +250,7 @@ "weeks": "Wochen", "months": "Monate", "years": "Jahre", - "day": "{count, plural, =1 {# Tag} other {# Tage}}", + "day": "{count, plural, one {# Tag} other {# Tage}}", "apiKeysTitle": "API-Schlüssel Information", "apiKeysConfirmCopy2": "Sie müssen bestätigen, dass Sie den API-Schlüssel kopiert haben.", "apiKeysErrorCreate": "Fehler beim Erstellen des API-Schlüssels", @@ -301,7 +302,7 @@ "userQuestionRemove": "Sind Sie sicher, dass Sie {selectedUser} dauerhaft vom Server löschen möchten?", "licenseKey": "Lizenzschlüssel", "valid": "Gültig", - "numberOfSites": "Anzahl der Sites", + "numberOfSites": "Anzahl der Standorte", "licenseKeySearch": "Lizenzschlüssel suchen...", "licenseKeyAdd": "Lizenzschlüssel hinzufügen", "type": "Typ", @@ -341,16 +342,16 @@ "licensedNot": "Nicht lizenziert", "hostId": "Host-ID", "licenseReckeckAll": "Überprüfe alle Schlüssel", - "licenseSiteUsage": "Website-Nutzung", - "licenseSiteUsageDecsription": "Sehen Sie sich die Anzahl der Sites an, die diese Lizenz verwenden.", - "licenseNoSiteLimit": "Die Anzahl der Sites, die einen nicht lizenzierten Host verwenden, ist unbegrenzt.", + "licenseSiteUsage": "Standort-Nutzung", + "licenseSiteUsageDecsription": "Sehen Sie sich die Anzahl der Standorte an, die diese Lizenz verwenden.", + "licenseNoSiteLimit": "Die Anzahl der Standorte, die einen nicht lizenzierten Host verwenden, ist unbegrenzt.", "licensePurchase": "Lizenz kaufen", - "licensePurchaseSites": "Zusätzliche Seiten kaufen", - "licenseSitesUsedMax": "{usedSites} der {maxSites} Seiten verwendet", - "licenseSitesUsed": "{count, plural, =0 {# Seiten} =1 {# Seite} other {# Seiten}} im System.", + "licensePurchaseSites": "Zusätzliche Standorte kaufen\n", + "licenseSitesUsedMax": "{usedSites} von {maxSites} Standorten verwendet", + "licenseSitesUsed": "{count, plural, =0 {# Standorte} one {# Standort} other {# Standorte}} im System.", "licensePurchaseDescription": "Wähle aus, für wieviele Seiten du möchtest {selectedMode, select, license {kaufe eine Lizenz. Du kannst später immer weitere Seiten hinzufügen.} other {Füge zu deiner bestehenden Lizenz hinzu.}}", "licenseFee": "Lizenzgebühr", - "licensePriceSite": "Preis pro Seite", + "licensePriceSite": "Preis pro Standort", "total": "Gesamt", "licenseContinuePayment": "Weiter zur Zahlung", "pricingPage": "Preisseite", @@ -436,7 +437,7 @@ "accessRoleSelect": "Rolle auswählen", "inviteEmailSentDescription": "Eine E-Mail mit dem Zugangslink wurde an den Benutzer gesendet. Er muss den Link aufrufen, um die Einladung anzunehmen.", "inviteSentDescription": "Der Benutzer wurde eingeladen. Er muss den unten stehenden Link aufrufen, um die Einladung anzunehmen.", - "inviteExpiresIn": "Die Einladung läuft in {days, plural, =1 {einem Tag} other {# Tagen}} ab.", + "inviteExpiresIn": "Die Einladung läuft in {days, plural, one {einem Tag} other {# Tagen}} ab.", "idpTitle": "Allgemeine Informationen", "idpSelect": "Wählen Sie den Identitätsanbieter für den externen Benutzer", "idpNotConfigured": "Es sind keine Identitätsanbieter konfiguriert. Bitte konfigurieren Sie einen Identitätsanbieter, bevor Sie externe Benutzer erstellen.", @@ -466,7 +467,7 @@ "targetErrorDuplicate": "Doppeltes Ziel", "targetErrorDuplicateDescription": "Ein Ziel mit diesen Einstellungen existiert bereits", "targetWireGuardErrorInvalidIp": "Ungültige Ziel-IP", - "targetWireGuardErrorInvalidIpDescription": "Die Ziel-IP muss innerhalb des Site-Subnets liegen", + "targetWireGuardErrorInvalidIpDescription": "Die Ziel-IP muss innerhalb des Standort-Subnets liegen", "targetsUpdated": "Ziele aktualisiert", "targetsUpdatedDescription": "Ziele und Einstellungen erfolgreich aktualisiert", "targetsErrorUpdate": "Fehler beim Aktualisieren der Ziele", @@ -557,8 +558,8 @@ "resourceErrorCreateDescription": "Beim Erstellen der Ressource ist ein Fehler aufgetreten", "resourceErrorCreateMessage": "Fehler beim Erstellen der Ressource:", "resourceErrorCreateMessageDescription": "Ein unerwarteter Fehler ist aufgetreten", - "sitesErrorFetch": "Fehler beim Abrufen der Sites", - "sitesErrorFetchDescription": "Beim Abrufen der Sites ist ein Fehler aufgetreten", + "sitesErrorFetch": "Fehler beim Abrufen der Standorte", + "sitesErrorFetchDescription": "Beim Abrufen der Standorte ist ein Fehler aufgetreten", "domainsErrorFetch": "Fehler beim Abrufen der Domains", "domainsErrorFetchDescription": "Beim Abrufen der Domains ist ein Fehler aufgetreten", "none": "Keine", @@ -676,10 +677,10 @@ "resourceGeneralDescription": "Konfigurieren Sie die allgemeinen Einstellungen für diese Ressource", "resourceEnable": "Ressource aktivieren", "resourceTransfer": "Ressource übertragen", - "resourceTransferDescription": "Diese Ressource auf eine andere Site übertragen", + "resourceTransferDescription": "Diese Ressource auf einen anderen Standort übertragen", "resourceTransferSubmit": "Ressource übertragen", - "siteDestination": "Zielsite", - "searchSites": "Sites durchsuchen", + "siteDestination": "Zielort", + "searchSites": "Standorte durchsuchen", "accessRoleCreate": "Rolle erstellen", "accessRoleCreateDescription": "Erstellen Sie eine neue Rolle, um Benutzer zu gruppieren und ihre Berechtigungen zu verwalten.", "accessRoleCreateSubmit": "Rolle erstellen", @@ -699,7 +700,7 @@ "accessRoleRemovedDescription": "Die Rolle wurde erfolgreich entfernt.", "accessRoleRequiredRemove": "Bevor Sie diese Rolle löschen, wählen Sie bitte eine neue Rolle aus, zu der die bestehenden Mitglieder übertragen werden sollen.", "manage": "Verwalten", - "sitesNotFound": "Keine Sites gefunden.", + "sitesNotFound": "Keine Standorte gefunden.", "pangolinServerAdmin": "Server-Admin - Pangolin", "licenseTierProfessional": "Professional Lizenz", "licenseTierEnterprise": "Enterprise Lizenz", @@ -707,10 +708,10 @@ "licensed": "Lizenziert", "yes": "Ja", "no": "Nein", - "sitesAdditional": "Zusätzliche Sites", + "sitesAdditional": "Zusätzliche Standorte", "licenseKeys": "Lizenzschlüssel", - "sitestCountDecrease": "Anzahl der Sites verringern", - "sitestCountIncrease": "Anzahl der Sites erhöhen", + "sitestCountDecrease": "Anzahl der Standorte verringern", + "sitestCountIncrease": "Anzahl der Standorte erhöhen", "idpManage": "Identitätsanbieter verwalten", "idpManageDescription": "Identitätsanbieter im System anzeigen und verwalten", "idpDeletedDescription": "Identitätsanbieter erfolgreich gelöscht", @@ -958,14 +959,16 @@ "licenseTierProfessionalRequiredDescription": "Diese Funktion ist nur in der Professional Edition verfügbar.", "actionGetOrg": "Organisation abrufen", "actionUpdateOrg": "Organisation aktualisieren", + "actionUpdateUser": "Benutzer aktualisieren", + "actionGetUser": "Benutzer abrufen", "actionGetOrgUser": "Organisationsbenutzer abrufen", "actionListOrgDomains": "Organisationsdomänen auflisten", - "actionCreateSite": "Site erstellen", - "actionDeleteSite": "Site löschen", - "actionGetSite": "Site abrufen", - "actionListSites": "Sites auflisten", - "actionUpdateSite": "Site aktualisieren", - "actionListSiteRoles": "Erlaubte Site-Rollen auflisten", + "actionCreateSite": "Standort erstellen", + "actionDeleteSite": "Standort löschen", + "actionGetSite": "Standort abrufen", + "actionListSites": "Standorte auflisten", + "actionUpdateSite": "Standorte aktualisieren", + "actionListSiteRoles": "Erlaubte Standort-Rollen auflisten", "actionCreateResource": "Ressource erstellen", "actionDeleteResource": "Ressource löschen", "actionGetResource": "Ressource abrufen", @@ -1019,6 +1022,11 @@ "actionDeleteIdpOrg": "IDP-Organisationsrichtlinie löschen", "actionListIdpOrgs": "IDP-Organisationen auflisten", "actionUpdateIdpOrg": "IDP-Organisation aktualisieren", + "actionCreateClient": "Kunde erstellen", + "actionDeleteClient": "Kunde löschen", + "actionUpdateClient": "Kunde aktualisieren", + "actionListClients": "Kunden auflisten", + "actionGetClient": "Kunde holen", "noneSelected": "Keine ausgewählt", "orgNotFound2": "Keine Organisationen gefunden.", "searchProgress": "Suche...", @@ -1070,7 +1078,7 @@ "language": "Sprache", "verificationCodeRequired": "Code ist erforderlich", "userErrorNoUpdate": "Kein Benutzer zum Aktualisieren", - "siteErrorNoUpdate": "Keine Site zum Aktualisieren", + "siteErrorNoUpdate": "Keine Standorte zum Aktualisieren", "resourceErrorNoUpdate": "Keine Ressource zum Aktualisieren", "authErrorNoUpdate": "Keine Auth-Informationen zum Aktualisieren", "orgErrorNoUpdate": "Keine Organisation zum Aktualisieren", @@ -1078,7 +1086,7 @@ "apiKeysErrorNoUpdate": "Kein API-Schlüssel zum Aktualisieren", "sidebarOverview": "Übersicht", "sidebarHome": "Zuhause", - "sidebarSites": "Seiten", + "sidebarSites": "Standorte", "sidebarResources": "Ressourcen", "sidebarAccessControl": "Zugriffskontrolle", "sidebarUsers": "Benutzer", @@ -1090,6 +1098,8 @@ "sidebarAllUsers": "Alle Benutzer", "sidebarIdentityProviders": "Identitätsanbieter", "sidebarLicense": "Lizenz", + "sidebarClients": "Clients (Beta)", + "sidebarDomains": "Domains", "enableDockerSocket": "Docker Socket aktivieren", "enableDockerSocketDescription": "Docker Socket-Erkennung aktivieren, um Container-Informationen zu befüllen. Socket-Pfad muss Newt bereitgestellt werden.", "enableDockerSocketLink": "Mehr erfahren", @@ -1102,7 +1112,7 @@ "containerNetworks": "Netzwerke", "containerHostnameIp": "Hostname/IP", "containerLabels": "Etiketten", - "containerLabelsCount": "{count} Label{s,plural,one{} other{s}}", + "containerLabelsCount": "{count, plural, one {# Etikett} other {# Etiketten}}", "containerLabelsTitle": "Container-Labels", "containerLabelEmpty": "", "containerPorts": "Häfen", @@ -1114,7 +1124,7 @@ "showStoppedContainers": "Stoppte Container anzeigen", "noContainersFound": "Keine Container gefunden. Stellen Sie sicher, dass Docker Container laufen.", "searchContainersPlaceholder": "Durchsuche {count} Container...", - "searchResultsCount": "{count} Ergebnis{s,plural,one{} other{s}}", + "searchResultsCount": "{count, plural, one {# Ergebnis} other {# Ergebnisse}}", "filters": "Filter", "filterOptions": "Filteroptionen", "filterPorts": "Häfen", @@ -1129,8 +1139,189 @@ "dark": "dunkel", "system": "System", "theme": "Design", + "subnetRequired": "Subnetz ist erforderlich", "initialSetupTitle": "Initial Einrichtung des Servers", "initialSetupDescription": "Erstellen Sie das initiale Server-Admin-Konto. Es kann nur einen Server-Admin geben. Sie können diese Anmeldedaten später immer ändern.", "createAdminAccount": "Admin-Konto erstellen", - "setupErrorCreateAdmin": "Beim Erstellen des Server-Admin-Kontos ist ein Fehler aufgetreten." + "setupErrorCreateAdmin": "Beim Erstellen des Server-Admin-Kontos ist ein Fehler aufgetreten.", + "certificateStatus": "Zertifikatsstatus", + "loading": "Laden", + "restart": "Neustart", + "domains": "Domains", + "domainsDescription": "Domains für Ihre Organisation verwalten", + "domainsSearch": "Domains durchsuchen...", + "domainAdd": "Domain hinzufügen", + "domainAddDescription": "Eine neue Domain in Ihrer Organisation registrieren", + "domainCreate": "Domain erstellen", + "domainCreatedDescription": "Domain erfolgreich erstellt", + "domainDeletedDescription": "Domain erfolgreich gelöscht", + "domainQuestionRemove": "Möchten Sie die Domain {domain} wirklich aus Ihrem Konto entfernen?", + "domainMessageRemove": "Nach dem Entfernen wird die Domain nicht mehr mit Ihrem Konto verknüpft.", + "domainMessageConfirm": "Um zu bestätigen, geben Sie bitte den Domainnamen unten ein.", + "domainConfirmDelete": "Domain-Löschung bestätigen", + "domainDelete": "Domain löschen", + "domain": "Domain", + "selectDomainTypeNsName": "Domain-Delegation (NS)", + "selectDomainTypeNsDescription": "Diese Domain und alle ihre Subdomains. Verwenden Sie dies, wenn Sie eine gesamte Domainzone kontrollieren möchten.", + "selectDomainTypeCnameName": "Einzelne Domain (CNAME)", + "selectDomainTypeCnameDescription": "Nur diese spezifische Domain. Verwenden Sie dies für einzelne Subdomains oder spezifische Domaineinträge.", + "selectDomainTypeWildcardName": "Wildcard-Domain", + "selectDomainTypeWildcardDescription": "Diese Domain und ihre Subdomains.", + "domainDelegation": "Einzelne Domain", + "selectType": "Typ auswählen", + "actions": "Aktionen", + "refresh": "Aktualisieren", + "refreshError": "Datenaktualisierung fehlgeschlagen", + "verified": "Verifiziert", + "pending": "Ausstehend", + "sidebarBilling": "Abrechnung", + "billing": "Abrechnung", + "orgBillingDescription": "Verwalten Sie Ihre Rechnungsinformationen und Abonnements", + "github": "GitHub", + "pangolinHosted": "Pangolin Hosted", + "fossorial": "Fossorial", + "completeAccountSetup": "Kontoeinrichtung abschließen", + "completeAccountSetupDescription": "Legen Sie Ihr Passwort fest, um zu beginnen", + "accountSetupSent": "Wir senden einen Code für die Kontoeinrichtung an diese E-Mail-Adresse.", + "accountSetupCode": "Einrichtungscode", + "accountSetupCodeDescription": "Prüfen Sie Ihre E-Mail auf den Einrichtungscode.", + "passwordCreate": "Passwort erstellen", + "passwordCreateConfirm": "Passwort bestätigen", + "accountSetupSubmit": "Einrichtungscode senden", + "completeSetup": "Einrichtung abschließen", + "accountSetupSuccess": "Kontoeinrichtung abgeschlossen! Willkommen bei Pangolin!", + "documentation": "Dokumentation", + "saveAllSettings": "Alle Einstellungen speichern", + "settingsUpdated": "Einstellungen aktualisiert", + "settingsUpdatedDescription": "Alle Einstellungen wurden erfolgreich aktualisiert", + "settingsErrorUpdate": "Einstellungen konnten nicht aktualisiert werden", + "settingsErrorUpdateDescription": "Beim Aktualisieren der Einstellungen ist ein Fehler aufgetreten", + "sidebarCollapse": "Zusammenklappen", + "sidebarExpand": "Erweitern", + "newtUpdateAvailable": "Update verfügbar", + "newtUpdateAvailableInfo": "Eine neue Version von Newt ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.", + "domainPickerEnterDomain": "Domain", + "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, oder einfach myapp", + "domainPickerDescription": "Geben Sie die vollständige Domäne der Ressource ein, um verfügbare Optionen zu sehen.", + "domainPickerDescriptionSaas": "Geben Sie eine vollständige Domäne, Subdomäne oder einfach einen Namen ein, um verfügbare Optionen zu sehen", + "domainPickerTabAll": "Alle", + "domainPickerTabOrganization": "Organisation", + "domainPickerTabProvided": "Bereitgestellt", + "domainPickerSortAsc": "A-Z", + "domainPickerSortDesc": "Z-A", + "domainPickerCheckingAvailability": "Verfügbarkeit prüfen...", + "domainPickerNoMatchingDomains": "Keine passenden Domains gefunden. Versuchen Sie es mit einer anderen Domain oder überprüfen Sie die Domain-Einstellungen Ihrer Organisation.", + "domainPickerOrganizationDomains": "Organisations-Domains", + "domainPickerProvidedDomains": "Bereitgestellte Domains", + "domainPickerSubdomain": "Subdomain: {subdomain}", + "domainPickerNamespace": "Namespace: {namespace}", + "domainPickerShowMore": "Mehr anzeigen", + "domainNotFound": "Domain nicht gefunden", + "domainNotFoundDescription": "Diese Ressource ist deaktiviert, weil die Domain nicht mehr in unserem System existiert. Bitte setzen Sie eine neue Domain für diese Ressource.", + "failed": "Fehlgeschlagen", + "createNewOrgDescription": "Eine neue Organisation erstellen", + "organization": "Organisation", + "port": "Port", + "securityKeyManage": "Sicherheitsschlüssel verwalten", + "securityKeyDescription": "Sicherheitsschlüssel für passwortlose Authentifizierung hinzufügen oder entfernen", + "securityKeyRegister": "Neuen Sicherheitsschlüssel registrieren", + "securityKeyList": "Ihre Sicherheitsschlüssel", + "securityKeyNone": "Noch keine Sicherheitsschlüssel registriert", + "securityKeyNameRequired": "Name ist erforderlich", + "securityKeyRemove": "Entfernen", + "securityKeyLastUsed": "Zuletzt verwendet: {date}", + "securityKeyNameLabel": "Name", + "securityKeyRegisterSuccess": "Sicherheitsschlüssel erfolgreich registriert", + "securityKeyRegisterError": "Fehler beim Registrieren des Sicherheitsschlüssels", + "securityKeyRemoveSuccess": "Sicherheitsschlüssel erfolgreich entfernt", + "securityKeyRemoveError": "Fehler beim Entfernen des Sicherheitsschlüssels", + "securityKeyLoadError": "Fehler beim Laden der Sicherheitsschlüssel", + "securityKeyLogin": "Mit dem Sicherheitsschlüssel fortfahren", + "securityKeyAuthError": "Fehler bei der Authentifizierung mit Sicherheitsschlüssel", + "securityKeyRecommendation": "Erwägen Sie die Registrierung eines weiteren Sicherheitsschlüssels auf einem anderen Gerät, um sicherzustellen, dass Sie sich nicht aus Ihrem Konto aussperren.", + "registering": "Registrierung...", + "securityKeyPrompt": "Bitte bestätigen Sie Ihre Identität mit Ihrem Sicherheitsschlüssel. Stellen Sie sicher, dass Ihr Sicherheitsschlüssel verbunden und einsatzbereit ist.", + "securityKeyBrowserNotSupported": "Ihr Browser unterstützt Sicherheitsschlüssel nicht. Bitte verwenden Sie einen modernen Browser wie Chrome, Firefox oder Safari.", + "securityKeyPermissionDenied": "Bitte erlauben Sie den Zugriff auf Ihren Sicherheitsschlüssel, um sich weiter anzumelden.", + "securityKeyRemovedTooQuickly": "Lassen Sie Ihren Sicherheitsschlüssel verbunden, bis der Anmeldeprozess abgeschlossen ist.", + "securityKeyNotSupported": "Ihr Sicherheitsschlüssel ist möglicherweise nicht kompatibel. Bitte versuchen Sie einen anderen Sicherheitsschlüssel.", + "securityKeyUnknownError": "Es gab ein Problem mit Ihrem Sicherheitsschlüssel. Bitte versuchen Sie es erneut.", + "twoFactorRequired": "Zur Registrierung eines Sicherheitsschlüssels ist eine Zwei-Faktor-Authentifizierung erforderlich.", + "twoFactor": "Zwei-Faktor-Authentifizierung", + "adminEnabled2FaOnYourAccount": "Ihr Administrator hat die Zwei-Faktor-Authentifizierung für {email} aktiviert. Bitte schließen Sie den Einrichtungsprozess ab, um fortzufahren.", + "continueToApplication": "Weiter zur Anwendung", + "securityKeyAdd": "Sicherheitsschlüssel hinzufügen", + "securityKeyRegisterTitle": "Neuen Sicherheitsschlüssel registrieren", + "securityKeyRegisterDescription": "Verbinden Sie Ihren Sicherheitsschlüssel und geben Sie einen Namen ein, um ihn zu identifizieren", + "securityKeyTwoFactorRequired": "Zwei-Faktor-Authentifizierung erforderlich", + "securityKeyTwoFactorDescription": "Bitte geben Sie Ihren Zwei-Faktor-Authentifizierungscode ein, um den Sicherheitsschlüssel zu registrieren", + "securityKeyTwoFactorRemoveDescription": "Bitte geben Sie Ihren Zwei-Faktor-Authentifizierungscode ein, um den Sicherheitsschlüssel zu entfernen", + "securityKeyTwoFactorCode": "Zwei-Faktor-Code", + "securityKeyRemoveTitle": "Sicherheitsschlüssel entfernen", + "securityKeyRemoveDescription": "Geben Sie Ihr Passwort ein, um den Sicherheitsschlüssel \"{name}\" zu entfernen", + "securityKeyNoKeysRegistered": "Keine Sicherheitsschlüssel registriert", + "securityKeyNoKeysDescription": "Fügen Sie einen Sicherheitsschlüssel hinzu, um die Sicherheit Ihres Kontos zu erhöhen", + "createDomainRequired": "Domain ist erforderlich", + "createDomainAddDnsRecords": "DNS-Einträge hinzufügen", + "createDomainAddDnsRecordsDescription": "Fügen Sie die folgenden DNS-Einträge zu Ihrem Domain-Provider hinzu, um die Einrichtung abzuschließen.", + "createDomainNsRecords": "NS-Einträge", + "createDomainRecord": "Eintrag", + "createDomainType": "Typ:", + "createDomainName": "Name:", + "createDomainValue": "Wert:", + "createDomainCnameRecords": "CNAME-Einträge", + "createDomainARecords": "A-Aufzeichnungen", + "createDomainRecordNumber": "Eintrag {number}", + "createDomainTxtRecords": "TXT-Einträge", + "createDomainSaveTheseRecords": "Diese Einträge speichern", + "createDomainSaveTheseRecordsDescription": "Achten Sie darauf, diese DNS-Einträge zu speichern, da Sie sie nicht erneut sehen werden.", + "createDomainDnsPropagation": "DNS-Verbreitung", + "createDomainDnsPropagationDescription": "Es kann einige Zeit dauern, bis DNS-Änderungen im Internet verbreitet werden. Dies kann je nach Ihrem DNS-Provider und den TTL-Einstellungen von einigen Minuten bis zu 48 Stunden dauern.", + "resourcePortRequired": "Portnummer ist für nicht-HTTP-Ressourcen erforderlich", + "resourcePortNotAllowed": "Portnummer sollte für HTTP-Ressourcen nicht gesetzt werden", + "signUpTerms": { + "IAgreeToThe": "Ich stimme den", + "termsOfService": "Nutzungsbedingungen zu", + "and": "und", + "privacyPolicy": "Datenschutzrichtlinie" + }, + "siteRequired": "Standort ist erforderlich.", + "olmTunnel": "Olm Tunnel", + "olmTunnelDescription": "Nutzen Sie Olm für die Kundenverbindung", + "errorCreatingClient": "Fehler beim Erstellen des Clients", + "clientDefaultsNotFound": "Kundenvorgaben nicht gefunden", + "createClient": "Client erstellen", + "createClientDescription": "Erstellen Sie einen neuen Client für die Verbindung zu Ihren Standorten.", + "seeAllClients": "Alle Clients anzeigen", + "clientInformation": "Kundeninformationen", + "clientNamePlaceholder": "Kundenname", + "address": "Adresse", + "subnetPlaceholder": "Subnetz", + "addressDescription": "Die Adresse, die dieser Client für die Verbindung verwenden wird.", + "selectSites": "Standorte auswählen", + "sitesDescription": "Der Client wird zu den ausgewählten Standorten eine Verbindung haben.", + "clientInstallOlm": "Olm installieren", + "clientInstallOlmDescription": "Olm auf Ihrem System zum Laufen bringen", + "clientOlmCredentials": "Olm-Zugangsdaten", + "clientOlmCredentialsDescription": "So authentifiziert sich Olm beim Server", + "olmEndpoint": "Olm-Endpunkt", + "olmId": "Olm-ID", + "olmSecretKey": "Olm-Geheimschlüssel", + "clientCredentialsSave": "Speichern Sie Ihre Zugangsdaten", + "clientCredentialsSaveDescription": "Sie können dies nur einmal sehen. Kopieren Sie es an einen sicheren Ort.", + "generalSettingsDescription": "Konfigurieren Sie die allgemeinen Einstellungen für diesen Client", + "clientUpdated": "Client aktualisiert", + "clientUpdatedDescription": "Der Client wurde aktualisiert.", + "clientUpdateFailed": "Fehler beim Aktualisieren des Clients", + "clientUpdateError": "Beim Aktualisieren des Clients ist ein Fehler aufgetreten.", + "sitesFetchFailed": "Fehler beim Abrufen von Standorten", + "sitesFetchError": "Beim Abrufen von Standorten ist ein Fehler aufgetreten.", + "olmErrorFetchReleases": "Beim Abrufen von Olm-Veröffentlichungen ist ein Fehler aufgetreten.", + "olmErrorFetchLatest": "Beim Abrufen der neuesten Olm-Veröffentlichung ist ein Fehler aufgetreten.", + "remoteSubnets": "Remote-Subnetze", + "enterCidrRange": "Geben Sie den CIDR-Bereich ein", + "remoteSubnetsDescription": "Fügen Sie CIDR-Bereiche hinzu, die aus der Ferne auf diesen Standort zugreifen können. Verwenden Sie das Format wie 10.0.0.0/24 oder 192.168.1.0/24.", + "resourceEnableProxy": "Öffentlichen Proxy aktivieren", + "resourceEnableProxyDescription": "Ermöglichen Sie öffentliches Proxieren zu dieser Ressource. Dies ermöglicht den Zugriff auf die Ressource von außerhalb des Netzwerks durch die Cloud über einen offenen Port. Erfordert Traefik-Config.", + "externalProxyEnabled": "Externer Proxy aktiviert" } diff --git a/messages/en-US.json b/messages/en-US.json index 4990774b..d1234d72 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -10,9 +10,10 @@ "setupErrorIdentifier": "Organization ID is already taken. Please choose a different one.", "componentsErrorNoMemberCreate": "You are not currently a member of any organizations. Create an organization to get started.", "componentsErrorNoMember": "You are not currently a member of any organizations.", - "welcome": "Welcome to Pangolin", + "welcome": "Welcome!", + "welcomeTo": "Welcome to", "componentsCreateOrg": "Create an Organization", - "componentsMember": "You're a member of {count, plural, =0 {no organization} =1 {one organization} other {# organizations}}.", + "componentsMember": "You're a member of {count, plural, =0 {no organization} one {one organization} other {# organizations}}.", "componentsInvalidKey": "Invalid or expired license keys detected. Follow license terms to continue using all features.", "dismiss": "Dismiss", "componentsLicenseViolation": "License Violation: This server is using {usedSites} sites which exceeds its licensed limit of {maxSites} sites. Follow license terms to continue using all features.", @@ -58,7 +59,6 @@ "siteErrorCreate": "Error creating site", "siteErrorCreateKeyPair": "Key pair or site defaults not found", "siteErrorCreateDefaults": "Site defaults not found", - "siteNameDescription": "This is the display name for the site.", "method": "Method", "siteMethodDescription": "This is how you will expose connections.", "siteLearnNewt": "Learn how to install Newt on your system", @@ -206,6 +206,7 @@ "orgGeneralSettings": "Organization Settings", "orgGeneralSettingsDescription": "Manage your organization details and configuration", "saveGeneralSettings": "Save General Settings", + "saveSettings": "Save Settings", "orgDangerZone": "Danger Zone", "orgDangerZoneDescription": "Once you delete this org, there is no going back. Please be certain.", "orgDelete": "Delete Organization", @@ -249,7 +250,7 @@ "weeks": "Weeks", "months": "Months", "years": "Years", - "day": "{count, plural, =1 {# day} other {# days}}", + "day": "{count, plural, one {# day} other {# days}}", "apiKeysTitle": "API Key Information", "apiKeysConfirmCopy2": "You must confirm that you have copied the API key.", "apiKeysErrorCreate": "Error creating API key", @@ -347,7 +348,7 @@ "licensePurchase": "Purchase License", "licensePurchaseSites": "Purchase Additional Sites", "licenseSitesUsedMax": "{usedSites} of {maxSites} sites used", - "licenseSitesUsed": "{count, plural, =0 {# sites} =1 {# site} other {# sites}} in system.", + "licenseSitesUsed": "{count, plural, =0 {# sites} one {# site} other {# sites}} in system.", "licensePurchaseDescription": "Choose how many sites you want to {selectedMode, select, license {purchase a license for. You can always add more sites later.} other {add to your existing license.}}", "licenseFee": "License fee", "licensePriceSite": "Price per site", @@ -436,7 +437,7 @@ "accessRoleSelect": "Select role", "inviteEmailSentDescription": "An email has been sent to the user with the access link below. They must access the link to accept the invitation.", "inviteSentDescription": "The user has been invited. They must access the link below to accept the invitation.", - "inviteExpiresIn": "The invite will expire in {days, plural, =1 {# day} other {# days}}.", + "inviteExpiresIn": "The invite will expire in {days, plural, one {# day} other {# days}}.", "idpTitle": "Identity Provider", "idpSelect": "Select the identity provider for the external user", "idpNotConfigured": "No identity providers are configured. Please configure an identity provider before creating external users.", @@ -958,6 +959,8 @@ "licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.", "actionGetOrg": "Get Organization", "actionUpdateOrg": "Update Organization", + "actionUpdateUser": "Update User", + "actionGetUser": "Get User", "actionGetOrgUser": "Get Organization User", "actionListOrgDomains": "List Organization Domains", "actionCreateSite": "Create Site", @@ -1019,6 +1022,11 @@ "actionDeleteIdpOrg": "Delete IDP Org Policy", "actionListIdpOrgs": "List IDP Orgs", "actionUpdateIdpOrg": "Update IDP Org", + "actionCreateClient": "Create Client", + "actionDeleteClient": "Delete Client", + "actionUpdateClient": "Update Client", + "actionListClients": "List Clients", + "actionGetClient": "Get Client", "noneSelected": "None selected", "orgNotFound2": "No organizations found.", "searchProgress": "Search...", @@ -1090,6 +1098,8 @@ "sidebarAllUsers": "All Users", "sidebarIdentityProviders": "Identity Providers", "sidebarLicense": "License", + "sidebarClients": "Clients (Beta)", + "sidebarDomains": "Domains", "enableDockerSocket": "Enable Docker Socket", "enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.", "enableDockerSocketLink": "Learn More", @@ -1102,7 +1112,7 @@ "containerNetworks": "Networks", "containerHostnameIp": "Hostname/IP", "containerLabels": "Labels", - "containerLabelsCount": "{count} label{s,plural,one{} other{s}}", + "containerLabelsCount": "{count, plural, one {# label} other {# labels}}", "containerLabelsTitle": "Container Labels", "containerLabelEmpty": "", "containerPorts": "Ports", @@ -1114,7 +1124,7 @@ "showStoppedContainers": "Show stopped containers", "noContainersFound": "No containers found. Make sure Docker containers are running.", "searchContainersPlaceholder": "Search across {count} containers...", - "searchResultsCount": "{count} result{s,plural,one{} other{s}}", + "searchResultsCount": "{count, plural, one {# result} other {# results}}", "filters": "Filters", "filterOptions": "Filter Options", "filterPorts": "Ports", @@ -1129,8 +1139,189 @@ "dark": "dark", "system": "system", "theme": "Theme", + "subnetRequired": "Subnet is required", "initialSetupTitle": "Initial Server Setup", "initialSetupDescription": "Create the intial server admin account. Only one server admin can exist. You can always change these credentials later.", "createAdminAccount": "Create Admin Account", - "setupErrorCreateAdmin": "An error occurred while creating the server admin account." + "setupErrorCreateAdmin": "An error occurred while creating the server admin account.", + "certificateStatus": "Certificate Status", + "loading": "Loading", + "restart": "Restart", + "domains": "Domains", + "domainsDescription": "Manage domains for your organization", + "domainsSearch": "Search domains...", + "domainAdd": "Add Domain", + "domainAddDescription": "Register a new domain with your organization", + "domainCreate": "Create Domain", + "domainCreatedDescription": "Domain created successfully", + "domainDeletedDescription": "Domain deleted successfully", + "domainQuestionRemove": "Are you sure you want to remove the domain {domain} from your account?", + "domainMessageRemove": "Once removed, the domain will no longer be associated with your account.", + "domainMessageConfirm": "To confirm, please type the domain name below.", + "domainConfirmDelete": "Confirm Delete Domain", + "domainDelete": "Delete Domain", + "domain": "Domain", + "selectDomainTypeNsName": "Domain Delegation (NS)", + "selectDomainTypeNsDescription": "This domain and all its subdomains. Use this when you want to control an entire domain zone.", + "selectDomainTypeCnameName": "Single Domain (CNAME)", + "selectDomainTypeCnameDescription": "Just this specific domain. Use this for individual subdomains or specific domain entries.", + "selectDomainTypeWildcardName": "Wildcard Domain", + "selectDomainTypeWildcardDescription": "This domain and its subdomains.", + "domainDelegation": "Single Domain", + "selectType": "Select a type", + "actions": "Actions", + "refresh": "Refresh", + "refreshError": "Failed to refresh data", + "verified": "Verified", + "pending": "Pending", + "sidebarBilling": "Billing", + "billing": "Billing", + "orgBillingDescription": "Manage your billing information and subscriptions", + "github": "GitHub", + "pangolinHosted": "Pangolin Hosted", + "fossorial": "Fossorial", + "completeAccountSetup": "Complete Account Setup", + "completeAccountSetupDescription": "Set your password to get started", + "accountSetupSent": "We'll send an account setup code to this email address.", + "accountSetupCode": "Setup Code", + "accountSetupCodeDescription": "Check your email for the setup code.", + "passwordCreate": "Create Password", + "passwordCreateConfirm": "Confirm Password", + "accountSetupSubmit": "Send Setup Code", + "completeSetup": "Complete Setup", + "accountSetupSuccess": "Account setup completed! Welcome to Pangolin!", + "documentation": "Documentation", + "saveAllSettings": "Save All Settings", + "settingsUpdated": "Settings updated", + "settingsUpdatedDescription": "All settings have been updated successfully", + "settingsErrorUpdate": "Failed to update settings", + "settingsErrorUpdateDescription": "An error occurred while updating settings", + "sidebarCollapse": "Collapse", + "sidebarExpand": "Expand", + "newtUpdateAvailable": "Update Available", + "newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", + "domainPickerEnterDomain": "Domain", + "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp", + "domainPickerDescription": "Enter the full domain of the resource to see available options.", + "domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options", + "domainPickerTabAll": "All", + "domainPickerTabOrganization": "Organization", + "domainPickerTabProvided": "Provided", + "domainPickerSortAsc": "A-Z", + "domainPickerSortDesc": "Z-A", + "domainPickerCheckingAvailability": "Checking availability...", + "domainPickerNoMatchingDomains": "No matching domains found. Try a different domain or check your organization's domain settings.", + "domainPickerOrganizationDomains": "Organization Domains", + "domainPickerProvidedDomains": "Provided Domains", + "domainPickerSubdomain": "Subdomain: {subdomain}", + "domainPickerNamespace": "Namespace: {namespace}", + "domainPickerShowMore": "Show More", + "domainNotFound": "Domain Not Found", + "domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.", + "failed": "Failed", + "createNewOrgDescription": "Create a new organization", + "organization": "Organization", + "port": "Port", + "securityKeyManage": "Manage Security Keys", + "securityKeyDescription": "Add or remove security keys for passwordless authentication", + "securityKeyRegister": "Register New Security Key", + "securityKeyList": "Your Security Keys", + "securityKeyNone": "No security keys registered yet", + "securityKeyNameRequired": "Name is required", + "securityKeyRemove": "Remove", + "securityKeyLastUsed": "Last used: {date}", + "securityKeyNameLabel": "Security Key Name", + "securityKeyRegisterSuccess": "Security key registered successfully", + "securityKeyRegisterError": "Failed to register security key", + "securityKeyRemoveSuccess": "Security key removed successfully", + "securityKeyRemoveError": "Failed to remove security key", + "securityKeyLoadError": "Failed to load security keys", + "securityKeyLogin": "Continue with security key", + "securityKeyAuthError": "Failed to authenticate with security key", + "securityKeyRecommendation": "Register a backup security key on another device to ensure you always have access to your account.", + "registering": "Registering...", + "securityKeyPrompt": "Please verify your identity using your security key. Make sure your security key is connected and ready.", + "securityKeyBrowserNotSupported": "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari.", + "securityKeyPermissionDenied": "Please allow access to your security key to continue signing in.", + "securityKeyRemovedTooQuickly": "Please keep your security key connected until the sign-in process completes.", + "securityKeyNotSupported": "Your security key may not be compatible. Please try a different security key.", + "securityKeyUnknownError": "There was a problem using your security key. Please try again.", + "twoFactorRequired": "Two-factor authentication is required to register a security key.", + "twoFactor": "Two-Factor Authentication", + "adminEnabled2FaOnYourAccount": "Your administrator has enabled two-factor authentication for {email}. Please complete the setup process to continue.", + "continueToApplication": "Continue to Application", + "securityKeyAdd": "Add Security Key", + "securityKeyRegisterTitle": "Register New Security Key", + "securityKeyRegisterDescription": "Connect your security key and enter a name to identify it", + "securityKeyTwoFactorRequired": "Two-Factor Authentication Required", + "securityKeyTwoFactorDescription": "Please enter your two-factor authentication code to register the security key", + "securityKeyTwoFactorRemoveDescription": "Please enter your two-factor authentication code to remove the security key", + "securityKeyTwoFactorCode": "Two-Factor Code", + "securityKeyRemoveTitle": "Remove Security Key", + "securityKeyRemoveDescription": "Enter your password to remove the security key \"{name}\"", + "securityKeyNoKeysRegistered": "No security keys registered", + "securityKeyNoKeysDescription": "Add a security key to enhance your account security", + "createDomainRequired": "Domain is required", + "createDomainAddDnsRecords": "Add DNS Records", + "createDomainAddDnsRecordsDescription": "Add the following DNS records to your domain provider to complete the setup.", + "createDomainNsRecords": "NS Records", + "createDomainRecord": "Record", + "createDomainType": "Type:", + "createDomainName": "Name:", + "createDomainValue": "Value:", + "createDomainCnameRecords": "CNAME Records", + "createDomainARecords": "A Records", + "createDomainRecordNumber": "Record {number}", + "createDomainTxtRecords": "TXT Records", + "createDomainSaveTheseRecords": "Save These Records", + "createDomainSaveTheseRecordsDescription": "Make sure to save these DNS records as you will not see them again.", + "createDomainDnsPropagation": "DNS Propagation", + "createDomainDnsPropagationDescription": "DNS changes may take some time to propagate across the internet. This can take anywhere from a few minutes to 48 hours, depending on your DNS provider and TTL settings.", + "resourcePortRequired": "Port number is required for non-HTTP resources", + "resourcePortNotAllowed": "Port number should not be set for HTTP resources", + "signUpTerms": { + "IAgreeToThe": "I agree to the", + "termsOfService": "terms of service", + "and": "and", + "privacyPolicy": "privacy policy" + }, + "siteRequired": "Site is required.", + "olmTunnel": "Olm Tunnel", + "olmTunnelDescription": "Use Olm for client connectivity", + "errorCreatingClient": "Error creating client", + "clientDefaultsNotFound": "Client defaults not found", + "createClient": "Create Client", + "createClientDescription": "Create a new client for connecting to your sites", + "seeAllClients": "See All Clients", + "clientInformation": "Client Information", + "clientNamePlaceholder": "Client name", + "address": "Address", + "subnetPlaceholder": "Subnet", + "addressDescription": "The address that this client will use for connectivity", + "selectSites": "Select sites", + "sitesDescription": "The client will have connectivity to the selected sites", + "clientInstallOlm": "Install Olm", + "clientInstallOlmDescription": "Get Olm running on your system", + "clientOlmCredentials": "Olm Credentials", + "clientOlmCredentialsDescription": "This is how Olm will authenticate with the server", + "olmEndpoint": "Olm Endpoint", + "olmId": "Olm ID", + "olmSecretKey": "Olm Secret Key", + "clientCredentialsSave": "Save Your Credentials", + "clientCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.", + "generalSettingsDescription": "Configure the general settings for this client", + "clientUpdated": "Client updated", + "clientUpdatedDescription": "The client has been updated.", + "clientUpdateFailed": "Failed to update client", + "clientUpdateError": "An error occurred while updating the client.", + "sitesFetchFailed": "Failed to fetch sites", + "sitesFetchError": "An error occurred while fetching sites.", + "olmErrorFetchReleases": "An error occurred while fetching Olm releases.", + "olmErrorFetchLatest": "An error occurred while fetching the latest Olm release.", + "remoteSubnets": "Remote Subnets", + "enterCidrRange": "Enter CIDR range", + "remoteSubnetsDescription": "Add CIDR ranges that can be accessed from this site remotely using clients. Use format like 10.0.0.0/24. This ONLY applies to VPN client connectivity.", + "resourceEnableProxy": "Enable Public Proxy", + "resourceEnableProxyDescription": "Enable public proxying to this resource. This allows access to the resource from outside the network through the cloud on an open port. Requires Traefik config.", + "externalProxyEnabled": "External Proxy Enabled" } diff --git a/messages/es-ES.json b/messages/es-ES.json index 60856fe8..5bd43502 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -11,8 +11,9 @@ "componentsErrorNoMemberCreate": "Actualmente no eres miembro de ninguna organización. Crea una organización para empezar.", "componentsErrorNoMember": "Actualmente no eres miembro de ninguna organización.", "welcome": "Bienvenido a Pangolin", + "welcomeTo": "Bienvenido a", "componentsCreateOrg": "Crear una organización", - "componentsMember": "¡Eres un miembro de {count, plural, =0 {¡Ninguna organización} =1 {¡una organización} other {# organizaciones}}.", + "componentsMember": "Eres un miembro de {count, plural, =0 {ninguna organización} one {una organización} other {# organizaciones}}.", "componentsInvalidKey": "Se han detectado claves de licencia inválidas o caducadas. Siga los términos de licencia para seguir usando todas las características.", "dismiss": "Descartar", "componentsLicenseViolation": "Violación de la Licencia: Este servidor está usando sitios {usedSites} que exceden su límite de licencias de sitios {maxSites} . Siga los términos de licencia para seguir usando todas las características.", @@ -58,7 +59,6 @@ "siteErrorCreate": "Error al crear el sitio", "siteErrorCreateKeyPair": "Por defecto no se encuentra el par de claves o el sitio", "siteErrorCreateDefaults": "Sitio por defecto no encontrado", - "siteNameDescription": "Este es el nombre para mostrar el sitio.", "method": "Método", "siteMethodDescription": "Así es como se expondrán las conexiones.", "siteLearnNewt": "Aprende cómo instalar Newt en tu sistema", @@ -206,6 +206,7 @@ "orgGeneralSettings": "Configuración de la organización", "orgGeneralSettingsDescription": "Administra los detalles y la configuración de tu organización", "saveGeneralSettings": "Guardar ajustes generales", + "saveSettings": "Guardar ajustes", "orgDangerZone": "Zona de peligro", "orgDangerZoneDescription": "Una vez que elimines este órgano, no hay vuelta atrás. Por favor, asegúrate de ello.", "orgDelete": "Eliminar organización", @@ -249,7 +250,7 @@ "weeks": "Semanas", "months": "Meses", "years": "Años", - "day": "{count, plural, =1 {# día} other {# días}}", + "day": "{count, plural, one {# día} other {# días}}", "apiKeysTitle": "Información de Clave API", "apiKeysConfirmCopy2": "Debes confirmar que has copiado la clave API.", "apiKeysErrorCreate": "Error al crear la clave API", @@ -347,7 +348,7 @@ "licensePurchase": "Comprar Licencia", "licensePurchaseSites": "Comprar sitios adicionales", "licenseSitesUsedMax": "{usedSites} de {maxSites} sitios usados", - "licenseSitesUsed": "{count, plural, =0 {# sitios} =1 {# sitio} other {# sitios}} en el sistema.", + "licenseSitesUsed": "{count, plural, =0 {# sitios} one {# sitio} other {# sitios}} en el sistema.", "licensePurchaseDescription": "Elige cuántos sitios quieres {selectedMode, select, license {compra una licencia para. Siempre puedes añadir más sitios más tarde.} other {añadir a tu licencia existente.}}", "licenseFee": "Tarifa de licencia", "licensePriceSite": "Precio por sitio", @@ -436,7 +437,7 @@ "accessRoleSelect": "Seleccionar rol", "inviteEmailSentDescription": "Se ha enviado un correo electrónico al usuario con el siguiente enlace de acceso. Debe acceder al enlace para aceptar la invitación.", "inviteSentDescription": "El usuario ha sido invitado. Debe acceder al enlace de abajo para aceptar la invitación.", - "inviteExpiresIn": "La invitación expirará en {days, plural, =1 {# día} other {# días}}.", + "inviteExpiresIn": "La invitación expirará en {days, plural, one {# día} other {# días}}.", "idpTitle": "Proveedor de identidad", "idpSelect": "Seleccione el proveedor de identidad para el usuario externo", "idpNotConfigured": "No hay proveedores de identidad configurados. Por favor, configure un proveedor de identidad antes de crear usuarios externos.", @@ -958,6 +959,8 @@ "licenseTierProfessionalRequiredDescription": "Esta característica sólo está disponible en la Edición Profesional.", "actionGetOrg": "Obtener organización", "actionUpdateOrg": "Actualizar organización", + "actionUpdateUser": "Actualizar usuario", + "actionGetUser": "Obtener usuario", "actionGetOrgUser": "Obtener usuario de la organización", "actionListOrgDomains": "Listar dominios de la organización", "actionCreateSite": "Crear sitio", @@ -1019,6 +1022,11 @@ "actionDeleteIdpOrg": "Eliminar política de IDP Org", "actionListIdpOrgs": "Listar Orgs IDP", "actionUpdateIdpOrg": "Actualizar IDP Org", + "actionCreateClient": "Crear cliente", + "actionDeleteClient": "Eliminar cliente", + "actionUpdateClient": "Actualizar cliente", + "actionListClients": "Listar clientes", + "actionGetClient": "Obtener cliente", "noneSelected": "Ninguno seleccionado", "orgNotFound2": "No se encontraron organizaciones.", "searchProgress": "Buscar...", @@ -1090,6 +1098,8 @@ "sidebarAllUsers": "Todos los usuarios", "sidebarIdentityProviders": "Proveedores de identidad", "sidebarLicense": "Licencia", + "sidebarClients": "Clientes (Beta)", + "sidebarDomains": "Dominios", "enableDockerSocket": "Habilitar conector Docker", "enableDockerSocketDescription": "Habilitar el descubrimiento de Docker Socket para completar la información del contenedor. La ruta del socket debe proporcionarse a Newt.", "enableDockerSocketLink": "Saber más", @@ -1102,7 +1112,7 @@ "containerNetworks": "Redes", "containerHostnameIp": "Nombre del host/IP", "containerLabels": "Etiquetas", - "containerLabelsCount": "{count} etiqueta{s,plural,one{} other{s}}", + "containerLabelsCount": "{count, plural, one {# etiqueta} other {# etiquetas}}", "containerLabelsTitle": "Etiquetas de contenedor", "containerLabelEmpty": "", "containerPorts": "Puertos", @@ -1114,7 +1124,7 @@ "showStoppedContainers": "Mostrar contenedores parados", "noContainersFound": "No se han encontrado contenedores. Asegúrate de que los contenedores Docker se estén ejecutando.", "searchContainersPlaceholder": "Buscar a través de contenedores {count}...", - "searchResultsCount": "{count} resultado{s,plural,one{} other{s}}", + "searchResultsCount": "{count, plural, one {# resultado} other {# resultados}}", "filters": "Filtros", "filterOptions": "Opciones de filtro", "filterPorts": "Puertos", @@ -1129,8 +1139,189 @@ "dark": "oscuro", "system": "sistema", "theme": "Tema", + "subnetRequired": "Se requiere subred", "initialSetupTitle": "Configuración inicial del servidor", "initialSetupDescription": "Cree la cuenta de administrador del servidor inicial. Solo puede existir un administrador del servidor. Siempre puede cambiar estas credenciales más tarde.", "createAdminAccount": "Crear cuenta de administrador", - "setupErrorCreateAdmin": "Se produjo un error al crear la cuenta de administrador del servidor." + "setupErrorCreateAdmin": "Se produjo un error al crear la cuenta de administrador del servidor.", + "certificateStatus": "Estado del certificado", + "loading": "Cargando", + "restart": "Reiniciar", + "domains": "Dominios", + "domainsDescription": "Administrar dominios de tu organización", + "domainsSearch": "Buscar dominios...", + "domainAdd": "Agregar dominio", + "domainAddDescription": "Registrar un nuevo dominio con tu organización", + "domainCreate": "Crear dominio", + "domainCreatedDescription": "Dominio creado con éxito", + "domainDeletedDescription": "Dominio eliminado exitosamente", + "domainQuestionRemove": "¿Está seguro de que desea eliminar el dominio {domain} de su cuenta?", + "domainMessageRemove": "Una vez eliminado, el dominio ya no estará asociado con su cuenta.", + "domainMessageConfirm": "Para confirmar, por favor escriba el nombre del dominio abajo.", + "domainConfirmDelete": "Confirmar eliminación del dominio", + "domainDelete": "Eliminar dominio", + "domain": "Dominio", + "selectDomainTypeNsName": "Delegación de dominio (NS)", + "selectDomainTypeNsDescription": "Este dominio y todos sus subdominios. Usa esto cuando quieras controlar una zona de dominio completa.", + "selectDomainTypeCnameName": "Dominio único (CNAME)", + "selectDomainTypeCnameDescription": "Solo este dominio específico. Úsalo para subdominios individuales o entradas específicas de dominio.", + "selectDomainTypeWildcardName": "Dominio comodín", + "selectDomainTypeWildcardDescription": "Este dominio y sus subdominios.", + "domainDelegation": "Dominio único", + "selectType": "Selecciona un tipo", + "actions": "Acciones", + "refresh": "Actualizar", + "refreshError": "Error al actualizar datos", + "verified": "Verificado", + "pending": "Pendiente", + "sidebarBilling": "Facturación", + "billing": "Facturación", + "orgBillingDescription": "Gestiona tu información de facturación y suscripciones", + "github": "GitHub", + "pangolinHosted": "Pangolin Hosted", + "fossorial": "Fossorial", + "completeAccountSetup": "Completar configuración de cuenta", + "completeAccountSetupDescription": "Establece tu contraseña para comenzar", + "accountSetupSent": "Enviaremos un código de configuración de cuenta a esta dirección de correo electrónico.", + "accountSetupCode": "Código de configuración", + "accountSetupCodeDescription": "Revisa tu correo para el código de configuración.", + "passwordCreate": "Crear contraseña", + "passwordCreateConfirm": "Confirmar contraseña", + "accountSetupSubmit": "Enviar código de configuración", + "completeSetup": "Completar configuración", + "accountSetupSuccess": "¡Configuración de cuenta completada! ¡Bienvenido a Pangolin!", + "documentation": "Documentación", + "saveAllSettings": "Guardar todos los ajustes", + "settingsUpdated": "Ajustes actualizados", + "settingsUpdatedDescription": "Todos los ajustes han sido actualizados exitosamente", + "settingsErrorUpdate": "Error al actualizar ajustes", + "settingsErrorUpdateDescription": "Ocurrió un error al actualizar ajustes", + "sidebarCollapse": "Colapsar", + "sidebarExpand": "Expandir", + "newtUpdateAvailable": "Nueva actualización disponible", + "newtUpdateAvailableInfo": "Hay una nueva versión de Newt disponible. Actualice a la última versión para la mejor experiencia.", + "domainPickerEnterDomain": "Dominio", + "domainPickerPlaceholder": "myapp.example.com, api.v1.miDominio.com, o solo myapp", + "domainPickerDescription": "Ingresa el dominio completo del recurso para ver las opciones disponibles.", + "domainPickerDescriptionSaas": "Ingresa un dominio completo, subdominio o simplemente un nombre para ver las opciones disponibles", + "domainPickerTabAll": "Todo", + "domainPickerTabOrganization": "Organización", + "domainPickerTabProvided": "Proporcionado", + "domainPickerSortAsc": "A-Z", + "domainPickerSortDesc": "Z-A", + "domainPickerCheckingAvailability": "Comprobando disponibilidad...", + "domainPickerNoMatchingDomains": "No se encontraron dominios que coincidan. Intente con un dominio diferente o verifique la configuración de dominios de su organización.", + "domainPickerOrganizationDomains": "Dominios de la organización", + "domainPickerProvidedDomains": "Dominios proporcionados", + "domainPickerSubdomain": "Subdominio: {subdomain}", + "domainPickerNamespace": "Espacio de nombres: {namespace}", + "domainPickerShowMore": "Mostrar más", + "domainNotFound": "Dominio no encontrado", + "domainNotFoundDescription": "Este recurso está deshabilitado porque el dominio ya no existe en nuestro sistema. Por favor, establece un nuevo dominio para este recurso.", + "failed": "Fallido", + "createNewOrgDescription": "Crear una nueva organización", + "organization": "Organización", + "port": "Puerto", + "securityKeyManage": "Gestionar llaves de seguridad", + "securityKeyDescription": "Agregar o eliminar llaves de seguridad para autenticación sin contraseña", + "securityKeyRegister": "Registrar nueva llave de seguridad", + "securityKeyList": "Tus llaves de seguridad", + "securityKeyNone": "No hay llaves de seguridad registradas", + "securityKeyNameRequired": "El nombre es requerido", + "securityKeyRemove": "Eliminar", + "securityKeyLastUsed": "Último uso: {date}", + "securityKeyNameLabel": "Nombre", + "securityKeyRegisterSuccess": "Llave de seguridad registrada exitosamente", + "securityKeyRegisterError": "Error al registrar la llave de seguridad", + "securityKeyRemoveSuccess": "Llave de seguridad eliminada exitosamente", + "securityKeyRemoveError": "Error al eliminar la llave de seguridad", + "securityKeyLoadError": "Error al cargar las llaves de seguridad", + "securityKeyLogin": "Continuar con clave de seguridad", + "securityKeyAuthError": "Error al autenticar con llave de seguridad", + "securityKeyRecommendation": "Considere registrar otra llave de seguridad en un dispositivo diferente para asegurarse de no quedar bloqueado de su cuenta.", + "registering": "Registrando...", + "securityKeyPrompt": "Por favor, verifica tu identidad usando tu llave de seguridad. Asegúrate de que tu llave de seguridad esté conectada y lista.", + "securityKeyBrowserNotSupported": "Tu navegador no admite llaves de seguridad. Por favor, usa un navegador moderno como Chrome, Firefox o Safari.", + "securityKeyPermissionDenied": "Por favor, permite el acceso a tu llave de seguridad para continuar iniciando sesión.", + "securityKeyRemovedTooQuickly": "Por favor, mantén tu llave de seguridad conectada hasta que el proceso de inicio de sesión se complete.", + "securityKeyNotSupported": "Tu llave de seguridad puede no ser compatible. Por favor, prueba con una llave de seguridad diferente.", + "securityKeyUnknownError": "Hubo un problema al usar tu llave de seguridad. Por favor, inténtalo de nuevo.", + "twoFactorRequired": "Se requiere autenticación de dos factores para registrar una llave de seguridad.", + "twoFactor": "Autenticación de dos factores", + "adminEnabled2FaOnYourAccount": "Su administrador ha habilitado la autenticación de dos factores para {email}. Por favor, complete el proceso de configuración para continuar.", + "continueToApplication": "Continuar a la aplicación", + "securityKeyAdd": "Agregar llave de seguridad", + "securityKeyRegisterTitle": "Registrar nueva llave de seguridad", + "securityKeyRegisterDescription": "Conecta tu llave de seguridad y escribe un nombre para identificarla", + "securityKeyTwoFactorRequired": "Se requiere autenticación de dos factores", + "securityKeyTwoFactorDescription": "Por favor, ingresa tu código de autenticación de dos factores para registrar la llave de seguridad", + "securityKeyTwoFactorRemoveDescription": "Por favor, ingresa tu código de autenticación de dos factores para eliminar la llave de seguridad", + "securityKeyTwoFactorCode": "Código de autenticación de dos factores", + "securityKeyRemoveTitle": "Eliminar llave de seguridad", + "securityKeyRemoveDescription": "Ingresa tu contraseña para eliminar la llave de seguridad \"{name}\"", + "securityKeyNoKeysRegistered": "No hay llaves de seguridad registradas", + "securityKeyNoKeysDescription": "Agrega una llave de seguridad para mejorar la seguridad de tu cuenta", + "createDomainRequired": "Se requiere dominio", + "createDomainAddDnsRecords": "Agregar registros DNS", + "createDomainAddDnsRecordsDescription": "Agrega los siguientes registros DNS a tu proveedor de dominios para completar la configuración.", + "createDomainNsRecords": "Registros NS", + "createDomainRecord": "Registro", + "createDomainType": "Tipo:", + "createDomainName": "Nombre:", + "createDomainValue": "Valor:", + "createDomainCnameRecords": "Registros CNAME", + "createDomainARecords": "Registros A", + "createDomainRecordNumber": "Registro {number}", + "createDomainTxtRecords": "Registros TXT", + "createDomainSaveTheseRecords": "Guardar estos registros", + "createDomainSaveTheseRecordsDescription": "Asegúrate de guardar estos registros DNS ya que no los verás de nuevo.", + "createDomainDnsPropagation": "Propagación DNS", + "createDomainDnsPropagationDescription": "Los cambios de DNS pueden tardar un tiempo en propagarse a través de internet. Esto puede tardar desde unos pocos minutos hasta 48 horas, dependiendo de tu proveedor de DNS y la configuración de TTL.", + "resourcePortRequired": "Se requiere número de puerto para recursos no HTTP", + "resourcePortNotAllowed": "El número de puerto no debe establecerse para recursos HTTP", + "signUpTerms": { + "IAgreeToThe": "Estoy de acuerdo con los", + "termsOfService": "términos del servicio", + "and": "y", + "privacyPolicy": "política de privacidad" + }, + "siteRequired": "El sitio es requerido.", + "olmTunnel": "Túnel Olm", + "olmTunnelDescription": "Usar Olm para la conectividad del cliente", + "errorCreatingClient": "Error al crear el cliente", + "clientDefaultsNotFound": "Configuración predeterminada del cliente no encontrada", + "createClient": "Crear cliente", + "createClientDescription": "Crear un cliente nuevo para conectar a sus sitios", + "seeAllClients": "Ver todos los clientes", + "clientInformation": "Información del cliente", + "clientNamePlaceholder": "Nombre del cliente", + "address": "Dirección", + "subnetPlaceholder": "Subred", + "addressDescription": "La dirección que este cliente utilizará para la conectividad", + "selectSites": "Seleccionar sitios", + "sitesDescription": "El cliente tendrá conectividad con los sitios seleccionados", + "clientInstallOlm": "Instalar Olm", + "clientInstallOlmDescription": "Obtén Olm funcionando en tu sistema", + "clientOlmCredentials": "Credenciales Olm", + "clientOlmCredentialsDescription": "Así es como Olm se autentificará con el servidor", + "olmEndpoint": "Punto final Olm", + "olmId": "ID de Olm", + "olmSecretKey": "Clave secreta de Olm", + "clientCredentialsSave": "Guarda tus credenciales", + "clientCredentialsSaveDescription": "Sólo podrás verlo una vez. Asegúrate de copiarlo a un lugar seguro.", + "generalSettingsDescription": "Configura la configuración general para este cliente", + "clientUpdated": "Cliente actualizado", + "clientUpdatedDescription": "El cliente ha sido actualizado.", + "clientUpdateFailed": "Error al actualizar el cliente", + "clientUpdateError": "Se ha producido un error al actualizar el cliente.", + "sitesFetchFailed": "Error al obtener los sitios", + "sitesFetchError": "Se ha producido un error al recuperar los sitios.", + "olmErrorFetchReleases": "Se ha producido un error al recuperar las versiones de Olm.", + "olmErrorFetchLatest": "Se ha producido un error al recuperar la última versión de Olm.", + "remoteSubnets": "Subredes remotas", + "enterCidrRange": "Ingresa el rango CIDR", + "remoteSubnetsDescription": "Agregue rangos CIDR que puedan acceder a este sitio de forma remota. Use un formato como 10.0.0.0/24 o 192.168.1.0/24.", + "resourceEnableProxy": "Habilitar proxy público", + "resourceEnableProxyDescription": "Habilite el proxy público para este recurso. Esto permite el acceso al recurso desde fuera de la red a través de la nube en un puerto abierto. Requiere configuración de Traefik.", + "externalProxyEnabled": "Proxy externo habilitado" } diff --git a/messages/fr-FR.json b/messages/fr-FR.json index a7a237bf..4d23e073 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -11,8 +11,9 @@ "componentsErrorNoMemberCreate": "Vous n'êtes actuellement membre d'aucune organisation. Créez une organisation pour commencer.", "componentsErrorNoMember": "Vous n'êtes actuellement membre d'aucune organisation.", "welcome": "Bienvenue à Pangolin", + "welcomeTo": "Bienvenue chez", "componentsCreateOrg": "Créer une organisation", - "componentsMember": "Vous êtes membre de {count, plural, =0 {aucune organisation} =1 {Une organisation} other {# organisations}}.", + "componentsMember": "Vous êtes membre de {count, plural, =0 {aucune organisation} one {une organisation} other {# organisations}}.", "componentsInvalidKey": "Clés de licence invalides ou expirées détectées. Suivez les conditions de licence pour continuer à utiliser toutes les fonctionnalités.", "dismiss": "Refuser", "componentsLicenseViolation": "Violation de licence : Ce serveur utilise des sites {usedSites} qui dépassent la limite autorisée des sites {maxSites} . Suivez les conditions de licence pour continuer à utiliser toutes les fonctionnalités.", @@ -58,7 +59,6 @@ "siteErrorCreate": "Erreur lors de la création du site", "siteErrorCreateKeyPair": "Paire de clés ou site par défaut introuvable", "siteErrorCreateDefaults": "Les valeurs par défaut du site sont introuvables", - "siteNameDescription": "Ceci est le nom d'affichage du site.", "method": "Méthode", "siteMethodDescription": "C'est ainsi que vous exposerez les connexions.", "siteLearnNewt": "Apprenez à installer Newt sur votre système", @@ -184,7 +184,7 @@ "cancel": "Abandonner", "resourceConfig": "Snippets de configuration", "resourceConfigDescription": "Copiez et collez ces modules de configuration pour configurer votre ressource TCP/UDP", - "resourceAddEntrypoints": "Traefik: Ajouter des points d’entrée", + "resourceAddEntrypoints": "Traefik: Ajouter des points d'entrée", "resourceExposePorts": "Gerbil: Exposer des ports dans Docker Compose", "resourceLearnRaw": "Apprenez à configurer les ressources TCP/UDP", "resourceBack": "Retour aux ressources", @@ -206,6 +206,7 @@ "orgGeneralSettings": "Paramètres de l'organisation", "orgGeneralSettingsDescription": "Gérer les détails et la configuration de votre organisation", "saveGeneralSettings": "Enregistrer les paramètres généraux", + "saveSettings": "Enregistrer les paramètres", "orgDangerZone": "Zone de danger", "orgDangerZoneDescription": "Une fois que vous supprimez cette organisation, il n'y a pas de retour en arrière. Soyez certain.", "orgDelete": "Supprimer l'organisation", @@ -249,7 +250,7 @@ "weeks": "Semaines", "months": "Mois", "years": "Années", - "day": "{count, plural, =1 {# jour} other {# jours}}", + "day": "{count, plural, one {# jour} other {# jours}}", "apiKeysTitle": "Informations sur la clé API", "apiKeysConfirmCopy2": "Vous devez confirmer que vous avez copié la clé API.", "apiKeysErrorCreate": "Erreur lors de la création de la clé API", @@ -347,7 +348,7 @@ "licensePurchase": "Acheter une licence", "licensePurchaseSites": "Acheter des sites supplémentaires", "licenseSitesUsedMax": "{usedSites} des sites {maxSites} utilisés", - "licenseSitesUsed": "{count, plural, =0 {# sites} =1 {# site} other {# sites}} dans le système.", + "licenseSitesUsed": "{count, plural, =0 {# sites} one {# site} other {# sites}} dans le système.", "licensePurchaseDescription": "Choisissez le nombre de sites que vous voulez {selectedMode, select, license {achetez une licence. Vous pouvez toujours ajouter plus de sites plus tard.} other {ajouter à votre licence existante.}}", "licenseFee": "Frais de licence", "licensePriceSite": "Prix par site", @@ -436,7 +437,7 @@ "accessRoleSelect": "Sélectionner un rôle", "inviteEmailSentDescription": "Un e-mail a été envoyé à l'utilisateur avec le lien d'accès ci-dessous. Ils doivent accéder au lien pour accepter l'invitation.", "inviteSentDescription": "L'utilisateur a été invité. Ils doivent accéder au lien ci-dessous pour accepter l'invitation.", - "inviteExpiresIn": "L'invitation expirera dans {days, plural, =1 {# jour} other {# jours}}.", + "inviteExpiresIn": "L'invitation expirera dans {days, plural, one {# jour} other {# jours}}.", "idpTitle": "Informations générales", "idpSelect": "Sélectionnez le fournisseur d'identité pour l'utilisateur externe", "idpNotConfigured": "Aucun fournisseur d'identité n'est configuré. Veuillez configurer un fournisseur d'identité avant de créer des utilisateurs externes.", @@ -958,6 +959,8 @@ "licenseTierProfessionalRequiredDescription": "Cette fonctionnalité n'est disponible que dans l'Édition Professionnelle.", "actionGetOrg": "Obtenir l'organisation", "actionUpdateOrg": "Mettre à jour l'organisation", + "actionUpdateUser": "Mettre à jour l'utilisateur", + "actionGetUser": "Obtenir l'utilisateur", "actionGetOrgUser": "Obtenir l'utilisateur de l'organisation", "actionListOrgDomains": "Lister les domaines de l'organisation", "actionCreateSite": "Créer un site", @@ -1019,6 +1022,11 @@ "actionDeleteIdpOrg": "Supprimer une politique d'organisation IDP", "actionListIdpOrgs": "Lister les organisations IDP", "actionUpdateIdpOrg": "Mettre à jour une organisation IDP", + "actionCreateClient": "Créer un client", + "actionDeleteClient": "Supprimer le client", + "actionUpdateClient": "Mettre à jour le client", + "actionListClients": "Liste des clients", + "actionGetClient": "Obtenir le client", "noneSelected": "Aucune sélection", "orgNotFound2": "Aucune organisation trouvée.", "searchProgress": "Rechercher...", @@ -1090,6 +1098,8 @@ "sidebarAllUsers": "Tous les utilisateurs", "sidebarIdentityProviders": "Fournisseurs d'identité", "sidebarLicense": "Licence", + "sidebarClients": "Clients (Bêta)", + "sidebarDomains": "Domaines", "enableDockerSocket": "Activer Docker Socket", "enableDockerSocketDescription": "Activer la découverte Docker Socket pour remplir les informations du conteneur. Le chemin du socket doit être fourni à Newt.", "enableDockerSocketLink": "En savoir plus", @@ -1102,7 +1112,7 @@ "containerNetworks": "Réseaux", "containerHostnameIp": "Nom d'hôte/IP", "containerLabels": "Étiquettes", - "containerLabelsCount": "{count} étiquette{s,plural,one{} other{s}}", + "containerLabelsCount": "{count, plural, one {# étiquette} other {# étiquettes}}", "containerLabelsTitle": "Étiquettes de conteneur", "containerLabelEmpty": "", "containerPorts": "Ports", @@ -1114,7 +1124,7 @@ "showStoppedContainers": "Afficher les conteneurs arrêtés", "noContainersFound": "Aucun conteneur trouvé. Assurez-vous que les conteneurs Docker sont en cours d'exécution.", "searchContainersPlaceholder": "Rechercher dans les conteneurs {count}...", - "searchResultsCount": "{count} résultat{s,plural,one{} other{s}}", + "searchResultsCount": "{count, plural, one {# résultat} other {# résultats}}", "filters": "Filtres", "filterOptions": "Options de filtre", "filterPorts": "Ports", @@ -1129,8 +1139,189 @@ "dark": "sombre", "system": "système", "theme": "Thème", + "subnetRequired": "Le sous-réseau est requis", "initialSetupTitle": "Configuration initiale du serveur", "initialSetupDescription": "Créer le compte administrateur du serveur initial. Un seul administrateur serveur peut exister. Vous pouvez toujours changer ces informations d'identification plus tard.", "createAdminAccount": "Créer un compte administrateur", - "setupErrorCreateAdmin": "Une erreur s'est produite lors de la création du compte administrateur du serveur." + "setupErrorCreateAdmin": "Une erreur s'est produite lors de la création du compte administrateur du serveur.", + "certificateStatus": "Statut du certificat", + "loading": "Chargement", + "restart": "Redémarrer", + "domains": "Domaines", + "domainsDescription": "Gérer les domaines de votre organisation", + "domainsSearch": "Rechercher des domaines...", + "domainAdd": "Ajouter un domaine", + "domainAddDescription": "Enregistrez un nouveau domaine avec votre organisation", + "domainCreate": "Créer un domaine", + "domainCreatedDescription": "Domaine créé avec succès", + "domainDeletedDescription": "Domaine supprimé avec succès", + "domainQuestionRemove": "Êtes-vous sûr de vouloir supprimer le domaine {domain} de votre compte ?", + "domainMessageRemove": "Une fois supprimé, le domaine ne sera plus associé à votre compte.", + "domainMessageConfirm": "Pour confirmer, veuillez taper le nom du domaine ci-dessous.", + "domainConfirmDelete": "Confirmer la suppression du domaine", + "domainDelete": "Supprimer le domaine", + "domain": "Domaine", + "selectDomainTypeNsName": "Délégation de domaine (NS)", + "selectDomainTypeNsDescription": "Ce domaine et tous ses sous-domaines. Utilisez cela lorsque vous souhaitez contrôler une zone de domaine entière.", + "selectDomainTypeCnameName": "Domaine unique (CNAME)", + "selectDomainTypeCnameDescription": "Juste ce domaine spécifique. Utilisez ce paramètre pour des sous-domaines individuels ou des entrées de domaine spécifiques.", + "selectDomainTypeWildcardName": "Domaine Générique", + "selectDomainTypeWildcardDescription": "Ce domaine et ses sous-domaines.", + "domainDelegation": "Domaine Unique", + "selectType": "Sélectionnez un type", + "actions": "Actions", + "refresh": "Actualiser", + "refreshError": "Échec de l'actualisation des données", + "verified": "Vérifié", + "pending": "En attente", + "sidebarBilling": "Facturation", + "billing": "Facturation", + "orgBillingDescription": "Gérez vos informations de facturation et vos abonnements", + "github": "GitHub", + "pangolinHosted": "Pangolin Hébergement", + "fossorial": "Fossorial", + "completeAccountSetup": "Complétez la configuration du compte", + "completeAccountSetupDescription": "Définissez votre mot de passe pour commencer", + "accountSetupSent": "Nous enverrons un code de configuration de compte à cette adresse e-mail.", + "accountSetupCode": "Code de configuration", + "accountSetupCodeDescription": "Vérifiez votre e-mail pour le code de configuration.", + "passwordCreate": "Créer un mot de passe", + "passwordCreateConfirm": "Confirmer le mot de passe", + "accountSetupSubmit": "Envoyer le code de configuration", + "completeSetup": "Configuration complète", + "accountSetupSuccess": "Configuration du compte terminée! Bienvenue chez Pangolin !", + "documentation": "Documentation", + "saveAllSettings": "Enregistrer tous les paramètres", + "settingsUpdated": "Paramètres mis à jour", + "settingsUpdatedDescription": "Tous les paramètres ont été mis à jour avec succès", + "settingsErrorUpdate": "Échec de la mise à jour des paramètres", + "settingsErrorUpdateDescription": "Une erreur s'est produite lors de la mise à jour des paramètres", + "sidebarCollapse": "Réduire", + "sidebarExpand": "Développer", + "newtUpdateAvailable": "Mise à jour disponible", + "newtUpdateAvailableInfo": "Une nouvelle version de Newt est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.", + "domainPickerEnterDomain": "Domaine", + "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, ou simplement myapp", + "domainPickerDescription": "Entrez le domaine complet de la ressource pour voir les options disponibles.", + "domainPickerDescriptionSaas": "Entrez un domaine complet, un sous-domaine ou juste un nom pour voir les options disponibles", + "domainPickerTabAll": "Tous", + "domainPickerTabOrganization": "Organisation", + "domainPickerTabProvided": "Fournis", + "domainPickerSortAsc": "A-Z", + "domainPickerSortDesc": "Z-A", + "domainPickerCheckingAvailability": "Vérification de la disponibilité...", + "domainPickerNoMatchingDomains": "Aucun domaine correspondant trouvé. Essayez un autre domaine ou vérifiez les paramètres de domaine de votre organisation.", + "domainPickerOrganizationDomains": "Domaines de l'organisation", + "domainPickerProvidedDomains": "Domaines fournis", + "domainPickerSubdomain": "Sous-domaine : {subdomain}", + "domainPickerNamespace": "Espace de noms : {namespace}", + "domainPickerShowMore": "Afficher plus", + "domainNotFound": "Domaine introuvable", + "domainNotFoundDescription": "Cette ressource est désactivée car le domaine n'existe plus dans notre système. Veuillez définir un nouveau domaine pour cette ressource.", + "failed": "Échec", + "createNewOrgDescription": "Créer une nouvelle organisation", + "organization": "Organisation", + "port": "Port", + "securityKeyManage": "Gérer les clés de sécurité", + "securityKeyDescription": "Ajouter ou supprimer des clés de sécurité pour l'authentification sans mot de passe", + "securityKeyRegister": "Enregistrer une nouvelle clé de sécurité", + "securityKeyList": "Vos clés de sécurité", + "securityKeyNone": "Aucune clé de sécurité enregistrée", + "securityKeyNameRequired": "Le nom est requis", + "securityKeyRemove": "Supprimer", + "securityKeyLastUsed": "Dernière utilisation : {date}", + "securityKeyNameLabel": "Nom", + "securityKeyRegisterSuccess": "Clé de sécurité enregistrée avec succès", + "securityKeyRegisterError": "Échec de l'enregistrement de la clé de sécurité", + "securityKeyRemoveSuccess": "Clé de sécurité supprimée avec succès", + "securityKeyRemoveError": "Échec de la suppression de la clé de sécurité", + "securityKeyLoadError": "Échec du chargement des clés de sécurité", + "securityKeyLogin": "Continuer avec une clé de sécurité", + "securityKeyAuthError": "Échec de l'authentification avec la clé de sécurité", + "securityKeyRecommendation": "Envisagez d'enregistrer une autre clé de sécurité sur un appareil différent pour vous assurer de ne pas être bloqué de votre compte.", + "registering": "Enregistrement...", + "securityKeyPrompt": "Veuillez vérifier votre identité à l'aide de votre clé de sécurité. Assurez-vous que votre clé de sécurité est connectée et prête.", + "securityKeyBrowserNotSupported": "Votre navigateur ne prend pas en charge les clés de sécurité. Veuillez utiliser un navigateur moderne comme Chrome, Firefox ou Safari.", + "securityKeyPermissionDenied": "Veuillez autoriser l'accès à votre clé de sécurité pour continuer la connexion.", + "securityKeyRemovedTooQuickly": "Veuillez garder votre clé de sécurité connectée jusqu'à ce que le processus de connexion soit terminé.", + "securityKeyNotSupported": "Votre clé de sécurité peut ne pas être compatible. Veuillez essayer une clé de sécurité différente.", + "securityKeyUnknownError": "Un problème est survenu avec votre clé de sécurité. Veuillez réessayer.", + "twoFactorRequired": "L'authentification à deux facteurs est requise pour enregistrer une clé de sécurité.", + "twoFactor": "Authentification à deux facteurs", + "adminEnabled2FaOnYourAccount": "Votre administrateur a activé l'authentification à deux facteurs pour {email}. Veuillez terminer le processus d'installation pour continuer.", + "continueToApplication": "Continuer vers l'application", + "securityKeyAdd": "Ajouter une clé de sécurité", + "securityKeyRegisterTitle": "Enregistrer une nouvelle clé de sécurité", + "securityKeyRegisterDescription": "Connectez votre clé de sécurité et saisissez un nom pour l'identifier", + "securityKeyTwoFactorRequired": "Authentification à deux facteurs requise", + "securityKeyTwoFactorDescription": "Veuillez entrer votre code d'authentification à deux facteurs pour enregistrer la clé de sécurité", + "securityKeyTwoFactorRemoveDescription": "Veuillez entrer votre code d'authentification à deux facteurs pour supprimer la clé de sécurité", + "securityKeyTwoFactorCode": "Code à deux facteurs", + "securityKeyRemoveTitle": "Supprimer la clé de sécurité", + "securityKeyRemoveDescription": "Saisissez votre mot de passe pour supprimer la clé de sécurité \"{name}\"", + "securityKeyNoKeysRegistered": "Aucune clé de sécurité enregistrée", + "securityKeyNoKeysDescription": "Ajoutez une clé de sécurité pour améliorer la sécurité de votre compte", + "createDomainRequired": "Le domaine est requis", + "createDomainAddDnsRecords": "Ajouter des enregistrements DNS", + "createDomainAddDnsRecordsDescription": "Ajouter les enregistrements DNS suivants à votre fournisseur de domaine pour compléter la configuration.", + "createDomainNsRecords": "Enregistrements NS", + "createDomainRecord": "Enregistrement", + "createDomainType": "Type :", + "createDomainName": "Nom :", + "createDomainValue": "Valeur :", + "createDomainCnameRecords": "Enregistrements CNAME", + "createDomainARecords": "Enregistrements A", + "createDomainRecordNumber": "Enregistrement {number}", + "createDomainTxtRecords": "Enregistrements TXT", + "createDomainSaveTheseRecords": "Enregistrez ces enregistrements", + "createDomainSaveTheseRecordsDescription": "Assurez-vous de sauvegarder ces enregistrements DNS car vous ne les reverrez pas.", + "createDomainDnsPropagation": "Propagation DNS", + "createDomainDnsPropagationDescription": "Les modifications DNS peuvent mettre du temps à se propager sur internet. Cela peut prendre de quelques minutes à 48 heures selon votre fournisseur DNS et les réglages TTL.", + "resourcePortRequired": "Le numéro de port est requis pour les ressources non-HTTP", + "resourcePortNotAllowed": "Le numéro de port ne doit pas être défini pour les ressources HTTP", + "signUpTerms": { + "IAgreeToThe": "Je suis d'accord avec", + "termsOfService": "les conditions d'utilisation", + "and": "et", + "privacyPolicy": "la politique de confidentialité" + }, + "siteRequired": "Le site est requis.", + "olmTunnel": "Tunnel Olm", + "olmTunnelDescription": "Utilisez Olm pour la connectivité client", + "errorCreatingClient": "Erreur lors de la création du client", + "clientDefaultsNotFound": "Les paramètres par défaut du client sont introuvables", + "createClient": "Créer un client", + "createClientDescription": "Créez un nouveau client pour vous connecter à vos sites", + "seeAllClients": "Voir tous les clients", + "clientInformation": "Informations client", + "clientNamePlaceholder": "Nom du client", + "address": "Adresse", + "subnetPlaceholder": "Sous-réseau", + "addressDescription": "L'adresse que ce client utilisera pour la connectivité", + "selectSites": "Sélectionner des sites", + "sitesDescription": "Le client aura une connectivité vers les sites sélectionnés", + "clientInstallOlm": "Installer Olm", + "clientInstallOlmDescription": "Faites fonctionner Olm sur votre système", + "clientOlmCredentials": "Identifiants Olm", + "clientOlmCredentialsDescription": "C'est ainsi qu'Olm s'authentifiera auprès du serveur", + "olmEndpoint": "Point de terminaison Olm", + "olmId": "ID Olm", + "olmSecretKey": "Clé secrète Olm", + "clientCredentialsSave": "Enregistrez vos identifiants", + "clientCredentialsSaveDescription": "Vous ne pourrez voir cela qu'une seule fois. Assurez-vous de la copier dans un endroit sécurisé.", + "generalSettingsDescription": "Configurez les paramètres généraux pour ce client", + "clientUpdated": "Client mis à jour", + "clientUpdatedDescription": "Le client a été mis à jour.", + "clientUpdateFailed": "Échec de la mise à jour du client", + "clientUpdateError": "Une erreur s'est produite lors de la mise à jour du client.", + "sitesFetchFailed": "Échec de la récupération des sites", + "sitesFetchError": "Une erreur s'est produite lors de la récupération des sites.", + "olmErrorFetchReleases": "Une erreur s'est produite lors de la récupération des versions d'Olm.", + "olmErrorFetchLatest": "Une erreur s'est produite lors de la récupération de la dernière version d'Olm.", + "remoteSubnets": "Sous-réseaux distants", + "enterCidrRange": "Entrez la plage CIDR", + "remoteSubnetsDescription": "Ajoutez des plages CIDR pouvant accéder à ce site à distance. Utilisez le format comme 10.0.0.0/24 ou 192.168.1.0/24.", + "resourceEnableProxy": "Activer le proxy public", + "resourceEnableProxyDescription": "Activez le proxy public vers cette ressource. Cela permet d'accéder à la ressource depuis l'extérieur du réseau via le cloud sur un port ouvert. Nécessite la configuration de Traefik.", + "externalProxyEnabled": "Proxy externe activé" } diff --git a/messages/it-IT.json b/messages/it-IT.json index cfe983d2..d336011a 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -11,8 +11,9 @@ "componentsErrorNoMemberCreate": "Al momento non sei un membro di nessuna organizzazione. Crea un'organizzazione per iniziare.", "componentsErrorNoMember": "Attualmente non sei membro di nessuna organizzazione.", "welcome": "Benvenuti a Pangolin", + "welcomeTo": "Benvenuto a", "componentsCreateOrg": "Crea un'organizzazione", - "componentsMember": "Sei un membro di {count, plural, =0 {nessuna organizzazione} =1 {una organizzazione} other {# organizzazioni}}.", + "componentsMember": "Sei un membro di {count, plural, =0 {nessuna organizzazione} one {un'organizzazione} other {# organizzazioni}}.", "componentsInvalidKey": "Rilevata chiave di licenza non valida o scaduta. Segui i termini di licenza per continuare a utilizzare tutte le funzionalità.", "dismiss": "Ignora", "componentsLicenseViolation": "Violazione della licenza: Questo server sta usando i siti {usedSites} che superano il suo limite concesso in licenza per i siti {maxSites} . Segui i termini di licenza per continuare a usare tutte le funzionalità.", @@ -58,7 +59,6 @@ "siteErrorCreate": "Errore nella creazione del sito", "siteErrorCreateKeyPair": "Coppia di chiavi o valori predefiniti del sito non trovati", "siteErrorCreateDefaults": "Predefiniti del sito non trovati", - "siteNameDescription": "Questo è il nome visualizzato per il sito.", "method": "Metodo", "siteMethodDescription": "Questo è il modo in cui esporrete le connessioni.", "siteLearnNewt": "Scopri come installare Newt sul tuo sistema", @@ -206,6 +206,7 @@ "orgGeneralSettings": "Impostazioni Organizzazione", "orgGeneralSettingsDescription": "Gestisci i dettagli dell'organizzazione e la configurazione", "saveGeneralSettings": "Salva Impostazioni Generali", + "saveSettings": "Salva Impostazioni", "orgDangerZone": "Zona Pericolosa", "orgDangerZoneDescription": "Una volta che si elimina questo org, non c'è ritorno. Si prega di essere certi.", "orgDelete": "Elimina Organizzazione", @@ -249,7 +250,7 @@ "weeks": "Settimane", "months": "Mesi", "years": "Anni", - "day": "{count, plural, =1 {# giorno} other {# giorni}}", + "day": "{count, plural, one {# giorno} other {# giorni}}", "apiKeysTitle": "Informazioni Chiave API", "apiKeysConfirmCopy2": "Devi confermare di aver copiato la chiave API.", "apiKeysErrorCreate": "Errore nella creazione della chiave API", @@ -347,7 +348,7 @@ "licensePurchase": "Acquista Licenza", "licensePurchaseSites": "Acquista Siti Aggiuntivi", "licenseSitesUsedMax": "{usedSites} di {maxSites} siti utilizzati", - "licenseSitesUsed": "{count, plural, =0 {# siti} =1 {# sito} other {# siti}} nel sistema.", + "licenseSitesUsed": "{count, plural, =0 {# siti} one {# sito} other {# siti}} nel sistema.", "licensePurchaseDescription": "Scegli quanti siti vuoi {selectedMode, select, license {acquista una licenza. Puoi sempre aggiungere altri siti più tardi.} other {aggiungi alla tua licenza esistente.}}", "licenseFee": "Costo della licenza", "licensePriceSite": "Prezzo per sito", @@ -436,7 +437,7 @@ "accessRoleSelect": "Seleziona ruolo", "inviteEmailSentDescription": "È stata inviata un'email all'utente con il link di accesso qui sotto. Devono accedere al link per accettare l'invito.", "inviteSentDescription": "L'utente è stato invitato. Deve accedere al link qui sotto per accettare l'invito.", - "inviteExpiresIn": "L'invito scadrà tra {days, plural, =1 {# giorno} other {# giorni}}.", + "inviteExpiresIn": "L'invito scadrà tra {days, plural, one {# giorno} other {# giorni}}.", "idpTitle": "Informazioni Generali", "idpSelect": "Seleziona il provider di identità per l'utente esterno", "idpNotConfigured": "Nessun provider di identità configurato. Configura un provider di identità prima di creare utenti esterni.", @@ -958,6 +959,8 @@ "licenseTierProfessionalRequiredDescription": "Questa funzionalità è disponibile solo nell'Edizione Professional.", "actionGetOrg": "Ottieni Organizzazione", "actionUpdateOrg": "Aggiorna Organizzazione", + "actionUpdateUser": "Aggiorna Utente", + "actionGetUser": "Ottieni Utente", "actionGetOrgUser": "Ottieni Utente Organizzazione", "actionListOrgDomains": "Elenca Domini Organizzazione", "actionCreateSite": "Crea Sito", @@ -1019,6 +1022,11 @@ "actionDeleteIdpOrg": "Elimina Politica Org IDP", "actionListIdpOrgs": "Elenca Org IDP", "actionUpdateIdpOrg": "Aggiorna Org IDP", + "actionCreateClient": "Crea Client", + "actionDeleteClient": "Elimina Client", + "actionUpdateClient": "Aggiorna Client", + "actionListClients": "Elenco Clienti", + "actionGetClient": "Ottieni Client", "noneSelected": "Nessuna selezione", "orgNotFound2": "Nessuna organizzazione trovata.", "searchProgress": "Ricerca...", @@ -1090,6 +1098,8 @@ "sidebarAllUsers": "Tutti Gli Utenti", "sidebarIdentityProviders": "Fornitori Di Identità", "sidebarLicense": "Licenza", + "sidebarClients": "Clienti (Beta)", + "sidebarDomains": "Domini", "enableDockerSocket": "Abilita Docker Socket", "enableDockerSocketDescription": "Abilita il rilevamento Docker Socket per popolare le informazioni del contenitore. Il percorso del socket deve essere fornito a Newt.", "enableDockerSocketLink": "Scopri di più", @@ -1102,7 +1112,7 @@ "containerNetworks": "Reti", "containerHostnameIp": "Hostname/IP", "containerLabels": "Etichette", - "containerLabelsCount": "{count} etichetta{s,plural,one{} other{s}}", + "containerLabelsCount": "{count, plural, one {# etichetta} other {# etichette}}", "containerLabelsTitle": "Etichette Del Contenitore", "containerLabelEmpty": "", "containerPorts": "Porte", @@ -1114,7 +1124,7 @@ "showStoppedContainers": "Mostra contenitori fermati", "noContainersFound": "Nessun contenitore trovato. Assicurarsi che i contenitori Docker siano in esecuzione.", "searchContainersPlaceholder": "Cerca tra i contenitori {count}...", - "searchResultsCount": "{count} risultato{s,plural,one{} other{s}}", + "searchResultsCount": "{count, plural, one {# risultato} other {# risultati}}", "filters": "Filtri", "filterOptions": "Opzioni Filtro", "filterPorts": "Porte", @@ -1129,8 +1139,189 @@ "dark": "scuro", "system": "sistema", "theme": "Tema", + "subnetRequired": "Sottorete richiesta", "initialSetupTitle": "Impostazione Iniziale del Server", "initialSetupDescription": "Crea l'account amministratore del server iniziale. Può esistere solo un amministratore del server. È sempre possibile modificare queste credenziali in seguito.", "createAdminAccount": "Crea Account Admin", - "setupErrorCreateAdmin": "Si è verificato un errore durante la creazione dell'account amministratore del server." + "setupErrorCreateAdmin": "Si è verificato un errore durante la creazione dell'account amministratore del server.", + "certificateStatus": "Stato del Certificato", + "loading": "Caricamento", + "restart": "Riavvia", + "domains": "Domini", + "domainsDescription": "Gestisci domini per la tua organizzazione", + "domainsSearch": "Cerca domini...", + "domainAdd": "Aggiungi Dominio", + "domainAddDescription": "Registra un nuovo dominio con la tua organizzazione", + "domainCreate": "Crea Dominio", + "domainCreatedDescription": "Dominio creato con successo", + "domainDeletedDescription": "Dominio eliminato con successo", + "domainQuestionRemove": "Sei sicuro di voler rimuovere il dominio {domain} dal tuo account?", + "domainMessageRemove": "Una volta rimosso, il dominio non sarà più associato al tuo account.", + "domainMessageConfirm": "Per confermare, digita il nome del dominio qui sotto.", + "domainConfirmDelete": "Conferma Eliminazione Dominio", + "domainDelete": "Elimina Dominio", + "domain": "Dominio", + "selectDomainTypeNsName": "Delega Dominio (NS)", + "selectDomainTypeNsDescription": "Questo dominio e tutti i suoi sottodomini. Usa questo quando desideri controllare un'intera zona di dominio.", + "selectDomainTypeCnameName": "Dominio Singolo (CNAME)", + "selectDomainTypeCnameDescription": "Solo questo dominio specifico. Usa questo per sottodomini individuali o specifiche voci di dominio.", + "selectDomainTypeWildcardName": "Dominio Jolly", + "selectDomainTypeWildcardDescription": "Questo dominio e i suoi sottodomini.", + "domainDelegation": "Dominio Singolo", + "selectType": "Seleziona un tipo", + "actions": "Azioni", + "refresh": "Aggiorna", + "refreshError": "Impossibile aggiornare i dati", + "verified": "Verificato", + "pending": "In attesa", + "sidebarBilling": "Fatturazione", + "billing": "Fatturazione", + "orgBillingDescription": "Gestisci le tue informazioni di fatturazione e abbonamenti", + "github": "GitHub", + "pangolinHosted": "Pangolin Hosted", + "fossorial": "Fossorial", + "completeAccountSetup": "Completa la Configurazione dell'Account", + "completeAccountSetupDescription": "Imposta la tua password per iniziare", + "accountSetupSent": "Invieremo un codice di configurazione dell'account a questo indirizzo email.", + "accountSetupCode": "Codice di Configurazione", + "accountSetupCodeDescription": "Controlla la tua email per il codice di configurazione.", + "passwordCreate": "Crea Password", + "passwordCreateConfirm": "Conferma Password", + "accountSetupSubmit": "Invia Codice di Configurazione", + "completeSetup": "Completa la Configurazione", + "accountSetupSuccess": "Configurazione dell'account completata! Benvenuto su Pangolin!", + "documentation": "Documentazione", + "saveAllSettings": "Salva Tutte le Impostazioni", + "settingsUpdated": "Impostazioni aggiornate", + "settingsUpdatedDescription": "Tutte le impostazioni sono state aggiornate con successo", + "settingsErrorUpdate": "Impossibile aggiornare le impostazioni", + "settingsErrorUpdateDescription": "Si è verificato un errore durante l'aggiornamento delle impostazioni", + "sidebarCollapse": "Comprimi", + "sidebarExpand": "Espandi", + "newtUpdateAvailable": "Aggiornamento Disponibile", + "newtUpdateAvailableInfo": "È disponibile una nuova versione di Newt. Si prega di aggiornare all'ultima versione per la migliore esperienza.", + "domainPickerEnterDomain": "Dominio", + "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, o semplicemente myapp", + "domainPickerDescription": "Inserisci il dominio completo della risorsa per vedere le opzioni disponibili.", + "domainPickerDescriptionSaas": "Inserisci un dominio completo, un sottodominio o semplicemente un nome per vedere le opzioni disponibili", + "domainPickerTabAll": "Tutti", + "domainPickerTabOrganization": "Organizzazione", + "domainPickerTabProvided": "Fornito", + "domainPickerSortAsc": "A-Z", + "domainPickerSortDesc": "Z-A", + "domainPickerCheckingAvailability": "Controllando la disponibilità...", + "domainPickerNoMatchingDomains": "Nessun dominio corrispondente trovato. Prova un dominio diverso o verifica le impostazioni del dominio della tua organizzazione.", + "domainPickerOrganizationDomains": "Domini dell'Organizzazione", + "domainPickerProvidedDomains": "Domini Forniti", + "domainPickerSubdomain": "Sottodominio: {subdomain}", + "domainPickerNamespace": "Namespace: {namespace}", + "domainPickerShowMore": "Mostra Altro", + "domainNotFound": "Domini Non Trovati", + "domainNotFoundDescription": "Questa risorsa è disabilitata perché il dominio non esiste più nel nostro sistema. Si prega di impostare un nuovo dominio per questa risorsa.", + "failed": "Fallito", + "createNewOrgDescription": "Crea una nuova organizzazione", + "organization": "Organizzazione", + "port": "Porta", + "securityKeyManage": "Gestisci chiavi di sicurezza", + "securityKeyDescription": "Aggiungi o rimuovi chiavi di sicurezza per l'autenticazione senza password", + "securityKeyRegister": "Registra nuova chiave di sicurezza", + "securityKeyList": "Le tue chiavi di sicurezza", + "securityKeyNone": "Nessuna chiave di sicurezza registrata", + "securityKeyNameRequired": "Il nome è obbligatorio", + "securityKeyRemove": "Rimuovi", + "securityKeyLastUsed": "Ultimo utilizzo: {date}", + "securityKeyNameLabel": "Nome", + "securityKeyRegisterSuccess": "Chiave di sicurezza registrata con successo", + "securityKeyRegisterError": "Errore durante la registrazione della chiave di sicurezza", + "securityKeyRemoveSuccess": "Chiave di sicurezza rimossa con successo", + "securityKeyRemoveError": "Errore durante la rimozione della chiave di sicurezza", + "securityKeyLoadError": "Errore durante il caricamento delle chiavi di sicurezza", + "securityKeyLogin": "Continua con la chiave di sicurezza", + "securityKeyAuthError": "Errore durante l'autenticazione con chiave di sicurezza", + "securityKeyRecommendation": "Considera di registrare un'altra chiave di sicurezza su un dispositivo diverso per assicurarti di non rimanere bloccato fuori dal tuo account.", + "registering": "Registrazione in corso...", + "securityKeyPrompt": "Verifica la tua identità usando la chiave di sicurezza. Assicurati che sia connessa e pronta.", + "securityKeyBrowserNotSupported": "Il tuo browser non supporta le chiavi di sicurezza. Per favore, usa un browser moderno come Chrome, Firefox o Safari.", + "securityKeyPermissionDenied": "Consenti accesso alla tua chiave di sicurezza per continuare ad accedere.", + "securityKeyRemovedTooQuickly": "Mantieni la chiave di sicurezza connessa fino a quando il processo di accesso non è completato.", + "securityKeyNotSupported": "La tua chiave di sicurezza potrebbe non essere compatibile. Prova un'altra chiave di sicurezza.", + "securityKeyUnknownError": "Si è verificato un problema con la tua chiave di sicurezza. Riprova.", + "twoFactorRequired": "È richiesta l'autenticazione a due fattori per registrare una chiave di sicurezza.", + "twoFactor": "Autenticazione a Due Fattori", + "adminEnabled2FaOnYourAccount": "Il tuo amministratore ha abilitato l'autenticazione a due fattori per {email}. Completa il processo di configurazione per continuare.", + "continueToApplication": "Continua all'Applicazione", + "securityKeyAdd": "Aggiungi Chiave di Sicurezza", + "securityKeyRegisterTitle": "Registra Nuova Chiave di Sicurezza", + "securityKeyRegisterDescription": "Collega la tua chiave di sicurezza e inserisci un nome per identificarla", + "securityKeyTwoFactorRequired": "Autenticazione a Due Fattori Richiesta", + "securityKeyTwoFactorDescription": "Inserisci il codice di autenticazione a due fattori per registrare la chiave di sicurezza", + "securityKeyTwoFactorRemoveDescription": "Inserisci il codice di autenticazione a due fattori per rimuovere la chiave di sicurezza", + "securityKeyTwoFactorCode": "Codice a Due Fattori", + "securityKeyRemoveTitle": "Rimuovi Chiave di Sicurezza", + "securityKeyRemoveDescription": "Inserisci la tua password per rimuovere la chiave di sicurezza \"{name}\"", + "securityKeyNoKeysRegistered": "Nessuna chiave di sicurezza registrata", + "securityKeyNoKeysDescription": "Aggiungi una chiave di sicurezza per migliorare la sicurezza del tuo account", + "createDomainRequired": "Dominio richiesto", + "createDomainAddDnsRecords": "Aggiungi Record DNS", + "createDomainAddDnsRecordsDescription": "Aggiungi i seguenti record DNS al tuo provider di domini per completare la configurazione.", + "createDomainNsRecords": "Record NS", + "createDomainRecord": "Record", + "createDomainType": "Tipo:", + "createDomainName": "Nome:", + "createDomainValue": "Valore:", + "createDomainCnameRecords": "Record CNAME", + "createDomainARecords": "Record A", + "createDomainRecordNumber": "Record {number}", + "createDomainTxtRecords": "Record TXT", + "createDomainSaveTheseRecords": "Salva Questi Record", + "createDomainSaveTheseRecordsDescription": "Assicurati di salvare questi record DNS poiché non li vedrai più.", + "createDomainDnsPropagation": "Propagazione DNS", + "createDomainDnsPropagationDescription": "Le modifiche DNS possono richiedere del tempo per propagarsi in Internet. Questo può richiedere da pochi minuti a 48 ore, a seconda del tuo provider DNS e delle impostazioni TTL.", + "resourcePortRequired": "Numero di porta richiesto per risorse non-HTTP", + "resourcePortNotAllowed": "Il numero di porta non deve essere impostato per risorse HTTP", + "signUpTerms": { + "IAgreeToThe": "Accetto i", + "termsOfService": "termini di servizio", + "and": "e", + "privacyPolicy": "informativa sulla privacy" + }, + "siteRequired": "Il sito è richiesto.", + "olmTunnel": "Olm Tunnel", + "olmTunnelDescription": "Usa Olm per la connettività client", + "errorCreatingClient": "Errore nella creazione del client", + "clientDefaultsNotFound": "Impostazioni predefinite del client non trovate", + "createClient": "Crea Cliente", + "createClientDescription": "Crea un nuovo cliente per connettersi ai tuoi siti", + "seeAllClients": "Vedi Tutti i Clienti", + "clientInformation": "Informazioni sul Cliente", + "clientNamePlaceholder": "Nome Cliente", + "address": "Indirizzo", + "subnetPlaceholder": "Sottorete", + "addressDescription": "L'indirizzo che questo cliente utilizzerà per la connettività", + "selectSites": "Seleziona siti", + "sitesDescription": "Il cliente avrà connettività ai siti selezionati", + "clientInstallOlm": "Installa Olm", + "clientInstallOlmDescription": "Avvia Olm sul tuo sistema", + "clientOlmCredentials": "Credenziali Olm", + "clientOlmCredentialsDescription": "Ecco come Olm si autenticherà con il server", + "olmEndpoint": "Endpoint Olm", + "olmId": "ID Olm", + "olmSecretKey": "Chiave Segreta Olm", + "clientCredentialsSave": "Salva le Tue Credenziali", + "clientCredentialsSaveDescription": "Potrai vederlo solo una volta. Assicurati di copiarlo in un luogo sicuro.", + "generalSettingsDescription": "Configura le impostazioni generali per questo cliente", + "clientUpdated": "Cliente aggiornato", + "clientUpdatedDescription": "Il cliente è stato aggiornato.", + "clientUpdateFailed": "Impossibile aggiornare il cliente", + "clientUpdateError": "Si è verificato un errore durante l'aggiornamento del cliente.", + "sitesFetchFailed": "Impossibile recuperare i siti", + "sitesFetchError": "Si è verificato un errore durante il recupero dei siti.", + "olmErrorFetchReleases": "Si è verificato un errore durante il recupero delle versioni di Olm.", + "olmErrorFetchLatest": "Si è verificato un errore durante il recupero dell'ultima versione di Olm.", + "remoteSubnets": "Sottoreti Remote", + "enterCidrRange": "Inserisci l'intervallo CIDR", + "remoteSubnetsDescription": "Aggiungi intervalli CIDR che possono accedere a questo sito da remoto. Usa il formato come 10.0.0.0/24 o 192.168.1.0/24.", + "resourceEnableProxy": "Abilita Proxy Pubblico", + "resourceEnableProxyDescription": "Abilita il proxy pubblico a questa risorsa. Consente l'accesso alla risorsa dall'esterno della rete tramite il cloud su una porta aperta. Richiede la configurazione di Traefik.", + "externalProxyEnabled": "Proxy Esterno Abilitato" } diff --git a/messages/ko-KR.json b/messages/ko-KR.json new file mode 100644 index 00000000..c70d34ff --- /dev/null +++ b/messages/ko-KR.json @@ -0,0 +1,1327 @@ +{ + "setupCreate": "조직, 사이트 및 리소스를 생성하십시오.", + "setupNewOrg": "새 조직", + "setupCreateOrg": "조직 생성", + "setupCreateResources": "리소스 생성", + "setupOrgName": "조직 이름", + "orgDisplayName": "이것은 귀하의 조직의 표시 이름입니다.", + "orgId": "조직 ID", + "setupIdentifierMessage": "이것은 귀하의 조직에 대한 고유 식별자입니다. 표시 이름과는 별개입니다.", + "setupErrorIdentifier": "조직 ID가 이미 사용 중입니다. 다른 것을 선택해 주세요.", + "componentsErrorNoMemberCreate": "현재 어떤 조직의 구성원도 아닙니다. 시작하려면 조직을 생성하세요.", + "componentsErrorNoMember": "현재 어떤 조직의 구성원도 아닙니다.", + "welcome": "판골린에 오신 것을 환영합니다.", + "welcomeTo": "환영합니다", + "componentsCreateOrg": "조직 생성", + "componentsMember": "당신은 {count, plural, =0 {조직이 없습니다} one {하나의 조직} other {# 개의 조직}}의 구성원입니다.", + "componentsInvalidKey": "유효하지 않거나 만료된 라이센스 키가 감지되었습니다. 모든 기능을 계속 사용하려면 라이센스 조건을 따르십시오.", + "dismiss": "해제", + "componentsLicenseViolation": "라이센스 위반: 이 서버는 {usedSites} 사이트를 사용하고 있으며, 이는 {maxSites} 사이트의 라이센스 한도를 초과합니다. 모든 기능을 계속 사용하려면 라이센스 조건을 따르십시오.", + "componentsSupporterMessage": "{tier}로 판골린을 지원해 주셔서 감사합니다!", + "inviteErrorNotValid": "죄송하지만, 접근하려는 초대가 수락되지 않았거나 더 이상 유효하지 않은 것 같습니다.", + "inviteErrorUser": "죄송하지만, 접근하려는 초대가 이 사용자에게 해당되지 않는 것 같습니다.", + "inviteLoginUser": "올바른 사용자로 로그인했는지 확인하십시오.", + "inviteErrorNoUser": "죄송하지만, 접근하려는 초대가 존재하지 않는 사용자에 대한 것인 것 같습니다.", + "inviteCreateUser": "먼저 계정을 생성해 주세요.", + "goHome": "홈으로 가기", + "inviteLogInOtherUser": "다른 사용자로 로그인", + "createAnAccount": "계정 만들기", + "inviteNotAccepted": "초대가 수락되지 않음", + "authCreateAccount": "시작하려면 계정을 생성하세요.", + "authNoAccount": "계정이 없으신가요?", + "email": "이메일", + "password": "비밀번호", + "confirmPassword": "비밀번호 확인", + "createAccount": "계정 생성", + "viewSettings": "설정 보기", + "delete": "삭제", + "name": "이름", + "online": "온라인", + "offline": "오프라인", + "site": "사이트", + "dataIn": "데이터 입력", + "dataOut": "데이터 출력", + "connectionType": "연결 유형", + "tunnelType": "터널 유형", + "local": "로컬", + "edit": "편집", + "siteConfirmDelete": "사이트 삭제 확인", + "siteDelete": "사이트 삭제", + "siteMessageRemove": "제거되면 사이트에 더 이상 접근할 수 없습니다. 사이트와 관련된 모든 리소스와 대상도 제거됩니다.", + "siteMessageConfirm": "확인을 위해 아래에 사이트 이름을 입력해 주세요.", + "siteQuestionRemove": "조직에서 사이트 {selectedSite}를 제거하시겠습니까?", + "siteManageSites": "사이트 관리", + "siteDescription": "안전한 터널을 통해 네트워크에 연결할 수 있도록 허용", + "siteCreate": "사이트 생성", + "siteCreateDescription2": "아래 단계를 따라 새 사이트를 생성하고 연결하십시오", + "siteCreateDescription": "리소스를 연결하기 위해 새 사이트를 생성하십시오.", + "close": "닫기", + "siteErrorCreate": "사이트 생성 오류", + "siteErrorCreateKeyPair": "키 쌍 또는 사이트 기본값을 찾을 수 없습니다", + "siteErrorCreateDefaults": "사이트 기본값을 찾을 수 없습니다", + "method": "방법", + "siteMethodDescription": "이것이 연결을 노출하는 방법입니다.", + "siteLearnNewt": "시스템에 Newt 설치하는 방법 배우기", + "siteSeeConfigOnce": "구성을 한 번만 볼 수 있습니다.", + "siteLoadWGConfig": "WireGuard 구성 로딩 중...", + "siteDocker": "Docker 배포 세부정보 확장", + "toggle": "전환", + "dockerCompose": "도커 컴포즈", + "dockerRun": "도커 실행", + "siteLearnLocal": "로컬 사이트는 터널링하지 않습니다. 자세히 알아보기", + "siteConfirmCopy": "구성을 복사했습니다.", + "searchSitesProgress": "사이트 검색...", + "siteAdd": "사이트 추가", + "siteInstallNewt": "Newt 설치", + "siteInstallNewtDescription": "시스템에서 Newt 실행하기", + "WgConfiguration": "WireGuard 구성", + "WgConfigurationDescription": "네트워크에 연결하기 위한 다음 구성을 사용하십시오.", + "operatingSystem": "운영 체제", + "commands": "명령", + "recommended": "추천", + "siteNewtDescription": "최고의 사용자 경험을 위해 Newt를 사용하십시오. Newt는 WireGuard를 기반으로 하며, 판골린 대시보드 내에서 개인 네트워크의 LAN 주소로 개인 리소스에 접근할 수 있도록 합니다.", + "siteRunsInDocker": "Docker에서 실행", + "siteRunsInShell": "macOS, Linux 및 Windows에서 셸에서 실행", + "siteErrorDelete": "사이트 삭제 오류", + "siteErrorUpdate": "사이트 업데이트에 실패했습니다", + "siteErrorUpdateDescription": "사이트 업데이트 중 오류가 발생했습니다.", + "siteUpdated": "사이트가 업데이트되었습니다", + "siteUpdatedDescription": "사이트가 업데이트되었습니다.", + "siteGeneralDescription": "이 사이트에 대한 일반 설정을 구성하세요.", + "siteSettingDescription": "사이트에서 설정을 구성하세요", + "siteSetting": "{siteName} 설정", + "siteNewtTunnel": "뉴트 터널 (추천)", + "siteNewtTunnelDescription": "네트워크에 대한 진입점을 생성하는 가장 쉬운 방법입니다. 추가 설정이 필요 없습니다.", + "siteWg": "기본 WireGuard", + "siteWgDescription": "모든 WireGuard 클라이언트를 사용하여 터널을 설정하세요. 수동 NAT 설정이 필요합니다.", + "siteLocalDescription": "로컬 리소스만 사용 가능합니다. 터널링이 없습니다.", + "siteSeeAll": "모든 사이트 보기", + "siteTunnelDescription": "사이트에 연결하는 방법을 결정하세요", + "siteNewtCredentials": "Newt 자격 증명", + "siteNewtCredentialsDescription": "이것이 Newt가 서버와 인증하는 방법입니다", + "siteCredentialsSave": "자격 증명 저장", + "siteCredentialsSaveDescription": "이것은 한 번만 볼 수 있습니다. 안전한 장소에 복사해 두세요.", + "siteInfo": "사이트 정보", + "status": "상태", + "shareTitle": "공유 링크 관리", + "shareDescription": "공유 가능한 링크를 생성하여 리소스에 대한 임시 또는 영구 액세스를 부여합니다.", + "shareSearch": "공유 링크 검색...", + "shareCreate": "공유 링크 생성", + "shareErrorDelete": "링크 삭제에 실패했습니다.", + "shareErrorDeleteMessage": "링크 삭제 중 오류가 발생했습니다.", + "shareDeleted": "링크가 삭제되었습니다.", + "shareDeletedDescription": "링크가 삭제되었습니다.", + "shareTokenDescription": "액세스 토큰은 쿼리 매개변수 또는 요청 헤더의 두 가지 방법으로 전달될 수 있습니다. 이는 인증된 액세스를 위해 클라이언트에서 모든 요청마다 전달되어야 합니다.", + "accessToken": "액세스 토큰", + "usageExamples": "사용 예", + "tokenId": "토큰 ID", + "requestHeades": "요청 헤더", + "queryParameter": "쿼리 매개변수", + "importantNote": "중요한 참고 사항", + "shareImportantDescription": "보안상의 이유로 가능한 경우 쿼리 매개변수보다 헤더를 사용하는 것이 권장됩니다. 쿼리 매개변수는 서버 로그나 브라우저 기록에 기록될 수 있습니다.", + "token": "토큰", + "shareTokenSecurety": "액세스 토큰을 안전하게 유지하세요. 공개적으로 접근 가능한 영역이나 클라이언트 측 코드에서 공유하지 마세요.", + "shareErrorFetchResource": "리소스를 가져오는 데 실패했습니다.", + "shareErrorFetchResourceDescription": "리소스를 가져오는 중 오류가 발생했습니다.", + "shareErrorCreate": "공유 링크 생성에 실패했습니다.", + "shareErrorCreateDescription": "공유 링크를 생성하는 동안 오류가 발생했습니다", + "shareCreateDescription": "이 링크가 있는 누구나 리소스에 접근할 수 있습니다.", + "shareTitleOptional": "제목 (선택 사항)", + "expireIn": "만료됨", + "neverExpire": "만료되지 않음", + "shareExpireDescription": "만료 시간은 링크가 사용 가능하고 리소스에 접근할 수 있는 기간입니다. 이 시간이 지나면 링크는 더 이상 작동하지 않으며, 이 링크를 사용한 사용자는 리소스에 대한 접근 권한을 잃게 됩니다.", + "shareSeeOnce": "이 링크는 한 번만 볼 수 있습니다. 반드시 복사해 두세요.", + "shareAccessHint": "이 링크가 있는 누구나 리소스에 접근할 수 있습니다. 주의해서 공유하세요.", + "shareTokenUsage": "액세스 토큰 사용 보기", + "createLink": "링크 생성", + "resourcesNotFound": "리소스가 발견되지 않았습니다.", + "resourceSearch": "리소스 검색", + "openMenu": "메뉴 열기", + "resource": "리소스", + "title": "제목", + "created": "생성됨", + "expires": "만료", + "never": "절대", + "shareErrorSelectResource": "리소스를 선택하세요", + "resourceTitle": "리소스 관리", + "resourceDescription": "개인 애플리케이션에 대한 보안 프록시 생성", + "resourcesSearch": "리소스 검색...", + "resourceAdd": "리소스 추가", + "resourceErrorDelte": "리소스 삭제 중 오류 발생", + "authentication": "인증", + "protected": "보호됨", + "notProtected": "보호되지 않음", + "resourceMessageRemove": "제거되면 리소스에 더 이상 접근할 수 없습니다. 리소스와 연결된 모든 대상도 제거됩니다.", + "resourceMessageConfirm": "확인을 위해 아래에 리소스의 이름을 입력하세요.", + "resourceQuestionRemove": "조직에서 리소스 {selectedResource}를 제거하시겠습니까?", + "resourceHTTP": "HTTPS 리소스", + "resourceHTTPDescription": "서브도메인 또는 기본 도메인을 사용하여 HTTPS를 통해 앱에 대한 요청을 프록시합니다.", + "resourceRaw": "원시 TCP/UDP 리소스", + "resourceRawDescription": "TCP/UDP를 통해 포트 번호를 사용하여 앱에 요청을 프록시합니다.", + "resourceCreate": "리소스 생성", + "resourceCreateDescription": "아래 단계를 따라 새 리소스를 생성하세요.", + "resourceSeeAll": "모든 리소스 보기", + "resourceInfo": "리소스 정보", + "resourceNameDescription": "이것은 리소스의 표시 이름입니다.", + "siteSelect": "사이트 선택", + "siteSearch": "사이트 검색", + "siteNotFound": "사이트를 찾을 수 없습니다.", + "siteSelectionDescription": "이 사이트는 리소스에 대한 연결을 제공합니다.", + "resourceType": "리소스 유형", + "resourceTypeDescription": "리소스에 접근하는 방법을 결정하세요", + "resourceHTTPSSettings": "HTTPS 설정", + "resourceHTTPSSettingsDescription": "리소스에 대한 HTTPS 접근 방식을 구성하십시오.", + "domainType": "도메인 유형", + "subdomain": "서브도메인", + "baseDomain": "기본 도메인", + "subdomnainDescription": "리소스에 접근할 수 있는 하위 도메인입니다.", + "resourceRawSettings": "TCP/UDP 설정", + "resourceRawSettingsDescription": "TCP/UDP를 통해 리소스에 접근하는 방법을 구성하세요.", + "protocol": "프로토콜", + "protocolSelect": "프로토콜 선택", + "resourcePortNumber": "포트 번호", + "resourcePortNumberDescription": "요청을 프록시하기 위한 외부 포트 번호입니다.", + "cancel": "취소", + "resourceConfig": "구성 스니펫", + "resourceConfigDescription": "TCP/UDP 리소스를 설정하기 위해 이 구성 스니펫을 복사하여 붙여넣으십시오.", + "resourceAddEntrypoints": "Traefik: 엔트리포인트 추가", + "resourceExposePorts": "Gerbil: Docker Compose에서 포트 노출", + "resourceLearnRaw": "TCP/UDP 리소스 구성 방법 알아보기", + "resourceBack": "리소스로 돌아가기", + "resourceGoTo": "리소스로 이동", + "resourceDelete": "리소스 삭제", + "resourceDeleteConfirm": "리소스 삭제 확인", + "visibility": "가시성", + "enabled": "활성화됨", + "disabled": "비활성화됨", + "general": "일반", + "generalSettings": "일반 설정", + "proxy": "프록시", + "rules": "규칙", + "resourceSettingDescription": "리소스의 설정을 구성하세요.", + "resourceSetting": "{resourceName} 설정", + "alwaysAllow": "항상 허용", + "alwaysDeny": "항상 거부", + "orgSettingsDescription": "조직의 일반 설정을 구성하세요", + "orgGeneralSettings": "조직 설정", + "orgGeneralSettingsDescription": "조직 세부정보 및 구성을 관리하세요.", + "saveGeneralSettings": "일반 설정 저장", + "saveSettings": "설정 저장", + "orgDangerZone": "위험 구역", + "orgDangerZoneDescription": "이 조직을 삭제하면 되돌릴 수 없습니다. 확실히 하세요.", + "orgDelete": "조직 삭제", + "orgDeleteConfirm": "조직 삭제 확인", + "orgMessageRemove": "이 작업은 되돌릴 수 없으며 모든 관련 데이터를 삭제합니다.", + "orgMessageConfirm": "확인을 위해 아래에 조직 이름을 입력하십시오.", + "orgQuestionRemove": "조직 {selectedOrg}을(를) 제거하시겠습니까?", + "orgUpdated": "조직이 업데이트되었습니다.", + "orgUpdatedDescription": "조직이 업데이트되었습니다.", + "orgErrorUpdate": "조직 업데이트에 실패했습니다.", + "orgErrorUpdateMessage": "조직을 업데이트하는 동안 오류가 발생했습니다.", + "orgErrorFetch": "조직을 가져오는 데 실패했습니다.", + "orgErrorFetchMessage": "조직을 나열하는 동안 오류가 발생했습니다", + "orgErrorDelete": "조직 삭제에 실패했습니다.", + "orgErrorDeleteMessage": "조직을 삭제하는 중 오류가 발생했습니다.", + "orgDeleted": "조직이 삭제되었습니다.", + "orgDeletedMessage": "조직과 그 데이터가 삭제되었습니다.", + "orgMissing": "조직 ID가 누락되었습니다", + "orgMissingMessage": "조직 ID 없이 초대장을 재생성할 수 없습니다.", + "accessUsersManage": "사용자 관리", + "accessUsersDescription": "사용자를 초대하고 역할에 추가하여 조직에 대한 접근을 관리하세요", + "accessUsersSearch": "사용자 검색...", + "accessUserCreate": "사용자 생성", + "accessUserRemove": "사용자 제거", + "username": "사용자 이름", + "identityProvider": "아이덴티티 공급자", + "role": "역할", + "nameRequired": "이름은 필수입니다", + "accessRolesManage": "역할 관리", + "accessRolesDescription": "조직에 대한 액세스를 관리할 역할 구성", + "accessRolesSearch": "역할 검색...", + "accessRolesAdd": "역할 추가", + "accessRoleDelete": "역할 삭제", + "description": "설명", + "inviteTitle": "열린 초대", + "inviteDescription": "다른 사용자에 대한 초대를 관리하세요", + "inviteSearch": "초대 검색...", + "minutes": "분", + "hours": "시간", + "days": "일", + "weeks": "주", + "months": "개월", + "years": "연도", + "day": "{count, plural, one {#일} other {#일}}", + "apiKeysTitle": "API 키 정보", + "apiKeysConfirmCopy2": "API 키를 복사했음을 확인해야 합니다.", + "apiKeysErrorCreate": "API 키 생성 오류", + "apiKeysErrorSetPermission": "권한 설정 오류", + "apiKeysCreate": "API 키 생성", + "apiKeysCreateDescription": "조직을 위한 새로운 API 키 생성", + "apiKeysGeneralSettings": "권한", + "apiKeysGeneralSettingsDescription": "이 API 키가 수행할 수 있는 작업 결정", + "apiKeysList": "귀하의 API 키", + "apiKeysSave": "API 키 저장", + "apiKeysSaveDescription": "이것은 한 번만 볼 수 있습니다. 안전한 장소에 복사해 두세요.", + "apiKeysInfo": "귀하의 API 키는 다음과 같습니다:", + "apiKeysConfirmCopy": "API 키를 복사했습니다", + "generate": "생성", + "done": "완료", + "apiKeysSeeAll": "모든 API 키 보기", + "apiKeysPermissionsErrorLoadingActions": "API 키 작업 로드 오류", + "apiKeysPermissionsErrorUpdate": "권한 설정 오류", + "apiKeysPermissionsUpdated": "권한이 업데이트되었습니다", + "apiKeysPermissionsUpdatedDescription": "권한이 업데이트되었습니다.", + "apiKeysPermissionsGeneralSettings": "권한", + "apiKeysPermissionsGeneralSettingsDescription": "이 API 키가 수행할 수 있는 작업 결정", + "apiKeysPermissionsSave": "권한 저장", + "apiKeysPermissionsTitle": "권한", + "apiKeys": "API 키", + "searchApiKeys": "API 키 검색...", + "apiKeysAdd": "API 키 생성", + "apiKeysErrorDelete": "API 키 삭제 오류", + "apiKeysErrorDeleteMessage": "API 키 삭제 오류", + "apiKeysQuestionRemove": "조직에서 API 키 {selectedApiKey}를 제거하시겠습니까?", + "apiKeysMessageRemove": "삭제되면 API 키를 더 이상 사용할 수 없습니다.", + "apiKeysMessageConfirm": "확인을 위해 아래에 API 키의 이름을 입력해 주세요.", + "apiKeysDeleteConfirm": "API 키 삭제 확인", + "apiKeysDelete": "API 키 삭제", + "apiKeysManage": "API 키 관리", + "apiKeysDescription": "API 키는 통합 API와 인증하는 데 사용됩니다.", + "apiKeysSettings": "{apiKeyName} 설정", + "userTitle": "모든 사용자 관리", + "userDescription": "시스템의 모든 사용자를 보고 관리합니다", + "userAbount": "사용자 관리에 대한 정보", + "userAbountDescription": "이 표는 시스템의 모든 루트 사용자 객체를 표시합니다. 각 사용자는 여러 조직에 속할 수 있습니다. 사용자를 조직에서 제거해도 루트 사용자 객체는 삭제되지 않으며, 시스템에 남아 있습니다. 사용자를 시스템에서 완전히 제거하려면 이 표의 삭제 작업을 사용하여 루트 사용자 객체를 삭제해야 합니다.", + "userServer": "서버 사용자", + "userSearch": "서버 사용자 검색 중...", + "userErrorDelete": "사용자 삭제 오류", + "userDeleteConfirm": "사용자 삭제 확인", + "userDeleteServer": "서버에서 사용자 삭제", + "userMessageRemove": "사용자가 모든 조직에서 제거되며 서버에서 완전히 삭제됩니다.", + "userMessageConfirm": "확인을 위해 아래에 사용자 이름을 입력하십시오.", + "userQuestionRemove": "정말로 {selectedUser}를 서버에서 영구적으로 삭제하시겠습니까?", + "licenseKey": "라이센스 키", + "valid": "유효", + "numberOfSites": "사이트 수", + "licenseKeySearch": "라이센스 키 검색 중...", + "licenseKeyAdd": "라이센스 키 추가", + "type": "유형", + "licenseKeyRequired": "라이센스 키가 필요합니다", + "licenseTermsAgree": "라이선스 조건에 동의해야 합니다.", + "licenseErrorKeyLoad": "라이센스 키를 로드하는 데 실패했습니다.", + "licenseErrorKeyLoadDescription": "라이센스 키 로드 중 오류가 발생했습니다.", + "licenseErrorKeyDelete": "라이센스 키 삭제에 실패했습니다.", + "licenseErrorKeyDeleteDescription": "라이센스 키 삭제 중 오류가 발생했습니다.", + "licenseKeyDeleted": "라이센스 키가 삭제되었습니다.", + "licenseKeyDeletedDescription": "라이센스 키가 삭제되었습니다.", + "licenseErrorKeyActivate": "라이센스 키 활성화에 실패했습니다.", + "licenseErrorKeyActivateDescription": "라이센스 키를 활성화하는 동안 오류가 발생했습니다", + "licenseAbout": "라이센스에 대한 정보", + "communityEdition": "커뮤니티 에디션", + "licenseAboutDescription": "이것은 상업적 환경에서 Pangolin을 사용하는 비즈니스 및 기업 사용자용입니다. 개인 용도로 Pangolin을 사용하는 경우 이 섹션을 무시할 수 있습니다.", + "licenseKeyActivated": "라이센스 키가 활성화되었습니다", + "licenseKeyActivatedDescription": "라이센스 키가 성공적으로 활성화되었습니다.", + "licenseErrorKeyRecheck": "라이센스 키 재확인 실패", + "licenseErrorKeyRecheckDescription": "라이센스 키를 재확인하는 중 오류가 발생했습니다.", + "licenseErrorKeyRechecked": "라이센스 키가 재확인되었습니다.", + "licenseErrorKeyRecheckedDescription": "모든 라이센스 키가 재검사되었습니다.", + "licenseActivateKey": "라이센스 키 활성화", + "licenseActivateKeyDescription": "라이센스 키를 입력하여 활성화하십시오.", + "licenseActivate": "라이센스 활성화", + "licenseAgreement": "이 상자를 체크함으로써, 귀하는 귀하의 라이선스 키와 관련된 계층에 해당하는 라이선스 조건을 읽고 동의했음을 확인합니다.", + "fossorialLicense": "Fossorial 상업 라이선스 및 구독 약관 보기", + "licenseMessageRemove": "이 작업은 라이센스 키와 그에 의해 부여된 모든 관련 권한을 제거합니다.", + "licenseMessageConfirm": "확인을 위해 아래에 라이센스 키를 입력하세요.", + "licenseQuestionRemove": "라이센스 키 {selectedKey}를 삭제하시겠습니까?", + "licenseKeyDelete": "라이센스 키 삭제", + "licenseKeyDeleteConfirm": "라이센스 키 삭제 확인", + "licenseTitle": "라이선스 상태 관리", + "licenseTitleDescription": "시스템에서 라이센스 키를 보고 관리합니다.", + "licenseHost": "호스트 라이센스", + "licenseHostDescription": "호스트의 주요 라이센스 키를 관리합니다.", + "licensedNot": "라이센스 없음", + "hostId": "호스트 ID", + "licenseReckeckAll": "모든 키 재확인", + "licenseSiteUsage": "사이트 사용량", + "licenseSiteUsageDecsription": "이 라이센스를 사용하는 사이트 수를 확인하세요.", + "licenseNoSiteLimit": "라이선스가 없는 호스트를 사용하는 사이트 수에 제한이 없습니다.", + "licensePurchase": "라이센스 구매", + "licensePurchaseSites": "추가 사이트 구매", + "licenseSitesUsedMax": "{maxSites}개의 사이트 중 {usedSites}개 사용 중", + "licenseSitesUsed": "시스템에 {count, plural, =0 {# 사이트} one {# 사이트} other {# 사이트}}가 있습니다.", + "licensePurchaseDescription": "구매할 사이트 수를 선택하세요 {selectedMode, select, license {라이센스를 구매합니다. 나중에 더 많은 사이트를 추가할 수 있습니다.} other {기존 라이센스에 추가합니다.}}", + "licenseFee": "라이선스 요금", + "licensePriceSite": "사이트당 가격", + "total": "총계", + "licenseContinuePayment": "결제로 진행", + "pricingPage": "가격 페이지", + "pricingPortal": "구매 포털 보기", + "licensePricingPage": "가장 최신의 가격 및 할인 정보를 보려면 방문하십시오 ", + "invite": "초대", + "inviteRegenerate": "초대장 재생성", + "inviteRegenerateDescription": "이전 초대를 취소하고 새로 생성", + "inviteRemove": "초대 제거", + "inviteRemoveError": "초대 제거 실패", + "inviteRemoveErrorDescription": "초대를 제거하는 동안 오류가 발생했습니다.", + "inviteRemoved": "초대가 제거되었습니다.", + "inviteRemovedDescription": "{email}에 대한 초대가 삭제되었습니다.", + "inviteQuestionRemove": "초대 {email}를 제거하시겠습니까?", + "inviteMessageRemove": "한 번 제거되면 이 초대는 더 이상 유효하지 않습니다. 나중에 사용자를 다시 초대할 수 있습니다.", + "inviteMessageConfirm": "확인을 위해 아래 초대의 이메일 주소를 입력해 주세요.", + "inviteQuestionRegenerate": "{email}에 대한 초대장을 다시 생성하시겠습니까? 이전 초대장은 취소됩니다.", + "inviteRemoveConfirm": "초대 제거 확인", + "inviteRegenerated": "초대 재생성됨", + "inviteSent": "새 초대장이 {email}로 전송되었습니다.", + "inviteSentEmail": "사용자에게 이메일 알림 전송", + "inviteGenerate": "{email}에 대한 새로운 초대장이 생성되었습니다.", + "inviteDuplicateError": "초대 중복", + "inviteDuplicateErrorDescription": "이 사용자에 대한 초대가 이미 존재합니다.", + "inviteRateLimitError": "요청 한도 초과", + "inviteRateLimitErrorDescription": "시간당 3회 재생성 한도를 초과했습니다. 나중에 다시 시도하세요.", + "inviteRegenerateError": "초대 재생성 실패", + "inviteRegenerateErrorDescription": "초대장을 재생성하는 동안 오류가 발생했습니다.", + "inviteValidityPeriod": "유효 기간", + "inviteValidityPeriodSelect": "유효 기간 선택", + "inviteRegenerateMessage": "초대장이 다시 생성되었습니다. 사용자는 아래 링크에 접속하여 초대장을 수락해야 합니다.", + "inviteRegenerateButton": "재생성", + "expiresAt": "만료 시간", + "accessRoleUnknown": "알 수 없는 역할", + "placeholder": "자리 표시자", + "userErrorOrgRemove": "사용자를 제거하지 못했습니다", + "userErrorOrgRemoveDescription": "사용자를 제거하는 동안 오류가 발생했습니다.", + "userOrgRemoved": "사용자가 제거되었습니다.", + "userOrgRemovedDescription": "사용자 {email}가 조직에서 제거되었습니다.", + "userQuestionOrgRemove": "{email}을 조직에서 제거하시겠습니까?", + "userMessageOrgRemove": "이 사용자가 제거되면 더 이상 조직에 접근할 수 없습니다. 나중에 다시 초대할 수 있지만, 초대를 다시 수락해야 합니다.", + "userMessageOrgConfirm": "확인을 위해 아래에 사용자 이름을 입력하세요.", + "userRemoveOrgConfirm": "사용자 제거 확인", + "userRemoveOrg": "조직에서 사용자 제거", + "users": "사용자", + "accessRoleMember": "회원", + "accessRoleOwner": "소유자", + "userConfirmed": "확인됨", + "idpNameInternal": "내부", + "emailInvalid": "유효하지 않은 이메일 주소입니다.", + "inviteValidityDuration": "지속 시간을 선택하십시오.", + "accessRoleSelectPlease": "역할을 선택하세요", + "usernameRequired": "사용자 이름은 필수입니다.", + "idpSelectPlease": "신원 제공자를 선택하십시오", + "idpGenericOidc": "일반 OAuth2/OIDC 공급자.", + "accessRoleErrorFetch": "역할을 가져오는 데 실패했습니다.", + "accessRoleErrorFetchDescription": "역할을 가져오는 중 오류가 발생했습니다.", + "idpErrorFetch": "신원 제공자를 가져오는 데 실패했습니다", + "idpErrorFetchDescription": "신원 공급자를 가져오는 중 오류가 발생했습니다.", + "userErrorExists": "사용자가 이미 존재합니다.", + "userErrorExistsDescription": "이 사용자는 이미 조직의 구성원입니다.", + "inviteError": "사용자 초대에 실패했습니다", + "inviteErrorDescription": "사용자를 초대하는 동안 오류가 발생했습니다.", + "userInvited": "사용자가 초대되었습니다.", + "userInvitedDescription": "사용자가 성공적으로 초대되었습니다.", + "userErrorCreate": "사용자 생성에 실패했습니다.", + "userErrorCreateDescription": "사용자를 생성하는 동안 오류가 발생했습니다.", + "userCreated": "사용자가 생성되었습니다.", + "userCreatedDescription": "사용자가 성공적으로 생성되었습니다.", + "userTypeInternal": "내부 사용자", + "userTypeInternalDescription": "사용자를 초대하여 귀하의 조직에 직접 참여하게 하세요.", + "userTypeExternal": "외부 사용자", + "userTypeExternalDescription": "외부 신원 공급자를 사용하여 사용자를 생성하세요.", + "accessUserCreateDescription": "새 사용자를 만들기 위한 아래 단계를 따르세요.", + "userSeeAll": "모든 사용자 보기", + "userTypeTitle": "사용자 유형", + "userTypeDescription": "사용자를 생성하는 방법을 결정하세요.", + "userSettings": "사용자 정보", + "userSettingsDescription": "새 사용자에 대한 세부정보를 입력하십시오.", + "inviteEmailSent": "사용자에게 초대 이메일 보내기", + "inviteValid": "유효 기간", + "selectDuration": "지속 시간 선택", + "accessRoleSelect": "역할 선택", + "inviteEmailSentDescription": "아래의 접근 링크와 함께 사용자에게 이메일이 전송되었습니다. 사용자는 초대를 수락하기 위해 링크에 접근해야 합니다.", + "inviteSentDescription": "사용자가 초대되었습니다. 초대를 수락하려면 아래 링크에 접속해야 합니다.", + "inviteExpiresIn": "초대는 {days, plural, one {#일} other {#일}} 후에 만료됩니다.", + "idpTitle": "아이덴티티 공급자", + "idpSelect": "외부 사용자를 위한 아이덴티티 공급자를 선택하십시오", + "idpNotConfigured": "구성된 아이덴티티 공급자가 없습니다. 외부 사용자를 생성하기 전에 아이덴티티 공급자를 구성하십시오.", + "usernameUniq": "선택한 아이덴티티 공급자에 존재하는 고유한 사용자 이름과 일치해야 합니다.", + "emailOptional": "이메일 (선택 사항)", + "nameOptional": "이름 (선택 사항)", + "accessControls": "접근 제어", + "userDescription2": "이 사용자의 설정 관리", + "accessRoleErrorAdd": "사용자를 역할에 추가하는 데 실패했습니다.", + "accessRoleErrorAddDescription": "사용자를 역할에 추가하는 동안 오류가 발생했습니다.", + "userSaved": "사용자 저장됨", + "userSavedDescription": "사용자가 업데이트되었습니다.", + "accessControlsDescription": "이 사용자가 조직에서 접근하고 수행할 수 있는 작업을 관리하세요", + "accessControlsSubmit": "접근 제어 저장", + "roles": "역할", + "accessUsersRoles": "사용자 및 역할 관리", + "accessUsersRolesDescription": "사용자를 초대하고 역할에 추가하여 조직에 대한 접근을 관리하세요", + "key": "키", + "createdAt": "생성일", + "proxyErrorInvalidHeader": "잘못된 사용자 정의 호스트 헤더 값입니다. 도메인 이름 형식을 사용하거나 사용자 정의 호스트 헤더를 해제하려면 비워 두십시오.", + "proxyErrorTls": "유효하지 않은 TLS 서버 이름입니다. 도메인 이름 형식을 사용하거나 비워 두어 TLS 서버 이름을 제거하십시오.", + "proxyEnableSSL": "SSL 활성화 (https)", + "targetErrorFetch": "대상 가져오는 데 실패했습니다.", + "targetErrorFetchDescription": "대상 가져오는 중 오류가 발생했습니다", + "siteErrorFetch": "리소스를 가져오는 데 실패했습니다", + "siteErrorFetchDescription": "리소스를 가져오는 동안 오류가 발생했습니다", + "targetErrorDuplicate": "중복 대상", + "targetErrorDuplicateDescription": "이 설정을 가진 대상이 이미 존재합니다", + "targetWireGuardErrorInvalidIp": "유효하지 않은 대상 IP", + "targetWireGuardErrorInvalidIpDescription": "대상 IP는 사이트 서브넷 내에 있어야 합니다.", + "targetsUpdated": "대상 업데이트됨", + "targetsUpdatedDescription": "대상 및 설정이 성공적으로 업데이트되었습니다.", + "targetsErrorUpdate": "대상 업데이트 실패", + "targetsErrorUpdateDescription": "대상 업데이트 중 오류가 발생했습니다.", + "targetTlsUpdate": "TLS 설정이 업데이트되었습니다.", + "targetTlsUpdateDescription": "TLS 설정이 성공적으로 업데이트되었습니다.", + "targetErrorTlsUpdate": "TLS 설정 업데이트에 실패했습니다.", + "targetErrorTlsUpdateDescription": "TLS 설정을 업데이트하는 동안 오류가 발생했습니다", + "proxyUpdated": "프록시 설정이 업데이트되었습니다.", + "proxyUpdatedDescription": "프록시 설정이 성공적으로 업데이트되었습니다", + "proxyErrorUpdate": "프록시 설정 업데이트에 실패했습니다.", + "proxyErrorUpdateDescription": "프록시 설정을 업데이트하는 동안 오류가 발생했습니다", + "targetAddr": "IP / 호스트 이름", + "targetPort": "포트", + "targetProtocol": "프로토콜", + "targetTlsSettings": "보안 연결 구성", + "targetTlsSettingsDescription": "리소스에 대한 SSL/TLS 설정 구성", + "targetTlsSettingsAdvanced": "고급 TLS 설정", + "targetTlsSni": "TLS 서버 이름 (SNI)", + "targetTlsSniDescription": "SNI에 사용할 TLS 서버 이름. 기본값을 사용하려면 비워 두십시오.", + "targetTlsSubmit": "설정 저장", + "targets": "대상 구성", + "targetsDescription": "서비스로 트래픽을 라우팅할 대상을 설정하십시오", + "targetStickySessions": "스티키 세션 활성화", + "targetStickySessionsDescription": "세션 전체 동안 동일한 백엔드 대상을 유지합니다.", + "methodSelect": "선택 방법", + "targetSubmit": "대상 추가", + "targetNoOne": "대상이 없습니다. 양식을 사용하여 대상을 추가하세요.", + "targetNoOneDescription": "위에 하나 이상의 대상을 추가하면 로드 밸런싱이 활성화됩니다.", + "targetsSubmit": "대상 저장", + "proxyAdditional": "추가 프록시 설정", + "proxyAdditionalDescription": "리소스가 프록시 설정을 처리하는 방법 구성", + "proxyCustomHeader": "사용자 정의 호스트 헤더", + "proxyCustomHeaderDescription": "요청을 프록시할 때 설정할 호스트 헤더입니다. 기본값을 사용하려면 비워 두십시오.", + "proxyAdditionalSubmit": "프록시 설정 저장", + "subnetMaskErrorInvalid": "유효하지 않은 서브넷 마스크입니다. 0에서 32 사이여야 합니다.", + "ipAddressErrorInvalidFormat": "잘못된 IP 주소 형식", + "ipAddressErrorInvalidOctet": "유효하지 않은 IP 주소 옥텟", + "path": "경로", + "ipAddressRange": "IP 범위", + "rulesErrorFetch": "규칙을 가져오는 데 실패했습니다.", + "rulesErrorFetchDescription": "규칙을 가져오는 중 오류가 발생했습니다", + "rulesErrorDuplicate": "중복 규칙", + "rulesErrorDuplicateDescription": "이 설정을 가진 규칙이 이미 존재합니다.", + "rulesErrorInvalidIpAddressRange": "유효하지 않은 CIDR", + "rulesErrorInvalidIpAddressRangeDescription": "유효한 CIDR 값을 입력하십시오.", + "rulesErrorInvalidUrl": "유효하지 않은 URL 경로", + "rulesErrorInvalidUrlDescription": "유효한 URL 경로 값을 입력해 주세요.", + "rulesErrorInvalidIpAddress": "유효하지 않은 IP", + "rulesErrorInvalidIpAddressDescription": "유효한 IP 주소를 입력하세요", + "rulesErrorUpdate": "규칙 업데이트에 실패했습니다.", + "rulesErrorUpdateDescription": "규칙 업데이트 중 오류가 발생했습니다.", + "rulesUpdated": "규칙 활성화", + "rulesUpdatedDescription": "규칙 평가가 업데이트되었습니다", + "rulesMatchIpAddressRangeDescription": "CIDR 형식으로 주소를 입력하세요 (예: 103.21.244.0/22)", + "rulesMatchIpAddress": "IP 주소를 입력하세요 (예: 103.21.244.12)", + "rulesMatchUrl": "URL 경로 또는 패턴을 입력하세요 (예: /api/v1/todos 또는 /api/v1/*)", + "rulesErrorInvalidPriority": "유효하지 않은 우선순위", + "rulesErrorInvalidPriorityDescription": "유효한 우선 순위를 입력하세요.", + "rulesErrorDuplicatePriority": "중복 우선순위", + "rulesErrorDuplicatePriorityDescription": "고유한 우선 순위를 입력하십시오.", + "ruleUpdated": "규칙이 업데이트되었습니다", + "ruleUpdatedDescription": "규칙이 성공적으로 업데이트되었습니다", + "ruleErrorUpdate": "작업 실패", + "ruleErrorUpdateDescription": "저장 작업 중 오류가 발생했습니다.", + "rulesPriority": "우선순위", + "rulesAction": "작업", + "rulesMatchType": "일치 유형", + "value": "값", + "rulesAbout": "규칙에 대한 정보", + "rulesAboutDescription": "규칙을 사용하면 IP 주소 또는 URL 경로를 기준으로 리소스에 대한 액세스를 제어할 수 있습니다. IP 주소 또는 URL 경로를 기준으로 액세스를 허용하거나 거부하는 규칙을 만들 수 있습니다.", + "rulesActions": "작업", + "rulesActionAlwaysAllow": "항상 허용: 모든 인증 방법 우회", + "rulesActionAlwaysDeny": "항상 거부: 모든 요청을 차단합니다. 인증을 시도할 수 없습니다.", + "rulesMatchCriteria": "일치 기준", + "rulesMatchCriteriaIpAddress": "특정 IP 주소와 일치", + "rulesMatchCriteriaIpAddressRange": "CIDR 표기법으로 IP 주소 범위를 일치시킵니다", + "rulesMatchCriteriaUrl": "URL 경로 또는 패턴 일치", + "rulesEnable": "규칙 활성화", + "rulesEnableDescription": "이 리소스에 대한 규칙 평가를 활성화하거나 비활성화합니다.", + "rulesResource": "리소스 규칙 구성", + "rulesResourceDescription": "리소스에 대한 접근을 제어하는 규칙 구성", + "ruleSubmit": "규칙 추가", + "rulesNoOne": "규칙이 없습니다. 양식을 사용하여 규칙을 추가하십시오.", + "rulesOrder": "규칙은 우선 순위에 따라 오름차순으로 평가됩니다.", + "rulesSubmit": "규칙 저장", + "resourceErrorCreate": "리소스 생성 오류", + "resourceErrorCreateDescription": "리소스를 생성하는 중 오류가 발생했습니다.", + "resourceErrorCreateMessage": "리소스 생성 오류:", + "resourceErrorCreateMessageDescription": "예기치 않은 오류가 발생했습니다.", + "sitesErrorFetch": "사이트를 가져오는 중 오류가 발생했습니다.", + "sitesErrorFetchDescription": "사이트를 가져오는 중 오류가 발생했습니다", + "domainsErrorFetch": "도메인 가져오기 오류", + "domainsErrorFetchDescription": "도메인을 가져오는 중 오류가 발생했습니다.", + "none": "없음", + "unknown": "알 수 없음", + "resources": "리소스", + "resourcesDescription": "리소스는 개인 네트워크에서 실행 중인 애플리케이션에 대한 프록시입니다. 개인 네트워크에서 HTTP/HTTPS 또는 원시 TCP/UDP 서비스에 대한 리소스를 생성하십시오. 각 리소스는 암호화된 WireGuard 터널을 통해 개인적이고 안전한 연결을 가능하게 하려면 사이트에 연결되어야 합니다.", + "resourcesWireGuardConnect": "WireGuard 암호화를 통한 안전한 연결", + "resourcesMultipleAuthenticationMethods": "다중 인증 방법 구성", + "resourcesUsersRolesAccess": "사용자 및 역할 기반 접근 제어", + "resourcesErrorUpdate": "리소스를 전환하는 데 실패했습니다.", + "resourcesErrorUpdateDescription": "리소스를 업데이트하는 동안 오류가 발생했습니다.", + "access": "접속", + "shareLink": "{resource} 공유 링크", + "resourceSelect": "리소스 선택", + "shareLinks": "공유 링크", + "share": "공유 가능한 링크", + "shareDescription2": "리소스에 대한 공유 가능한 링크를 생성하세요. 링크는 리소스에 대한 임시 또는 무제한 액세스를 제공합니다. 링크를 생성할 때 만료 기간을 설정할 수 있습니다.", + "shareEasyCreate": "생성하고 공유하기 쉬움", + "shareConfigurableExpirationDuration": "구성 가능한 만료 기간", + "shareSecureAndRevocable": "안전하고 철회 가능", + "nameMin": "이름은 최소 {len}자 이상이어야 합니다.", + "nameMax": "이름은 {len}자보다 길 수 없습니다.", + "sitesConfirmCopy": "구성을 복사했는지 확인하십시오.", + "unknownCommand": "알 수 없는 명령", + "newtErrorFetchReleases": "릴리스 정보를 가져오는 데 실패했습니다: {err}", + "newtErrorFetchLatest": "최신 릴리스를 가져오는 중 오류 발생: {err}", + "newtEndpoint": "Newt 엔드포인트", + "newtId": "뉴트 ID", + "newtSecretKey": "Newt 비밀 키", + "architecture": "아키텍처", + "sites": "사이트", + "siteWgAnyClients": "WireGuard 클라이언트를 사용하여 연결하십시오. 피어 IP를 사용하여 내부 리소스에 접근해야 합니다.", + "siteWgCompatibleAllClients": "모든 WireGuard 클라이언트와 호환", + "siteWgManualConfigurationRequired": "수동 구성이 필요합니다.", + "userErrorNotAdminOrOwner": "사용자는 관리자 또는 소유자가 아닙니다.", + "pangolinSettings": "설정 - 판골린", + "accessRoleYour": "귀하의 역할:", + "accessRoleSelect2": "역할 선택", + "accessUserSelect": "사용자를 선택하세요.", + "otpEmailEnter": "이메일을 입력하세요", + "otpEmailEnterDescription": "입력 필드에 입력한 후 Enter 키를 눌러 이메일을 추가합니다.", + "otpEmailErrorInvalid": "유효하지 않은 이메일 주소입니다. 와일드카드(*)는 전체 로컬 부분이어야 합니다.", + "otpEmailSmtpRequired": "SMTP 필요", + "otpEmailSmtpRequiredDescription": "일회성 비밀번호 인증을 사용하려면 서버에서 SMTP가 활성화되어 있어야 합니다.", + "otpEmailTitle": "일회용 비밀번호", + "otpEmailTitleDescription": "리소스 접근을 위한 이메일 기반 인증 필요", + "otpEmailWhitelist": "이메일 화이트리스트", + "otpEmailWhitelistList": "화이트리스트된 이메일", + "otpEmailWhitelistListDescription": "이 이메일 주소를 가진 사용자만 이 리소스에 접근할 수 있습니다. 그들은 이메일로 전송된 일회용 비밀번호를 입력하라는 메시지를 받게 됩니다. 도메인에서 모든 이메일 주소를 허용하기 위해 와일드카드(*@example.com)를 사용할 수 있습니다.", + "otpEmailWhitelistSave": "허용 목록 저장", + "passwordAdd": "비밀번호 추가", + "passwordRemove": "비밀번호 제거", + "pincodeAdd": "PIN 코드 추가", + "pincodeRemove": "PIN 코드 제거", + "resourceAuthMethods": "인증 방법", + "resourceAuthMethodsDescriptions": "추가 인증 방법을 통해 리소스에 대한 액세스 허용", + "resourceAuthSettingsSave": "성공적으로 저장되었습니다.", + "resourceAuthSettingsSaveDescription": "인증 설정이 저장되었습니다", + "resourceErrorAuthFetch": "데이터를 가져오는 데 실패했습니다.", + "resourceErrorAuthFetchDescription": "데이터를 가져오는 중 오류가 발생했습니다.", + "resourceErrorPasswordRemove": "리소스 비밀번호 제거 오류", + "resourceErrorPasswordRemoveDescription": "리소스 비밀번호를 제거하는 동안 오류가 발생했습니다.", + "resourceErrorPasswordSetup": "리소스 비밀번호 설정 오류", + "resourceErrorPasswordSetupDescription": "리소스 비밀번호 설정 중 오류가 발생했습니다", + "resourceErrorPincodeRemove": "리소스 핀 코드 제거 오류", + "resourceErrorPincodeRemoveDescription": "리소스 핀코드를 제거하는 중 오류가 발생했습니다.", + "resourceErrorPincodeSetup": "리소스 PIN 코드 설정 중 오류가 발생했습니다.", + "resourceErrorPincodeSetupDescription": "리소스 PIN 코드를 설정하는 동안 오류가 발생했습니다.", + "resourceErrorUsersRolesSave": "역할 설정에 실패했습니다.", + "resourceErrorUsersRolesSaveDescription": "역할 설정 중 오류가 발생했습니다.", + "resourceErrorWhitelistSave": "화이트리스트 저장에 실패했습니다.", + "resourceErrorWhitelistSaveDescription": "화이트리스트를 저장하는 동안 오류가 발생했습니다.", + "resourcePasswordSubmit": "비밀번호 보호 활성화", + "resourcePasswordProtection": "비밀번호 보호 {status}", + "resourcePasswordRemove": "리소스 비밀번호가 제거되었습니다", + "resourcePasswordRemoveDescription": "리소스 비밀번호가 성공적으로 제거되었습니다.", + "resourcePasswordSetup": "리소스 비밀번호 설정됨", + "resourcePasswordSetupDescription": "리소스 비밀번호가 성공적으로 설정되었습니다.", + "resourcePasswordSetupTitle": "비밀번호 설정", + "resourcePasswordSetupTitleDescription": "이 리소스를 보호하기 위해 비밀번호를 설정하세요.", + "resourcePincode": "PIN 코드", + "resourcePincodeSubmit": "PIN 코드 보호 활성화", + "resourcePincodeProtection": "PIN 코드 보호 {상태}", + "resourcePincodeRemove": "리소스 핀코드가 제거되었습니다.", + "resourcePincodeRemoveDescription": "리소스 비밀번호가 성공적으로 제거되었습니다.", + "resourcePincodeSetup": "리소스 PIN 코드가 설정되었습니다", + "resourcePincodeSetupDescription": "리소스 핀코드가 성공적으로 설정되었습니다", + "resourcePincodeSetupTitle": "핀코드 설정", + "resourcePincodeSetupTitleDescription": "이 리소스를 보호하기 위해 핀 코드를 설정하십시오.", + "resourceRoleDescription": "관리자는 항상 이 리소스에 접근할 수 있습니다.", + "resourceUsersRoles": "사용자 및 역할", + "resourceUsersRolesDescription": "이 리소스를 방문할 수 있는 사용자 및 역할을 구성하십시오", + "resourceUsersRolesSubmit": "사용자 및 역할 저장", + "resourceWhitelistSave": "성공적으로 저장되었습니다.", + "resourceWhitelistSaveDescription": "허용 목록 설정이 저장되었습니다.", + "ssoUse": "플랫폼 SSO 사용", + "ssoUseDescription": "기존 사용자는 이 기능이 활성화된 모든 리소스에 대해 한 번만 로그인하면 됩니다.", + "proxyErrorInvalidPort": "유효하지 않은 포트 번호", + "subdomainErrorInvalid": "잘못된 하위 도메인", + "domainErrorFetch": "도메인 가져오기 오류", + "domainErrorFetchDescription": "도메인을 가져오는 중 오류가 발생했습니다.", + "resourceErrorUpdate": "리소스 업데이트에 실패했습니다.", + "resourceErrorUpdateDescription": "리소스를 업데이트하는 동안 오류가 발생했습니다.", + "resourceUpdated": "리소스가 업데이트되었습니다.", + "resourceUpdatedDescription": "리소스가 성공적으로 업데이트되었습니다.", + "resourceErrorTransfer": "리소스 전송에 실패했습니다", + "resourceErrorTransferDescription": "리소스를 전송하는 동안 오류가 발생했습니다", + "resourceTransferred": "리소스가 전송되었습니다.", + "resourceTransferredDescription": "리소스가 성공적으로 전송되었습니다.", + "resourceErrorToggle": "리소스를 전환하는 데 실패했습니다.", + "resourceErrorToggleDescription": "리소스를 업데이트하는 동안 오류가 발생했습니다.", + "resourceVisibilityTitle": "가시성", + "resourceVisibilityTitleDescription": "리소스 가시성을 완전히 활성화하거나 비활성화", + "resourceGeneral": "일반 설정", + "resourceGeneralDescription": "이 리소스에 대한 일반 설정을 구성하십시오.", + "resourceEnable": "리소스 활성화", + "resourceTransfer": "리소스 전송", + "resourceTransferDescription": "이 리소스를 다른 사이트로 전송", + "resourceTransferSubmit": "리소스 전송", + "siteDestination": "대상 사이트", + "searchSites": "사이트 검색", + "accessRoleCreate": "역할 생성", + "accessRoleCreateDescription": "사용자를 그룹화하고 권한을 관리하기 위해 새 역할을 생성하세요.", + "accessRoleCreateSubmit": "역할 생성", + "accessRoleCreated": "역할이 생성되었습니다.", + "accessRoleCreatedDescription": "역할이 성공적으로 생성되었습니다.", + "accessRoleErrorCreate": "역할 생성 실패", + "accessRoleErrorCreateDescription": "역할 생성 중 오류가 발생했습니다.", + "accessRoleErrorNewRequired": "새 역할이 필요합니다.", + "accessRoleErrorRemove": "역할 제거에 실패했습니다.", + "accessRoleErrorRemoveDescription": "역할을 제거하는 동안 오류가 발생했습니다.", + "accessRoleName": "역할 이름", + "accessRoleQuestionRemove": "{name} 역할을 삭제하려고 합니다. 이 작업은 취소할 수 없습니다.", + "accessRoleRemove": "역할 제거", + "accessRoleRemoveDescription": "조직에서 역할 제거", + "accessRoleRemoveSubmit": "역할 제거", + "accessRoleRemoved": "역할이 제거되었습니다", + "accessRoleRemovedDescription": "역할이 성공적으로 제거되었습니다.", + "accessRoleRequiredRemove": "이 역할을 삭제하기 전에 기존 구성원을 전송할 새 역할을 선택하세요.", + "manage": "관리", + "sitesNotFound": "사이트를 찾을 수 없습니다.", + "pangolinServerAdmin": "서버 관리자 - 판골린", + "licenseTierProfessional": "전문 라이센스", + "licenseTierEnterprise": "기업 라이선스", + "licenseTierCommercial": "상업용 라이선스", + "licensed": "라이센스", + "yes": "예", + "no": "아니요", + "sitesAdditional": "추가 사이트", + "licenseKeys": "라이센스 키", + "sitestCountDecrease": "사이트 수 줄이기", + "sitestCountIncrease": "사이트 수 증가", + "idpManage": "아이덴티티 공급자 관리", + "idpManageDescription": "시스템에서 ID 제공자를 보고 관리합니다", + "idpDeletedDescription": "신원 공급자가 성공적으로 삭제되었습니다", + "idpOidc": "OAuth2/OIDC", + "idpQuestionRemove": "정말로 아이덴티티 공급자 {name}를 영구적으로 삭제하시겠습니까?", + "idpMessageRemove": "이 작업은 아이덴티티 공급자와 모든 관련 구성을 제거합니다. 이 공급자를 통해 인증하는 사용자는 더 이상 로그인할 수 없습니다.", + "idpMessageConfirm": "확인을 위해 아래에 아이덴티티 제공자의 이름을 입력하세요.", + "idpConfirmDelete": "신원 제공자 삭제 확인", + "idpDelete": "아이덴티티 공급자 삭제", + "idp": "신원 공급자", + "idpSearch": "ID 공급자 검색...", + "idpAdd": "아이덴티티 공급자 추가", + "idpClientIdRequired": "클라이언트 ID가 필요합니다.", + "idpClientSecretRequired": "클라이언트 비밀이 필요합니다.", + "idpErrorAuthUrlInvalid": "인증 URL은 유효한 URL이어야 합니다.", + "idpErrorTokenUrlInvalid": "토큰 URL은 유효한 URL이어야 합니다.", + "idpPathRequired": "식별자 경로가 필요합니다.", + "idpScopeRequired": "범위가 필요합니다.", + "idpOidcDescription": "OpenID Connect ID 공급자를 구성하십시오.", + "idpCreatedDescription": "ID 공급자가 성공적으로 생성되었습니다.", + "idpCreate": "아이덴티티 공급자 생성", + "idpCreateDescription": "사용자 인증을 위한 새로운 ID 공급자를 구성합니다.", + "idpSeeAll": "모든 ID 공급자 보기", + "idpSettingsDescription": "신원 제공자의 기본 정보를 구성하세요", + "idpDisplayName": "이 신원 공급자를 위한 표시 이름", + "idpAutoProvisionUsers": "사용자 자동 프로비저닝", + "idpAutoProvisionUsersDescription": "활성화되면 사용자가 첫 로그인 시 시스템에 자동으로 생성되며, 사용자와 역할 및 조직을 매핑할 수 있습니다.", + "licenseBadge": "전문가", + "idpType": "제공자 유형", + "idpTypeDescription": "구성할 ID 공급자의 유형을 선택하십시오.", + "idpOidcConfigure": "OAuth2/OIDC 구성", + "idpOidcConfigureDescription": "OAuth2/OIDC 공급자 엔드포인트 및 자격 증명을 구성하십시오.", + "idpClientId": "클라이언트 ID", + "idpClientIdDescription": "아이덴티티 공급자의 OAuth2 클라이언트 ID", + "idpClientSecret": "클라이언트 비밀", + "idpClientSecretDescription": "신원 제공자로부터의 OAuth2 클라이언트 비밀", + "idpAuthUrl": "인증 URL", + "idpAuthUrlDescription": "OAuth2 인증 엔드포인트 URL", + "idpTokenUrl": "토큰 URL", + "idpTokenUrlDescription": "OAuth2 토큰 엔드포인트 URL", + "idpOidcConfigureAlert": "중요 정보", + "idpOidcConfigureAlertDescription": "아이덴티티 공급자를 생성한 후, 아이덴티티 공급자의 설정에서 콜백 URL을 구성해야 합니다. 콜백 URL은 성공적으로 생성된 후 제공됩니다.", + "idpToken": "토큰 구성", + "idpTokenDescription": "ID 토큰에서 사용자 정보를 추출하는 방법 구성", + "idpJmespathAbout": "JMESPath에 대하여", + "idpJmespathAboutDescription": "아래 경로는 ID 토큰에서 값을 추출하기 위해 JMESPath 구문을 사용합니다.", + "idpJmespathAboutDescriptionLink": "JMESPath에 대해 더 알아보기", + "idpJmespathLabel": "식별자 경로", + "idpJmespathLabelDescription": "ID 토큰에서 사용자 식별자에 대한 경로", + "idpJmespathEmailPathOptional": "이메일 경로 (선택 사항)", + "idpJmespathEmailPathOptionalDescription": "ID 토큰에서 사용자의 이메일 경로", + "idpJmespathNamePathOptional": "이름 경로 (선택 사항)", + "idpJmespathNamePathOptionalDescription": "ID 토큰에서 사용자의 이름 경로", + "idpOidcConfigureScopes": "범위", + "idpOidcConfigureScopesDescription": "요청할 OAuth2 범위의 공백으로 구분된 목록", + "idpSubmit": "아이덴티티 공급자 생성", + "orgPolicies": "조직 정책", + "idpSettings": "{idpName} 설정", + "idpCreateSettingsDescription": "아이덴티티 공급자의 설정을 구성하십시오", + "roleMapping": "역할 매핑", + "orgMapping": "조직 매핑", + "orgPoliciesSearch": "조직 정책 검색...", + "orgPoliciesAdd": "조직 정책 추가", + "orgRequired": "조직은 필수입니다.", + "error": "오류", + "success": "성공", + "orgPolicyAddedDescription": "정책이 성공적으로 추가되었습니다", + "orgPolicyUpdatedDescription": "정책이 성공적으로 업데이트되었습니다.", + "orgPolicyDeletedDescription": "정책이 성공적으로 삭제되었습니다", + "defaultMappingsUpdatedDescription": "기본 매핑이 성공적으로 업데이트되었습니다.", + "orgPoliciesAbout": "조직 정책에 대하여", + "orgPoliciesAboutDescription": "조직 정책은 사용자의 ID 토큰에 따라 조직에 대한 액세스를 제어하는 데 사용됩니다. ID 토큰에서 역할 및 조직 정보를 추출하기 위해 JMESPath 표현식을 지정할 수 있습니다.", + "orgPoliciesAboutDescriptionLink": "자세한 내용은 문서를 참조하십시오.", + "defaultMappingsOptional": "기본 매핑(선택 사항)", + "defaultMappingsOptionalDescription": "조직에 대해 정의된 정책이 없을 때 기본 매핑이 사용됩니다. 여기에서 기본 역할 및 조직 매핑을 지정하여 대체할 수 있습니다.", + "defaultMappingsRole": "기본 역할 매핑", + "defaultMappingsRoleDescription": "이 표현식의 결과는 조직에서 정의된 역할 이름을 문자열로 반환해야 합니다.", + "defaultMappingsOrg": "기본 조직 매핑", + "defaultMappingsOrgDescription": "이 표현식은 사용자가 조직에 접근할 수 있도록 조직 ID 또는 true를 반환해야 합니다.", + "defaultMappingsSubmit": "기본 매핑 저장", + "orgPoliciesEdit": "조직 정책 편집", + "org": "조직", + "orgSelect": "조직 선택", + "orgSearch": "조직 검색", + "orgNotFound": "조직을 찾을 수 없습니다.", + "roleMappingPathOptional": "역할 매핑 경로 (선택 사항)", + "orgMappingPathOptional": "조직 매핑 경로 (선택 사항)", + "orgPolicyUpdate": "정책 업데이트", + "orgPolicyAdd": "정책 추가", + "orgPolicyConfig": "조직에 대한 접근을 구성하십시오.", + "idpUpdatedDescription": "아이덴티티 제공자가 성공적으로 업데이트되었습니다", + "redirectUrl": "리디렉션 URL", + "redirectUrlAbout": "리디렉션 URL에 대한 정보", + "redirectUrlAboutDescription": "사용자가 인증 후 리디렉션될 URL입니다. 이 URL을 신원 제공자 설정에서 구성해야 합니다.", + "pangolinAuth": "인증 - 판골린", + "verificationCodeLengthRequirements": "인증 코드가 8자여야 합니다.", + "errorOccurred": "오류가 발생했습니다.", + "emailErrorVerify": "이메일 확인에 실패했습니다:", + "emailVerified": "이메일이 성공적으로 확인되었습니다! 리디렉션 중입니다...", + "verificationCodeErrorResend": "인증 코드를 재전송하는 데 실패했습니다:", + "verificationCodeResend": "인증 코드가 재전송되었습니다", + "verificationCodeResendDescription": "검증 코드를 귀하의 이메일 주소로 재전송했습니다. 받은 편지함을 확인해 주세요.", + "emailVerify": "이메일 확인", + "emailVerifyDescription": "이메일 주소로 전송된 인증 코드를 입력하세요.", + "verificationCode": "인증 코드", + "verificationCodeEmailSent": "귀하의 이메일 주소로 인증 코드가 전송되었습니다.", + "submit": "제출", + "emailVerifyResendProgress": "재전송 중...", + "emailVerifyResend": "코드를 받지 못하셨나요? 여기 클릭하여 재전송하세요", + "passwordNotMatch": "비밀번호가 일치하지 않습니다.", + "signupError": "가입하는 동안 오류가 발생했습니다.", + "pangolinLogoAlt": "판골린 로고", + "inviteAlready": "초대받은 것 같습니다!", + "inviteAlreadyDescription": "초대를 수락하려면 로그인하거나 계정을 생성해야 합니다.", + "signupQuestion": "이미 계정이 있습니까?", + "login": "로그인", + "resourceNotFound": "리소스를 찾을 수 없습니다", + "resourceNotFoundDescription": "접근하려는 리소스가 존재하지 않습니다.", + "pincodeRequirementsLength": "PIN은 정확히 6자리여야 합니다", + "pincodeRequirementsChars": "PIN은 숫자만 포함해야 합니다.", + "passwordRequirementsLength": "비밀번호는 최소 1자 이상이어야 합니다", + "otpEmailRequirementsLength": "OTP는 최소 1자 이상이어야 합니다", + "otpEmailSent": "OTP 전송됨", + "otpEmailSentDescription": "OTP가 귀하의 이메일로 전송되었습니다.", + "otpEmailErrorAuthenticate": "이메일로 인증에 실패했습니다", + "pincodeErrorAuthenticate": "핀코드로 인증하는 데 실패했습니다", + "passwordErrorAuthenticate": "비밀번호로 인증하는 데 실패했습니다.", + "poweredBy": "제공자", + "authenticationRequired": "인증 필요", + "authenticationMethodChoose": "{name}에 접근하기 위한 선호하는 방법을 선택하세요.", + "authenticationRequest": "{name}에 접근하려면 인증해야 합니다.", + "user": "사용자", + "pincodeInput": "6자리 PIN 코드", + "pincodeSubmit": "PIN으로 로그인", + "passwordSubmit": "비밀번호로 로그인", + "otpEmailDescription": "일회성 코드가 이 이메일로 전송됩니다.", + "otpEmailSend": "일회성 코드 전송", + "otpEmail": "일회성 비밀번호 (OTP)", + "otpEmailSubmit": "OTP 제출", + "backToEmail": "이메일로 돌아가기", + "noSupportKey": "서버가 지원 키 없이 실행되고 있습니다. 프로젝트 지원을 고려하세요!", + "accessDenied": "접근 거부", + "accessDeniedDescription": "이 리소스에 접근할 수 있는 권한이 없습니다. 이게 실수라면 관리자에게 문의해 주세요.", + "accessTokenError": "액세스 토큰 확인 중 오류 발생", + "accessGranted": "접근 허가됨", + "accessUrlInvalid": "접근 URL이 유효하지 않습니다", + "accessGrantedDescription": "이 리소스에 대한 접근이 허용되었습니다. 리디렉션 중입니다...", + "accessUrlInvalidDescription": "이 공유 액세스 URL은 유효하지 않습니다. 새로운 URL을 위해 리소스 소유자에게 문의하세요.", + "tokenInvalid": "유효하지 않은 토큰", + "pincodeInvalid": "유효하지 않은 코드", + "passwordErrorRequestReset": "재설정을 요청하는 데 실패했습니다:", + "passwordErrorReset": "비밀번호 재설정 실패:", + "passwordResetSuccess": "비밀번호가 성공적으로 재설정되었습니다! 로그인으로 돌아가기...", + "passwordReset": "비밀번호 재설정", + "passwordResetDescription": "비밀번호를 재설정하는 단계를 따르세요", + "passwordResetSent": "이 이메일 주소로 비밀번호 재설정 코드를 전송하겠습니다.", + "passwordResetCode": "코드 재설정", + "passwordResetCodeDescription": "재설정 코드를 확인하려면 이메일을 확인하세요.", + "passwordNew": "새 비밀번호", + "passwordNewConfirm": "새 비밀번호 확인", + "pincodeAuth": "인증 코드", + "pincodeSubmit2": "코드 제출", + "passwordResetSubmit": "재설정 요청", + "passwordBack": "비밀번호로 돌아가기", + "loginBack": "로그인으로 돌아가기", + "signup": "가입하기", + "loginStart": "시작하려면 로그인하세요.", + "idpOidcTokenValidating": "OIDC 토큰 검증 중", + "idpOidcTokenResponse": "OIDC 토큰 응답 검증", + "idpErrorOidcTokenValidating": "OIDC 토큰 검증 오류", + "idpConnectingTo": "{name}에 연결 중", + "idpConnectingToDescription": "귀하의 신원을 확인하는 중", + "idpConnectingToProcess": "연결 중...", + "idpConnectingToFinished": "연결됨", + "idpErrorConnectingTo": "{name}에 연결하는 데 문제가 발생했습니다. 관리자에게 문의하십시오.", + "idpErrorNotFound": "IdP를 찾을 수 없습니다.", + "inviteInvalid": "유효하지 않은 초대", + "inviteInvalidDescription": "초대 링크가 유효하지 않습니다.", + "inviteErrorWrongUser": "이 초대는 이 사용자에게 해당되지 않습니다", + "inviteErrorUserNotExists": "사용자가 존재하지 않습니다. 먼저 계정을 생성해 주세요.", + "inviteErrorLoginRequired": "초대를 수락하려면 로그인해야 합니다.", + "inviteErrorExpired": "초대가 만료되었을 수 있습니다.", + "inviteErrorRevoked": "초대가 취소되었을 수 있습니다.", + "inviteErrorTypo": "초대 링크에 오타가 있을 수 있습니다.", + "pangolinSetup": "설정 - 판골린", + "orgNameRequired": "조직 이름은 필수입니다.", + "orgIdRequired": "조직 ID가 필요합니다", + "orgErrorCreate": "조직 생성 중 오류가 발생했습니다.", + "pageNotFound": "페이지를 찾을 수 없습니다", + "pageNotFoundDescription": "앗! 찾고 있는 페이지가 존재하지 않습니다.", + "overview": "개요", + "home": "홈", + "accessControl": "액세스 제어", + "settings": "설정", + "usersAll": "모든 사용자", + "license": "라이선스", + "pangolinDashboard": "대시보드 - 판골린", + "noResults": "결과를 찾을 수 없습니다.", + "terabytes": "{count} TB", + "gigabytes": "{count} GB", + "megabytes": "{count} MB", + "tagsEntered": "입력된 태그", + "tagsEnteredDescription": "입력한 태그는 다음과 같습니다.", + "tagsWarnCannotBeLessThanZero": "maxTags와 minTags는 0보다 작을 수 없습니다", + "tagsWarnNotAllowedAutocompleteOptions": "자동 완성 옵션에 따라 태그가 허용되지 않습니다", + "tagsWarnInvalid": "validateTag에 따라 유효하지 않은 태그입니다", + "tagWarnTooShort": "태그 {tagText}가 너무 짧습니다", + "tagWarnTooLong": "태그 {tagText}가 너무 깁니다.", + "tagsWarnReachedMaxNumber": "허용된 최대 태그 수에 도달했습니다.", + "tagWarnDuplicate": "중복 태그 {tagText}가 추가되지 않았습니다.", + "supportKeyInvalid": "유효하지 않은 키", + "supportKeyInvalidDescription": "지원자 키가 유효하지 않습니다.", + "supportKeyValid": "유효한 키", + "supportKeyValidDescription": "귀하의 후원자 키가 검증되었습니다. 지원해 주셔서 감사합니다!", + "supportKeyErrorValidationDescription": "서포터 키 유효성 검사에 실패했습니다.", + "supportKey": "개발 지원 및 판골린을 입양하세요!", + "supportKeyDescription": "커뮤니티를 위해 Pangolin 개발을 지속할 수 있도록 후원자 키를 구매하세요. 귀하의 기여는 모든 사용자를 위해 애플리케이션을 유지하고 새로운 기능을 추가하는 데 더 많은 시간을 할애할 수 있게 해줍니다. 우리는 절대 이 기능을 유료화하는 데 사용하지 않을 것입니다. 이는 상업용 에디션과는 별개입니다.", + "supportKeyPet": "자신만의 애완 판골린을 입양하고 만날 수 있습니다!", + "supportKeyPurchase": "결제는 GitHub를 통해 처리됩니다. 이후, 키를 다음에서 검색할 수 있습니다.", + "supportKeyPurchaseLink": "우리 웹사이트", + "supportKeyPurchase2": "여기에서 사용하세요.", + "supportKeyLearnMore": "자세히 알아보기.", + "supportKeyOptions": "가장 적합한 옵션을 선택해 주세요.", + "supportKetOptionFull": "전체 후원자", + "forWholeServer": "전체 서버에 대해", + "lifetimePurchase": "평생 구매", + "supporterStatus": "후원자 상태", + "buy": "구매", + "supportKeyOptionLimited": "제한된 후원자", + "forFiveUsers": "5명 이하의 사용자에 대해", + "supportKeyRedeem": "서포터 키 사용", + "supportKeyHideSevenDays": "7일 동안 숨기기", + "supportKeyEnter": "지원자 키 입력", + "supportKeyEnterDescription": "당신만의 펭귄 애완동물을 만나보세요!", + "githubUsername": "GitHub 사용자 이름", + "supportKeyInput": "후원자 키", + "supportKeyBuy": "서포터 키 구매", + "logoutError": "로그아웃 중 오류 발생", + "signingAs": "로그인한 사용자", + "serverAdmin": "서버 관리자", + "otpEnable": "이중 인증 활성화", + "otpDisable": "이중 인증 비활성화", + "logout": "로그 아웃", + "licenseTierProfessionalRequired": "전문 에디션이 필요합니다.", + "licenseTierProfessionalRequiredDescription": "이 기능은 Professional Edition에서만 사용할 수 있습니다.", + "actionGetOrg": "조직 가져오기", + "actionUpdateOrg": "조직 업데이트", + "actionUpdateUser": "사용자 업데이트", + "actionGetUser": "사용자 조회", + "actionGetOrgUser": "조직 사용자 가져오기", + "actionListOrgDomains": "조직 도메인 목록", + "actionCreateSite": "사이트 생성", + "actionDeleteSite": "사이트 삭제", + "actionGetSite": "사이트 가져오기", + "actionListSites": "사이트 목록", + "actionUpdateSite": "사이트 업데이트", + "actionListSiteRoles": "허용된 사이트 역할 목록", + "actionCreateResource": "리소스 생성", + "actionDeleteResource": "리소스 삭제", + "actionGetResource": "리소스 가져오기", + "actionListResource": "리소스 목록", + "actionUpdateResource": "리소스 업데이트", + "actionListResourceUsers": "리소스 사용자 목록", + "actionSetResourceUsers": "리소스 사용자 설정", + "actionSetAllowedResourceRoles": "허용된 리소스 역할 설정", + "actionListAllowedResourceRoles": "허용된 리소스 역할 목록", + "actionSetResourcePassword": "리소스 비밀번호 설정", + "actionSetResourcePincode": "리소스 핀코드 설정", + "actionSetResourceEmailWhitelist": "리소스 이메일 화이트리스트 설정", + "actionGetResourceEmailWhitelist": "리소스 이메일 화이트리스트 가져오기", + "actionCreateTarget": "대상 만들기", + "actionDeleteTarget": "대상 삭제", + "actionGetTarget": "대상 가져오기", + "actionListTargets": "대상 목록", + "actionUpdateTarget": "대상 업데이트", + "actionCreateRole": "역할 생성", + "actionDeleteRole": "역할 삭제", + "actionGetRole": "역할 가져오기", + "actionListRole": "역할 목록", + "actionUpdateRole": "역할 업데이트", + "actionListAllowedRoleResources": "허용된 역할 리소스 목록", + "actionInviteUser": "사용자 초대", + "actionRemoveUser": "사용자 제거", + "actionListUsers": "사용자 목록", + "actionAddUserRole": "사용자 역할 추가", + "actionGenerateAccessToken": "액세스 토큰 생성", + "actionDeleteAccessToken": "액세스 토큰 삭제", + "actionListAccessTokens": "액세스 토큰 목록", + "actionCreateResourceRule": "리소스 규칙 생성", + "actionDeleteResourceRule": "리소스 규칙 삭제", + "actionListResourceRules": "리소스 규칙 목록", + "actionUpdateResourceRule": "리소스 규칙 업데이트", + "actionListOrgs": "조직 목록", + "actionCheckOrgId": "ID 확인", + "actionCreateOrg": "조직 생성", + "actionDeleteOrg": "조직 삭제", + "actionListApiKeys": "API 키 목록", + "actionListApiKeyActions": "API 키 작업 목록", + "actionSetApiKeyActions": "API 키 허용 작업 설정", + "actionCreateApiKey": "API 키 생성", + "actionDeleteApiKey": "API 키 삭제", + "actionCreateIdp": "IDP 생성", + "actionUpdateIdp": "IDP 업데이트", + "actionDeleteIdp": "IDP 삭제", + "actionListIdps": "IDP 목록", + "actionGetIdp": "IDP 가져오기", + "actionCreateIdpOrg": "IDP 조직 정책 생성", + "actionDeleteIdpOrg": "IDP 조직 정책 삭제", + "actionListIdpOrgs": "IDP 조직 목록", + "actionUpdateIdpOrg": "IDP 조직 업데이트", + "actionCreateClient": "Create Client", + "actionDeleteClient": "Delete Client", + "actionUpdateClient": "Update Client", + "actionListClients": "List Clients", + "actionGetClient": "Get Client", + "noneSelected": "선택된 항목 없음", + "orgNotFound2": "조직이 없습니다.", + "searchProgress": "검색...", + "create": "생성", + "orgs": "조직", + "loginError": "로그인 중 오류가 발생했습니다", + "passwordForgot": "비밀번호를 잊으셨나요?", + "otpAuth": "이중 인증", + "otpAuthDescription": "인증 앱에서 코드를 입력하거나 단일 사용 백업 코드 중 하나를 입력하세요.", + "otpAuthSubmit": "코드 제출", + "idpContinue": "또는 계속 진행하십시오.", + "otpAuthBack": "로그인으로 돌아가기", + "navbar": "탐색 메뉴", + "navbarDescription": "애플리케이션의 주요 탐색 메뉴", + "navbarDocsLink": "문서", + "commercialEdition": "상업용 에디션", + "otpErrorEnable": "2FA를 활성화할 수 없습니다.", + "otpErrorEnableDescription": "2FA를 활성화하는 동안 오류가 발생했습니다", + "otpSetupCheckCode": "6자리 코드를 입력하세요", + "otpSetupCheckCodeRetry": "유효하지 않은 코드입니다. 다시 시도하세요.", + "otpSetup": "이중 인증 활성화", + "otpSetupDescription": "추가 보호 계층으로 계정을 안전하게 유지하세요.", + "otpSetupScanQr": "인증 앱으로 이 QR 코드를 스캔하거나 비밀 키를 수동으로 입력하십시오:", + "otpSetupSecretCode": "인증 코드", + "otpSetupSuccess": "이중 인증 활성화됨", + "otpSetupSuccessStoreBackupCodes": "귀하의 계정이 이제 더 안전해졌습니다. 백업 코드를 저장하는 것을 잊지 마세요.", + "otpErrorDisable": "2FA를 비활성화할 수 없습니다.", + "otpErrorDisableDescription": "2FA를 비활성화하는 동안 오류가 발생했습니다.", + "otpRemove": "이중 인증 비활성화", + "otpRemoveDescription": "계정에 대한 이중 인증 비활성화", + "otpRemoveSuccess": "이중 인증 비활성화", + "otpRemoveSuccessMessage": "이중 인증이 귀하의 계정에서 비활성화되었습니다. 언제든지 다시 활성화할 수 있습니다.", + "otpRemoveSubmit": "2FA 비활성화", + "paginator": "페이지 {current} / {last}", + "paginatorToFirst": "첫 페이지로 이동", + "paginatorToPrevious": "이전 페이지로 이동", + "paginatorToNext": "다음 페이지로 이동", + "paginatorToLast": "마지막 페이지로 이동", + "copyText": "텍스트 복사", + "copyTextFailed": "텍스트 복사 실패: ", + "copyTextClipboard": "클립보드에 복사", + "inviteErrorInvalidConfirmation": "유효하지 않은 확인", + "passwordRequired": "비밀번호는 필수입니다.", + "allowAll": "모두 허용", + "permissionsAllowAll": "모든 권한 허용", + "githubUsernameRequired": "GitHub 사용자 이름이 필요합니다.", + "supportKeyRequired": "지원자 키가 필요합니다.", + "passwordRequirementsChars": "비밀번호는 최소 8자 이상이어야 합니다", + "language": "언어", + "verificationCodeRequired": "코드가 필요합니다.", + "userErrorNoUpdate": "업데이트할 사용자가 없습니다", + "siteErrorNoUpdate": "업데이트할 사이트가 없습니다.", + "resourceErrorNoUpdate": "업데이트할 리소스가 없습니다", + "authErrorNoUpdate": "업데이트할 인증 정보가 없습니다.", + "orgErrorNoUpdate": "업데이트할 조직이 없습니다.", + "orgErrorNoProvided": "제공된 조직이 없습니다.", + "apiKeysErrorNoUpdate": "업데이트할 API 키가 없습니다.", + "sidebarOverview": "개요", + "sidebarHome": "홈", + "sidebarSites": "사이트", + "sidebarResources": "리소스", + "sidebarAccessControl": "액세스 제어", + "sidebarUsers": "사용자", + "sidebarInvitations": "초대", + "sidebarRoles": "역할", + "sidebarShareableLinks": "공유 가능한 링크", + "sidebarApiKeys": "API 키", + "sidebarSettings": "설정", + "sidebarAllUsers": "모든 사용자", + "sidebarIdentityProviders": "신원 공급자", + "sidebarLicense": "라이선스", + "sidebarClients": "Clients (Beta)", + "sidebarDomains": "도메인", + "enableDockerSocket": "Docker 소켓 활성화", + "enableDockerSocketDescription": "컨테이너 정보를 채우기 위해 Docker 소켓 검색을 활성화합니다. 소켓 경로는 Newt에 제공되어야 합니다.", + "enableDockerSocketLink": "자세히 알아보기", + "viewDockerContainers": "도커 컨테이너 보기", + "containersIn": "{siteName}의 컨테이너", + "selectContainerDescription": "이 대상을 위한 호스트 이름으로 사용할 컨테이너를 선택하세요. 포트를 사용하려면 포트를 클릭하세요.", + "containerName": "이름", + "containerImage": "이미지", + "containerState": "주", + "containerNetworks": "네트워크", + "containerHostnameIp": "호스트 이름/IP", + "containerLabels": "레이블", + "containerLabelsCount": "{count, plural, one {# 레이블} other {# 레이블}}", + "containerLabelsTitle": "컨테이너 레이블", + "containerLabelEmpty": "<비어 있음>", + "containerPorts": "포트", + "containerPortsMore": "+{count}개 더", + "containerActions": "작업", + "select": "선택", + "noContainersMatchingFilters": "현재 필터와 일치하는 컨테이너를 찾을 수 없습니다.", + "showContainersWithoutPorts": "포트가 없는 컨테이너 표시", + "showStoppedContainers": "중지된 컨테이너 표시", + "noContainersFound": "컨테이너를 찾을 수 없습니다. Docker 컨테이너가 실행 중인지 확인하십시오.", + "searchContainersPlaceholder": "{count}개의 컨테이너에서 검색...", + "searchResultsCount": "{count, plural, one {# 결과} other {# 결과}}", + "filters": "필터", + "filterOptions": "필터 옵션", + "filterPorts": "포트", + "filterStopped": "중지됨", + "clearAllFilters": "모든 필터 지우기", + "columns": "열", + "toggleColumns": "열 전환", + "refreshContainersList": "컨테이너 목록 새로 고침", + "searching": "검색 중...", + "noContainersFoundMatching": "\"{filter}\"와 일치하는 컨테이너를 찾을 수 없습니다.", + "light": "빛", + "dark": "어두운", + "system": "시스템", + "theme": "테마", + "subnetRequired": "서브넷은 필수입니다", + "initialSetupTitle": "초기 서버 설정", + "initialSetupDescription": "초기 서버 관리자 계정을 생성하세요. 서버 관리자 계정은 하나만 존재할 수 있습니다. 이러한 자격 증명은 나중에 언제든지 변경할 수 있습니다.", + "createAdminAccount": "관리자 계정 생성", + "setupErrorCreateAdmin": "서버 관리자 계정을 생성하는 동안 오류가 발생했습니다.", + "certificateStatus": "인증서 상태", + "loading": "로딩 중", + "restart": "재시작", + "domains": "도메인", + "domainsDescription": "조직의 도메인을 관리합니다", + "domainsSearch": "도메인 검색...", + "domainAdd": "도메인 추가", + "domainAddDescription": "조직에 새로운 도메인을 등록하세요", + "domainCreate": "도메인 생성", + "domainCreatedDescription": "도메인이 성공적으로 생성되었습니다", + "domainDeletedDescription": "도메인이 성공적으로 삭제되었습니다", + "domainQuestionRemove": "도메인 {domain}을(를) 계정에서 제거하시겠습니까?", + "domainMessageRemove": "제거되면 도메인이 더 이상 계정과 연관되지 않습니다.", + "domainMessageConfirm": "확인하려면 아래에 도메인명을 입력하세요.", + "domainConfirmDelete": "도메인 삭제 확인", + "domainDelete": "도메인 삭제", + "domain": "도메인", + "selectDomainTypeNsName": "도메인 위임 (NS)", + "selectDomainTypeNsDescription": "이 도메인과 모든 하위 도메인입니다. 전체 도메인 영역을 제어하려면 이를 사용하세요.", + "selectDomainTypeCnameName": "단일 도메인 (CNAME)", + "selectDomainTypeCnameDescription": "단일 하위 도메인 또는 특정 도메인 항목에 사용됩니다.", + "selectDomainTypeWildcardName": "와일드카드 도메인", + "selectDomainTypeWildcardDescription": "This domain and its subdomains.", + "domainDelegation": "단일 도메인", + "selectType": "유형 선택", + "actions": "작업", + "refresh": "새로 고침", + "refreshError": "데이터 새로고침 실패", + "verified": "검증됨", + "pending": "대기 중", + "sidebarBilling": "청구", + "billing": "청구", + "orgBillingDescription": "청구 정보 및 구독을 관리하세요", + "github": "GitHub", + "pangolinHosted": "판골린 호스팅", + "fossorial": "지하 서식", + "completeAccountSetup": "계정 설정 완료", + "completeAccountSetupDescription": "시작하려면 비밀번호를 설정하세요", + "accountSetupSent": "이 이메일 주소로 계정 설정 코드를 보내드리겠습니다.", + "accountSetupCode": "설정 코드", + "accountSetupCodeDescription": "설정 코드를 확인하기 위해 이메일을 확인하세요.", + "passwordCreate": "비밀번호 생성", + "passwordCreateConfirm": "비밀번호 확인", + "accountSetupSubmit": "설정 코드 전송", + "completeSetup": "설정 완료", + "accountSetupSuccess": "계정 설정이 완료되었습니다! 판골린에 오신 것을 환영합니다!", + "documentation": "문서", + "saveAllSettings": "모든 설정 저장", + "settingsUpdated": "설정이 업데이트되었습니다", + "settingsUpdatedDescription": "모든 설정이 성공적으로 업데이트되었습니다", + "settingsErrorUpdate": "설정 업데이트 실패", + "settingsErrorUpdateDescription": "설정을 업데이트하는 동안 오류가 발생했습니다", + "sidebarCollapse": "줄이기", + "sidebarExpand": "확장하기", + "newtUpdateAvailable": "업데이트 가능", + "newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", + "domainPickerEnterDomain": "Domain", + "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, 또는 그냥 myapp", + "domainPickerDescription": "Enter the full domain of the resource to see available options.", + "domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options", + "domainPickerTabAll": "모두", + "domainPickerTabOrganization": "조직", + "domainPickerTabProvided": "제공 됨", + "domainPickerSortAsc": "A-Z", + "domainPickerSortDesc": "Z-A", + "domainPickerCheckingAvailability": "가용성을 확인 중...", + "domainPickerNoMatchingDomains": "No matching domains found. Try a different domain or check your organization's domain settings.", + "domainPickerOrganizationDomains": "조직 도메인", + "domainPickerProvidedDomains": "제공된 도메인", + "domainPickerSubdomain": "서브도메인: {subdomain}", + "domainPickerNamespace": "이름 공간: {namespace}", + "domainPickerShowMore": "더보기", + "domainNotFound": "도메인을 찾을 수 없습니다", + "domainNotFoundDescription": "이 리소스는 도메인이 더 이상 시스템에 존재하지 않아 비활성화되었습니다. 이 리소스에 대한 새 도메인을 설정하세요.", + "failed": "실패", + "createNewOrgDescription": "새 조직 생성", + "organization": "조직", + "port": "포트", + "securityKeyManage": "보안 키 관리", + "securityKeyDescription": "비밀번호 없는 인증을 위해 보안 키를 추가하거나 제거합니다.", + "securityKeyRegister": "새 보안 키 등록", + "securityKeyList": "귀하의 보안 키", + "securityKeyNone": "등록된 보안 키가 아직 없습니다", + "securityKeyNameRequired": "이름은 필수입니다", + "securityKeyRemove": "제거", + "securityKeyLastUsed": "마지막 사용: {date}", + "securityKeyNameLabel": "보안 키 이름", + "securityKeyRegisterSuccess": "보안 키가 성공적으로 등록되었습니다", + "securityKeyRegisterError": "보안 키 등록 실패", + "securityKeyRemoveSuccess": "보안 키가 성공적으로 제거되었습니다", + "securityKeyRemoveError": "보안 키 제거 실패", + "securityKeyLoadError": "보안 키를 불러오는 데 실패했습니다", + "securityKeyLogin": "Continue with security key", + "securityKeyAuthError": "보안 키를 사용한 인증 실패", + "securityKeyRecommendation": "항상 계정에 액세스할 수 있도록 다른 장치에 백업 보안 키를 등록하세요.", + "registering": "등록 중...", + "securityKeyPrompt": "보안 키를 사용하여 본인 확인을 진행하세요. 보안 키가 연결되어 사용 준비가 되었는지 확인하세요.", + "securityKeyBrowserNotSupported": "귀하의 브라우저는 보안 키를 지원하지 않습니다. Chrome, Firefox, 또는 Safari와 같은 최신 브라우저를 사용하세요.", + "securityKeyPermissionDenied": "로그인을 계속하려면 보안 키에 대한 액세스를 허용하세요.", + "securityKeyRemovedTooQuickly": "로그인 프로세스가 완료될 때까지 보안 키를 연결 상태로 유지하세요.", + "securityKeyNotSupported": "보안 키가 호환되지 않을 수 있습니다. 다른 보안 키를 사용해보세요.", + "securityKeyUnknownError": "보안 키를 사용하는 데 문제가 발생했습니다. 다시 시도하세요.", + "twoFactorRequired": "보안 키를 등록하려면 이중 인증이 필요합니다.", + "twoFactor": "이중 인증", + "adminEnabled2FaOnYourAccount": "관리자가 {email}에 대한 이중 인증을 활성화했습니다. 계속하려면 설정을 완료하세요.", + "continueToApplication": "응용 프로그램으로 계속", + "securityKeyAdd": "보안 키 추가", + "securityKeyRegisterTitle": "새 보안 키 등록", + "securityKeyRegisterDescription": "보안 키를 연결하고 식별할 이름을 입력하세요.", + "securityKeyTwoFactorRequired": "이중 인증 필요", + "securityKeyTwoFactorDescription": "보안 키를 등록하려면 이중 인증 코드를 입력하세요.", + "securityKeyTwoFactorRemoveDescription": "보안 키를 제거하려면 이중 인증 코드를 입력하세요.", + "securityKeyTwoFactorCode": "이중 인증 코드", + "securityKeyRemoveTitle": "보안 키 삭제", + "securityKeyRemoveDescription": "보안 키 \"{name}\"를 제거하려면 비밀번호를 입력하세요", + "securityKeyNoKeysRegistered": "등록된 보안 키가 없습니다", + "securityKeyNoKeysDescription": "계정 보안을 강화하려면 보안 키를 추가하세요.", + "createDomainRequired": "도메인은 필수입니다", + "createDomainAddDnsRecords": "DNS 레코드 추가", + "createDomainAddDnsRecordsDescription": "설정을 완료하려면 도메인 제공자에게 다음 DNS 레코드를 추가하세요.", + "createDomainNsRecords": "NS 레코드", + "createDomainRecord": "레코드", + "createDomainType": "유형:", + "createDomainName": "이름:", + "createDomainValue": "값:", + "createDomainCnameRecords": "CNAME 레코드", + "createDomainARecords": "A Records", + "createDomainRecordNumber": "레코드 {number}", + "createDomainTxtRecords": "TXT 레코드", + "createDomainSaveTheseRecords": "이 레코드 저장", + "createDomainSaveTheseRecordsDescription": "이 DNS 레코드를 저장하여 이후에 다시 볼 수 없습니다.", + "createDomainDnsPropagation": "DNS 전파", + "createDomainDnsPropagationDescription": "DNS 변경 사항은 인터넷 전체에 전파되는 데 시간이 걸립니다. DNS 제공자와 TTL 설정에 따라 몇 분에서 48시간까지 걸릴 수 있습니다.", + "resourcePortRequired": "HTTP 리소스가 아닌 경우 포트 번호가 필요합니다", + "resourcePortNotAllowed": "HTTP 리소스에 대해 포트 번호를 설정하지 마세요", + "signUpTerms": { + "IAgreeToThe": "I agree to the", + "termsOfService": "terms of service", + "and": "and", + "privacyPolicy": "privacy policy" + }, + "siteRequired": "Site is required.", + "olmTunnel": "Olm Tunnel", + "olmTunnelDescription": "Use Olm for client connectivity", + "errorCreatingClient": "Error creating client", + "clientDefaultsNotFound": "Client defaults not found", + "createClient": "Create Client", + "createClientDescription": "Create a new client for connecting to your sites", + "seeAllClients": "See All Clients", + "clientInformation": "Client Information", + "clientNamePlaceholder": "Client name", + "address": "Address", + "subnetPlaceholder": "Subnet", + "addressDescription": "The address that this client will use for connectivity", + "selectSites": "Select sites", + "sitesDescription": "The client will have connectivity to the selected sites", + "clientInstallOlm": "Install Olm", + "clientInstallOlmDescription": "Get Olm running on your system", + "clientOlmCredentials": "Olm Credentials", + "clientOlmCredentialsDescription": "This is how Olm will authenticate with the server", + "olmEndpoint": "Olm Endpoint", + "olmId": "Olm ID", + "olmSecretKey": "Olm Secret Key", + "clientCredentialsSave": "Save Your Credentials", + "clientCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.", + "generalSettingsDescription": "Configure the general settings for this client", + "clientUpdated": "Client updated", + "clientUpdatedDescription": "The client has been updated.", + "clientUpdateFailed": "Failed to update client", + "clientUpdateError": "An error occurred while updating the client.", + "sitesFetchFailed": "Failed to fetch sites", + "sitesFetchError": "An error occurred while fetching sites.", + "olmErrorFetchReleases": "An error occurred while fetching Olm releases.", + "olmErrorFetchLatest": "An error occurred while fetching the latest Olm release.", + "remoteSubnets": "Remote Subnets", + "enterCidrRange": "Enter CIDR range", + "remoteSubnetsDescription": "Add CIDR ranges that can access this site remotely. Use format like 10.0.0.0/24 or 192.168.1.0/24.", + "resourceEnableProxy": "Enable Public Proxy", + "resourceEnableProxyDescription": "Enable public proxying to this resource. This allows access to the resource from outside the network through the cloud on an open port. Requires Traefik config.", + "externalProxyEnabled": "External Proxy Enabled" +} diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 7e625b00..38f68a3b 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -11,8 +11,9 @@ "componentsErrorNoMemberCreate": "U bent momenteel geen lid van een organisatie. Maak een organisatie aan om aan de slag te gaan.", "componentsErrorNoMember": "U bent momenteel geen lid van een organisatie.", "welcome": "Welkom bij Pangolin", + "welcomeTo": "Welkom bij", "componentsCreateOrg": "Maak een Organisatie", - "componentsMember": "Je bent lid van {count, plural, =0 {geen organisatie} =1 {één organisatie} other {# organisaties}}.", + "componentsMember": "Je bent lid van {count, plural, =0 {geen organisatie} one {één organisatie} other {# organisaties}}.", "componentsInvalidKey": "Ongeldige of verlopen licentiesleutels gedetecteerd. Volg de licentievoorwaarden om alle functies te blijven gebruiken.", "dismiss": "Uitschakelen", "componentsLicenseViolation": "Licentie overtreding: Deze server gebruikt {usedSites} sites die de gelicentieerde limiet van {maxSites} sites overschrijden. Volg de licentievoorwaarden om door te gaan met het gebruik van alle functies.", @@ -58,7 +59,6 @@ "siteErrorCreate": "Fout bij maken site", "siteErrorCreateKeyPair": "Key pair of site standaard niet gevonden", "siteErrorCreateDefaults": "Standaardinstellingen niet gevonden", - "siteNameDescription": "Dit is de weergavenaam van de site.", "method": "Methode", "siteMethodDescription": "Op deze manier legt u verbindingen bloot.", "siteLearnNewt": "Leer hoe u Newt kunt installeren op uw systeem", @@ -206,6 +206,7 @@ "orgGeneralSettings": "Organisatie Instellingen", "orgGeneralSettingsDescription": "Beheer de details en configuratie van uw organisatie", "saveGeneralSettings": "Algemene instellingen opslaan", + "saveSettings": "Instellingen opslaan", "orgDangerZone": "Gevaarlijke zone", "orgDangerZoneDescription": "Als u deze instantie verwijdert, is er geen weg terug. Wees het alstublieft zeker.", "orgDelete": "Verwijder organisatie", @@ -249,7 +250,7 @@ "weeks": "Weken", "months": "maanden", "years": "Jaar", - "day": "{count, plural, =1 {# dag} other {# dagen}}", + "day": "{count, plural, one {# dag} other {# dagen}}", "apiKeysTitle": "API Key Informatie", "apiKeysConfirmCopy2": "Bevestig dat u de API-sleutel hebt gekopieerd.", "apiKeysErrorCreate": "Fout bij maken API-sleutel", @@ -347,7 +348,7 @@ "licensePurchase": "Licentie kopen", "licensePurchaseSites": "Extra sites kopen", "licenseSitesUsedMax": "{usedSites} van {maxSites} sites gebruikt", - "licenseSitesUsed": "{count, plural, =0 {# sites} =1 {# site} other {# sites}} in het systeem.", + "licenseSitesUsed": "{count, plural, =0 {# locaties} one {# locatie} other {# locaties}} in het systeem.", "licensePurchaseDescription": "Kies hoeveel sites je wilt {selectedMode, select, license {Koop een licentie. Je kunt later altijd meer sites toevoegen.} other {Voeg je bestaande licentie toe}}", "licenseFee": "Licentie vergoeding", "licensePriceSite": "Prijs per site", @@ -436,7 +437,7 @@ "accessRoleSelect": "Selecteer rol", "inviteEmailSentDescription": "Een e-mail is verstuurd naar de gebruiker met de link hieronder. Ze moeten toegang krijgen tot de link om de uitnodiging te accepteren.", "inviteSentDescription": "De gebruiker is uitgenodigd. Ze moeten toegang krijgen tot de link hieronder om de uitnodiging te accepteren.", - "inviteExpiresIn": "De uitnodiging vervalt in {days, plural, =1 {# dag} other {# dagen}}.", + "inviteExpiresIn": "De uitnodiging vervalt over {days, plural, one {# dag} other {# dagen}}.", "idpTitle": "Identiteit Provider", "idpSelect": "Identiteitsprovider voor de externe gebruiker selecteren", "idpNotConfigured": "Er zijn geen identiteitsproviders geconfigureerd. Configureer een identiteitsprovider voordat u externe gebruikers aanmaakt.", @@ -958,6 +959,8 @@ "licenseTierProfessionalRequiredDescription": "Deze functie is alleen beschikbaar in de Professional Edition.", "actionGetOrg": "Krijg Organisatie", "actionUpdateOrg": "Organisatie bijwerken", + "actionUpdateUser": "Gebruiker bijwerken", + "actionGetUser": "Gebruiker ophalen", "actionGetOrgUser": "Krijg organisatie-gebruiker", "actionListOrgDomains": "Lijst organisatie domeinen", "actionCreateSite": "Site maken", @@ -1019,6 +1022,11 @@ "actionDeleteIdpOrg": "Verwijder IDP Org Beleid", "actionListIdpOrgs": "Toon IDP Orgs", "actionUpdateIdpOrg": "IDP-org bijwerken", + "actionCreateClient": "Client aanmaken", + "actionDeleteClient": "Verwijder klant", + "actionUpdateClient": "Klant bijwerken", + "actionListClients": "Lijst klanten", + "actionGetClient": "Client ophalen", "noneSelected": "Niet geselecteerd", "orgNotFound2": "Geen organisaties gevonden.", "searchProgress": "Zoeken...", @@ -1090,6 +1098,8 @@ "sidebarAllUsers": "Alle gebruikers", "sidebarIdentityProviders": "Identiteit aanbieders", "sidebarLicense": "Licentie", + "sidebarClients": "Clients (Bèta)", + "sidebarDomains": "Domeinen", "enableDockerSocket": "Docker Socket inschakelen", "enableDockerSocketDescription": "Docker Socket-ontdekking inschakelen voor het invullen van containerinformatie. Socket-pad moet aan Newt worden verstrekt.", "enableDockerSocketLink": "Meer informatie", @@ -1102,7 +1112,7 @@ "containerNetworks": "Netwerken", "containerHostnameIp": "Hostnaam/IP", "containerLabels": "Labels", - "containerLabelsCount": "{count} label{s,plural,one{} other{s}}", + "containerLabelsCount": "{count, plural, one {# label} other {# labels}}", "containerLabelsTitle": "Container labels", "containerLabelEmpty": "", "containerPorts": "Poorten", @@ -1114,7 +1124,7 @@ "showStoppedContainers": "Toon gestopte containers", "noContainersFound": "Geen containers gevonden. Zorg ervoor dat Docker containers draaien.", "searchContainersPlaceholder": "Zoek tussen {count} containers...", - "searchResultsCount": "{count} resultaat{s,plural,one{} other{s}}", + "searchResultsCount": "{count, plural, one {# resultaat} other {# resultaten}}", "filters": "Filters", "filterOptions": "Filter opties", "filterPorts": "Poorten", @@ -1129,8 +1139,189 @@ "dark": "donker", "system": "systeem", "theme": "Thema", + "subnetRequired": "Subnet is vereist", "initialSetupTitle": "Initiële serverconfiguratie", "initialSetupDescription": "Maak het eerste serverbeheeraccount aan. Er kan slechts één serverbeheerder bestaan. U kunt deze inloggegevens later altijd wijzigen.", "createAdminAccount": "Maak een beheeraccount aan", - "setupErrorCreateAdmin": "Er is een fout opgetreden bij het maken van het serverbeheerdersaccount." + "setupErrorCreateAdmin": "Er is een fout opgetreden bij het maken van het serverbeheerdersaccount.", + "certificateStatus": "Certificaatstatus", + "loading": "Bezig met laden", + "restart": "Herstarten", + "domains": "Domeinen", + "domainsDescription": "Beheer domeinen voor je organisatie", + "domainsSearch": "Zoek domeinen...", + "domainAdd": "Domein toevoegen", + "domainAddDescription": "Registreer een nieuw domein bij je organisatie", + "domainCreate": "Domein aanmaken", + "domainCreatedDescription": "Domein succesvol aangemaakt", + "domainDeletedDescription": "Domein succesvol verwijderd", + "domainQuestionRemove": "Weet je zeker dat je het domein {domain} uit je account wilt verwijderen?", + "domainMessageRemove": "Na verwijdering zal het domein niet langer aan je account gekoppeld zijn.", + "domainMessageConfirm": "Om te bevestigen, typ hieronder de domeinnaam.", + "domainConfirmDelete": "Bevestig verwijdering van domein", + "domainDelete": "Domein verwijderen", + "domain": "Domein", + "selectDomainTypeNsName": "Domeindelegatie (NS)", + "selectDomainTypeNsDescription": "Dit domein en al zijn subdomeinen. Gebruik dit wanneer je een volledige domeinzone wilt beheersen.", + "selectDomainTypeCnameName": "Enkel domein (CNAME)", + "selectDomainTypeCnameDescription": "Alleen dit specifieke domein. Gebruik dit voor individuele subdomeinen of specifieke domeinvermeldingen.", + "selectDomainTypeWildcardName": "Wildcard Domein", + "selectDomainTypeWildcardDescription": "Dit domein en zijn subdomeinen.", + "domainDelegation": "Enkel domein", + "selectType": "Selecteer een type", + "actions": "acties", + "refresh": "Vernieuwen", + "refreshError": "Het vernieuwen van gegevens is mislukt", + "verified": "Gecontroleerd", + "pending": "In afwachting", + "sidebarBilling": "Facturering", + "billing": "Facturering", + "orgBillingDescription": "Beheer je factureringsgegevens en abonnementen", + "github": "GitHub", + "pangolinHosted": "Pangolin gehost", + "fossorial": "Fossorial", + "completeAccountSetup": "Voltooi accountinstelling", + "completeAccountSetupDescription": "Stel je wachtwoord in om te beginnen", + "accountSetupSent": "We sturen een accountinstellingscode naar dit e-mailadres.", + "accountSetupCode": "Instellingscode", + "accountSetupCodeDescription": "Controleer je e-mail voor de instellingscode.", + "passwordCreate": "Wachtwoord aanmaken", + "passwordCreateConfirm": "Bevestig wachtwoord", + "accountSetupSubmit": "Instellingscode verzenden", + "completeSetup": "Voltooi instellen", + "accountSetupSuccess": "Accountinstelling voltooid! Welkom bij Pangolin!", + "documentation": "Documentatie", + "saveAllSettings": "Alle instellingen opslaan", + "settingsUpdated": "Instellingen bijgewerkt", + "settingsUpdatedDescription": "Alle instellingen zijn succesvol bijgewerkt", + "settingsErrorUpdate": "Bijwerken van instellingen mislukt", + "settingsErrorUpdateDescription": "Er is een fout opgetreden bij het bijwerken van instellingen", + "sidebarCollapse": "Inklappen", + "sidebarExpand": "Uitklappen", + "newtUpdateAvailable": "Update beschikbaar", + "newtUpdateAvailableInfo": "Er is een nieuwe versie van Newt beschikbaar. Update naar de nieuwste versie voor de beste ervaring.", + "domainPickerEnterDomain": "Domein", + "domainPickerPlaceholder": "mijnapp.voorbeeld.com, api.v1.mijndomein.com, of gewoon mijnapp", + "domainPickerDescription": "Voer de volledige domein van de bron in om beschikbare opties te zien.", + "domainPickerDescriptionSaas": "Voer een volledig domein, subdomein of gewoon een naam in om beschikbare opties te zien", + "domainPickerTabAll": "Alles", + "domainPickerTabOrganization": "Organisatie", + "domainPickerTabProvided": "Aangeboden", + "domainPickerSortAsc": "A-Z", + "domainPickerSortDesc": "Z-A", + "domainPickerCheckingAvailability": "Beschikbaarheid controleren...", + "domainPickerNoMatchingDomains": "Geen overeenkomende domeinen gevonden. Probeer een ander domein of controleer de domeininstellingen van uw organisatie.", + "domainPickerOrganizationDomains": "Organisatiedomeinen", + "domainPickerProvidedDomains": "Aangeboden domeinen", + "domainPickerSubdomain": "Subdomein: {subdomain}", + "domainPickerNamespace": "Namespace: {namespace}", + "domainPickerShowMore": "Meer weergeven", + "domainNotFound": "Domein niet gevonden", + "domainNotFoundDescription": "Deze bron is uitgeschakeld omdat het domein niet langer in ons systeem bestaat. Stel een nieuw domein in voor deze bron.", + "failed": "Mislukt", + "createNewOrgDescription": "Maak een nieuwe organisatie", + "organization": "Organisatie", + "port": "Poort", + "securityKeyManage": "Beveiligingssleutels beheren", + "securityKeyDescription": "Voeg beveiligingssleutels toe of verwijder ze voor wachtwoordloze authenticatie", + "securityKeyRegister": "Nieuwe beveiligingssleutel registreren", + "securityKeyList": "Uw beveiligingssleutels", + "securityKeyNone": "Nog geen beveiligingssleutels geregistreerd", + "securityKeyNameRequired": "Naam is verplicht", + "securityKeyRemove": "Verwijderen", + "securityKeyLastUsed": "Laatst gebruikt: {date}", + "securityKeyNameLabel": "Naam", + "securityKeyRegisterSuccess": "Beveiligingssleutel succesvol geregistreerd", + "securityKeyRegisterError": "Fout bij registreren van beveiligingssleutel", + "securityKeyRemoveSuccess": "Beveiligingssleutel succesvol verwijderd", + "securityKeyRemoveError": "Fout bij verwijderen van beveiligingssleutel", + "securityKeyLoadError": "Fout bij laden van beveiligingssleutels", + "securityKeyLogin": "Doorgaan met beveiligingssleutel", + "securityKeyAuthError": "Fout bij authenticatie met beveiligingssleutel", + "securityKeyRecommendation": "Overweeg om een andere beveiligingssleutel te registreren op een ander apparaat om ervoor te zorgen dat u niet buitengesloten raakt van uw account.", + "registering": "Registreren...", + "securityKeyPrompt": "Verifieer je identiteit met je beveiligingssleutel. Zorg ervoor dat je beveiligingssleutel verbonden en klaar is.", + "securityKeyBrowserNotSupported": "Je browser ondersteunt geen beveiligingssleutels. Gebruik een moderne browser zoals Chrome, Firefox of Safari.", + "securityKeyPermissionDenied": "Verleen toegang tot je beveiligingssleutel om door te gaan met inloggen.", + "securityKeyRemovedTooQuickly": "Houd je beveiligingssleutel verbonden totdat het inlogproces is voltooid.", + "securityKeyNotSupported": "Je beveiligingssleutel is mogelijk niet compatibel. Probeer een andere beveiligingssleutel.", + "securityKeyUnknownError": "Er was een probleem met het gebruik van je beveiligingssleutel. Probeer het opnieuw.", + "twoFactorRequired": "Tweestapsverificatie is vereist om een beveiligingssleutel te registreren.", + "twoFactor": "Tweestapsverificatie", + "adminEnabled2FaOnYourAccount": "Je beheerder heeft tweestapsverificatie voor {email} ingeschakeld. Voltooi het instellingsproces om verder te gaan.", + "continueToApplication": "Doorgaan naar de applicatie", + "securityKeyAdd": "Beveiligingssleutel toevoegen", + "securityKeyRegisterTitle": "Nieuwe beveiligingssleutel registreren", + "securityKeyRegisterDescription": "Verbind je beveiligingssleutel en voer een naam in om deze te identificeren", + "securityKeyTwoFactorRequired": "Tweestapsverificatie vereist", + "securityKeyTwoFactorDescription": "Voer je tweestapsverificatiecode in om de beveiligingssleutel te registreren", + "securityKeyTwoFactorRemoveDescription": "Voer je tweestapsverificatiecode in om de beveiligingssleutel te verwijderen", + "securityKeyTwoFactorCode": "Tweestapsverificatiecode", + "securityKeyRemoveTitle": "Beveiligingssleutel verwijderen", + "securityKeyRemoveDescription": "Voer je wachtwoord in om de beveiligingssleutel \"{name}\" te verwijderen", + "securityKeyNoKeysRegistered": "Geen beveiligingssleutels geregistreerd", + "securityKeyNoKeysDescription": "Voeg een beveiligingssleutel toe om je accountbeveiliging te verbeteren", + "createDomainRequired": "Domein is vereist", + "createDomainAddDnsRecords": "DNS-records toevoegen", + "createDomainAddDnsRecordsDescription": "Voeg de volgende DNS-records toe aan je domeinprovider om het instellen te voltooien.", + "createDomainNsRecords": "NS-records", + "createDomainRecord": "Record", + "createDomainType": "Type:", + "createDomainName": "Naam:", + "createDomainValue": "Waarde:", + "createDomainCnameRecords": "CNAME-records", + "createDomainARecords": "A Records", + "createDomainRecordNumber": "Record {number}", + "createDomainTxtRecords": "TXT-records", + "createDomainSaveTheseRecords": "Deze records opslaan", + "createDomainSaveTheseRecordsDescription": "Zorg ervoor dat je deze DNS-records opslaat, want je zult ze niet opnieuw zien.", + "createDomainDnsPropagation": "DNS-propagatie", + "createDomainDnsPropagationDescription": "DNS-wijzigingen kunnen enige tijd duren om over het internet te worden verspreid. Dit kan enkele minuten tot 48 uur duren, afhankelijk van je DNS-provider en TTL-instellingen.", + "resourcePortRequired": "Poortnummer is vereist voor niet-HTTP-bronnen", + "resourcePortNotAllowed": "Poortnummer mag niet worden ingesteld voor HTTP-bronnen", + "signUpTerms": { + "IAgreeToThe": "Ik ga akkoord met de", + "termsOfService": "servicevoorwaarden", + "and": "en", + "privacyPolicy": "privacybeleid" + }, + "siteRequired": "Site is vereist.", + "olmTunnel": "Olm Tunnel", + "olmTunnelDescription": "Gebruik Olm voor clientconnectiviteit", + "errorCreatingClient": "Fout bij het aanmaken van de client", + "clientDefaultsNotFound": "Standaardinstellingen van klant niet gevonden", + "createClient": "Client aanmaken", + "createClientDescription": "Maak een nieuwe client aan om verbinding te maken met uw sites", + "seeAllClients": "Alle clients bekijken", + "clientInformation": "Klantinformatie", + "clientNamePlaceholder": "Clientnaam", + "address": "Adres", + "subnetPlaceholder": "Subnet", + "addressDescription": "Het adres dat deze client zal gebruiken voor connectiviteit", + "selectSites": "Selecteer sites", + "sitesDescription": "De client heeft connectiviteit met de geselecteerde sites", + "clientInstallOlm": "Installeer Olm", + "clientInstallOlmDescription": "Laat Olm draaien op uw systeem", + "clientOlmCredentials": "Olm inloggegevens", + "clientOlmCredentialsDescription": "Dit is hoe Olm zich bij de server zal verifiëren", + "olmEndpoint": "Olm Eindpunt", + "olmId": "Olm ID", + "olmSecretKey": "Olm Geheime Sleutel", + "clientCredentialsSave": "Uw referenties opslaan", + "clientCredentialsSaveDescription": "Je kunt dit slechts één keer zien. Kopieer het naar een beveiligde plek.", + "generalSettingsDescription": "Configureer de algemene instellingen voor deze client", + "clientUpdated": "Klant bijgewerkt ", + "clientUpdatedDescription": "De client is bijgewerkt.", + "clientUpdateFailed": "Het bijwerken van de client is mislukt", + "clientUpdateError": "Er is een fout opgetreden tijdens het bijwerken van de client.", + "sitesFetchFailed": "Het ophalen van sites is mislukt", + "sitesFetchError": "Er is een fout opgetreden bij het ophalen van sites.", + "olmErrorFetchReleases": "Er is een fout opgetreden bij het ophalen van Olm releases.", + "olmErrorFetchLatest": "Er is een fout opgetreden bij het ophalen van de nieuwste Olm release.", + "remoteSubnets": "Externe Subnets", + "enterCidrRange": "Voer CIDR-bereik in", + "remoteSubnetsDescription": "Voeg CIDR-bereiken toe die deze site op afstand kunnen openen. Gebruik een format zoals 10.0.0.0/24 of 192.168.1.0/24.", + "resourceEnableProxy": "Openbare proxy inschakelen", + "resourceEnableProxyDescription": "Schakel publieke proxy in voor deze resource. Dit maakt toegang tot de resource mogelijk vanuit het netwerk via de cloud met een open poort. Vereist Traefik-configuratie.", + "externalProxyEnabled": "Externe Proxy Ingeschakeld" } diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 25cf2e6a..0f1eb29a 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -11,8 +11,9 @@ "componentsErrorNoMemberCreate": "Nie jesteś obecnie członkiem żadnej organizacji. Aby rozpocząć, utwórz organizację.", "componentsErrorNoMember": "Nie jesteś obecnie członkiem żadnej organizacji.", "welcome": "Witaj w Pangolinie", + "welcomeTo": "Witaj w", "componentsCreateOrg": "Utwórz organizację", - "componentsMember": "Jesteś członkiem {count, plural, =0 {Żadna organizacja} =1 {Jedna organizacja} other {# organizacji}}.", + "componentsMember": "Jesteś członkiem {count, plural, =0 {żadna organizacja} one {jedna organizacja} few {# organizacje} many {# organizacji} other {# organizacji}}.", "componentsInvalidKey": "Wykryto nieprawidłowe lub wygasłe klucze licencyjne. Postępuj zgodnie z warunkami licencji, aby kontynuować korzystanie ze wszystkich funkcji.", "dismiss": "Odrzuć", "componentsLicenseViolation": "Naruszenie licencji: Ten serwer używa stron {usedSites} , które przekraczają limit licencyjny stron {maxSites} . Postępuj zgodnie z warunkami licencji, aby kontynuować korzystanie ze wszystkich funkcji.", @@ -34,7 +35,7 @@ "createAccount": "Utwórz konto", "viewSettings": "Pokaż ustawienia", "delete": "Usuń", - "name": "Nazwisko", + "name": "Nazwa", "online": "Dostępny", "offline": "Offline", "site": "Witryna", @@ -58,7 +59,6 @@ "siteErrorCreate": "Błąd podczas tworzenia witryny", "siteErrorCreateKeyPair": "Nie znaleziono pary kluczy lub domyślnych ustawień witryny", "siteErrorCreateDefaults": "Nie znaleziono domyślnych ustawień witryny", - "siteNameDescription": "To jest wyświetlana nazwa witryny.", "method": "Metoda", "siteMethodDescription": "W ten sposób ujawnisz połączenia.", "siteLearnNewt": "Dowiedz się, jak zainstalować Newt w systemie", @@ -138,7 +138,7 @@ "resourceSearch": "Szukaj zasobów", "openMenu": "Otwórz menu", "resource": "Zasoby", - "title": "Rozporządzenie Rady (EWG) nr 2658/87 z dnia 23 lipca 1987 r. w sprawie nomenklatury taryfowej i statystycznej oraz w sprawie Wspólnej Taryfy Celnej (Dz.U. L 256 z 7.9.1987, s. 1).", + "title": "Tytuł", "created": "Utworzono", "expires": "Wygasa", "never": "Nigdy", @@ -206,6 +206,7 @@ "orgGeneralSettings": "Ustawienia organizacji", "orgGeneralSettingsDescription": "Zarządzaj szczegółami swojej organizacji i konfiguracją", "saveGeneralSettings": "Zapisz ustawienia ogólne", + "saveSettings": "Zapisz ustawienia", "orgDangerZone": "Strefa zagrożenia", "orgDangerZoneDescription": "Po usunięciu tego organa nie ma odwrotu. Upewnij się.", "orgDelete": "Usuń organizację", @@ -249,7 +250,7 @@ "weeks": "Tygodnie", "months": "Miesiące", "years": "Lata", - "day": "{count, plural, =1 {# dzień} other {# dni}}", + "day": "{count, plural, one {# dzień} few {# dni} many {# dni} other {# dni}}", "apiKeysTitle": "Informacje o kluczu API", "apiKeysConfirmCopy2": "Musisz potwierdzić, że skopiowałeś klucz API.", "apiKeysErrorCreate": "Błąd podczas tworzenia klucza API", @@ -347,7 +348,7 @@ "licensePurchase": "Kup licencję", "licensePurchaseSites": "Kup dodatkowe witryny", "licenseSitesUsedMax": "Użyte strony {usedSites} z {maxSites}", - "licenseSitesUsed": "{count, plural, =0 {# witryn} =1 {# witryn} other {# witryn}} w systemie.", + "licenseSitesUsed": "{count, plural, =0 {# witryn} one {# witryna} few {# witryny} many {# witryn} other {# witryn}} w systemie.", "licensePurchaseDescription": "Wybierz ile witryn chcesz {selectedMode, select, license {kupić licencję. Zawsze możesz dodać więcej witryn później.} other {dodaj do swojej istniejącej licencji.}}", "licenseFee": "Opłata licencyjna", "licensePriceSite": "Cena za witrynę", @@ -436,7 +437,7 @@ "accessRoleSelect": "Wybierz rolę", "inviteEmailSentDescription": "Email został wysłany do użytkownika z linkiem dostępu poniżej. Musi on uzyskać dostęp do linku, aby zaakceptować zaproszenie.", "inviteSentDescription": "Użytkownik został zaproszony. Musi uzyskać dostęp do poniższego linku, aby zaakceptować zaproszenie.", - "inviteExpiresIn": "Zaproszenie wygaśnie za {days, plural, =1 {# dzień} other {# dni}}.", + "inviteExpiresIn": "Zaproszenie wygaśnie za {days, plural, one {# dzień} few {# dni} many {# dni} other {# dni}}.", "idpTitle": "Informacje ogólne", "idpSelect": "Wybierz dostawcę tożsamości dla użytkownika zewnętrznego", "idpNotConfigured": "Nie skonfigurowano żadnych dostawców tożsamości. Skonfiguruj dostawcę tożsamości przed utworzeniem użytkowników zewnętrznych.", @@ -958,6 +959,8 @@ "licenseTierProfessionalRequiredDescription": "Ta funkcja jest dostępna tylko w edycji Professional.", "actionGetOrg": "Pobierz organizację", "actionUpdateOrg": "Aktualizuj organizację", + "actionUpdateUser": "Zaktualizuj użytkownika", + "actionGetUser": "Pobierz użytkownika", "actionGetOrgUser": "Pobierz użytkownika organizacji", "actionListOrgDomains": "Lista domen organizacji", "actionCreateSite": "Utwórz witrynę", @@ -1019,6 +1022,11 @@ "actionDeleteIdpOrg": "Usuń politykę organizacji IDP", "actionListIdpOrgs": "Lista organizacji IDP", "actionUpdateIdpOrg": "Aktualizuj organizację IDP", + "actionCreateClient": "Utwórz klienta", + "actionDeleteClient": "Usuń klienta", + "actionUpdateClient": "Aktualizuj klienta", + "actionListClients": "Lista klientów", + "actionGetClient": "Pobierz klienta", "noneSelected": "Nie wybrano", "orgNotFound2": "Nie znaleziono organizacji.", "searchProgress": "Szukaj...", @@ -1090,19 +1098,21 @@ "sidebarAllUsers": "Wszyscy użytkownicy", "sidebarIdentityProviders": "Dostawcy tożsamości", "sidebarLicense": "Licencja", + "sidebarClients": "Klienci (Beta)", + "sidebarDomains": "Domeny", "enableDockerSocket": "Włącz gniazdo dokera", "enableDockerSocketDescription": "Włącz wykrywanie Docker Socket w celu wypełnienia informacji o kontenerach. Ścieżka gniazda musi być dostarczona do Newt.", "enableDockerSocketLink": "Dowiedz się więcej", "viewDockerContainers": "Zobacz kontenery dokujące", "containersIn": "Pojemniki w {siteName}", "selectContainerDescription": "Wybierz dowolny kontener do użycia jako nazwa hosta dla tego celu. Kliknij port, aby użyć portu.", - "containerName": "Nazwisko", + "containerName": "Nazwa", "containerImage": "Obraz", "containerState": "Stan", "containerNetworks": "Sieci", "containerHostnameIp": "Nazwa hosta/IP", "containerLabels": "Etykiety", - "containerLabelsCount": "{count} etykieta{s,plural,one{} other{s}}", + "containerLabelsCount": "{count, plural, one {# etykieta} few {# etykiety} many {# etykiet} other {# etykiet}}", "containerLabelsTitle": "Etykiety kontenera", "containerLabelEmpty": "", "containerPorts": "Porty", @@ -1114,7 +1124,7 @@ "showStoppedContainers": "Pokaż zatrzymane kontenery", "noContainersFound": "Nie znaleziono kontenerów. Upewnij się, że kontenery dokujące są uruchomione.", "searchContainersPlaceholder": "Szukaj w {count} kontenerach...", - "searchResultsCount": "{count} wynik{s,plural,one{} other{s}}", + "searchResultsCount": "{count, plural, one {# wynik} few {# wyniki} many {# wyników} other {# wyników}}", "filters": "Filtry", "filterOptions": "Opcje filtru", "filterPorts": "Porty", @@ -1129,8 +1139,189 @@ "dark": "ciemny", "system": "System", "theme": "Motyw", + "subnetRequired": "Podsieć jest wymagana", "initialSetupTitle": "Wstępna konfiguracja serwera", "initialSetupDescription": "Utwórz początkowe konto administratora serwera. Może istnieć tylko jeden administrator serwera. Zawsze można zmienić te dane uwierzytelniające.", "createAdminAccount": "Utwórz konto administratora", - "setupErrorCreateAdmin": "Wystąpił błąd podczas tworzenia konta administratora serwera." + "setupErrorCreateAdmin": "Wystąpił błąd podczas tworzenia konta administratora serwera.", + "certificateStatus": "Status certyfikatu", + "loading": "Ładowanie", + "restart": "Uruchom ponownie", + "domains": "Domeny", + "domainsDescription": "Zarządzaj domenami swojej organizacji", + "domainsSearch": "Szukaj domen...", + "domainAdd": "Dodaj domenę", + "domainAddDescription": "Zarejestruj nową domenę w swojej organizacji", + "domainCreate": "Utwórz domenę", + "domainCreatedDescription": "Domena utworzona pomyślnie", + "domainDeletedDescription": "Domena usunięta pomyślnie", + "domainQuestionRemove": "Czy na pewno chcesz usunąć domenę {domain} ze swojego konta?", + "domainMessageRemove": "Po usunięciu domena nie będzie już powiązana z twoim kontem.", + "domainMessageConfirm": "Aby potwierdzić, wpisz nazwę domeny poniżej.", + "domainConfirmDelete": "Potwierdź usunięcie domeny", + "domainDelete": "Usuń domenę", + "domain": "Domena", + "selectDomainTypeNsName": "Delegacja domeny (NS)", + "selectDomainTypeNsDescription": "Ta domena i wszystkie jej subdomeny. Użyj tego, gdy chcesz kontrolować całą strefę domeny.", + "selectDomainTypeCnameName": "Pojedyncza domena (CNAME)", + "selectDomainTypeCnameDescription": "Tylko ta pojedyncza domena. Użyj tego dla poszczególnych subdomen lub wpisów specyficznych dla domeny.", + "selectDomainTypeWildcardName": "Domena wieloznaczna", + "selectDomainTypeWildcardDescription": "Ta domena i jej subdomeny.", + "domainDelegation": "Pojedyncza domena", + "selectType": "Wybierz typ", + "actions": "Akcje", + "refresh": "Odśwież", + "refreshError": "Nie udało się odświeżyć danych", + "verified": "Zatwierdzony", + "pending": "Oczekuje", + "sidebarBilling": "Fakturowanie", + "billing": "Fakturowanie", + "orgBillingDescription": "Zarządzaj swoimi informacjami rozliczeniowymi i subskrypcjami", + "github": "GitHub", + "pangolinHosted": "Logo Pangolin", + "fossorial": "Fossorial", + "completeAccountSetup": "Zakończ konfigurację konta", + "completeAccountSetupDescription": "Ustaw swoje hasło, aby rozpocząć", + "accountSetupSent": "Wyślemy kod konfiguracji konta na ten adres e-mail.", + "accountSetupCode": "Kod konfiguracji", + "accountSetupCodeDescription": "Sprawdź swój e-mail, aby znaleźć kod konfiguracji.", + "passwordCreate": "Utwórz hasło", + "passwordCreateConfirm": "Potwierdź hasło", + "accountSetupSubmit": "Wyślij kod konfiguracji", + "completeSetup": "Zakończ konfigurację", + "accountSetupSuccess": "Konfiguracja konta zakończona! Witaj w Pangolin!", + "documentation": "Dokumentacja", + "saveAllSettings": "Zapisz wszystkie ustawienia", + "settingsUpdated": "Ustawienia zaktualizowane", + "settingsUpdatedDescription": "Wszystkie ustawienia zostały pomyślnie zaktualizowane", + "settingsErrorUpdate": "Nie udało się zaktualizować ustawień", + "settingsErrorUpdateDescription": "Wystąpił błąd podczas aktualizacji ustawień", + "sidebarCollapse": "Zwiń", + "sidebarExpand": "Rozwiń", + "newtUpdateAvailable": "Dostępna aktualizacja", + "newtUpdateAvailableInfo": "Nowa wersja Newt jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.", + "domainPickerEnterDomain": "Domena", + "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com lub po prostu myapp", + "domainPickerDescription": "Wpisz pełną domenę zasobu, aby zobaczyć dostępne opcje.", + "domainPickerDescriptionSaas": "Wprowadź pełną domenę, subdomenę lub po prostu nazwę, aby zobaczyć dostępne opcje", + "domainPickerTabAll": "Wszystko", + "domainPickerTabOrganization": "Organizacja", + "domainPickerTabProvided": "Dostarczona", + "domainPickerSortAsc": "A-Z", + "domainPickerSortDesc": "Z-A", + "domainPickerCheckingAvailability": "Sprawdzanie dostępności...", + "domainPickerNoMatchingDomains": "Nie znaleziono pasujących domen. Spróbuj innej domeny lub sprawdź ustawienia domeny swojej organizacji.", + "domainPickerOrganizationDomains": "Domeny organizacji", + "domainPickerProvidedDomains": "Dostarczone domeny", + "domainPickerSubdomain": "Subdomena: {subdomain}", + "domainPickerNamespace": "Przestrzeń nazw: {namespace}", + "domainPickerShowMore": "Pokaż więcej", + "domainNotFound": "Nie znaleziono domeny", + "domainNotFoundDescription": "Zasób jest wyłączony, ponieważ domena nie istnieje już w naszym systemie. Proszę ustawić nową domenę dla tego zasobu.", + "failed": "Niepowodzenie", + "createNewOrgDescription": "Utwórz nową organizację", + "organization": "Organizacja", + "port": "Port", + "securityKeyManage": "Zarządzaj kluczami bezpieczeństwa", + "securityKeyDescription": "Dodaj lub usuń klucze bezpieczeństwa do uwierzytelniania bez hasła", + "securityKeyRegister": "Zarejestruj nowy klucz bezpieczeństwa", + "securityKeyList": "Twoje klucze bezpieczeństwa", + "securityKeyNone": "Brak zarejestrowanych kluczy bezpieczeństwa", + "securityKeyNameRequired": "Nazwa jest wymagana", + "securityKeyRemove": "Usuń", + "securityKeyLastUsed": "Ostatnio używany: {date}", + "securityKeyNameLabel": "Nazwa", + "securityKeyRegisterSuccess": "Klucz bezpieczeństwa został pomyślnie zarejestrowany", + "securityKeyRegisterError": "Błąd podczas rejestracji klucza bezpieczeństwa", + "securityKeyRemoveSuccess": "Klucz bezpieczeństwa został pomyślnie usunięty", + "securityKeyRemoveError": "Błąd podczas usuwania klucza bezpieczeństwa", + "securityKeyLoadError": "Błąd podczas ładowania kluczy bezpieczeństwa", + "securityKeyLogin": "Zaloguj się kluczem bezpieczeństwa", + "securityKeyAuthError": "Błąd podczas uwierzytelniania kluczem bezpieczeństwa", + "securityKeyRecommendation": "Rozważ zarejestrowanie innego klucza bezpieczeństwa na innym urządzeniu, aby upewnić się, że nie zostaniesz zablokowany z dostępu do swojego konta.", + "registering": "Rejestracja...", + "securityKeyPrompt": "Proszę zweryfikować swoją tożsamość, używając klucza bezpieczeństwa. Upewnij się, że twój klucz bezpieczeństwa jest podłączony i gotowy.", + "securityKeyBrowserNotSupported": "Twoja przeglądarka nie obsługuje kluczy bezpieczeństwa. Proszę użyć nowoczesnej przeglądarki, takiej jak Chrome, Firefox lub Safari.", + "securityKeyPermissionDenied": "Proszę umożliwić dostęp do klucza bezpieczeństwa, aby kontynuować logowanie.", + "securityKeyRemovedTooQuickly": "Proszę utrzymać klucz bezpieczeństwa podłączony, dopóki proces logowania się nie zakończy.", + "securityKeyNotSupported": "Twój klucz bezpieczeństwa może być niekompatybilny. Proszę spróbować innego klucza bezpieczeństwa.", + "securityKeyUnknownError": "Wystąpił problem z używaniem klucza bezpieczeństwa. Proszę spróbować ponownie.", + "twoFactorRequired": "Uwierzytelnianie dwuskładnikowe jest wymagane do zarejestrowania klucza bezpieczeństwa.", + "twoFactor": "Uwierzytelnianie dwuskładnikowe", + "adminEnabled2FaOnYourAccount": "Twój administrator włączył uwierzytelnianie dwuskładnikowe dla {email}. Proszę ukończyć proces konfiguracji, aby kontynuować.", + "continueToApplication": "Kontynuuj do aplikacji", + "securityKeyAdd": "Dodaj klucz bezpieczeństwa", + "securityKeyRegisterTitle": "Zarejestruj nowy klucz bezpieczeństwa", + "securityKeyRegisterDescription": "Podłącz swój klucz bezpieczeństwa i wprowadź nazwę, aby go zidentyfikować", + "securityKeyTwoFactorRequired": "Wymagane uwierzytelnianie dwuskładnikowe", + "securityKeyTwoFactorDescription": "Proszę wprowadzić kod uwierzytelnienia dwuskładnikowego, aby zarejestrować klucz bezpieczeństwa", + "securityKeyTwoFactorRemoveDescription": "Proszę wprowadzić kod uwierzytelnienia dwuskładnikowego, aby usunąć klucz bezpieczeństwa", + "securityKeyTwoFactorCode": "Kod dwuskładnikowy", + "securityKeyRemoveTitle": "Usuń klucz bezpieczeństwa", + "securityKeyRemoveDescription": "Wprowadź hasło, aby usunąć klucz bezpieczeństwa \"{name}\"", + "securityKeyNoKeysRegistered": "Nie zarejestrowano kluczy bezpieczeństwa", + "securityKeyNoKeysDescription": "Dodaj klucz bezpieczeństwa, aby zwiększyć swoje zabezpieczenia konta", + "createDomainRequired": "Domena jest wymagana", + "createDomainAddDnsRecords": "Dodaj rekordy DNS", + "createDomainAddDnsRecordsDescription": "Dodaj poniższe rekordy DNS do swojego dostawcy domeny, aby zakończyć konfigurację.", + "createDomainNsRecords": "Rekordy NS", + "createDomainRecord": "Rekord", + "createDomainType": "Typ:", + "createDomainName": "Nazwa:", + "createDomainValue": "Wartość:", + "createDomainCnameRecords": "Rekordy CNAME", + "createDomainARecords": "Rekordy A", + "createDomainRecordNumber": "Rekord {number}", + "createDomainTxtRecords": "Rekordy TXT", + "createDomainSaveTheseRecords": "Zapisz te rekordy", + "createDomainSaveTheseRecordsDescription": "Upewnij się, że zapiszesz te rekordy DNS, ponieważ nie będziesz mieć ich ponownie na ekranie.", + "createDomainDnsPropagation": "Propagacja DNS", + "createDomainDnsPropagationDescription": "Zmiany DNS mogą zająć trochę czasu na rozpropagowanie się w Internecie. Może to potrwać od kilku minut do 48 godzin, w zależności od dostawcy DNS i ustawień TTL.", + "resourcePortRequired": "Numer portu jest wymagany dla zasobów non-HTTP", + "resourcePortNotAllowed": "Numer portu nie powinien być ustawiony dla zasobów HTTP", + "signUpTerms": { + "IAgreeToThe": "Zgadzam się z", + "termsOfService": "warunkami usługi", + "and": "oraz", + "privacyPolicy": "polityką prywatności" + }, + "siteRequired": "Strona jest wymagana.", + "olmTunnel": "Tunel Olm", + "olmTunnelDescription": "Użyj Olm do łączności klienta", + "errorCreatingClient": "Błąd podczas tworzenia klienta", + "clientDefaultsNotFound": "Nie znaleziono domyślnych ustawień klienta", + "createClient": "Utwórz Klienta", + "createClientDescription": "Utwórz nowego klienta do łączenia się z Twoimi witrynami", + "seeAllClients": "Zobacz Wszystkich Klientów", + "clientInformation": "Informacje o Kliencie", + "clientNamePlaceholder": "Nazwa klienta", + "address": "Adres", + "subnetPlaceholder": "Podsieć", + "addressDescription": "Adres, którego ten klient będzie używać do łączności", + "selectSites": "Wybierz witryny", + "sitesDescription": "Klient będzie miał łączność z wybranymi witrynami", + "clientInstallOlm": "Zainstaluj Olm", + "clientInstallOlmDescription": "Uruchom Olm na swoim systemie", + "clientOlmCredentials": "Poświadczenia Olm", + "clientOlmCredentialsDescription": "To jest sposób, w jaki Olm będzie się uwierzytelniać z serwerem", + "olmEndpoint": "Punkt Końcowy Olm", + "olmId": "Identyfikator Olm", + "olmSecretKey": "Tajny Klucz Olm", + "clientCredentialsSave": "Zapisz swoje poświadczenia", + "clientCredentialsSaveDescription": "Będziesz mógł zobaczyć to tylko raz. Upewnij się, że skopiujesz go w bezpieczne miejsce.", + "generalSettingsDescription": "Skonfiguruj ogólne ustawienia dla tego klienta", + "clientUpdated": "Klient zaktualizowany", + "clientUpdatedDescription": "Klient został zaktualizowany.", + "clientUpdateFailed": "Nie udało się zaktualizować klienta", + "clientUpdateError": "Wystąpił błąd podczas aktualizacji klienta.", + "sitesFetchFailed": "Nie udało się pobrać witryn", + "sitesFetchError": "Wystąpił błąd podczas pobierania witryn.", + "olmErrorFetchReleases": "Wystąpił błąd podczas pobierania wydań Olm.", + "olmErrorFetchLatest": "Wystąpił błąd podczas pobierania najnowszego wydania Olm.", + "remoteSubnets": "Zdalne Podsieci", + "enterCidrRange": "Wprowadź zakres CIDR", + "remoteSubnetsDescription": "Dodaj zakresy CIDR, które mogą uzyskać zdalny dostęp do tej witryny. Użyj formatu takiego jak 10.0.0.0/24 lub 192.168.1.0/24.", + "resourceEnableProxy": "Włącz publiczny proxy", + "resourceEnableProxyDescription": "Włącz publiczne proxy dla tego zasobu. To umożliwia dostęp do zasobu spoza sieci przez chmurę na otwartym porcie. Wymaga konfiguracji Traefik.", + "externalProxyEnabled": "Zewnętrzny Proxy Włączony" } diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 69e650d5..9a3104bd 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -11,8 +11,9 @@ "componentsErrorNoMemberCreate": "Você não é atualmente um membro de nenhuma organização. Crie uma organização para começar.", "componentsErrorNoMember": "Você não é atualmente um membro de nenhuma organização.", "welcome": "Bem-vindo ao Pangolin", + "welcomeTo": "Bem-vindo ao", "componentsCreateOrg": "Criar uma organização", - "componentsMember": "Você é membro de {count, plural, =0 {Nenhuma organização} =1 {Uma organização} other {# organizações}}", + "componentsMember": "Você é membro de {count, plural, =0 {nenhuma organização} one {uma organização} other {# organizações}}.", "componentsInvalidKey": "Chaves de licença inválidas ou expiradas detectadas. Siga os termos da licença para continuar usando todos os recursos.", "dismiss": "Descartar", "componentsLicenseViolation": "Violação de Licença: Este servidor está usando sites {usedSites} que excedem o limite licenciado de sites {maxSites} . Siga os termos da licença para continuar usando todos os recursos.", @@ -58,7 +59,6 @@ "siteErrorCreate": "Erro ao criar site", "siteErrorCreateKeyPair": "Par de chaves ou padrões do site não encontrados", "siteErrorCreateDefaults": "Padrão do site não encontrado", - "siteNameDescription": "Este é o nome de exibição do site.", "method": "Método", "siteMethodDescription": "É assim que você irá expor as conexões.", "siteLearnNewt": "Saiba como instalar o Newt no seu sistema", @@ -206,6 +206,7 @@ "orgGeneralSettings": "Configurações da organização", "orgGeneralSettingsDescription": "Gerencie os detalhes e a configuração da sua organização", "saveGeneralSettings": "Salvar configurações gerais", + "saveSettings": "Salvar Configurações", "orgDangerZone": "Zona de Perigo", "orgDangerZoneDescription": "Uma vez que você exclui esta organização, não há volta. Por favor, tenha certeza.", "orgDelete": "Excluir Organização", @@ -249,7 +250,7 @@ "weeks": "semanas", "months": "Meses", "years": "anos", - "day": "{count, plural, =1 {# dia} other {# dias}}", + "day": "{count, plural, one {# dia} other {# dias}}", "apiKeysTitle": "Informações da Chave API", "apiKeysConfirmCopy2": "Você deve confirmar que copiou a chave API.", "apiKeysErrorCreate": "Erro ao criar chave API", @@ -347,7 +348,7 @@ "licensePurchase": "Comprar Licença", "licensePurchaseSites": "Comprar Sites Adicionais", "licenseSitesUsedMax": "{usedSites} de {maxSites} utilizados", - "licenseSitesUsed": "{count, plural, =0 {# sites} =1 {# site} other {# sites}} no sistema.", + "licenseSitesUsed": "{count, plural, =0 {# sites} one {# site} other {# sites}} no sistema.", "licensePurchaseDescription": "Escolha quantos sites você quer {selectedMode, select, license {Compre uma licença. Você sempre pode adicionar mais sites depois.} other {adicione à sua licença existente.}}", "licenseFee": "Taxa de licença", "licensePriceSite": "Preço por site", @@ -436,7 +437,7 @@ "accessRoleSelect": "Selecionar função", "inviteEmailSentDescription": "Um e-mail foi enviado ao usuário com o link de acesso abaixo. Eles devem acessar o link para aceitar o convite.", "inviteSentDescription": "O usuário foi convidado. Eles devem acessar o link abaixo para aceitar o convite.", - "inviteExpiresIn": "O convite expirará em {days, plural, =1 {# dia} other {# dias}}.", + "inviteExpiresIn": "O convite expirará em {days, plural, one {# dia} other {# dias}}.", "idpTitle": "Informações Gerais", "idpSelect": "Selecione o provedor de identidade para o usuário externo", "idpNotConfigured": "Nenhum provedor de identidade está configurado. Configure um provedor de identidade antes de criar usuários externos.", @@ -958,6 +959,8 @@ "licenseTierProfessionalRequiredDescription": "Esta funcionalidade só está disponível na Edição Profissional.", "actionGetOrg": "Obter Organização", "actionUpdateOrg": "Atualizar Organização", + "actionUpdateUser": "Atualizar Usuário", + "actionGetUser": "Obter Usuário", "actionGetOrgUser": "Obter Utilizador da Organização", "actionListOrgDomains": "Listar Domínios da Organização", "actionCreateSite": "Criar Site", @@ -1019,6 +1022,11 @@ "actionDeleteIdpOrg": "Eliminar Política de Organização IDP", "actionListIdpOrgs": "Listar Organizações IDP", "actionUpdateIdpOrg": "Atualizar Organização IDP", + "actionCreateClient": "Criar Cliente", + "actionDeleteClient": "Excluir Cliente", + "actionUpdateClient": "Atualizar Cliente", + "actionListClients": "Listar Clientes", + "actionGetClient": "Obter Cliente", "noneSelected": "Nenhum selecionado", "orgNotFound2": "Nenhuma organização encontrada.", "searchProgress": "Pesquisar...", @@ -1090,6 +1098,8 @@ "sidebarAllUsers": "Todos os usuários", "sidebarIdentityProviders": "Provedores de identidade", "sidebarLicense": "Tipo:", + "sidebarClients": "Clientes (Beta)", + "sidebarDomains": "Domínios", "enableDockerSocket": "Habilitar Docker Socket", "enableDockerSocketDescription": "Ativar a descoberta do Docker Socket para preencher informações do contêiner. O caminho do socket deve ser fornecido ao Newt.", "enableDockerSocketLink": "Saiba mais", @@ -1102,7 +1112,7 @@ "containerNetworks": "Redes", "containerHostnameIp": "Hostname/IP", "containerLabels": "Marcadores", - "containerLabelsCount": "{count} rótulo{s,plural,one{} other{s}}", + "containerLabelsCount": "{count, plural, one {# rótulo} other {# rótulos}}", "containerLabelsTitle": "Etiquetas do Contêiner", "containerLabelEmpty": "", "containerPorts": "Portas", @@ -1114,7 +1124,7 @@ "showStoppedContainers": "Mostrar contêineres parados", "noContainersFound": "Nenhum contêiner encontrado. Certifique-se de que os contêineres Docker estão em execução.", "searchContainersPlaceholder": "Pesquisar entre os contêineres {count}...", - "searchResultsCount": "{count} resultado{s,plural,one{} other{s}}", + "searchResultsCount": "{count, plural, one {# resultado} other {# resultados}}", "filters": "Filtros", "filterOptions": "Opções de Filtro", "filterPorts": "Portas", @@ -1129,8 +1139,189 @@ "dark": "escuro", "system": "sistema", "theme": "Tema", + "subnetRequired": "Sub-rede é obrigatória", "initialSetupTitle": "Configuração Inicial do Servidor", "initialSetupDescription": "Crie a conta de administrador inicial do servidor. Apenas um administrador do servidor pode existir. Você sempre pode alterar essas credenciais posteriormente.", "createAdminAccount": "Criar Conta de Administrador", - "setupErrorCreateAdmin": "Ocorreu um erro ao criar a conta de administrador do servidor." + "setupErrorCreateAdmin": "Ocorreu um erro ao criar a conta de administrador do servidor.", + "certificateStatus": "Status do Certificado", + "loading": "Carregando", + "restart": "Reiniciar", + "domains": "Domínios", + "domainsDescription": "Gerencie domínios para sua organização", + "domainsSearch": "Pesquisar domínios...", + "domainAdd": "Adicionar Domínio", + "domainAddDescription": "Registre um novo domínio com sua organização", + "domainCreate": "Criar Domínio", + "domainCreatedDescription": "Domínio criado com sucesso", + "domainDeletedDescription": "Domínio deletado com sucesso", + "domainQuestionRemove": "Tem certeza de que deseja remover o domínio {domain} da sua conta?", + "domainMessageRemove": "Uma vez removido, o domínio não estará mais associado à sua conta.", + "domainMessageConfirm": "Para confirmar, digite o nome do domínio abaixo.", + "domainConfirmDelete": "Confirmar Exclusão de Domínio", + "domainDelete": "Excluir Domínio", + "domain": "Domínio", + "selectDomainTypeNsName": "Delegação de Domínio (NS)", + "selectDomainTypeNsDescription": "Este domínio e todos os seus subdomínios. Use isso quando quiser controlar uma zona de domínio inteira.", + "selectDomainTypeCnameName": "Domínio Único (CNAME)", + "selectDomainTypeCnameDescription": "Apenas este domínio específico. Use isso para subdomínios individuais ou entradas de domínio específicas.", + "selectDomainTypeWildcardName": "Domínio Coringa", + "selectDomainTypeWildcardDescription": "Este domínio e seus subdomínios.", + "domainDelegation": "Domínio Único", + "selectType": "Selecione um tipo", + "actions": "Ações", + "refresh": "Atualizar", + "refreshError": "Falha ao atualizar dados", + "verified": "Verificado", + "pending": "Pendente", + "sidebarBilling": "Faturamento", + "billing": "Faturamento", + "orgBillingDescription": "Gerencie suas informações de faturamento e assinaturas", + "github": "GitHub", + "pangolinHosted": "Hospedagem Pangolin", + "fossorial": "Fossorial", + "completeAccountSetup": "Completar Configuração da Conta", + "completeAccountSetupDescription": "Defina sua senha para começar", + "accountSetupSent": "Enviaremos um código de ativação da conta para este endereço de e-mail.", + "accountSetupCode": "Código de Ativação", + "accountSetupCodeDescription": "Verifique seu e-mail para obter o código de ativação.", + "passwordCreate": "Criar Senha", + "passwordCreateConfirm": "Confirmar Senha", + "accountSetupSubmit": "Enviar Código de Ativação", + "completeSetup": "Configuração Completa", + "accountSetupSuccess": "Configuração da conta concluída! Bem-vindo ao Pangolin!", + "documentation": "Documentação", + "saveAllSettings": "Salvar Todas as Configurações", + "settingsUpdated": "Configurações atualizadas", + "settingsUpdatedDescription": "Todas as configurações foram atualizadas com sucesso", + "settingsErrorUpdate": "Falha ao atualizar configurações", + "settingsErrorUpdateDescription": "Ocorreu um erro ao atualizar configurações", + "sidebarCollapse": "Recolher", + "sidebarExpand": "Expandir", + "newtUpdateAvailable": "Nova Atualização Disponível", + "newtUpdateAvailableInfo": "Uma nova versão do Newt está disponível. Atualize para a versão mais recente para uma melhor experiência.", + "domainPickerEnterDomain": "Domínio", + "domainPickerPlaceholder": "meuapp.exemplo.com, api.v1.meudominio.com, ou apenas meuapp", + "domainPickerDescription": "Insira o domínio completo do recurso para ver as opções disponíveis.", + "domainPickerDescriptionSaas": "Insira um domínio completo, subdomínio ou apenas um nome para ver as opções disponíveis", + "domainPickerTabAll": "Todos", + "domainPickerTabOrganization": "Organização", + "domainPickerTabProvided": "Fornecido", + "domainPickerSortAsc": "A-Z", + "domainPickerSortDesc": "Z-A", + "domainPickerCheckingAvailability": "Verificando disponibilidade...", + "domainPickerNoMatchingDomains": "Nenhum domínio correspondente encontrado. Tente um domínio diferente ou verifique as configurações do domínio da sua organização.", + "domainPickerOrganizationDomains": "Domínios da Organização", + "domainPickerProvidedDomains": "Domínios Fornecidos", + "domainPickerSubdomain": "Subdomínio: {subdomain}", + "domainPickerNamespace": "Namespace: {namespace}", + "domainPickerShowMore": "Mostrar Mais", + "domainNotFound": "Domínio Não Encontrado", + "domainNotFoundDescription": "Este recurso está desativado porque o domínio não existe mais em nosso sistema. Defina um novo domínio para este recurso.", + "failed": "Falhou", + "createNewOrgDescription": "Crie uma nova organização", + "organization": "Organização", + "port": "Porta", + "securityKeyManage": "Gerenciar chaves de segurança", + "securityKeyDescription": "Adicionar ou remover chaves de segurança para autenticação sem senha", + "securityKeyRegister": "Registrar nova chave de segurança", + "securityKeyList": "Suas chaves de segurança", + "securityKeyNone": "Nenhuma chave de segurança registrada", + "securityKeyNameRequired": "Nome é obrigatório", + "securityKeyRemove": "Remover", + "securityKeyLastUsed": "Último uso: {date}", + "securityKeyNameLabel": "Nome", + "securityKeyRegisterSuccess": "Chave de segurança registrada com sucesso", + "securityKeyRegisterError": "Erro ao registrar chave de segurança", + "securityKeyRemoveSuccess": "Chave de segurança removida com sucesso", + "securityKeyRemoveError": "Erro ao remover chave de segurança", + "securityKeyLoadError": "Erro ao carregar chaves de segurança", + "securityKeyLogin": "Continuar com a chave de segurança", + "securityKeyAuthError": "Erro ao autenticar com chave de segurança", + "securityKeyRecommendation": "Considere registrar outra chave de segurança em um dispositivo diferente para garantir que você não fique bloqueado da sua conta.", + "registering": "Registrando...", + "securityKeyPrompt": "Verifique sua identidade usando sua chave de segurança. Certifique-se de que sua chave de segurança está conectada e pronta.", + "securityKeyBrowserNotSupported": "Seu navegador não suporta chaves de segurança. Use um navegador moderno como Chrome, Firefox ou Safari.", + "securityKeyPermissionDenied": "Permita o acesso à sua chave de segurança para continuar o login.", + "securityKeyRemovedTooQuickly": "Mantenha sua chave de segurança conectada até que o processo de login seja concluído.", + "securityKeyNotSupported": "Sua chave de segurança pode não ser compatível. Tente uma chave de segurança diferente.", + "securityKeyUnknownError": "Houve um problema ao usar sua chave de segurança. Tente novamente.", + "twoFactorRequired": "A autenticação de dois fatores é necessária para registrar uma chave de segurança.", + "twoFactor": "Autenticação de Dois Fatores", + "adminEnabled2FaOnYourAccount": "Seu administrador ativou a autenticação de dois fatores para {email}. Complete o processo de configuração para continuar.", + "continueToApplication": "Continuar para Aplicativo", + "securityKeyAdd": "Adicionar Chave de Segurança", + "securityKeyRegisterTitle": "Registrar Nova Chave de Segurança", + "securityKeyRegisterDescription": "Conecte sua chave de segurança e insira um nome para identificá-la", + "securityKeyTwoFactorRequired": "Autenticação de Dois Fatores Obrigatória", + "securityKeyTwoFactorDescription": "Insira seu código de autenticação de dois fatores para registrar a chave de segurança", + "securityKeyTwoFactorRemoveDescription": "Insira seu código de autenticação de dois fatores para remover a chave de segurança", + "securityKeyTwoFactorCode": "Código de Dois Fatores", + "securityKeyRemoveTitle": "Remover Chave de Segurança", + "securityKeyRemoveDescription": "Insira sua senha para remover a chave de segurança \"{name}\"", + "securityKeyNoKeysRegistered": "Nenhuma chave de segurança registrada", + "securityKeyNoKeysDescription": "Adicione uma chave de segurança para melhorar a segurança da sua conta", + "createDomainRequired": "Domínio é obrigatório", + "createDomainAddDnsRecords": "Adicionar Registros DNS", + "createDomainAddDnsRecordsDescription": "Adicione os seguintes registros DNS ao seu provedor de domínio para completar a configuração.", + "createDomainNsRecords": "Registros NS", + "createDomainRecord": "Registrar", + "createDomainType": "Tipo:", + "createDomainName": "Nome:", + "createDomainValue": "Valor:", + "createDomainCnameRecords": "Registros CNAME", + "createDomainARecords": "Registros A", + "createDomainRecordNumber": "Registrar {number}", + "createDomainTxtRecords": "Registros TXT", + "createDomainSaveTheseRecords": "Salvar Esses Registros", + "createDomainSaveTheseRecordsDescription": "Certifique-se de salvar esses registros DNS, pois você não os verá novamente.", + "createDomainDnsPropagation": "Propagação DNS", + "createDomainDnsPropagationDescription": "Alterações no DNS podem levar algum tempo para se propagar pela internet. Pode levar de alguns minutos a 48 horas, dependendo do seu provedor de DNS e das configurações de TTL.", + "resourcePortRequired": "Número da porta é obrigatório para recursos não-HTTP", + "resourcePortNotAllowed": "Número da porta não deve ser definido para recursos HTTP", + "signUpTerms": { + "IAgreeToThe": "Concordo com", + "termsOfService": "os termos de serviço", + "and": "e", + "privacyPolicy": "política de privacidade" + }, + "siteRequired": "Site é obrigatório.", + "olmTunnel": "Olm Tunnel", + "olmTunnelDescription": "Use Olm para conectividade do cliente", + "errorCreatingClient": "Erro ao criar cliente", + "clientDefaultsNotFound": "Padrões do cliente não encontrados", + "createClient": "Criar Cliente", + "createClientDescription": "Crie um novo cliente para conectar aos seus sites", + "seeAllClients": "Ver Todos os Clientes", + "clientInformation": "Informações do Cliente", + "clientNamePlaceholder": "Nome do cliente", + "address": "Endereço", + "subnetPlaceholder": "Sub-rede", + "addressDescription": "O endereço que este cliente usará para conectividade", + "selectSites": "Selecionar sites", + "sitesDescription": "O cliente terá conectividade com os sites selecionados", + "clientInstallOlm": "Instalar Olm", + "clientInstallOlmDescription": "Execute o Olm em seu sistema", + "clientOlmCredentials": "Credenciais Olm", + "clientOlmCredentialsDescription": "É assim que Olm se autenticará com o servidor", + "olmEndpoint": "Endpoint Olm", + "olmId": "ID Olm", + "olmSecretKey": "Chave Secreta Olm", + "clientCredentialsSave": "Salve suas Credenciais", + "clientCredentialsSaveDescription": "Você só poderá ver isto uma vez. Certifique-se de copiá-las para um local seguro.", + "generalSettingsDescription": "Configure as configurações gerais para este cliente", + "clientUpdated": "Cliente atualizado", + "clientUpdatedDescription": "O cliente foi atualizado.", + "clientUpdateFailed": "Falha ao atualizar cliente", + "clientUpdateError": "Ocorreu um erro ao atualizar o cliente.", + "sitesFetchFailed": "Falha ao buscar sites", + "sitesFetchError": "Ocorreu um erro ao buscar sites.", + "olmErrorFetchReleases": "Ocorreu um erro ao buscar lançamentos do Olm.", + "olmErrorFetchLatest": "Ocorreu um erro ao buscar o lançamento mais recente do Olm.", + "remoteSubnets": "Sub-redes Remotas", + "enterCidrRange": "Insira o intervalo CIDR", + "remoteSubnetsDescription": "Adicione intervalos CIDR que podem acessar este site remotamente. Use o formato como 10.0.0.0/24 ou 192.168.1.0/24.", + "resourceEnableProxy": "Ativar Proxy Público", + "resourceEnableProxyDescription": "Permite proxy público para este recurso. Isso permite o acesso ao recurso de fora da rede através da nuvem em uma porta aberta. Requer configuração do Traefik.", + "externalProxyEnabled": "Proxy Externo Habilitado" } diff --git a/messages/ru-RU.json b/messages/ru-RU.json new file mode 100644 index 00000000..90d3804d --- /dev/null +++ b/messages/ru-RU.json @@ -0,0 +1,1327 @@ +{ + "setupCreate": "Создайте свою организацию, сайт и ресурсы", + "setupNewOrg": "Новая организация", + "setupCreateOrg": "Создать организацию", + "setupCreateResources": "Создать ресурсы", + "setupOrgName": "Название организации", + "orgDisplayName": "Это отображаемое имя вашей организации.", + "orgId": "ID организации", + "setupIdentifierMessage": "Уникальный идентификатор вашей организации. Он задаётся отдельно от отображаемого имени.", + "setupErrorIdentifier": "ID организации уже занят. Выберите другой.", + "componentsErrorNoMemberCreate": "Вы пока не состоите ни в одной организации. Создайте организацию для начала работы.", + "componentsErrorNoMember": "Вы пока не состоите ни в одной организации.", + "welcome": "Добро пожаловать!", + "welcomeTo": "Добро пожаловать в", + "componentsCreateOrg": "Создать организацию", + "componentsMember": "Вы состоите в {count, plural, =0 {0 организациях} one {# организации} few {# организациях} many {# организациях} other {# организациях}}.", + "componentsInvalidKey": "Обнаружены недействительные или просроченные лицензионные ключи. Соблюдайте условия лицензии для использования всех функций.", + "dismiss": "Отменить", + "componentsLicenseViolation": "Нарушение лицензии: Сервер использует {usedSites} сайтов, что превышает лицензионный лимит в {maxSites} сайтов. Соблюдайте условия лицензии для использования всех функций.", + "componentsSupporterMessage": "Спасибо за поддержку Pangolin в качестве {tier}!", + "inviteErrorNotValid": "Извините, но это приглашение не было принято или срок его действия истёк.", + "inviteErrorUser": "Извините, но приглашение, к которому вы пытаетесь получить доступ, предназначено не для этого пользователя.", + "inviteLoginUser": "Убедитесь, что вы вошли под правильным пользователем.", + "inviteErrorNoUser": "Извините, но похоже, что приглашение, к которому вы пытаетесь получить доступ, предназначено для несуществующего пользователя.", + "inviteCreateUser": "Сначала создайте аккаунт.", + "goHome": "На главную", + "inviteLogInOtherUser": "Войти под другим пользователем", + "createAnAccount": "Создать учётную запись", + "inviteNotAccepted": "Приглашение не принято", + "authCreateAccount": "Создайте учётную запись для начала работы", + "authNoAccount": "Нет учётной записи?", + "email": "Email", + "password": "Пароль", + "confirmPassword": "Подтвердите пароль", + "createAccount": "Создать учётную запись", + "viewSettings": "Посмотреть настройки", + "delete": "Удалить", + "name": "Имя", + "online": "Онлайн", + "offline": "Офлайн", + "site": "Сайт", + "dataIn": "Входящий трафик", + "dataOut": "Исходящий трафик", + "connectionType": "Тип соединения", + "tunnelType": "Тип туннеля", + "local": "Локальный", + "edit": "Редактировать", + "siteConfirmDelete": "Подтвердить удаление сайта", + "siteDelete": "Удалить сайт", + "siteMessageRemove": "После удаления сайт больше не будет доступен. Все ресурсы и целевые узлы, связанные с сайтом, также будут удалены.", + "siteMessageConfirm": "Для подтверждения введите название сайта ниже.", + "siteQuestionRemove": "Вы уверены, что хотите удалить сайт {selectedSite} из организации?", + "siteManageSites": "Управление сайтами", + "siteDescription": "Обеспечьте подключение к вашей сети через защищённые туннели", + "siteCreate": "Создать сайт", + "siteCreateDescription2": "Следуйте инструкциям ниже для создания и подключения нового сайта", + "siteCreateDescription": "Создайте новый сайт для подключения ваших ресурсов", + "close": "Закрыть", + "siteErrorCreate": "Ошибка при создании сайта", + "siteErrorCreateKeyPair": "Пара ключей или настройки сайта по умолчанию не найдены", + "siteErrorCreateDefaults": "Настройки сайта по умолчанию не найдены", + "method": "Метод", + "siteMethodDescription": "Это способ, которым вы будете открывать соединения.", + "siteLearnNewt": "Узнайте, как установить Newt в вашей системе", + "siteSeeConfigOnce": "Вы сможете увидеть конфигурацию только один раз.", + "siteLoadWGConfig": "Загрузка конфигурации WireGuard...", + "siteDocker": "Развернуть для просмотра деталей развертывания Docker", + "toggle": "Переключить", + "dockerCompose": "Docker Compose", + "dockerRun": "Docker Run", + "siteLearnLocal": "Локальные сайты не создают туннели, узнать больше", + "siteConfirmCopy": "Я скопировал(а) конфигурацию", + "searchSitesProgress": "Поиск сайтов...", + "siteAdd": "Добавить сайт", + "siteInstallNewt": "Установить Newt", + "siteInstallNewtDescription": "Запустите Newt в вашей системе", + "WgConfiguration": "Конфигурация WireGuard", + "WgConfigurationDescription": "Используйте следующую конфигурацию для подключения к вашей сети", + "operatingSystem": "Операционная система", + "commands": "Команды", + "recommended": "Рекомендуется", + "siteNewtDescription": "Для лучшего пользовательского опыта используйте Newt. Он использует WireGuard под капотом и позволяет обращаться к вашим приватным ресурсам по их LAN-адресу в вашей частной сети прямо из панели управления Pangolin.", + "siteRunsInDocker": "Работает в Docker", + "siteRunsInShell": "Работает в оболочке на macOS, Linux и Windows", + "siteErrorDelete": "Ошибка при удалении сайта", + "siteErrorUpdate": "Не удалось обновить сайт", + "siteErrorUpdateDescription": "Произошла ошибка при обновлении сайта.", + "siteUpdated": "Сайт обновлён", + "siteUpdatedDescription": "Сайт был успешно обновлён.", + "siteGeneralDescription": "Настройте общие параметры для этого сайта", + "siteSettingDescription": "Настройте параметры вашего сайта", + "siteSetting": "Настройки {siteName}", + "siteNewtTunnel": "Туннель Newt (Рекомендуется)", + "siteNewtTunnelDescription": "Простейший способ создать точку входа в вашу сеть. Дополнительная настройка не требуется.", + "siteWg": "Базовый WireGuard", + "siteWgDescription": "Используйте любой клиент WireGuard для открытия туннеля. Требуется ручная настройка NAT.", + "siteLocalDescription": "Только локальные ресурсы. Без туннелирования.", + "siteSeeAll": "Просмотреть все сайты", + "siteTunnelDescription": "Выберите способ подключения к вашему сайту", + "siteNewtCredentials": "Учётные данные Newt", + "siteNewtCredentialsDescription": "Так Newt будет выполнять аутентификацию на сервере", + "siteCredentialsSave": "Сохраните ваши учётные данные", + "siteCredentialsSaveDescription": "Вы сможете увидеть эти данные только один раз. Обязательно скопируйте их в безопасное место.", + "siteInfo": "Информация о сайте", + "status": "Статус", + "shareTitle": "Управление общими ссылками", + "shareDescription": "Создавайте общие ссылки для предоставления временного или постоянного доступа к вашим ресурсам", + "shareSearch": "Поиск общих ссылок...", + "shareCreate": "Создать общую ссылку", + "shareErrorDelete": "Не удалось удалить ссылку", + "shareErrorDeleteMessage": "Произошла ошибка при удалении ссылки", + "shareDeleted": "Ссылка удалена", + "shareDeletedDescription": "Ссылка была успешно удалена", + "shareTokenDescription": "Ваш токен доступа может быть передан двумя способами: как параметр запроса или в заголовках запроса. Он должен передаваться клиентом при каждом запросе для аутентификации.", + "accessToken": "Токен доступа", + "usageExamples": "Примеры использования", + "tokenId": "ID токена", + "requestHeades": "Заголовки запроса", + "queryParameter": "Параметр запроса", + "importantNote": "Важное примечание", + "shareImportantDescription": "Из соображений безопасности рекомендуется использовать заголовки вместо параметров запроса, когда это возможно, так как параметры запроса могут сохраняться в логах сервера или истории браузера.", + "token": "Токен", + "shareTokenSecurety": "Храните ваш токен доступа в безопасности. Не делитесь им в общедоступных местах или клиентском коде.", + "shareErrorFetchResource": "Не удалось получить ресурсы", + "shareErrorFetchResourceDescription": "Произошла ошибка при получении ресурсов", + "shareErrorCreate": "Не удалось создать общую ссылку", + "shareErrorCreateDescription": "Произошла ошибка при создании общей ссылки", + "shareCreateDescription": "Любой, у кого есть эта ссылка, может получить доступ к ресурсу", + "shareTitleOptional": "Заголовок (необязательно)", + "expireIn": "Срок действия", + "neverExpire": "Бессрочный доступ", + "shareExpireDescription": "Срок действия - это период, в течение которого ссылка будет работать и предоставлять доступ к ресурсу. После этого времени ссылка перестанет работать, и пользователи, использовавшие эту ссылку, потеряют доступ к ресурсу.", + "shareSeeOnce": "Вы сможете увидеть эту ссылку только один раз. Обязательно скопируйте её.", + "shareAccessHint": "Любой, у кого есть эта ссылка, может получить доступ к ресурсу. Делитесь ею с осторожностью.", + "shareTokenUsage": "Посмотреть использование токена доступа", + "createLink": "Создать ссылку", + "resourcesNotFound": "Ресурсы не найдены", + "resourceSearch": "Поиск ресурсов", + "openMenu": "Открыть меню", + "resource": "Ресурс", + "title": "Заголовок", + "created": "Создан", + "expires": "Истекает", + "never": "Никогда", + "shareErrorSelectResource": "Пожалуйста, выберите ресурс", + "resourceTitle": "Управление ресурсами", + "resourceDescription": "Создавайте защищённые прокси к вашим приватным приложениям", + "resourcesSearch": "Поиск ресурсов...", + "resourceAdd": "Добавить ресурс", + "resourceErrorDelte": "Ошибка при удалении ресурса", + "authentication": "Аутентификация", + "protected": "Защищён", + "notProtected": "Не защищён", + "resourceMessageRemove": "После удаления ресурс больше не будет доступен. Все целевые узлы, связанные с ресурсом, также будут удалены.", + "resourceMessageConfirm": "Для подтверждения введите название ресурса ниже.", + "resourceQuestionRemove": "Вы действительно хотите удалить ресурс {selectedResource} из организации?", + "resourceHTTP": "HTTPS-ресурс", + "resourceHTTPDescription": "Проксирование запросов к вашему приложению через HTTPS с использованием поддомена или базового домена.", + "resourceRaw": "Сырой TCP/UDP-ресурс", + "resourceRawDescription": "Проксирование запросов к вашему приложению через TCP/UDP с использованием по номеру порта.", + "resourceCreate": "Создание ресурса", + "resourceCreateDescription": "Следуйте инструкциям ниже для создания нового ресурса", + "resourceSeeAll": "Посмотреть все ресурсы", + "resourceInfo": "Информация о ресурсе", + "resourceNameDescription": "Отображаемое имя ресурса.", + "siteSelect": "Выберите сайт", + "siteSearch": "Поиск сайта", + "siteNotFound": "Сайт не найден.", + "siteSelectionDescription": "Этот сайт обеспечит подключение к ресурсу.", + "resourceType": "Тип ресурса", + "resourceTypeDescription": "Определите, как вы хотите получать доступ к вашему ресурсу", + "resourceHTTPSSettings": "Настройки HTTPS", + "resourceHTTPSSettingsDescription": "Настройте, как будет осуществляться доступ к вашему ресурсу через HTTPS", + "domainType": "Тип домена", + "subdomain": "Поддомен", + "baseDomain": "Базовый домен", + "subdomnainDescription": "Поддомен, на котором будет доступен ресурс.", + "resourceRawSettings": "Настройки TCP/UDP", + "resourceRawSettingsDescription": "Настройте, как будет осуществляться доступ к вашему ресурсу через TCP/UDP", + "protocol": "Протокол", + "protocolSelect": "Выберите протокол", + "resourcePortNumber": "Номер порта", + "resourcePortNumberDescription": "Внешний номер порта для проксирования запросов.", + "cancel": "Отмена", + "resourceConfig": "Фрагменты конфигурации", + "resourceConfigDescription": "Скопируйте и вставьте эти фрагменты конфигурации для настройки вашего TCP/UDP-ресурса", + "resourceAddEntrypoints": "Traefik: Добавить точки входа", + "resourceExposePorts": "Gerbil: Открыть порты в Docker Compose", + "resourceLearnRaw": "Узнайте, как настроить TCP/UDP-ресурсы", + "resourceBack": "Назад к ресурсам", + "resourceGoTo": "Перейти к ресурсу", + "resourceDelete": "Удалить ресурс", + "resourceDeleteConfirm": "Подтвердить удаление", + "visibility": "Видимость", + "enabled": "Включено", + "disabled": "Отключено", + "general": "Общие", + "generalSettings": "Общие настройки", + "proxy": "Прокси", + "rules": "Правила", + "resourceSettingDescription": "Настройте параметры вашего ресурса", + "resourceSetting": "Настройки {resourceName}", + "alwaysAllow": "Всегда разрешать", + "alwaysDeny": "Всегда запрещать", + "orgSettingsDescription": "Настройте общие параметры вашей организации", + "orgGeneralSettings": "Настройки организации", + "orgGeneralSettingsDescription": "Управляйте данными и конфигурацией вашей организации", + "saveGeneralSettings": "Сохранить общие настройки", + "saveSettings": "Сохранить настройки", + "orgDangerZone": "Опасная зона", + "orgDangerZoneDescription": "Будьте осторожны: удалив организацию, вы не сможете восстановить её.", + "orgDelete": "Удалить организацию", + "orgDeleteConfirm": "Подтвердить удаление", + "orgMessageRemove": "Это действие необратимо и удалит все связанные данные.", + "orgMessageConfirm": "Для подтверждения введите название организации ниже.", + "orgQuestionRemove": "Вы действительно хотите удалить организацию {selectedOrg}?", + "orgUpdated": "Организация обновлена", + "orgUpdatedDescription": "Организация была успешно обновлена.", + "orgErrorUpdate": "Не удалось обновить организацию", + "orgErrorUpdateMessage": "Произошла ошибка при обновлении организации.", + "orgErrorFetch": "Не удалось получить организации", + "orgErrorFetchMessage": "Произошла ошибка при получении списка ваших организаций", + "orgErrorDelete": "Не удалось удалить организацию", + "orgErrorDeleteMessage": "Произошла ошибка при удалении организации.", + "orgDeleted": "Организация удалена", + "orgDeletedMessage": "Организация и её данные были удалены.", + "orgMissing": "Отсутствует ID организации", + "orgMissingMessage": "Невозможно восстановить приглашение без ID организации.", + "accessUsersManage": "Управление пользователями", + "accessUsersDescription": "Приглашайте пользователей и назначайте им роли для управления доступом к вашей организации", + "accessUsersSearch": "Поиск пользователей...", + "accessUserCreate": "Создать пользователя", + "accessUserRemove": "Удалить пользователя", + "username": "Имя пользователя", + "identityProvider": "Identity Provider", + "role": "Роль", + "nameRequired": "Имя обязательно", + "accessRolesManage": "Управление ролями", + "accessRolesDescription": "Настройте роли для управления доступом к вашей организации", + "accessRolesSearch": "Поиск ролей...", + "accessRolesAdd": "Добавить роль", + "accessRoleDelete": "Удалить роль", + "description": "Описание", + "inviteTitle": "Открытые приглашения", + "inviteDescription": "Управляйте вашими приглашениями для других пользователей", + "inviteSearch": "Поиск приглашений...", + "minutes": "мин.", + "hours": "ч.", + "days": "д.", + "weeks": "нед.", + "months": "мес.", + "years": "г.", + "day": "{count, plural, one {# день} few {# дня} many {# дней} other {# дней}}", + "apiKeysTitle": "Информация о ключе API", + "apiKeysConfirmCopy2": "Подтверидте, что вы скопировали ключ API.", + "apiKeysErrorCreate": "Ошибка при создании ключа API", + "apiKeysErrorSetPermission": "Ошибка при установке разрешений", + "apiKeysCreate": "Сгенерировать ключ API", + "apiKeysCreateDescription": "Сгенерируйте новый ключ API для вашей организации", + "apiKeysGeneralSettings": "Разрешения", + "apiKeysGeneralSettingsDescription": "Определите, что может делать этот ключ API", + "apiKeysList": "Ваш ключ API", + "apiKeysSave": "Сохраните ваш ключ API", + "apiKeysSaveDescription": "Вы сможете увидеть этот ключ только один раз. Обязательно скопируйте его в безопасное место.", + "apiKeysInfo": "Ваш ключ API:", + "apiKeysConfirmCopy": "Я скопировал(а) ключ API", + "generate": "Сгенерировать", + "done": "Готово", + "apiKeysSeeAll": "Посмотреть все ключи API", + "apiKeysPermissionsErrorLoadingActions": "Ошибка загрузки действий ключа API", + "apiKeysPermissionsErrorUpdate": "Ошибка установки разрешений", + "apiKeysPermissionsUpdated": "Разрешения обновлены", + "apiKeysPermissionsUpdatedDescription": "Разрешения были успешно обновлены.", + "apiKeysPermissionsGeneralSettings": "Разрешения", + "apiKeysPermissionsGeneralSettingsDescription": "Определите, что может делать этот ключ API", + "apiKeysPermissionsSave": "Сохранить разрешения", + "apiKeysPermissionsTitle": "Разрешения", + "apiKeys": "Ключи API", + "searchApiKeys": "Поиск ключей API...", + "apiKeysAdd": "Сгенерировать ключ API", + "apiKeysErrorDelete": "Ошибка при удалении ключа API", + "apiKeysErrorDeleteMessage": "Не удалось удалить ключ API", + "apiKeysQuestionRemove": "Вы действительно хотите удалить ключ API {selectedApiKey} из организации?", + "apiKeysMessageRemove": "После удаления ключ API больше сможет быть использован.", + "apiKeysMessageConfirm": "Для подтверждения введите название ключа API ниже.", + "apiKeysDeleteConfirm": "Подтвердить удаление", + "apiKeysDelete": "Удаление ключа API", + "apiKeysManage": "Управление ключами API", + "apiKeysDescription": "Ключи API используются для аутентификации в интеграционном API", + "apiKeysSettings": "Настройки {apiKeyName}", + "userTitle": "Управление всеми пользователями", + "userDescription": "Просмотр и управление всеми пользователями в системе", + "userAbount": "Об управлении пользователями", + "userAbountDescription": "В этой таблице отображаются все корневые объекты пользователей в системе. Каждый пользователь может принадлежать нескольким организациям. Удаление пользователя из организации не удаляет его корневой объект - он останется в системе. Чтобы полностью удалить пользователя из системы, вы должны удалить его корневой объект, используя действие удаления в этой таблице.", + "userServer": "Пользователи сервера", + "userSearch": "Поиск пользователей сервера...", + "userErrorDelete": "Ошибка при удалении пользователя", + "userDeleteConfirm": "Подтвердить удаление", + "userDeleteServer": "Удаление пользователя с сервера", + "userMessageRemove": "Пользователь будет удалён из всех организаций и полностью удалён с сервера.", + "userMessageConfirm": "Для подтверждения введите имя пользователя ниже.", + "userQuestionRemove": "Вы действительно хотите навсегда удалить {selectedUser} с сервера?", + "licenseKey": "Лицензионный ключ", + "valid": "Действителен", + "numberOfSites": "Количество сайтов", + "licenseKeySearch": "Поиск лицензионных ключей...", + "licenseKeyAdd": "Добавить лицензионный ключ", + "type": "Тип", + "licenseKeyRequired": "Лицензионный ключ обязателен", + "licenseTermsAgree": "Вы должны согласиться с условиями лицензии", + "licenseErrorKeyLoad": "Не удалось загрузить лицензионные ключи", + "licenseErrorKeyLoadDescription": "Произошла ошибка при загрузке лицензионных ключей.", + "licenseErrorKeyDelete": "Не удалось удалить лицензионный ключ", + "licenseErrorKeyDeleteDescription": "Произошла ошибка при удалении лицензионного ключа.", + "licenseKeyDeleted": "Лицензионный ключ удалён", + "licenseKeyDeletedDescription": "Лицензионный ключ был удалён.", + "licenseErrorKeyActivate": "Не удалось активировать лицензионный ключ", + "licenseErrorKeyActivateDescription": "Произошла ошибка при активации лицензионного ключа.", + "licenseAbout": "О лицензировании", + "communityEdition": "Community Edition", + "licenseAboutDescription": "Это для бизнес и корпоративных пользователей, использующих Pangolin в коммерческой среде. Если вы используете Pangolin для личного использования, вы можете игнорировать этот раздел.", + "licenseKeyActivated": "Лицензионный ключ активирован", + "licenseKeyActivatedDescription": "Лицензионный ключ был успешно активирован.", + "licenseErrorKeyRecheck": "Не удалось перепроверить лицензионные ключи", + "licenseErrorKeyRecheckDescription": "Произошла ошибка при перепроверке лицензионных ключей.", + "licenseErrorKeyRechecked": "Лицензионные ключи перепроверены", + "licenseErrorKeyRecheckedDescription": "Все лицензионные ключи были перепроверены", + "licenseActivateKey": "Активировать лицензионный ключ", + "licenseActivateKeyDescription": "Введите лицензионный ключ для его активации.", + "licenseActivate": "Активировать лицензию", + "licenseAgreement": "Установив этот флажок, вы подтверждаете, что прочитали и согласны с условиями лицензии, соответствующими уровню, связанному с вашим лицензионным ключом.", + "fossorialLicense": "Просмотреть коммерческую лицензию Fossorial и условия подписки", + "licenseMessageRemove": "Это удалит лицензионный ключ и все связанные с ним разрешения.", + "licenseMessageConfirm": "Для подтверждения введите лицензионный ключ ниже.", + "licenseQuestionRemove": "Вы уверены, что хотите удалить лицензионный ключ {selectedKey}?", + "licenseKeyDelete": "Удалить лицензионный ключ", + "licenseKeyDeleteConfirm": "Подтвердить удаление лицензионного ключа", + "licenseTitle": "Управление статусом лицензии", + "licenseTitleDescription": "Просмотр и управление лицензионными ключами в системе", + "licenseHost": "Лицензия хоста", + "licenseHostDescription": "Управление основным лицензионным ключом для хоста.", + "licensedNot": "Не лицензировано", + "hostId": "ID хоста", + "licenseReckeckAll": "Перепроверить все ключи", + "licenseSiteUsage": "Использование сайтов", + "licenseSiteUsageDecsription": "Просмотр количества сайтов, использующих эту лицензию.", + "licenseNoSiteLimit": "Нет ограничения на количество сайтов при использовании нелицензированного хоста.", + "licensePurchase": "Приобрести лицензию", + "licensePurchaseSites": "Приобрести дополнительные сайты", + "licenseSitesUsedMax": "Использовано сайтов: {usedSites} из {maxSites}", + "licenseSitesUsed": "{count, plural, =0 {0 сайтов} one {# сайт} few {# сайта} many {# сайтов} other {# сайтов}} в системе.", + "licensePurchaseDescription": "Выберите, для скольких сайтов вы хотите {selectedMode, select, license {приобрести лицензию. Вы всегда можете добавить больше сайтов позже.} other {добавить к существующей лицензии.}}", + "licenseFee": "Лицензионный сбор", + "licensePriceSite": "Цена за сайт", + "total": "Итого", + "licenseContinuePayment": "Перейти к оплате", + "pricingPage": "страница цен", + "pricingPortal": "Посмотреть портал покупок", + "licensePricingPage": "Для актуальных цен и скидок посетите ", + "invite": "Приглашения", + "inviteRegenerate": "Пересоздать приглашение", + "inviteRegenerateDescription": "Отозвать предыдущее приглашение и создать новое", + "inviteRemove": "Удалить приглашение", + "inviteRemoveError": "Не удалось удалить приглашение", + "inviteRemoveErrorDescription": "Произошла ошибка при удалении приглашения.", + "inviteRemoved": "Приглашение удалено", + "inviteRemovedDescription": "Приглашение для {email} было удалено.", + "inviteQuestionRemove": "Вы уверены, что хотите удалить приглашение {email}?", + "inviteMessageRemove": "После удаления это приглашение больше не будет действительным. Вы всегда можете пригласить пользователя заново.", + "inviteMessageConfirm": "Для подтверждения введите email адрес приглашения ниже.", + "inviteQuestionRegenerate": "Вы уверены, что хотите пересоздать приглашение для {email}? Это отзовёт предыдущее приглашение.", + "inviteRemoveConfirm": "Подтвердить удаление приглашения", + "inviteRegenerated": "Приглашение пересоздано", + "inviteSent": "Новое приглашение отправлено {email}.", + "inviteSentEmail": "Отправить email уведомление пользователю", + "inviteGenerate": "Новое приглашение создано для {email}.", + "inviteDuplicateError": "Дублирующее приглашение", + "inviteDuplicateErrorDescription": "Приглашение для этого пользователя уже существует.", + "inviteRateLimitError": "Превышен лимит запросов", + "inviteRateLimitErrorDescription": "Вы превысили лимит в 3 пересоздания в час. Попробуйте позже.", + "inviteRegenerateError": "Не удалось пересоздать приглашение", + "inviteRegenerateErrorDescription": "Произошла ошибка при пересоздании приглашения.", + "inviteValidityPeriod": "Период действия", + "inviteValidityPeriodSelect": "Выберите период действия", + "inviteRegenerateMessage": "Приглашение было пересоздано. Пользователь должен перейти по ссылке ниже для принятия приглашения.", + "inviteRegenerateButton": "Пересоздать", + "expiresAt": "Истекает", + "accessRoleUnknown": "Неизвестная роль", + "placeholder": "Заполнитель", + "userErrorOrgRemove": "Не удалось удалить пользователя", + "userErrorOrgRemoveDescription": "Произошла ошибка при удалении пользователя.", + "userOrgRemoved": "Пользователь удалён", + "userOrgRemovedDescription": "Пользователь {email} был удалён из организации.", + "userQuestionOrgRemove": "Вы уверены, что хотите удалить {email} из организации?", + "userMessageOrgRemove": "После удаления этот пользователь больше не будет иметь доступ к организации. Вы всегда можете пригласить его заново, но ему нужно будет снова принять приглашение.", + "userMessageOrgConfirm": "Для подтверждения введите имя пользователя ниже.", + "userRemoveOrgConfirm": "Подтвердить удаление пользователя", + "userRemoveOrg": "Удалить пользователя из организации", + "users": "Пользователи", + "accessRoleMember": "Участник", + "accessRoleOwner": "Владелец", + "userConfirmed": "Подтверждён", + "idpNameInternal": "Внутренний", + "emailInvalid": "Неверный адрес Email", + "inviteValidityDuration": "Пожалуйста, выберите продолжительность", + "accessRoleSelectPlease": "Пожалуйста, выберите роль", + "usernameRequired": "Имя пользователя обязательно", + "idpSelectPlease": "Пожалуйста, выберите Identity Provider", + "idpGenericOidc": "Обычный OAuth2/OIDC provider.", + "accessRoleErrorFetch": "Не удалось получить роли", + "accessRoleErrorFetchDescription": "Произошла ошибка при получении ролей", + "idpErrorFetch": "Не удалось получить идентификатор провайдера", + "idpErrorFetchDescription": "Произошла ошибка при получении поставщиков удостоверений", + "userErrorExists": "Пользователь уже существует", + "userErrorExistsDescription": "Этот пользователь уже является участником организации.", + "inviteError": "Не удалось пригласить пользователя", + "inviteErrorDescription": "Произошла ошибка при приглашении пользователя", + "userInvited": "Пользователь приглашён", + "userInvitedDescription": "Пользователь был успешно приглашён.", + "userErrorCreate": "Не удалось создать пользователя", + "userErrorCreateDescription": "Произошла ошибка при создании пользователя", + "userCreated": "Пользователь создан", + "userCreatedDescription": "Пользователь был успешно создан.", + "userTypeInternal": "Внутренний пользователь", + "userTypeInternalDescription": "Пригласите пользователя напрямую в вашу организацию.", + "userTypeExternal": "Внешний пользователь", + "userTypeExternalDescription": "Создайте пользователя через внешний Identity Provider.", + "accessUserCreateDescription": "Следуйте инструкциям ниже для создания нового пользователя", + "userSeeAll": "Просмотр всех пользователей", + "userTypeTitle": "Тип пользователя", + "userTypeDescription": "Выберите способ создание пользователя", + "userSettings": "Информация о пользователе", + "userSettingsDescription": "Введите сведения о новом пользователе", + "inviteEmailSent": "Отправить приглашение по Email", + "inviteValid": "Действительно", + "selectDuration": "Укажите срок действия", + "accessRoleSelect": "Выберите роль", + "inviteEmailSentDescription": "Email был отправлен пользователю со ссылкой доступа ниже. Он должен перейти по ссылке для принятия приглашения.", + "inviteSentDescription": "Пользователь был приглашён. Он должен перейти по ссылке ниже для принятия приглашения.", + "inviteExpiresIn": "Приглашение истечёт через {days, plural, one {# день} few {# дня} many {# дней} other {# дней}}.", + "idpTitle": "Поставщик удостоверений", + "idpSelect": "Выберите поставщика удостоверений для внешнего пользователя", + "idpNotConfigured": "Поставщики удостоверений не настроены. Пожалуйста, настройте поставщика удостоверений перед созданием внешних пользователей.", + "usernameUniq": "Это должно соответствовать уникальному имени пользователя, существующему в выбранном поставщике удостоверений.", + "emailOptional": "Email (необязательно)", + "nameOptional": "Имя (необязательно)", + "accessControls": "Контроль доступа", + "userDescription2": "Управление настройками этого пользователя", + "accessRoleErrorAdd": "Не удалось добавить пользователя в роль", + "accessRoleErrorAddDescription": "Произошла ошибка при добавлении пользователя в роль.", + "userSaved": "Пользователь сохранён", + "userSavedDescription": "Пользователь был обновлён.", + "accessControlsDescription": "Управляйте тем, к чему этот пользователь может получить доступ и что делать в организации", + "accessControlsSubmit": "Сохранить контроль доступа", + "roles": "Роли", + "accessUsersRoles": "Управление пользователями и ролями", + "accessUsersRolesDescription": "Приглашайте пользователей и добавляйте их в роли для управления доступом к вашей организации", + "key": "Ключ", + "createdAt": "Создано в", + "proxyErrorInvalidHeader": "Неверное значение пользовательского заголовка Host. Используйте формат доменного имени или оставьте пустым для сброса пользовательского заголовка Host.", + "proxyErrorTls": "Неверное имя TLS сервера. Используйте формат доменного имени или оставьте пустым для удаления имени TLS сервера.", + "proxyEnableSSL": "Включить SSL (https)", + "targetErrorFetch": "Не удалось получить цели", + "targetErrorFetchDescription": "Произошла ошибка при получении целей", + "siteErrorFetch": "Не удалось получить ресурс", + "siteErrorFetchDescription": "Произошла ошибка при получении ресурса", + "targetErrorDuplicate": "Дублирующая цель", + "targetErrorDuplicateDescription": "Цель с такими настройками уже существует", + "targetWireGuardErrorInvalidIp": "Неверный IP цели", + "targetWireGuardErrorInvalidIpDescription": "IP цели должен быть в пределах подсети сайта", + "targetsUpdated": "Цели обновлены", + "targetsUpdatedDescription": "Цели и настройки успешно обновлены", + "targetsErrorUpdate": "Не удалось обновить цели", + "targetsErrorUpdateDescription": "Произошла ошибка при обновлении целей", + "targetTlsUpdate": "Настройки TLS обновлены", + "targetTlsUpdateDescription": "Ваши настройки TLS были успешно обновлены", + "targetErrorTlsUpdate": "Не удалось обновить настройки TLS", + "targetErrorTlsUpdateDescription": "Произошла ошибка при обновлении настроек TLS", + "proxyUpdated": "Настройки прокси обновлены", + "proxyUpdatedDescription": "Ваши настройки прокси были успешно обновлены", + "proxyErrorUpdate": "Не удалось обновить настройки прокси", + "proxyErrorUpdateDescription": "Произошла ошибка при обновлении настроек прокси", + "targetAddr": "IP / Имя хоста", + "targetPort": "Порт", + "targetProtocol": "Протокол", + "targetTlsSettings": "Конфигурация безопасного соединения", + "targetTlsSettingsDescription": "Настройте параметры SSL/TLS для вашего ресурса", + "targetTlsSettingsAdvanced": "Расширенные настройки TLS", + "targetTlsSni": "Имя TLS сервера (SNI)", + "targetTlsSniDescription": "Имя TLS сервера для использования в SNI. Оставьте пустым для использования по умолчанию.", + "targetTlsSubmit": "Сохранить настройки", + "targets": "Конфигурация целей", + "targetsDescription": "Настройте цели для маршрутизации трафика к вашим сервисам", + "targetStickySessions": "Включить фиксированные сессии", + "targetStickySessionsDescription": "Сохранять соединения на одной и той же целевой точке в течение всей сессии.", + "methodSelect": "Выберите метод", + "targetSubmit": "Добавить цель", + "targetNoOne": "Нет целей. Добавьте цель с помощью формы.", + "targetNoOneDescription": "Добавление более одной цели выше включит балансировку нагрузки.", + "targetsSubmit": "Сохранить цели", + "proxyAdditional": "Дополнительные настройки прокси", + "proxyAdditionalDescription": "Настройте, как ваш ресурс обрабатывает настройки прокси", + "proxyCustomHeader": "Пользовательский заголовок Host", + "proxyCustomHeaderDescription": "Заголовок host для установки при проксировании запросов. Оставьте пустым для использования по умолчанию.", + "proxyAdditionalSubmit": "Сохранить настройки прокси", + "subnetMaskErrorInvalid": "Неверная маска подсети. Должна быть между 0 и 32.", + "ipAddressErrorInvalidFormat": "Неверный формат IP адреса", + "ipAddressErrorInvalidOctet": "Неверный октет IP адреса", + "path": "Путь", + "ipAddressRange": "Диапазон IP", + "rulesErrorFetch": "Не удалось получить правила", + "rulesErrorFetchDescription": "Произошла ошибка при получении правил", + "rulesErrorDuplicate": "Дублирующее правило", + "rulesErrorDuplicateDescription": "Правило с такими настройками уже существует", + "rulesErrorInvalidIpAddressRange": "Неверный CIDR", + "rulesErrorInvalidIpAddressRangeDescription": "Пожалуйста, введите корректное значение CIDR", + "rulesErrorInvalidUrl": "Неверный URL путь", + "rulesErrorInvalidUrlDescription": "Пожалуйста, введите корректное значение URL пути", + "rulesErrorInvalidIpAddress": "Неверный IP", + "rulesErrorInvalidIpAddressDescription": "Пожалуйста, введите корректный IP адрес", + "rulesErrorUpdate": "Не удалось обновить правила", + "rulesErrorUpdateDescription": "Произошла ошибка при обновлении правил", + "rulesUpdated": "Включить правила", + "rulesUpdatedDescription": "Оценка правил была обновлена", + "rulesMatchIpAddressRangeDescription": "Введите адрес в формате CIDR (например, 103.21.244.0/22)", + "rulesMatchIpAddress": "Введите IP адрес (например, 103.21.244.12)", + "rulesMatchUrl": "Введите URL путь или шаблон (например, /api/v1/todos или /api/v1/*)", + "rulesErrorInvalidPriority": "Неверный приоритет", + "rulesErrorInvalidPriorityDescription": "Пожалуйста, введите корректный приоритет", + "rulesErrorDuplicatePriority": "Дублирующие приоритеты", + "rulesErrorDuplicatePriorityDescription": "Пожалуйста, введите уникальные приоритеты", + "ruleUpdated": "Правила обновлены", + "ruleUpdatedDescription": "Правила успешно обновлены", + "ruleErrorUpdate": "Операция не удалась", + "ruleErrorUpdateDescription": "Произошла ошибка во время операции сохранения", + "rulesPriority": "Приоритет", + "rulesAction": "Действие", + "rulesMatchType": "Тип совпадения", + "value": "Значение", + "rulesAbout": "О правилах", + "rulesAboutDescription": "Правила позволяют контролировать доступ к вашему ресурсу на основе набора критериев. Вы можете создавать правила для разрешения или запрета доступа на основе IP адреса или URL пути.", + "rulesActions": "Действия", + "rulesActionAlwaysAllow": "Всегда разрешать: Обойти все методы аутентификации", + "rulesActionAlwaysDeny": "Всегда запрещать: Блокировать все запросы; аутентификация не может быть выполнена", + "rulesMatchCriteria": "Критерии совпадения", + "rulesMatchCriteriaIpAddress": "Совпадение с конкретным IP адресом", + "rulesMatchCriteriaIpAddressRange": "Совпадение с диапазоном IP адресов в нотации CIDR", + "rulesMatchCriteriaUrl": "Совпадение с URL путём или шаблоном", + "rulesEnable": "Включить правила", + "rulesEnableDescription": "Включить или отключить проверку правил для этого ресурса", + "rulesResource": "Конфигурация правил ресурса", + "rulesResourceDescription": "Настройте правила для контроля доступа к вашему ресурсу", + "ruleSubmit": "Добавить правило", + "rulesNoOne": "Нет правил. Добавьте правило с помощью формы.", + "rulesOrder": "Правила оцениваются по приоритету в возрастающем порядке.", + "rulesSubmit": "Сохранить правила", + "resourceErrorCreate": "Ошибка при создании ресурса", + "resourceErrorCreateDescription": "Произошла ошибка при создании ресурса", + "resourceErrorCreateMessage": "Ошибка создания ресурса:", + "resourceErrorCreateMessageDescription": "Произошла неизвестная ошибка.", + "sitesErrorFetch": "Ошибка при получении сайтов", + "sitesErrorFetchDescription": "Произошла ошибка при получении сайтов", + "domainsErrorFetch": "Ошибка при получении доменов", + "domainsErrorFetchDescription": "Произошла ошибка при получении доменов", + "none": "Нет", + "unknown": "Неизвестно", + "resources": "Ресурсы", + "resourcesDescription": "Ресурсы - это прокси к приложениям, работающим в вашей частной сети. Создайте ресурс для любого HTTP/HTTPS или сырого TCP/UDP сервиса в вашей частной сети. Каждый ресурс должен быть подключен к сайту для обеспечения приватного, безопасного соединения через зашифрованный туннель WireGuard.", + "resourcesWireGuardConnect": "Безопасное соединение с шифрованием WireGuard", + "resourcesMultipleAuthenticationMethods": "Настройка нескольких методов аутентификации", + "resourcesUsersRolesAccess": "Контроль доступа на основе пользователей и ролей", + "resourcesErrorUpdate": "Не удалось переключить ресурс", + "resourcesErrorUpdateDescription": "Произошла ошибка при обновлении ресурса", + "access": "Доступ", + "shareLink": "Общая ссылка {resource}", + "resourceSelect": "Выберите ресурс", + "shareLinks": "Общие ссылки", + "share": "Общие ссылки", + "shareDescription2": "Создавайте общие ссылки к вашим ресурсам. Ссылки предоставляют временный или неограниченный доступ к вашему ресурсу. Вы можете настроить время истечения ссылки при её создании.", + "shareEasyCreate": "Легко создавать и делиться", + "shareConfigurableExpirationDuration": "Настраиваемая продолжительность истечения", + "shareSecureAndRevocable": "Безопасные и отзываемые", + "nameMin": "Имя должно быть не менее {len} символов.", + "nameMax": "Имя не должно быть длиннее {len} символов.", + "sitesConfirmCopy": "Пожалуйста, подтвердите, что вы скопировали конфигурацию.", + "unknownCommand": "Неизвестная команда", + "newtErrorFetchReleases": "Не удалось получить информацию о релизе: {err}", + "newtErrorFetchLatest": "Ошибка при получении последнего релиза: {err}", + "newtEndpoint": "Конечная точка Newt", + "newtId": "Newt ID", + "newtSecretKey": "Секретный ключ Newt", + "architecture": "Архитектура", + "sites": "Сайты", + "siteWgAnyClients": "Используйте любой клиент WireGuard для подключения. Вам придётся обращаться к вашим внутренним ресурсам, используя IP узла.", + "siteWgCompatibleAllClients": "Совместим со всеми клиентами WireGuard", + "siteWgManualConfigurationRequired": "Требуется ручная настройка", + "userErrorNotAdminOrOwner": "Пользователь не является администратором или владельцем", + "pangolinSettings": "Настройки - Pangolin", + "accessRoleYour": "Ваша роль:", + "accessRoleSelect2": "Выберите роль", + "accessUserSelect": "Выберите пользователя", + "otpEmailEnter": "Введите email", + "otpEmailEnterDescription": "Нажмите enter для добавления email после ввода в поле.", + "otpEmailErrorInvalid": "Неверный email адрес. Подстановочный знак (*) должен быть всей локальной частью.", + "otpEmailSmtpRequired": "Требуется SMTP", + "otpEmailSmtpRequiredDescription": "SMTP должен быть включён на сервере для использования аутентификации с одноразовым паролем.", + "otpEmailTitle": "Одноразовые пароли", + "otpEmailTitleDescription": "Требовать аутентификацию на основе email для доступа к ресурсу", + "otpEmailWhitelist": "Белый список email", + "otpEmailWhitelistList": "Email адреса в белом списке", + "otpEmailWhitelistListDescription": "Только пользователи с этими email адресами смогут получить доступ к этому ресурсу. Им будет предложено ввести одноразовый пароль, отправленный на их email. Можно использовать подстановочные знаки (*@example.com) для разрешения любого email адреса с домена.", + "otpEmailWhitelistSave": "Сохранить белый список", + "passwordAdd": "Добавить пароль", + "passwordRemove": "Удалить пароль", + "pincodeAdd": "Добавить PIN-код", + "pincodeRemove": "Удалить PIN-код", + "resourceAuthMethods": "Методы аутентификации", + "resourceAuthMethodsDescriptions": "Разрешить доступ к ресурсу через дополнительные методы аутентификации", + "resourceAuthSettingsSave": "Успешно сохранено", + "resourceAuthSettingsSaveDescription": "Настройки аутентификации сохранены", + "resourceErrorAuthFetch": "Не удалось получить данные", + "resourceErrorAuthFetchDescription": "Произошла ошибка при получении данных", + "resourceErrorPasswordRemove": "Ошибка при удалении пароля ресурса", + "resourceErrorPasswordRemoveDescription": "Произошла ошибка при удалении пароля ресурса", + "resourceErrorPasswordSetup": "Ошибка при установке пароля ресурса", + "resourceErrorPasswordSetupDescription": "Произошла ошибка при установке пароля ресурса", + "resourceErrorPincodeRemove": "Ошибка при удалении PIN-кода ресурса", + "resourceErrorPincodeRemoveDescription": "Произошла ошибка при удалении PIN-кода ресурса", + "resourceErrorPincodeSetup": "Ошибка при установке PIN-кода ресурса", + "resourceErrorPincodeSetupDescription": "Произошла ошибка при установке PIN-кода ресурса", + "resourceErrorUsersRolesSave": "Не удалось установить роли", + "resourceErrorUsersRolesSaveDescription": "Произошла ошибка при установке ролей", + "resourceErrorWhitelistSave": "Не удалось сохранить белый список", + "resourceErrorWhitelistSaveDescription": "Произошла ошибка при сохранении белого списка", + "resourcePasswordSubmit": "Включить защиту паролем", + "resourcePasswordProtection": "Защита паролем {status}", + "resourcePasswordRemove": "Пароль ресурса удалён", + "resourcePasswordRemoveDescription": "Пароль ресурса был успешно удалён", + "resourcePasswordSetup": "Пароль ресурса установлен", + "resourcePasswordSetupDescription": "Пароль ресурса был успешно установлен", + "resourcePasswordSetupTitle": "Установить пароль", + "resourcePasswordSetupTitleDescription": "Установите пароль для защиты этого ресурса", + "resourcePincode": "PIN-код", + "resourcePincodeSubmit": "Включить защиту PIN-кодом", + "resourcePincodeProtection": "Защита PIN-кодом {status}", + "resourcePincodeRemove": "PIN-код ресурса удалён", + "resourcePincodeRemoveDescription": "PIN-код ресурса был успешно удалён", + "resourcePincodeSetup": "PIN-код ресурса установлен", + "resourcePincodeSetupDescription": "PIN-код ресурса был успешно установлен", + "resourcePincodeSetupTitle": "Установить PIN-код", + "resourcePincodeSetupTitleDescription": "Установите PIN-код для защиты этого ресурса", + "resourceRoleDescription": "Администраторы всегда имеют доступ к этому ресурсу.", + "resourceUsersRoles": "Пользователи и роли", + "resourceUsersRolesDescription": "Выберите пользователей и роли с доступом к этому ресурсу", + "resourceUsersRolesSubmit": "Сохранить пользователей и роли", + "resourceWhitelistSave": "Успешно сохранено", + "resourceWhitelistSaveDescription": "Настройки белого списка были сохранены", + "ssoUse": "Использовать Platform SSO", + "ssoUseDescription": "Существующим пользователям нужно будет войти только один раз для всех ресурсов с включенной этой опцией.", + "proxyErrorInvalidPort": "Неверный номер порта", + "subdomainErrorInvalid": "Неверный поддомен", + "domainErrorFetch": "Ошибка при получении доменов", + "domainErrorFetchDescription": "Произошла ошибка при получении доменов", + "resourceErrorUpdate": "Не удалось обновить ресурс", + "resourceErrorUpdateDescription": "Произошла ошибка при обновлении ресурса", + "resourceUpdated": "Ресурс обновлён", + "resourceUpdatedDescription": "Ресурс был успешно обновлён", + "resourceErrorTransfer": "Не удалось перенести ресурс", + "resourceErrorTransferDescription": "Произошла ошибка при переносе ресурса", + "resourceTransferred": "Ресурс перенесён", + "resourceTransferredDescription": "Ресурс был успешно перенесён", + "resourceErrorToggle": "Не удалось переключить ресурс", + "resourceErrorToggleDescription": "Произошла ошибка при обновлении ресурса", + "resourceVisibilityTitle": "Видимость", + "resourceVisibilityTitleDescription": "Включите или отключите видимость ресурса", + "resourceGeneral": "Общие настройки", + "resourceGeneralDescription": "Настройте общие параметры этого ресурса", + "resourceEnable": "Ресурс активен", + "resourceTransfer": "Перенести ресурс", + "resourceTransferDescription": "Перенесите этот ресурс на другой сайт", + "resourceTransferSubmit": "Перенести ресурс", + "siteDestination": "Новый сайт для ресурса", + "searchSites": "Поиск сайтов", + "accessRoleCreate": "Создание роли", + "accessRoleCreateDescription": "Создайте новую роль для группы пользователей и выдавайте им разрешения.", + "accessRoleCreateSubmit": "Создать роль", + "accessRoleCreated": "Роль создана", + "accessRoleCreatedDescription": "Роль была успешно создана.", + "accessRoleErrorCreate": "Не удалось создать роль", + "accessRoleErrorCreateDescription": "Произошла ошибка при создании роли.", + "accessRoleErrorNewRequired": "Новая роль обязательна", + "accessRoleErrorRemove": "Не удалось удалить роль", + "accessRoleErrorRemoveDescription": "Произошла ошибка при удалении роли.", + "accessRoleName": "Название роли", + "accessRoleQuestionRemove": "Вы собираетесь удалить роль {name}. Это действие нельзя отменить.", + "accessRoleRemove": "Удалить роль", + "accessRoleRemoveDescription": "Удалить роль из организации", + "accessRoleRemoveSubmit": "Удалить роль", + "accessRoleRemoved": "Роль удалена", + "accessRoleRemovedDescription": "Роль была успешно удалена.", + "accessRoleRequiredRemove": "Перед удалением этой роли выберите новую роль для переноса существующих участников.", + "manage": "Управление", + "sitesNotFound": "Сайты не найдены.", + "pangolinServerAdmin": "Администратор сервера - Pangolin", + "licenseTierProfessional": "Профессиональная лицензия", + "licenseTierEnterprise": "Корпоративная лицензия", + "licenseTierCommercial": "Коммерческая лицензия", + "licensed": "Лицензировано", + "yes": "Да", + "no": "Нет", + "sitesAdditional": "Дополнительные сайты", + "licenseKeys": "Лицензионные ключи", + "sitestCountDecrease": "Уменьшить количество сайтов", + "sitestCountIncrease": "Увеличить количество сайтов", + "idpManage": "Управление поставщиками удостоверений", + "idpManageDescription": "Просмотр и управление поставщиками удостоверений в системе", + "idpDeletedDescription": "Поставщик удостоверений успешно удалён", + "idpOidc": "OAuth2/OIDC", + "idpQuestionRemove": "Вы уверены, что хотите навсегда удалить поставщика удостоверений {name}?", + "idpMessageRemove": "Это удалит поставщика удостоверений и все связанные конфигурации. Пользователи, которые аутентифицируются через этого поставщика, больше не смогут войти.", + "idpMessageConfirm": "Для подтверждения введите имя поставщика удостоверений ниже.", + "idpConfirmDelete": "Подтвердить удаление поставщика удостоверений", + "idpDelete": "Удалить поставщика удостоверений", + "idp": "Поставщики удостоверений", + "idpSearch": "Поиск поставщиков удостоверений...", + "idpAdd": "Добавить поставщика удостоверений", + "idpClientIdRequired": "ID клиента обязателен.", + "idpClientSecretRequired": "Требуется секретный пароль клиента.", + "idpErrorAuthUrlInvalid": "URL авторизации должен быть корректным URL.", + "idpErrorTokenUrlInvalid": "URL токена должен быть корректным URL.", + "idpPathRequired": "Путь идентификатора обязателен.", + "idpScopeRequired": "Области действия обязательны.", + "idpOidcDescription": "Настройте поставщика удостоверений OpenID Connect", + "idpCreatedDescription": "Поставщик удостоверений успешно создан", + "idpCreate": "Создать поставщика удостоверений", + "idpCreateDescription": "Настройте нового поставщика удостоверений для аутентификации пользователей", + "idpSeeAll": "Посмотреть всех поставщиков удостоверений", + "idpSettingsDescription": "Настройте базовую информацию для вашего поставщика удостоверений", + "idpDisplayName": "Отображаемое имя для этого поставщика удостоверений", + "idpAutoProvisionUsers": "Автоматическое создание пользователей", + "idpAutoProvisionUsersDescription": "При включении пользователи будут автоматически создаваться в системе при первом входе с возможностью сопоставления пользователей с ролями и организациями.", + "licenseBadge": "Профессиональная", + "idpType": "Тип поставщика", + "idpTypeDescription": "Выберите тип поставщика удостоверений, который вы хотите настроить", + "idpOidcConfigure": "Конфигурация OAuth2/OIDC", + "idpOidcConfigureDescription": "Настройте конечные точки и учётные данные поставщика OAuth2/OIDC", + "idpClientId": "ID клиента", + "idpClientIdDescription": "OAuth2 ID клиента от вашего поставщика удостоверений", + "idpClientSecret": "Секрет клиента", + "idpClientSecretDescription": "OAuth2 секрет клиента от вашего поставщика удостоверений", + "idpAuthUrl": "URL авторизации", + "idpAuthUrlDescription": "URL конечной точки авторизации OAuth2", + "idpTokenUrl": "URL токена", + "idpTokenUrlDescription": "URL конечной точки токена OAuth2", + "idpOidcConfigureAlert": "Важная информация", + "idpOidcConfigureAlertDescription": "После создания поставщика удостоверений вам нужно будет настроить URL обратного вызова в настройках вашего поставщика удостоверений. URL обратного вызова будет предоставлен после успешного создания.", + "idpToken": "Конфигурация токена", + "idpTokenDescription": "Настройте, как извлекать информацию о пользователе из ID токена", + "idpJmespathAbout": "О JMESPath", + "idpJmespathAboutDescription": "Пути ниже используют синтаксис JMESPath для извлечения значений из ID токена.", + "idpJmespathAboutDescriptionLink": "Узнать больше о JMESPath", + "idpJmespathLabel": "Путь идентификатора", + "idpJmespathLabelDescription": "Путь к идентификатору пользователя в ID токене", + "idpJmespathEmailPathOptional": "Путь к email (необязательно)", + "idpJmespathEmailPathOptionalDescription": "Путь к email пользователя в ID токене", + "idpJmespathNamePathOptional": "Путь к имени (необязательно)", + "idpJmespathNamePathOptionalDescription": "Путь к имени пользователя в ID токене", + "idpOidcConfigureScopes": "Области действия", + "idpOidcConfigureScopesDescription": "Список областей OAuth2, разделённых пробелами", + "idpSubmit": "Создать поставщика удостоверений", + "orgPolicies": "Политики организации", + "idpSettings": "Настройки {idpName}", + "idpCreateSettingsDescription": "Настройте параметры для вашего поставщика удостоверений", + "roleMapping": "Сопоставление ролей", + "orgMapping": "Сопоставление организаций", + "orgPoliciesSearch": "Поиск политик организации...", + "orgPoliciesAdd": "Добавить политику организации", + "orgRequired": "Организация обязательна", + "error": "Ошибка", + "success": "Успешно", + "orgPolicyAddedDescription": "Политика успешно добавлена", + "orgPolicyUpdatedDescription": "Политика успешно обновлена", + "orgPolicyDeletedDescription": "Политика успешно удалена", + "defaultMappingsUpdatedDescription": "Сопоставления по умолчанию успешно обновлены", + "orgPoliciesAbout": "О политиках организации", + "orgPoliciesAboutDescription": "Политики организации используются для контроля доступа к организациям на основе ID токена пользователя. Вы можете указать выражения JMESPath для извлечения информации о роли и организации из ID токена.", + "orgPoliciesAboutDescriptionLink": "См. документацию для получения дополнительной информации.", + "defaultMappingsOptional": "Сопоставления по умолчанию (необязательно)", + "defaultMappingsOptionalDescription": "Сопоставления по умолчанию используются, когда для организации не определена политика организации. Здесь вы можете указать сопоставления ролей и организаций по умолчанию.", + "defaultMappingsRole": "Сопоставление ролей по умолчанию", + "defaultMappingsRoleDescription": "Результат этого выражения должен возвращать имя роли, как определено в организации, в виде строки.", + "defaultMappingsOrg": "Сопоставление организаций по умолчанию", + "defaultMappingsOrgDescription": "Это выражение должно возвращать ID организации или true для разрешения доступа пользователя к организации.", + "defaultMappingsSubmit": "Сохранить сопоставления по умолчанию", + "orgPoliciesEdit": "Редактировать политику организации", + "org": "Организация", + "orgSelect": "Выберите организацию", + "orgSearch": "Поиск организации", + "orgNotFound": "Организация не найдена.", + "roleMappingPathOptional": "Путь сопоставления ролей (необязательно)", + "orgMappingPathOptional": "Путь сопоставления организаций (необязательно)", + "orgPolicyUpdate": "Обновить политику", + "orgPolicyAdd": "Добавить политику", + "orgPolicyConfig": "Настроить доступ для организации", + "idpUpdatedDescription": "Поставщик удостоверений успешно обновлён", + "redirectUrl": "URL редиректа", + "redirectUrlAbout": "О редиректе URL", + "redirectUrlAboutDescription": "Это URL, на который пользователи будут перенаправлены после аутентификации. Вам нужно настроить этот URL в настройках вашего поставщика удостоверений.", + "pangolinAuth": "Аутентификация - Pangolin", + "verificationCodeLengthRequirements": "Ваш код подтверждения должен состоять из 8 символов.", + "errorOccurred": "Произошла ошибка", + "emailErrorVerify": "Не удалось подтвердить email:", + "emailVerified": "Email успешно подтверждён! Перенаправляем вас...", + "verificationCodeErrorResend": "Не удалось повторно отправить код подтверждения:", + "verificationCodeResend": "Код подтверждения отправлен повторно", + "verificationCodeResendDescription": "Мы повторно отправили код подтверждения на ваш email адрес. Пожалуйста, проверьте вашу почту.", + "emailVerify": "Подтвердить email", + "emailVerifyDescription": "Введите код подтверждения, отправленный на ваш email адрес.", + "verificationCode": "Код подтверждения", + "verificationCodeEmailSent": "Мы отправили код подтверждения на ваш email адрес.", + "submit": "Отправить", + "emailVerifyResendProgress": "Отправка повторно...", + "emailVerifyResend": "Не получили код? Нажмите здесь для повторной отправки", + "passwordNotMatch": "Пароли не совпадают", + "signupError": "Произошла ошибка при регистрации", + "pangolinLogoAlt": "Логотип Pangolin", + "inviteAlready": "Похоже, вы были приглашены!", + "inviteAlreadyDescription": "Чтобы принять приглашение, вы должны войти или создать учётную запись.", + "signupQuestion": "Уже есть учётная запись?", + "login": "Войти", + "resourceNotFound": "Ресурс не найден", + "resourceNotFoundDescription": "Ресурс, к которому вы пытаетесь получить доступ, не существует.", + "pincodeRequirementsLength": "PIN должен состоять ровно из 6 цифр", + "pincodeRequirementsChars": "PIN должен содержать только цифры", + "passwordRequirementsLength": "Пароль должен быть не менее 1 символа", + "otpEmailRequirementsLength": "OTP должен быть не менее 1 символа", + "otpEmailSent": "OTP отправлен", + "otpEmailSentDescription": "OTP был отправлен на ваш email", + "otpEmailErrorAuthenticate": "Не удалось аутентифицироваться с email", + "pincodeErrorAuthenticate": "Не удалось аутентифицироваться с PIN-кодом", + "passwordErrorAuthenticate": "Не удалось аутентифицироваться с паролем", + "poweredBy": "Разработано", + "authenticationRequired": "Требуется аутентификация", + "authenticationMethodChoose": "Выберите предпочтительный метод для доступа к {name}", + "authenticationRequest": "Вы должны аутентифицироваться для доступа к {name}", + "user": "Пользователь", + "pincodeInput": "6-значный PIN-код", + "pincodeSubmit": "Войти с PIN-кодом", + "passwordSubmit": "Войти с паролем", + "otpEmailDescription": "Одноразовый код будет отправлен на этот email.", + "otpEmailSend": "Отправить одноразовый код", + "otpEmail": "Одноразовый пароль (OTP)", + "otpEmailSubmit": "Отправить OTP", + "backToEmail": "Назад к email", + "noSupportKey": "Сервер работает без ключа поддержки. Подумайте о поддержке проекта!", + "accessDenied": "Доступ запрещён", + "accessDeniedDescription": "Вам не разрешён доступ к этому ресурсу. Если это ошибка, пожалуйста, свяжитесь с администратором.", + "accessTokenError": "Ошибка проверки токена доступа", + "accessGranted": "Доступ предоставлен", + "accessUrlInvalid": "Неверный URL доступа", + "accessGrantedDescription": "Вам был предоставлен доступ к этому ресурсу. Перенаправляем вас...", + "accessUrlInvalidDescription": "Этот общий URL доступа недействителен. Пожалуйста, свяжитесь с владельцем ресурса для получения нового URL.", + "tokenInvalid": "Неверный токен", + "pincodeInvalid": "Неверный код", + "passwordErrorRequestReset": "Не удалось запросить сброс:", + "passwordErrorReset": "Не удалось сбросить пароль:", + "passwordResetSuccess": "Пароль успешно сброшен! Вернуться к входу...", + "passwordReset": "Сброс пароля", + "passwordResetDescription": "Следуйте инструкциям для сброса вашего пароля", + "passwordResetSent": "Мы отправим код сброса пароля на этот email адрес.", + "passwordResetCode": "Код сброса пароля", + "passwordResetCodeDescription": "Проверьте вашу почту для получения кода сброса пароля.", + "passwordNew": "Новый пароль", + "passwordNewConfirm": "Подтвердите новый пароль", + "pincodeAuth": "Код аутентификатора", + "pincodeSubmit2": "Отправить код", + "passwordResetSubmit": "Запросить сброс", + "passwordBack": "Назад к паролю", + "loginBack": "Вернуться к входу", + "signup": "Регистрация", + "loginStart": "Войдите для начала работы", + "idpOidcTokenValidating": "Проверка OIDC токена", + "idpOidcTokenResponse": "Проверить ответ OIDC токена", + "idpErrorOidcTokenValidating": "Ошибка проверки OIDC токена", + "idpConnectingTo": "Подключение к {name}", + "idpConnectingToDescription": "Проверка вашей личности", + "idpConnectingToProcess": "Подключение...", + "idpConnectingToFinished": "Подключено", + "idpErrorConnectingTo": "Возникла проблема при подключении к {name}. Пожалуйста, свяжитесь с вашим администратором.", + "idpErrorNotFound": "IdP не найден", + "inviteInvalid": "Недействительное приглашение", + "inviteInvalidDescription": "Ссылка на приглашение недействительна.", + "inviteErrorWrongUser": "Приглашение не для этого пользователя", + "inviteErrorUserNotExists": "Пользователь не существует. Пожалуйста, сначала создайте учетную запись.", + "inviteErrorLoginRequired": "Вы должны войти, чтобы принять приглашение", + "inviteErrorExpired": "Срок действия приглашения истек", + "inviteErrorRevoked": "Возможно, приглашение было отозвано", + "inviteErrorTypo": "В пригласительной ссылке может быть опечатка", + "pangolinSetup": "Настройка - Pangolin", + "orgNameRequired": "Название организации обязательно", + "orgIdRequired": "ID организации обязателен", + "orgErrorCreate": "Произошла ошибка при создании организации", + "pageNotFound": "Страница не найдена", + "pageNotFoundDescription": "Упс! Страница, которую вы ищете, не существует.", + "overview": "Обзор", + "home": "Главная", + "accessControl": "Контроль доступа", + "settings": "Настройки", + "usersAll": "Все пользователи", + "license": "Лицензия", + "pangolinDashboard": "Дашборд - Pangolin", + "noResults": "Результаты не найдены.", + "terabytes": "{count} ТБ", + "gigabytes": "{count} ГБ", + "megabytes": "{count} МБ", + "tagsEntered": "Введённые теги", + "tagsEnteredDescription": "Это теги, которые вы ввели.", + "tagsWarnCannotBeLessThanZero": "maxTags и minTags не могут быть меньше 0", + "tagsWarnNotAllowedAutocompleteOptions": "Тег не разрешён согласно опциям автозаполнения", + "tagsWarnInvalid": "Недействительный тег согласно validateTag", + "tagWarnTooShort": "Тег {tagText} слишком короткий", + "tagWarnTooLong": "Тег {tagText} слишком длинный", + "tagsWarnReachedMaxNumber": "Достигнуто максимальное количество разрешённых тегов", + "tagWarnDuplicate": "Дублирующий тег {tagText} не добавлен", + "supportKeyInvalid": "Недействительный ключ", + "supportKeyInvalidDescription": "Ваш ключ поддержки недействителен.", + "supportKeyValid": "Действительный ключ", + "supportKeyValidDescription": "Ваш ключ поддержки был проверен. Спасибо за поддержку!", + "supportKeyErrorValidationDescription": "Не удалось проверить ключ поддержки.", + "supportKey": "Поддержите разработку и усыновите Панголина!", + "supportKeyDescription": "Приобретите ключ поддержки, чтобы помочь нам продолжать разработку Pangolin для сообщества. Ваш вклад позволяет нам уделять больше времени поддержке и добавлению новых функций в приложение для всех. Мы никогда не будем использовать это для платного доступа к функциям. Это отдельно от любой коммерческой версии.", + "supportKeyPet": "Вы также сможете усыновить и встретить вашего собственного питомца Панголина!", + "supportKeyPurchase": "Платежи обрабатываются через GitHub. После этого вы сможете получить свой ключ на", + "supportKeyPurchaseLink": "нашем сайте", + "supportKeyPurchase2": "и активировать его здесь.", + "supportKeyLearnMore": "Узнать больше.", + "supportKeyOptions": "Пожалуйста, выберите подходящий вам вариант.", + "supportKetOptionFull": "Полная поддержка", + "forWholeServer": "За весь сервер", + "lifetimePurchase": "Пожизненная покупка", + "supporterStatus": "Статус поддержки", + "buy": "Купить", + "supportKeyOptionLimited": "Лимитированная поддержка", + "forFiveUsers": "За 5 или меньше пользователей", + "supportKeyRedeem": "Использовать ключ Поддержки", + "supportKeyHideSevenDays": "Скрыть на 7 дней", + "supportKeyEnter": "Введите ключ поддержки", + "supportKeyEnterDescription": "Встречайте своего питомца Панголина!", + "githubUsername": "Имя пользователя Github", + "supportKeyInput": "Ключ поддержки", + "supportKeyBuy": "Ключ поддержки", + "logoutError": "Ошибка при выходе", + "signingAs": "Вы вошли как", + "serverAdmin": "Администратор сервера", + "otpEnable": "Включить Двухфакторную Аутентификацию", + "otpDisable": "Отключить двухфакторную аутентификацию", + "logout": "Выйти", + "licenseTierProfessionalRequired": "Требуется профессиональная версия", + "licenseTierProfessionalRequiredDescription": "Эта функция доступна только в профессиональной версии.", + "actionGetOrg": "Получить организацию", + "actionUpdateOrg": "Обновить организацию", + "actionUpdateUser": "Обновить пользователя", + "actionGetUser": "Получить пользователя", + "actionGetOrgUser": "Получить пользователя организации", + "actionListOrgDomains": "Список доменов организации", + "actionCreateSite": "Создать сайт", + "actionDeleteSite": "Удалить сайт", + "actionGetSite": "Получить сайт", + "actionListSites": "Список сайтов", + "actionUpdateSite": "Обновить сайт", + "actionListSiteRoles": "Список разрешенных ролей сайта", + "actionCreateResource": "Создать ресурс", + "actionDeleteResource": "Удалить ресурс", + "actionGetResource": "Получить ресурсы", + "actionListResource": "Список ресурсов", + "actionUpdateResource": "Обновить ресурс", + "actionListResourceUsers": "Список пользователей ресурсов", + "actionSetResourceUsers": "Список пользователей ресурсов", + "actionSetAllowedResourceRoles": "Набор разрешенных ролей ресурсов", + "actionListAllowedResourceRoles": "Список разрешенных ролей сайта", + "actionSetResourcePassword": "Задать пароль ресурса", + "actionSetResourcePincode": "Установить ПИН-код ресурса", + "actionSetResourceEmailWhitelist": "Set Resource Email Whitelist", + "actionGetResourceEmailWhitelist": "Get Resource Email Whitelist", + "actionCreateTarget": "Создать цель", + "actionDeleteTarget": "Удалить цель", + "actionGetTarget": "Получить цель", + "actionListTargets": "Список целей", + "actionUpdateTarget": "Обновить цель", + "actionCreateRole": "Создать роль", + "actionDeleteRole": "Удалить роль", + "actionGetRole": "Получить Роль", + "actionListRole": "Список ролей", + "actionUpdateRole": "Обновить роль", + "actionListAllowedRoleResources": "Список разрешенных ролей сайта", + "actionInviteUser": "Пригласить пользователя", + "actionRemoveUser": "Удалить пользователя", + "actionListUsers": "Список пользователей", + "actionAddUserRole": "Добавить роль пользователя", + "actionGenerateAccessToken": "Сгенерировать токен доступа", + "actionDeleteAccessToken": "Удалить токен доступа", + "actionListAccessTokens": "Список токенов доступа", + "actionCreateResourceRule": "Создать правило ресурса", + "actionDeleteResourceRule": "Удалить правило ресурса", + "actionListResourceRules": "Список правил ресурса", + "actionUpdateResourceRule": "Обновить правило ресурса", + "actionListOrgs": "Список организаций", + "actionCheckOrgId": "Проверить ID", + "actionCreateOrg": "Создать организацию", + "actionDeleteOrg": "Удалить организацию", + "actionListApiKeys": "Список API ключей", + "actionListApiKeyActions": "Список действий API ключа", + "actionSetApiKeyActions": "Установить разрешённые действия API ключа", + "actionCreateApiKey": "Создать API ключ", + "actionDeleteApiKey": "Удалить API ключ", + "actionCreateIdp": "Создать IDP", + "actionUpdateIdp": "Обновить IDP", + "actionDeleteIdp": "Удалить IDP", + "actionListIdps": "Список IDP", + "actionGetIdp": "Получить IDP", + "actionCreateIdpOrg": "Создать политику IDP организации", + "actionDeleteIdpOrg": "Удалить политику IDP организации", + "actionListIdpOrgs": "Список организаций IDP", + "actionUpdateIdpOrg": "Обновить организацию IDP", + "actionCreateClient": "Создать Клиента", + "actionDeleteClient": "Удалить Клиента", + "actionUpdateClient": "Обновить Клиента", + "actionListClients": "Список Клиентов", + "actionGetClient": "Получить Клиента", + "noneSelected": "Ничего не выбрано", + "orgNotFound2": "Организации не найдены.", + "searchProgress": "Поиск...", + "create": "Создать", + "orgs": "Организации", + "loginError": "Произошла ошибка при входе", + "passwordForgot": "Забыли пароль?", + "otpAuth": "Двухфакторная аутентификация", + "otpAuthDescription": "Введите код из вашего приложения-аутентификатора или один из ваших одноразовых резервных кодов.", + "otpAuthSubmit": "Отправить код", + "idpContinue": "Или продолжить с", + "otpAuthBack": "Вернуться к входу", + "navbar": "Навигационное меню", + "navbarDescription": "Главное навигационное меню приложения", + "navbarDocsLink": "Документация", + "commercialEdition": "Коммерческая версия", + "otpErrorEnable": "Невозможно включить 2FA", + "otpErrorEnableDescription": "Произошла ошибка при включении 2FA", + "otpSetupCheckCode": "Пожалуйста, введите 6-значный код", + "otpSetupCheckCodeRetry": "Неверный код. Попробуйте снова.", + "otpSetup": "Включить двухфакторную аутентификацию", + "otpSetupDescription": "Защитите свою учётную запись дополнительным уровнем защиты", + "otpSetupScanQr": "Отсканируйте этот QR-код с помощью вашего приложения-аутентификатора или введите секретный ключ вручную:", + "otpSetupSecretCode": "Код аутентификатора", + "otpSetupSuccess": "Двухфакторная аутентификация включена", + "otpSetupSuccessStoreBackupCodes": "Ваша учётная запись теперь более защищена. Не забудьте сохранить резервные коды.", + "otpErrorDisable": "Невозможно отключить 2FA", + "otpErrorDisableDescription": "Произошла ошибка при отключении 2FA", + "otpRemove": "Отключить двухфакторную аутентификацию", + "otpRemoveDescription": "Отключить двухфакторную аутентификацию для вашей учётной записи", + "otpRemoveSuccess": "Двухфакторная аутентификация отключена", + "otpRemoveSuccessMessage": "Двухфакторная аутентификация была отключена для вашей учётной записи. Вы можете включить её снова в любое время.", + "otpRemoveSubmit": "Отключить 2FA", + "paginator": "Страница {current} из {last}", + "paginatorToFirst": "Перейти на первую страницу", + "paginatorToPrevious": "Перейти на предыдущую страницу", + "paginatorToNext": "Перейти на следующую страницу", + "paginatorToLast": "Перейти на последнюю страницу", + "copyText": "Скопировать текст", + "copyTextFailed": "Не удалось скопировать текст: ", + "copyTextClipboard": "Копировать в буфер обмена", + "inviteErrorInvalidConfirmation": "Неверное подтверждение", + "passwordRequired": "Пароль обязателен", + "allowAll": "Разрешить всё", + "permissionsAllowAll": "Разрешить все разрешения", + "githubUsernameRequired": "Имя пользователя GitHub обязательно", + "supportKeyRequired": "Ключ поддержки обязателен", + "passwordRequirementsChars": "Пароль должен быть не менее 8 символов", + "language": "Язык", + "verificationCodeRequired": "Код обязателен", + "userErrorNoUpdate": "Нет пользователя для обновления", + "siteErrorNoUpdate": "Нет сайта для обновления", + "resourceErrorNoUpdate": "Нет ресурса для обновления", + "authErrorNoUpdate": "Нет информации об аутентификации для обновления", + "orgErrorNoUpdate": "Нет организации для обновления", + "orgErrorNoProvided": "Организация не предоставлена", + "apiKeysErrorNoUpdate": "Нет API ключа для обновления", + "sidebarOverview": "Обзор", + "sidebarHome": "Главная", + "sidebarSites": "Сайты", + "sidebarResources": "Ресурсы", + "sidebarAccessControl": "Контроль доступа", + "sidebarUsers": "Пользователи", + "sidebarInvitations": "Приглашения", + "sidebarRoles": "Роли", + "sidebarShareableLinks": "Общие ссылки", + "sidebarApiKeys": "API ключи", + "sidebarSettings": "Настройки", + "sidebarAllUsers": "Все пользователи", + "sidebarIdentityProviders": "Поставщики удостоверений", + "sidebarLicense": "Лицензия", + "sidebarClients": "Клиенты (бета)", + "sidebarDomains": "Домены", + "enableDockerSocket": "Включить Docker Socket", + "enableDockerSocketDescription": "Включить обнаружение Docker Socket для заполнения информации о контейнерах. Путь к сокету должен быть предоставлен Newt.", + "enableDockerSocketLink": "Узнать больше", + "viewDockerContainers": "Просмотр контейнеров Docker", + "containersIn": "Контейнеры в {siteName}", + "selectContainerDescription": "Выберите любой контейнер для использования в качестве имени хоста для этой цели. Нажмите на порт, чтобы использовать порт.", + "containerName": "Имя", + "containerImage": "Образ", + "containerState": "Состояние", + "containerNetworks": "Сети", + "containerHostnameIp": "Имя хоста/IP", + "containerLabels": "Метки", + "containerLabelsCount": "{count, plural, one {# метка} few {# метки} many {# меток} other {# меток}}", + "containerLabelsTitle": "Метки контейнера", + "containerLabelEmpty": "", + "containerPorts": "Порты", + "containerPortsMore": "+{count} ещё", + "containerActions": "Действия", + "select": "Выбрать", + "noContainersMatchingFilters": "Контейнеры, соответствующие текущим фильтрам, не найдены.", + "showContainersWithoutPorts": "Показать контейнеры без портов", + "showStoppedContainers": "Показать остановленные контейнеры", + "noContainersFound": "Контейнеры не найдены. Убедитесь, что контейнеры Docker запущены.", + "searchContainersPlaceholder": "Поиск среди {count} {count, plural, one {контейнера} few {контейнеров} many {контейнеров} other {контейнеров}}...", + "searchResultsCount": "{count, plural, one {# результат} few {# результата} many {# результатов} other {# результатов}}", + "filters": "Фильтры", + "filterOptions": "Параметры фильтрации", + "filterPorts": "Порты", + "filterStopped": "Остановлены", + "clearAllFilters": "Очистить все фильтры", + "columns": "Колонки", + "toggleColumns": "Переключить колонки", + "refreshContainersList": "Обновить список контейнеров", + "searching": "Поиск...", + "noContainersFoundMatching": "Контейнеры, соответствующие \"{filter}\", не найдены.", + "light": "светлая", + "dark": "тёмная", + "system": "системная", + "theme": "Тема", + "subnetRequired": "Требуется подсеть", + "initialSetupTitle": "Начальная настройка сервера", + "initialSetupDescription": "Создайте первоначальную учётную запись администратора сервера. Может существовать только один администратор сервера. Вы всегда можете изменить эти учётные данные позже.", + "createAdminAccount": "Создать учётную запись администратора", + "setupErrorCreateAdmin": "Произошла ошибка при создании учётной записи администратора сервера.", + "certificateStatus": "Статус сертификата", + "loading": "Загрузка", + "restart": "Перезагрузка", + "domains": "Домены", + "domainsDescription": "Управление доменами для вашей организации", + "domainsSearch": "Поиск доменов...", + "domainAdd": "Добавить Домен", + "domainAddDescription": "Зарегистрировать новый домен в вашей организации", + "domainCreate": "Создать Домен", + "domainCreatedDescription": "Домен успешно создан", + "domainDeletedDescription": "Домен успешно удален", + "domainQuestionRemove": "Вы уверены, что хотите удалить домен {domain} из вашего аккаунта?", + "domainMessageRemove": "После удаления домен больше не будет связан с вашей учетной записью.", + "domainMessageConfirm": "Для подтверждения введите ниже имя домена.", + "domainConfirmDelete": "Подтвердить удаление домена", + "domainDelete": "Удалить Домен", + "domain": "Домен", + "selectDomainTypeNsName": "Делегация домена (NS)", + "selectDomainTypeNsDescription": "Этот домен и все его субдомены. Используйте это, когда вы хотите управлять всей доменной зоной.", + "selectDomainTypeCnameName": "Одиночный домен (CNAME)", + "selectDomainTypeCnameDescription": "Только этот конкретный домен. Используйте это для отдельных субдоменов или отдельных записей домена.", + "selectDomainTypeWildcardName": "Wildcard Domain", + "selectDomainTypeWildcardDescription": "Этот домен и его субдомены.", + "domainDelegation": "Единый домен", + "selectType": "Выберите тип", + "actions": "Actions", + "refresh": "Refresh", + "refreshError": "Failed to refresh data", + "verified": "Verified", + "pending": "Pending", + "sidebarBilling": "Billing", + "billing": "Billing", + "orgBillingDescription": "Manage your billing information and subscriptions", + "github": "GitHub", + "pangolinHosted": "Pangolin Hosted", + "fossorial": "Fossorial", + "completeAccountSetup": "Complete Account Setup", + "completeAccountSetupDescription": "Set your password to get started", + "accountSetupSent": "We'll send an account setup code to this email address.", + "accountSetupCode": "Setup Code", + "accountSetupCodeDescription": "Check your email for the setup code.", + "passwordCreate": "Create Password", + "passwordCreateConfirm": "Confirm Password", + "accountSetupSubmit": "Send Setup Code", + "completeSetup": "Complete Setup", + "accountSetupSuccess": "Account setup completed! Welcome to Pangolin!", + "documentation": "Documentation", + "saveAllSettings": "Save All Settings", + "settingsUpdated": "Settings updated", + "settingsUpdatedDescription": "All settings have been updated successfully", + "settingsErrorUpdate": "Failed to update settings", + "settingsErrorUpdateDescription": "An error occurred while updating settings", + "sidebarCollapse": "Collapse", + "sidebarExpand": "Expand", + "newtUpdateAvailable": "Update Available", + "newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", + "domainPickerEnterDomain": "Domain", + "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp", + "domainPickerDescription": "Enter the full domain of the resource to see available options.", + "domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options", + "domainPickerTabAll": "All", + "domainPickerTabOrganization": "Organization", + "domainPickerTabProvided": "Provided", + "domainPickerSortAsc": "A-Z", + "domainPickerSortDesc": "Z-A", + "domainPickerCheckingAvailability": "Checking availability...", + "domainPickerNoMatchingDomains": "No matching domains found. Try a different domain or check your organization's domain settings.", + "domainPickerOrganizationDomains": "Organization Domains", + "domainPickerProvidedDomains": "Provided Domains", + "domainPickerSubdomain": "Subdomain: {subdomain}", + "domainPickerNamespace": "Namespace: {namespace}", + "domainPickerShowMore": "Show More", + "domainNotFound": "Domain Not Found", + "domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.", + "failed": "Failed", + "createNewOrgDescription": "Create a new organization", + "organization": "Organization", + "port": "Port", + "securityKeyManage": "Manage Security Keys", + "securityKeyDescription": "Add or remove security keys for passwordless authentication", + "securityKeyRegister": "Register New Security Key", + "securityKeyList": "Your Security Keys", + "securityKeyNone": "No security keys registered yet", + "securityKeyNameRequired": "Name is required", + "securityKeyRemove": "Remove", + "securityKeyLastUsed": "Last used: {date}", + "securityKeyNameLabel": "Security Key Name", + "securityKeyRegisterSuccess": "Security key registered successfully", + "securityKeyRegisterError": "Failed to register security key", + "securityKeyRemoveSuccess": "Security key removed successfully", + "securityKeyRemoveError": "Failed to remove security key", + "securityKeyLoadError": "Failed to load security keys", + "securityKeyLogin": "Continue with security key", + "securityKeyAuthError": "Failed to authenticate with security key", + "securityKeyRecommendation": "Register a backup security key on another device to ensure you always have access to your account.", + "registering": "Registering...", + "securityKeyPrompt": "Please verify your identity using your security key. Make sure your security key is connected and ready.", + "securityKeyBrowserNotSupported": "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari.", + "securityKeyPermissionDenied": "Please allow access to your security key to continue signing in.", + "securityKeyRemovedTooQuickly": "Please keep your security key connected until the sign-in process completes.", + "securityKeyNotSupported": "Your security key may not be compatible. Please try a different security key.", + "securityKeyUnknownError": "There was a problem using your security key. Please try again.", + "twoFactorRequired": "Two-factor authentication is required to register a security key.", + "twoFactor": "Two-Factor Authentication", + "adminEnabled2FaOnYourAccount": "Your administrator has enabled two-factor authentication for {email}. Please complete the setup process to continue.", + "continueToApplication": "Continue to Application", + "securityKeyAdd": "Add Security Key", + "securityKeyRegisterTitle": "Register New Security Key", + "securityKeyRegisterDescription": "Connect your security key and enter a name to identify it", + "securityKeyTwoFactorRequired": "Two-Factor Authentication Required", + "securityKeyTwoFactorDescription": "Please enter your two-factor authentication code to register the security key", + "securityKeyTwoFactorRemoveDescription": "Please enter your two-factor authentication code to remove the security key", + "securityKeyTwoFactorCode": "Two-Factor Code", + "securityKeyRemoveTitle": "Remove Security Key", + "securityKeyRemoveDescription": "Enter your password to remove the security key \"{name}\"", + "securityKeyNoKeysRegistered": "No security keys registered", + "securityKeyNoKeysDescription": "Add a security key to enhance your account security", + "createDomainRequired": "Domain is required", + "createDomainAddDnsRecords": "Add DNS Records", + "createDomainAddDnsRecordsDescription": "Add the following DNS records to your domain provider to complete the setup.", + "createDomainNsRecords": "NS Records", + "createDomainRecord": "Record", + "createDomainType": "Type:", + "createDomainName": "Name:", + "createDomainValue": "Value:", + "createDomainCnameRecords": "CNAME Records", + "createDomainARecords": "A Records", + "createDomainRecordNumber": "Record {number}", + "createDomainTxtRecords": "TXT Records", + "createDomainSaveTheseRecords": "Сохранить эти записи", + "createDomainSaveTheseRecordsDescription": "Обязательно сохраните эти DNS записи, так как вы их больше не увидите.", + "createDomainDnsPropagation": "Распространение DNS", + "createDomainDnsPropagationDescription": "Изменения DNS могут занять некоторое время для распространения через интернет. Это может занять от нескольких минут до 48 часов в зависимости от вашего DNS провайдера и настроек TTL.", + "resourcePortRequired": "Номер порта необходим для не-HTTP ресурсов", + "resourcePortNotAllowed": "Номер порта не должен быть установлен для HTTP ресурсов", + "signUpTerms": { + "IAgreeToThe": "Я согласен с", + "termsOfService": "условия использования", + "and": "и", + "privacyPolicy": "политика конфиденциальности" + }, + "siteRequired": "Необходимо указать сайт.", + "olmTunnel": "Olm Tunnel", + "olmTunnelDescription": "Use Olm for client connectivity", + "errorCreatingClient": "Error creating client", + "clientDefaultsNotFound": "Client defaults not found", + "createClient": "Create Client", + "createClientDescription": "Create a new client for connecting to your sites", + "seeAllClients": "See All Clients", + "clientInformation": "Client Information", + "clientNamePlaceholder": "Client name", + "address": "Address", + "subnetPlaceholder": "Subnet", + "addressDescription": "The address that this client will use for connectivity", + "selectSites": "Select sites", + "sitesDescription": "The client will have connectivity to the selected sites", + "clientInstallOlm": "Install Olm", + "clientInstallOlmDescription": "Get Olm running on your system", + "clientOlmCredentials": "Olm Credentials", + "clientOlmCredentialsDescription": "This is how Olm will authenticate with the server", + "olmEndpoint": "Olm Endpoint", + "olmId": "Olm ID", + "olmSecretKey": "Olm Secret Key", + "clientCredentialsSave": "Save Your Credentials", + "clientCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.", + "generalSettingsDescription": "Configure the general settings for this client", + "clientUpdated": "Client updated", + "clientUpdatedDescription": "The client has been updated.", + "clientUpdateFailed": "Failed to update client", + "clientUpdateError": "An error occurred while updating the client.", + "sitesFetchFailed": "Failed to fetch sites", + "sitesFetchError": "An error occurred while fetching sites.", + "olmErrorFetchReleases": "An error occurred while fetching Olm releases.", + "olmErrorFetchLatest": "An error occurred while fetching the latest Olm release.", + "remoteSubnets": "Remote Subnets", + "enterCidrRange": "Enter CIDR range", + "remoteSubnetsDescription": "Add CIDR ranges that can access this site remotely. Use format like 10.0.0.0/24 or 192.168.1.0/24.", + "resourceEnableProxy": "Enable Public Proxy", + "resourceEnableProxyDescription": "Enable public proxying to this resource. This allows access to the resource from outside the network through the cloud on an open port. Requires Traefik config.", + "externalProxyEnabled": "External Proxy Enabled" +} diff --git a/messages/tr-TR.json b/messages/tr-TR.json index ad6a0fe3..e4d68eba 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -11,8 +11,9 @@ "componentsErrorNoMemberCreate": "Şu anda herhangi bir organizasyona üye değilsiniz. Başlamak için bir organizasyon oluşturun.", "componentsErrorNoMember": "Şu anda herhangi bir organizasyona üye değilsiniz.", "welcome": "Pangolin'e hoş geldiniz", + "welcomeTo": "Hoş geldiniz", "componentsCreateOrg": "Bir Organizasyon Oluşturun", - "componentsMember": "You're a member of {count, plural, =0 {no organization} =1 {one organization} other {# organizations}}.", + "componentsMember": "{count, plural, =0 {hiçbir organizasyon} one {bir organizasyon} other {# organizasyon}} üyesisiniz.", "componentsInvalidKey": "Geçersiz veya süresi dolmuş lisans anahtarları tespit edildi. Tüm özellikleri kullanmaya devam etmek için lisans koşullarına uyun.", "dismiss": "Kapat", "componentsLicenseViolation": "Lisans İhlali: Bu sunucu, lisanslı sınırı olan {maxSites} sitesini aşarak {usedSites} site kullanmaktadır. Tüm özellikleri kullanmaya devam etmek için lisans koşullarına uyun.", @@ -58,7 +59,6 @@ "siteErrorCreate": "Site oluşturulurken hata", "siteErrorCreateKeyPair": "Anahtar çifti veya site varsayılanları bulunamadı", "siteErrorCreateDefaults": "Site varsayılanları bulunamadı", - "siteNameDescription": "Bu, site için görünen addır.", "method": "Yöntem", "siteMethodDescription": "Bağlantıları nasıl açığa çıkaracağınız budur.", "siteLearnNewt": "Newt'i sisteminize nasıl kuracağınızı öğrenin", @@ -206,6 +206,7 @@ "orgGeneralSettings": "Organizasyon Ayarları", "orgGeneralSettingsDescription": "Organizasyon detaylarınızı ve yapılandırmanızı yönetin", "saveGeneralSettings": "Genel Ayarları Kaydet", + "saveSettings": "Ayarları Kaydet", "orgDangerZone": "Tehlike Alanı", "orgDangerZoneDescription": "Bu organizasyonu sildikten sonra geri dönüş yoktur. Emin olun.", "orgDelete": "Organizasyonu Sil", @@ -249,7 +250,7 @@ "weeks": "Hafta", "months": "Ay", "years": "Yıl", - "day": "{count, plural, =1 {# day} other {# days}}", + "day": "{count, plural, one {# gün} other {# gün}}", "apiKeysTitle": "API Anahtar Bilgilendirmesi", "apiKeysConfirmCopy2": "API anahtarını kopyaladığınızı onaylamanız gerekmektedir.", "apiKeysErrorCreate": "API anahtarı oluşturulurken hata", @@ -347,7 +348,7 @@ "licensePurchase": "Lisans Satın Al", "licensePurchaseSites": "Ek Siteler Satın Al", "licenseSitesUsedMax": "{usedSites} / {maxSites} siteleri kullanıldı", - "licenseSitesUsed": "{count, plural, =0 {# site} =1 {# site} other {# site}} sistemde bulunmaktadır.", + "licenseSitesUsed": "{count, plural, =0 {# site} one {# site} other {# site}} sistemde bulunmaktadır.", "licensePurchaseDescription": "{selectedMode, select, license {Lisans satın almak için kaç site istediğinizi seçin. Daha sonra daha fazla site ekleyebilirsiniz.} other {mevcut lisansınıza kaç site ekleneceğini seçin.}}", "licenseFee": "Lisans ücreti", "licensePriceSite": "Site başına fiyat", @@ -436,7 +437,7 @@ "accessRoleSelect": "Rol seçin", "inviteEmailSentDescription": "Kullanıcıya erişim bağlantısı ile bir e-posta gönderildi. Daveti kabul etmek için bağlantıya erişmelidirler.", "inviteSentDescription": "Kullanıcı davet edilmiştir. Daveti kabul etmek için aşağıdaki bağlantıya erişmelidirler.", - "inviteExpiresIn": "The invite will expire in {days, plural, =1 {# day} other {# days}}.", + "inviteExpiresIn": "Davetiye {days, plural, one {# gün} other {# gün}} içinde sona erecektir.", "idpTitle": "General Information", "idpSelect": "Dış kullanıcı için kimlik sağlayıcıyı seçin", "idpNotConfigured": "Herhangi bir kimlik sağlayıcı yapılandırılmamış. Harici kullanıcılar oluşturulmadan önce lütfen bir kimlik sağlayıcı yapılandırın.", @@ -958,6 +959,8 @@ "licenseTierProfessionalRequiredDescription": "Bu özellik yalnızca Professional Edition'da kullanılabilir.", "actionGetOrg": "Kuruluşu Al", "actionUpdateOrg": "Kuruluşu Güncelle", + "actionUpdateUser": "Kullanıcıyı Güncelle", + "actionGetUser": "Kullanıcıyı Getir", "actionGetOrgUser": "Kuruluş Kullanıcısını Al", "actionListOrgDomains": "Kuruluş Alan Adlarını Listele", "actionCreateSite": "Site Oluştur", @@ -1019,6 +1022,11 @@ "actionDeleteIdpOrg": "Kimlik Sağlayıcı Organizasyon Politikasını Sil", "actionListIdpOrgs": "Kimlik Sağlayıcı Organizasyonları Listele", "actionUpdateIdpOrg": "Kimlik Sağlayıcı Organizasyonu Güncelle", + "actionCreateClient": "Müşteri Oluştur", + "actionDeleteClient": "Müşteri Sil", + "actionUpdateClient": "Müşteri Güncelle", + "actionListClients": "Müşterileri Listele", + "actionGetClient": "Müşteriyi Al", "noneSelected": "Hiçbiri seçili değil", "orgNotFound2": "Hiçbir organizasyon bulunamadı.", "searchProgress": "Ara...", @@ -1090,6 +1098,8 @@ "sidebarAllUsers": "Tüm Kullanıcılar", "sidebarIdentityProviders": "Kimlik Sağlayıcılar", "sidebarLicense": "Lisans", + "sidebarClients": "Müşteriler (Beta)", + "sidebarDomains": "Alan Adları", "enableDockerSocket": "Docker Soketi Etkinleştir", "enableDockerSocketDescription": "Konteyner bilgilerini doldurmak için Docker Socket keşfini etkinleştirin. Socket yolu Newt'e sağlanmalıdır.", "enableDockerSocketLink": "Daha fazla bilgi", @@ -1102,7 +1112,7 @@ "containerNetworks": "Ağlar", "containerHostnameIp": "Ana Makine/IP", "containerLabels": "Etiketler", - "containerLabelsCount": "{count} etiket{s,plural,one{} other{ler}}", + "containerLabelsCount": "{count, plural, one {# etiket} other {# etiketler}}", "containerLabelsTitle": "Konteyner Etiketleri", "containerLabelEmpty": "", "containerPorts": "Bağlantı Noktaları", @@ -1114,7 +1124,7 @@ "showStoppedContainers": "Durdurulmuş konteynerleri göster", "noContainersFound": "Konteyner bulunamadı. Docker konteynerlerinin çalıştığından emin olun.", "searchContainersPlaceholder": "{count} konteyner arasında arama yapın...", - "searchResultsCount": "{count} sonuç{s,plural,one{} other{lar}}", + "searchResultsCount": "{count, plural, one {# sonuç} other {# sonuçlar}}", "filters": "Filtreler", "filterOptions": "Filtre Seçenekleri", "filterPorts": "Bağlantı Noktaları", @@ -1129,8 +1139,189 @@ "dark": "koyu", "system": "sistem", "theme": "Tema", + "subnetRequired": "Alt ağ gereklidir", "initialSetupTitle": "İlk Sunucu Kurulumu", "initialSetupDescription": "İlk sunucu yönetici hesabını oluşturun. Yalnızca bir sunucu yöneticisi olabilir. Bu kimlik bilgilerini daha sonra her zaman değiştirebilirsiniz.", "createAdminAccount": "Yönetici Hesabı Oluştur", - "setupErrorCreateAdmin": "Sunucu yönetici hesabı oluşturulurken bir hata oluştu." + "setupErrorCreateAdmin": "Sunucu yönetici hesabı oluşturulurken bir hata oluştu.", + "certificateStatus": "Sertifika Durumu", + "loading": "Yükleniyor", + "restart": "Yeniden Başlat", + "domains": "Alan Adları", + "domainsDescription": "Organizasyonunuz için alan adlarını yönetin", + "domainsSearch": "Alan adlarını ara...", + "domainAdd": "Alan Adı Ekle", + "domainAddDescription": "Organizasyonunuz için yeni bir alan adı kaydedin", + "domainCreate": "Alan Adı Oluştur", + "domainCreatedDescription": "Alan adı başarıyla oluşturuldu", + "domainDeletedDescription": "Alan adı başarıyla silindi", + "domainQuestionRemove": "{domain} alan adını hesabınızdan kaldırmak istediğinizden emin misiniz?", + "domainMessageRemove": "Kaldırıldığında, alan adı hesabınızla ilişkilendirilmeyecek.", + "domainMessageConfirm": "Onaylamak için lütfen aşağıya alan adını yazın.", + "domainConfirmDelete": "Alan Adı Silinmesini Onayla", + "domainDelete": "Alan Adını Sil", + "domain": "Alan Adı", + "selectDomainTypeNsName": "Alan Adı Delege Etme (NS)", + "selectDomainTypeNsDescription": "Bu alan adı ve tüm alt alan adları. Tüm bir alan adı bölgesini kontrol etmek istediğinizde bunu kullanın.", + "selectDomainTypeCnameName": "Tekil Alan Adı (CNAME)", + "selectDomainTypeCnameDescription": "Sadece bu belirli alan adı. Bireysel alt alan adları veya belirli alan adı girişleri için bunu kullanın.", + "selectDomainTypeWildcardName": "Wildcard Alan Adı", + "selectDomainTypeWildcardDescription": "Bu domain ve alt alan adları.", + "domainDelegation": "Tekil Alan Adı", + "selectType": "Bir tür seçin", + "actions": "İşlemler", + "refresh": "Yenile", + "refreshError": "Veriler yenilenemedi", + "verified": "Doğrulandı", + "pending": "Beklemede", + "sidebarBilling": "Faturalama", + "billing": "Faturalama", + "orgBillingDescription": "Fatura bilgilerinizi ve aboneliklerinizi yönetin", + "github": "GitHub", + "pangolinHosted": "Pangolin Barındırılan", + "fossorial": "Fossorial", + "completeAccountSetup": "Hesap Kurulumunu Tamamla", + "completeAccountSetupDescription": "Başlamak için şifrenizi ayarlayın", + "accountSetupSent": "Bu e-posta adresine bir hesap kurulum kodu göndereceğiz.", + "accountSetupCode": "Kurulum Kodu", + "accountSetupCodeDescription": "Kurulum kodu için e-posta gelen kutunuzu kontrol edin.", + "passwordCreate": "Parola Oluştur", + "passwordCreateConfirm": "Şifreyi Onayla", + "accountSetupSubmit": "Kurulum Kodunu Gönder", + "completeSetup": "Kurulumu Tamamla", + "accountSetupSuccess": "Hesap kurulumu tamamlandı! Pangolin'e hoş geldiniz!", + "documentation": "Dokümantasyon", + "saveAllSettings": "Tüm Ayarları Kaydet", + "settingsUpdated": "Ayarlar güncellendi", + "settingsUpdatedDescription": "Tüm ayarlar başarıyla güncellendi", + "settingsErrorUpdate": "Ayarlar güncellenemedi", + "settingsErrorUpdateDescription": "Ayarları güncellerken bir hata oluştu", + "sidebarCollapse": "Daralt", + "sidebarExpand": "Genişlet", + "newtUpdateAvailable": "Güncelleme Mevcut", + "newtUpdateAvailableInfo": "Newt'in yeni bir versiyonu mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.", + "domainPickerEnterDomain": "Domain", + "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com veya sadece myapp", + "domainPickerDescription": "Mevcut seçenekleri görmek için kaynağın tam etki alanını girin.", + "domainPickerDescriptionSaas": "Mevcut seçenekleri görmek için tam etki alanı, alt etki alanı veya sadece bir isim girin", + "domainPickerTabAll": "Tümü", + "domainPickerTabOrganization": "Organizasyon", + "domainPickerTabProvided": "Sağlanan", + "domainPickerSortAsc": "A-Z", + "domainPickerSortDesc": "Z-A", + "domainPickerCheckingAvailability": "Kullanılabilirlik kontrol ediliyor...", + "domainPickerNoMatchingDomains": "Eşleşen domain bulunamadı. Farklı bir domain deneyin veya organizasyonunuzun domain ayarlarını kontrol edin.", + "domainPickerOrganizationDomains": "Organizasyon Alan Adları", + "domainPickerProvidedDomains": "Sağlanan Alan Adları", + "domainPickerSubdomain": "Alt Alan: {subdomain}", + "domainPickerNamespace": "Ad Alanı: {namespace}", + "domainPickerShowMore": "Daha Fazla Göster", + "domainNotFound": "Alan Adı Bulunamadı", + "domainNotFoundDescription": "Bu kaynak devre dışıdır çünkü alan adı sistemimizde artık mevcut değil. Bu kaynak için yeni bir alan adı belirleyin.", + "failed": "Başarısız", + "createNewOrgDescription": "Yeni bir organizasyon oluşturun", + "organization": "Kuruluş", + "port": "Bağlantı Noktası", + "securityKeyManage": "Güvenlik Anahtarlarını Yönet", + "securityKeyDescription": "Şifresiz kimlik doğrulama için güvenlik anahtarları ekleyin veya kaldırın", + "securityKeyRegister": "Yeni Güvenlik Anahtarı Kaydet", + "securityKeyList": "Güvenlik Anahtarlarınız", + "securityKeyNone": "Henüz kayıtlı güvenlik anahtarı yok", + "securityKeyNameRequired": "İsim gerekli", + "securityKeyRemove": "Kaldır", + "securityKeyLastUsed": "Son kullanım: {date}", + "securityKeyNameLabel": "İsim", + "securityKeyRegisterSuccess": "Güvenlik anahtarı başarıyla kaydedildi", + "securityKeyRegisterError": "Güvenlik anahtarı kaydedilirken hata oluştu", + "securityKeyRemoveSuccess": "Güvenlik anahtarı başarıyla kaldırıldı", + "securityKeyRemoveError": "Güvenlik anahtarı kaldırılırken hata oluştu", + "securityKeyLoadError": "Güvenlik anahtarları yüklenirken hata oluştu", + "securityKeyLogin": "Güvenlik anahtarı ile devam edin", + "securityKeyAuthError": "Güvenlik anahtarı ile kimlik doğrulama başarısız oldu", + "securityKeyRecommendation": "Hesabınızdan kilitlenmediğinizden emin olmak için farklı bir cihazda başka bir güvenlik anahtarı kaydetmeyi düşünün.", + "registering": "Kaydediliyor...", + "securityKeyPrompt": "Lütfen güvenlik anahtarınızı kullanarak kimliğinizi doğrulayın. Güvenlik anahtarınızın bağlı ve hazır olduğundan emin olun.", + "securityKeyBrowserNotSupported": "Tarayıcınız güvenlik anahtarlarını desteklemiyor. Lütfen Chrome, Firefox veya Safari gibi modern bir tarayıcı kullanın.", + "securityKeyPermissionDenied": "Giriş yapmaya devam etmek için lütfen güvenlik anahtarınıza erişime izin verin.", + "securityKeyRemovedTooQuickly": "Güvenlik anahtarınızın bağlantısını kesmeden önce oturum açma işlemi tamamlanana kadar bağlı kalmasını sağlayın.", + "securityKeyNotSupported": "Güvenlik anahtarınız uyumlu olmayabilir. Lütfen farklı bir güvenlik anahtarı deneyin.", + "securityKeyUnknownError": "Güvenlik anahtarınızı kullanırken bir sorun oluştu. Lütfen tekrar deneyin.", + "twoFactorRequired": "Güvenlik anahtarını kaydetmek için iki faktörlü kimlik doğrulama gereklidir.", + "twoFactor": "İki Faktörlü Kimlik Doğrulama", + "adminEnabled2FaOnYourAccount": "Yöneticiniz {email} için iki faktörlü kimlik doğrulamayı etkinleştirdi. Devam etmek için kurulum işlemini tamamlayın.", + "continueToApplication": "Uygulamaya Devam Et", + "securityKeyAdd": "Güvenlik Anahtarı Ekle", + "securityKeyRegisterTitle": "Yeni Güvenlik Anahtarı Kaydet", + "securityKeyRegisterDescription": "Güvenlik anahtarınızı bağlayın ve tanımlamak için bir ad girin", + "securityKeyTwoFactorRequired": "İki Faktörlü Kimlik Doğrulama Gereklidir", + "securityKeyTwoFactorDescription": "Güvenlik anahtarını kaydetmek için lütfen iki faktörlü kimlik doğrulama kodunuzu girin", + "securityKeyTwoFactorRemoveDescription": "Güvenlik anahtarını kaldırmak için lütfen iki faktörlü kimlik doğrulama kodunuzu girin", + "securityKeyTwoFactorCode": "İki Faktörlü Kod", + "securityKeyRemoveTitle": "Güvenlik Anahtarını Kaldır", + "securityKeyRemoveDescription": "Güvenlik anahtarını \"{name}\" kaldırmak için şifrenizi girin", + "securityKeyNoKeysRegistered": "Kayıtlı güvenlik anahtarı yok", + "securityKeyNoKeysDescription": "Hesabınızın güvenliğini artırmak için bir güvenlik anahtarı ekleyin", + "createDomainRequired": "Alan adı gereklidir", + "createDomainAddDnsRecords": "DNS Kayıtlarını Ekle", + "createDomainAddDnsRecordsDescription": "Kurulumu tamamlamak için alan sağlayıcınıza şu DNS kayıtlarını ekleyin.", + "createDomainNsRecords": "NS Kayıtları", + "createDomainRecord": "Kayıt", + "createDomainType": "Tür:", + "createDomainName": "Ad:", + "createDomainValue": "Değer:", + "createDomainCnameRecords": "CNAME Kayıtları", + "createDomainARecords": "A Kayıtları", + "createDomainRecordNumber": "Kayıt {number}", + "createDomainTxtRecords": "TXT Kayıtları", + "createDomainSaveTheseRecords": "Bu Kayıtları Kaydet", + "createDomainSaveTheseRecordsDescription": "Bu DNS kayıtlarını kaydettiğinizden emin olun çünkü tekrar görmeyeceksiniz.", + "createDomainDnsPropagation": "DNS Yayılması", + "createDomainDnsPropagationDescription": "DNS değişikliklerinin internet genelinde yayılması zaman alabilir. DNS sağlayıcınız ve TTL ayarlarına bağlı olarak bu birkaç dakika ile 48 saat arasında değişebilir.", + "resourcePortRequired": "HTTP dışı kaynaklar için bağlantı noktası numarası gereklidir", + "resourcePortNotAllowed": "HTTP kaynakları için bağlantı noktası numarası ayarlanmamalı", + "signUpTerms": { + "IAgreeToThe": "Kabul ediyorum", + "termsOfService": "hizmet şartları", + "and": "ve", + "privacyPolicy": "gizlilik politikası" + }, + "siteRequired": "Site gerekli.", + "olmTunnel": "Olm Tüneli", + "olmTunnelDescription": "Müşteri bağlantıları için Olm kullanın", + "errorCreatingClient": "Müşteri oluşturulurken hata oluştu", + "clientDefaultsNotFound": "Müşteri varsayılanları bulunamadı", + "createClient": "Müşteri Oluştur", + "createClientDescription": "Sitelerinize bağlanmak için yeni bir müşteri oluşturun", + "seeAllClients": "Tüm Müşterileri Gör", + "clientInformation": "Müşteri Bilgileri", + "clientNamePlaceholder": "Müşteri adı", + "address": "Adres", + "subnetPlaceholder": "Alt ağ", + "addressDescription": "Bu müşteri için bağlantıda kullanılacak adres", + "selectSites": "Siteleri seçin", + "sitesDescription": "Müşteri seçilen sitelere bağlantı kuracaktır", + "clientInstallOlm": "Olm Yükle", + "clientInstallOlmDescription": "Sisteminizde Olm çalıştırın", + "clientOlmCredentials": "Olm Kimlik Bilgileri", + "clientOlmCredentialsDescription": "Bu, Olm'in sunucu ile kimlik doğrulaması yapacağı yöntemdir", + "olmEndpoint": "Olm Uç Noktası", + "olmId": "Olm Kimliği", + "olmSecretKey": "Olm Gizli Anahtarı", + "clientCredentialsSave": "Kimlik Bilgilerinizi Kaydedin", + "clientCredentialsSaveDescription": "Bunu yalnızca bir kez görebileceksiniz. Güvenli bir yere kopyaladığınızdan emin olun.", + "generalSettingsDescription": "Bu müşteri için genel ayarları yapılandırın", + "clientUpdated": "Müşteri güncellendi", + "clientUpdatedDescription": "Müşteri güncellenmiştir.", + "clientUpdateFailed": "Müşteri güncellenemedi", + "clientUpdateError": "Müşteri güncellenirken bir hata oluştu.", + "sitesFetchFailed": "Siteler alınamadı", + "sitesFetchError": "Siteler alınırken bir hata oluştu.", + "olmErrorFetchReleases": "Olm yayınları alınırken bir hata oluştu.", + "olmErrorFetchLatest": "En son Olm yayını alınırken bir hata oluştu.", + "remoteSubnets": "Uzak Alt Ağlar", + "enterCidrRange": "CIDR aralığını girin", + "remoteSubnetsDescription": "Bu siteye uzaktan erişebilecek CIDR aralıklarını ekleyin. 10.0.0.0/24 veya 192.168.1.0/24 gibi formatlar kullanın.", + "resourceEnableProxy": "Genel Proxy'i Etkinleştir", + "resourceEnableProxyDescription": "Bu kaynağa genel proxy erişimini etkinleştirin. Bu sayede ağ dışından açık bir port üzerinden kaynağa bulut aracılığıyla erişim sağlanır. Traefik yapılandırması gereklidir.", + "externalProxyEnabled": "Dış Proxy Etkinleştirildi" } diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 676d5f56..b18a7ab7 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -11,8 +11,9 @@ "componentsErrorNoMemberCreate": "您目前不是任何组织的成员。创建组织以开始操作。", "componentsErrorNoMember": "您目前不是任何组织的成员。", "welcome": "欢迎使用 Pangolin", + "welcomeTo": "欢迎来到", "componentsCreateOrg": "创建组织", - "componentsMember": "您属于 {count, plural, =0 {无组织} =1 {一个组织} other {# 个组织}}。", + "componentsMember": "您属于{count, plural, =0 {没有组织} one {一个组织} other {# 个组织}}。", "componentsInvalidKey": "检测到无效或过期的许可证密钥。按照许可证条款操作以继续使用所有功能。", "dismiss": "忽略", "componentsLicenseViolation": "许可证超限:该服务器使用了 {usedSites} 个站点,已超过授权的 {maxSites} 个。请遵守许可证条款以继续使用全部功能。", @@ -58,7 +59,6 @@ "siteErrorCreate": "创建站点出错", "siteErrorCreateKeyPair": "找不到密钥对或站点默认值", "siteErrorCreateDefaults": "未找到站点默认值", - "siteNameDescription": "这是站点的显示名称。", "method": "方法", "siteMethodDescription": "这是您将如何显示连接。", "siteLearnNewt": "学习如何在您的系统上安装 Newt", @@ -206,13 +206,14 @@ "orgGeneralSettings": "组织设置", "orgGeneralSettingsDescription": "管理您的机构详细信息和配置", "saveGeneralSettings": "保存常规设置", + "saveSettings": "保存设置", "orgDangerZone": "危险区域", "orgDangerZoneDescription": "一旦删除该组织,将无法恢复,请务必确认。", "orgDelete": "删除组织", "orgDeleteConfirm": "确认删除组织", "orgMessageRemove": "此操作不可逆,这将删除所有相关数据。", "orgMessageConfirm": "要确认,请在下面输入组织名称。", - "orgQuestionRemove": "你确定要删除 “{selectedOrg}” 组织吗?", + "orgQuestionRemove": "你确定要删除 \"{selectedOrg}\" 组织吗?", "orgUpdated": "组织已更新", "orgUpdatedDescription": "组织已更新。", "orgErrorUpdate": "更新组织失败", @@ -249,7 +250,7 @@ "weeks": "周", "months": "月", "years": "年", - "day": "{count, plural, =1 {# 天} other {# 天}}", + "day": "{count, plural, other {# 天}}", "apiKeysTitle": "API 密钥", "apiKeysConfirmCopy2": "您必须确认您已复制 API 密钥。", "apiKeysErrorCreate": "创建 API 密钥出错", @@ -279,7 +280,7 @@ "apiKeysAdd": "生成 API 密钥", "apiKeysErrorDelete": "删除 API 密钥出错", "apiKeysErrorDeleteMessage": "删除 API 密钥出错", - "apiKeysQuestionRemove": "您确定要从组织中删除 “{selectedApiKey}” API密钥吗?", + "apiKeysQuestionRemove": "您确定要从组织中删除 \"{selectedApiKey}\" API密钥吗?", "apiKeysMessageRemove": "一旦删除,此API密钥将无法被使用。", "apiKeysMessageConfirm": "要确认,请在下方输入API密钥名称。", "apiKeysDeleteConfirm": "确认删除 API 密钥", @@ -347,7 +348,7 @@ "licensePurchase": "购买许可证", "licensePurchaseSites": "购买更多站点", "licenseSitesUsedMax": "使用了 {usedSites}/{maxSites} 个站点", - "licenseSitesUsed": "{count, plural, =0 {# 站点} =1 {# 站点} other {# 站点}}", + "licenseSitesUsed": "{count, plural, =0 {# 站点} one {# 站点} other {# 站点}}", "licensePurchaseDescription": "请选择您希望 {selectedMode, select, license {直接购买许可证,您可以随时增加更多站点。} other {为现有许可证购买更多站点}}", "licenseFee": "许可证费用", "licensePriceSite": "每个站点的价格", @@ -436,7 +437,7 @@ "accessRoleSelect": "选择角色", "inviteEmailSentDescription": "一封电子邮件已经发送给用户,带有下面的访问链接。他们必须访问该链接才能接受邀请。", "inviteSentDescription": "用户已被邀请。他们必须访问下面的链接才能接受邀请。", - "inviteExpiresIn": "邀请将于 {days, plural, =1 {# 天} other {# 天}}", + "inviteExpiresIn": "邀请将在{days, plural, other {# 天}}后过期。", "idpTitle": "身份提供商", "idpSelect": "为外部用户选择身份提供商", "idpNotConfigured": "没有配置身份提供者。请在创建外部用户之前配置身份提供者。", @@ -715,7 +716,7 @@ "idpManageDescription": "查看和管理系统中的身份提供商", "idpDeletedDescription": "身份提供商删除成功", "idpOidc": "OAuth2/OIDC", - "idpQuestionRemove": "你确定要永久删除 “{name}” 这个身份提供商吗?", + "idpQuestionRemove": "你确定要永久删除 \"{name}\" 这个身份提供商吗?", "idpMessageRemove": "这将删除身份提供者和所有相关的配置。通过此提供者进行身份验证的用户将无法登录。", "idpMessageConfirm": "要确认,请在下面输入身份提供者的名称。", "idpConfirmDelete": "确认删除身份提供商", @@ -958,6 +959,8 @@ "licenseTierProfessionalRequiredDescription": "此功能仅在专业版可用。", "actionGetOrg": "获取组织", "actionUpdateOrg": "更新组织", + "actionUpdateUser": "更新用户", + "actionGetUser": "获取用户", "actionGetOrgUser": "获取组织用户", "actionListOrgDomains": "列出组织域", "actionCreateSite": "创建站点", @@ -1019,6 +1022,11 @@ "actionDeleteIdpOrg": "删除 IDP组织策略", "actionListIdpOrgs": "列出 IDP组织", "actionUpdateIdpOrg": "更新 IDP组织", + "actionCreateClient": "创建客户端", + "actionDeleteClient": "删除客户端", + "actionUpdateClient": "更新客户端", + "actionListClients": "列出客户端", + "actionGetClient": "获取客户端", "noneSelected": "未选择", "orgNotFound2": "未找到组织。", "searchProgress": "搜索中...", @@ -1090,6 +1098,8 @@ "sidebarAllUsers": "所有用户", "sidebarIdentityProviders": "身份提供商", "sidebarLicense": "证书", + "sidebarClients": "客户端(测试版)", + "sidebarDomains": "域", "enableDockerSocket": "启用停靠套接字", "enableDockerSocketDescription": "启用 Docker Socket 发现以填充容器信息。必须向 Newt 提供 Socket 路径。", "enableDockerSocketLink": "了解更多", @@ -1102,7 +1112,7 @@ "containerNetworks": "网络", "containerHostnameIp": "主机名/IP", "containerLabels": "标签", - "containerLabelsCount": "{count} label{s,plural,one{} other{s}}", + "containerLabelsCount": "{count, plural, other {# 标签}}", "containerLabelsTitle": "容器标签", "containerLabelEmpty": "", "containerPorts": "端口", @@ -1114,7 +1124,7 @@ "showStoppedContainers": "显示已停止的容器", "noContainersFound": "未找到容器。请确保Docker容器正在运行。", "searchContainersPlaceholder": "在 {count} 个容器中搜索...", - "searchResultsCount": "{count} result{s,plural,one{} other{s}}", + "searchResultsCount": "{count, plural, other {# 个结果}}", "filters": "筛选器", "filterOptions": "过滤器选项", "filterPorts": "端口", @@ -1129,8 +1139,189 @@ "dark": "深色", "system": "系统", "theme": "主题", + "subnetRequired": "子网是必填项", "initialSetupTitle": "初始服务器设置", "initialSetupDescription": "创建初始服务器管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。", "createAdminAccount": "创建管理员帐户", - "setupErrorCreateAdmin": "创建服务器管理员帐户时出错。" + "setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。", + "certificateStatus": "证书状态", + "loading": "加载中", + "restart": "重启", + "domains": "域", + "domainsDescription": "管理您的组织域", + "domainsSearch": "搜索域...", + "domainAdd": "添加域", + "domainAddDescription": "在您的组织中注册新域", + "domainCreate": "创建域", + "domainCreatedDescription": "域创建成功", + "domainDeletedDescription": "成功删除域", + "domainQuestionRemove": "您确定要从您的账户中移除域{domain}吗?", + "domainMessageRemove": "移除后,该域将不再与您的账户关联。", + "domainMessageConfirm": "要确认,请在下方输入域名。", + "domainConfirmDelete": "确认删除域", + "domainDelete": "删除域", + "domain": "域", + "selectDomainTypeNsName": "域委派(NS)", + "selectDomainTypeNsDescription": "此域及其所有子域。当您希望控制整个域区域时使用此选项。", + "selectDomainTypeCnameName": "单个域(CNAME)", + "selectDomainTypeCnameDescription": "仅此特定域。用于单个子域或特定域条目。", + "selectDomainTypeWildcardName": "通配符域", + "selectDomainTypeWildcardDescription": "此域名及其子域名。", + "domainDelegation": "单个域", + "selectType": "选择一个类型", + "actions": "操作", + "refresh": "刷新", + "refreshError": "刷新数据失败", + "verified": "已验证", + "pending": "待定", + "sidebarBilling": "计费", + "billing": "计费", + "orgBillingDescription": "管理您的账单信息和订阅", + "github": "GitHub", + "pangolinHosted": "Pangolin 托管", + "fossorial": "Fossorial", + "completeAccountSetup": "完成账户设置", + "completeAccountSetupDescription": "设置您的密码以开始", + "accountSetupSent": "我们将发送账号设置代码到该电子邮件地址。", + "accountSetupCode": "设置代码", + "accountSetupCodeDescription": "请检查您的邮箱以获取设置代码。", + "passwordCreate": "创建密码", + "passwordCreateConfirm": "确认密码", + "accountSetupSubmit": "发送设置代码", + "completeSetup": "完成设置", + "accountSetupSuccess": "账号设置完成!欢迎来到 Pangolin!", + "documentation": "文档", + "saveAllSettings": "保存所有设置", + "settingsUpdated": "设置已更新", + "settingsUpdatedDescription": "所有设置已成功更新", + "settingsErrorUpdate": "设置更新失败", + "settingsErrorUpdateDescription": "更新设置时发生错误", + "sidebarCollapse": "折叠", + "sidebarExpand": "展开", + "newtUpdateAvailable": "更新可用", + "newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。", + "domainPickerEnterDomain": "域名", + "domainPickerPlaceholder": "myapp.example.com、api.v1.mydomain.com 或仅 myapp", + "domainPickerDescription": "输入资源的完整域名以查看可用选项。", + "domainPickerDescriptionSaas": "输入完整域名、子域或名称以查看可用选项。", + "domainPickerTabAll": "所有", + "domainPickerTabOrganization": "组织", + "domainPickerTabProvided": "提供的", + "domainPickerSortAsc": "A-Z", + "domainPickerSortDesc": "Z-A", + "domainPickerCheckingAvailability": "检查可用性...", + "domainPickerNoMatchingDomains": "未找到匹配的域名。尝试不同的域名或检查您组织的域名设置。", + "domainPickerOrganizationDomains": "组织域", + "domainPickerProvidedDomains": "提供的域", + "domainPickerSubdomain": "子域:{subdomain}", + "domainPickerNamespace": "命名空间:{namespace}", + "domainPickerShowMore": "显示更多", + "domainNotFound": "域未找到", + "domainNotFoundDescription": "此资源已禁用,因为该域不再在我们的系统中存在。请为此资源设置一个新域。", + "failed": "失败", + "createNewOrgDescription": "创建一个新组织", + "organization": "组织", + "port": "端口", + "securityKeyManage": "管理安全密钥", + "securityKeyDescription": "添加或删除用于无密码认证的安全密钥", + "securityKeyRegister": "注册新的安全密钥", + "securityKeyList": "您的安全密钥", + "securityKeyNone": "尚未注册安全密钥", + "securityKeyNameRequired": "名称为必填项", + "securityKeyRemove": "删除", + "securityKeyLastUsed": "上次使用:{date}", + "securityKeyNameLabel": "名称", + "securityKeyRegisterSuccess": "安全密钥注册成功", + "securityKeyRegisterError": "注册安全密钥失败", + "securityKeyRemoveSuccess": "安全密钥删除成功", + "securityKeyRemoveError": "删除安全密钥失败", + "securityKeyLoadError": "加载安全密钥失败", + "securityKeyLogin": "使用安全密钥继续", + "securityKeyAuthError": "使用安全密钥认证失败", + "securityKeyRecommendation": "考虑在其他设备上注册另一个安全密钥,以确保不会被锁定在您的账户之外。", + "registering": "注册中...", + "securityKeyPrompt": "请使用您的安全密钥验证身份。确保您的安全密钥已连接并准备好。", + "securityKeyBrowserNotSupported": "您的浏览器不支持安全密钥。请使用像 Chrome、Firefox 或 Safari 这样的现代浏览器。", + "securityKeyPermissionDenied": "请允许访问您的安全密钥以继续登录。", + "securityKeyRemovedTooQuickly": "请保持您的安全密钥连接,直到登录过程完成。", + "securityKeyNotSupported": "您的安全密钥可能不兼容。请尝试不同的安全密钥。", + "securityKeyUnknownError": "使用安全密钥时出现问题。请再试一次。", + "twoFactorRequired": "注册安全密钥需要两步验证。", + "twoFactor": "两步验证", + "adminEnabled2FaOnYourAccount": "管理员已为{email}启用两步验证。请完成设置以继续。", + "continueToApplication": "继续到应用程序", + "securityKeyAdd": "添加安全密钥", + "securityKeyRegisterTitle": "注册新安全密钥", + "securityKeyRegisterDescription": "连接您的安全密钥并输入名称以便识别", + "securityKeyTwoFactorRequired": "要求两步验证", + "securityKeyTwoFactorDescription": "请输入你的两步验证代码以注册安全密钥", + "securityKeyTwoFactorRemoveDescription": "请输入你的两步验证代码以移除安全密钥", + "securityKeyTwoFactorCode": "双因素代码", + "securityKeyRemoveTitle": "移除安全密钥", + "securityKeyRemoveDescription": "输入您的密码以移除安全密钥 \"{name}\"", + "securityKeyNoKeysRegistered": "没有注册安全密钥", + "securityKeyNoKeysDescription": "添加安全密钥以加强您的账户安全", + "createDomainRequired": "必须输入域", + "createDomainAddDnsRecords": "添加 DNS 记录", + "createDomainAddDnsRecordsDescription": "将以下 DNS 记录添加到您的域名提供商以完成设置。", + "createDomainNsRecords": "NS 记录", + "createDomainRecord": "记录", + "createDomainType": "类型:", + "createDomainName": "名称:", + "createDomainValue": "值:", + "createDomainCnameRecords": "CNAME 记录", + "createDomainARecords": "A记录", + "createDomainRecordNumber": "记录 {number}", + "createDomainTxtRecords": "TXT 记录", + "createDomainSaveTheseRecords": "保存这些记录", + "createDomainSaveTheseRecordsDescription": "务必保存这些 DNS 记录,因为您将无法再次查看它们。", + "createDomainDnsPropagation": "DNS 传播", + "createDomainDnsPropagationDescription": "DNS 更改可能需要一些时间才能在互联网上传播。这可能需要从几分钟到 48 小时,具体取决于您的 DNS 提供商和 TTL 设置。", + "resourcePortRequired": "非 HTTP 资源必须输入端口号", + "resourcePortNotAllowed": "HTTP 资源不应设置端口号", + "signUpTerms": { + "IAgreeToThe": "我同意", + "termsOfService": "服务条款", + "and": "和", + "privacyPolicy": "隐私政策" + }, + "siteRequired": "需要站点。", + "olmTunnel": "Olm 隧道", + "olmTunnelDescription": "使用 Olm 进行客户端连接", + "errorCreatingClient": "创建客户端出错", + "clientDefaultsNotFound": "未找到客户端默认值", + "createClient": "创建客户端", + "createClientDescription": "创建一个新客户端来连接您的站点", + "seeAllClients": "查看所有客户端", + "clientInformation": "客户端信息", + "clientNamePlaceholder": "客户端名称", + "address": "地址", + "subnetPlaceholder": "子网", + "addressDescription": "此客户端将用于连接的地址", + "selectSites": "选择站点", + "sitesDescription": "客户端将与所选站点进行连接", + "clientInstallOlm": "安装 Olm", + "clientInstallOlmDescription": "在您的系统上运行 Olm", + "clientOlmCredentials": "Olm 凭据", + "clientOlmCredentialsDescription": "这是 Olm 服务器的身份验证方式", + "olmEndpoint": "Olm 端点", + "olmId": "Olm ID", + "olmSecretKey": "Olm 私钥", + "clientCredentialsSave": "保存您的凭据", + "clientCredentialsSaveDescription": "该信息仅会显示一次,请确保将其复制到安全位置。", + "generalSettingsDescription": "配置此客户端的常规设置", + "clientUpdated": "客户端已更新", + "clientUpdatedDescription": "客户端已更新。", + "clientUpdateFailed": "更新客户端失败", + "clientUpdateError": "更新客户端时出错。", + "sitesFetchFailed": "获取站点失败", + "sitesFetchError": "获取站点时出错。", + "olmErrorFetchReleases": "获取 Olm 发布版本时出错。", + "olmErrorFetchLatest": "获取最新 Olm 发布版本时出错。", + "remoteSubnets": "远程子网", + "enterCidrRange": "输入 CIDR 范围", + "remoteSubnetsDescription": "添加能远程访问此站点的 CIDR 范围。使用格式如 10.0.0.0/24 或 192.168.1.0/24。", + "resourceEnableProxy": "启用公共代理", + "resourceEnableProxyDescription": "启用到此资源的公共代理。这允许外部网络通过开放端口访问资源。需要 Traefik 配置。", + "externalProxyEnabled": "外部代理已启用" } diff --git a/package-lock.json b/package-lock.json index c8d7a1c9..baec0b2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,16 +31,19 @@ "@radix-ui/react-switch": "1.2.5", "@radix-ui/react-tabs": "1.1.12", "@radix-ui/react-toast": "1.2.14", - "@react-email/components": "0.1.0", + "@radix-ui/react-tooltip": "^1.2.7", + "@react-email/components": "0.3.1", "@react-email/render": "^1.1.2", - "@react-email/tailwind": "1.0.5", + "@react-email/tailwind": "1.2.1", + "@simplewebauthn/browser": "^13.1.0", + "@simplewebauthn/server": "^9.0.3", "@tailwindcss/forms": "^0.5.10", "@tanstack/react-table": "8.21.3", "arctic": "^3.7.0", "axios": "1.10.0", "better-sqlite3": "11.7.0", "canvas-confetti": "1.9.3", - "class-variance-authority": "0.7.1", + "class-variance-authority": "^0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", "cookie": "^1.0.2", @@ -49,8 +52,8 @@ "cors": "2.8.5", "crypto-js": "^4.2.0", "drizzle-orm": "0.44.2", - "eslint": "9.29.0", - "eslint-config-next": "15.3.4", + "eslint": "9.31.0", + "eslint-config-next": "15.3.5", "express": "4.21.2", "express-rate-limit": "7.5.1", "glob": "11.0.3", @@ -58,42 +61,44 @@ "http-errors": "2.0.0", "i": "^0.3.7", "input-otp": "1.4.2", + "ioredis": "^5.6.1", "jmespath": "^0.16.0", "js-yaml": "4.1.0", "jsonwebtoken": "^9.0.2", - "lucide-react": "0.522.0", + "lucide-react": "0.525.0", "moment": "2.30.1", - "next": "15.3.4", - "next-intl": "^4.1.0", + "next": "15.3.5", + "next-intl": "^4.3.4", "next-themes": "0.4.6", "node-cache": "5.1.2", "node-fetch": "3.3.2", - "nodemailer": "7.0.3", + "nodemailer": "7.0.5", "npm": "^11.4.2", "oslo": "1.2.1", "pg": "^8.16.2", "qrcode.react": "4.2.0", + "rate-limit-redis": "^4.2.1", "react": "19.1.0", "react-dom": "19.1.0", "react-easy-sort": "^1.6.0", - "react-hook-form": "7.58.1", + "react-hook-form": "7.60.0", "react-icons": "^5.5.0", "rebuild": "0.1.2", - "semver": "7.7.2", + "semver": "^7.7.2", "swagger-ui-express": "^5.0.1", "tailwind-merge": "3.3.1", - "tw-animate-css": "^1.3.3", + "tw-animate-css": "^1.3.5", "uuid": "^11.1.0", "vaul": "1.1.2", "winston": "3.17.0", "winston-daily-rotate-file": "5.0.0", - "ws": "8.18.2", + "ws": "8.18.3", "yargs": "18.0.0", - "zod": "3.25.67", + "zod": "3.25.76", "zod-validation-error": "3.5.2" }, "devDependencies": { - "@dotenvx/dotenvx": "1.45.1", + "@dotenvx/dotenvx": "1.47.6", "@esbuild-plugins/tsconfig-paths": "0.1.2", "@tailwindcss/postcss": "^4.1.10", "@types/better-sqlite3": "7.6.12", @@ -101,28 +106,29 @@ "@types/cors": "2.8.19", "@types/crypto-js": "^4.2.2", "@types/express": "5.0.0", + "@types/express-session": "^1.18.2", "@types/jmespath": "^0.15.2", "@types/js-yaml": "4.0.9", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24", "@types/nodemailer": "6.4.17", + "@types/pg": "8.15.4", "@types/react": "19.1.8", "@types/react-dom": "19.1.6", - "@types/semver": "7.7.0", + "@types/semver": "^7.7.0", "@types/swagger-ui-express": "^4.1.8", "@types/ws": "8.18.1", "@types/yargs": "17.0.33", - "drizzle-kit": "0.31.2", - "esbuild": "0.25.5", + "drizzle-kit": "0.31.4", + "esbuild": "0.25.6", "esbuild-node-externals": "1.18.0", "postcss": "^8", - "react-email": "4.0.16", + "react-email": "4.1.0", "tailwindcss": "^4.1.4", "tsc-alias": "1.8.16", "tsx": "4.20.3", "typescript": "^5", - "typescript-eslint": "^8.35.0", - "yargs": "18.0.0" + "typescript-eslint": "^8.36.0" } }, "node_modules/@alloc/quick-lru": { @@ -180,22 +186,32 @@ } }, "node_modules/@babel/generator": { - "version": "7.27.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz", - "integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.27.5", - "@babel/types": "^7.27.3", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -217,13 +233,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.7.tgz", - "integrity": "sha512-qnzXzDXdr/po3bOTbTIQZ7+TxNKxpkN5IifVLXS+r7qwynkZfPyjZfE7hCXbo7IoO9TNcSyibgONsf2HauUd3Q==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.27.7" + "@babel/types": "^7.28.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -248,38 +264,28 @@ } }, "node_modules/@babel/traverse": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.7.tgz", - "integrity": "sha512-X6ZlfR/O/s5EQ/SnUSLzr+6kGnkg8HXGMzpgsMsrJVcfDtH1vIp6ctCN4eZ1LS5c0+te5Cb6Y514fASjMRJ1nw==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.5", - "@babel/parser": "^7.27.7", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", - "@babel/types": "^7.27.7", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/types": "^7.28.0", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/types": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.7.tgz", - "integrity": "sha512-8OLQgDScAOHXnAz2cV+RfzzNMipuLVBz2biuAJFMV9bfkNf393je3VM8CLkjQodW5+iWsSJdSgSWT6rsZoXHPw==", + "version": "7.28.1", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz", + "integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==", "dev": true, "license": "MIT", "dependencies": { @@ -311,9 +317,9 @@ } }, "node_modules/@dotenvx/dotenvx": { - "version": "1.45.1", - "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.45.1.tgz", - "integrity": "sha512-wKHPD+/NMMJVBPg3i98uD9jsURDy+Ck6RQRiWf39TlOAzC+Ge1FkmDk3sgeljYZxA3qF6E7SJmvRqC70XQuuVA==", + "version": "1.47.6", + "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.47.6.tgz", + "integrity": "sha512-bvVMFc3Z9/mtYUWP1S1UB4SA3U2mQ1p7Qc9QW6Cm7t1Vm6D+dysmus/Mt26Dc1QrE6OgrKUGC99EQcMvcFZC3Q==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -328,8 +334,7 @@ "which": "^4.0.0" }, "bin": { - "dotenvx": "src/cli/dotenvx.js", - "git-dotenvx": "src/cli/dotenvx.js" + "dotenvx": "src/cli/dotenvx.js" }, "funding": { "url": "https://dotenvx.com" @@ -343,9 +348,9 @@ "license": "Apache-2.0" }, "node_modules/@ecies/ciphers": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.3.tgz", - "integrity": "sha512-tapn6XhOueMwht3E2UzY0ZZjYokdaw9XtL9kEyjhQ/Fb9vL9xTFbOaI+fV0AWvTpYu4BNloC6getKW6NtSg4mA==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.4.tgz", + "integrity": "sha512-t+iX+Wf5nRKyNzk8dviW3Ikb/280+aEJAnw9YXvCp2tYGPSkMki+NRY+8aNLmVFv3eNtMdvViPNOPxS8SZNP+w==", "dev": true, "license": "MIT", "engines": { @@ -358,20 +363,20 @@ } }, "node_modules/@emnapi/core": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", - "integrity": "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==", + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.4.tgz", + "integrity": "sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.0.2", + "@emnapi/wasi-threads": "1.0.3", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.4.tgz", + "integrity": "sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==", "license": "MIT", "optional": true, "dependencies": { @@ -379,9 +384,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz", - "integrity": "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.3.tgz", + "integrity": "sha512-8K5IFFsQqF9wQNJptGbS6FNKgUTsSRYnTqNCG1vPP8jFdjSv18n2mQfJpkt2Oibo9iBEzcDnDxNwKTzC7svlJw==", "license": "MIT", "optional": true, "dependencies": { @@ -841,9 +846,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", - "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz", + "integrity": "sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw==", "cpu": [ "ppc64" ], @@ -858,9 +863,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", - "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.6.tgz", + "integrity": "sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg==", "cpu": [ "arm" ], @@ -875,9 +880,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", - "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.6.tgz", + "integrity": "sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA==", "cpu": [ "arm64" ], @@ -892,9 +897,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", - "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.6.tgz", + "integrity": "sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A==", "cpu": [ "x64" ], @@ -909,9 +914,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", - "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.6.tgz", + "integrity": "sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA==", "cpu": [ "arm64" ], @@ -926,9 +931,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", - "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.6.tgz", + "integrity": "sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg==", "cpu": [ "x64" ], @@ -943,9 +948,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", - "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.6.tgz", + "integrity": "sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg==", "cpu": [ "arm64" ], @@ -960,9 +965,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", - "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.6.tgz", + "integrity": "sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ==", "cpu": [ "x64" ], @@ -977,9 +982,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", - "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.6.tgz", + "integrity": "sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw==", "cpu": [ "arm" ], @@ -994,9 +999,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", - "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.6.tgz", + "integrity": "sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ==", "cpu": [ "arm64" ], @@ -1011,9 +1016,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", - "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.6.tgz", + "integrity": "sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw==", "cpu": [ "ia32" ], @@ -1028,9 +1033,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", - "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.6.tgz", + "integrity": "sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg==", "cpu": [ "loong64" ], @@ -1045,9 +1050,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", - "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.6.tgz", + "integrity": "sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw==", "cpu": [ "mips64el" ], @@ -1062,9 +1067,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", - "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.6.tgz", + "integrity": "sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw==", "cpu": [ "ppc64" ], @@ -1079,9 +1084,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", - "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.6.tgz", + "integrity": "sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w==", "cpu": [ "riscv64" ], @@ -1096,9 +1101,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", - "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.6.tgz", + "integrity": "sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw==", "cpu": [ "s390x" ], @@ -1113,9 +1118,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", - "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.6.tgz", + "integrity": "sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig==", "cpu": [ "x64" ], @@ -1130,9 +1135,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", - "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.6.tgz", + "integrity": "sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q==", "cpu": [ "arm64" ], @@ -1147,9 +1152,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", - "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.6.tgz", + "integrity": "sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g==", "cpu": [ "x64" ], @@ -1164,9 +1169,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", - "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.6.tgz", + "integrity": "sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg==", "cpu": [ "arm64" ], @@ -1181,9 +1186,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", - "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.6.tgz", + "integrity": "sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw==", "cpu": [ "x64" ], @@ -1197,10 +1202,27 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.6.tgz", + "integrity": "sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", - "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.6.tgz", + "integrity": "sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA==", "cpu": [ "x64" ], @@ -1215,9 +1237,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", - "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.6.tgz", + "integrity": "sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q==", "cpu": [ "arm64" ], @@ -1232,9 +1254,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", - "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.6.tgz", + "integrity": "sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ==", "cpu": [ "ia32" ], @@ -1249,9 +1271,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", - "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.6.tgz", + "integrity": "sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA==", "cpu": [ "x64" ], @@ -1305,9 +1327,9 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz", - "integrity": "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.6", @@ -1319,18 +1341,18 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz", - "integrity": "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", - "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" @@ -1363,9 +1385,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.29.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", - "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", + "version": "9.31.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz", + "integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1396,18 +1418,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", - "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@floating-ui/core": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz", @@ -1506,6 +1516,12 @@ "tslib": "2" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@hookform/resolvers": { "version": "3.9.1", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.1.tgz", @@ -1577,9 +1593,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.2.tgz", - "integrity": "sha512-OfXHZPppddivUJnqyKoi5YVeHRkkNE2zUFT2gbpKxp/JZCFYEYubnMg+gOp6lWfasPrTS+KPosKqdI+ELYVDtg==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz", + "integrity": "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==", "cpu": [ "arm64" ], @@ -1595,13 +1611,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.1.0" + "@img/sharp-libvips-darwin-arm64": "1.2.0" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.2.tgz", - "integrity": "sha512-dYvWqmjU9VxqXmjEtjmvHnGqF8GrVjM2Epj9rJ6BUIXvk8slvNDJbhGFvIoXzkDhrJC2jUxNLz/GUjjvSzfw+g==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz", + "integrity": "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==", "cpu": [ "x64" ], @@ -1617,13 +1633,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.1.0" + "@img/sharp-libvips-darwin-x64": "1.2.0" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz", - "integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==", "cpu": [ "arm64" ], @@ -1637,9 +1653,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.1.0.tgz", - "integrity": "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz", + "integrity": "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==", "cpu": [ "x64" ], @@ -1653,9 +1669,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.1.0.tgz", - "integrity": "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.0.tgz", + "integrity": "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==", "cpu": [ "arm" ], @@ -1669,9 +1685,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.1.0.tgz", - "integrity": "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz", + "integrity": "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==", "cpu": [ "arm64" ], @@ -1685,9 +1701,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz", - "integrity": "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz", + "integrity": "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==", "cpu": [ "ppc64" ], @@ -1701,9 +1717,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.1.0.tgz", - "integrity": "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.0.tgz", + "integrity": "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==", "cpu": [ "s390x" ], @@ -1717,9 +1733,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.1.0.tgz", - "integrity": "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz", + "integrity": "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==", "cpu": [ "x64" ], @@ -1733,9 +1749,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.1.0.tgz", - "integrity": "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", + "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==", "cpu": [ "arm64" ], @@ -1749,9 +1765,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.1.0.tgz", - "integrity": "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz", + "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==", "cpu": [ "x64" ], @@ -1765,9 +1781,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.2.tgz", - "integrity": "sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz", + "integrity": "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==", "cpu": [ "arm" ], @@ -1783,13 +1799,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.1.0" + "@img/sharp-libvips-linux-arm": "1.2.0" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.2.tgz", - "integrity": "sha512-D8n8wgWmPDakc83LORcfJepdOSN6MvWNzzz2ux0MnIbOqdieRZwVYY32zxVx+IFUT8er5KPcyU3XXsn+GzG/0Q==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.3.tgz", + "integrity": "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==", "cpu": [ "arm64" ], @@ -1805,13 +1821,35 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.1.0" + "@img/sharp-libvips-linux-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz", + "integrity": "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.0" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.2.tgz", - "integrity": "sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.3.tgz", + "integrity": "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==", "cpu": [ "s390x" ], @@ -1827,13 +1865,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.1.0" + "@img/sharp-libvips-linux-s390x": "1.2.0" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.2.tgz", - "integrity": "sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz", + "integrity": "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==", "cpu": [ "x64" ], @@ -1849,13 +1887,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.1.0" + "@img/sharp-libvips-linux-x64": "1.2.0" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.2.tgz", - "integrity": "sha512-NEE2vQ6wcxYav1/A22OOxoSOGiKnNmDzCYFOZ949xFmrWZOVII1Bp3NqVVpvj+3UeHMFyN5eP/V5hzViQ5CZNA==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", + "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==", "cpu": [ "arm64" ], @@ -1871,13 +1909,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.1.0" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.2.tgz", - "integrity": "sha512-DOYMrDm5E6/8bm/yQLCWyuDJwUnlevR8xtF8bs+gjZ7cyUNYXiSf/E8Kp0Ss5xasIaXSHzb888V1BE4i1hFhAA==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz", + "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==", "cpu": [ "x64" ], @@ -1893,20 +1931,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.1.0" + "@img/sharp-libvips-linuxmusl-x64": "1.2.0" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.2.tgz", - "integrity": "sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.3.tgz", + "integrity": "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==", "cpu": [ "wasm32" ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.4.3" + "@emnapi/runtime": "^1.4.4" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -1916,9 +1954,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.2.tgz", - "integrity": "sha512-cfP/r9FdS63VA5k0xiqaNaEoGxBg9k7uE+RQGzuK9fHt7jib4zAVVseR9LsE4gJcNWgT6APKMNnCcnyOtmSEUQ==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz", + "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==", "cpu": [ "arm64" ], @@ -1935,9 +1973,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.2.tgz", - "integrity": "sha512-QLjGGvAbj0X/FXl8n1WbtQ6iVBpWU7JO94u/P2M4a8CFYsvQi4GW2mRy/JqkRx0qpBzaOdKJKw8uc930EX2AHw==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.3.tgz", + "integrity": "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==", "cpu": [ "ia32" ], @@ -1954,9 +1992,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.2.tgz", - "integrity": "sha512-aUdT6zEYtDKCaxkofmmJDJYGCf0+pJg3eU9/oBuqvEeoB9dKI6ZLc/1iLJCTuJQDO4ptntAlkUmHgGjyuobZbw==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz", + "integrity": "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==", "cpu": [ "x64" ], @@ -1972,6 +2010,12 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", + "license": "MIT" + }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -2023,9 +2067,9 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.11.tgz", - "integrity": "sha512-C512c1ytBTio4MrpWKlJpyFHT6+qfFL8SZ58zBzJ1OOzUEjHeF1BtjY2fH7n4x/g2OV/KiiMLAivOp1DXmiMMw==", + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", "dev": true, "license": "MIT", "dependencies": { @@ -2044,16 +2088,16 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.3.tgz", - "integrity": "sha512-AiR5uKpFxP3PjO4R19kQGIMwxyRyPuXmKEEy301V1C0+1rVjS94EZQXf1QKZYN8Q0YM+estSPhmx5JwNftv6nw==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.28", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.28.tgz", - "integrity": "sha512-KNNHHwW3EIp4EDYOvYFGyIFfx36R2dNJYH4knnZlF8T5jdbD5Wx8xmSaQ2gP9URkJ04LGEtlcCtwArKcmFcwKw==", + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2061,37 +2105,43 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { + "node_modules/@levischuck/tiny-cbor": { "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz", - "integrity": "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", "license": "MIT", "optional": true, "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.9.0" + "@tybys/wasm-util": "^0.10.0" } }, "node_modules/@next/env": { - "version": "15.3.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.4.tgz", - "integrity": "sha512-ZkdYzBseS6UjYzz6ylVKPOK+//zLWvD6Ta+vpoye8cW11AjiQjGYVibF0xuvT4L0iJfAPfZLFidaEzAOywyOAQ==", + "version": "15.3.5", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.5.tgz", + "integrity": "sha512-7g06v8BUVtN2njAX/r8gheoVffhiKFVt4nx74Tt6G4Hqw9HCLYQVx/GkH2qHvPtAHZaUNZ0VXAa0pQP6v1wk7g==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "15.3.4", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.3.4.tgz", - "integrity": "sha512-lBxYdj7TI8phbJcLSAqDt57nIcobEign5NYIKCiy0hXQhrUbTqLqOaSDi568U6vFg4hJfBdZYsG4iP/uKhCqgg==", + "version": "15.3.5", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.3.5.tgz", + "integrity": "sha512-BZwWPGfp9po/rAnJcwUBaM+yT/+yTWIkWdyDwc74G9jcfTrNrmsHe+hXHljV066YNdVs8cxROxX5IgMQGX190w==", "license": "MIT", "dependencies": { "fast-glob": "3.3.1" } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.4.tgz", - "integrity": "sha512-z0qIYTONmPRbwHWvpyrFXJd5F9YWLCsw3Sjrzj2ZvMYy9NPQMPZ1NjOJh4ojr4oQzcGYwgJKfidzehaNa1BpEg==", + "version": "15.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.5.tgz", + "integrity": "sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w==", "cpu": [ "arm64" ], @@ -2105,9 +2155,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.4.tgz", - "integrity": "sha512-Z0FYJM8lritw5Wq+vpHYuCIzIlEMjewG2aRkc3Hi2rcbULknYL/xqfpBL23jQnCSrDUGAo/AEv0Z+s2bff9Zkw==", + "version": "15.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.5.tgz", + "integrity": "sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA==", "cpu": [ "x64" ], @@ -2121,9 +2171,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.4.tgz", - "integrity": "sha512-l8ZQOCCg7adwmsnFm8m5q9eIPAHdaB2F3cxhufYtVo84pymwKuWfpYTKcUiFcutJdp9xGHC+F1Uq3xnFU1B/7g==", + "version": "15.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.5.tgz", + "integrity": "sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ==", "cpu": [ "arm64" ], @@ -2137,9 +2187,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.4.tgz", - "integrity": "sha512-wFyZ7X470YJQtpKot4xCY3gpdn8lE9nTlldG07/kJYexCUpX1piX+MBfZdvulo+t1yADFVEuzFfVHfklfEx8kw==", + "version": "15.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.5.tgz", + "integrity": "sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A==", "cpu": [ "arm64" ], @@ -2153,9 +2203,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.4.tgz", - "integrity": "sha512-gEbH9rv9o7I12qPyvZNVTyP/PWKqOp8clvnoYZQiX800KkqsaJZuOXkWgMa7ANCCh/oEN2ZQheh3yH8/kWPSEg==", + "version": "15.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.5.tgz", + "integrity": "sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A==", "cpu": [ "x64" ], @@ -2169,9 +2219,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.4.tgz", - "integrity": "sha512-Cf8sr0ufuC/nu/yQ76AnarbSAXcwG/wj+1xFPNbyNo8ltA6kw5d5YqO8kQuwVIxk13SBdtgXrNyom3ZosHAy4A==", + "version": "15.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.5.tgz", + "integrity": "sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg==", "cpu": [ "x64" ], @@ -2185,9 +2235,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.4.tgz", - "integrity": "sha512-ay5+qADDN3rwRbRpEhTOreOn1OyJIXS60tg9WMYTWCy3fB6rGoyjLVxc4dR9PYjEdR2iDYsaF5h03NA+XuYPQQ==", + "version": "15.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.5.tgz", + "integrity": "sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ==", "cpu": [ "arm64" ], @@ -2201,9 +2251,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.4.tgz", - "integrity": "sha512-4kDt31Bc9DGyYs41FTL1/kNpDeHyha2TC0j5sRRoKCyrhNcfZ/nRQkAUlF27mETwm8QyHqIjHJitfcza2Iykfg==", + "version": "15.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.5.tgz", + "integrity": "sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw==", "cpu": [ "x64" ], @@ -2883,6 +2933,64 @@ "integrity": "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==", "license": "MIT" }, + "node_modules/@peculiar/asn1-android": { + "version": "2.3.16", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.3.16.tgz", + "integrity": "sha512-a1viIv3bIahXNssrOIkXZIlI2ePpZaNmR30d4aBL99mu2rO+mT9D6zBsp7H6eROWGtmwv0Ionp5olJurIo09dw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.15", + "asn1js": "^3.0.5", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.15.tgz", + "integrity": "sha512-/HtR91dvgog7z/WhCVdxZJ/jitJuIu8iTqiyWVgRE9Ac5imt2sT/E4obqIVGKQw7PIy+X6i8lVBoT6wC73XUgA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.15", + "@peculiar/asn1-x509": "^2.3.15", + "asn1js": "^3.0.5", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.15.tgz", + "integrity": "sha512-p6hsanvPhexRtYSOHihLvUUgrJ8y0FtOM97N5UEpC+VifFYyZa0iZ5cXjTkZoDwxJ/TTJ1IJo3HVTB2JJTpXvg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.15", + "@peculiar/asn1-x509": "^2.3.15", + "asn1js": "^3.0.5", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.15.tgz", + "integrity": "sha512-QPeD8UA8axQREpgR5UTAfu2mqQmm97oUqahDtNdBcfj3qAnoXzFdQW+aNf/tD2WVXF8Fhmftxoj0eMIT++gX2w==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.15.tgz", + "integrity": "sha512-0dK5xqTqSLaxv1FHXIcd4Q/BZNuopg+u1l23hT9rOmQ1g4dNtw0g/RnEi+TboB0gOwGtrWn269v27cMgchFIIg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.15", + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -3733,6 +3841,40 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz", + "integrity": "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -3926,9 +4068,9 @@ } }, "node_modules/@react-email/button": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.1.0.tgz", - "integrity": "sha512-fg4LtgTu5zXxaRSly9cuv6sHVF/hi1lElbRaIA8EPx5coWOBhCto6rCPfawcXpaN2oER7rNHUrcNBkI+lz5F9A==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.2.0.tgz", + "integrity": "sha512-8i+v6cMxr2emz4ihCrRiYJPp2/sdYsNNsBzXStlcA+/B9Umpm5Jj3WJKYpgTPM+aeyiqlG/MMI1AucnBm4f1oQ==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -3977,13 +4119,13 @@ } }, "node_modules/@react-email/components": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.1.0.tgz", - "integrity": "sha512-Rx0eZk0XuzLKXC5NoMm8xuH72ALVsPYNb/BvcdCJx4EZAoVpQISb4sCqpo9blVYVIazNr4MqWroqFb3ZNrCLMQ==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.3.1.tgz", + "integrity": "sha512-FqcyGaUpJJu8zfYGSS+qaSy7Zc2BFAswBc/LvHeSV4iTQMZMD8Dy7aS/NvP1SQMg5vjsO1aMpGFdrD4NBY58dw==", "license": "MIT", "dependencies": { "@react-email/body": "0.0.11", - "@react-email/button": "0.1.0", + "@react-email/button": "0.2.0", "@react-email/code-block": "0.1.0", "@react-email/code-inline": "0.0.5", "@react-email/column": "0.0.13", @@ -3997,10 +4139,10 @@ "@react-email/link": "0.0.12", "@react-email/markdown": "0.0.15", "@react-email/preview": "0.0.13", - "@react-email/render": "1.1.2", + "@react-email/render": "1.1.3", "@react-email/row": "0.0.12", "@react-email/section": "0.0.16", - "@react-email/tailwind": "1.0.5", + "@react-email/tailwind": "1.2.1", "@react-email/text": "0.1.5" }, "engines": { @@ -4010,24 +4152,6 @@ "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, - "node_modules/@react-email/components/node_modules/@react-email/render": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz", - "integrity": "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw==", - "license": "MIT", - "dependencies": { - "html-to-text": "^9.0.5", - "prettier": "^3.5.3", - "react-promise-suspense": "^0.3.4" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "react": "^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" - } - }, "node_modules/@react-email/container": { "version": "0.0.15", "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.15.tgz", @@ -4191,9 +4315,9 @@ } }, "node_modules/@react-email/tailwind": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-1.0.5.tgz", - "integrity": "sha512-BH00cZSeFfP9HiDASl+sPHi7Hh77W5nzDgdnxtsVr/m3uQD9g180UwxcE3PhOfx0vRdLzQUU8PtmvvDfbztKQg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-1.2.1.tgz", + "integrity": "sha512-SmVyDuNQLJwO3wHEe/snSTaRhf/Exldy5DQU/RyPjcSPC0EuXXYwFlBr16br8jJSxkZA/fL91AxKL7HbbWp0Rw==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -4252,6 +4376,38 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/@simplewebauthn/browser": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.1.2.tgz", + "integrity": "sha512-aZnW0KawAM83fSBUgglP5WofbrLbLyr7CoPqYr66Eppm7zO86YX6rrCjRB3hQKPrL7ATvY4FVXlykZ6w6FwYYw==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/server": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-9.0.3.tgz", + "integrity": "sha512-FMZieoBosrVLFxCnxPFD9Enhd1U7D8nidVDT4MsHc6l4fdVcjoeHjDueeXCloO1k5O/fZg1fsSXXPKbY2XTzDA==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.3.10", + "@peculiar/asn1-ecc": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@simplewebauthn/types": "^9.0.1", + "cross-fetch": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@simplewebauthn/types": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-9.0.1.tgz", + "integrity": "sha512-tGSRP1QvsAvsJmnOlRQyw/mvK9gnPtjEc5fg2+m8n+QUa+D7rvrKkOYyfpy42GTs90X3RDOnqJgfHt+qO67/+w==", + "license": "MIT" + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -4596,9 +4752,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", - "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", + "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", "license": "MIT", "optional": true, "dependencies": { @@ -4683,9 +4839,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", - "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", + "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4695,6 +4851,16 @@ "@types/send": "*" } }, + "node_modules/@types/express-session": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.2.tgz", + "integrity": "sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -4754,9 +4920,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.0.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.7.tgz", - "integrity": "sha512-YIEUUr4yf8q8oQoXPpSlnvKNVKDQlPMWrmOcgzoduo7kvA2UF0/BwJ/eMKFTiTtkNL17I0M6Xe2tvwFU7be6iw==", + "version": "24.0.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.14.tgz", + "integrity": "sha512-4zXMWD91vBLGRtHK3YbIoFMia+1nqEz72coM42C5ETjnNCa/heoj7NT1G67iAfOqMmcfhuCZ4uNpyz8EjlAejw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -4773,6 +4939,18 @@ "@types/node": "*" } }, + "node_modules/@types/pg": { + "version": "8.15.4", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.4.tgz", + "integrity": "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -4882,16 +5060,16 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz", - "integrity": "sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.37.0.tgz", + "integrity": "sha512-jsuVWeIkb6ggzB+wPCsR4e6loj+rM72ohW6IBn2C+5NCvfUVY8s33iFPySSVXqtm5Hu29Ne/9bnA0JmyLmgenA==", "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.35.1", - "@typescript-eslint/type-utils": "8.35.1", - "@typescript-eslint/utils": "8.35.1", - "@typescript-eslint/visitor-keys": "8.35.1", + "@typescript-eslint/scope-manager": "8.37.0", + "@typescript-eslint/type-utils": "8.37.0", + "@typescript-eslint/utils": "8.37.0", + "@typescript-eslint/visitor-keys": "8.37.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -4905,7 +5083,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.35.1", + "@typescript-eslint/parser": "^8.37.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } @@ -4920,15 +5098,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.1.tgz", - "integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.37.0.tgz", + "integrity": "sha512-kVIaQE9vrN9RLCQMQ3iyRlVJpTiDUY6woHGb30JDkfJErqrQEmtdWH3gV0PBAfGZgQXoqzXOO0T3K6ioApbbAA==", "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.35.1", - "@typescript-eslint/types": "8.35.1", - "@typescript-eslint/typescript-estree": "8.35.1", - "@typescript-eslint/visitor-keys": "8.35.1", + "@typescript-eslint/scope-manager": "8.37.0", + "@typescript-eslint/types": "8.37.0", + "@typescript-eslint/typescript-estree": "8.37.0", + "@typescript-eslint/visitor-keys": "8.37.0", "debug": "^4.3.4" }, "engines": { @@ -4944,13 +5122,13 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.1.tgz", - "integrity": "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.37.0.tgz", + "integrity": "sha512-BIUXYsbkl5A1aJDdYJCBAo8rCEbAvdquQ8AnLb6z5Lp1u3x5PNgSSx9A/zqYc++Xnr/0DVpls8iQ2cJs/izTXA==", "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.35.1", - "@typescript-eslint/types": "^8.35.1", + "@typescript-eslint/tsconfig-utils": "^8.37.0", + "@typescript-eslint/types": "^8.37.0", "debug": "^4.3.4" }, "engines": { @@ -4965,13 +5143,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.1.tgz", - "integrity": "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.37.0.tgz", + "integrity": "sha512-0vGq0yiU1gbjKob2q691ybTg9JX6ShiVXAAfm2jGf3q0hdP6/BruaFjL/ManAR/lj05AvYCH+5bbVo0VtzmjOA==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.35.1", - "@typescript-eslint/visitor-keys": "8.35.1" + "@typescript-eslint/types": "8.37.0", + "@typescript-eslint/visitor-keys": "8.37.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4982,9 +5160,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.1.tgz", - "integrity": "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.37.0.tgz", + "integrity": "sha512-1/YHvAVTimMM9mmlPvTec9NP4bobA1RkDbMydxG8omqwJJLEW/Iy2C4adsAESIXU3WGLXFHSZUU+C9EoFWl4Zg==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4998,13 +5176,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.1.tgz", - "integrity": "sha512-HOrUBlfVRz5W2LIKpXzZoy6VTZzMu2n8q9C2V/cFngIC5U1nStJgv0tMV4sZPzdf4wQm9/ToWUFPMN9Vq9VJQQ==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.37.0.tgz", + "integrity": "sha512-SPkXWIkVZxhgwSwVq9rqj/4VFo7MnWwVaRNznfQDc/xPYHjXnPfLWn+4L6FF1cAz6e7dsqBeMawgl7QjUMj4Ow==", "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.35.1", - "@typescript-eslint/utils": "8.35.1", + "@typescript-eslint/types": "8.37.0", + "@typescript-eslint/typescript-estree": "8.37.0", + "@typescript-eslint/utils": "8.37.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -5021,9 +5200,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.1.tgz", - "integrity": "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.37.0.tgz", + "integrity": "sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5034,15 +5213,15 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.1.tgz", - "integrity": "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.37.0.tgz", + "integrity": "sha512-zuWDMDuzMRbQOM+bHyU4/slw27bAUEcKSKKs3hcv2aNnc/tvE/h7w60dwVw8vnal2Pub6RT1T7BI8tFZ1fE+yg==", "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.35.1", - "@typescript-eslint/tsconfig-utils": "8.35.1", - "@typescript-eslint/types": "8.35.1", - "@typescript-eslint/visitor-keys": "8.35.1", + "@typescript-eslint/project-service": "8.37.0", + "@typescript-eslint/tsconfig-utils": "8.37.0", + "@typescript-eslint/types": "8.37.0", + "@typescript-eslint/visitor-keys": "8.37.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -5114,15 +5293,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.1.tgz", - "integrity": "sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.37.0.tgz", + "integrity": "sha512-TSFvkIW6gGjN2p6zbXo20FzCABbyUAuq6tBvNRGsKdsSQ6a7rnV6ADfZ7f4iI3lIiXc4F4WWvtUfDw9CJ9pO5A==", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.35.1", - "@typescript-eslint/types": "8.35.1", - "@typescript-eslint/typescript-estree": "8.35.1" + "@typescript-eslint/scope-manager": "8.37.0", + "@typescript-eslint/types": "8.37.0", + "@typescript-eslint/typescript-estree": "8.37.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5137,12 +5316,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.1.tgz", - "integrity": "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.37.0.tgz", + "integrity": "sha512-YzfhzcTnZVPiLfP/oeKtDp2evwvHLMe0LOy7oe+hb9KKIumLNohYS9Hgp1ifwpu42YWxhZE8yieggz6JpqO/1w==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/types": "8.37.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -5154,9 +5333,9 @@ } }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.9.2.tgz", - "integrity": "sha512-tS+lqTU3N0kkthU+rYp0spAYq15DU8ld9kXkaKg9sbQqJNF+WPMuNHZQGCgdxrUOEO0j22RKMwRVhF1HTl+X8A==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", "cpu": [ "arm" ], @@ -5167,9 +5346,9 @@ ] }, "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.9.2.tgz", - "integrity": "sha512-MffGiZULa/KmkNjHeuuflLVqfhqLv1vZLm8lWIyeADvlElJ/GLSOkoUX+5jf4/EGtfwrNFcEaB8BRas03KT0/Q==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", "cpu": [ "arm64" ], @@ -5180,9 +5359,9 @@ ] }, "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.9.2.tgz", - "integrity": "sha512-dzJYK5rohS1sYl1DHdJ3mwfwClJj5BClQnQSyAgEfggbUwA9RlROQSSbKBLqrGfsiC/VyrDPtbO8hh56fnkbsQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", "cpu": [ "arm64" ], @@ -5193,9 +5372,9 @@ ] }, "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.9.2.tgz", - "integrity": "sha512-gaIMWK+CWtXcg9gUyznkdV54LzQ90S3X3dn8zlh+QR5Xy7Y+Efqw4Rs4im61K1juy4YNb67vmJsCDAGOnIeffQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", "cpu": [ "x64" ], @@ -5206,9 +5385,9 @@ ] }, "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.9.2.tgz", - "integrity": "sha512-S7QpkMbVoVJb0xwHFwujnwCAEDe/596xqY603rpi/ioTn9VDgBHnCCxh+UFrr5yxuMH+dliHfjwCZJXOPJGPnw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", "cpu": [ "x64" ], @@ -5219,9 +5398,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.9.2.tgz", - "integrity": "sha512-+XPUMCuCCI80I46nCDFbGum0ZODP5NWGiwS3Pj8fOgsG5/ctz+/zzuBlq/WmGa+EjWZdue6CF0aWWNv84sE1uw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", "cpu": [ "arm" ], @@ -5232,9 +5411,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.9.2.tgz", - "integrity": "sha512-sqvUyAd1JUpwbz33Ce2tuTLJKM+ucSsYpPGl2vuFwZnEIg0CmdxiZ01MHQ3j6ExuRqEDUCy8yvkDKvjYFPb8Zg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", "cpu": [ "arm" ], @@ -5245,9 +5424,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.9.2.tgz", - "integrity": "sha512-UYA0MA8ajkEDCFRQdng/FVx3F6szBvk3EPnkTTQuuO9lV1kPGuTB+V9TmbDxy5ikaEgyWKxa4CI3ySjklZ9lFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", "cpu": [ "arm64" ], @@ -5258,9 +5437,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.9.2.tgz", - "integrity": "sha512-P/CO3ODU9YJIHFqAkHbquKtFst0COxdphc8TKGL5yCX75GOiVpGqd1d15ahpqu8xXVsqP4MGFP2C3LRZnnL5MA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", "cpu": [ "arm64" ], @@ -5271,9 +5450,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.9.2.tgz", - "integrity": "sha512-uKStFlOELBxBum2s1hODPtgJhY4NxYJE9pAeyBgNEzHgTqTiVBPjfTlPFJkfxyTjQEuxZbbJlJnMCrRgD7ubzw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", "cpu": [ "ppc64" ], @@ -5284,9 +5463,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.9.2.tgz", - "integrity": "sha512-LkbNnZlhINfY9gK30AHs26IIVEZ9PEl9qOScYdmY2o81imJYI4IMnJiW0vJVtXaDHvBvxeAgEy5CflwJFIl3tQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", "cpu": [ "riscv64" ], @@ -5297,9 +5476,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.9.2.tgz", - "integrity": "sha512-vI+e6FzLyZHSLFNomPi+nT+qUWN4YSj8pFtQZSFTtmgFoxqB6NyjxSjAxEC1m93qn6hUXhIsh8WMp+fGgxCoRg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", "cpu": [ "riscv64" ], @@ -5310,9 +5489,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.9.2.tgz", - "integrity": "sha512-sSO4AlAYhSM2RAzBsRpahcJB1msc6uYLAtP6pesPbZtptF8OU/CbCPhSRW6cnYOGuVmEmWVW5xVboAqCnWTeHQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", "cpu": [ "s390x" ], @@ -5323,9 +5502,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.9.2.tgz", - "integrity": "sha512-jkSkwch0uPFva20Mdu8orbQjv2A3G88NExTN2oPTI1AJ+7mZfYW3cDCTyoH6OnctBKbBVeJCEqh0U02lTkqD5w==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", "cpu": [ "x64" ], @@ -5336,9 +5515,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.9.2.tgz", - "integrity": "sha512-Uk64NoiTpQbkpl+bXsbeyOPRpUoMdcUqa+hDC1KhMW7aN1lfW8PBlBH4mJ3n3Y47dYE8qi0XTxy1mBACruYBaw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", "cpu": [ "x64" ], @@ -5349,9 +5528,9 @@ ] }, "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.9.2.tgz", - "integrity": "sha512-EpBGwkcjDicjR/ybC0g8wO5adPNdVuMrNalVgYcWi+gYtC1XYNuxe3rufcO7dA76OHGeVabcO6cSkPJKVcbCXQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", "cpu": [ "wasm32" ], @@ -5365,9 +5544,9 @@ } }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.9.2.tgz", - "integrity": "sha512-EdFbGn7o1SxGmN6aZw9wAkehZJetFPao0VGZ9OMBwKx6TkvDuj6cNeLimF/Psi6ts9lMOe+Dt6z19fZQ9Ye2fw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", "cpu": [ "arm64" ], @@ -5378,9 +5557,9 @@ ] }, "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.9.2.tgz", - "integrity": "sha512-JY9hi1p7AG+5c/dMU8o2kWemM8I6VZxfGwn1GCtf3c5i+IKcMo2NQ8OjZ4Z3/itvY/Si3K10jOBQn7qsD/whUA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", "cpu": [ "ia32" ], @@ -5391,9 +5570,9 @@ ] }, "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.9.2.tgz", - "integrity": "sha512-ryoo+EB19lMxAd80ln9BVf8pdOAxLb97amrQ3SFN9OCRn/5M5wvwDgAe4i8ZjhpbiHoDeP8yavcTEnpKBo7lZg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", "cpu": [ "x64" ], @@ -5725,6 +5904,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1js": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz", + "integrity": "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -6051,9 +6244,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001726", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz", - "integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==", + "version": "1.0.30001727", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", "funding": [ { "type": "opencollective", @@ -6121,6 +6314,16 @@ "node": ">=18" } }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -6172,7 +6375,6 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^7.2.0", @@ -6187,7 +6389,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -6200,14 +6401,12 @@ "version": "10.4.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, "license": "MIT" }, "node_modules/cliui/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -6225,7 +6424,6 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -6257,6 +6455,15 @@ "node": ">=6" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/cmdk": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", @@ -6378,6 +6585,23 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, + "node_modules/confbox": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -6462,6 +6686,35 @@ "node": ">= 0.10" } }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/cross-fetch/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6607,9 +6860,9 @@ } }, "node_modules/decimal.js": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", - "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "license": "MIT" }, "node_modules/decompress-response": { @@ -6694,6 +6947,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6822,9 +7084,9 @@ } }, "node_modules/drizzle-kit": { - "version": "0.31.2", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.2.tgz", - "integrity": "sha512-Z2Uqxvu4HNFzlDkG3NQ2BYpII8SlOMkpjsC5XFh9TsYP2nYhfVamVjQ8spiMFXH3vGOyUt1cQ5FZ1JSgl6+8QQ==", + "version": "0.31.4", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.4.tgz", + "integrity": "sha512-tCPWVZWZqWVx2XUsVpJRnH9Mx0ClVOf5YUHerZ5so1OKSlqww4zy1R5ksEdGRcO3tM3zj0PYN6V48TbQCL1RfA==", "dev": true, "license": "MIT", "dependencies": { @@ -7322,9 +7584,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", - "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "version": "0.25.6", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz", + "integrity": "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -7335,31 +7597,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.5", - "@esbuild/android-arm": "0.25.5", - "@esbuild/android-arm64": "0.25.5", - "@esbuild/android-x64": "0.25.5", - "@esbuild/darwin-arm64": "0.25.5", - "@esbuild/darwin-x64": "0.25.5", - "@esbuild/freebsd-arm64": "0.25.5", - "@esbuild/freebsd-x64": "0.25.5", - "@esbuild/linux-arm": "0.25.5", - "@esbuild/linux-arm64": "0.25.5", - "@esbuild/linux-ia32": "0.25.5", - "@esbuild/linux-loong64": "0.25.5", - "@esbuild/linux-mips64el": "0.25.5", - "@esbuild/linux-ppc64": "0.25.5", - "@esbuild/linux-riscv64": "0.25.5", - "@esbuild/linux-s390x": "0.25.5", - "@esbuild/linux-x64": "0.25.5", - "@esbuild/netbsd-arm64": "0.25.5", - "@esbuild/netbsd-x64": "0.25.5", - "@esbuild/openbsd-arm64": "0.25.5", - "@esbuild/openbsd-x64": "0.25.5", - "@esbuild/sunos-x64": "0.25.5", - "@esbuild/win32-arm64": "0.25.5", - "@esbuild/win32-ia32": "0.25.5", - "@esbuild/win32-x64": "0.25.5" + "@esbuild/aix-ppc64": "0.25.6", + "@esbuild/android-arm": "0.25.6", + "@esbuild/android-arm64": "0.25.6", + "@esbuild/android-x64": "0.25.6", + "@esbuild/darwin-arm64": "0.25.6", + "@esbuild/darwin-x64": "0.25.6", + "@esbuild/freebsd-arm64": "0.25.6", + "@esbuild/freebsd-x64": "0.25.6", + "@esbuild/linux-arm": "0.25.6", + "@esbuild/linux-arm64": "0.25.6", + "@esbuild/linux-ia32": "0.25.6", + "@esbuild/linux-loong64": "0.25.6", + "@esbuild/linux-mips64el": "0.25.6", + "@esbuild/linux-ppc64": "0.25.6", + "@esbuild/linux-riscv64": "0.25.6", + "@esbuild/linux-s390x": "0.25.6", + "@esbuild/linux-x64": "0.25.6", + "@esbuild/netbsd-arm64": "0.25.6", + "@esbuild/netbsd-x64": "0.25.6", + "@esbuild/openbsd-arm64": "0.25.6", + "@esbuild/openbsd-x64": "0.25.6", + "@esbuild/openharmony-arm64": "0.25.6", + "@esbuild/sunos-x64": "0.25.6", + "@esbuild/win32-arm64": "0.25.6", + "@esbuild/win32-ia32": "0.25.6", + "@esbuild/win32-x64": "0.25.6" } }, "node_modules/esbuild-node-externals": { @@ -7395,7 +7658,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7420,18 +7682,18 @@ } }, "node_modules/eslint": { - "version": "9.29.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz", - "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", + "version": "9.31.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz", + "integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.1", - "@eslint/config-helpers": "^0.2.1", - "@eslint/core": "^0.14.0", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", + "@eslint/core": "^0.15.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.29.0", + "@eslint/js": "9.31.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -7480,12 +7742,12 @@ } }, "node_modules/eslint-config-next": { - "version": "15.3.4", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.3.4.tgz", - "integrity": "sha512-WqeumCq57QcTP2lYlV6BRUySfGiBYEXlQ1L0mQ+u4N4X4ZhUVSSQ52WtjqHv60pJ6dD7jn+YZc0d1/ZSsxccvg==", + "version": "15.3.5", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.3.5.tgz", + "integrity": "sha512-oQdvnIgP68wh2RlR3MdQpvaJ94R6qEFl+lnu8ZKxPj5fsAHrSF/HlAOZcsimLw3DT6bnEQIUdbZC2Ab6sWyptg==", "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "15.3.4", + "@next/eslint-plugin-next": "15.3.5", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", @@ -7950,6 +8212,13 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/exsolve": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", + "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8340,7 +8609,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -8350,7 +8618,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -8855,6 +9122,30 @@ "tslib": "^2.8.0" } }, + "node_modules/ioredis": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz", + "integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -9511,6 +9802,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", @@ -9811,12 +10112,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -9915,9 +10228,9 @@ } }, "node_modules/lucide-react": { - "version": "0.522.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.522.0.tgz", - "integrity": "sha512-jnJbw974yZ7rQHHEFKJOlWAefG3ATSCZHANZxIdx8Rk/16siuwjgA4fBULpXEAWx/RlTs3FzmKW/udWUuO0aRw==", + "version": "0.525.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz", + "integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -10251,9 +10564,9 @@ "license": "MIT" }, "node_modules/napi-postinstall": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.2.5.tgz", - "integrity": "sha512-kmsgUvCRIJohHjbZ3V8avP0I1Pekw329MVAMDzVxsrkjgdnqiwvMX5XwR+hWV66vsAtZ+iM+fVnq8RTQawUmCQ==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.0.tgz", + "integrity": "sha512-M7NqKyhODKV1gRLdkwE7pDsZP2/SC2a2vHkOYh9MCpKMbWVfyVfUw5MaH83Fv6XMjxr5jryUp3IDDL9rlxsTeA==", "license": "MIT", "bin": { "napi-postinstall": "lib/cli.js" @@ -10281,12 +10594,12 @@ } }, "node_modules/next": { - "version": "15.3.4", - "resolved": "https://registry.npmjs.org/next/-/next-15.3.4.tgz", - "integrity": "sha512-mHKd50C+mCjam/gcnwqL1T1vPx/XQNFlXqFIVdgQdVAFY9iIQtY0IfaVflEYzKiqjeA7B0cYYMaCrmAYFjs4rA==", + "version": "15.3.5", + "resolved": "https://registry.npmjs.org/next/-/next-15.3.5.tgz", + "integrity": "sha512-RkazLBMMDJSJ4XZQ81kolSpwiCt907l0xcgcpF4xC2Vml6QVcPNXW0NQRwQ80FFtSn7UM52XN0anaw8TEJXaiw==", "license": "MIT", "dependencies": { - "@next/env": "15.3.4", + "@next/env": "15.3.5", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", @@ -10301,14 +10614,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.3.4", - "@next/swc-darwin-x64": "15.3.4", - "@next/swc-linux-arm64-gnu": "15.3.4", - "@next/swc-linux-arm64-musl": "15.3.4", - "@next/swc-linux-x64-gnu": "15.3.4", - "@next/swc-linux-x64-musl": "15.3.4", - "@next/swc-win32-arm64-msvc": "15.3.4", - "@next/swc-win32-x64-msvc": "15.3.4", + "@next/swc-darwin-arm64": "15.3.5", + "@next/swc-darwin-x64": "15.3.5", + "@next/swc-linux-arm64-gnu": "15.3.5", + "@next/swc-linux-arm64-musl": "15.3.5", + "@next/swc-linux-x64-gnu": "15.3.5", + "@next/swc-linux-x64-musl": "15.3.5", + "@next/swc-win32-arm64-msvc": "15.3.5", + "@next/swc-win32-x64-msvc": "15.3.5", "sharp": "^0.34.1" }, "peerDependencies": { @@ -10335,9 +10648,9 @@ } }, "node_modules/next-intl": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.3.1.tgz", - "integrity": "sha512-FylHpOoQw5MpOyJt4cw8pNEGba7r3jKDSqt112fmBqXVceGR5YncmqpxS5MvSHsWRwbjqpOV8OsZCIY/4f4HWg==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.3.4.tgz", + "integrity": "sha512-VWLIDlGbnL/o4LnveJTJD1NOYN8lh3ZAGTWw2krhfgg53as3VsS4jzUVnArJdqvwtlpU/2BIDbWTZ7V4o1jFEw==", "funding": [ { "type": "individual", @@ -10348,7 +10661,7 @@ "dependencies": { "@formatjs/intl-localematcher": "^0.5.4", "negotiator": "^1.0.0", - "use-intl": "^4.3.1" + "use-intl": "^4.3.4" }, "peerDependencies": { "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0", @@ -10471,9 +10784,9 @@ } }, "node_modules/nodemailer": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.3.tgz", - "integrity": "sha512-Ajq6Sz1x7cIK3pN6KesGTah+1gnwMnx5gKl3piQlQQE/PwyJ4Mbc8is2psWYxK3RJTVeqsDaCv8ZzXLCDHMTZw==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz", + "integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -12966,6 +13279,26 @@ "inBundle": true, "license": "ISC" }, + "node_modules/nypm": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.0.tgz", + "integrity": "sha512-mn8wBFV9G9+UFHIrq+pZ2r2zL4aPau/by3kJb3cM7+5tQHMt6HGQB8FDIeKFYp8o0D2pnH6nVsO88N4AmUxIWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "pathe": "^2.0.3", + "pkg-types": "^2.0.0", + "tinyexec": "^0.3.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": "^14.16.0 || >=16.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -13709,6 +14042,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/peberminta": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", @@ -13814,9 +14154,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", "engines": { "node": ">=12" @@ -13825,6 +14165,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz", + "integrity": "sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, "node_modules/plimit-lit": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz", @@ -13974,6 +14326,20 @@ "node": ">=6" } }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -14023,6 +14389,24 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", + "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/qrcode.react": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", @@ -14086,6 +14470,18 @@ "node": ">= 0.6" } }, + "node_modules/rate-limit-redis": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/rate-limit-redis/-/rate-limit-redis-4.2.1.tgz", + "integrity": "sha512-JsUsVmRVI6G/XrlYtfGV1NMCbGS/CVYayHkxD5Ism5FaL8qpFHCXbFkUeIi5WJ/onJOKWCgtB/xtCLa6qSXb4g==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "peerDependencies": { + "express-rate-limit": ">= 6" + } + }, "node_modules/raw-body": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", @@ -14170,9 +14566,9 @@ "license": "0BSD" }, "node_modules/react-email": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/react-email/-/react-email-4.0.16.tgz", - "integrity": "sha512-auhFU+nQxAkKkP6lQhPyGsa9exwfUEzp2BwZnjHokCwphZlg30tu4t1LgdKRwGPYsi7XNGy6asbVLAUhOVpzzg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/react-email/-/react-email-4.1.0.tgz", + "integrity": "sha512-UvG5z1/gNOsLNwKPO87vgMoF7tdzUGd0kIy4fozzdBBsyLUju7hNVLBRm9j+Li/CwP5CXFT8Y5jZBtIFvSyr0w==", "dev": true, "license": "MIT", "dependencies": { @@ -14184,15 +14580,18 @@ "debounce": "^2.0.0", "esbuild": "^0.25.0", "glob": "^11.0.0", + "jiti": "2.4.2", "log-symbols": "^7.0.0", "mime-types": "^3.0.0", - "next": "^15.3.1", "normalize-path": "^3.0.0", + "nypm": "0.6.0", "ora": "^8.0.0", - "socket.io": "^4.8.1" + "prompts": "2.4.2", + "socket.io": "^4.8.1", + "tsconfig-paths": "4.2.0" }, "bin": { - "email": "dist/cli/index.mjs" + "email": "dist/index.js" }, "engines": { "node": ">=18.0.0" @@ -14221,6 +14620,19 @@ "node": ">=18" } }, + "node_modules/react-email/node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/react-email/node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -14244,10 +14656,25 @@ "node": ">= 0.6" } }, + "node_modules/react-email/node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/react-hook-form": { - "version": "7.58.1", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.58.1.tgz", - "integrity": "sha512-Lml/KZYEEFfPhUVgE0RdCVpnC4yhW+PndRhbiTtdvSlQTL8IfVR+iQkBjLIvmmc6+GGoVeM11z37ktKFPAb0FA==", + "version": "7.60.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.60.0.tgz", + "integrity": "sha512-SBrYOvMbDB7cV8ZfNpaiLcgjH/a1c7aK0lK+aNigpf4xWLO8q+o4tcvVurv3c4EOyzn/3dCsYt4GKD42VvJ/+A==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -14401,6 +14828,27 @@ "node": ">=0.8.8" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -14793,9 +15241,9 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.2.tgz", - "integrity": "sha512-lszvBmB9QURERtyKT2bNmsgxXK0ShJrL/fvqlonCo7e6xBF8nT8xU6pW+PMIbLsz0RxQk3rgH9kd8UmvOzlMJg==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz", + "integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==", "hasInstallScript": true, "license": "Apache-2.0", "optional": true, @@ -14811,27 +15259,28 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.2", - "@img/sharp-darwin-x64": "0.34.2", - "@img/sharp-libvips-darwin-arm64": "1.1.0", - "@img/sharp-libvips-darwin-x64": "1.1.0", - "@img/sharp-libvips-linux-arm": "1.1.0", - "@img/sharp-libvips-linux-arm64": "1.1.0", - "@img/sharp-libvips-linux-ppc64": "1.1.0", - "@img/sharp-libvips-linux-s390x": "1.1.0", - "@img/sharp-libvips-linux-x64": "1.1.0", - "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", - "@img/sharp-libvips-linuxmusl-x64": "1.1.0", - "@img/sharp-linux-arm": "0.34.2", - "@img/sharp-linux-arm64": "0.34.2", - "@img/sharp-linux-s390x": "0.34.2", - "@img/sharp-linux-x64": "0.34.2", - "@img/sharp-linuxmusl-arm64": "0.34.2", - "@img/sharp-linuxmusl-x64": "0.34.2", - "@img/sharp-wasm32": "0.34.2", - "@img/sharp-win32-arm64": "0.34.2", - "@img/sharp-win32-ia32": "0.34.2", - "@img/sharp-win32-x64": "0.34.2" + "@img/sharp-darwin-arm64": "0.34.3", + "@img/sharp-darwin-x64": "0.34.3", + "@img/sharp-libvips-darwin-arm64": "1.2.0", + "@img/sharp-libvips-darwin-x64": "1.2.0", + "@img/sharp-libvips-linux-arm": "1.2.0", + "@img/sharp-libvips-linux-arm64": "1.2.0", + "@img/sharp-libvips-linux-ppc64": "1.2.0", + "@img/sharp-libvips-linux-s390x": "1.2.0", + "@img/sharp-libvips-linux-x64": "1.2.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", + "@img/sharp-libvips-linuxmusl-x64": "1.2.0", + "@img/sharp-linux-arm": "0.34.3", + "@img/sharp-linux-arm64": "0.34.3", + "@img/sharp-linux-ppc64": "0.34.3", + "@img/sharp-linux-s390x": "0.34.3", + "@img/sharp-linux-x64": "0.34.3", + "@img/sharp-linuxmusl-arm64": "0.34.3", + "@img/sharp-linuxmusl-x64": "0.34.3", + "@img/sharp-wasm32": "0.34.3", + "@img/sharp-win32-arm64": "0.34.3", + "@img/sharp-win32-ia32": "0.34.3", + "@img/sharp-win32-x64": "0.34.3" } }, "node_modules/shebang-command": { @@ -14988,6 +15437,13 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -15172,6 +15628,12 @@ "node": "*" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -15506,9 +15968,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.25.3", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.25.3.tgz", - "integrity": "sha512-mqWJAhfl8mhVKJezwszUqRJAlrvKG/22am5xRUWzr7ya0MFaFBAAd7Nm+tD4BdKnVx7KRWkWYJMYRkFm5a8iTg==", + "version": "5.26.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.26.2.tgz", + "integrity": "sha512-WmMS9iMlHQejNm/Uw5ZTo4e3M2QMmEavRz7WLWVsq7Mlx4PSHJbY+VCrLsAz9wLxyHVgrJdt7N8+SdQLa52Ykg==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -15612,6 +16074,13 @@ "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", "license": "MIT" }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -15649,6 +16118,12 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -15826,9 +16301,9 @@ } }, "node_modules/tw-animate-css": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.4.tgz", - "integrity": "sha512-dd1Ht6/YQHcNbq0znIT6dG8uhO7Ce+VIIhZUhjsryXsMPJQz3bZg7Q2eNzLwipb25bRZslGb2myio5mScd1TFg==", + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.5.tgz", + "integrity": "sha512-t3u+0YNoloIhj1mMXs779P6MO9q3p3mvGn4k1n3nJPqJw/glZcuijG2qTSN4z4mgNRfW5ZC3aXJFLwDtiipZXA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Wombosvideo" @@ -15947,15 +16422,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.35.1.tgz", - "integrity": "sha512-xslJjFzhOmHYQzSB/QTeASAHbjmxOGEP6Coh93TXmUBFQoJ1VU35UHIDmG06Jd6taf3wqqC1ntBnCMeymy5Ovw==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.37.0.tgz", + "integrity": "sha512-TnbEjzkE9EmcO0Q2zM+GE8NQLItNAJpMmED1BdgoBMYNdqMhzlbqfdSwiRlAzEK2pA9UzVW0gzaaIzXWg2BjfA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.35.1", - "@typescript-eslint/parser": "8.35.1", - "@typescript-eslint/utils": "8.35.1" + "@typescript-eslint/eslint-plugin": "8.37.0", + "@typescript-eslint/parser": "8.37.0", + "@typescript-eslint/typescript-estree": "8.37.0", + "@typescript-eslint/utils": "8.37.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -16004,37 +16480,37 @@ } }, "node_modules/unrs-resolver": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.9.2.tgz", - "integrity": "sha512-VUyWiTNQD7itdiMuJy+EuLEErLj3uwX/EpHQF8EOf33Dq3Ju6VW1GXm+swk6+1h7a49uv9fKZ+dft9jU7esdLA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "napi-postinstall": "^0.2.4" + "napi-postinstall": "^0.3.0" }, "funding": { "url": "https://opencollective.com/unrs-resolver" }, "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.9.2", - "@unrs/resolver-binding-android-arm64": "1.9.2", - "@unrs/resolver-binding-darwin-arm64": "1.9.2", - "@unrs/resolver-binding-darwin-x64": "1.9.2", - "@unrs/resolver-binding-freebsd-x64": "1.9.2", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.9.2", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.9.2", - "@unrs/resolver-binding-linux-arm64-gnu": "1.9.2", - "@unrs/resolver-binding-linux-arm64-musl": "1.9.2", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.9.2", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.9.2", - "@unrs/resolver-binding-linux-riscv64-musl": "1.9.2", - "@unrs/resolver-binding-linux-s390x-gnu": "1.9.2", - "@unrs/resolver-binding-linux-x64-gnu": "1.9.2", - "@unrs/resolver-binding-linux-x64-musl": "1.9.2", - "@unrs/resolver-binding-wasm32-wasi": "1.9.2", - "@unrs/resolver-binding-win32-arm64-msvc": "1.9.2", - "@unrs/resolver-binding-win32-ia32-msvc": "1.9.2", - "@unrs/resolver-binding-win32-x64-msvc": "1.9.2" + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "node_modules/uri-js": { @@ -16068,9 +16544,9 @@ } }, "node_modules/use-intl": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.3.1.tgz", - "integrity": "sha512-8Xn5RXzeHZhWqqZimi1wi2pKFqm0NxRUOB41k1QdjbPX+ysoeLW3Ey+fi603D/e5EGb0fYw8WzjgtUagJdlIvg==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.3.4.tgz", + "integrity": "sha512-sHfiU0QeJ1rirNWRxvCyvlSh9+NczcOzRnPyMeo2rtHXhVnBsvMRjE+UG4eh3lRhCxrvcqei/I0lBxsc59on1w==", "license": "MIT", "dependencies": { "@formatjs/fast-memoize": "^2.2.0", @@ -16171,6 +16647,22 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", @@ -16438,9 +16930,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -16471,7 +16963,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -16502,7 +16993,6 @@ "version": "18.0.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^9.0.1", @@ -16520,7 +17010,6 @@ "version": "22.0.0", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, "license": "ISC", "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" @@ -16530,14 +17019,12 @@ "version": "10.4.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, "license": "MIT" }, "node_modules/yargs/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -16577,9 +17064,9 @@ } }, "node_modules/zod": { - "version": "3.25.67", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz", - "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index e1b15d7d..e769bab2 100644 --- a/package.json +++ b/package.json @@ -49,16 +49,19 @@ "@radix-ui/react-switch": "1.2.5", "@radix-ui/react-tabs": "1.1.12", "@radix-ui/react-toast": "1.2.14", - "@react-email/components": "0.1.0", + "@radix-ui/react-tooltip": "^1.2.7", + "@react-email/components": "0.3.1", "@react-email/render": "^1.1.2", - "@react-email/tailwind": "1.0.5", + "@simplewebauthn/browser": "^13.1.0", + "@simplewebauthn/server": "^9.0.3", + "@react-email/tailwind": "1.2.1", "@tailwindcss/forms": "^0.5.10", "@tanstack/react-table": "8.21.3", "arctic": "^3.7.0", "axios": "1.10.0", "better-sqlite3": "11.7.0", "canvas-confetti": "1.9.3", - "class-variance-authority": "0.7.1", + "class-variance-authority": "^0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", "cookie": "^1.0.2", @@ -67,8 +70,8 @@ "cors": "2.8.5", "crypto-js": "^4.2.0", "drizzle-orm": "0.44.2", - "eslint": "9.29.0", - "eslint-config-next": "15.3.4", + "eslint": "9.31.0", + "eslint-config-next": "15.3.5", "express": "4.21.2", "express-rate-limit": "7.5.1", "glob": "11.0.3", @@ -79,14 +82,14 @@ "jmespath": "^0.16.0", "js-yaml": "4.1.0", "jsonwebtoken": "^9.0.2", - "lucide-react": "0.522.0", + "lucide-react": "0.525.0", "moment": "2.30.1", - "next": "15.3.4", - "next-intl": "^4.1.0", + "next": "15.3.5", + "next-intl": "^4.3.4", "next-themes": "0.4.6", "node-cache": "5.1.2", "node-fetch": "3.3.2", - "nodemailer": "7.0.3", + "nodemailer": "7.0.5", "npm": "^11.4.2", "oslo": "1.2.1", "pg": "^8.16.2", @@ -94,24 +97,24 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-easy-sort": "^1.6.0", - "react-hook-form": "7.58.1", + "react-hook-form": "7.60.0", "react-icons": "^5.5.0", "rebuild": "0.1.2", - "semver": "7.7.2", + "semver": "^7.7.2", "swagger-ui-express": "^5.0.1", "tailwind-merge": "3.3.1", - "tw-animate-css": "^1.3.3", + "tw-animate-css": "^1.3.5", "uuid": "^11.1.0", "vaul": "1.1.2", "winston": "3.17.0", "winston-daily-rotate-file": "5.0.0", - "ws": "8.18.2", - "zod": "3.25.67", + "ws": "8.18.3", + "zod": "3.25.76", "zod-validation-error": "3.5.2", "yargs": "18.0.0" }, "devDependencies": { - "@dotenvx/dotenvx": "1.45.1", + "@dotenvx/dotenvx": "1.47.6", "@esbuild-plugins/tsconfig-paths": "0.1.2", "@tailwindcss/postcss": "^4.1.10", "@types/better-sqlite3": "7.6.12", @@ -119,27 +122,29 @@ "@types/cors": "2.8.19", "@types/crypto-js": "^4.2.2", "@types/express": "5.0.0", + "@types/express-session": "^1.18.2", "@types/jmespath": "^0.15.2", "@types/js-yaml": "4.0.9", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24", "@types/nodemailer": "6.4.17", + "@types/pg": "8.15.4", "@types/react": "19.1.8", "@types/react-dom": "19.1.6", - "@types/semver": "7.7.0", + "@types/semver": "^7.7.0", "@types/swagger-ui-express": "^4.1.8", "@types/ws": "8.18.1", "@types/yargs": "17.0.33", - "drizzle-kit": "0.31.2", - "esbuild": "0.25.5", + "drizzle-kit": "0.31.4", + "esbuild": "0.25.6", "esbuild-node-externals": "1.18.0", "postcss": "^8", - "react-email": "4.0.16", + "react-email": "4.1.0", "tailwindcss": "^4.1.4", "tsc-alias": "1.8.16", "tsx": "4.20.3", "typescript": "^5", - "typescript-eslint": "^8.35.0" + "typescript-eslint": "^8.36.0" }, "overrides": { "emblor": { diff --git a/public/auth-diagram1.png b/public/auth-diagram1.png new file mode 100644 index 00000000..92843a6d Binary files /dev/null and b/public/auth-diagram1.png differ diff --git a/public/clip.gif b/public/clip.gif new file mode 100644 index 00000000..4202d679 Binary files /dev/null and b/public/clip.gif differ diff --git a/public/diagram-dark.svg b/public/diagram-dark.svg new file mode 100644 index 00000000..58e44f35 --- /dev/null +++ b/public/diagram-dark.svg @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/diagram.svg b/public/diagram.svg new file mode 100644 index 00000000..9e9e39fb --- /dev/null +++ b/public/diagram.svg @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/screenshots/collage.png b/public/screenshots/collage.png deleted file mode 100644 index c791e7ea..00000000 Binary files a/public/screenshots/collage.png and /dev/null differ diff --git a/public/screenshots/create-api-key.png b/public/screenshots/create-api-key.png new file mode 100644 index 00000000..ad0ef6a4 Binary files /dev/null and b/public/screenshots/create-api-key.png differ diff --git a/public/screenshots/create-idp.png b/public/screenshots/create-idp.png new file mode 100644 index 00000000..e19ddec5 Binary files /dev/null and b/public/screenshots/create-idp.png differ diff --git a/public/screenshots/create-resource.png b/public/screenshots/create-resource.png new file mode 100644 index 00000000..3b21f22b Binary files /dev/null and b/public/screenshots/create-resource.png differ diff --git a/public/screenshots/create-share-link.png b/public/screenshots/create-share-link.png new file mode 100644 index 00000000..18849501 Binary files /dev/null and b/public/screenshots/create-share-link.png differ diff --git a/public/screenshots/create-site.png b/public/screenshots/create-site.png new file mode 100644 index 00000000..b5ff8048 Binary files /dev/null and b/public/screenshots/create-site.png differ diff --git a/public/screenshots/edit-resource.png b/public/screenshots/edit-resource.png new file mode 100644 index 00000000..2d21afa6 Binary files /dev/null and b/public/screenshots/edit-resource.png differ diff --git a/public/screenshots/hero.png b/public/screenshots/hero.png index 4e321ee1..86216cf6 100644 Binary files a/public/screenshots/hero.png and b/public/screenshots/hero.png differ diff --git a/public/screenshots/resource-auth.png b/public/screenshots/resource-auth.png new file mode 100644 index 00000000..e9d39f4c Binary files /dev/null and b/public/screenshots/resource-auth.png differ diff --git a/public/screenshots/resource-authentication.png b/public/screenshots/resource-authentication.png new file mode 100644 index 00000000..764cd616 Binary files /dev/null and b/public/screenshots/resource-authentication.png differ diff --git a/public/screenshots/resources.png b/public/screenshots/resources.png new file mode 100644 index 00000000..86216cf6 Binary files /dev/null and b/public/screenshots/resources.png differ diff --git a/public/screenshots/roles.png b/public/screenshots/roles.png new file mode 100644 index 00000000..09d27387 Binary files /dev/null and b/public/screenshots/roles.png differ diff --git a/public/screenshots/site-online.png b/public/screenshots/site-online.png new file mode 100644 index 00000000..0adef017 Binary files /dev/null and b/public/screenshots/site-online.png differ diff --git a/public/screenshots/sites-fade.png b/public/screenshots/sites-fade.png new file mode 100644 index 00000000..7e21c2cd Binary files /dev/null and b/public/screenshots/sites-fade.png differ diff --git a/public/screenshots/sites.png b/public/screenshots/sites.png new file mode 100644 index 00000000..0aaa79d0 Binary files /dev/null and b/public/screenshots/sites.png differ diff --git a/public/screenshots/users.png b/public/screenshots/users.png new file mode 100644 index 00000000..91286e02 Binary files /dev/null and b/public/screenshots/users.png differ diff --git a/server/apiServer.ts b/server/apiServer.ts index ace27e9b..2bf6b615 100644 --- a/server/apiServer.ts +++ b/server/apiServer.ts @@ -5,20 +5,25 @@ import config from "@server/lib/config"; import logger from "@server/logger"; import { errorHandlerMiddleware, - notFoundMiddleware, - rateLimitMiddleware + notFoundMiddleware } from "@server/middlewares"; import { authenticated, unauthenticated } from "@server/routers/external"; import { router as wsRouter, handleWSUpgrade } from "@server/routers/ws"; import { logIncomingMiddleware } from "./middlewares/logIncoming"; import { csrfProtectionMiddleware } from "./middlewares/csrfProtection"; import helmet from "helmet"; +import rateLimit from "express-rate-limit"; +import createHttpError from "http-errors"; +import HttpCode from "./types/HttpCode"; +import requestTimeoutMiddleware from "./middlewares/requestTimeout"; +import { createStore } from "./lib/rateLimitStore"; const dev = config.isDev; const externalPort = config.getRawConfig().server.external_port; export function createApiServer() { const apiServer = express(); + const prefix = `/api/v1`; const trustProxy = config.getRawConfig().server.trust_proxy; if (trustProxy) { @@ -54,19 +59,30 @@ export function createApiServer() { apiServer.use(cookieParser()); apiServer.use(express.json()); + // Add request timeout middleware + apiServer.use(requestTimeoutMiddleware(60000)); // 60 second timeout + if (!dev) { apiServer.use( - rateLimitMiddleware({ - windowMin: - config.getRawConfig().rate_limits.global.window_minutes, + rateLimit({ + windowMs: + config.getRawConfig().rate_limits.global.window_minutes * + 60 * + 1000, max: config.getRawConfig().rate_limits.global.max_requests, - type: "IP_AND_PATH" + keyGenerator: (req) => `apiServerGlobal:${req.ip}:${req.path}`, + handler: (req, res, next) => { + const message = `Rate limit exceeded. You can make ${config.getRawConfig().rate_limits.global.max_requests} requests every ${config.getRawConfig().rate_limits.global.window_minutes} minute(s).`; + return next( + createHttpError(HttpCode.TOO_MANY_REQUESTS, message) + ); + }, + store: createStore() }) ); } // API routes - const prefix = `/api/v1`; apiServer.use(logIncomingMiddleware); apiServer.use(prefix, unauthenticated); apiServer.use(prefix, authenticated); diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 08c86321..ee2c5dac 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -56,6 +56,8 @@ export enum ActionsEnum { // removeUserAction = "removeUserAction", // removeUserSite = "removeUserSite", getOrgUser = "getOrgUser", + updateUser = "updateUser", + getUser = "getUser", setResourcePassword = "setResourcePassword", setResourcePincode = "setResourcePincode", setResourceWhitelist = "setResourceWhitelist", @@ -67,6 +69,11 @@ export enum ActionsEnum { deleteResourceRule = "deleteResourceRule", listResourceRules = "listResourceRules", updateResourceRule = "updateResourceRule", + createClient = "createClient", + deleteClient = "deleteClient", + updateClient = "updateClient", + listClients = "listClients", + getClient = "getClient", listOrgDomains = "listOrgDomains", createNewt = "createNewt", createIdp = "createIdp", @@ -85,7 +92,10 @@ export enum ActionsEnum { setApiKeyOrgs = "setApiKeyOrgs", listApiKeyActions = "listApiKeyActions", listApiKeys = "listApiKeys", - getApiKey = "getApiKey" + getApiKey = "getApiKey", + createOrgDomain = "createOrgDomain", + deleteOrgDomain = "deleteOrgDomain", + restartOrgDomain = "restartOrgDomain" } export async function checkUserActionPermission( diff --git a/server/auth/limits.ts b/server/auth/limits.ts deleted file mode 100644 index 5d0b14e4..00000000 --- a/server/auth/limits.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { db } from '@server/db'; -import { limitsTable } from '@server/db'; -import { and, eq } from 'drizzle-orm'; -import createHttpError from 'http-errors'; -import HttpCode from '@server/types/HttpCode'; - -interface CheckLimitOptions { - orgId: string; - limitName: string; - currentValue: number; - increment?: number; -} - -export async function checkOrgLimit({ orgId, limitName, currentValue, increment = 0 }: CheckLimitOptions): Promise { - try { - const limit = await db.select() - .from(limitsTable) - .where( - and( - eq(limitsTable.orgId, orgId), - eq(limitsTable.name, limitName) - ) - ) - .limit(1); - - if (limit.length === 0) { - throw createHttpError(HttpCode.NOT_FOUND, `Limit "${limitName}" not found for organization`); - } - - const limitValue = limit[0].value; - - // Check if the current value plus the increment is within the limit - return (currentValue + increment) <= limitValue; - } catch (error) { - if (error instanceof Error) { - throw createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `Error checking limit: ${error.message}`); - } - throw createHttpError(HttpCode.INTERNAL_SERVER_ERROR, 'Unknown error occurred while checking limit'); - } -} diff --git a/server/auth/sessions/olm.ts b/server/auth/sessions/olm.ts new file mode 100644 index 00000000..89a0e81e --- /dev/null +++ b/server/auth/sessions/olm.ts @@ -0,0 +1,72 @@ +import { + encodeHexLowerCase, +} from "@oslojs/encoding"; +import { sha256 } from "@oslojs/crypto/sha2"; +import { Olm, olms, olmSessions, OlmSession } from "@server/db"; +import { db } from "@server/db"; +import { eq } from "drizzle-orm"; + +export const EXPIRES = 1000 * 60 * 60 * 24 * 30; + +export async function createOlmSession( + token: string, + olmId: string, +): Promise { + const sessionId = encodeHexLowerCase( + sha256(new TextEncoder().encode(token)), + ); + const session: OlmSession = { + sessionId: sessionId, + olmId, + expiresAt: new Date(Date.now() + EXPIRES).getTime(), + }; + await db.insert(olmSessions).values(session); + return session; +} + +export async function validateOlmSessionToken( + token: string, +): Promise { + const sessionId = encodeHexLowerCase( + sha256(new TextEncoder().encode(token)), + ); + const result = await db + .select({ olm: olms, session: olmSessions }) + .from(olmSessions) + .innerJoin(olms, eq(olmSessions.olmId, olms.olmId)) + .where(eq(olmSessions.sessionId, sessionId)); + if (result.length < 1) { + return { session: null, olm: null }; + } + const { olm, session } = result[0]; + if (Date.now() >= session.expiresAt) { + await db + .delete(olmSessions) + .where(eq(olmSessions.sessionId, session.sessionId)); + return { session: null, olm: null }; + } + if (Date.now() >= session.expiresAt - (EXPIRES / 2)) { + session.expiresAt = new Date( + Date.now() + EXPIRES, + ).getTime(); + await db + .update(olmSessions) + .set({ + expiresAt: session.expiresAt, + }) + .where(eq(olmSessions.sessionId, session.sessionId)); + } + return { session, olm }; +} + +export async function invalidateOlmSession(sessionId: string): Promise { + await db.delete(olmSessions).where(eq(olmSessions.sessionId, sessionId)); +} + +export async function invalidateAllOlmSessions(olmId: string): Promise { + await db.delete(olmSessions).where(eq(olmSessions.olmId, olmId)); +} + +export type SessionValidationResult = + | { session: OlmSession; olm: Olm } + | { session: null; olm: null }; diff --git a/server/build.ts b/server/build.ts new file mode 100644 index 00000000..babe5e8b --- /dev/null +++ b/server/build.ts @@ -0,0 +1 @@ +export const build = "oss" as any; diff --git a/server/db/names.ts b/server/db/names.ts index 56d62373..41f4c170 100644 --- a/server/db/names.ts +++ b/server/db/names.ts @@ -59,7 +59,7 @@ export async function getUniqueExitNodeEndpointName(): Promise { export function generateName(): string { - return ( + const name = ( names.descriptors[ Math.floor(Math.random() * names.descriptors.length) ] + @@ -68,4 +68,7 @@ export function generateName(): string { ) .toLowerCase() .replace(/\s/g, "-"); + + // clean out any non-alphanumeric characters except for dashes + return name.replace(/[^a-z0-9-]/g, ""); } diff --git a/server/db/pg/driver.ts b/server/db/pg/driver.ts index 116e4610..9625867d 100644 --- a/server/db/pg/driver.ts +++ b/server/db/pg/driver.ts @@ -1,4 +1,5 @@ import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; import { readConfigFile } from "@server/lib/readConfigFile"; import { withReplicas } from "drizzle-orm/pg-core"; @@ -20,19 +21,31 @@ function createDb() { ); } - const primary = DrizzlePostgres(connectionString); + // Create connection pools instead of individual connections + const primaryPool = new Pool({ + connectionString, + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, + }); + const replicas = []; if (!replicaConnections.length) { - replicas.push(primary); + replicas.push(DrizzlePostgres(primaryPool)); } else { for (const conn of replicaConnections) { - const replica = DrizzlePostgres(conn.connection_string); - replicas.push(replica); + const replicaPool = new Pool({ + connectionString: conn.connection_string, + max: 10, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, + }); + replicas.push(DrizzlePostgres(replicaPool)); } } - return withReplicas(primary, replicas as any); + return withReplicas(DrizzlePostgres(primaryPool), replicas as any); } export const db = createDb(); diff --git a/server/db/pg/index.ts b/server/db/pg/index.ts index 9ad4678c..4829c04c 100644 --- a/server/db/pg/index.ts +++ b/server/db/pg/index.ts @@ -1,2 +1,2 @@ export * from "./driver"; -export * from "./schema"; +export * from "./schema"; \ No newline at end of file diff --git a/server/db/pg/migrate.ts b/server/db/pg/migrate.ts index b9463dd4..70b2ef54 100644 --- a/server/db/pg/migrate.ts +++ b/server/db/pg/migrate.ts @@ -1,5 +1,5 @@ import { migrate } from "drizzle-orm/node-postgres/migrator"; -import db from "./driver"; +import { db } from "./driver"; import path from "path"; const migrationsFolder = path.join("server/migrations"); diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index cb641974..be4e58e2 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -5,19 +5,26 @@ import { boolean, integer, bigint, - real + real, + text } from "drizzle-orm/pg-core"; import { InferSelectModel } from "drizzle-orm"; export const domains = pgTable("domains", { domainId: varchar("domainId").primaryKey(), baseDomain: varchar("baseDomain").notNull(), - configManaged: boolean("configManaged").notNull().default(false) + configManaged: boolean("configManaged").notNull().default(false), + type: varchar("type"), // "ns", "cname", "wildcard" + verified: boolean("verified").notNull().default(false), + failed: boolean("failed").notNull().default(false), + tries: integer("tries").notNull().default(0) }); export const orgs = pgTable("orgs", { orgId: varchar("orgId").primaryKey(), - name: varchar("name").notNull() + name: varchar("name").notNull(), + subnet: varchar("subnet"), + createdAt: text("createdAt") }); export const orgDomains = pgTable("orgDomains", { @@ -42,13 +49,19 @@ export const sites = pgTable("sites", { }), name: varchar("name").notNull(), pubKey: varchar("pubKey"), - subnet: varchar("subnet").notNull(), - megabytesIn: real("bytesIn"), - megabytesOut: real("bytesOut"), + subnet: varchar("subnet"), + megabytesIn: real("bytesIn").default(0), + megabytesOut: real("bytesOut").default(0), lastBandwidthUpdate: varchar("lastBandwidthUpdate"), type: varchar("type").notNull(), // "newt" or "wireguard" online: boolean("online").notNull().default(false), - dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true) + address: varchar("address"), + endpoint: varchar("endpoint"), + publicKey: varchar("publicKey"), + lastHolePunch: bigint("lastHolePunch", { mode: "number" }), + listenPort: integer("listenPort"), + dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), + remoteSubnets: text("remoteSubnets") // comma-separated list of subnets that this site can access }); export const resources = pgTable("resources", { @@ -78,12 +91,12 @@ export const resources = pgTable("resources", { emailWhitelistEnabled: boolean("emailWhitelistEnabled") .notNull() .default(false), - isBaseDomain: boolean("isBaseDomain"), applyRules: boolean("applyRules").notNull().default(false), enabled: boolean("enabled").notNull().default(true), stickySession: boolean("stickySession").notNull().default(false), tlsServerName: varchar("tlsServerName"), - setHostHeader: varchar("setHostHeader") + setHostHeader: varchar("setHostHeader"), + enableProxy: boolean("enableProxy").default(true), }); export const targets = pgTable("targets", { @@ -107,7 +120,8 @@ export const exitNodes = pgTable("exitNodes", { endpoint: varchar("endpoint").notNull(), publicKey: varchar("publicKey").notNull(), listenPort: integer("listenPort").notNull(), - reachableAt: varchar("reachableAt") + reachableAt: varchar("reachableAt"), + maxConnections: integer("maxConnections") }); export const users = pgTable("user", { @@ -121,9 +135,12 @@ export const users = pgTable("user", { }), passwordHash: varchar("passwordHash"), twoFactorEnabled: boolean("twoFactorEnabled").notNull().default(false), + twoFactorSetupRequested: boolean("twoFactorSetupRequested").default(false), twoFactorSecret: varchar("twoFactorSecret"), emailVerified: boolean("emailVerified").notNull().default(false), dateCreated: varchar("dateCreated").notNull(), + termsAcceptedTimestamp: varchar("termsAcceptedTimestamp"), + termsVersion: varchar("termsVersion"), serverAdmin: boolean("serverAdmin").notNull().default(false) }); @@ -131,6 +148,7 @@ export const newts = pgTable("newt", { newtId: varchar("id").primaryKey(), secretHash: varchar("secretHash").notNull(), dateCreated: varchar("dateCreated").notNull(), + version: varchar("version"), siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade" }) @@ -273,18 +291,6 @@ export const userResources = pgTable("userResources", { .references(() => resources.resourceId, { onDelete: "cascade" }) }); -export const limitsTable = pgTable("limits", { - limitId: serial("limitId").primaryKey(), - orgId: varchar("orgId") - .references(() => orgs.orgId, { - onDelete: "cascade" - }) - .notNull(), - name: varchar("name").notNull(), - value: bigint("value", { mode: "number" }).notNull(), - description: varchar("description") -}); - export const userInvites = pgTable("userInvites", { inviteId: varchar("inviteId").primaryKey(), orgId: varchar("orgId") @@ -491,6 +497,101 @@ export const idpOrg = pgTable("idpOrg", { orgMapping: varchar("orgMapping") }); +export const clients = pgTable("clients", { + clientId: serial("id").primaryKey(), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { + onDelete: "set null" + }), + name: varchar("name").notNull(), + pubKey: varchar("pubKey"), + subnet: varchar("subnet").notNull(), + megabytesIn: real("bytesIn"), + megabytesOut: real("bytesOut"), + lastBandwidthUpdate: varchar("lastBandwidthUpdate"), + lastPing: varchar("lastPing"), + type: varchar("type").notNull(), // "olm" + online: boolean("online").notNull().default(false), + endpoint: varchar("endpoint"), + lastHolePunch: integer("lastHolePunch"), + maxConnections: integer("maxConnections") +}); + +export const clientSites = pgTable("clientSites", { + clientId: integer("clientId") + .notNull() + .references(() => clients.clientId, { onDelete: "cascade" }), + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { onDelete: "cascade" }), + isRelayed: boolean("isRelayed").notNull().default(false) +}); + +export const olms = pgTable("olms", { + olmId: varchar("id").primaryKey(), + secretHash: varchar("secretHash").notNull(), + dateCreated: varchar("dateCreated").notNull(), + clientId: integer("clientId").references(() => clients.clientId, { + onDelete: "cascade" + }) +}); + +export const olmSessions = pgTable("clientSession", { + sessionId: varchar("id").primaryKey(), + olmId: varchar("olmId") + .notNull() + .references(() => olms.olmId, { onDelete: "cascade" }), + expiresAt: bigint("expiresAt", { mode: "number" }).notNull() +}); + +export const userClients = pgTable("userClients", { + userId: varchar("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + clientId: integer("clientId") + .notNull() + .references(() => clients.clientId, { onDelete: "cascade" }) +}); + +export const roleClients = pgTable("roleClients", { + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }), + clientId: integer("clientId") + .notNull() + .references(() => clients.clientId, { onDelete: "cascade" }) +}); + +export const securityKeys = pgTable("webauthnCredentials", { + credentialId: varchar("credentialId").primaryKey(), + userId: varchar("userId") + .notNull() + .references(() => users.userId, { + onDelete: "cascade" + }), + publicKey: varchar("publicKey").notNull(), + signCount: integer("signCount").notNull(), + transports: varchar("transports"), + name: varchar("name"), + lastUsed: varchar("lastUsed").notNull(), + dateCreated: varchar("dateCreated").notNull(), + securityKeyName: varchar("securityKeyName") +}); + +export const webauthnChallenge = pgTable("webauthnChallenge", { + sessionId: varchar("sessionId").primaryKey(), + challenge: varchar("challenge").notNull(), + securityKeyName: varchar("securityKeyName"), + userId: varchar("userId").references(() => users.userId, { + onDelete: "cascade" + }), + expiresAt: bigint("expiresAt", { mode: "number" }).notNull() // Unix timestamp +}); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -513,7 +614,6 @@ export type RoleSite = InferSelectModel; export type UserSite = InferSelectModel; export type RoleResource = InferSelectModel; export type UserResource = InferSelectModel; -export type Limit = InferSelectModel; export type UserInvite = InferSelectModel; export type UserOrg = InferSelectModel; export type ResourceSession = InferSelectModel; @@ -530,3 +630,10 @@ export type Idp = InferSelectModel; export type ApiKey = InferSelectModel; export type ApiKeyAction = InferSelectModel; export type ApiKeyOrg = InferSelectModel; +export type Client = InferSelectModel; +export type ClientSite = InferSelectModel; +export type Olm = InferSelectModel; +export type OlmSession = InferSelectModel; +export type UserClient = InferSelectModel; +export type RoleClient = InferSelectModel; +export type OrgDomains = InferSelectModel; diff --git a/server/db/sqlite/driver.ts b/server/db/sqlite/driver.ts index 9a12b43d..124bd885 100644 --- a/server/db/sqlite/driver.ts +++ b/server/db/sqlite/driver.ts @@ -2,12 +2,12 @@ import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3"; import Database from "better-sqlite3"; import * as schema from "./schema"; import path from "path"; -import fs from "fs/promises"; +import fs from "fs"; import { APP_PATH } from "@server/lib/consts"; import { existsSync, mkdirSync } from "fs"; export const location = path.join(APP_PATH, "db", "db.sqlite"); -export const exists = await checkFileExists(location); +export const exists = checkFileExists(location); bootstrapVolume(); @@ -19,9 +19,9 @@ function createDb() { export const db = createDb(); export default db; -async function checkFileExists(filePath: string): Promise { +function checkFileExists(filePath: string): boolean { try { - await fs.access(filePath); + fs.accessSync(filePath); return true; } catch { return false; diff --git a/server/db/sqlite/migrate.ts b/server/db/sqlite/migrate.ts index 20b9043f..e4a730d0 100644 --- a/server/db/sqlite/migrate.ts +++ b/server/db/sqlite/migrate.ts @@ -1,5 +1,5 @@ import { migrate } from "drizzle-orm/better-sqlite3/migrator"; -import db from "./driver"; +import { db } from "./driver"; import path from "path"; const migrationsFolder = path.join("server/migrations"); diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index b587d1c7..5773a5f3 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -6,12 +6,27 @@ export const domains = sqliteTable("domains", { baseDomain: text("baseDomain").notNull(), configManaged: integer("configManaged", { mode: "boolean" }) .notNull() - .default(false) + .default(false), + type: text("type"), // "ns", "cname", "wildcard" + verified: integer("verified", { mode: "boolean" }).notNull().default(false), + failed: integer("failed", { mode: "boolean" }).notNull().default(false), + tries: integer("tries").notNull().default(0) }); export const orgs = sqliteTable("orgs", { orgId: text("orgId").primaryKey(), - name: text("name").notNull() + name: text("name").notNull(), + subnet: text("subnet"), + createdAt: text("createdAt") +}); + +export const userDomains = sqliteTable("userDomains", { + userId: text("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + domainId: text("domainId") + .notNull() + .references(() => domains.domainId, { onDelete: "cascade" }) }); export const orgDomains = sqliteTable("orgDomains", { @@ -36,15 +51,23 @@ export const sites = sqliteTable("sites", { }), name: text("name").notNull(), pubKey: text("pubKey"), - subnet: text("subnet").notNull(), - megabytesIn: integer("bytesIn"), - megabytesOut: integer("bytesOut"), + subnet: text("subnet"), + megabytesIn: integer("bytesIn").default(0), + megabytesOut: integer("bytesOut").default(0), lastBandwidthUpdate: text("lastBandwidthUpdate"), type: text("type").notNull(), // "newt" or "wireguard" online: integer("online", { mode: "boolean" }).notNull().default(false), + + // exit node stuff that is how to connect to the site when it has a wg server + address: text("address"), // this is the address of the wireguard interface in newt + endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config + publicKey: text("publicKey"), // TODO: Fix typo in publicKey + lastHolePunch: integer("lastHolePunch"), + listenPort: integer("listenPort"), dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) .notNull() - .default(true) + .default(true), + remoteSubnets: text("remoteSubnets"), // comma-separated list of subnets that this site can access }); export const resources = sqliteTable("resources", { @@ -76,7 +99,6 @@ export const resources = sqliteTable("resources", { emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" }) .notNull() .default(false), - isBaseDomain: integer("isBaseDomain", { mode: "boolean" }), applyRules: integer("applyRules", { mode: "boolean" }) .notNull() .default(false), @@ -85,7 +107,8 @@ export const resources = sqliteTable("resources", { .notNull() .default(false), tlsServerName: text("tlsServerName"), - setHostHeader: text("setHostHeader") + setHostHeader: text("setHostHeader"), + enableProxy: integer("enableProxy", { mode: "boolean" }).default(true), }); export const targets = sqliteTable("targets", { @@ -109,7 +132,8 @@ export const exitNodes = sqliteTable("exitNodes", { endpoint: text("endpoint").notNull(), // this is how to reach gerbil externally - gets put into the wireguard config publicKey: text("publicKey").notNull(), listenPort: integer("listenPort").notNull(), - reachableAt: text("reachableAt") // this is the internal address of the gerbil http server for command control + reachableAt: text("reachableAt"), // this is the internal address of the gerbil http server for command control + maxConnections: integer("maxConnections") }); export const users = sqliteTable("user", { @@ -125,25 +149,96 @@ export const users = sqliteTable("user", { twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" }) .notNull() .default(false), + twoFactorSetupRequested: integer("twoFactorSetupRequested", { + mode: "boolean" + }).default(false), twoFactorSecret: text("twoFactorSecret"), emailVerified: integer("emailVerified", { mode: "boolean" }) .notNull() .default(false), dateCreated: text("dateCreated").notNull(), + termsAcceptedTimestamp: text("termsAcceptedTimestamp"), + termsVersion: text("termsVersion"), serverAdmin: integer("serverAdmin", { mode: "boolean" }) .notNull() .default(false) }); +export const securityKeys = sqliteTable("webauthnCredentials", { + credentialId: text("credentialId").primaryKey(), + userId: text("userId").notNull().references(() => users.userId, { + onDelete: "cascade" + }), + publicKey: text("publicKey").notNull(), + signCount: integer("signCount").notNull(), + transports: text("transports"), + name: text("name"), + lastUsed: text("lastUsed").notNull(), + dateCreated: text("dateCreated").notNull() +}); + +export const webauthnChallenge = sqliteTable("webauthnChallenge", { + sessionId: text("sessionId").primaryKey(), + challenge: text("challenge").notNull(), + securityKeyName: text("securityKeyName"), + userId: text("userId").references(() => users.userId, { + onDelete: "cascade" + }), + expiresAt: integer("expiresAt").notNull() // Unix timestamp +}); + export const newts = sqliteTable("newt", { newtId: text("id").primaryKey(), secretHash: text("secretHash").notNull(), dateCreated: text("dateCreated").notNull(), + version: text("version"), siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade" }) }); +export const clients = sqliteTable("clients", { + clientId: integer("id").primaryKey({ autoIncrement: true }), + orgId: text("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { + onDelete: "set null" + }), + name: text("name").notNull(), + pubKey: text("pubKey"), + subnet: text("subnet").notNull(), + megabytesIn: integer("bytesIn"), + megabytesOut: integer("bytesOut"), + lastBandwidthUpdate: text("lastBandwidthUpdate"), + lastPing: text("lastPing"), + type: text("type").notNull(), // "olm" + online: integer("online", { mode: "boolean" }).notNull().default(false), + endpoint: text("endpoint"), + lastHolePunch: integer("lastHolePunch") +}); + +export const clientSites = sqliteTable("clientSites", { + clientId: integer("clientId") + .notNull() + .references(() => clients.clientId, { onDelete: "cascade" }), + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { onDelete: "cascade" }), + isRelayed: integer("isRelayed", { mode: "boolean" }).notNull().default(false) +}); + +export const olms = sqliteTable("olms", { + olmId: text("id").primaryKey(), + secretHash: text("secretHash").notNull(), + dateCreated: text("dateCreated").notNull(), + clientId: integer("clientId").references(() => clients.clientId, { + onDelete: "cascade" + }) +}); + export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", { codeId: integer("id").primaryKey({ autoIncrement: true }), userId: text("userId") @@ -168,6 +263,14 @@ export const newtSessions = sqliteTable("newtSession", { expiresAt: integer("expiresAt").notNull() }); +export const olmSessions = sqliteTable("clientSession", { + sessionId: text("id").primaryKey(), + olmId: text("olmId") + .notNull() + .references(() => olms.olmId, { onDelete: "cascade" }), + expiresAt: integer("expiresAt").notNull() +}); + export const userOrgs = sqliteTable("userOrgs", { userId: text("userId") .notNull() @@ -263,6 +366,24 @@ export const userSites = sqliteTable("userSites", { .references(() => sites.siteId, { onDelete: "cascade" }) }); +export const userClients = sqliteTable("userClients", { + userId: text("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + clientId: integer("clientId") + .notNull() + .references(() => clients.clientId, { onDelete: "cascade" }) +}); + +export const roleClients = sqliteTable("roleClients", { + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }), + clientId: integer("clientId") + .notNull() + .references(() => clients.clientId, { onDelete: "cascade" }) +}); + export const roleResources = sqliteTable("roleResources", { roleId: integer("roleId") .notNull() @@ -521,6 +642,8 @@ export type Target = InferSelectModel; export type Session = InferSelectModel; export type Newt = InferSelectModel; export type NewtSession = InferSelectModel; +export type Olm = InferSelectModel; +export type OlmSession = InferSelectModel; export type EmailVerificationCode = InferSelectModel< typeof emailVerificationCodes >; @@ -546,8 +669,13 @@ export type ResourceWhitelist = InferSelectModel; export type VersionMigration = InferSelectModel; export type ResourceRule = InferSelectModel; export type Domain = InferSelectModel; +export type Client = InferSelectModel; +export type ClientSite = InferSelectModel; +export type RoleClient = InferSelectModel; +export type UserClient = InferSelectModel; export type SupporterKey = InferSelectModel; export type Idp = InferSelectModel; export type ApiKey = InferSelectModel; export type ApiKeyAction = InferSelectModel; export type ApiKeyOrg = InferSelectModel; +export type OrgDomains = InferSelectModel; diff --git a/server/emails/index.ts b/server/emails/index.ts index 46d1df69..42cfa39c 100644 --- a/server/emails/index.ts +++ b/server/emails/index.ts @@ -18,10 +18,10 @@ function createEmailClient() { host: emailConfig.smtp_host, port: emailConfig.smtp_port, secure: emailConfig.smtp_secure || false, - auth: { + auth: (emailConfig.smtp_user && emailConfig.smtp_pass) ? { user: emailConfig.smtp_user, pass: emailConfig.smtp_pass - } + } : null } as SMTPTransport.Options; if (emailConfig.smtp_tls_reject_unauthorized !== undefined) { diff --git a/server/emails/sendEmail.ts b/server/emails/sendEmail.ts index d7a59608..9b99d18e 100644 --- a/server/emails/sendEmail.ts +++ b/server/emails/sendEmail.ts @@ -2,6 +2,7 @@ import { render } from "@react-email/render"; import { ReactElement } from "react"; import emailClient from "@server/emails"; import logger from "@server/logger"; +import config from "@server/lib/config"; export async function sendEmail( template: ReactElement, @@ -24,9 +25,11 @@ export async function sendEmail( const emailHtml = await render(template); + const appName = "Pangolin"; + await emailClient.sendMail({ from: { - name: opts.name || "Pangolin", + name: opts.name || appName, address: opts.from, }, to: opts.to, diff --git a/server/emails/templates/NotifyResetPassword.tsx b/server/emails/templates/NotifyResetPassword.tsx index aaa1cbdd..66ea2430 100644 --- a/server/emails/templates/NotifyResetPassword.tsx +++ b/server/emails/templates/NotifyResetPassword.tsx @@ -1,11 +1,5 @@ -import { - Body, - Head, - Html, - Preview, - Tailwind -} from "@react-email/components"; -import * as React from "react"; +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; import { themeColors } from "./lib/theme"; import { EmailContainer, @@ -22,29 +16,29 @@ interface Props { } export const ConfirmPasswordReset = ({ email }: Props) => { - const previewText = `Your password has been reset`; + const previewText = `Your password has been successfully reset.`; return ( {previewText} - + - Password Reset Confirmation + {/* Password Successfully Reset */} - Hi {email || "there"}, + Hi there, - This email confirms that your password has just been - reset. If you made this change, no further action is - required. + Your password has been successfully reset. You can + now sign in to your account using your new password. - Thank you for keeping your account secure. + If you didn't make this change, please contact our + support team immediately to secure your account. diff --git a/server/emails/templates/ResetPasswordCode.tsx b/server/emails/templates/ResetPasswordCode.tsx index 1a79527b..df14b8be 100644 --- a/server/emails/templates/ResetPasswordCode.tsx +++ b/server/emails/templates/ResetPasswordCode.tsx @@ -1,11 +1,5 @@ -import { - Body, - Head, - Html, - Preview, - Tailwind -} from "@react-email/components"; -import * as React from "react"; +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; import { themeColors } from "./lib/theme"; import { EmailContainer, @@ -18,6 +12,7 @@ import { EmailText } from "./components/Email"; import CopyCodeBox from "./components/CopyCodeBox"; +import ButtonLink from "./components/ButtonLink"; interface Props { email: string; @@ -26,37 +21,39 @@ interface Props { } export const ResetPasswordCode = ({ email, code, link }: Props) => { - const previewText = `Your password reset code is ${code}`; + const previewText = `Reset your password with code: ${code}`; return ( {previewText} - + - Password Reset Request + {/* Reset Your Password */} - Hi {email || "there"}, + Hi there, - You’ve requested to reset your password. Please{" "} - - click here - {" "} - and follow the instructions to reset your password, - or manually enter the following code: + You've requested to reset your password. Click the + button below to reset your password, or use the + verification code provided if prompted. + + Reset Password + + - If you didn’t request this, you can safely ignore - this email. + This reset code will expire in 2 hours. If you + didn't request a password reset, you can safely + ignore this email. diff --git a/server/emails/templates/ResourceOTPCode.tsx b/server/emails/templates/ResourceOTPCode.tsx index 086dc444..4f68d9df 100644 --- a/server/emails/templates/ResourceOTPCode.tsx +++ b/server/emails/templates/ResourceOTPCode.tsx @@ -1,11 +1,5 @@ -import { - Body, - Head, - Html, - Preview, - Tailwind -} from "@react-email/components"; -import * as React from "react"; +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; import { EmailContainer, EmailLetterHead, @@ -32,34 +26,40 @@ export const ResourceOTPCode = ({ orgName: organizationName, otp }: ResourceOTPCodeProps) => { - const previewText = `Your one-time password for ${resourceName} is ${otp}`; + const previewText = `Your access code for ${resourceName}: ${otp}`; return ( {previewText} - + - - Your One-Time Code for {resourceName} - + {/* */} + {/* Access Code for {resourceName} */} + {/* */} - Hi {email || "there"}, + Hi there, - You’ve requested a one-time password to access{" "} + You've requested access to{" "} {resourceName} in{" "} - {organizationName}. Use the code - below to complete your authentication: + {organizationName}. Use the + verification code below to complete your + authentication. + + This code will expire in 15 minutes. If you didn't + request this code, please ignore this email. + + diff --git a/server/emails/templates/SendInviteLink.tsx b/server/emails/templates/SendInviteLink.tsx index ed3c7b53..c859d3d7 100644 --- a/server/emails/templates/SendInviteLink.tsx +++ b/server/emails/templates/SendInviteLink.tsx @@ -1,11 +1,5 @@ -import { - Body, - Head, - Html, - Preview, - Tailwind, -} from "@react-email/components"; -import * as React from "react"; +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; import { themeColors } from "./lib/theme"; import { EmailContainer, @@ -41,35 +35,44 @@ export const SendInviteLink = ({ {previewText} - + - Invited to Join {orgName} + {/* */} + {/* You're Invited to Join {orgName} */} + {/* */} - Hi {email || "there"}, + Hi there, - You’ve been invited to join the organization{" "} + You've been invited to join{" "} {orgName} - {inviterName ? ` by ${inviterName}.` : "."} Please - access the link below to accept the invite. - - - - This invite will expire in{" "} - - {expiresInDays}{" "} - {expiresInDays === "1" ? "day" : "days"}. - + {inviterName ? ` by ${inviterName}` : ""}. Click the + button below to accept your invitation and get + started. - Accept Invite to {orgName} + Accept Invitation + {/* */} + {/* If you're having trouble clicking the button, copy */} + {/* and paste the URL below into your web browser: */} + {/*
*/} + {/* {inviteLink} */} + {/*
*/} + + + This invite expires in {expiresInDays}{" "} + {expiresInDays === "1" ? "day" : "days"}. If the + link has expired, please contact the owner of the + organization to request a new invitation. + + diff --git a/server/emails/templates/TwoFactorAuthNotification.tsx b/server/emails/templates/TwoFactorAuthNotification.tsx index 8993a3bd..3261023e 100644 --- a/server/emails/templates/TwoFactorAuthNotification.tsx +++ b/server/emails/templates/TwoFactorAuthNotification.tsx @@ -1,11 +1,5 @@ -import { - Body, - Head, - Html, - Preview, - Tailwind -} from "@react-email/components"; -import * as React from "react"; +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; import { themeColors } from "./lib/theme"; import { EmailContainer, @@ -23,44 +17,52 @@ interface Props { } export const TwoFactorAuthNotification = ({ email, enabled }: Props) => { - const previewText = `Two-Factor Authentication has been ${enabled ? "enabled" : "disabled"}`; + const previewText = `Two-Factor Authentication ${enabled ? "enabled" : "disabled"} for your account`; return ( {previewText} - + - - Two-Factor Authentication{" "} - {enabled ? "Enabled" : "Disabled"} - + {/* */} + {/* Security Update: 2FA{" "} */} + {/* {enabled ? "Enabled" : "Disabled"} */} + {/* */} - Hi {email || "there"}, + Hi there, - This email confirms that Two-Factor Authentication - has been successfully{" "} - {enabled ? "enabled" : "disabled"} on your account. + Two-factor authentication has been successfully{" "} + {enabled ? "enabled" : "disabled"}{" "} + on your account. {enabled ? ( - - With Two-Factor Authentication enabled, your - account is now more secure. Please ensure you - keep your authentication method safe. - + <> + + Your account is now protected with an + additional layer of security. Keep your + authentication method safe and accessible. + + ) : ( - - With Two-Factor Authentication disabled, your - account may be less secure. We recommend - enabling it to protect your account. - + <> + + We recommend re-enabling two-factor + authentication to keep your account secure. + + )} + + If you didn't make this change, please contact our + support team immediately. + + diff --git a/server/emails/templates/VerifyEmailCode.tsx b/server/emails/templates/VerifyEmailCode.tsx index ad0ef053..6a361648 100644 --- a/server/emails/templates/VerifyEmailCode.tsx +++ b/server/emails/templates/VerifyEmailCode.tsx @@ -1,5 +1,5 @@ +import React from "react"; import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; -import * as React from "react"; import { themeColors } from "./lib/theme"; import { EmailContainer, @@ -24,25 +24,24 @@ export const VerifyEmail = ({ verificationCode, verifyLink }: VerifyEmailProps) => { - const previewText = `Your verification code is ${verificationCode}`; + const previewText = `Verify your email with code: ${verificationCode}`; return ( {previewText} - + - Please Verify Your Email + {/* Verify Your Email Address */} - Hi {username || "there"}, + Hi there, - You’ve requested to verify your email. Please use - the code below to complete the verification process - upon logging in. + Welcome! To complete your account setup, please + verify your email address using the code below. @@ -50,7 +49,8 @@ export const VerifyEmail = ({ - If you didn’t request this, you can safely ignore + This verification code will expire in 15 minutes. If + you didn't create an account, you can safely ignore this email. diff --git a/server/emails/templates/WelcomeQuickStart.tsx b/server/emails/templates/WelcomeQuickStart.tsx new file mode 100644 index 00000000..cd18f8b5 --- /dev/null +++ b/server/emails/templates/WelcomeQuickStart.tsx @@ -0,0 +1,131 @@ +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailFooter, + EmailGreeting, + EmailHeading, + EmailLetterHead, + EmailSection, + EmailSignature, + EmailText, + EmailInfoSection +} from "./components/Email"; +import ButtonLink from "./components/ButtonLink"; +import CopyCodeBox from "./components/CopyCodeBox"; + +interface WelcomeQuickStartProps { + username?: string; + link: string; + fallbackLink: string; + resourceMethod: string; + resourceHostname: string; + resourcePort: string | number; + resourceUrl: string; + cliCommand: string; +} + +export const WelcomeQuickStart = ({ + username, + link, + fallbackLink, + resourceMethod, + resourceHostname, + resourcePort, + resourceUrl, + cliCommand +}: WelcomeQuickStartProps) => { + const previewText = "Welcome! Here's what to do next"; + + return ( + + + {previewText} + + + + + + Hi there, + + + Thank you for trying out Pangolin! We're excited to + have you on board. + + + + To continue to configure your site, resources, and + other features, complete your account setup to + access the full dashboard. + + + + + View Your Dashboard + + {/*

*/} + {/* If the button above doesn't work, you can also */} + {/* use this{" "} */} + {/* */} + {/* link */} + {/* */} + {/* . */} + {/*

*/} +
+ + +
+ Connect your site using Newt +
+
+
+ + {cliCommand} + +
+

+ To learn how to use Newt, including more + installation methods, visit the{" "} + + docs + + . +

+
+
+ + + {resourceUrl} + + ) + } + ]} + /> + + + + +
+ +
+ + ); +}; + +export default WelcomeQuickStart; diff --git a/server/emails/templates/components/ButtonLink.tsx b/server/emails/templates/components/ButtonLink.tsx index e32e1810..618fed15 100644 --- a/server/emails/templates/components/ButtonLink.tsx +++ b/server/emails/templates/components/ButtonLink.tsx @@ -12,7 +12,11 @@ export default function ButtonLink({ return ( {children} diff --git a/server/emails/templates/components/CopyCodeBox.tsx b/server/emails/templates/components/CopyCodeBox.tsx index ef48b383..3e4d1d08 100644 --- a/server/emails/templates/components/CopyCodeBox.tsx +++ b/server/emails/templates/components/CopyCodeBox.tsx @@ -2,10 +2,15 @@ import React from "react"; export default function CopyCodeBox({ text }: { text: string }) { return ( -
- - {text} - +
+
+ + {text} + +
+

+ Copy and paste this code when prompted +

); } diff --git a/server/emails/templates/components/Email.tsx b/server/emails/templates/components/Email.tsx index c73e4c85..ef5c37f8 100644 --- a/server/emails/templates/components/Email.tsx +++ b/server/emails/templates/components/Email.tsx @@ -1,47 +1,27 @@ -import { Container } from "@react-email/components"; import React from "react"; +import { Container, Img } from "@react-email/components"; +import { build } from "@server/build"; // EmailContainer: Wraps the entire email layout export function EmailContainer({ children }: { children: React.ReactNode }) { return ( - + {children} ); } -// EmailLetterHead: For branding or logo at the top +// EmailLetterHead: For branding with logo on dark background export function EmailLetterHead() { return ( -
- - - - - -
- Pangolin - - {new Date().getFullYear()} -
+
+ Fossorial
); } @@ -49,14 +29,22 @@ export function EmailLetterHead() { // EmailHeading: For the primary message or headline export function EmailHeading({ children }: { children: React.ReactNode }) { return ( -

- {children} -

+
+

+ {children} +

+
); } export function EmailGreeting({ children }: { children: React.ReactNode }) { - return

{children}

; + return ( +
+

+ {children} +

+
+ ); } // EmailText: For general text content @@ -68,9 +56,13 @@ export function EmailText({ className?: string; }) { return ( -

- {children} -

+
+

+ {children} +

+
); } @@ -82,20 +74,74 @@ export function EmailSection({ children: React.ReactNode; className?: string; }) { - return
{children}
; + return ( +
{children}
+ ); } // EmailFooter: For closing or signature export function EmailFooter({ children }: { children: React.ReactNode }) { - return
{children}
; + return ( + <> + {build === "saas" && ( +
+ {children} +

+ For any questions or support, please contact us at: +
+ support@fossorial.io +

+

+ © {new Date().getFullYear()} Fossorial, Inc. All + rights reserved. +

+
+ )} + + ); } export function EmailSignature() { return ( -

- Best regards, -
- Fossorial -

+
+

+ Best regards, +
+ The Fossorial Team +

+
+ ); +} + +// EmailInfoSection: For structured key-value info (like resource details) +export function EmailInfoSection({ + title, + items +}: { + title?: string; + items: { label: string; value: React.ReactNode }[]; +}) { + return ( +
+ {title && ( +
+ {title} +
+ )} + + + {items.map((item, idx) => ( + + + + + ))} + +
+ {item.label} + + {item.value} +
+
); } diff --git a/server/emails/templates/lib/theme.ts b/server/emails/templates/lib/theme.ts index ada77fd2..a10ff77a 100644 --- a/server/emails/templates/lib/theme.ts +++ b/server/emails/templates/lib/theme.ts @@ -1,3 +1,5 @@ +import React from "react"; + export const themeColors = { theme: { extend: { diff --git a/server/index.ts b/server/index.ts index 4daeb711..d3f90281 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,3 +1,4 @@ +#! /usr/bin/env node import "./extendZod.ts"; import { runSetupFunctions } from "./setup"; @@ -9,6 +10,7 @@ import { createIntegrationApiServer } from "./integrationApiServer"; import config from "@server/lib/config"; async function startServers() { + await config.initServer(); await runSetupFunctions(); // Start all servers @@ -35,7 +37,7 @@ declare global { interface Request { apiKey?: ApiKey; user?: User; - session?: Session; + session: Session; userOrg?: UserOrg; apiKeyOrg?: ApiKeyOrg; userOrgRoleId?: number; diff --git a/server/integrationApiServer.ts b/server/integrationApiServer.ts index f3dfbbef..eefaacd8 100644 --- a/server/integrationApiServer.ts +++ b/server/integrationApiServer.ts @@ -20,8 +20,9 @@ const externalPort = config.getRawConfig().server.integration_port; export function createIntegrationApiServer() { const apiServer = express(); - if (config.getRawConfig().server.trust_proxy) { - apiServer.set("trust proxy", 1); + const trustProxy = config.getRawConfig().server.trust_proxy; + if (trustProxy) { + apiServer.set("trust proxy", trustProxy); } apiServer.use(cors()); diff --git a/server/lib/config.ts b/server/lib/config.ts index f6cd240f..023ae054 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -17,10 +17,6 @@ export class Config { isDev: boolean = process.env.ENVIRONMENT !== "prod"; constructor() { - this.load(); - } - - public load() { const environment = readConfigFile(); const { @@ -34,12 +30,6 @@ export class Config { throw new Error(`Invalid configuration file: ${errors}`); } - if (process.env.APP_BASE_DOMAIN) { - console.log( - "WARNING: You're using deprecated environment variables. Transition to the configuration file. https://docs.fossorial.io/" - ); - } - if ( // @ts-ignore parsedConfig.users || @@ -85,22 +75,37 @@ export class Config { parsedConfig.server.resource_access_token_headers.token; process.env.RESOURCE_SESSION_REQUEST_PARAM = parsedConfig.server.resource_session_request_param; - process.env.FLAGS_ALLOW_BASE_DOMAIN_RESOURCES = parsedConfig.flags - ?.allow_base_domain_resources + process.env.DASHBOARD_URL = parsedConfig.app.dashboard_url; + process.env.FLAGS_DISABLE_LOCAL_SITES = parsedConfig.flags + ?.disable_local_sites + ? "true" + : "false"; + process.env.FLAGS_DISABLE_BASIC_WIREGUARD_SITES = parsedConfig.flags + ?.disable_basic_wireguard_sites ? "true" : "false"; - process.env.DASHBOARD_URL = parsedConfig.app.dashboard_url; - license.setServerSecret(parsedConfig.server.secret); - - this.checkKeyStatus(); + process.env.FLAGS_ENABLE_CLIENTS = parsedConfig.flags?.enable_clients + ? "true" + : "false"; this.rawConfig = parsedConfig; } + public async initServer() { + if (!this.rawConfig) { + throw new Error("Config not loaded. Call load() first."); + } + license.setServerSecret(this.rawConfig.server.secret); + + await this.checkKeyStatus(); + } + private async checkKeyStatus() { const licenseStatus = await license.check(); - if (!licenseStatus.isHostLicensed) { + if ( + !licenseStatus.isHostLicensed + ) { this.checkSupporterKey(); } } @@ -116,6 +121,9 @@ export class Config { } public getDomain(domainId: string) { + if (!this.rawConfig.domains || !this.rawConfig.domains[domainId]) { + return null; + } return this.rawConfig.domains[domainId]; } diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 843ce54f..cfe45620 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.6.0"; +export const APP_VERSION = "1.8.0"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/lib/ip.test.ts b/server/lib/ip.test.ts index 2c2dd057..67a2faaa 100644 --- a/server/lib/ip.test.ts +++ b/server/lib/ip.test.ts @@ -4,7 +4,14 @@ import { assertEquals } from "@test/assert"; // Test cases function testFindNextAvailableCidr() { console.log("Running findNextAvailableCidr tests..."); - + + // Test 0: Basic IPv4 allocation with a subnet in the wrong range + { + const existing = ["100.90.130.1/30", "100.90.128.4/30"]; + const result = findNextAvailableCidr(existing, 30, "100.90.130.1/24"); + assertEquals(result, "100.90.130.4/30", "Basic IPv4 allocation failed"); + } + // Test 1: Basic IPv4 allocation { const existing = ["10.0.0.0/16", "10.1.0.0/16"]; @@ -26,6 +33,12 @@ function testFindNextAvailableCidr() { assertEquals(result, null, "No available space test failed"); } + // Test 4: Empty existing + { + const existing: string[] = []; + const result = findNextAvailableCidr(existing, 30, "10.0.0.0/8"); + assertEquals(result, "10.0.0.0/30", "Empty existing test failed"); + } // // Test 4: IPv6 allocation // { // const existing = ["2001:db8::/32", "2001:db8:1::/32"]; diff --git a/server/lib/ip.ts b/server/lib/ip.ts index fd6f07ab..ad952098 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -1,3 +1,8 @@ +import { db } from "@server/db"; +import { clients, orgs, sites } from "@server/db"; +import { and, eq, isNotNull } from "drizzle-orm"; +import config from "@server/lib/config"; + interface IPRange { start: bigint; end: bigint; @@ -9,7 +14,7 @@ type IPVersion = 4 | 6; * Detects IP version from address string */ function detectIpVersion(ip: string): IPVersion { - return ip.includes(':') ? 6 : 4; + return ip.includes(":") ? 6 : 4; } /** @@ -19,34 +24,34 @@ function ipToBigInt(ip: string): bigint { const version = detectIpVersion(ip); if (version === 4) { - return ip.split('.') - .reduce((acc, octet) => { - const num = parseInt(octet); - if (isNaN(num) || num < 0 || num > 255) { - throw new Error(`Invalid IPv4 octet: ${octet}`); - } - return BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(num)); - }, BigInt(0)); + return ip.split(".").reduce((acc, octet) => { + const num = parseInt(octet); + if (isNaN(num) || num < 0 || num > 255) { + throw new Error(`Invalid IPv4 octet: ${octet}`); + } + return BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(num)); + }, BigInt(0)); } else { // Handle IPv6 // Expand :: notation let fullAddress = ip; - if (ip.includes('::')) { - const parts = ip.split('::'); - if (parts.length > 2) throw new Error('Invalid IPv6 address: multiple :: found'); - const missing = 8 - (parts[0].split(':').length + parts[1].split(':').length); - const padding = Array(missing).fill('0').join(':'); + if (ip.includes("::")) { + const parts = ip.split("::"); + if (parts.length > 2) + throw new Error("Invalid IPv6 address: multiple :: found"); + const missing = + 8 - (parts[0].split(":").length + parts[1].split(":").length); + const padding = Array(missing).fill("0").join(":"); fullAddress = `${parts[0]}:${padding}:${parts[1]}`; } - return fullAddress.split(':') - .reduce((acc, hextet) => { - const num = parseInt(hextet || '0', 16); - if (isNaN(num) || num < 0 || num > 65535) { - throw new Error(`Invalid IPv6 hextet: ${hextet}`); - } - return BigInt.asUintN(128, (acc << BigInt(16)) + BigInt(num)); - }, BigInt(0)); + return fullAddress.split(":").reduce((acc, hextet) => { + const num = parseInt(hextet || "0", 16); + if (isNaN(num) || num < 0 || num > 65535) { + throw new Error(`Invalid IPv6 hextet: ${hextet}`); + } + return BigInt.asUintN(128, (acc << BigInt(16)) + BigInt(num)); + }, BigInt(0)); } } @@ -60,11 +65,15 @@ function bigIntToIp(num: bigint, version: IPVersion): string { octets.unshift(Number(num & BigInt(255))); num = num >> BigInt(8); } - return octets.join('.'); + return octets.join("."); } else { const hextets: string[] = []; for (let i = 0; i < 8; i++) { - hextets.unshift(Number(num & BigInt(65535)).toString(16).padStart(4, '0')); + hextets.unshift( + Number(num & BigInt(65535)) + .toString(16) + .padStart(4, "0") + ); num = num >> BigInt(16); } // Compress zero sequences @@ -74,7 +83,7 @@ function bigIntToIp(num: bigint, version: IPVersion): string { let currentZeroLength = 0; for (let i = 0; i < hextets.length; i++) { - if (hextets[i] === '0000') { + if (hextets[i] === "0000") { if (currentZeroStart === -1) currentZeroStart = i; currentZeroLength++; if (currentZeroLength > maxZeroLength) { @@ -88,12 +97,14 @@ function bigIntToIp(num: bigint, version: IPVersion): string { } if (maxZeroLength > 1) { - hextets.splice(maxZeroStart, maxZeroLength, ''); - if (maxZeroStart === 0) hextets.unshift(''); - if (maxZeroStart + maxZeroLength === 8) hextets.push(''); + hextets.splice(maxZeroStart, maxZeroLength, ""); + if (maxZeroStart === 0) hextets.unshift(""); + if (maxZeroStart + maxZeroLength === 8) hextets.push(""); } - return hextets.map(h => h === '0000' ? '0' : h.replace(/^0+/, '')).join(':'); + return hextets + .map((h) => (h === "0000" ? "0" : h.replace(/^0+/, ""))) + .join(":"); } } @@ -101,7 +112,7 @@ function bigIntToIp(num: bigint, version: IPVersion): string { * Converts CIDR to IP range */ export function cidrToRange(cidr: string): IPRange { - const [ip, prefix] = cidr.split('/'); + const [ip, prefix] = cidr.split("/"); const version = detectIpVersion(ip); const prefixBits = parseInt(prefix); const ipBigInt = ipToBigInt(ip); @@ -113,7 +124,10 @@ export function cidrToRange(cidr: string): IPRange { } const shiftBits = BigInt(maxPrefix - prefixBits); - const mask = BigInt.asUintN(version === 4 ? 64 : 128, (BigInt(1) << shiftBits) - BigInt(1)); + const mask = BigInt.asUintN( + version === 4 ? 64 : 128, + (BigInt(1) << shiftBits) - BigInt(1) + ); const start = ipBigInt & ~mask; const end = start | mask; @@ -132,28 +146,32 @@ export function findNextAvailableCidr( blockSize: number, startCidr?: string ): string | null { - if (!startCidr && existingCidrs.length === 0) { return null; } // If no existing CIDRs, use the IP version from startCidr - const version = startCidr - ? detectIpVersion(startCidr.split('/')[0]) - : 4; // Default to IPv4 if no startCidr provided + const version = startCidr ? detectIpVersion(startCidr.split("/")[0]) : 4; // Default to IPv4 if no startCidr provided // Use appropriate default startCidr if none provided startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0"); // If there are existing CIDRs, ensure all are same version - if (existingCidrs.length > 0 && - existingCidrs.some(cidr => detectIpVersion(cidr.split('/')[0]) !== version)) { - throw new Error('All CIDRs must be of the same IP version'); + if ( + existingCidrs.length > 0 && + existingCidrs.some( + (cidr) => detectIpVersion(cidr.split("/")[0]) !== version + ) + ) { + throw new Error("All CIDRs must be of the same IP version"); } + // Extract the network part from startCidr to ensure we stay in the right subnet + const startCidrRange = cidrToRange(startCidr); + // Convert existing CIDRs to ranges and sort them const existingRanges = existingCidrs - .map(cidr => cidrToRange(cidr)) + .map((cidr) => cidrToRange(cidr)) .sort((a, b) => (a.start < b.start ? -1 : 1)); // Calculate block size @@ -161,14 +179,17 @@ export function findNextAvailableCidr( const blockSizeBigInt = BigInt(1) << BigInt(maxPrefix - blockSize); // Start from the beginning of the given CIDR - let current = cidrToRange(startCidr).start; - const maxIp = cidrToRange(startCidr).end; + let current = startCidrRange.start; + const maxIp = startCidrRange.end; // Iterate through existing ranges for (let i = 0; i <= existingRanges.length; i++) { const nextRange = existingRanges[i]; + // Align current to block size - const alignedCurrent = current + ((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt); + const alignedCurrent = + current + + ((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt); // Check if we've gone beyond the maximum allowed IP if (alignedCurrent + blockSizeBigInt - BigInt(1) > maxIp) { @@ -176,12 +197,18 @@ export function findNextAvailableCidr( } // If we're at the end of existing ranges or found a gap - if (!nextRange || alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start) { + if ( + !nextRange || + alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start + ) { return `${bigIntToIp(alignedCurrent, version)}/${blockSize}`; } - // Move current pointer to after the current range - current = nextRange.end + BigInt(1); + // If next range overlaps with our search space, move past it + if (nextRange.end >= startCidrRange.start && nextRange.start <= maxIp) { + // Move current pointer to after the current range + current = nextRange.end + BigInt(1); + } } return null; @@ -195,7 +222,7 @@ export function findNextAvailableCidr( */ export function isIpInCidr(ip: string, cidr: string): boolean { const ipVersion = detectIpVersion(ip); - const cidrVersion = detectIpVersion(cidr.split('/')[0]); + const cidrVersion = detectIpVersion(cidr.split("/")[0]); // If IP versions don't match, the IP cannot be in the CIDR range if (ipVersion !== cidrVersion) { @@ -207,3 +234,69 @@ export function isIpInCidr(ip: string, cidr: string): boolean { const range = cidrToRange(cidr); return ipBigInt >= range.start && ipBigInt <= range.end; } + +export async function getNextAvailableClientSubnet( + orgId: string +): Promise { + const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); + + if (!org) { + throw new Error(`Organization with ID ${orgId} not found`); + } + + if (!org.subnet) { + throw new Error(`Organization with ID ${orgId} has no subnet defined`); + } + + const existingAddressesSites = await db + .select({ + address: sites.address + }) + .from(sites) + .where(and(isNotNull(sites.address), eq(sites.orgId, orgId))); + + const existingAddressesClients = await db + .select({ + address: clients.subnet + }) + .from(clients) + .where(and(isNotNull(clients.subnet), eq(clients.orgId, orgId))); + + const addresses = [ + ...existingAddressesSites.map( + (site) => `${site.address?.split("/")[0]}/32` + ), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org + ...existingAddressesClients.map( + (client) => `${client.address.split("/")}/32` + ) + ].filter((address) => address !== null) as string[]; + + let subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org + if (!subnet) { + throw new Error("No available subnets remaining in space"); + } + + return subnet; +} + +export async function getNextAvailableOrgSubnet(): Promise { + const existingAddresses = await db + .select({ + subnet: orgs.subnet + }) + .from(orgs) + .where(isNotNull(orgs.subnet)); + + const addresses = existingAddresses.map((org) => org.subnet!); + + let subnet = findNextAvailableCidr( + addresses, + config.getRawConfig().orgs.block_size, + config.getRawConfig().orgs.subnet_group + ); + if (!subnet) { + throw new Error("No available subnets remaining in space"); + } + + return subnet; +} diff --git a/server/lib/rateLimitStore.ts b/server/lib/rateLimitStore.ts new file mode 100644 index 00000000..2f6dc675 --- /dev/null +++ b/server/lib/rateLimitStore.ts @@ -0,0 +1,6 @@ +import { MemoryStore, Store } from "express-rate-limit"; + +export function createStore(): Store { + let rateLimitStore: Store = new MemoryStore(); + return rateLimitStore; +} diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 0058127f..1bc119fa 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -3,8 +3,7 @@ import yaml from "js-yaml"; import { configFilePath1, configFilePath2 } from "./consts"; import { z } from "zod"; import stoi from "./stoi"; -import { passwordSchema } from "@server/auth/passwordSchema"; -import { fromError } from "zod-validation-error"; +import { build } from "@server/build"; const portSchema = z.number().positive().gt(0).lte(65535); @@ -12,203 +11,256 @@ const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => { return process.env[envVar] ?? valFromYaml; }; -export const configSchema = z.object({ - app: z.object({ - dashboard_url: z - .string() - .url() - .optional() - .pipe(z.string().url()) - .transform((url) => url.toLowerCase()), - log_level: z - .enum(["debug", "info", "warn", "error"]) - .optional() - .default("info"), - save_logs: z.boolean().optional().default(false), - log_failed_attempts: z.boolean().optional().default(false) - }), - domains: z - .record( - z.string(), - z.object({ - base_domain: z - .string() - .nonempty("base_domain must not be empty") - .transform((url) => url.toLowerCase()), - cert_resolver: z.string().optional().default("letsencrypt"), - prefer_wildcard_cert: z.boolean().optional().default(false) - }) - ) - .refine( - (domains) => { - const keys = Object.keys(domains); - - if (keys.length === 0) { - return false; - } - - return true; - }, - { - message: "At least one domain must be defined" - } - ), - server: z.object({ - integration_port: portSchema - .optional() - .default(3003) - .transform(stoi) - .pipe(portSchema.optional()), - external_port: portSchema - .optional() - .default(3000) - .transform(stoi) - .pipe(portSchema), - internal_port: portSchema - .optional() - .default(3001) - .transform(stoi) - .pipe(portSchema), - next_port: portSchema - .optional() - .default(3002) - .transform(stoi) - .pipe(portSchema), - internal_hostname: z - .string() - .optional() - .default("pangolin") - .transform((url) => url.toLowerCase()), - session_cookie_name: z.string().optional().default("p_session_token"), - resource_access_token_param: z.string().optional().default("p_token"), - resource_access_token_headers: z - .object({ - id: z.string().optional().default("P-Access-Token-Id"), - token: z.string().optional().default("P-Access-Token") - }) - .optional() - .default({}), - resource_session_request_param: z - .string() - .optional() - .default("resource_session_request_param"), - dashboard_session_length_hours: z - .number() - .positive() - .gt(0) - .optional() - .default(720), - resource_session_length_hours: z - .number() - .positive() - .gt(0) - .optional() - .default(720), - cors: z - .object({ - origins: z.array(z.string()).optional(), - methods: z.array(z.string()).optional(), - allowed_headers: z.array(z.string()).optional(), - credentials: z.boolean().optional() - }) +export const configSchema = z + .object({ + app: z.object({ + dashboard_url: z + .string() + .url() + .optional() + .pipe(z.string().url()) + .transform((url) => url.toLowerCase()), + log_level: z + .enum(["debug", "info", "warn", "error"]) + .optional() + .default("info"), + save_logs: z.boolean().optional().default(false), + log_failed_attempts: z.boolean().optional().default(false) + }), + domains: z + .record( + z.string(), + z.object({ + base_domain: z + .string() + .nonempty("base_domain must not be empty") + .transform((url) => url.toLowerCase()), + cert_resolver: z.string().optional().default("letsencrypt"), + prefer_wildcard_cert: z.boolean().optional().default(false) + }) + ) .optional(), - trust_proxy: z.number().int().gte(0).optional().default(1), - secret: z - .string() - .optional() - .transform(getEnvOrYaml("SERVER_SECRET")) - .pipe(z.string().min(8)) - }), - postgres: z - .object({ - connection_string: z.string(), - replicas: z - .array( - z.object({ - connection_string: z.string() - }) - ) + server: z.object({ + integration_port: portSchema .optional() - }) - .optional(), - traefik: z - .object({ - http_entrypoint: z.string().optional().default("web"), - https_entrypoint: z.string().optional().default("websecure"), - additional_middlewares: z.array(z.string()).optional() - }) - .optional() - .default({}), - gerbil: z - .object({ - start_port: portSchema + .default(3003) + .transform(stoi) + .pipe(portSchema.optional()), + external_port: portSchema .optional() - .default(51820) + .default(3000) .transform(stoi) .pipe(portSchema), - base_endpoint: z + internal_port: portSchema + .optional() + .default(3001) + .transform(stoi) + .pipe(portSchema), + next_port: portSchema + .optional() + .default(3002) + .transform(stoi) + .pipe(portSchema), + internal_hostname: z .string() .optional() - .pipe(z.string()) + .default("pangolin") .transform((url) => url.toLowerCase()), - use_subdomain: z.boolean().optional().default(false), - subnet_group: z.string().optional().default("100.89.137.0/20"), - block_size: z.number().positive().gt(0).optional().default(24), - site_block_size: z.number().positive().gt(0).optional().default(30) - }) - .optional() - .default({}), - rate_limits: z - .object({ - global: z + session_cookie_name: z + .string() + .optional() + .default("p_session_token"), + resource_access_token_param: z + .string() + .optional() + .default("p_token"), + resource_access_token_headers: z .object({ - window_minutes: z - .number() - .positive() - .gt(0) - .optional() - .default(1), - max_requests: z - .number() - .positive() - .gt(0) - .optional() - .default(500) + id: z.string().optional().default("P-Access-Token-Id"), + token: z.string().optional().default("P-Access-Token") }) .optional() .default({}), - auth: z - .object({ - window_minutes: z.number().positive().gt(0), - max_requests: z.number().positive().gt(0) - }) + resource_session_request_param: z + .string() .optional() - }) - .optional() - .default({}), - email: z - .object({ - smtp_host: z.string().optional(), - smtp_port: portSchema.optional(), - smtp_user: z.string().optional(), - smtp_pass: z.string().optional(), - smtp_secure: z.boolean().optional(), - smtp_tls_reject_unauthorized: z.boolean().optional(), - no_reply: z.string().email().optional() - }) - .optional(), - flags: z - .object({ - require_email_verification: z.boolean().optional(), - disable_signup_without_invite: z.boolean().optional(), - disable_user_create_org: z.boolean().optional(), - allow_raw_resources: z.boolean().optional(), - allow_base_domain_resources: z.boolean().optional(), - allow_local_sites: z.boolean().optional(), - enable_integration_api: z.boolean().optional() - }) - .optional() -}); + .default("resource_session_request_param"), + dashboard_session_length_hours: z + .number() + .positive() + .gt(0) + .optional() + .default(720), + resource_session_length_hours: z + .number() + .positive() + .gt(0) + .optional() + .default(720), + cors: z + .object({ + origins: z.array(z.string()).optional(), + methods: z.array(z.string()).optional(), + allowed_headers: z.array(z.string()).optional(), + credentials: z.boolean().optional() + }) + .optional(), + trust_proxy: z.number().int().gte(0).optional().default(1), + secret: z + .string() + .optional() + .transform(getEnvOrYaml("SERVER_SECRET")) + .pipe(z.string().min(8)) + }), + postgres: z + .object({ + connection_string: z.string(), + replicas: z + .array( + z.object({ + connection_string: z.string() + }) + ) + .optional() + }) + .optional(), + traefik: z + .object({ + http_entrypoint: z.string().optional().default("web"), + https_entrypoint: z.string().optional().default("websecure"), + additional_middlewares: z.array(z.string()).optional(), + cert_resolver: z.string().optional().default("letsencrypt"), + prefer_wildcard_cert: z.boolean().optional().default(false) + }) + .optional() + .default({}), + gerbil: z + .object({ + exit_node_name: z.string().optional(), + start_port: portSchema + .optional() + .default(51820) + .transform(stoi) + .pipe(portSchema), + base_endpoint: z + .string() + .optional() + .pipe(z.string()) + .transform((url) => url.toLowerCase()), + use_subdomain: z.boolean().optional().default(false), + subnet_group: z.string().optional().default("100.89.137.0/20"), + block_size: z.number().positive().gt(0).optional().default(24), + site_block_size: z + .number() + .positive() + .gt(0) + .optional() + .default(30) + }) + .optional() + .default({}), + orgs: z + .object({ + block_size: z.number().positive().gt(0).optional().default(24), + subnet_group: z.string().optional().default("100.90.128.0/24") + }) + .optional() + .default({ + block_size: 24, + subnet_group: "100.90.128.0/24" + }), + rate_limits: z + .object({ + global: z + .object({ + window_minutes: z + .number() + .positive() + .gt(0) + .optional() + .default(1), + max_requests: z + .number() + .positive() + .gt(0) + .optional() + .default(500) + }) + .optional() + .default({}), + auth: z + .object({ + window_minutes: z + .number() + .positive() + .gt(0) + .optional() + .default(1), + max_requests: z + .number() + .positive() + .gt(0) + .optional() + .default(500) + }) + .optional() + .default({}) + }) + .optional() + .default({}), + email: z + .object({ + smtp_host: z.string().optional(), + smtp_port: portSchema.optional(), + smtp_user: z.string().optional(), + smtp_pass: z.string().optional().transform(getEnvOrYaml("EMAIL_SMTP_PASS")), + smtp_secure: z.boolean().optional(), + smtp_tls_reject_unauthorized: z.boolean().optional(), + no_reply: z.string().email().optional() + }) + .optional(), + flags: z + .object({ + require_email_verification: z.boolean().optional(), + disable_signup_without_invite: z.boolean().optional(), + disable_user_create_org: z.boolean().optional(), + allow_raw_resources: z.boolean().optional(), + enable_integration_api: z.boolean().optional(), + disable_local_sites: z.boolean().optional(), + disable_basic_wireguard_sites: z.boolean().optional(), + disable_config_managed_domains: z.boolean().optional(), + enable_clients: z.boolean().optional().default(true), + }) + .optional(), + dns: z + .object({ + nameservers: z + .array(z.string().optional().optional()) + .optional() + .default(["ns1.fossorial.io", "ns2.fossorial.io"]), + cname_extension: z.string().optional().default("fossorial.io") + }) + .optional() + .default({ + nameservers: ["ns1.fossorial.io", "ns2.fossorial.io"], + cname_extension: "fossorial.io" + }) + }) + .refine( + (data) => { + const keys = Object.keys(data.domains || {}); + if (data.flags?.disable_config_managed_domains) { + return true; + } + if (keys.length === 0) { + return false; + } + return true; + }, + { + message: "At least one domain must be defined" + } + ); export function readConfigFile() { const loadConfig = (configPath: string) => { @@ -235,7 +287,7 @@ export function readConfigFile() { if (!environment) { throw new Error( - "No configuration file found. Please create one. https://docs.fossorial.io/" + "No configuration file found. Please create one. https://docs.digpangolin.com/self-host/advanced/config-file" ); } diff --git a/server/lib/totp.ts b/server/lib/totp.ts new file mode 100644 index 00000000..d9f819ab --- /dev/null +++ b/server/lib/totp.ts @@ -0,0 +1,10 @@ +import { alphabet, generateRandomString } from "oslo/crypto"; + +export async function generateBackupCodes(): Promise { + const codes = []; + for (let i = 0; i < 10; i++) { + const code = generateRandomString(6, alphabet("0-9", "A-Z", "a-z")); + codes.push(code); + } + return codes; +} diff --git a/server/lib/validators.ts b/server/lib/validators.ts index 50ff5674..6c581e47 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -93,3 +93,1482 @@ export function isTargetValid(value: string | undefined) { return DOMAIN_REGEX.test(value); } + +export function isValidDomain(domain: string): boolean { + // Check overall length + if (domain.length > 253) return false; + + // Check for invalid characters or patterns + if ( + domain.startsWith(".") || + domain.endsWith(".") || + domain.includes("..") + ) { + return false; + } + + const labels = domain.split("."); + + // Must have at least 2 labels (domain + TLD) + if (labels.length < 2) return false; + + // Validate each label + for (const label of labels) { + if (label.length === 0 || label.length > 63) return false; + if (label.startsWith("-") || label.endsWith("-")) return false; + if (!/^[a-zA-Z0-9-]+$/.test(label)) return false; + } + + // TLD should be at least 2 characters and contain only letters + const tld = labels[labels.length - 1]; + if (tld.length < 2 || !/^[a-zA-Z]+$/.test(tld)) return false; + + // Check if TLD is in the list of valid TLDs + if (!validTlds.includes(tld.toUpperCase())) return false; + + return true; +} + +const validTlds = [ + "AAA", + "AARP", + "ABB", + "ABBOTT", + "ABBVIE", + "ABC", + "ABLE", + "ABOGADO", + "ABUDHABI", + "AC", + "ACADEMY", + "ACCENTURE", + "ACCOUNTANT", + "ACCOUNTANTS", + "ACO", + "ACTOR", + "AD", + "ADS", + "ADULT", + "AE", + "AEG", + "AERO", + "AETNA", + "AF", + "AFL", + "AFRICA", + "AG", + "AGAKHAN", + "AGENCY", + "AI", + "AIG", + "AIRBUS", + "AIRFORCE", + "AIRTEL", + "AKDN", + "AL", + "ALIBABA", + "ALIPAY", + "ALLFINANZ", + "ALLSTATE", + "ALLY", + "ALSACE", + "ALSTOM", + "AM", + "AMAZON", + "AMERICANEXPRESS", + "AMERICANFAMILY", + "AMEX", + "AMFAM", + "AMICA", + "AMSTERDAM", + "ANALYTICS", + "ANDROID", + "ANQUAN", + "ANZ", + "AO", + "AOL", + "APARTMENTS", + "APP", + "APPLE", + "AQ", + "AQUARELLE", + "AR", + "ARAB", + "ARAMCO", + "ARCHI", + "ARMY", + "ARPA", + "ART", + "ARTE", + "AS", + "ASDA", + "ASIA", + "ASSOCIATES", + "AT", + "ATHLETA", + "ATTORNEY", + "AU", + "AUCTION", + "AUDI", + "AUDIBLE", + "AUDIO", + "AUSPOST", + "AUTHOR", + "AUTO", + "AUTOS", + "AW", + "AWS", + "AX", + "AXA", + "AZ", + "AZURE", + "BA", + "BABY", + "BAIDU", + "BANAMEX", + "BAND", + "BANK", + "BAR", + "BARCELONA", + "BARCLAYCARD", + "BARCLAYS", + "BAREFOOT", + "BARGAINS", + "BASEBALL", + "BASKETBALL", + "BAUHAUS", + "BAYERN", + "BB", + "BBC", + "BBT", + "BBVA", + "BCG", + "BCN", + "BD", + "BE", + "BEATS", + "BEAUTY", + "BEER", + "BERLIN", + "BEST", + "BESTBUY", + "BET", + "BF", + "BG", + "BH", + "BHARTI", + "BI", + "BIBLE", + "BID", + "BIKE", + "BING", + "BINGO", + "BIO", + "BIZ", + "BJ", + "BLACK", + "BLACKFRIDAY", + "BLOCKBUSTER", + "BLOG", + "BLOOMBERG", + "BLUE", + "BM", + "BMS", + "BMW", + "BN", + "BNPPARIBAS", + "BO", + "BOATS", + "BOEHRINGER", + "BOFA", + "BOM", + "BOND", + "BOO", + "BOOK", + "BOOKING", + "BOSCH", + "BOSTIK", + "BOSTON", + "BOT", + "BOUTIQUE", + "BOX", + "BR", + "BRADESCO", + "BRIDGESTONE", + "BROADWAY", + "BROKER", + "BROTHER", + "BRUSSELS", + "BS", + "BT", + "BUILD", + "BUILDERS", + "BUSINESS", + "BUY", + "BUZZ", + "BV", + "BW", + "BY", + "BZ", + "BZH", + "CA", + "CAB", + "CAFE", + "CAL", + "CALL", + "CALVINKLEIN", + "CAM", + "CAMERA", + "CAMP", + "CANON", + "CAPETOWN", + "CAPITAL", + "CAPITALONE", + "CAR", + "CARAVAN", + "CARDS", + "CARE", + "CAREER", + "CAREERS", + "CARS", + "CASA", + "CASE", + "CASH", + "CASINO", + "CAT", + "CATERING", + "CATHOLIC", + "CBA", + "CBN", + "CBRE", + "CC", + "CD", + "CENTER", + "CEO", + "CERN", + "CF", + "CFA", + "CFD", + "CG", + "CH", + "CHANEL", + "CHANNEL", + "CHARITY", + "CHASE", + "CHAT", + "CHEAP", + "CHINTAI", + "CHRISTMAS", + "CHROME", + "CHURCH", + "CI", + "CIPRIANI", + "CIRCLE", + "CISCO", + "CITADEL", + "CITI", + "CITIC", + "CITY", + "CK", + "CL", + "CLAIMS", + "CLEANING", + "CLICK", + "CLINIC", + "CLINIQUE", + "CLOTHING", + "CLOUD", + "CLUB", + "CLUBMED", + "CM", + "CN", + "CO", + "COACH", + "CODES", + "COFFEE", + "COLLEGE", + "COLOGNE", + "COM", + "COMMBANK", + "COMMUNITY", + "COMPANY", + "COMPARE", + "COMPUTER", + "COMSEC", + "CONDOS", + "CONSTRUCTION", + "CONSULTING", + "CONTACT", + "CONTRACTORS", + "COOKING", + "COOL", + "COOP", + "CORSICA", + "COUNTRY", + "COUPON", + "COUPONS", + "COURSES", + "CPA", + "CR", + "CREDIT", + "CREDITCARD", + "CREDITUNION", + "CRICKET", + "CROWN", + "CRS", + "CRUISE", + "CRUISES", + "CU", + "CUISINELLA", + "CV", + "CW", + "CX", + "CY", + "CYMRU", + "CYOU", + "CZ", + "DAD", + "DANCE", + "DATA", + "DATE", + "DATING", + "DATSUN", + "DAY", + "DCLK", + "DDS", + "DE", + "DEAL", + "DEALER", + "DEALS", + "DEGREE", + "DELIVERY", + "DELL", + "DELOITTE", + "DELTA", + "DEMOCRAT", + "DENTAL", + "DENTIST", + "DESI", + "DESIGN", + "DEV", + "DHL", + "DIAMONDS", + "DIET", + "DIGITAL", + "DIRECT", + "DIRECTORY", + "DISCOUNT", + "DISCOVER", + "DISH", + "DIY", + "DJ", + "DK", + "DM", + "DNP", + "DO", + "DOCS", + "DOCTOR", + "DOG", + "DOMAINS", + "DOT", + "DOWNLOAD", + "DRIVE", + "DTV", + "DUBAI", + "DUNLOP", + "DUPONT", + "DURBAN", + "DVAG", + "DVR", + "DZ", + "EARTH", + "EAT", + "EC", + "ECO", + "EDEKA", + "EDU", + "EDUCATION", + "EE", + "EG", + "EMAIL", + "EMERCK", + "ENERGY", + "ENGINEER", + "ENGINEERING", + "ENTERPRISES", + "EPSON", + "EQUIPMENT", + "ER", + "ERICSSON", + "ERNI", + "ES", + "ESQ", + "ESTATE", + "ET", + "EU", + "EUROVISION", + "EUS", + "EVENTS", + "EXCHANGE", + "EXPERT", + "EXPOSED", + "EXPRESS", + "EXTRASPACE", + "FAGE", + "FAIL", + "FAIRWINDS", + "FAITH", + "FAMILY", + "FAN", + "FANS", + "FARM", + "FARMERS", + "FASHION", + "FAST", + "FEDEX", + "FEEDBACK", + "FERRARI", + "FERRERO", + "FI", + "FIDELITY", + "FIDO", + "FILM", + "FINAL", + "FINANCE", + "FINANCIAL", + "FIRE", + "FIRESTONE", + "FIRMDALE", + "FISH", + "FISHING", + "FIT", + "FITNESS", + "FJ", + "FK", + "FLICKR", + "FLIGHTS", + "FLIR", + "FLORIST", + "FLOWERS", + "FLY", + "FM", + "FO", + "FOO", + "FOOD", + "FOOTBALL", + "FORD", + "FOREX", + "FORSALE", + "FORUM", + "FOUNDATION", + "FOX", + "FR", + "FREE", + "FRESENIUS", + "FRL", + "FROGANS", + "FRONTIER", + "FTR", + "FUJITSU", + "FUN", + "FUND", + "FURNITURE", + "FUTBOL", + "FYI", + "GA", + "GAL", + "GALLERY", + "GALLO", + "GALLUP", + "GAME", + "GAMES", + "GAP", + "GARDEN", + "GAY", + "GB", + "GBIZ", + "GD", + "GDN", + "GE", + "GEA", + "GENT", + "GENTING", + "GEORGE", + "GF", + "GG", + "GGEE", + "GH", + "GI", + "GIFT", + "GIFTS", + "GIVES", + "GIVING", + "GL", + "GLASS", + "GLE", + "GLOBAL", + "GLOBO", + "GM", + "GMAIL", + "GMBH", + "GMO", + "GMX", + "GN", + "GODADDY", + "GOLD", + "GOLDPOINT", + "GOLF", + "GOO", + "GOODYEAR", + "GOOG", + "GOOGLE", + "GOP", + "GOT", + "GOV", + "GP", + "GQ", + "GR", + "GRAINGER", + "GRAPHICS", + "GRATIS", + "GREEN", + "GRIPE", + "GROCERY", + "GROUP", + "GS", + "GT", + "GU", + "GUCCI", + "GUGE", + "GUIDE", + "GUITARS", + "GURU", + "GW", + "GY", + "HAIR", + "HAMBURG", + "HANGOUT", + "HAUS", + "HBO", + "HDFC", + "HDFCBANK", + "HEALTH", + "HEALTHCARE", + "HELP", + "HELSINKI", + "HERE", + "HERMES", + "HIPHOP", + "HISAMITSU", + "HITACHI", + "HIV", + "HK", + "HKT", + "HM", + "HN", + "HOCKEY", + "HOLDINGS", + "HOLIDAY", + "HOMEDEPOT", + "HOMEGOODS", + "HOMES", + "HOMESENSE", + "HONDA", + "HORSE", + "HOSPITAL", + "HOST", + "HOSTING", + "HOT", + "HOTELS", + "HOTMAIL", + "HOUSE", + "HOW", + "HR", + "HSBC", + "HT", + "HU", + "HUGHES", + "HYATT", + "HYUNDAI", + "IBM", + "ICBC", + "ICE", + "ICU", + "ID", + "IE", + "IEEE", + "IFM", + "IKANO", + "IL", + "IM", + "IMAMAT", + "IMDB", + "IMMO", + "IMMOBILIEN", + "IN", + "INC", + "INDUSTRIES", + "INFINITI", + "INFO", + "ING", + "INK", + "INSTITUTE", + "INSURANCE", + "INSURE", + "INT", + "INTERNATIONAL", + "INTUIT", + "INVESTMENTS", + "IO", + "IPIRANGA", + "IQ", + "IR", + "IRISH", + "IS", + "ISMAILI", + "IST", + "ISTANBUL", + "IT", + "ITAU", + "ITV", + "JAGUAR", + "JAVA", + "JCB", + "JE", + "JEEP", + "JETZT", + "JEWELRY", + "JIO", + "JLL", + "JM", + "JMP", + "JNJ", + "JO", + "JOBS", + "JOBURG", + "JOT", + "JOY", + "JP", + "JPMORGAN", + "JPRS", + "JUEGOS", + "JUNIPER", + "KAUFEN", + "KDDI", + "KE", + "KERRYHOTELS", + "KERRYPROPERTIES", + "KFH", + "KG", + "KH", + "KI", + "KIA", + "KIDS", + "KIM", + "KINDLE", + "KITCHEN", + "KIWI", + "KM", + "KN", + "KOELN", + "KOMATSU", + "KOSHER", + "KP", + "KPMG", + "KPN", + "KR", + "KRD", + "KRED", + "KUOKGROUP", + "KW", + "KY", + "KYOTO", + "KZ", + "LA", + "LACAIXA", + "LAMBORGHINI", + "LAMER", + "LAND", + "LANDROVER", + "LANXESS", + "LASALLE", + "LAT", + "LATINO", + "LATROBE", + "LAW", + "LAWYER", + "LB", + "LC", + "LDS", + "LEASE", + "LECLERC", + "LEFRAK", + "LEGAL", + "LEGO", + "LEXUS", + "LGBT", + "LI", + "LIDL", + "LIFE", + "LIFEINSURANCE", + "LIFESTYLE", + "LIGHTING", + "LIKE", + "LILLY", + "LIMITED", + "LIMO", + "LINCOLN", + "LINK", + "LIVE", + "LIVING", + "LK", + "LLC", + "LLP", + "LOAN", + "LOANS", + "LOCKER", + "LOCUS", + "LOL", + "LONDON", + "LOTTE", + "LOTTO", + "LOVE", + "LPL", + "LPLFINANCIAL", + "LR", + "LS", + "LT", + "LTD", + "LTDA", + "LU", + "LUNDBECK", + "LUXE", + "LUXURY", + "LV", + "LY", + "MA", + "MADRID", + "MAIF", + "MAISON", + "MAKEUP", + "MAN", + "MANAGEMENT", + "MANGO", + "MAP", + "MARKET", + "MARKETING", + "MARKETS", + "MARRIOTT", + "MARSHALLS", + "MATTEL", + "MBA", + "MC", + "MCKINSEY", + "MD", + "ME", + "MED", + "MEDIA", + "MEET", + "MELBOURNE", + "MEME", + "MEMORIAL", + "MEN", + "MENU", + "MERCKMSD", + "MG", + "MH", + "MIAMI", + "MICROSOFT", + "MIL", + "MINI", + "MINT", + "MIT", + "MITSUBISHI", + "MK", + "ML", + "MLB", + "MLS", + "MM", + "MMA", + "MN", + "MO", + "MOBI", + "MOBILE", + "MODA", + "MOE", + "MOI", + "MOM", + "MONASH", + "MONEY", + "MONSTER", + "MORMON", + "MORTGAGE", + "MOSCOW", + "MOTO", + "MOTORCYCLES", + "MOV", + "MOVIE", + "MP", + "MQ", + "MR", + "MS", + "MSD", + "MT", + "MTN", + "MTR", + "MU", + "MUSEUM", + "MUSIC", + "MV", + "MW", + "MX", + "MY", + "MZ", + "NA", + "NAB", + "NAGOYA", + "NAME", + "NAVY", + "NBA", + "NC", + "NE", + "NEC", + "NET", + "NETBANK", + "NETFLIX", + "NETWORK", + "NEUSTAR", + "NEW", + "NEWS", + "NEXT", + "NEXTDIRECT", + "NEXUS", + "NF", + "NFL", + "NG", + "NGO", + "NHK", + "NI", + "NICO", + "NIKE", + "NIKON", + "NINJA", + "NISSAN", + "NISSAY", + "NL", + "NO", + "NOKIA", + "NORTON", + "NOW", + "NOWRUZ", + "NOWTV", + "NP", + "NR", + "NRA", + "NRW", + "NTT", + "NU", + "NYC", + "NZ", + "OBI", + "OBSERVER", + "OFFICE", + "OKINAWA", + "OLAYAN", + "OLAYANGROUP", + "OLLO", + "OM", + "OMEGA", + "ONE", + "ONG", + "ONL", + "ONLINE", + "OOO", + "OPEN", + "ORACLE", + "ORANGE", + "ORG", + "ORGANIC", + "ORIGINS", + "OSAKA", + "OTSUKA", + "OTT", + "OVH", + "PA", + "PAGE", + "PANASONIC", + "PARIS", + "PARS", + "PARTNERS", + "PARTS", + "PARTY", + "PAY", + "PCCW", + "PE", + "PET", + "PF", + "PFIZER", + "PG", + "PH", + "PHARMACY", + "PHD", + "PHILIPS", + "PHONE", + "PHOTO", + "PHOTOGRAPHY", + "PHOTOS", + "PHYSIO", + "PICS", + "PICTET", + "PICTURES", + "PID", + "PIN", + "PING", + "PINK", + "PIONEER", + "PIZZA", + "PK", + "PL", + "PLACE", + "PLAY", + "PLAYSTATION", + "PLUMBING", + "PLUS", + "PM", + "PN", + "PNC", + "POHL", + "POKER", + "POLITIE", + "PORN", + "POST", + "PR", + "PRAXI", + "PRESS", + "PRIME", + "PRO", + "PROD", + "PRODUCTIONS", + "PROF", + "PROGRESSIVE", + "PROMO", + "PROPERTIES", + "PROPERTY", + "PROTECTION", + "PRU", + "PRUDENTIAL", + "PS", + "PT", + "PUB", + "PW", + "PWC", + "PY", + "QA", + "QPON", + "QUEBEC", + "QUEST", + "RACING", + "RADIO", + "RE", + "READ", + "REALESTATE", + "REALTOR", + "REALTY", + "RECIPES", + "RED", + "REDSTONE", + "REDUMBRELLA", + "REHAB", + "REISE", + "REISEN", + "REIT", + "RELIANCE", + "REN", + "RENT", + "RENTALS", + "REPAIR", + "REPORT", + "REPUBLICAN", + "REST", + "RESTAURANT", + "REVIEW", + "REVIEWS", + "REXROTH", + "RICH", + "RICHARDLI", + "RICOH", + "RIL", + "RIO", + "RIP", + "RO", + "ROCKS", + "RODEO", + "ROGERS", + "ROOM", + "RS", + "RSVP", + "RU", + "RUGBY", + "RUHR", + "RUN", + "RW", + "RWE", + "RYUKYU", + "SA", + "SAARLAND", + "SAFE", + "SAFETY", + "SAKURA", + "SALE", + "SALON", + "SAMSCLUB", + "SAMSUNG", + "SANDVIK", + "SANDVIKCOROMANT", + "SANOFI", + "SAP", + "SARL", + "SAS", + "SAVE", + "SAXO", + "SB", + "SBI", + "SBS", + "SC", + "SCB", + "SCHAEFFLER", + "SCHMIDT", + "SCHOLARSHIPS", + "SCHOOL", + "SCHULE", + "SCHWARZ", + "SCIENCE", + "SCOT", + "SD", + "SE", + "SEARCH", + "SEAT", + "SECURE", + "SECURITY", + "SEEK", + "SELECT", + "SENER", + "SERVICES", + "SEVEN", + "SEW", + "SEX", + "SEXY", + "SFR", + "SG", + "SH", + "SHANGRILA", + "SHARP", + "SHELL", + "SHIA", + "SHIKSHA", + "SHOES", + "SHOP", + "SHOPPING", + "SHOUJI", + "SHOW", + "SI", + "SILK", + "SINA", + "SINGLES", + "SITE", + "SJ", + "SK", + "SKI", + "SKIN", + "SKY", + "SKYPE", + "SL", + "SLING", + "SM", + "SMART", + "SMILE", + "SN", + "SNCF", + "SO", + "SOCCER", + "SOCIAL", + "SOFTBANK", + "SOFTWARE", + "SOHU", + "SOLAR", + "SOLUTIONS", + "SONG", + "SONY", + "SOY", + "SPA", + "SPACE", + "SPORT", + "SPOT", + "SR", + "SRL", + "SS", + "ST", + "STADA", + "STAPLES", + "STAR", + "STATEBANK", + "STATEFARM", + "STC", + "STCGROUP", + "STOCKHOLM", + "STORAGE", + "STORE", + "STREAM", + "STUDIO", + "STUDY", + "STYLE", + "SU", + "SUCKS", + "SUPPLIES", + "SUPPLY", + "SUPPORT", + "SURF", + "SURGERY", + "SUZUKI", + "SV", + "SWATCH", + "SWISS", + "SX", + "SY", + "SYDNEY", + "SYSTEMS", + "SZ", + "TAB", + "TAIPEI", + "TALK", + "TAOBAO", + "TARGET", + "TATAMOTORS", + "TATAR", + "TATTOO", + "TAX", + "TAXI", + "TC", + "TCI", + "TD", + "TDK", + "TEAM", + "TECH", + "TECHNOLOGY", + "TEL", + "TEMASEK", + "TENNIS", + "TEVA", + "TF", + "TG", + "TH", + "THD", + "THEATER", + "THEATRE", + "TIAA", + "TICKETS", + "TIENDA", + "TIPS", + "TIRES", + "TIROL", + "TJ", + "TJMAXX", + "TJX", + "TK", + "TKMAXX", + "TL", + "TM", + "TMALL", + "TN", + "TO", + "TODAY", + "TOKYO", + "TOOLS", + "TOP", + "TORAY", + "TOSHIBA", + "TOTAL", + "TOURS", + "TOWN", + "TOYOTA", + "TOYS", + "TR", + "TRADE", + "TRADING", + "TRAINING", + "TRAVEL", + "TRAVELERS", + "TRAVELERSINSURANCE", + "TRUST", + "TRV", + "TT", + "TUBE", + "TUI", + "TUNES", + "TUSHU", + "TV", + "TVS", + "TW", + "TZ", + "UA", + "UBANK", + "UBS", + "UG", + "UK", + "UNICOM", + "UNIVERSITY", + "UNO", + "UOL", + "UPS", + "US", + "UY", + "UZ", + "VA", + "VACATIONS", + "VANA", + "VANGUARD", + "VC", + "VE", + "VEGAS", + "VENTURES", + "VERISIGN", + "VERSICHERUNG", + "VET", + "VG", + "VI", + "VIAJES", + "VIDEO", + "VIG", + "VIKING", + "VILLAS", + "VIN", + "VIP", + "VIRGIN", + "VISA", + "VISION", + "VIVA", + "VIVO", + "VLAANDEREN", + "VN", + "VODKA", + "VOLVO", + "VOTE", + "VOTING", + "VOTO", + "VOYAGE", + "VU", + "WALES", + "WALMART", + "WALTER", + "WANG", + "WANGGOU", + "WATCH", + "WATCHES", + "WEATHER", + "WEATHERCHANNEL", + "WEBCAM", + "WEBER", + "WEBSITE", + "WED", + "WEDDING", + "WEIBO", + "WEIR", + "WF", + "WHOSWHO", + "WIEN", + "WIKI", + "WILLIAMHILL", + "WIN", + "WINDOWS", + "WINE", + "WINNERS", + "WME", + "WOLTERSKLUWER", + "WOODSIDE", + "WORK", + "WORKS", + "WORLD", + "WOW", + "WS", + "WTC", + "WTF", + "XBOX", + "XEROX", + "XIHUAN", + "XIN", + "XN--11B4C3D", + "XN--1CK2E1B", + "XN--1QQW23A", + "XN--2SCRJ9C", + "XN--30RR7Y", + "XN--3BST00M", + "XN--3DS443G", + "XN--3E0B707E", + "XN--3HCRJ9C", + "XN--3PXU8K", + "XN--42C2D9A", + "XN--45BR5CYL", + "XN--45BRJ9C", + "XN--45Q11C", + "XN--4DBRK0CE", + "XN--4GBRIM", + "XN--54B7FTA0CC", + "XN--55QW42G", + "XN--55QX5D", + "XN--5SU34J936BGSG", + "XN--5TZM5G", + "XN--6FRZ82G", + "XN--6QQ986B3XL", + "XN--80ADXHKS", + "XN--80AO21A", + "XN--80AQECDR1A", + "XN--80ASEHDB", + "XN--80ASWG", + "XN--8Y0A063A", + "XN--90A3AC", + "XN--90AE", + "XN--90AIS", + "XN--9DBQ2A", + "XN--9ET52U", + "XN--9KRT00A", + "XN--B4W605FERD", + "XN--BCK1B9A5DRE4C", + "XN--C1AVG", + "XN--C2BR7G", + "XN--CCK2B3B", + "XN--CCKWCXETD", + "XN--CG4BKI", + "XN--CLCHC0EA0B2G2A9GCD", + "XN--CZR694B", + "XN--CZRS0T", + "XN--CZRU2D", + "XN--D1ACJ3B", + "XN--D1ALF", + "XN--E1A4C", + "XN--ECKVDTC9D", + "XN--EFVY88H", + "XN--FCT429K", + "XN--FHBEI", + "XN--FIQ228C5HS", + "XN--FIQ64B", + "XN--FIQS8S", + "XN--FIQZ9S", + "XN--FJQ720A", + "XN--FLW351E", + "XN--FPCRJ9C3D", + "XN--FZC2C9E2C", + "XN--FZYS8D69UVGM", + "XN--G2XX48C", + "XN--GCKR3F0F", + "XN--GECRJ9C", + "XN--GK3AT1E", + "XN--H2BREG3EVE", + "XN--H2BRJ9C", + "XN--H2BRJ9C8C", + "XN--HXT814E", + "XN--I1B6B1A6A2E", + "XN--IMR513N", + "XN--IO0A7I", + "XN--J1AEF", + "XN--J1AMH", + "XN--J6W193G", + "XN--JLQ480N2RG", + "XN--JVR189M", + "XN--KCRX77D1X4A", + "XN--KPRW13D", + "XN--KPRY57D", + "XN--KPUT3I", + "XN--L1ACC", + "XN--LGBBAT1AD8J", + "XN--MGB9AWBF", + "XN--MGBA3A3EJT", + "XN--MGBA3A4F16A", + "XN--MGBA7C0BBN0A", + "XN--MGBAAM7A8H", + "XN--MGBAB2BD", + "XN--MGBAH1A3HJKRD", + "XN--MGBAI9AZGQP6J", + "XN--MGBAYH7GPA", + "XN--MGBBH1A", + "XN--MGBBH1A71E", + "XN--MGBC0A9AZCG", + "XN--MGBCA7DZDO", + "XN--MGBCPQ6GPA1A", + "XN--MGBERP4A5D4AR", + "XN--MGBGU82A", + "XN--MGBI4ECEXP", + "XN--MGBPL2FH", + "XN--MGBT3DHD", + "XN--MGBTX2B", + "XN--MGBX4CD0AB", + "XN--MIX891F", + "XN--MK1BU44C", + "XN--MXTQ1M", + "XN--NGBC5AZD", + "XN--NGBE9E0A", + "XN--NGBRX", + "XN--NODE", + "XN--NQV7F", + "XN--NQV7FS00EMA", + "XN--NYQY26A", + "XN--O3CW4H", + "XN--OGBPF8FL", + "XN--OTU796D", + "XN--P1ACF", + "XN--P1AI", + "XN--PGBS0DH", + "XN--PSSY2U", + "XN--Q7CE6A", + "XN--Q9JYB4C", + "XN--QCKA1PMC", + "XN--QXA6A", + "XN--QXAM", + "XN--RHQV96G", + "XN--ROVU88B", + "XN--RVC1E0AM3E", + "XN--S9BRJ9C", + "XN--SES554G", + "XN--T60B56A", + "XN--TCKWE", + "XN--TIQ49XQYJ", + "XN--UNUP4Y", + "XN--VERMGENSBERATER-CTB", + "XN--VERMGENSBERATUNG-PWB", + "XN--VHQUV", + "XN--VUQ861B", + "XN--W4R85EL8FHU5DNRA", + "XN--W4RS40L", + "XN--WGBH1C", + "XN--WGBL6A", + "XN--XHQ521B", + "XN--XKC2AL3HYE2A", + "XN--XKC2DL3A5EE0H", + "XN--Y9A3AQ", + "XN--YFRO4I67O", + "XN--YGBI2AMMX", + "XN--ZFR164B", + "XXX", + "XYZ", + "YACHTS", + "YAHOO", + "YAMAXUN", + "YANDEX", + "YE", + "YODOBASHI", + "YOGA", + "YOKOHAMA", + "YOU", + "YOUTUBE", + "YT", + "YUN", + "ZA", + "ZAPPOS", + "ZARA", + "ZERO", + "ZIP", + "ZM", + "ZONE", + "ZUERICH", + "ZW", + "" +]; diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index 03d6f3bb..b1180995 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -1,5 +1,4 @@ export * from "./notFound"; -export * from "./rateLimit"; export * from "./formatError"; export * from "./verifySession"; export * from "./verifyUser"; @@ -14,9 +13,17 @@ export * from "./verifyAdmin"; export * from "./verifySetResourceUsers"; export * from "./verifyUserInRole"; export * from "./verifyAccessTokenAccess"; +export * from "./requestTimeout"; +export * from "./verifyClientAccess"; +export * from "./verifyUserHasAction"; export * from "./verifyUserIsServerAdmin"; export * from "./verifyIsLoggedInUser"; +export * from "./verifyIsLoggedInUser"; +export * from "./verifyClientAccess"; export * from "./integration"; export * from "./verifyValidLicense"; export * from "./verifyUserHasAction"; export * from "./verifyApiKeyAccess"; +export * from "./verifyDomainAccess"; +export * from "./verifyClientsEnabled"; +export * from "./verifyUserIsOrgOwner"; diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts index 19bf128e..4caf017b 100644 --- a/server/middlewares/integration/index.ts +++ b/server/middlewares/integration/index.ts @@ -10,3 +10,4 @@ export * from "./verifyApiKeySetResourceUsers"; export * from "./verifyAccessTokenAccess"; export * from "./verifyApiKeyIsRoot"; export * from "./verifyApiKeyApiKeyAccess"; +export * from "./verifyApiKeyClientAccess"; diff --git a/server/middlewares/integration/verifyApiKeyApiKeyAccess.ts b/server/middlewares/integration/verifyApiKeyApiKeyAccess.ts index 1441589d..ad5b7fc4 100644 --- a/server/middlewares/integration/verifyApiKeyApiKeyAccess.ts +++ b/server/middlewares/integration/verifyApiKeyApiKeyAccess.ts @@ -35,6 +35,11 @@ export async function verifyApiKeyApiKeyAccess( ); } + if (callerApiKey.isRoot) { + // Root keys can access any key in any org + return next(); + } + const [callerApiKeyOrg] = await db .select() .from(apiKeyOrg) diff --git a/server/middlewares/integration/verifyApiKeyClientAccess.ts b/server/middlewares/integration/verifyApiKeyClientAccess.ts new file mode 100644 index 00000000..e5ed624d --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyClientAccess.ts @@ -0,0 +1,91 @@ +import { Request, Response, NextFunction } from "express"; +import { clients, db } from "@server/db"; +import { apiKeyOrg } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyClientAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const clientId = parseInt( + req.params.clientId || req.body.clientId || req.query.clientId + ); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (isNaN(clientId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid client ID") + ); + } + + if (apiKey.isRoot) { + // Root keys can access any key in any org + return next(); + } + + const client = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (client.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with ID ${clientId} not found` + ) + ); + } + + if (!client[0].orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Client with ID ${clientId} does not have an organization ID` + ) + ); + } + + if (!req.apiKeyOrg) { + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, client[0].orgId) + ) + ); + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying site access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyOrgAccess.ts b/server/middlewares/integration/verifyApiKeyOrgAccess.ts index 84ba7fe9..c705dc0f 100644 --- a/server/middlewares/integration/verifyApiKeyOrgAccess.ts +++ b/server/middlewares/integration/verifyApiKeyOrgAccess.ts @@ -27,6 +27,11 @@ export async function verifyApiKeyOrgAccess( ); } + if (req.apiKey?.isRoot) { + // Root keys can access any key in any org + return next(); + } + if (!req.apiKeyOrg) { const apiKeyOrgRes = await db .select() diff --git a/server/middlewares/integration/verifyApiKeyResourceAccess.ts b/server/middlewares/integration/verifyApiKeyResourceAccess.ts index 2473c814..184ee73c 100644 --- a/server/middlewares/integration/verifyApiKeyResourceAccess.ts +++ b/server/middlewares/integration/verifyApiKeyResourceAccess.ts @@ -37,6 +37,11 @@ export async function verifyApiKeyResourceAccess( ); } + if (apiKey.isRoot) { + // Root keys can access any key in any org + return next(); + } + if (!resource.orgId) { return next( createHttpError( diff --git a/server/middlewares/integration/verifyApiKeyRoleAccess.ts b/server/middlewares/integration/verifyApiKeyRoleAccess.ts index 0df10913..ffe223a6 100644 --- a/server/middlewares/integration/verifyApiKeyRoleAccess.ts +++ b/server/middlewares/integration/verifyApiKeyRoleAccess.ts @@ -45,6 +45,11 @@ export async function verifyApiKeyRoleAccess( ); } + if (apiKey.isRoot) { + // Root keys can access any key in any org + return next(); + } + const orgIds = new Set(rolesData.map((role) => role.orgId)); for (const role of rolesData) { diff --git a/server/middlewares/integration/verifyApiKeySetResourceUsers.ts b/server/middlewares/integration/verifyApiKeySetResourceUsers.ts index cbb2b598..9c96e6ec 100644 --- a/server/middlewares/integration/verifyApiKeySetResourceUsers.ts +++ b/server/middlewares/integration/verifyApiKeySetResourceUsers.ts @@ -32,6 +32,11 @@ export async function verifyApiKeySetResourceUsers( return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid user IDs")); } + if (apiKey.isRoot) { + // Root keys can access any key in any org + return next(); + } + if (userIds.length === 0) { return next(); } diff --git a/server/middlewares/integration/verifyApiKeySiteAccess.ts b/server/middlewares/integration/verifyApiKeySiteAccess.ts index 35ec3b6a..0a310d15 100644 --- a/server/middlewares/integration/verifyApiKeySiteAccess.ts +++ b/server/middlewares/integration/verifyApiKeySiteAccess.ts @@ -1,9 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; -import { - sites, - apiKeyOrg -} from "@server/db"; +import { sites, apiKeyOrg } from "@server/db"; import { and, eq, or } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; @@ -31,6 +28,11 @@ export async function verifyApiKeySiteAccess( ); } + if (apiKey.isRoot) { + // Root keys can access any key in any org + return next(); + } + const site = await db .select() .from(sites) diff --git a/server/middlewares/integration/verifyApiKeyTargetAccess.ts b/server/middlewares/integration/verifyApiKeyTargetAccess.ts index f810e4a2..71146c15 100644 --- a/server/middlewares/integration/verifyApiKeyTargetAccess.ts +++ b/server/middlewares/integration/verifyApiKeyTargetAccess.ts @@ -66,6 +66,11 @@ export async function verifyApiKeyTargetAccess( ); } + if (apiKey.isRoot) { + // Root keys can access any key in any org + return next(); + } + if (!resource.orgId) { return next( createHttpError( diff --git a/server/middlewares/integration/verifyApiKeyUserAccess.ts b/server/middlewares/integration/verifyApiKeyUserAccess.ts index 070ae5ac..a69489bf 100644 --- a/server/middlewares/integration/verifyApiKeyUserAccess.ts +++ b/server/middlewares/integration/verifyApiKeyUserAccess.ts @@ -27,6 +27,11 @@ export async function verifyApiKeyUserAccess( ); } + if (apiKey.isRoot) { + // Root keys can access any key in any org + return next(); + } + if (!req.apiKeyOrg || !req.apiKeyOrg.orgId) { return next( createHttpError( diff --git a/server/middlewares/rateLimit.ts b/server/middlewares/rateLimit.ts deleted file mode 100644 index 2098288f..00000000 --- a/server/middlewares/rateLimit.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { rateLimit } from "express-rate-limit"; -import createHttpError from "http-errors"; -import { NextFunction, Request, Response } from "express"; -import logger from "@server/logger"; -import HttpCode from "@server/types/HttpCode"; - -export function rateLimitMiddleware({ - windowMin, - max, - type, - skipCondition, -}: { - windowMin: number; - max: number; - type: "IP_ONLY" | "IP_AND_PATH"; - skipCondition?: (req: Request, res: Response) => boolean; -}) { - if (type === "IP_AND_PATH") { - return rateLimit({ - windowMs: windowMin * 60 * 1000, - max, - skip: skipCondition, - keyGenerator: (req: Request) => { - return `${req.ip}-${req.path}`; - }, - handler: (req: Request, res: Response, next: NextFunction) => { - const message = `Rate limit exceeded. You can make ${max} requests every ${windowMin} minute(s).`; - logger.warn( - `Rate limit exceeded for IP ${req.ip} on path ${req.path}`, - ); - return next( - createHttpError(HttpCode.TOO_MANY_REQUESTS, message), - ); - }, - }); - } - return rateLimit({ - windowMs: windowMin * 60 * 1000, - max, - skip: skipCondition, - handler: (req: Request, res: Response, next: NextFunction) => { - const message = `Rate limit exceeded. You can make ${max} requests every ${windowMin} minute(s).`; - logger.warn(`Rate limit exceeded for IP ${req.ip}`); - return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); - }, - }); -} - -export default rateLimitMiddleware; diff --git a/server/middlewares/requestTimeout.ts b/server/middlewares/requestTimeout.ts new file mode 100644 index 00000000..8b5852b7 --- /dev/null +++ b/server/middlewares/requestTimeout.ts @@ -0,0 +1,35 @@ +import { Request, Response, NextFunction } from 'express'; +import logger from '@server/logger'; +import createHttpError from 'http-errors'; +import HttpCode from '@server/types/HttpCode'; + +export function requestTimeoutMiddleware(timeoutMs: number = 30000) { + return (req: Request, res: Response, next: NextFunction) => { + // Set a timeout for the request + const timeout = setTimeout(() => { + if (!res.headersSent) { + logger.error(`Request timeout: ${req.method} ${req.url} from ${req.ip}`); + return next( + createHttpError( + HttpCode.REQUEST_TIMEOUT, + 'Request timeout - operation took too long to complete' + ) + ); + } + }, timeoutMs); + + // Clear timeout when response finishes + res.on('finish', () => { + clearTimeout(timeout); + }); + + // Clear timeout when response closes + res.on('close', () => { + clearTimeout(timeout); + }); + + next(); + }; +} + +export default requestTimeoutMiddleware; diff --git a/server/middlewares/verifyClientAccess.ts b/server/middlewares/verifyClientAccess.ts new file mode 100644 index 00000000..df45b541 --- /dev/null +++ b/server/middlewares/verifyClientAccess.ts @@ -0,0 +1,131 @@ +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { userOrgs, clients, roleClients, userClients } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyClientAccess( + req: Request, + res: Response, + next: NextFunction +) { + const userId = req.user!.userId; // Assuming you have user information in the request + const clientId = parseInt( + req.params.clientId || req.body.clientId || req.query.clientId + ); + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (isNaN(clientId)) { + return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid client ID")); + } + + try { + // Get the client + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with ID ${clientId} not found` + ) + ); + } + + if (!client.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Client with ID ${clientId} does not have an organization ID` + ) + ); + } + + if (!req.userOrg) { + // Get user's role ID in the organization + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, client.orgId) + ) + ) + .limit(1); + req.userOrg = userOrgRole[0]; + } + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + const userOrgRoleId = req.userOrg.roleId; + req.userOrgRoleId = userOrgRoleId; + req.userOrgId = client.orgId; + + // Check role-based site access first + const [roleClientAccess] = await db + .select() + .from(roleClients) + .where( + and( + eq(roleClients.clientId, clientId), + eq(roleClients.roleId, userOrgRoleId) + ) + ) + .limit(1); + + if (roleClientAccess) { + // User has access to the site through their role + return next(); + } + + // If role doesn't have access, check user-specific site access + const [userClientAccess] = await db + .select() + .from(userClients) + .where( + and( + eq(userClients.userId, userId), + eq(userClients.clientId, clientId) + ) + ) + .limit(1); + + if (userClientAccess) { + // User has direct access to the site + return next(); + } + + // If we reach here, the user doesn't have access to the site + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this client" + ) + ); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying site access" + ) + ); + } +} diff --git a/server/middlewares/verifyClientsEnabled.ts b/server/middlewares/verifyClientsEnabled.ts new file mode 100644 index 00000000..6e8070da --- /dev/null +++ b/server/middlewares/verifyClientsEnabled.ts @@ -0,0 +1,29 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import config from "@server/lib/config"; + +export async function verifyClientsEnabled( + req: Request, + res: Response, + next: NextFunction +) { + try { + if (!config.getRawConfig().flags?.enable_clients) { + return next( + createHttpError( + HttpCode.NOT_IMPLEMENTED, + "Clients are not enabled on this server." + ) + ); + } + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to check if clients are enabled" + ) + ); + } +} diff --git a/server/middlewares/verifyDomainAccess.ts b/server/middlewares/verifyDomainAccess.ts new file mode 100644 index 00000000..a6daf451 --- /dev/null +++ b/server/middlewares/verifyDomainAccess.ts @@ -0,0 +1,93 @@ +import { Request, Response, NextFunction } from "express"; +import { db, domains, orgDomains } from "@server/db"; +import { userOrgs, apiKeyOrg } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyDomainAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const userId = req.user!.userId; + const domainId = + req.params.domainId || req.body.apiKeyId || req.query.apiKeyId; + const orgId = req.params.orgId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (!domainId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid domain ID") + ); + } + + const [domain] = await db + .select() + .from(domains) + .innerJoin(orgDomains, eq(orgDomains.domainId, domains.domainId)) + .where( + and( + eq(orgDomains.domainId, domainId), + eq(orgDomains.orgId, orgId) + ) + ) + .limit(1); + + if (!domain.orgDomains) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Domain with ID ${domainId} not found` + ) + ); + } + + if (!req.userOrg) { + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, apiKeyOrg.orgId) + ) + ) + .limit(1); + req.userOrg = userOrgRole[0]; + } + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + const userOrgRoleId = req.userOrg.roleId; + req.userOrgRoleId = userOrgRoleId; + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying domain access" + ) + ); + } +} diff --git a/server/openApi.ts b/server/openApi.ts index 4df6cbdd..32cdb67b 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -14,5 +14,6 @@ export enum OpenAPITags { AccessToken = "Access Token", Idp = "Identity Provider", Client = "Client", - ApiKey = "API Key" + ApiKey = "API Key", + Domain = "Domain" } diff --git a/server/routers/apiKeys/createRootApiKey.ts b/server/routers/apiKeys/createRootApiKey.ts index 095d952b..0754574a 100644 --- a/server/routers/apiKeys/createRootApiKey.ts +++ b/server/routers/apiKeys/createRootApiKey.ts @@ -63,15 +63,6 @@ export async function createRootApiKey( lastChars, isRoot: true }); - - const allOrgs = await trx.select().from(orgs); - - for (const org of allOrgs) { - await trx.insert(apiKeyOrg).values({ - apiKeyId, - orgId: org.orgId - }); - } }); try { diff --git a/server/routers/auth/index.ts b/server/routers/auth/index.ts index 6955e16c..cc8fd630 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -6,9 +6,10 @@ export * from "./requestTotpSecret"; export * from "./disable2fa"; export * from "./verifyEmail"; export * from "./requestEmailVerificationCode"; -export * from "./changePassword"; -export * from "./requestPasswordReset"; export * from "./resetPassword"; -export * from "./checkResourceSession"; +export * from "./requestPasswordReset"; export * from "./setServerAdmin"; export * from "./initialSetupComplete"; +export * from "./changePassword"; +export * from "./checkResourceSession"; +export * from "./securityKey"; diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index f5f7ff77..8dad5a42 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -4,7 +4,7 @@ import { serializeSessionCookie } from "@server/auth/sessions/app"; import { db } from "@server/db"; -import { users } from "@server/db"; +import { users, securityKeys } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq, and } from "drizzle-orm"; @@ -21,10 +21,7 @@ import { UserType } from "@server/types/UserTypes"; export const loginBodySchema = z .object({ - email: z - .string() - .toLowerCase() - .email(), + email: z.string().toLowerCase().email(), password: z.string(), code: z.string().optional() }) @@ -35,10 +32,10 @@ export type LoginBody = z.infer; export type LoginResponse = { codeRequested?: boolean; emailVerificationRequired?: boolean; + useSecurityKey?: boolean; + twoFactorSetupRequired?: boolean; }; -export const dynamic = "force-dynamic"; - export async function login( req: Request, res: Response, @@ -109,6 +106,35 @@ export async function login( ); } + // // Check if user has security keys registered + // const userSecurityKeys = await db + // .select() + // .from(securityKeys) + // .where(eq(securityKeys.userId, existingUser.userId)); + // + // if (userSecurityKeys.length > 0) { + // return response(res, { + // data: { useSecurityKey: true }, + // success: true, + // error: false, + // message: "Security key authentication required", + // status: HttpCode.OK + // }); + // } + + if ( + existingUser.twoFactorSetupRequested && + !existingUser.twoFactorEnabled + ) { + return response(res, { + data: { twoFactorSetupRequired: true }, + success: true, + error: false, + message: "Two-factor authentication setup required", + status: HttpCode.ACCEPTED + }); + } + if (existingUser.twoFactorEnabled) { if (!code) { return response<{ codeRequested: boolean }>(res, { diff --git a/server/routers/auth/requestTotpSecret.ts b/server/routers/auth/requestTotpSecret.ts index 2de35412..753867b6 100644 --- a/server/routers/auth/requestTotpSecret.ts +++ b/server/routers/auth/requestTotpSecret.ts @@ -7,16 +7,19 @@ import HttpCode from "@server/types/HttpCode"; import { response } from "@server/lib"; import { db } from "@server/db"; import { User, users } from "@server/db"; -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; import { createTOTPKeyURI } from "oslo/otp"; import logger from "@server/logger"; import { verifyPassword } from "@server/auth/password"; import { unauthorized } from "@server/auth/unauthorizedResponse"; import { UserType } from "@server/types/UserTypes"; +import { verifySession } from "@server/auth/sessions/verifySession"; +import config from "@server/lib/config"; export const requestTotpSecretBody = z .object({ - password: z.string() + password: z.string(), + email: z.string().email().optional() }) .strict(); @@ -43,9 +46,42 @@ export async function requestTotpSecret( ); } - const { password } = parsedBody.data; + const { password, email } = parsedBody.data; - const user = req.user as User; + const { user: sessionUser, session: existingSession } = await verifySession(req); + + let user: User | null = sessionUser; + if (!existingSession) { + if (!email) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Email is required for two-factor authentication setup" + ) + ); + } + const [res] = await db + .select() + .from(users) + .where( + and(eq(users.type, UserType.Internal), eq(users.email, email)) + ); + user = res; + } + + if (!user) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Username or password incorrect. Email: ${email}. IP: ${req.ip}.` + ); + } + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Username or password is incorrect" + ) + ); + } if (user.type !== UserType.Internal) { return next( @@ -57,7 +93,10 @@ export async function requestTotpSecret( } try { - const validPassword = await verifyPassword(password, user.passwordHash!); + const validPassword = await verifyPassword( + password, + user.passwordHash! + ); if (!validPassword) { return next(unauthorized()); } @@ -73,7 +112,11 @@ export async function requestTotpSecret( const hex = crypto.getRandomValues(new Uint8Array(20)); const secret = encodeHex(hex); - const uri = createTOTPKeyURI("Pangolin", user.email!, hex); + const uri = createTOTPKeyURI( + "Pangolin", + user.email!, + hex + ); await db .update(users) diff --git a/server/routers/auth/securityKey.ts b/server/routers/auth/securityKey.ts new file mode 100644 index 00000000..dad3c692 --- /dev/null +++ b/server/routers/auth/securityKey.ts @@ -0,0 +1,717 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import { z } from "zod"; +import { db } from "@server/db"; +import { User, securityKeys, users, webauthnChallenge } from "@server/db"; +import { eq, and, lt } from "drizzle-orm"; +import { response } from "@server/lib"; +import logger from "@server/logger"; +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse +} from "@simplewebauthn/server"; +import type { + GenerateRegistrationOptionsOpts, + VerifyRegistrationResponseOpts, + GenerateAuthenticationOptionsOpts, + VerifyAuthenticationResponseOpts, + VerifiedRegistrationResponse, + VerifiedAuthenticationResponse +} from "@simplewebauthn/server"; +import type { + AuthenticatorTransport, + AuthenticatorTransportFuture, + PublicKeyCredentialDescriptorJSON, + PublicKeyCredentialDescriptorFuture +} from "@simplewebauthn/types"; +import config from "@server/lib/config"; +import { UserType } from "@server/types/UserTypes"; +import { verifyPassword } from "@server/auth/password"; +import { unauthorized } from "@server/auth/unauthorizedResponse"; +import { verifyTotpCode } from "@server/auth/totp"; + +// The RP ID is the domain name of your application +const rpID = (() => { + const url = new URL(config.getRawConfig().app.dashboard_url); + // For localhost, we must use 'localhost' without port + if (url.hostname === 'localhost') { + return 'localhost'; + } + return url.hostname; +})(); + +const rpName = "Pangolin"; +const origin = config.getRawConfig().app.dashboard_url; + +// Database-based challenge storage (replaces in-memory storage) +// Challenges are stored in the webauthnChallenge table with automatic expiration +// This supports clustered deployments and persists across server restarts + +// Clean up expired challenges every 5 minutes +setInterval(async () => { + try { + const now = Date.now(); + await db + .delete(webauthnChallenge) + .where(lt(webauthnChallenge.expiresAt, now)); + logger.debug("Cleaned up expired security key challenges"); + } catch (error) { + logger.error("Failed to clean up expired security key challenges", error); + } +}, 5 * 60 * 1000); + +// Helper functions for challenge management +async function storeChallenge(sessionId: string, challenge: string, securityKeyName?: string, userId?: string) { + const expiresAt = Date.now() + (5 * 60 * 1000); // 5 minutes + + // Delete any existing challenge for this session + await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId)); + + // Insert new challenge + await db.insert(webauthnChallenge).values({ + sessionId, + challenge, + securityKeyName, + userId, + expiresAt + }); +} + +async function getChallenge(sessionId: string) { + const [challengeData] = await db + .select() + .from(webauthnChallenge) + .where(eq(webauthnChallenge.sessionId, sessionId)) + .limit(1); + + if (!challengeData) { + return null; + } + + // Check if expired + if (challengeData.expiresAt < Date.now()) { + await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId)); + return null; + } + + return challengeData; +} + +async function clearChallenge(sessionId: string) { + await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId)); +} + +export const registerSecurityKeyBody = z.object({ + name: z.string().min(1), + password: z.string().min(1), + code: z.string().optional() +}).strict(); + +export const verifyRegistrationBody = z.object({ + credential: z.any() +}).strict(); + +export const startAuthenticationBody = z.object({ + email: z.string().email().optional() +}).strict(); + +export const verifyAuthenticationBody = z.object({ + credential: z.any() +}).strict(); + +export const deleteSecurityKeyBody = z.object({ + password: z.string().min(1), + code: z.string().optional() +}).strict(); + +export async function startRegistration( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = registerSecurityKeyBody.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { name, password, code } = parsedBody.data; + const user = req.user as User; + + // Only allow internal users to use security keys + if (user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Security keys are only available for internal users" + ) + ); + } + + try { + // Verify password + const validPassword = await verifyPassword(password, user.passwordHash!); + if (!validPassword) { + return next(unauthorized()); + } + + // If user has 2FA enabled, require and verify the code + if (user.twoFactorEnabled) { + if (!code) { + return response<{ codeRequested: boolean }>(res, { + data: { codeRequested: true }, + success: true, + error: false, + message: "Two-factor authentication required", + status: HttpCode.ACCEPTED + }); + } + + const validOTP = await verifyTotpCode( + code, + user.twoFactorSecret!, + user.userId + ); + + if (!validOTP) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Two-factor code incorrect. Email: ${user.email}. IP: ${req.ip}.` + ); + } + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "The two-factor code you entered is incorrect" + ) + ); + } + } + + // Get existing security keys for user + const existingSecurityKeys = await db + .select() + .from(securityKeys) + .where(eq(securityKeys.userId, user.userId)); + + const excludeCredentials = existingSecurityKeys.map(key => ({ + id: new Uint8Array(Buffer.from(key.credentialId, 'base64')), + type: 'public-key' as const, + transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransportFuture[] : undefined + })); + + const options: GenerateRegistrationOptionsOpts = { + rpName, + rpID, + userID: user.userId, + userName: user.email || user.username, + attestationType: 'none', + excludeCredentials, + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'preferred', + } + }; + + const registrationOptions = await generateRegistrationOptions(options); + + // Store challenge in database + await storeChallenge(req.session.sessionId, registrationOptions.challenge, name, user.userId); + + return response(res, { + data: registrationOptions, + success: true, + error: false, + message: "Registration options generated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to start registration" + ) + ); + } +} + +export async function verifyRegistration( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = verifyRegistrationBody.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { credential } = parsedBody.data; + const user = req.user as User; + + // Only allow internal users to use security keys + if (user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Security keys are only available for internal users" + ) + ); + } + + try { + // Get challenge from database + const challengeData = await getChallenge(req.session.sessionId); + + if (!challengeData) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "No challenge found in session or challenge expired" + ) + ); + } + + const verification = await verifyRegistrationResponse({ + response: credential, + expectedChallenge: challengeData.challenge, + expectedOrigin: origin, + expectedRPID: rpID, + requireUserVerification: false + }); + + const { verified, registrationInfo } = verification; + + if (!verified || !registrationInfo) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Verification failed" + ) + ); + } + + // Store the security key in the database + await db.insert(securityKeys).values({ + credentialId: Buffer.from(registrationInfo.credentialID).toString('base64'), + userId: user.userId, + publicKey: Buffer.from(registrationInfo.credentialPublicKey).toString('base64'), + signCount: registrationInfo.counter || 0, + transports: credential.response.transports ? JSON.stringify(credential.response.transports) : null, + name: challengeData.securityKeyName, + lastUsed: new Date().toISOString(), + dateCreated: new Date().toISOString() + }); + + // Clear challenge data + await clearChallenge(req.session.sessionId); + + return response(res, { + data: null, + success: true, + error: false, + message: "Security key registered successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to verify registration" + ) + ); + } +} + +export async function listSecurityKeys( + req: Request, + res: Response, + next: NextFunction +): Promise { + const user = req.user as User; + + // Only allow internal users to use security keys + if (user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Security keys are only available for internal users" + ) + ); + } + + try { + const userSecurityKeys = await db + .select() + .from(securityKeys) + .where(eq(securityKeys.userId, user.userId)); + + return response(res, { + data: userSecurityKeys, + success: true, + error: false, + message: "Security keys retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to retrieve security keys" + ) + ); + } +} + +export async function deleteSecurityKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + const { credentialId: encodedCredentialId } = req.params; + const credentialId = decodeURIComponent(encodedCredentialId); + const user = req.user as User; + + const parsedBody = deleteSecurityKeyBody.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { password, code } = parsedBody.data; + + // Only allow internal users to use security keys + if (user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Security keys are only available for internal users" + ) + ); + } + + try { + // Verify password + const validPassword = await verifyPassword(password, user.passwordHash!); + if (!validPassword) { + return next(unauthorized()); + } + + // If user has 2FA enabled, require and verify the code + if (user.twoFactorEnabled) { + if (!code) { + return response<{ codeRequested: boolean }>(res, { + data: { codeRequested: true }, + success: true, + error: false, + message: "Two-factor authentication required", + status: HttpCode.ACCEPTED + }); + } + + const validOTP = await verifyTotpCode( + code, + user.twoFactorSecret!, + user.userId + ); + + if (!validOTP) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Two-factor code incorrect. Email: ${user.email}. IP: ${req.ip}.` + ); + } + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "The two-factor code you entered is incorrect" + ) + ); + } + } + + await db + .delete(securityKeys) + .where(and( + eq(securityKeys.credentialId, credentialId), + eq(securityKeys.userId, user.userId) + )); + + return response(res, { + data: null, + success: true, + error: false, + message: "Security key deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to delete security key" + ) + ); + } +} + +export async function startAuthentication( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = startAuthenticationBody.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { email } = parsedBody.data; + + try { + let allowCredentials: PublicKeyCredentialDescriptorFuture[] = []; + let userId; + + // If email is provided, get security keys for that specific user + if (email) { + const [user] = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1); + + if (!user || user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid credentials" + ) + ); + } + + userId = user.userId; + + const userSecurityKeys = await db + .select() + .from(securityKeys) + .where(eq(securityKeys.userId, user.userId)); + + if (userSecurityKeys.length === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "No security keys registered for this user" + ) + ); + } + + allowCredentials = userSecurityKeys.map(key => ({ + id: new Uint8Array(Buffer.from(key.credentialId, 'base64')), + type: 'public-key' as const, + transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransportFuture[] : undefined + })); + } else { + // If no email provided, allow any security key (for resident key authentication) + allowCredentials = []; + } + + const options: GenerateAuthenticationOptionsOpts = { + rpID, + allowCredentials, + userVerification: 'preferred', + }; + + const authenticationOptions = await generateAuthenticationOptions(options); + + // Generate a temporary session ID for unauthenticated users + const tempSessionId = email ? `temp_${email}_${Date.now()}` : `temp_${Date.now()}`; + + // Store challenge in database + await storeChallenge(tempSessionId, authenticationOptions.challenge, undefined, userId); + + return response(res, { + data: { ...authenticationOptions, tempSessionId }, + success: true, + error: false, + message: "Authentication options generated", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to generate authentication options" + ) + ); + } +} + +export async function verifyAuthentication( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = verifyAuthenticationBody.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { credential } = parsedBody.data; + const tempSessionId = req.headers['x-temp-session-id'] as string; + + if (!tempSessionId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Your session information is missing. This might happen if you've been inactive for too long or if your browser cleared temporary data. Please start the sign-in process again." + ) + ); + } + + try { + // Get challenge from database + const challengeData = await getChallenge(tempSessionId); + + if (!challengeData) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Your sign-in session has expired. For security reasons, you have 5 minutes to complete the authentication process. Please try signing in again." + ) + ); + } + + // Find the security key in database + const credentialId = Buffer.from(credential.id, 'base64').toString('base64'); + const [securityKey] = await db + .select() + .from(securityKeys) + .where(eq(securityKeys.credentialId, credentialId)) + .limit(1); + + if (!securityKey) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "We couldn't verify your security key. This might happen if your device isn't compatible or if the security key was removed too quickly. Please try again and keep your security key connected until the process completes." + ) + ); + } + + // Get the user + const [user] = await db + .select() + .from(users) + .where(eq(users.userId, securityKey.userId)) + .limit(1); + + if (!user || user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User not found or not authorized for security key authentication" + ) + ); + } + + const verification = await verifyAuthenticationResponse({ + response: credential, + expectedChallenge: challengeData.challenge, + expectedOrigin: origin, + expectedRPID: rpID, + authenticator: { + credentialID: Buffer.from(securityKey.credentialId, 'base64'), + credentialPublicKey: Buffer.from(securityKey.publicKey, 'base64'), + counter: securityKey.signCount, + transports: securityKey.transports ? JSON.parse(securityKey.transports) as AuthenticatorTransportFuture[] : undefined + }, + requireUserVerification: false + }); + + const { verified, authenticationInfo } = verification; + + if (!verified) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Authentication failed. This could happen if your security key wasn't recognized or was removed too early. Please ensure your security key is properly connected and try again." + ) + ); + } + + // Update sign count + await db + .update(securityKeys) + .set({ + signCount: authenticationInfo.newCounter, + lastUsed: new Date().toISOString() + }) + .where(eq(securityKeys.credentialId, credentialId)); + + // Create session for the user + const { createSession, generateSessionToken, serializeSessionCookie } = await import("@server/auth/sessions/app"); + const token = generateSessionToken(); + const session = await createSession(token, user.userId); + const isSecure = req.protocol === "https"; + const cookie = serializeSessionCookie( + token, + isSecure, + new Date(session.expiresAt) + ); + + res.setHeader("Set-Cookie", cookie); + + // Clear challenge data + await clearChallenge(tempSessionId); + + return response(res, { + data: null, + success: true, + error: false, + message: "Authentication successful", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to verify authentication" + ) + ); + } +} \ No newline at end of file diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 0c7e926e..09c8db07 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -1,8 +1,7 @@ import { NextFunction, Request, Response } from "express"; -import { db } from "@server/db"; +import { db, users } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import { z } from "zod"; -import { users } from "@server/db"; import { fromError } from "zod-validation-error"; import createHttpError from "http-errors"; import response from "@server/lib/response"; @@ -22,15 +21,14 @@ import { hashPassword } from "@server/auth/password"; import { checkValidInvite } from "@server/auth/checkValidInvite"; import { passwordSchema } from "@server/auth/passwordSchema"; import { UserType } from "@server/types/UserTypes"; +import { build } from "@server/build"; export const signupBodySchema = z.object({ - email: z - .string() - .toLowerCase() - .email(), + email: z.string().toLowerCase().email(), password: passwordSchema, inviteToken: z.string().optional(), - inviteId: z.string().optional() + inviteId: z.string().optional(), + termsAcceptedTimestamp: z.string().nullable().optional() }); export type SignUpBody = z.infer; @@ -55,9 +53,8 @@ export async function signup( ); } - const { email, password, inviteToken, inviteId } = parsedBody.data; - - logger.debug("signup", { email, password, inviteToken, inviteId }); + const { email, password, inviteToken, inviteId, termsAcceptedTimestamp } = + parsedBody.data; const passwordHash = await hashPassword(password); const userId = generateId(15); @@ -143,28 +140,45 @@ export async function signup( if (diff < 2) { // If the user was created less than 2 hours ago, we don't want to create a new user - return response(res, { - data: { - emailVerificationRequired: true - }, - success: true, - error: false, - message: `A user with that email address already exists. We sent an email to ${email} with a verification code.`, - status: HttpCode.OK - }); + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "A user with that email address already exists" + ) + ); + // return response(res, { + // data: { + // emailVerificationRequired: true + // }, + // success: true, + // error: false, + // message: `A user with that email address already exists. We sent an email to ${email} with a verification code.`, + // status: HttpCode.OK + // }); } else { // If the user was created more than 2 hours ago, we want to delete the old user and create a new one await db.delete(users).where(eq(users.userId, user.userId)); } } + if (build === "saas" && !termsAcceptedTimestamp) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "You must accept the terms of service and privacy policy" + ) + ); + } + await db.insert(users).values({ userId: userId, type: UserType.Internal, username: email, email: email, passwordHash, - dateCreated: moment().toISOString() + dateCreated: moment().toISOString(), + termsAcceptedTimestamp: termsAcceptedTimestamp || null, + termsVersion: "1" }); // give the user their default permissions: diff --git a/server/routers/auth/verifyEmail.ts b/server/routers/auth/verifyEmail.ts index f707de22..97ab540b 100644 --- a/server/routers/auth/verifyEmail.ts +++ b/server/routers/auth/verifyEmail.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import HttpCode from "@server/types/HttpCode"; import { response } from "@server/lib"; -import { db } from "@server/db"; +import { db, userOrgs } from "@server/db"; import { User, emailVerificationCodes, users } from "@server/db"; import { eq } from "drizzle-orm"; import { isWithinExpirationDate } from "oslo"; diff --git a/server/routers/auth/verifyTotp.ts b/server/routers/auth/verifyTotp.ts index 70018a7d..6b45a93e 100644 --- a/server/routers/auth/verifyTotp.ts +++ b/server/routers/auth/verifyTotp.ts @@ -6,18 +6,22 @@ import HttpCode from "@server/types/HttpCode"; import { response } from "@server/lib"; import { db } from "@server/db"; import { twoFactorBackupCodes, User, users } from "@server/db"; -import { eq } from "drizzle-orm"; -import { alphabet, generateRandomString } from "oslo/crypto"; -import { hashPassword } from "@server/auth/password"; +import { eq, and } from "drizzle-orm"; +import { hashPassword, verifyPassword } from "@server/auth/password"; import { verifyTotpCode } from "@server/auth/totp"; import logger from "@server/logger"; import { sendEmail } from "@server/emails"; import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification"; import config from "@server/lib/config"; import { UserType } from "@server/types/UserTypes"; +import { generateBackupCodes } from "@server/lib/totp"; +import { verifySession } from "@server/auth/sessions/verifySession"; +import { unauthorized } from "@server/auth/unauthorizedResponse"; export const verifyTotpBody = z .object({ + email: z.string().email().optional(), + password: z.string().optional(), code: z.string() }) .strict(); @@ -45,38 +49,83 @@ export async function verifyTotp( ); } - const { code } = parsedBody.data; - - const user = req.user as User; - - if (user.type !== UserType.Internal) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Two-factor authentication is not supported for external users" - ) - ); - } - - if (user.twoFactorEnabled) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Two-factor authentication is already enabled" - ) - ); - } - - if (!user.twoFactorSecret) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "User has not requested two-factor authentication" - ) - ); - } + const { code, email, password } = parsedBody.data; try { + const { user: sessionUser, session: existingSession } = + await verifySession(req); + + let user: User | null = sessionUser; + if (!existingSession) { + if (!email || !password) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Email and password are required for two-factor authentication" + ) + ); + } + const [res] = await db + .select() + .from(users) + .where( + and( + eq(users.type, UserType.Internal), + eq(users.email, email) + ) + ); + user = res; + + const validPassword = await verifyPassword( + password, + user.passwordHash! + ); + if (!validPassword) { + return next(unauthorized()); + } + } + + if (!user) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Username or password incorrect. Email: ${email}. IP: ${req.ip}.` + ); + } + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Username or password is incorrect" + ) + ); + } + + if (user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Two-factor authentication is not supported for external users" + ) + ); + } + + if (user.twoFactorEnabled) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Two-factor authentication is already enabled" + ) + ); + } + + if (!user.twoFactorSecret) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User has not requested two-factor authentication" + ) + ); + } + const valid = await verifyTotpCode( code, user.twoFactorSecret, @@ -89,7 +138,9 @@ export async function verifyTotp( await db.transaction(async (trx) => { await trx .update(users) - .set({ twoFactorEnabled: true }) + .set({ + twoFactorEnabled: true + }) .where(eq(users.userId, user.userId)); const backupCodes = await generateBackupCodes(); @@ -153,12 +204,3 @@ export async function verifyTotp( ); } } - -async function generateBackupCodes(): Promise { - const codes = []; - for (let i = 0; i < 10; i++) { - const code = generateRandomString(6, alphabet("0-9", "A-Z", "a-z")); - codes.push(code); - } - return codes; -} diff --git a/server/routers/badger/exchangeSession.ts b/server/routers/badger/exchangeSession.ts index 8139694a..b4289281 100644 --- a/server/routers/badger/exchangeSession.ts +++ b/server/routers/badger/exchangeSession.ts @@ -52,20 +52,26 @@ export async function exchangeSession( try { const { requestToken, host, requestIp } = parsedBody.data; + let cleanHost = host; + // if the host ends with :port + if (cleanHost.match(/:[0-9]{1,5}$/)) { + let matched = ''+cleanHost.match(/:[0-9]{1,5}$/); + cleanHost = cleanHost.slice(0, -1*matched.length); + } const clientIp = requestIp?.split(":")[0]; const [resource] = await db .select() .from(resources) - .where(eq(resources.fullDomain, host)) + .where(eq(resources.fullDomain, cleanHost)) .limit(1); if (!resource) { return next( createHttpError( HttpCode.NOT_FOUND, - `Resource with host ${host} not found` + `Resource with host ${cleanHost} not found` ) ); } diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 7ee431d6..48d7c064 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -121,11 +121,10 @@ export async function verifyResourceSession( logger.debug("Client IP:", { clientIp }); let cleanHost = host; - // if the host ends with :443 or :80 remove it - if (cleanHost.endsWith(":443")) { - cleanHost = cleanHost.slice(0, -4); - } else if (cleanHost.endsWith(":80")) { - cleanHost = cleanHost.slice(0, -3); + // if the host ends with :port, strip it + if (cleanHost.match(/:[0-9]{1,5}$/)) { + let matched = ''+cleanHost.match(/:[0-9]{1,5}$/); + cleanHost = cleanHost.slice(0, -1*matched.length); } const resourceCacheKey = `resource:${cleanHost}`; diff --git a/server/routers/client/createClient.ts b/server/routers/client/createClient.ts new file mode 100644 index 00000000..4e9dcdce --- /dev/null +++ b/server/routers/client/createClient.ts @@ -0,0 +1,265 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { + roles, + Client, + clients, + roleClients, + userClients, + olms, + clientSites, + exitNodes, + orgs, + sites +} from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { eq, and } from "drizzle-orm"; +import { fromError } from "zod-validation-error"; +import moment from "moment"; +import { hashPassword } from "@server/auth/password"; +import { isValidCIDR, isValidIP } from "@server/lib/validators"; +import { isIpInCidr } from "@server/lib/ip"; +import { OpenAPITags, registry } from "@server/openApi"; + +const createClientParamsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +const createClientSchema = z + .object({ + name: z.string().min(1).max(255), + siteIds: z.array(z.number().int().positive()), + olmId: z.string(), + secret: z.string(), + subnet: z.string(), + type: z.enum(["olm"]) + }) + .strict(); + +export type CreateClientBody = z.infer; + +export type CreateClientResponse = Client; + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/client", + description: "Create a new client.", + tags: [OpenAPITags.Client, OpenAPITags.Org], + request: { + params: createClientParamsSchema, + body: { + content: { + "application/json": { + schema: createClientSchema + } + } + } + }, + responses: {} +}); + +export async function createClient( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = createClientSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { name, type, siteIds, olmId, secret, subnet } = parsedBody.data; + + const parsedParams = createClientParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + if (req.user && !req.userOrgRoleId) { + return next( + createHttpError(HttpCode.FORBIDDEN, "User does not have a role") + ); + } + + if (!isValidIP(subnet)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid subnet format. Please provide a valid CIDR notation." + ) + ); + } + + const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); + + if (!org) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Organization with ID ${orgId} not found` + ) + ); + } + + if (!org.subnet) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Organization with ID ${orgId} has no subnet defined` + ) + ); + } + + if (!isIpInCidr(subnet, org.subnet)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IP is not in the CIDR range of the subnet." + ) + ); + } + + const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org + + // make sure the subnet is unique + const subnetExistsClients = await db + .select() + .from(clients) + .where( + and(eq(clients.subnet, updatedSubnet), eq(clients.orgId, orgId)) + ) + .limit(1); + + if (subnetExistsClients.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Subnet ${updatedSubnet} already exists in clients` + ) + ); + } + + const subnetExistsSites = await db + .select() + .from(sites) + .where( + and(eq(sites.address, updatedSubnet), eq(sites.orgId, orgId)) + ) + .limit(1); + + if (subnetExistsSites.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Subnet ${updatedSubnet} already exists in sites` + ) + ); + } + + await db.transaction(async (trx) => { + // TODO: more intelligent way to pick the exit node + + // make sure there is an exit node by counting the exit nodes table + const nodes = await db.select().from(exitNodes); + if (nodes.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No exit nodes available" + ) + ); + } + + // get the first exit node + const exitNode = nodes[0]; + + const adminRole = await trx + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); + + if (adminRole.length === 0) { + trx.rollback(); + return next( + createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) + ); + } + + const [newClient] = await trx + .insert(clients) + .values({ + exitNodeId: exitNode.exitNodeId, + orgId, + name, + subnet: updatedSubnet, + type + }) + .returning(); + + await trx.insert(roleClients).values({ + roleId: adminRole[0].roleId, + clientId: newClient.clientId + }); + + if (req.user && req.userOrgRoleId != adminRole[0].roleId) { + // make sure the user can access the site + trx.insert(userClients).values({ + userId: req.user?.userId!, + clientId: newClient.clientId + }); + } + + // Create site to client associations + if (siteIds && siteIds.length > 0) { + await trx.insert(clientSites).values( + siteIds.map((siteId) => ({ + clientId: newClient.clientId, + siteId + })) + ); + } + + const secretHash = await hashPassword(secret); + + await trx.insert(olms).values({ + olmId, + secretHash, + clientId: newClient.clientId, + dateCreated: moment().toISOString() + }); + + return response(res, { + data: newClient, + success: true, + error: false, + message: "Site created successfully", + status: HttpCode.CREATED + }); + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/client/deleteClient.ts b/server/routers/client/deleteClient.ts new file mode 100644 index 00000000..a7512574 --- /dev/null +++ b/server/routers/client/deleteClient.ts @@ -0,0 +1,88 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { clients, clientSites } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const deleteClientSchema = z + .object({ + clientId: z.string().transform(Number).pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "delete", + path: "/client/{clientId}", + description: "Delete a client by its client ID.", + tags: [OpenAPITags.Client], + request: { + params: deleteClientSchema + }, + responses: {} +}); + +export async function deleteClient( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = deleteClientSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId } = parsedParams.data; + + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with ID ${clientId} not found` + ) + ); + } + + await db.transaction(async (trx) => { + // Delete the client-site associations first + await trx + .delete(clientSites) + .where(eq(clientSites.clientId, clientId)); + + // Then delete the client itself + await trx + .delete(clients) + .where(eq(clients.clientId, clientId)); + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "Client deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/client/getClient.ts b/server/routers/client/getClient.ts new file mode 100644 index 00000000..8f01e87d --- /dev/null +++ b/server/routers/client/getClient.ts @@ -0,0 +1,101 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { clients, clientSites } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import stoi from "@server/lib/stoi"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const getClientSchema = z + .object({ + clientId: z.string().transform(stoi).pipe(z.number().int().positive()), + orgId: z.string() + }) + .strict(); + +async function query(clientId: number, orgId: string) { + // Get the client + const [client] = await db + .select() + .from(clients) + .where(and(eq(clients.clientId, clientId), eq(clients.orgId, orgId))) + .limit(1); + + if (!client) { + return null; + } + + // Get the siteIds associated with this client + const sites = await db + .select({ siteId: clientSites.siteId }) + .from(clientSites) + .where(eq(clientSites.clientId, clientId)); + + // Add the siteIds to the client object + return { + ...client, + siteIds: sites.map((site) => site.siteId) + }; +} + +export type GetClientResponse = NonNullable>>; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/client/{clientId}", + description: "Get a client by its client ID.", + tags: [OpenAPITags.Client, OpenAPITags.Org], + request: { + params: getClientSchema + }, + responses: {} +}); + +export async function getClient( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getClientSchema.safeParse(req.params); + if (!parsedParams.success) { + logger.error( + `Error parsing params: ${fromError(parsedParams.error).toString()}` + ); + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId, orgId } = parsedParams.data; + + const client = await query(clientId, orgId); + + if (!client) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Client not found") + ); + } + + return response(res, { + data: client, + success: true, + error: false, + message: "Client retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/client/index.ts b/server/routers/client/index.ts new file mode 100644 index 00000000..385c7bed --- /dev/null +++ b/server/routers/client/index.ts @@ -0,0 +1,6 @@ +export * from "./pickClientDefaults"; +export * from "./createClient"; +export * from "./deleteClient"; +export * from "./listClients"; +export * from "./updateClient"; +export * from "./getClient"; \ No newline at end of file diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts new file mode 100644 index 00000000..ff03b2e0 --- /dev/null +++ b/server/routers/client/listClients.ts @@ -0,0 +1,229 @@ +import { db } from "@server/db"; +import { + clients, + orgs, + roleClients, + sites, + userClients, + clientSites +} from "@server/db"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import response from "@server/lib/response"; +import { and, count, eq, inArray, or, sql } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const listClientsParamsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +const listClientsSchema = z.object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) +}); + +function queryClients(orgId: string, accessibleClientIds: number[]) { + return db + .select({ + clientId: clients.clientId, + orgId: clients.orgId, + name: clients.name, + pubKey: clients.pubKey, + subnet: clients.subnet, + megabytesIn: clients.megabytesIn, + megabytesOut: clients.megabytesOut, + orgName: orgs.name, + type: clients.type, + online: clients.online + }) + .from(clients) + .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) + .where( + and( + inArray(clients.clientId, accessibleClientIds), + eq(clients.orgId, orgId) + ) + ); +} + +async function getSiteAssociations(clientIds: number[]) { + if (clientIds.length === 0) return []; + + return db + .select({ + clientId: clientSites.clientId, + siteId: clientSites.siteId, + siteName: sites.name, + siteNiceId: sites.niceId + }) + .from(clientSites) + .leftJoin(sites, eq(clientSites.siteId, sites.siteId)) + .where(inArray(clientSites.clientId, clientIds)); +} + +export type ListClientsResponse = { + clients: Array>[0] & { sites: Array<{ + siteId: number; + siteName: string | null; + siteNiceId: string | null; + }> }>; + pagination: { total: number; limit: number; offset: number }; +}; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/clients", + description: "List all clients for an organization.", + tags: [OpenAPITags.Client, OpenAPITags.Org], + request: { + query: listClientsSchema, + params: listClientsParamsSchema + }, + responses: {} +}); + +export async function listClients( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listClientsSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const parsedParams = listClientsParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + const { orgId } = parsedParams.data; + + if (req.user && orgId && orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + let accessibleClients; + if (req.user) { + accessibleClients = await db + .select({ + clientId: sql`COALESCE(${userClients.clientId}, ${roleClients.clientId})` + }) + .from(userClients) + .fullJoin( + roleClients, + eq(userClients.clientId, roleClients.clientId) + ) + .where( + or( + eq(userClients.userId, req.user!.userId), + eq(roleClients.roleId, req.userOrgRoleId!) + ) + ); + } else { + accessibleClients = await db + .select({ clientId: clients.clientId }) + .from(clients) + .where(eq(clients.orgId, orgId)); + } + + const accessibleClientIds = accessibleClients.map( + (client) => client.clientId + ); + const baseQuery = queryClients(orgId, accessibleClientIds); + + // Get client count + const countQuery = db + .select({ count: count() }) + .from(clients) + .where( + and( + inArray(clients.clientId, accessibleClientIds), + eq(clients.orgId, orgId) + ) + ); + + const clientsList = await baseQuery.limit(limit).offset(offset); + const totalCountResult = await countQuery; + const totalCount = totalCountResult[0].count; + + // Get associated sites for all clients + const clientIds = clientsList.map(client => client.clientId); + const siteAssociations = await getSiteAssociations(clientIds); + + // Group site associations by client ID + const sitesByClient = siteAssociations.reduce((acc, association) => { + if (!acc[association.clientId]) { + acc[association.clientId] = []; + } + acc[association.clientId].push({ + siteId: association.siteId, + siteName: association.siteName, + siteNiceId: association.siteNiceId + }); + return acc; + }, {} as Record>); + + // Merge clients with their site associations + const clientsWithSites = clientsList.map(client => ({ + ...client, + sites: sitesByClient[client.clientId] || [] + })); + + return response(res, { + data: { + clients: clientsWithSites, + pagination: { + total: totalCount, + limit, + offset + } + }, + success: true, + error: false, + message: "Clients retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/client/pickClientDefaults.ts b/server/routers/client/pickClientDefaults.ts new file mode 100644 index 00000000..6f452142 --- /dev/null +++ b/server/routers/client/pickClientDefaults.ts @@ -0,0 +1,85 @@ +import { Request, Response, NextFunction } from "express"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { generateId } from "@server/auth/sessions/app"; +import { getNextAvailableClientSubnet } from "@server/lib/ip"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +export type PickClientDefaultsResponse = { + olmId: string; + olmSecret: string; + subnet: string; +}; + +const pickClientDefaultsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/pick-client-defaults", + description: "Return pre-requisite data for creating a client.", + tags: [OpenAPITags.Client, OpenAPITags.Site], + request: { + params: pickClientDefaultsSchema + }, + responses: {} +}); + +export async function pickClientDefaults( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = pickClientDefaultsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const olmId = generateId(15); + const secret = generateId(48); + + const newSubnet = await getNextAvailableClientSubnet(orgId); + if (!newSubnet) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "No available subnet found" + ) + ); + } + + const subnet = newSubnet.split("/")[0]; + + return response(res, { + data: { + olmId: olmId, + olmSecret: secret, + subnet: subnet + }, + success: true, + error: false, + message: "Organization retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/client/updateClient.ts b/server/routers/client/updateClient.ts new file mode 100644 index 00000000..60a48732 --- /dev/null +++ b/server/routers/client/updateClient.ts @@ -0,0 +1,377 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, exitNodes, sites } from "@server/db"; +import { clients, clientSites } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { eq, and } from "drizzle-orm"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { + addPeer as newtAddPeer, + deletePeer as newtDeletePeer +} from "../newt/peers"; +import { + addPeer as olmAddPeer, + deletePeer as olmDeletePeer +} from "../olm/peers"; +import axios from "axios"; + +const updateClientParamsSchema = z + .object({ + clientId: z.string().transform(Number).pipe(z.number().int().positive()) + }) + .strict(); + +const updateClientSchema = z + .object({ + name: z.string().min(1).max(255).optional(), + siteIds: z + .array(z.string().transform(Number).pipe(z.number())) + .optional() + }) + .strict(); + +export type UpdateClientBody = z.infer; + +registry.registerPath({ + method: "post", + path: "/client/{clientId}", + description: "Update a client by its client ID.", + tags: [OpenAPITags.Client], + request: { + params: updateClientParamsSchema, + body: { + content: { + "application/json": { + schema: updateClientSchema + } + } + } + }, + responses: {} +}); + +interface PeerDestination { + destinationIP: string; + destinationPort: number; +} + +export async function updateClient( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = updateClientSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { name, siteIds } = parsedBody.data; + + const parsedParams = updateClientParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId } = parsedParams.data; + + // Fetch the client to make sure it exists and the user has access to it + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with ID ${clientId} not found` + ) + ); + } + + if (siteIds) { + let sitesAdded = []; + let sitesRemoved = []; + + // Fetch existing site associations + const existingSites = await db + .select({ siteId: clientSites.siteId }) + .from(clientSites) + .where(eq(clientSites.clientId, clientId)); + + const existingSiteIds = existingSites.map((site) => site.siteId); + + // Determine which sites were added and removed + sitesAdded = siteIds.filter( + (siteId) => !existingSiteIds.includes(siteId) + ); + sitesRemoved = existingSiteIds.filter( + (siteId) => !siteIds.includes(siteId) + ); + + logger.info( + `Adding ${sitesAdded.length} new sites to client ${client.clientId}` + ); + for (const siteId of sitesAdded) { + if (!client.subnet || !client.pubKey || !client.endpoint) { + logger.debug( + "Client subnet, pubKey or endpoint is not set" + ); + continue; + } + + // TODO: WE NEED TO HANDLE THIS BETTER. RIGHT NOW WE ARE JUST GUESSING BASED ON THE OTHER SITES + // BUT REALLY WE NEED TO TRACK THE USERS PREFERENCE THAT THEY CHOSE IN THE CLIENTS + const isRelayed = true; + + const site = await newtAddPeer(siteId, { + publicKey: client.pubKey, + allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client + endpoint: isRelayed ? "" : client.endpoint + }); + + if (!site) { + logger.debug("Failed to add peer to newt - missing site"); + continue; + } + + if (!site.endpoint || !site.publicKey) { + logger.debug("Site endpoint or publicKey is not set"); + continue; + } + + let endpoint; + + if (isRelayed) { + if (!site.exitNodeId) { + logger.warn( + `Site ${site.siteId} has no exit node, skipping` + ); + return null; + } + + // get the exit node for the site + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, site.exitNodeId)) + .limit(1); + + if (!exitNode) { + logger.warn( + `Exit node not found for site ${site.siteId}` + ); + return null; + } + + endpoint = `${exitNode.endpoint}:21820`; + } else { + if (!endpoint) { + logger.warn( + `Site ${site.siteId} has no endpoint, skipping` + ); + return null; + } + endpoint = site.endpoint; + } + + await olmAddPeer(client.clientId, { + siteId: site.siteId, + endpoint: endpoint, + publicKey: site.publicKey, + serverIP: site.address, + serverPort: site.listenPort, + remoteSubnets: site.remoteSubnets + }); + } + + logger.info( + `Removing ${sitesRemoved.length} sites from client ${client.clientId}` + ); + for (const siteId of sitesRemoved) { + if (!client.pubKey) { + logger.debug("Client pubKey is not set"); + continue; + } + const site = await newtDeletePeer(siteId, client.pubKey); + if (!site) { + logger.debug( + "Failed to delete peer from newt - missing site" + ); + continue; + } + if (!site.endpoint || !site.publicKey) { + logger.debug("Site endpoint or publicKey is not set"); + continue; + } + await olmDeletePeer( + client.clientId, + site.siteId, + site.publicKey + ); + } + } + + await db.transaction(async (trx) => { + // Update client name if provided + if (name) { + await trx + .update(clients) + .set({ name }) + .where(eq(clients.clientId, clientId)); + } + + // Update site associations if provided + if (siteIds) { + // Delete existing site associations + await trx + .delete(clientSites) + .where(eq(clientSites.clientId, clientId)); + + // Create new site associations + if (siteIds.length > 0) { + await trx.insert(clientSites).values( + siteIds.map((siteId) => ({ + clientId, + siteId + })) + ); + } + } + + if (client.endpoint) { + // get all sites for this client and join with exit nodes with site.exitNodeId + const sitesData = await db + .select() + .from(sites) + .innerJoin( + clientSites, + eq(sites.siteId, clientSites.siteId) + ) + .leftJoin( + exitNodes, + eq(sites.exitNodeId, exitNodes.exitNodeId) + ) + .where(eq(clientSites.clientId, client.clientId)); + + let exitNodeDestinations: { + reachableAt: string; + destinations: PeerDestination[]; + }[] = []; + + for (const site of sitesData) { + if (!site.sites.subnet) { + logger.warn( + `Site ${site.sites.siteId} has no subnet, skipping` + ); + continue; + } + // find the destinations in the array + let destinations = exitNodeDestinations.find( + (d) => d.reachableAt === site.exitNodes?.reachableAt + ); + + if (!destinations) { + destinations = { + reachableAt: site.exitNodes?.reachableAt || "", + destinations: [ + { + destinationIP: + site.sites.subnet.split("/")[0], + destinationPort: site.sites.listenPort || 0 + } + ] + }; + } else { + // add to the existing destinations + destinations.destinations.push({ + destinationIP: site.sites.subnet.split("/")[0], + destinationPort: site.sites.listenPort || 0 + }); + } + + // update it in the array + exitNodeDestinations = exitNodeDestinations.filter( + (d) => d.reachableAt !== site.exitNodes?.reachableAt + ); + exitNodeDestinations.push(destinations); + } + + for (const destination of exitNodeDestinations) { + try { + logger.info( + `Updating destinations for exit node at ${destination.reachableAt}` + ); + const payload = { + sourceIp: client.endpoint?.split(":")[0] || "", + sourcePort: parseInt(client.endpoint?.split(":")[1]) || 0, + destinations: destination.destinations + }; + logger.info( + `Payload for update-destinations: ${JSON.stringify(payload, null, 2)}` + ); + const response = await axios.post( + `${destination.reachableAt}/update-destinations`, + payload, + { + headers: { + "Content-Type": "application/json" + } + } + ); + + logger.info("Destinations updated:", { + peer: response.data.status + }); + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error( + `Error updating destinations (can Pangolin see Gerbil HTTP API?) for exit node at ${destination.reachableAt} (status: ${error.response?.status}): ${JSON.stringify(error.response?.data, null, 2)}` + ); + } else { + logger.error( + `Error updating destinations for exit node at ${destination.reachableAt}: ${error}` + ); + } + } + } + } + + // Fetch the updated client + const [updatedClient] = await trx + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + return response(res, { + data: updatedClient, + success: true, + error: false, + message: "Client updated successfully", + status: HttpCode.OK + }); + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/domain/createOrgDomain.ts b/server/routers/domain/createOrgDomain.ts new file mode 100644 index 00000000..08718d44 --- /dev/null +++ b/server/routers/domain/createOrgDomain.ts @@ -0,0 +1,291 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, Domain, domains, OrgDomains, orgDomains } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { subdomainSchema } from "@server/lib/schemas"; +import { generateId } from "@server/auth/sessions/app"; +import { eq, and } from "drizzle-orm"; +import { isValidDomain } from "@server/lib/validators"; +import { build } from "@server/build"; +import config from "@server/lib/config"; + +const paramsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +const bodySchema = z + .object({ + type: z.enum(["ns", "cname", "wildcard"]), + baseDomain: subdomainSchema + }) + .strict(); + +export type CreateDomainResponse = { + domainId: string; + nsRecords?: string[]; + cnameRecords?: { baseDomain: string; value: string }[]; + aRecords?: { baseDomain: string; value: string }[]; + txtRecords?: { baseDomain: string; value: string }[]; +}; + +// Helper to check if a domain is a subdomain or equal to another domain +function isSubdomainOrEqual(a: string, b: string): boolean { + const aParts = a.toLowerCase().split("."); + const bParts = b.toLowerCase().split("."); + if (aParts.length < bParts.length) return false; + return aParts.slice(-bParts.length).join(".") === bParts.join("."); +} + +export async function createOrgDomain( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { type, baseDomain } = parsedBody.data; + + if (build == "oss") { + if (type !== "wildcard") { + return next( + createHttpError( + HttpCode.NOT_IMPLEMENTED, + "Creating NS or CNAME records is not supported" + ) + ); + } + } else if (build == "enterprise" || build == "saas") { + if (type !== "ns" && type !== "cname") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid domain type. Only NS, CNAME are allowed." + ) + ); + } + } + + // Validate organization exists + if (!isValidDomain(baseDomain)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid domain format") + ); + } + + let numOrgDomains: OrgDomains[] | undefined; + let aRecords: CreateDomainResponse["aRecords"]; + let cnameRecords: CreateDomainResponse["cnameRecords"]; + let txtRecords: CreateDomainResponse["txtRecords"]; + let nsRecords: CreateDomainResponse["nsRecords"]; + let returned: Domain | undefined; + + await db.transaction(async (trx) => { + const [existing] = await trx + .select() + .from(domains) + .where( + and( + eq(domains.baseDomain, baseDomain), + eq(domains.type, type) + ) + ) + .leftJoin( + orgDomains, + eq(orgDomains.domainId, domains.domainId) + ); + + if (existing) { + const { + domains: existingDomain, + orgDomains: existingOrgDomain + } = existing; + + // user alrady added domain to this account + // always reject + if (existingOrgDomain?.orgId === orgId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Domain is already added to this org" + ) + ); + } + + // domain already exists elsewhere + // check if it's already fully verified + if (existingDomain.verified) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Domain is already verified to an org" + ) + ); + } + } + + // --- Domain overlap logic --- + // Only consider existing verified domains + const verifiedDomains = await trx + .select() + .from(domains) + .where(eq(domains.verified, true)); + + if (type == "cname") { + // Block if a verified CNAME exists at the same name + const cnameExists = verifiedDomains.some( + (d) => d.type === "cname" && d.baseDomain === baseDomain + ); + if (cnameExists) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `A CNAME record already exists for ${baseDomain}. Only one CNAME record is allowed per domain.` + ) + ); + } + // Block if a verified NS exists at or below (same or subdomain) + const nsAtOrBelow = verifiedDomains.some( + (d) => + d.type === "ns" && + (isSubdomainOrEqual(baseDomain, d.baseDomain) || + baseDomain === d.baseDomain) + ); + if (nsAtOrBelow) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `A nameserver (NS) record exists at or below ${baseDomain}. You cannot create a CNAME record here.` + ) + ); + } + } else if (type == "ns") { + // Block if a verified NS exists at or below (same or subdomain) + const nsAtOrBelow = verifiedDomains.some( + (d) => + d.type === "ns" && + (isSubdomainOrEqual(baseDomain, d.baseDomain) || + baseDomain === d.baseDomain) + ); + if (nsAtOrBelow) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `A nameserver (NS) record already exists at or below ${baseDomain}. You cannot create another NS record here.` + ) + ); + } + } else if (type == "wildcard") { + // TODO: Figure out how to handle wildcards + } + + const domainId = generateId(15); + + const [insertedDomain] = await trx + .insert(domains) + .values({ + domainId, + baseDomain, + type, + verified: build == "oss" ? true : false + }) + .returning(); + + returned = insertedDomain; + + // add domain to account + await trx + .insert(orgDomains) + .values({ + orgId, + domainId + }) + .returning(); + + // TODO: This needs to be cross region and not hardcoded + if (type === "ns") { + nsRecords = config.getRawConfig().dns.nameservers as string[]; + } else if (type === "cname") { + cnameRecords = [ + { + value: `${domainId}.${config.getRawConfig().dns.cname_extension}`, + baseDomain: baseDomain + }, + { + value: `_acme-challenge.${domainId}.${config.getRawConfig().dns.cname_extension}`, + baseDomain: `_acme-challenge.${baseDomain}` + } + ]; + } else if (type === "wildcard") { + aRecords = [ + { + value: `Server IP Address`, + baseDomain: `*.${baseDomain}` + }, + { + value: `Server IP Address`, + baseDomain: `${baseDomain}` + } + ]; + } + + numOrgDomains = await trx + .select() + .from(orgDomains) + .where(eq(orgDomains.orgId, orgId)); + }); + + if (!returned) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create domain" + ) + ); + } + + return response(res, { + data: { + domainId: returned.domainId, + cnameRecords, + txtRecords, + nsRecords, + aRecords + }, + success: true, + error: false, + message: "Domain created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/domain/deleteOrgDomain.ts b/server/routers/domain/deleteOrgDomain.ts new file mode 100644 index 00000000..345dafe7 --- /dev/null +++ b/server/routers/domain/deleteOrgDomain.ts @@ -0,0 +1,104 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, domains, OrgDomains, orgDomains } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { and, eq } from "drizzle-orm"; + +const paramsSchema = z + .object({ + domainId: z.string(), + orgId: z.string() + }) + .strict(); + +export type DeleteAccountDomainResponse = { + success: boolean; +}; + +export async function deleteAccountDomain( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsed = paramsSchema.safeParse(req.params); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsed.error).toString() + ) + ); + } + const { domainId, orgId } = parsed.data; + + let numOrgDomains: OrgDomains[] | undefined; + + await db.transaction(async (trx) => { + const [existing] = await trx + .select() + .from(orgDomains) + .where( + and( + eq(orgDomains.orgId, orgId), + eq(orgDomains.domainId, domainId) + ) + ) + .innerJoin( + domains, + eq(orgDomains.domainId, domains.domainId) + ); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Domain not found for this account" + ) + ); + } + + if (existing.domains.configManaged) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Cannot delete a domain that is managed by the config" + ) + ); + } + + await trx + .delete(orgDomains) + .where( + and( + eq(orgDomains.orgId, orgId), + eq(orgDomains.domainId, domainId) + ) + ); + + await trx.delete(domains).where(eq(domains.domainId, domainId)); + + numOrgDomains = await trx + .select() + .from(orgDomains) + .where(eq(orgDomains.orgId, orgId)); + }); + + return response(res, { + data: { success: true }, + success: true, + error: false, + message: "Domain deleted from account successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/domain/index.ts b/server/routers/domain/index.ts index 2233b069..c0cafafe 100644 --- a/server/routers/domain/index.ts +++ b/server/routers/domain/index.ts @@ -1 +1,4 @@ export * from "./listDomains"; +export * from "./createOrgDomain"; +export * from "./deleteOrgDomain"; +export * from "./restartOrgDomain"; \ No newline at end of file diff --git a/server/routers/domain/listDomains.ts b/server/routers/domain/listDomains.ts index a8216c5f..fe51cde6 100644 --- a/server/routers/domain/listDomains.ts +++ b/server/routers/domain/listDomains.ts @@ -37,7 +37,12 @@ async function queryDomains(orgId: string, limit: number, offset: number) { const res = await db .select({ domainId: domains.domainId, - baseDomain: domains.baseDomain + baseDomain: domains.baseDomain, + verified: domains.verified, + type: domains.type, + failed: domains.failed, + tries: domains.tries, + configManaged: domains.configManaged }) .from(orgDomains) .where(eq(orgDomains.orgId, orgId)) @@ -112,7 +117,7 @@ export async function listDomains( }, success: true, error: false, - message: "Users retrieved successfully", + message: "Domains retrieved successfully", status: HttpCode.OK }); } catch (error) { diff --git a/server/routers/domain/restartOrgDomain.ts b/server/routers/domain/restartOrgDomain.ts new file mode 100644 index 00000000..f40f2516 --- /dev/null +++ b/server/routers/domain/restartOrgDomain.ts @@ -0,0 +1,57 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, domains } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { and, eq } from "drizzle-orm"; + +const paramsSchema = z + .object({ + domainId: z.string(), + orgId: z.string() + }) + .strict(); + +export type RestartOrgDomainResponse = { + success: boolean; +}; + +export async function restartOrgDomain( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsed = paramsSchema.safeParse(req.params); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsed.error).toString() + ) + ); + } + const { domainId, orgId } = parsed.data; + + await db + .update(domains) + .set({ failed: false, tries: 0 }) + .where(and(eq(domains.domainId, domainId))); + + return response(res, { + data: { success: true }, + success: true, + error: false, + message: "Domain restarted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 8cb3a19d..5bae553e 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -8,6 +8,7 @@ import * as target from "./target"; import * as user from "./user"; import * as auth from "./auth"; import * as role from "./role"; +import * as client from "./client"; import * as supporterKey from "./supporterKey"; import * as accessToken from "./accessToken"; import * as idp from "./idp"; @@ -16,7 +17,6 @@ import * as apiKeys from "./apiKeys"; import HttpCode from "@server/types/HttpCode"; import { verifyAccessTokenAccess, - rateLimitMiddleware, verifySessionMiddleware, verifySessionUserMiddleware, verifyOrgAccess, @@ -29,14 +29,20 @@ import { getUserOrgs, verifyUserIsServerAdmin, verifyIsLoggedInUser, - verifyApiKeyAccess + verifyClientAccess, + verifyApiKeyAccess, + verifyDomainAccess, + verifyClientsEnabled, + verifyUserHasAction, + verifyUserIsOrgOwner } from "@server/middlewares"; -import { verifyUserHasAction } from "../middlewares/verifyUserHasAction"; +import { createStore } from "@server/lib/rateLimitStore"; import { ActionsEnum } from "@server/auth/actions"; -import { verifyUserIsOrgOwner } from "../middlewares/verifyUserIsOrgOwner"; -import { createNewt, getToken } from "./newt"; +import { createNewt, getNewtToken } from "./newt"; +import { getOlmToken } from "./olm"; import rateLimit from "express-rate-limit"; import createHttpError from "http-errors"; +import { build } from "@server/build"; // Root routes export const unauthenticated = Router(); @@ -49,8 +55,11 @@ unauthenticated.get("/", (_, res) => { export const authenticated = Router(); authenticated.use(verifySessionUserMiddleware); +authenticated.get("/pick-org-defaults", org.pickOrgDefaults); authenticated.get("/org/checkId", org.checkId); -authenticated.put("/org", getUserOrgs, org.createOrg); +if (build === "oss" || build === "enterprise") { + authenticated.put("/org", getUserOrgs, org.createOrg); +} authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs); authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs); @@ -105,6 +114,55 @@ authenticated.get( verifyUserHasAction(ActionsEnum.getSite), site.getSite ); + +authenticated.get( + "/org/:orgId/pick-client-defaults", + verifyClientsEnabled, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createClient), + client.pickClientDefaults +); + +authenticated.get( + "/org/:orgId/clients", + verifyClientsEnabled, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listClients), + client.listClients +); + +authenticated.get( + "/org/:orgId/client/:clientId", + verifyClientsEnabled, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getClient), + client.getClient +); + +authenticated.put( + "/org/:orgId/client", + verifyClientsEnabled, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createClient), + client.createClient +); + +authenticated.delete( + "/client/:clientId", + verifyClientsEnabled, + verifyClientAccess, + verifyUserHasAction(ActionsEnum.deleteClient), + client.deleteClient +); + +authenticated.post( + "/client/:clientId", + verifyClientsEnabled, + verifyClientAccess, // this will check if the user has access to the client + verifyUserHasAction(ActionsEnum.updateClient), // this will check if the user has permission to update the client + client.updateClient +); + // authenticated.get( // "/site/:siteId/roles", // verifySiteAccess, @@ -175,6 +233,12 @@ authenticated.get( resource.listResources ); +authenticated.get( + "/org/:orgId/user-resources", + verifyOrgAccess, + resource.getUserResources +); + authenticated.get( "/org/:orgId/domains", verifyOrgAccess, @@ -476,6 +540,7 @@ unauthenticated.get("/resource/:resourceId/auth", resource.getResourceAuthInfo); unauthenticated.get("/user", verifySessionMiddleware, user.getUser); authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers); +authenticated.get("/user/:userId", verifyUserIsServerAdmin, user.adminGetUser); authenticated.delete( "/user/:userId", verifyUserIsServerAdmin, @@ -491,6 +556,12 @@ authenticated.put( authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); +authenticated.post( + "/user/:userId/2fa", + verifyUserIsServerAdmin, + user.updateUser2FA +); + authenticated.get( "/org/:orgId/users", verifyOrgAccess, @@ -555,8 +626,6 @@ authenticated.post( authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp); -authenticated.get("/idp", verifyUserIsServerAdmin, idp.listIdps); - authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); authenticated.put( @@ -692,46 +761,178 @@ authenticated.get( apiKeys.getApiKey ); +authenticated.put( + `/org/:orgId/domain`, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createOrgDomain), + domain.createOrgDomain +); + +authenticated.post( + `/org/:orgId/domain/:domainId/restart`, + verifyOrgAccess, + verifyDomainAccess, + verifyUserHasAction(ActionsEnum.restartOrgDomain), + domain.restartOrgDomain +); + +authenticated.delete( + `/org/:orgId/domain/:domainId`, + verifyOrgAccess, + verifyDomainAccess, + verifyUserHasAction(ActionsEnum.deleteOrgDomain), + domain.deleteAccountDomain +); + // Auth routes export const authRouter = Router(); unauthenticated.use("/auth", authRouter); authRouter.use( - rateLimitMiddleware({ - windowMin: - config.getRawConfig().rate_limits.auth?.window_minutes || - config.getRawConfig().rate_limits.global.window_minutes, - max: - config.getRawConfig().rate_limits.auth?.max_requests || - config.getRawConfig().rate_limits.global.max_requests, - type: "IP_AND_PATH" + rateLimit({ + windowMs: config.getRawConfig().rate_limits.auth.window_minutes, + max: config.getRawConfig().rate_limits.auth.max_requests, + keyGenerator: (req) => `authRouterGlobal:${req.ip}:${req.path}`, + handler: (req, res, next) => { + const message = `Rate limit exceeded. You can make ${config.getRawConfig().rate_limits.auth.max_requests} requests every ${config.getRawConfig().rate_limits.auth.window_minutes} minute(s).`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() }) ); -authRouter.put("/signup", auth.signup); -authRouter.post("/login", auth.login); +authRouter.put( + "/signup", + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 15, + keyGenerator: (req) => `signup:${req.ip}:${req.body.email}`, + handler: (req, res, next) => { + const message = `You can only sign up ${15} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + auth.signup +); +authRouter.post( + "/login", + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 15, + keyGenerator: (req) => `login:${req.body.email || req.ip}`, + handler: (req, res, next) => { + const message = `You can only log in ${15} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + auth.login +); authRouter.post("/logout", auth.logout); -authRouter.post("/newt/get-token", getToken); +authRouter.post( + "/newt/get-token", + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 900, + keyGenerator: (req) => `newtGetToken:${req.body.newtId || req.ip}`, + handler: (req, res, next) => { + const message = `You can only request a Newt token ${900} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + getNewtToken +); +authRouter.post( + "/olm/get-token", + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 900, + keyGenerator: (req) => `newtGetToken:${req.body.newtId || req.ip}`, + handler: (req, res, next) => { + const message = `You can only request an Olm token ${900} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + getOlmToken +); -authRouter.post("/2fa/enable", verifySessionUserMiddleware, auth.verifyTotp); +authRouter.post( + "/2fa/enable", + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 15, + keyGenerator: (req) => { + return `signup:${req.body.email || req.user?.userId || req.ip}`; + }, + handler: (req, res, next) => { + const message = `You can only enable 2FA ${15} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + auth.verifyTotp +); authRouter.post( "/2fa/request", - verifySessionUserMiddleware, + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 15, + keyGenerator: (req) => { + return `signup:${req.body.email || req.user?.userId || req.ip}`; + }, + handler: (req, res, next) => { + const message = `You can only request a 2FA code ${15} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), auth.requestTotpSecret ); -authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa); -authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail); +authRouter.post( + "/2fa/disable", + verifySessionUserMiddleware, + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 15, + keyGenerator: (req) => `signup:${req.user?.userId || req.ip}`, + handler: (req, res, next) => { + const message = `You can only disable 2FA ${15} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + auth.disable2fa +); +authRouter.post( + "/verify-email", + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 15, + keyGenerator: (req) => `signup:${req.body.email || req.ip}`, + handler: (req, res, next) => { + const message = `You can only sign up ${15} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + verifySessionMiddleware, + auth.verifyEmail +); authRouter.post( "/verify-email/request", verifySessionMiddleware, rateLimit({ windowMs: 15 * 60 * 1000, - max: 3, - keyGenerator: (req) => `requestEmailVerificationCode:${req.body.email}`, + max: 15, + keyGenerator: (req) => `requestEmailVerificationCode:${req.body.email || req.ip}`, handler: (req, res, next) => { - const message = `You can only request an email verification code ${3} times every ${15} minutes. Please try again later.`; + const message = `You can only request an email verification code ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); - } + }, + store: createStore() }), auth.requestEmailVerificationCode ); @@ -746,31 +947,75 @@ authRouter.post( "/reset-password/request", rateLimit({ windowMs: 15 * 60 * 1000, - max: 3, - keyGenerator: (req) => `requestPasswordReset:${req.body.email}`, + max: 15, + keyGenerator: (req) => `requestPasswordReset:${req.body.email || req.ip}`, handler: (req, res, next) => { - const message = `You can only request a password reset ${3} times every ${15} minutes. Please try again later.`; + const message = `You can only request a password reset ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); - } + }, + store: createStore() }), auth.requestPasswordReset ); -authRouter.post("/reset-password/", auth.resetPassword); +authRouter.post( + "/reset-password/", + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 15, + keyGenerator: (req) => `resetPassword:${req.body.email || req.ip}`, + handler: (req, res, next) => { + const message = `You can only request a password reset ${15} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + auth.resetPassword +); -authRouter.post("/resource/:resourceId/password", resource.authWithPassword); -authRouter.post("/resource/:resourceId/pincode", resource.authWithPincode); +authRouter.post( + "/resource/:resourceId/password", + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 15, + keyGenerator: (req) => + `authWithPassword:${req.ip}:${req.params.resourceId || req.ip}`, + handler: (req, res, next) => { + const message = `You can only authenticate with password ${15} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + resource.authWithPassword +); +authRouter.post( + "/resource/:resourceId/pincode", + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 15, + keyGenerator: (req) => + `authWithPincode:${req.ip}:${req.params.resourceId || req.ip}`, + handler: (req, res, next) => { + const message = `You can only authenticate with pincode ${15} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + resource.authWithPincode +); authRouter.post( "/resource/:resourceId/whitelist", rateLimit({ windowMs: 15 * 60 * 1000, - max: 10, - keyGenerator: (req) => `authWithWhitelist:${req.body.email}`, + max: 15, + keyGenerator: (req) => + `authWithWhitelist:${req.ip}:${req.body.email}:${req.params.resourceId}`, handler: (req, res, next) => { - const message = `You can only request an email OTP ${10} times every ${15} minutes. Please try again later.`; + const message = `You can only request an email OTP ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); - } + }, + store: createStore() }), resource.authWithWhitelist ); @@ -788,3 +1033,62 @@ authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback); authRouter.put("/set-server-admin", auth.setServerAdmin); authRouter.get("/initial-setup-complete", auth.initialSetupComplete); + +// Security Key routes +authRouter.post( + "/security-key/register/start", + verifySessionUserMiddleware, + rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, // Allow 5 security key registrations per 15 minutes + keyGenerator: (req) => `securityKeyRegister:${req.user?.userId || req.ip}`, + handler: (req, res, next) => { + const message = `You can only register a security key ${5} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + auth.startRegistration +); +authRouter.post( + "/security-key/register/verify", + verifySessionUserMiddleware, + auth.verifyRegistration +); +authRouter.post( + "/security-key/authenticate/start", + rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10, // Allow 10 authentication attempts per 15 minutes per IP + keyGenerator: (req) => { + return `securityKeyAuth:${req.body.email || req.ip}`; + }, + handler: (req, res, next) => { + const message = `You can only attempt security key authentication ${10} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + auth.startAuthentication +); +authRouter.post("/security-key/authenticate/verify", auth.verifyAuthentication); +authRouter.get( + "/security-key/list", + verifySessionUserMiddleware, + auth.listSecurityKeys +); +authRouter.delete( + "/security-key/:credentialId", + verifySessionUserMiddleware, + rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 20, // Allow 10 authentication attempts per 15 minutes per IP + keyGenerator: (req) => `securityKeyAuth:${req.user?.userId || req.ip}`, + handler: (req, res, next) => { + const message = `You can only delete a security key ${10} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + auth.deleteSecurityKey +); diff --git a/server/routers/gerbil/getAllRelays.ts b/server/routers/gerbil/getAllRelays.ts new file mode 100644 index 00000000..abe4d593 --- /dev/null +++ b/server/routers/gerbil/getAllRelays.ts @@ -0,0 +1,160 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { clients, exitNodes, newts, olms, Site, sites, clientSites } from "@server/db"; +import { db } from "@server/db"; +import { eq } from "drizzle-orm"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +// Define Zod schema for request validation +const getAllRelaysSchema = z.object({ + publicKey: z.string().optional(), +}); + +// Type for peer destination +interface PeerDestination { + destinationIP: string; + destinationPort: number; +} + +// Updated mappings type to support multiple destinations per endpoint +interface ProxyMapping { + destinations: PeerDestination[]; +} + +export async function getAllRelays( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + // Validate request parameters + const parsedParams = getAllRelaysSchema.safeParse(req.body); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { publicKey } = parsedParams.data; + + if (!publicKey) { + return next(createHttpError(HttpCode.BAD_REQUEST, 'publicKey is required')); + } + + // Fetch exit node + let [exitNode] = await db.select().from(exitNodes).where(eq(exitNodes.publicKey, publicKey)); + if (!exitNode) { + return next(createHttpError(HttpCode.NOT_FOUND, "Exit node not found")); + } + + // Fetch sites for this exit node + const sitesRes = await db.select().from(sites).where(eq(sites.exitNodeId, exitNode.exitNodeId)); + + if (sitesRes.length === 0) { + return res.status(HttpCode.OK).send({ + mappings: {} + }); + } + + // Initialize mappings object for multi-peer support + let mappings: { [key: string]: ProxyMapping } = {}; + + // Process each site + for (const site of sitesRes) { + if (!site.endpoint || !site.subnet || !site.listenPort) { + continue; + } + + // Find all clients associated with this site through clientSites + const clientSitesRes = await db + .select() + .from(clientSites) + .where(eq(clientSites.siteId, site.siteId)); + + for (const clientSite of clientSitesRes) { + // Get client information + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientSite.clientId)); + + if (!client || !client.endpoint) { + continue; + } + + // Add this site as a destination for the client + if (!mappings[client.endpoint]) { + mappings[client.endpoint] = { destinations: [] }; + } + + // Add site as a destination for this client + const destination: PeerDestination = { + destinationIP: site.subnet.split("/")[0], + destinationPort: site.listenPort + }; + + // Check if this destination is already in the array to avoid duplicates + const isDuplicate = mappings[client.endpoint].destinations.some( + dest => dest.destinationIP === destination.destinationIP && + dest.destinationPort === destination.destinationPort + ); + + if (!isDuplicate) { + mappings[client.endpoint].destinations.push(destination); + } + } + + // Also handle site-to-site communication (all sites in the same org) + if (site.orgId) { + const orgSites = await db + .select() + .from(sites) + .where(eq(sites.orgId, site.orgId)); + + for (const peer of orgSites) { + // Skip self + if (peer.siteId === site.siteId || !peer.endpoint || !peer.subnet || !peer.listenPort) { + continue; + } + + // Add peer site as a destination for this site + if (!mappings[site.endpoint]) { + mappings[site.endpoint] = { destinations: [] }; + } + + const destination: PeerDestination = { + destinationIP: peer.subnet.split("/")[0], + destinationPort: peer.listenPort + }; + + // Check for duplicates + const isDuplicate = mappings[site.endpoint].destinations.some( + dest => dest.destinationIP === destination.destinationIP && + dest.destinationPort === destination.destinationPort + ); + + if (!isDuplicate) { + mappings[site.endpoint].destinations.push(destination); + } + } + } + } + + logger.debug(`Returning mappings for ${Object.keys(mappings).length} endpoints`); + return res.status(HttpCode.OK).send({ mappings }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred..." + ) + ); + } +} \ No newline at end of file diff --git a/server/routers/gerbil/getConfig.ts b/server/routers/gerbil/getConfig.ts index de3da171..d5ec6ced 100644 --- a/server/routers/gerbil/getConfig.ts +++ b/server/routers/gerbil/getConfig.ts @@ -2,8 +2,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { sites, resources, targets, exitNodes } from "@server/db"; import { db } from "@server/db"; -import { eq } from "drizzle-orm"; -import response from "@server/lib/response"; +import { eq, isNotNull, and } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; @@ -53,7 +52,7 @@ export async function getConfig( } // Fetch exit node - let exitNodeQuery = await db + const exitNodeQuery = await db .select() .from(exitNodes) .where(eq(exitNodes.publicKey, publicKey)); @@ -68,6 +67,10 @@ export async function getConfig( subEndpoint = await getUniqueExitNodeEndpointName(); } + const exitNodeName = + config.getRawConfig().gerbil.exit_node_name || + `Exit Node ${publicKey.slice(0, 8)}`; + // create a new exit node exitNode = await db .insert(exitNodes) @@ -77,7 +80,7 @@ export async function getConfig( address, listenPort, reachableAt, - name: `Exit Node ${publicKey.slice(0, 8)}` + name: exitNodeName }) .returning() .execute(); @@ -101,13 +104,30 @@ export async function getConfig( const sitesRes = await db .select() .from(sites) - .where(eq(sites.exitNodeId, exitNode[0].exitNodeId)); + .where( + and( + eq(sites.exitNodeId, exitNode[0].exitNodeId), + isNotNull(sites.pubKey), + isNotNull(sites.subnet) + ) + ); - const peers = await Promise.all( + let peers = await Promise.all( sitesRes.map(async (site) => { + if (site.type === "wireguard") { + return { + publicKey: site.pubKey, + allowedIps: await getAllowedIps(site.siteId) + }; + } else if (site.type === "newt") { + return { + publicKey: site.pubKey, + allowedIps: [site.subnet!] + }; + } return { - publicKey: site.pubKey, - allowedIps: await getAllowedIps(site.siteId) + publicKey: null, + allowedIps: [] }; }) ); diff --git a/server/routers/gerbil/index.ts b/server/routers/gerbil/index.ts index 82f82c4c..4a4f3b60 100644 --- a/server/routers/gerbil/index.ts +++ b/server/routers/gerbil/index.ts @@ -1,2 +1,4 @@ export * from "./getConfig"; export * from "./receiveBandwidth"; +export * from "./updateHolePunch"; +export * from "./getAllRelays"; \ No newline at end of file diff --git a/server/routers/gerbil/peers.ts b/server/routers/gerbil/peers.ts index ce378ad4..40203c41 100644 --- a/server/routers/gerbil/peers.ts +++ b/server/routers/gerbil/peers.ts @@ -1,15 +1,24 @@ -import axios from 'axios'; -import logger from '@server/logger'; +import axios from "axios"; +import logger from "@server/logger"; import { db } from "@server/db"; -import { exitNodes } from '@server/db'; -import { eq } from 'drizzle-orm'; +import { exitNodes } from "@server/db"; +import { eq } from "drizzle-orm"; -export async function addPeer(exitNodeId: number, peer: { - publicKey: string; - allowedIps: string[]; -}) { - - const [exitNode] = await db.select().from(exitNodes).where(eq(exitNodes.exitNodeId, exitNodeId)).limit(1); +export async function addPeer( + exitNodeId: number, + peer: { + publicKey: string; + allowedIps: string[]; + } +) { + logger.info( + `Adding peer with public key ${peer.publicKey} to exit node ${exitNodeId}` + ); + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, exitNodeId)) + .limit(1); if (!exitNode) { throw new Error(`Exit node with ID ${exitNodeId} not found`); } @@ -18,24 +27,40 @@ export async function addPeer(exitNodeId: number, peer: { } try { - const response = await axios.post(`${exitNode.reachableAt}/peer`, peer, { - headers: { - 'Content-Type': 'application/json', + const response = await axios.post( + `${exitNode.reachableAt}/peer`, + peer, + { + headers: { + "Content-Type": "application/json" + } } - }); + ); - logger.info('Peer added successfully:', { peer: response.data.status }); + logger.info("Peer added successfully:", { peer: response.data.status }); return response.data; } catch (error) { if (axios.isAxiosError(error)) { - throw new Error(`Error communicating with Gerbil. Make sure Pangolin can reach the Gerbil HTTP API: ${error.response?.status}`); + logger.error( + `Error adding peer (can Pangolin see Gerbil HTTP API?) for exit node at ${exitNode.reachableAt} (status: ${error.response?.status}): ${error.message}` + ); + } else { + logger.error( + `Error adding peer for exit node at ${exitNode.reachableAt}: ${error}` + ); } - throw error; } } export async function deletePeer(exitNodeId: number, publicKey: string) { - const [exitNode] = await db.select().from(exitNodes).where(eq(exitNodes.exitNodeId, exitNodeId)).limit(1); + logger.info( + `Deleting peer with public key ${publicKey} from exit node ${exitNodeId}` + ); + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, exitNodeId)) + .limit(1); if (!exitNode) { throw new Error(`Exit node with ID ${exitNodeId} not found`); } @@ -43,13 +68,20 @@ export async function deletePeer(exitNodeId: number, publicKey: string) { throw new Error(`Exit node with ID ${exitNodeId} is not reachable`); } try { - const response = await axios.delete(`${exitNode.reachableAt}/peer?public_key=${encodeURIComponent(publicKey)}`); - logger.info('Peer deleted successfully:', response.data.status); + const response = await axios.delete( + `${exitNode.reachableAt}/peer?public_key=${encodeURIComponent(publicKey)}` + ); + logger.info("Peer deleted successfully:", response.data.status); return response.data; } catch (error) { if (axios.isAxiosError(error)) { - throw new Error(`Error communicating with Gerbil. Make sure Pangolin can reach the Gerbil HTTP API: ${error.response?.status}`); + logger.error( + `Error deleting peer (can Pangolin see Gerbil HTTP API?) for exit node at ${exitNode.reachableAt} (status: ${error.response?.status}): ${error.message}` + ); + } else { + logger.error( + `Error deleting peer for exit node at ${exitNode.reachableAt}: ${error}` + ); } - throw error; } } diff --git a/server/routers/gerbil/receiveBandwidth.ts b/server/routers/gerbil/receiveBandwidth.ts index cd025b7e..5e672d0f 100644 --- a/server/routers/gerbil/receiveBandwidth.ts +++ b/server/routers/gerbil/receiveBandwidth.ts @@ -1,12 +1,15 @@ import { Request, Response, NextFunction } from "express"; -import { eq } from "drizzle-orm"; -import { sites, } from "@server/db"; +import { eq, and, lt, inArray, sql } from "drizzle-orm"; +import { sites } from "@server/db"; import { db } from "@server/db"; import logger from "@server/logger"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; +// Track sites that are already offline to avoid unnecessary queries +const offlineSites = new Set(); + interface PeerBandwidth { publicKey: string; bytesIn: number; @@ -25,47 +28,101 @@ export const receiveBandwidth = async ( throw new Error("Invalid bandwidth data"); } + const currentTime = new Date(); + const oneMinuteAgo = new Date(currentTime.getTime() - 60000); // 1 minute ago + + logger.debug(`Received data: ${JSON.stringify(bandwidthData)}`); + await db.transaction(async (trx) => { - for (const peer of bandwidthData) { - const { publicKey, bytesIn, bytesOut } = peer; + // First, handle sites that are actively reporting bandwidth + const activePeers = bandwidthData.filter(peer => peer.bytesIn > 0); // Bytesout will have data as it tries to send keep alive messages - const [site] = await trx - .select() - .from(sites) - .where(eq(sites.pubKey, publicKey)) - .limit(1); + if (activePeers.length > 0) { + // Remove any active peers from offline tracking since they're sending data + activePeers.forEach(peer => offlineSites.delete(peer.publicKey)); - if (!site) { - logger.warn(`Site not found for public key: ${publicKey}`); - continue; - } - let online = site.online; + // Aggregate usage data by organization + const orgUsageMap = new Map(); + const orgUptimeMap = new Map(); - // if the bandwidth for the site is > 0 then set it to online. if it has been less than 0 (no update) for 5 minutes then set it to offline - if (bytesIn > 0 || bytesOut > 0) { - online = true; - } else if (site.lastBandwidthUpdate) { - const lastBandwidthUpdate = new Date( - site.lastBandwidthUpdate - ); - const currentTime = new Date(); - const diff = - currentTime.getTime() - lastBandwidthUpdate.getTime(); - if (diff < 300000) { - online = false; + // Update all active sites with bandwidth data and get the site data in one operation + const updatedSites = []; + for (const peer of activePeers) { + const updatedSite = await trx + .update(sites) + .set({ + megabytesOut: sql`${sites.megabytesOut} + ${peer.bytesIn}`, + megabytesIn: sql`${sites.megabytesIn} + ${peer.bytesOut}`, + lastBandwidthUpdate: currentTime.toISOString(), + online: true + }) + .where(eq(sites.pubKey, peer.publicKey)) + .returning({ + online: sites.online, + orgId: sites.orgId, + siteId: sites.siteId, + lastBandwidthUpdate: sites.lastBandwidthUpdate, + }); + + if (updatedSite.length > 0) { + updatedSites.push({ ...updatedSite[0], peer }); } } - // Update the site's bandwidth usage - await trx - .update(sites) - .set({ - megabytesOut: (site.megabytesOut || 0) + bytesIn, - megabytesIn: (site.megabytesIn || 0) + bytesOut, - lastBandwidthUpdate: new Date().toISOString(), - online - }) - .where(eq(sites.siteId, site.siteId)); + // Calculate org usage aggregations using the updated site data + for (const { peer, ...site } of updatedSites) { + // Aggregate bandwidth usage for the org + const totalBandwidth = peer.bytesIn + peer.bytesOut; + const currentOrgUsage = orgUsageMap.get(site.orgId) || 0; + orgUsageMap.set(site.orgId, currentOrgUsage + totalBandwidth); + + // Add 10 seconds of uptime for each active site + const currentOrgUptime = orgUptimeMap.get(site.orgId) || 0; + orgUptimeMap.set(site.orgId, currentOrgUptime + 10 / 60); // Store in minutes and jut add 10 seconds + } + } + + // Handle sites that reported zero bandwidth but need online status updated + const zeroBandwidthPeers = bandwidthData.filter(peer => + peer.bytesIn === 0 && !offlineSites.has(peer.publicKey) // Bytesout will have data as it tries to send keep alive messages + ); + + if (zeroBandwidthPeers.length > 0) { + const zeroBandwidthSites = await trx + .select() + .from(sites) + .where(inArray(sites.pubKey, zeroBandwidthPeers.map(p => p.publicKey))); + + for (const site of zeroBandwidthSites) { + let newOnlineStatus = site.online; + + // Check if site should go offline based on last bandwidth update WITH DATA + if (site.lastBandwidthUpdate) { + const lastUpdateWithData = new Date(site.lastBandwidthUpdate); + if (lastUpdateWithData < oneMinuteAgo) { + newOnlineStatus = false; + } + } else { + // No previous data update recorded, set to offline + newOnlineStatus = false; + } + + // Always update lastBandwidthUpdate to show this instance is receiving reports + // Only update online status if it changed + if (site.online !== newOnlineStatus) { + await trx + .update(sites) + .set({ + online: newOnlineStatus + }) + .where(eq(sites.siteId, site.siteId)); + + // If site went offline, add it to our tracking set + if (!newOnlineStatus && site.pubKey) { + offlineSites.add(site.pubKey); + } + } + } } }); @@ -73,7 +130,7 @@ export const receiveBandwidth = async ( data: {}, success: true, error: false, - message: "Organization retrieved successfully", + message: "Bandwidth data updated successfully", status: HttpCode.OK }); } catch (error) { diff --git a/server/routers/gerbil/updateHolePunch.ts b/server/routers/gerbil/updateHolePunch.ts new file mode 100644 index 00000000..836061d6 --- /dev/null +++ b/server/routers/gerbil/updateHolePunch.ts @@ -0,0 +1,339 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { clients, newts, olms, Site, sites, clientSites, exitNodes } from "@server/db"; +import { db } from "@server/db"; +import { eq } from "drizzle-orm"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { validateNewtSessionToken } from "@server/auth/sessions/newt"; +import { validateOlmSessionToken } from "@server/auth/sessions/olm"; +import axios from "axios"; + +// Define Zod schema for request validation +const updateHolePunchSchema = z.object({ + olmId: z.string().optional(), + newtId: z.string().optional(), + token: z.string(), + ip: z.string(), + port: z.number(), + timestamp: z.number(), + reachableAt: z.string().optional() +}); + +// New response type with multi-peer destination support +interface PeerDestination { + destinationIP: string; + destinationPort: number; +} + +export async function updateHolePunch( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + // Validate request parameters + const parsedParams = updateHolePunchSchema.safeParse(req.body); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { olmId, newtId, ip, port, timestamp, token, reachableAt } = parsedParams.data; + + let currentSiteId: number | undefined; + let destinations: PeerDestination[] = []; + + if (olmId) { + logger.debug(`Got hole punch with ip: ${ip}, port: ${port} for olmId: ${olmId}`); + + const { session, olm: olmSession } = + await validateOlmSessionToken(token); + if (!session || !olmSession) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Unauthorized") + ); + } + + if (olmId !== olmSession.olmId) { + logger.warn(`Olm ID mismatch: ${olmId} !== ${olmSession.olmId}`); + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Unauthorized") + ); + } + + const [olm] = await db + .select() + .from(olms) + .where(eq(olms.olmId, olmId)); + + if (!olm || !olm.clientId) { + logger.warn(`Olm not found: ${olmId}`); + return next( + createHttpError(HttpCode.NOT_FOUND, "Olm not found") + ); + } + + const [client] = await db + .update(clients) + .set({ + endpoint: `${ip}:${port}`, + lastHolePunch: timestamp + }) + .where(eq(clients.clientId, olm.clientId)) + .returning(); + + if (!client) { + logger.warn(`Client not found for olm: ${olmId}`); + return next( + createHttpError(HttpCode.NOT_FOUND, "Client not found") + ); + } + + // // Get all sites that this client is connected to + // const clientSitePairs = await db + // .select() + // .from(clientSites) + // .where(eq(clientSites.clientId, client.clientId)); + + // if (clientSitePairs.length === 0) { + // logger.warn(`No sites found for client: ${client.clientId}`); + // return next( + // createHttpError(HttpCode.NOT_FOUND, "No sites found for client") + // ); + // } + + // // Get all sites details + // const siteIds = clientSitePairs.map(pair => pair.siteId); + + // for (const siteId of siteIds) { + // const [site] = await db + // .select() + // .from(sites) + // .where(eq(sites.siteId, siteId)); + + // if (site && site.subnet && site.listenPort) { + // destinations.push({ + // destinationIP: site.subnet.split("/")[0], + // destinationPort: site.listenPort + // }); + // } + // } + + // get all sites for this client and join with exit nodes with site.exitNodeId + const sitesData = await db + .select() + .from(sites) + .innerJoin(clientSites, eq(sites.siteId, clientSites.siteId)) + .leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId)) + .where(eq(clientSites.clientId, client.clientId)); + + let exitNodeDestinations: { + reachableAt: string; + destinations: PeerDestination[]; + }[] = []; + + for (const site of sitesData) { + if (!site.sites.subnet) { + logger.warn(`Site ${site.sites.siteId} has no subnet, skipping`); + continue; + } + // find the destinations in the array + let destinations = exitNodeDestinations.find( + (d) => d.reachableAt === site.exitNodes?.reachableAt + ); + + if (!destinations) { + destinations = { + reachableAt: site.exitNodes?.reachableAt || "", + destinations: [ + { + destinationIP: site.sites.subnet.split("/")[0], + destinationPort: site.sites.listenPort || 0 + } + ] + }; + } else { + // add to the existing destinations + destinations.destinations.push({ + destinationIP: site.sites.subnet.split("/")[0], + destinationPort: site.sites.listenPort || 0 + }); + } + + // update it in the array + exitNodeDestinations = exitNodeDestinations.filter( + (d) => d.reachableAt !== site.exitNodes?.reachableAt + ); + exitNodeDestinations.push(destinations); + } + + logger.debug(JSON.stringify(exitNodeDestinations, null, 2)); + + for (const destination of exitNodeDestinations) { + // if its the current exit node skip it because it is replying with the same data + if (reachableAt && destination.reachableAt == reachableAt) { + logger.debug(`Skipping update for reachableAt: ${reachableAt}`); + continue; + } + + try { + const response = await axios.post( + `${destination.reachableAt}/update-destinations`, + { + sourceIp: client.endpoint?.split(":")[0] || "", + sourcePort: parseInt(client.endpoint?.split(":")[1] || "0"), + destinations: destination.destinations + }, + { + headers: { + "Content-Type": "application/json" + } + } + ); + + logger.info("Destinations updated:", { + peer: response.data.status + }); + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error( + `Error updating destinations (can Pangolin see Gerbil HTTP API?) for exit node at ${destination.reachableAt} (status: ${error.response?.status}): ${JSON.stringify(error.response?.data, null, 2)}` + ); + } else { + logger.error( + `Error updating destinations for exit node at ${destination.reachableAt}: ${error}` + ); + } + } + } + + // Send the desinations back to the origin + destinations = exitNodeDestinations.find( + (d) => d.reachableAt === reachableAt + )?.destinations || []; + + } else if (newtId) { + logger.debug(`Got hole punch with ip: ${ip}, port: ${port} for newtId: ${newtId}`); + + const { session, newt: newtSession } = + await validateNewtSessionToken(token); + + if (!session || !newtSession) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Unauthorized") + ); + } + + if (newtId !== newtSession.newtId) { + logger.warn(`Newt ID mismatch: ${newtId} !== ${newtSession.newtId}`); + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Unauthorized") + ); + } + + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.newtId, newtId)); + + if (!newt || !newt.siteId) { + logger.warn(`Newt not found: ${newtId}`); + return next( + createHttpError(HttpCode.NOT_FOUND, "New not found") + ); + } + + currentSiteId = newt.siteId; + + // Update the current site with the new endpoint + const [updatedSite] = await db + .update(sites) + .set({ + endpoint: `${ip}:${port}`, + lastHolePunch: timestamp + }) + .where(eq(sites.siteId, newt.siteId)) + .returning(); + + if (!updatedSite || !updatedSite.subnet) { + logger.warn(`Site not found: ${newt.siteId}`); + return next( + createHttpError(HttpCode.NOT_FOUND, "Site not found") + ); + } + + // Find all clients that connect to this site + // const sitesClientPairs = await db + // .select() + // .from(clientSites) + // .where(eq(clientSites.siteId, newt.siteId)); + + // THE NEWT IS NOT SENDING RAW WG TO THE GERBIL SO IDK IF WE REALLY NEED THIS - REMOVING + // Get client details for each client + // for (const pair of sitesClientPairs) { + // const [client] = await db + // .select() + // .from(clients) + // .where(eq(clients.clientId, pair.clientId)); + + // if (client && client.endpoint) { + // const [host, portStr] = client.endpoint.split(':'); + // if (host && portStr) { + // destinations.push({ + // destinationIP: host, + // destinationPort: parseInt(portStr, 10) + // }); + // } + // } + // } + + // If this is a newt/site, also add other sites in the same org + // if (updatedSite.orgId) { + // const orgSites = await db + // .select() + // .from(sites) + // .where(eq(sites.orgId, updatedSite.orgId)); + + // for (const site of orgSites) { + // // Don't add the current site to the destinations + // if (site.siteId !== currentSiteId && site.subnet && site.endpoint && site.listenPort) { + // const [host, portStr] = site.endpoint.split(':'); + // if (host && portStr) { + // destinations.push({ + // destinationIP: host, + // destinationPort: site.listenPort + // }); + // } + // } + // } + // } + } + + // if (destinations.length === 0) { + // logger.warn( + // `No peer destinations found for olmId: ${olmId} or newtId: ${newtId}` + // ); + // return next(createHttpError(HttpCode.NOT_FOUND, "No peer destinations found")); + // } + + // Return the new multi-peer structure + return res.status(HttpCode.OK).send({ + destinations: destinations + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred..." + ) + ); + } +} \ No newline at end of file diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 3d28db24..9991ba9c 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -11,6 +11,7 @@ import { idpOidcConfig, idpOrg, orgs, + Role, roles, userOrgs, users @@ -161,6 +162,12 @@ export async function validateOidcCallback( ); } + logger.debug("State verified", { + urL: ensureTrailingSlash(existingIdp.idpOidcConfig.tokenUrl), + expectedState, + state + }); + const tokens = await client.validateAuthorizationCode( ensureTrailingSlash(existingIdp.idpOidcConfig.tokenUrl), code, @@ -307,6 +314,8 @@ export async function validateOidcCallback( let existingUserId = existingUser?.userId; + let orgUserCounts: { orgId: string; userCount: number }[] = []; + // sync the user with the orgs and roles await db.transaction(async (trx) => { let userId = existingUser?.userId; @@ -410,6 +419,19 @@ export async function validateOidcCallback( })) ); } + + // Loop through all the orgs and get the total number of users from the userOrgs table + for (const org of currentUserOrgs) { + const userCount = await trx + .select() + .from(userOrgs) + .where(eq(userOrgs.orgId, org.orgId)); + + orgUserCounts.push({ + orgId: org.orgId, + userCount: userCount.length + }); + } }); const token = generateSessionToken(); diff --git a/server/routers/integration.ts b/server/routers/integration.ts index fc66a88d..39939e1c 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -5,7 +5,7 @@ import * as domain from "./domain"; import * as target from "./target"; import * as user from "./user"; import * as role from "./role"; -// import * as client from "./client"; +import * as client from "./client"; import * as accessToken from "./accessToken"; import * as apiKeys from "./apiKeys"; import * as idp from "./idp"; @@ -20,7 +20,9 @@ import { verifyApiKeyUserAccess, verifyApiKeySetResourceUsers, verifyApiKeyAccessTokenAccess, - verifyApiKeyIsRoot + verifyApiKeyIsRoot, + verifyApiKeyClientAccess, + verifyClientsEnabled } from "@server/middlewares"; import HttpCode from "@server/types/HttpCode"; import { Router } from "express"; @@ -381,6 +383,20 @@ authenticated.get( user.getOrgUser ); +authenticated.post( + "/user/:userId/2fa", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.updateUser), + user.updateUser2FA +); + +authenticated.get( + "/user/:userId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.getUser), + user.adminGetUser +); + authenticated.get( "/org/:orgId/users", verifyApiKeyOrgAccess, @@ -499,3 +515,51 @@ authenticated.get( verifyApiKeyHasAction(ActionsEnum.listIdpOrgs), idp.listIdpOrgPolicies ); + +authenticated.get( + "/org/:orgId/pick-client-defaults", + verifyClientsEnabled, + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createClient), + client.pickClientDefaults +); + +authenticated.get( + "/org/:orgId/clients", + verifyClientsEnabled, + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listClients), + client.listClients +); + +authenticated.get( + "/org/:orgId/client/:clientId", + verifyClientsEnabled, + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.getClient), + client.getClient +); + +authenticated.put( + "/org/:orgId/client", + verifyClientsEnabled, + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createClient), + client.createClient +); + +authenticated.delete( + "/client/:clientId", + verifyClientsEnabled, + verifyApiKeyClientAccess, + verifyApiKeyHasAction(ActionsEnum.deleteClient), + client.deleteClient +); + +authenticated.post( + "/client/:clientId", + verifyClientsEnabled, + verifyApiKeyClientAccess, + verifyApiKeyHasAction(ActionsEnum.updateClient), + client.updateClient +); diff --git a/server/routers/internal.ts b/server/routers/internal.ts index 345a8b4e..118c8ae3 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -51,6 +51,8 @@ internalRouter.use("/gerbil", gerbilRouter); gerbilRouter.post("/get-config", gerbil.getConfig); gerbilRouter.post("/receive-bandwidth", gerbil.receiveBandwidth); +gerbilRouter.post("/update-hole-punch", gerbil.updateHolePunch); +gerbilRouter.post("/get-all-relays", gerbil.getAllRelays); // Badger routes const badgerRouter = Router(); diff --git a/server/routers/messageHandlers.ts b/server/routers/messageHandlers.ts deleted file mode 100644 index e79f8606..00000000 --- a/server/routers/messageHandlers.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { - handleRegisterMessage, - handleDockerStatusMessage, - handleDockerContainersMessage -} from "./newt"; -import { MessageHandler } from "./ws"; - -export const messageHandlers: Record = { - "newt/wg/register": handleRegisterMessage, - "newt/socket/status": handleDockerStatusMessage, - "newt/socket/containers": handleDockerContainersMessage -}; diff --git a/server/routers/newt/getToken.ts b/server/routers/newt/getNewtToken.ts similarity index 98% rename from server/routers/newt/getToken.ts rename to server/routers/newt/getNewtToken.ts index 15071348..3bf45dcf 100644 --- a/server/routers/newt/getToken.ts +++ b/server/routers/newt/getNewtToken.ts @@ -24,7 +24,7 @@ export const newtGetTokenBodySchema = z.object({ export type NewtGetTokenBody = z.infer; -export async function getToken( +export async function getNewtToken( req: Request, res: Response, next: NextFunction diff --git a/server/routers/newt/handleGetConfigMessage.ts b/server/routers/newt/handleGetConfigMessage.ts new file mode 100644 index 00000000..1059847c --- /dev/null +++ b/server/routers/newt/handleGetConfigMessage.ts @@ -0,0 +1,326 @@ +import { z } from "zod"; +import { MessageHandler } from "../ws"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { + db, + ExitNode, + exitNodes, + resources, + Target, + targets +} from "@server/db"; +import { clients, clientSites, Newt, sites } from "@server/db"; +import { eq, and, inArray } from "drizzle-orm"; +import { updatePeer } from "../olm/peers"; +import axios from "axios"; + +const inputSchema = z.object({ + publicKey: z.string(), + port: z.number().int().positive() +}); + +type Input = z.infer; + +export const handleGetConfigMessage: MessageHandler = async (context) => { + const { message, client, sendToClient } = context; + const newt = client as Newt; + + const now = new Date().getTime() / 1000; + + logger.debug("Handling Newt get config message!"); + + if (!newt) { + logger.warn("Newt not found"); + return; + } + + if (!newt.siteId) { + logger.warn("Newt has no site!"); // TODO: Maybe we create the site here? + return; + } + + const parsed = inputSchema.safeParse(message.data); + if (!parsed.success) { + logger.error( + "handleGetConfigMessage: Invalid input: " + + fromError(parsed.error).toString() + ); + return; + } + + const { publicKey, port } = message.data as Input; + const siteId = newt.siteId; + + // Get the current site data + const [existingSite] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)); + + if (!existingSite) { + logger.warn("handleGetConfigMessage: Site not found"); + return; + } + + // we need to wait for hole punch success + if (!existingSite.endpoint) { + logger.warn(`Site ${existingSite.siteId} has no endpoint, skipping`); + return; + } + + if (existingSite.publicKey !== publicKey) { + // TODO: somehow we should make sure a recent hole punch has happened if this occurs (hole punch could be from the last restart if done quickly) + } + + if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 6) { + logger.warn( + `Site ${existingSite.siteId} last hole punch is too old, skipping` + ); + return; + } + + // update the endpoint and the public key + const [site] = await db + .update(sites) + .set({ + publicKey, + listenPort: port + }) + .where(eq(sites.siteId, siteId)) + .returning(); + + if (!site) { + logger.error("handleGetConfigMessage: Failed to update site"); + return; + } + + let exitNode: ExitNode | undefined; + if (site.exitNodeId) { + [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, site.exitNodeId)) + .limit(1); + if (exitNode.reachableAt) { + try { + const response = await axios.post( + `${exitNode.reachableAt}/update-proxy-mapping`, + { + oldDestination: { + destinationIP: existingSite.subnet?.split("/")[0], + destinationPort: existingSite.listenPort + }, + newDestination: { + destinationIP: site.subnet?.split("/")[0], + destinationPort: site.listenPort + } + }, + { + headers: { + "Content-Type": "application/json" + } + } + ); + + logger.info("Destinations updated:", { + peer: response.data.status + }); + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error( + `Error updating proxy mapping (can Pangolin see Gerbil HTTP API?) for exit node at ${exitNode.reachableAt} (status: ${error.response?.status}): ${error.message}` + ); + } else { + logger.error( + `Error updating proxy mapping for exit node at ${exitNode.reachableAt}: ${error}` + ); + } + } + } + } + + // Get all clients connected to this site + const clientsRes = await db + .select() + .from(clients) + .innerJoin(clientSites, eq(clients.clientId, clientSites.clientId)) + .where(eq(clientSites.siteId, siteId)); + + // Prepare peers data for the response + const peers = await Promise.all( + clientsRes + .filter((client) => { + if (!client.clients.pubKey) { + return false; + } + if (!client.clients.subnet) { + return false; + } + if (!client.clients.endpoint) { + return false; + } + return true; + }) + .map(async (client) => { + // Add or update this peer on the olm if it is connected + try { + if (!site.publicKey) { + logger.warn( + `Site ${site.siteId} has no public key, skipping` + ); + return null; + } + let endpoint = site.endpoint; + if (client.clientSites.isRelayed) { + if (!site.exitNodeId) { + logger.warn( + `Site ${site.siteId} has no exit node, skipping` + ); + return null; + } + + if (!exitNode) { + logger.warn( + `Exit node not found for site ${site.siteId}` + ); + return null; + } + endpoint = `${exitNode.endpoint}:21820`; + } + + if (!endpoint) { + logger.warn( + `Site ${site.siteId} has no endpoint, skipping` + ); + return null; + } + + await updatePeer(client.clients.clientId, { + siteId: site.siteId, + endpoint: endpoint, + publicKey: site.publicKey, + serverIP: site.address, + serverPort: site.listenPort, + remoteSubnets: site.remoteSubnets + }); + } catch (error) { + logger.error( + `Failed to add/update peer ${client.clients.pubKey} to olm ${newt.newtId}: ${error}` + ); + } + + return { + publicKey: client.clients.pubKey!, + allowedIps: [`${client.clients.subnet.split("/")[0]}/32`], // we want to only allow from that client + endpoint: client.clientSites.isRelayed + ? "" + : client.clients.endpoint! // if its relayed it should be localhost + }; + }) + ); + + // Filter out any null values from peers that didn't have an olm + const validPeers = peers.filter((peer) => peer !== null); + + // Improved version + const allResources = await db.transaction(async (tx) => { + // First get all resources for the site + const resourcesList = await tx + .select({ + resourceId: resources.resourceId, + subdomain: resources.subdomain, + fullDomain: resources.fullDomain, + ssl: resources.ssl, + blockAccess: resources.blockAccess, + sso: resources.sso, + emailWhitelistEnabled: resources.emailWhitelistEnabled, + http: resources.http, + proxyPort: resources.proxyPort, + protocol: resources.protocol + }) + .from(resources) + .where(and(eq(resources.siteId, siteId), eq(resources.http, false))); + + // Get all enabled targets for these resources in a single query + const resourceIds = resourcesList.map((r) => r.resourceId); + const allTargets = + resourceIds.length > 0 + ? await tx + .select({ + resourceId: targets.resourceId, + targetId: targets.targetId, + ip: targets.ip, + method: targets.method, + port: targets.port, + internalPort: targets.internalPort, + enabled: targets.enabled, + }) + .from(targets) + .where( + and( + inArray(targets.resourceId, resourceIds), + eq(targets.enabled, true) + ) + ) + : []; + + // Combine the data in JS instead of using SQL for the JSON + return resourcesList.map((resource) => ({ + ...resource, + targets: allTargets.filter( + (target) => target.resourceId === resource.resourceId + ) + })); + }); + + const { tcpTargets, udpTargets } = allResources.reduce( + (acc, resource) => { + // Skip resources with no targets + if (!resource.targets?.length) return acc; + + // Format valid targets into strings + const formattedTargets = resource.targets + .filter( + (target: Target) => + resource.proxyPort && target?.ip && target?.port + ) + .map( + (target: Target) => + `${resource.proxyPort}:${target.ip}:${target.port}` + ); + + // Add to the appropriate protocol array + if (resource.protocol === "tcp") { + acc.tcpTargets.push(...formattedTargets); + } else { + acc.udpTargets.push(...formattedTargets); + } + + return acc; + }, + { tcpTargets: [] as string[], udpTargets: [] as string[] } + ); + + // Build the configuration response + const configResponse = { + ipAddress: site.address, + peers: validPeers, + targets: { + udp: udpTargets, + tcp: tcpTargets + } + }; + + logger.debug("Sending config: ", configResponse); + return { + message: { + type: "newt/wg/receive-config", + data: { + ...configResponse + } + }, + broadcast: false, + excludeSender: false + }; +}; diff --git a/server/routers/newt/handleNewtPingRequestMessage.ts b/server/routers/newt/handleNewtPingRequestMessage.ts new file mode 100644 index 00000000..91266434 --- /dev/null +++ b/server/routers/newt/handleNewtPingRequestMessage.ts @@ -0,0 +1,89 @@ +import { db, sites } from "@server/db"; +import { MessageHandler } from "../ws"; +import { exitNodes, Newt } from "@server/db"; +import logger from "@server/logger"; +import config from "@server/lib/config"; +import { ne, eq, or, and, count } from "drizzle-orm"; + +export const handleNewtPingRequestMessage: MessageHandler = async (context) => { + const { message, client, sendToClient } = context; + const newt = client as Newt; + + logger.info("Handling ping request newt message!"); + + if (!newt) { + logger.warn("Newt not found"); + return; + } + + // TODO: pick which nodes to send and ping better than just all of them + let exitNodesList = await db + .select() + .from(exitNodes); + + exitNodesList = exitNodesList.filter((node) => node.maxConnections !== 0); + + let lastExitNodeId = null; + if (newt.siteId) { + const [lastExitNode] = await db + .select() + .from(sites) + .where(eq(sites.siteId, newt.siteId)) + .limit(1); + lastExitNodeId = lastExitNode?.exitNodeId || null; + } + + const exitNodesPayload = await Promise.all( + exitNodesList.map(async (node) => { + // (MAX_CONNECTIONS - current_connections) / MAX_CONNECTIONS) + // higher = more desirable + // like saying, this node has x% of its capacity left + + let weight = 1; + const maxConnections = node.maxConnections; + if (maxConnections !== null && maxConnections !== undefined) { + const [currentConnections] = await db + .select({ + count: count() + }) + .from(sites) + .where( + and( + eq(sites.exitNodeId, node.exitNodeId), + eq(sites.online, true) + ) + ); + + if (currentConnections.count >= maxConnections) { + return null + } + + weight = + (maxConnections - currentConnections.count) / + maxConnections; + } + + return { + exitNodeId: node.exitNodeId, + exitNodeName: node.name, + endpoint: node.endpoint, + weight, + wasPreviouslyConnected: node.exitNodeId === lastExitNodeId + }; + }) + ); + + // filter out null values + const filteredExitNodes = exitNodesPayload.filter((node) => node !== null); + + return { + message: { + type: "newt/ping/exitNodes", + data: { + exitNodes: filteredExitNodes + } + }, + broadcast: false, // Send to all clients + excludeSender: false // Include sender in broadcast + }; +}; diff --git a/server/routers/newt/handleRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts similarity index 53% rename from server/routers/newt/handleRegisterMessage.ts rename to server/routers/newt/handleNewtRegisterMessage.ts index e63de0e0..71a6fd5c 100644 --- a/server/routers/newt/handleRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -1,20 +1,30 @@ -import { db } from "@server/db"; +import { db, newts } from "@server/db"; import { MessageHandler } from "../ws"; -import { - exitNodes, - resources, - sites, - Target, - targets -} from "@server/db"; +import { exitNodes, Newt, resources, sites, Target, targets } from "@server/db"; import { eq, and, sql, inArray } from "drizzle-orm"; import { addPeer, deletePeer } from "../gerbil/peers"; import logger from "@server/logger"; +import config from "@server/lib/config"; +import { + findNextAvailableCidr, + getNextAvailableClientSubnet +} from "@server/lib/ip"; -export const handleRegisterMessage: MessageHandler = async (context) => { - const { message, newt, sendToClient } = context; +export type ExitNodePingResult = { + exitNodeId: number; + latencyMs: number; + weight: number; + error?: string; + exitNodeName: string; + endpoint: string; + wasPreviouslyConnected: boolean; +}; - logger.info("Handling register message!"); +export const handleNewtRegisterMessage: MessageHandler = async (context) => { + const { message, client, sendToClient } = context; + const newt = client as Newt; + + logger.info("Handling register newt message!"); if (!newt) { logger.warn("Newt not found"); @@ -28,51 +38,126 @@ export const handleRegisterMessage: MessageHandler = async (context) => { const siteId = newt.siteId; - const { publicKey } = message.data; + const { publicKey, pingResults, newtVersion, backwardsCompatible } = + message.data; if (!publicKey) { logger.warn("Public key not provided"); return; } - const [site] = await db + if (backwardsCompatible) { + logger.debug( + "Backwards compatible mode detecting - not sending connect message and waiting for ping response." + ); + return; + } + + let exitNodeId: number | undefined; + if (pingResults) { + const bestPingResult = selectBestExitNode( + pingResults as ExitNodePingResult[] + ); + if (!bestPingResult) { + logger.warn("No suitable exit node found based on ping results"); + return; + } + exitNodeId = bestPingResult.exitNodeId; + } + + if (newtVersion) { + // update the newt version in the database + await db + .update(newts) + .set({ + version: newtVersion as string + }) + .where(eq(newts.newtId, newt.newtId)); + } + + const [oldSite] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .limit(1); - if (!site || !site.exitNodeId) { + if (!oldSite || !oldSite.exitNodeId) { logger.warn("Site not found or does not have exit node"); return; } - await db - .update(sites) - .set({ - pubKey: publicKey - }) - .where(eq(sites.siteId, siteId)) - .returning(); + let siteSubnet = oldSite.subnet; + let exitNodeIdToQuery = oldSite.exitNodeId; + if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) { + // This effectively moves the exit node to the new one + exitNodeIdToQuery = exitNodeId; // Use the provided exitNodeId if it differs from the site's exitNodeId + + const sitesQuery = await db + .select({ + subnet: sites.subnet + }) + .from(sites) + .where(eq(sites.exitNodeId, exitNodeId)); + + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, exitNodeIdToQuery)) + .limit(1); + + const blockSize = config.getRawConfig().gerbil.site_block_size; + const subnets = sitesQuery.map((site) => site.subnet).filter((subnet) => subnet !== null); + subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`)); + const newSubnet = findNextAvailableCidr( + subnets, + blockSize, + exitNode.address + ); + if (!newSubnet) { + logger.error("No available subnets found for the new exit node"); + return; + } + + siteSubnet = newSubnet; + + await db + .update(sites) + .set({ + pubKey: publicKey, + exitNodeId: exitNodeId, + subnet: newSubnet + }) + .where(eq(sites.siteId, siteId)) + .returning(); + } else { + await db + .update(sites) + .set({ + pubKey: publicKey + }) + .where(eq(sites.siteId, siteId)) + .returning(); + } const [exitNode] = await db .select() .from(exitNodes) - .where(eq(exitNodes.exitNodeId, site.exitNodeId)) + .where(eq(exitNodes.exitNodeId, exitNodeIdToQuery)) .limit(1); - if (site.pubKey && site.pubKey !== publicKey) { + if (oldSite.pubKey && oldSite.pubKey !== publicKey) { logger.info("Public key mismatch. Deleting old peer..."); - await deletePeer(site.exitNodeId, site.pubKey); + await deletePeer(oldSite.exitNodeId, oldSite.pubKey); } - if (!site.subnet) { + if (!siteSubnet) { logger.warn("Site has no subnet"); return; } // add the peer to the exit node - await addPeer(site.exitNodeId, { + await addPeer(exitNodeIdToQuery, { publicKey: publicKey, - allowedIps: [site.subnet] + allowedIps: [siteSubnet] }); // Improved version @@ -161,7 +246,7 @@ export const handleRegisterMessage: MessageHandler = async (context) => { endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`, publicKey: exitNode.publicKey, serverIP: exitNode.address.split("/")[0], - tunnelIP: site.subnet.split("/")[0], + tunnelIP: siteSubnet.split("/")[0], targets: { udp: udpTargets, tcp: tcpTargets @@ -172,3 +257,14 @@ export const handleRegisterMessage: MessageHandler = async (context) => { excludeSender: false // Include sender in broadcast }; }; + +function selectBestExitNode( + pingResults: ExitNodePingResult[] +): ExitNodePingResult | null { + if (!pingResults || pingResults.length === 0) { + logger.warn("No ping results provided"); + return null; + } + + return pingResults[0]; +} diff --git a/server/routers/newt/handleReceiveBandwidthMessage.ts b/server/routers/newt/handleReceiveBandwidthMessage.ts new file mode 100644 index 00000000..89b24f78 --- /dev/null +++ b/server/routers/newt/handleReceiveBandwidthMessage.ts @@ -0,0 +1,52 @@ +import { db } from "@server/db"; +import { MessageHandler } from "../ws"; +import { clients, Newt } from "@server/db"; +import { eq } from "drizzle-orm"; +import logger from "@server/logger"; + +interface PeerBandwidth { + publicKey: string; + bytesIn: number; + bytesOut: number; +} + +export const handleReceiveBandwidthMessage: MessageHandler = async (context) => { + const { message, client, sendToClient } = context; + + if (!message.data.bandwidthData) { + logger.warn("No bandwidth data provided"); + } + + const bandwidthData: PeerBandwidth[] = message.data.bandwidthData; + + if (!Array.isArray(bandwidthData)) { + throw new Error("Invalid bandwidth data"); + } + + await db.transaction(async (trx) => { + for (const peer of bandwidthData) { + const { publicKey, bytesIn, bytesOut } = peer; + + // Find the client by public key + const [client] = await trx + .select() + .from(clients) + .where(eq(clients.pubKey, publicKey)) + .limit(1); + + if (!client) { + continue; + } + + // Update the client's bandwidth usage + await trx + .update(clients) + .set({ + megabytesOut: (client.megabytesIn || 0) + bytesIn, + megabytesIn: (client.megabytesOut || 0) + bytesOut, + lastBandwidthUpdate: new Date().toISOString(), + }) + .where(eq(clients.clientId, client.clientId)); + } + }); +}; diff --git a/server/routers/newt/handleSocketMessages.ts b/server/routers/newt/handleSocketMessages.ts index 0a217c52..01b7be60 100644 --- a/server/routers/newt/handleSocketMessages.ts +++ b/server/routers/newt/handleSocketMessages.ts @@ -1,9 +1,11 @@ import { MessageHandler } from "../ws"; import logger from "@server/logger"; import { dockerSocketCache } from "./dockerSocket"; +import { Newt } from "@server/db"; export const handleDockerStatusMessage: MessageHandler = async (context) => { - const { message, newt } = context; + const { message, client, sendToClient } = context; + const newt = client as Newt; logger.info("Handling Docker socket check response"); @@ -33,7 +35,8 @@ export const handleDockerStatusMessage: MessageHandler = async (context) => { export const handleDockerContainersMessage: MessageHandler = async ( context ) => { - const { message, newt } = context; + const { message, client, sendToClient } = context; + const newt = client as Newt; logger.info("Handling Docker containers response"); diff --git a/server/routers/newt/index.ts b/server/routers/newt/index.ts index ad6d531c..08f047e3 100644 --- a/server/routers/newt/index.ts +++ b/server/routers/newt/index.ts @@ -1,4 +1,7 @@ export * from "./createNewt"; -export * from "./getToken"; -export * from "./handleRegisterMessage"; -export * from "./handleSocketMessages"; \ No newline at end of file +export * from "./getNewtToken"; +export * from "./handleNewtRegisterMessage"; +export * from "./handleReceiveBandwidthMessage"; +export * from "./handleGetConfigMessage"; +export * from "./handleSocketMessages"; +export * from "./handleNewtPingRequestMessage"; \ No newline at end of file diff --git a/server/routers/newt/peers.ts b/server/routers/newt/peers.ts new file mode 100644 index 00000000..ff57e6fd --- /dev/null +++ b/server/routers/newt/peers.ts @@ -0,0 +1,114 @@ +import { db } from "@server/db"; +import { newts, sites } from "@server/db"; +import { eq } from "drizzle-orm"; +import { sendToClient } from "../ws"; +import logger from "@server/logger"; + +export async function addPeer( + siteId: number, + peer: { + publicKey: string; + allowedIps: string[]; + endpoint: string; + } +) { + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + if (!site) { + throw new Error(`Exit node with ID ${siteId} not found`); + } + + // get the newt on the site + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); + if (!newt) { + throw new Error(`Site found for site ${siteId}`); + } + + sendToClient(newt.newtId, { + type: "newt/wg/peer/add", + data: peer + }); + + logger.info(`Added peer ${peer.publicKey} to newt ${newt.newtId}`); + + return site; +} + +export async function deletePeer(siteId: number, publicKey: string) { + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + if (!site) { + throw new Error(`Site with ID ${siteId} not found`); + } + + // get the newt on the site + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); + if (!newt) { + throw new Error(`Newt not found for site ${siteId}`); + } + + sendToClient(newt.newtId, { + type: "newt/wg/peer/remove", + data: { + publicKey + } + }); + + logger.info(`Deleted peer ${publicKey} from newt ${newt.newtId}`); + + return site; +} + +export async function updatePeer( + siteId: number, + publicKey: string, + peer: { + allowedIps?: string[]; + endpoint?: string; + } +) { + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + if (!site) { + throw new Error(`Site with ID ${siteId} not found`); + } + + // get the newt on the site + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); + if (!newt) { + throw new Error(`Newt not found for site ${siteId}`); + } + + sendToClient(newt.newtId, { + type: "newt/wg/peer/update", + data: { + publicKey, + ...peer + } + }); + + logger.info(`Updated peer ${publicKey} on newt ${newt.newtId}`); + + return site; +} diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index d3c541a6..642fc2df 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -4,7 +4,8 @@ import { sendToClient } from "../ws"; export function addTargets( newtId: string, targets: Target[], - protocol: string + protocol: string, + port: number | null = null ) { //create a list of udp and tcp targets const payloadTargets = targets.map((target) => { @@ -13,19 +14,32 @@ export function addTargets( }:${target.port}`; }); - const payload = { + sendToClient(newtId, { type: `newt/${protocol}/add`, data: { targets: payloadTargets } - }; - sendToClient(newtId, payload); + }); + + const payloadTargetsResources = targets.map((target) => { + return `${port ? port + ":" : ""}${ + target.ip + }:${target.port}`; + }); + + sendToClient(newtId, { + type: `newt/wg/${protocol}/add`, + data: { + targets: [payloadTargetsResources[0]] // We can only use one target for WireGuard right now + } + }); } export function removeTargets( newtId: string, targets: Target[], - protocol: string + protocol: string, + port: number | null = null ) { //create a list of udp and tcp targets const payloadTargets = targets.map((target) => { @@ -34,11 +48,23 @@ export function removeTargets( }:${target.port}`; }); - const payload = { + sendToClient(newtId, { type: `newt/${protocol}/remove`, data: { targets: payloadTargets } - }; - sendToClient(newtId, payload); + }); + + const payloadTargetsResources = targets.map((target) => { + return `${port ? port + ":" : ""}${ + target.ip + }:${target.port}`; + }); + + sendToClient(newtId, { + type: `newt/wg/${protocol}/remove`, + data: { + targets: [payloadTargetsResources[0]] // We can only use one target for WireGuard right now + } + }); } diff --git a/server/routers/olm/createOlm.ts b/server/routers/olm/createOlm.ts new file mode 100644 index 00000000..3066e4ea --- /dev/null +++ b/server/routers/olm/createOlm.ts @@ -0,0 +1,106 @@ +import { NextFunction, Request, Response } from "express"; +import { db } from "@server/db"; +import { hash } from "@node-rs/argon2"; +import HttpCode from "@server/types/HttpCode"; +import { z } from "zod"; +import { newts } from "@server/db"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import { SqliteError } from "better-sqlite3"; +import moment from "moment"; +import { generateSessionToken } from "@server/auth/sessions/app"; +import { createNewtSession } from "@server/auth/sessions/newt"; +import { fromError } from "zod-validation-error"; +import { hashPassword } from "@server/auth/password"; + +export const createNewtBodySchema = z.object({}); + +export type CreateNewtBody = z.infer; + +export type CreateNewtResponse = { + token: string; + newtId: string; + secret: string; +}; + +const createNewtSchema = z + .object({ + newtId: z.string(), + secret: z.string() + }) + .strict(); + +export async function createNewt( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + + const parsedBody = createNewtSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { newtId, secret } = parsedBody.data; + + if (req.user && !req.userOrgRoleId) { + return next( + createHttpError(HttpCode.FORBIDDEN, "User does not have a role") + ); + } + + const secretHash = await hashPassword(secret); + + await db.insert(newts).values({ + newtId: newtId, + secretHash, + dateCreated: moment().toISOString(), + }); + + // give the newt their default permissions: + // await db.insert(newtActions).values({ + // newtId: newtId, + // actionId: ActionsEnum.createOrg, + // orgId: null, + // }); + + const token = generateSessionToken(); + await createNewtSession(token, newtId); + + return response(res, { + data: { + newtId, + secret, + token, + }, + success: true, + error: false, + message: "Newt created successfully", + status: HttpCode.OK, + }); + } catch (e) { + if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "A newt with that email address already exists" + ) + ); + } else { + console.error(e); + + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create newt" + ) + ); + } + } +} diff --git a/server/routers/olm/getOlmToken.ts b/server/routers/olm/getOlmToken.ts new file mode 100644 index 00000000..c26f5936 --- /dev/null +++ b/server/routers/olm/getOlmToken.ts @@ -0,0 +1,119 @@ +import { generateSessionToken } from "@server/auth/sessions/app"; +import { db } from "@server/db"; +import { olms } from "@server/db"; +import HttpCode from "@server/types/HttpCode"; +import response from "@server/lib/response"; +import { eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { + createOlmSession, + validateOlmSessionToken +} from "@server/auth/sessions/olm"; +import { verifyPassword } from "@server/auth/password"; +import logger from "@server/logger"; +import config from "@server/lib/config"; + +export const olmGetTokenBodySchema = z.object({ + olmId: z.string(), + secret: z.string(), + token: z.string().optional() +}); + +export type OlmGetTokenBody = z.infer; + +export async function getOlmToken( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = olmGetTokenBodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { olmId, secret, token } = parsedBody.data; + + try { + if (token) { + const { session, olm } = await validateOlmSessionToken(token); + if (session) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Olm session already valid. Olm ID: ${olmId}. IP: ${req.ip}.` + ); + } + return response(res, { + data: null, + success: true, + error: false, + message: "Token session already valid", + status: HttpCode.OK + }); + } + } + + const existingOlmRes = await db + .select() + .from(olms) + .where(eq(olms.olmId, olmId)); + if (!existingOlmRes || !existingOlmRes.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "No olm found with that olmId" + ) + ); + } + + const existingOlm = existingOlmRes[0]; + + const validSecret = await verifyPassword( + secret, + existingOlm.secretHash + ); + if (!validSecret) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Olm id or secret is incorrect. Olm: ID ${olmId}. IP: ${req.ip}.` + ); + } + return next( + createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect") + ); + } + + logger.debug("Creating new olm session token"); + + const resToken = generateSessionToken(); + await createOlmSession(resToken, existingOlm.olmId); + + logger.debug("Token created successfully"); + + return response<{ token: string }>(res, { + data: { + token: resToken + }, + success: true, + error: false, + message: "Token created successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to authenticate olm" + ) + ); + } +} diff --git a/server/routers/olm/handleOlmPingMessage.ts b/server/routers/olm/handleOlmPingMessage.ts new file mode 100644 index 00000000..941f7638 --- /dev/null +++ b/server/routers/olm/handleOlmPingMessage.ts @@ -0,0 +1,93 @@ +import { db } from "@server/db"; +import { MessageHandler } from "../ws"; +import { clients, Olm } from "@server/db"; +import { eq, lt, isNull } from "drizzle-orm"; +import logger from "@server/logger"; + +// Track if the offline checker interval is running +let offlineCheckerInterval: NodeJS.Timeout | null = null; +const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds +const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes + +/** + * Starts the background interval that checks for clients that haven't pinged recently + * and marks them as offline + */ +export const startOfflineChecker = (): void => { + if (offlineCheckerInterval) { + return; // Already running + } + + offlineCheckerInterval = setInterval(async () => { + try { + const twoMinutesAgo = new Date(Date.now() - OFFLINE_THRESHOLD_MS); + + // Find clients that haven't pinged in the last 2 minutes and mark them as offline + await db + .update(clients) + .set({ online: false }) + .where( + eq(clients.online, true) && + (lt(clients.lastPing, twoMinutesAgo.toISOString()) || isNull(clients.lastPing)) + ); + + } catch (error) { + logger.error("Error in offline checker interval", { error }); + } + }, OFFLINE_CHECK_INTERVAL); + + logger.info("Started offline checker interval"); +} + +/** + * Stops the background interval that checks for offline clients + */ +export const stopOfflineChecker = (): void => { + if (offlineCheckerInterval) { + clearInterval(offlineCheckerInterval); + offlineCheckerInterval = null; + logger.info("Stopped offline checker interval"); + } +} + +/** + * Handles ping messages from clients and responds with pong + */ +export const handleOlmPingMessage: MessageHandler = async (context) => { + const { message, client: c, sendToClient } = context; + const olm = c as Olm; + + if (!olm) { + logger.warn("Olm not found"); + return; + } + + if (!olm.clientId) { + logger.warn("Olm has no client ID!"); + return; + } + + try { + // Update the client's last ping timestamp + await db + .update(clients) + .set({ + lastPing: new Date().toISOString(), + online: true, + }) + .where(eq(clients.clientId, olm.clientId)); + } catch (error) { + logger.error("Error handling ping message", { error }); + } + + return { + message: { + type: "pong", + data: { + timestamp: new Date().toISOString(), + } + }, + broadcast: false, + excludeSender: false + }; +}; diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts new file mode 100644 index 00000000..32e4fe51 --- /dev/null +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -0,0 +1,209 @@ +import { db, ExitNode } from "@server/db"; +import { MessageHandler } from "../ws"; +import { + clients, + clientSites, + exitNodes, + Olm, + olms, + sites +} from "@server/db"; +import { eq, inArray } from "drizzle-orm"; +import { addPeer, deletePeer } from "../newt/peers"; +import logger from "@server/logger"; + +export const handleOlmRegisterMessage: MessageHandler = async (context) => { + logger.info("Handling register olm message!"); + const { message, client: c, sendToClient } = context; + const olm = c as Olm; + + const now = new Date().getTime() / 1000; + + if (!olm) { + logger.warn("Olm not found"); + return; + } + if (!olm.clientId) { + logger.warn("Olm has no client ID!"); + return; + } + const clientId = olm.clientId; + const { publicKey, relay } = message.data; + + logger.debug(`Olm client ID: ${clientId}, Public Key: ${publicKey}, Relay: ${relay}`); + + if (!publicKey) { + logger.warn("Public key not provided"); + return; + } + + // Get the client + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + logger.warn("Client not found"); + return; + } + + if (client.exitNodeId) { + // Get the exit node for this site + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, client.exitNodeId)) + .limit(1); + + // Send holepunch message for each site + sendToClient(olm.olmId, { + type: "olm/wg/holepunch", + data: { + serverPubKey: exitNode.publicKey, + endpoint: exitNode.endpoint, + } + }); + + } + + if (now - (client.lastHolePunch || 0) > 6) { + logger.warn("Client last hole punch is too old, skipping all sites"); + return; + } + + if (client.pubKey !== publicKey) { + logger.info( + "Public key mismatch. Updating public key and clearing session info..." + ); + // Update the client's public key + await db + .update(clients) + .set({ + pubKey: publicKey + }) + .where(eq(clients.clientId, olm.clientId)); + + // set isRelay to false for all of the client's sites to reset the connection metadata + await db + .update(clientSites) + .set({ + isRelayed: relay == true + }) + .where(eq(clientSites.clientId, olm.clientId)); + } + + // Get all sites data + const sitesData = await db + .select() + .from(sites) + .innerJoin(clientSites, eq(sites.siteId, clientSites.siteId)) + .where(eq(clientSites.clientId, client.clientId)); + + // Prepare an array to store site configurations + let siteConfigurations = []; + logger.debug(`Found ${sitesData.length} sites for client ${client.clientId}`); + + if (sitesData.length === 0) { + sendToClient(olm.olmId, { + type: "olm/register/no-sites", + data: {} + }); + } + + // Process each site + for (const { sites: site } of sitesData) { + if (!site.exitNodeId) { + logger.warn( + `Site ${site.siteId} does not have exit node, skipping` + ); + continue; + } + + // Validate endpoint and hole punch status + if (!site.endpoint) { + logger.warn(`Site ${site.siteId} has no endpoint, skipping`); + continue; + } + + // if (site.lastHolePunch && now - site.lastHolePunch > 6 && relay) { + // logger.warn( + // `Site ${site.siteId} last hole punch is too old, skipping` + // ); + // continue; + // } + + // If public key changed, delete old peer from this site + if (client.pubKey && client.pubKey != publicKey) { + logger.info( + `Public key mismatch. Deleting old peer from site ${site.siteId}...` + ); + await deletePeer(site.siteId, client.pubKey!); + } + + if (!site.subnet) { + logger.warn(`Site ${site.siteId} has no subnet, skipping`); + continue; + } + + // Add the peer to the exit node for this site + if (client.endpoint) { + logger.info( + `Adding peer ${publicKey} to site ${site.siteId} with endpoint ${client.endpoint}` + ); + await addPeer(site.siteId, { + publicKey: publicKey, + allowedIps: [`${client.subnet.split('/')[0]}/32`], // we want to only allow from that client + endpoint: relay ? "" : client.endpoint + }); + } else { + logger.warn( + `Client ${client.clientId} has no endpoint, skipping peer addition` + ); + } + + let endpoint = site.endpoint; + if (relay) { + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, site.exitNodeId)) + .limit(1); + if (!exitNode) { + logger.warn(`Exit node not found for site ${site.siteId}`); + continue; + } + endpoint = `${exitNode.endpoint}:21820`; + } + + // Add site configuration to the array + siteConfigurations.push({ + siteId: site.siteId, + endpoint: endpoint, + publicKey: site.publicKey, + serverIP: site.address, + serverPort: site.listenPort, + remoteSubnets: site.remoteSubnets + }); + } + + // REMOVED THIS SO IT CREATES THE INTERFACE AND JUST WAITS FOR THE SITES + // if (siteConfigurations.length === 0) { + // logger.warn("No valid site configurations found"); + // return; + // } + + // Return connect message with all site configurations + return { + message: { + type: "olm/wg/connect", + data: { + sites: siteConfigurations, + tunnelIP: client.subnet + } + }, + broadcast: false, + excludeSender: false + }; +}; diff --git a/server/routers/olm/handleOlmRelayMessage.ts b/server/routers/olm/handleOlmRelayMessage.ts new file mode 100644 index 00000000..cefc5b91 --- /dev/null +++ b/server/routers/olm/handleOlmRelayMessage.ts @@ -0,0 +1,96 @@ +import { db, exitNodes, sites } from "@server/db"; +import { MessageHandler } from "../ws"; +import { clients, clientSites, Olm } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import { updatePeer } from "../newt/peers"; +import logger from "@server/logger"; + +export const handleOlmRelayMessage: MessageHandler = async (context) => { + const { message, client: c, sendToClient } = context; + const olm = c as Olm; + + logger.info("Handling relay olm message!"); + + if (!olm) { + logger.warn("Olm not found"); + return; + } + + if (!olm.clientId) { + logger.warn("Olm has no site!"); // TODO: Maybe we create the site here? + return; + } + + const clientId = olm.clientId; + + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + logger.warn("Client not found"); + return; + } + + // make sure we hand endpoints for both the site and the client and the lastHolePunch is not too old + if (!client.pubKey) { + logger.warn("Client has no endpoint or listen port"); + return; + } + + const { siteId } = message.data; + + // Get the site + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!site || !site.exitNodeId) { + logger.warn("Site not found or has no exit node"); + return; + } + + // get the site's exit node + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, site.exitNodeId)) + .limit(1); + + if (!exitNode) { + logger.warn("Exit node not found for site"); + return; + } + + await db + .update(clientSites) + .set({ + isRelayed: true + }) + .where( + and( + eq(clientSites.clientId, olm.clientId), + eq(clientSites.siteId, siteId) + ) + ); + + // update the peer on the exit node + await updatePeer(siteId, client.pubKey, { + endpoint: "" // this removes the endpoint + }); + + sendToClient(olm.olmId, { + type: "olm/wg/peer/relay", + data: { + siteId: siteId, + endpoint: exitNode.endpoint, + publicKey: exitNode.publicKey + } + }); + + return; +}; diff --git a/server/routers/olm/index.ts b/server/routers/olm/index.ts new file mode 100644 index 00000000..8426612e --- /dev/null +++ b/server/routers/olm/index.ts @@ -0,0 +1,5 @@ +export * from "./handleOlmRegisterMessage"; +export * from "./getOlmToken"; +export * from "./createOlm"; +export * from "./handleOlmRelayMessage"; +export * from "./handleOlmPingMessage"; \ No newline at end of file diff --git a/server/routers/olm/peers.ts b/server/routers/olm/peers.ts new file mode 100644 index 00000000..c47c84a8 --- /dev/null +++ b/server/routers/olm/peers.ts @@ -0,0 +1,96 @@ +import { db } from "@server/db"; +import { clients, olms, newts, sites } from "@server/db"; +import { eq } from "drizzle-orm"; +import { sendToClient } from "../ws"; +import logger from "@server/logger"; + +export async function addPeer( + clientId: number, + peer: { + siteId: number; + publicKey: string; + endpoint: string; + serverIP: string | null; + serverPort: number | null; + remoteSubnets: string | null; // optional, comma-separated list of subnets that this site can access + } +) { + const [olm] = await db + .select() + .from(olms) + .where(eq(olms.clientId, clientId)) + .limit(1); + if (!olm) { + throw new Error(`Olm with ID ${clientId} not found`); + } + + sendToClient(olm.olmId, { + type: "olm/wg/peer/add", + data: { + siteId: peer.siteId, + publicKey: peer.publicKey, + endpoint: peer.endpoint, + serverIP: peer.serverIP, + serverPort: peer.serverPort, + remoteSubnets: peer.remoteSubnets // optional, comma-separated list of subnets that this site can access + } + }); + + logger.info(`Added peer ${peer.publicKey} to olm ${olm.olmId}`); +} + +export async function deletePeer(clientId: number, siteId: number, publicKey: string) { + const [olm] = await db + .select() + .from(olms) + .where(eq(olms.clientId, clientId)) + .limit(1); + if (!olm) { + throw new Error(`Olm with ID ${clientId} not found`); + } + + sendToClient(olm.olmId, { + type: "olm/wg/peer/remove", + data: { + publicKey, + siteId: siteId + } + }); + + logger.info(`Deleted peer ${publicKey} from olm ${olm.olmId}`); +} + +export async function updatePeer( + clientId: number, + peer: { + siteId: number; + publicKey: string; + endpoint: string; + serverIP: string | null; + serverPort: number | null; + remoteSubnets?: string | null; // optional, comma-separated list of subnets that + } +) { + const [olm] = await db + .select() + .from(olms) + .where(eq(olms.clientId, clientId)) + .limit(1); + if (!olm) { + throw new Error(`Olm with ID ${clientId} not found`); + } + + sendToClient(olm.olmId, { + type: "olm/wg/peer/update", + data: { + siteId: peer.siteId, + publicKey: peer.publicKey, + endpoint: peer.endpoint, + serverIP: peer.serverIP, + serverPort: peer.serverPort, + remoteSubnets: peer.remoteSubnets + } + }); + + logger.info(`Added peer ${peer.publicKey} to olm ${olm.olmId}`); +} diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index ac977063..d26774dd 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -23,16 +23,16 @@ import config from "@server/lib/config"; import { fromError } from "zod-validation-error"; import { defaultRoleAllowedActions } from "../role"; import { OpenAPITags, registry } from "@server/openApi"; +import { isValidCIDR } from "@server/lib/validators"; const createOrgSchema = z .object({ orgId: z.string(), - name: z.string().min(1).max(255) + name: z.string().min(1).max(255), + subnet: z.string() }) .strict(); -// const MAX_ORGS = 5; - registry.registerPath({ method: "put", path: "/org", @@ -78,7 +78,33 @@ export async function createOrg( ); } - const { orgId, name } = parsedBody.data; + const { orgId, name, subnet } = parsedBody.data; + + if (!isValidCIDR(subnet)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid subnet format. Please provide a valid CIDR notation." + ) + ); + } + + // TODO: for now we are making all of the orgs the same subnet + // make sure the subnet is unique + // const subnetExists = await db + // .select() + // .from(orgs) + // .where(eq(orgs.subnet, subnet)) + // .limit(1); + + // if (subnetExists.length > 0) { + // return next( + // createHttpError( + // HttpCode.CONFLICT, + // `Subnet ${subnet} already exists` + // ) + // ); + // } // make sure the orgId is unique const orgExists = await db @@ -109,7 +135,9 @@ export async function createOrg( .insert(orgs) .values({ orgId, - name + name, + subnet, + createdAt: new Date().toISOString() }) .returning(); @@ -142,25 +170,25 @@ export async function createOrg( // Get all actions and create role actions const actionIds = await trx.select().from(actions).execute(); - + if (actionIds.length > 0) { - await trx - .insert(roleActions) - .values( - actionIds.map((action) => ({ - roleId, - actionId: action.actionId, - orgId: newOrg[0].orgId - })) - ); + await trx.insert(roleActions).values( + actionIds.map((action) => ({ + roleId, + actionId: action.actionId, + orgId: newOrg[0].orgId + })) + ); } - await trx.insert(orgDomains).values( - allDomains.map((domain) => ({ - orgId: newOrg[0].orgId, - domainId: domain.domainId - })) - ); + if (allDomains.length) { + await trx.insert(orgDomains).values( + allDomains.map((domain) => ({ + orgId: newOrg[0].orgId, + domainId: domain.domainId + })) + ); + } if (req.user) { await trx.insert(userOrgs).values({ @@ -206,18 +234,6 @@ export async function createOrg( orgId })) ); - - const rootApiKeys = await trx - .select() - .from(apiKeys) - .where(eq(apiKeys.isRoot, true)); - - for (const apiKey of rootApiKeys) { - await trx.insert(apiKeyOrg).values({ - apiKeyId: apiKey.apiKeyId, - orgId: newOrg[0].orgId - }); - } }); if (!org) { diff --git a/server/routers/org/deleteOrg.ts b/server/routers/org/deleteOrg.ts index 5b2accce..76e2ad79 100644 --- a/server/routers/org/deleteOrg.ts +++ b/server/routers/org/deleteOrg.ts @@ -1,14 +1,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; -import { - newts, - newtSessions, - orgs, - sites, - userActions -} from "@server/db"; -import { eq } from "drizzle-orm"; +import { db, domains, orgDomains, resources } from "@server/db"; +import { newts, newtSessions, orgs, sites, userActions } from "@server/db"; +import { eq, and, inArray, sql } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -89,6 +83,8 @@ export async function deleteOrg( .where(eq(sites.orgId, orgId)) .limit(1); + const deletedNewtIds: string[] = []; + await db.transaction(async (trx) => { if (sites) { for (const site of orgSites) { @@ -102,11 +98,7 @@ export async function deleteOrg( .where(eq(newts.siteId, site.siteId)) .returning(); if (deletedNewt) { - const payload = { - type: `newt/terminate`, - data: {} - }; - sendToClient(deletedNewt.newtId, payload); + deletedNewtIds.push(deletedNewt.newtId); // delete all of the sessions for the newt await trx @@ -128,9 +120,62 @@ export async function deleteOrg( } } + const allOrgDomains = await trx + .select() + .from(orgDomains) + .innerJoin(domains, eq(domains.domainId, orgDomains.domainId)) + .where( + and( + eq(orgDomains.orgId, orgId), + eq(domains.configManaged, false) + ) + ); + + // For each domain, check if it belongs to multiple organizations + const domainIdsToDelete: string[] = []; + for (const orgDomain of allOrgDomains) { + const domainId = orgDomain.domains.domainId; + + // Count how many organizations this domain belongs to + const orgCount = await trx + .select({ count: sql`count(*)` }) + .from(orgDomains) + .where(eq(orgDomains.domainId, domainId)); + + // Only delete the domain if it belongs to exactly 1 organization (the one being deleted) + if (orgCount[0].count === 1) { + domainIdsToDelete.push(domainId); + } + } + + // Delete domains that belong exclusively to this organization + if (domainIdsToDelete.length > 0) { + await trx + .delete(domains) + .where(inArray(domains.domainId, domainIdsToDelete)); + } + + // Delete resources + await trx.delete(resources).where(eq(resources.orgId, orgId)); + await trx.delete(orgs).where(eq(orgs.orgId, orgId)); }); + // Send termination messages outside of transaction to prevent blocking + for (const newtId of deletedNewtIds) { + const payload = { + type: `newt/terminate`, + data: {} + }; + // Don't await this to prevent blocking the response + sendToClient(newtId, payload).catch((error) => { + logger.error( + "Failed to send termination message to newt:", + error + ); + }); + } + return response(res, { data: null, success: true, diff --git a/server/routers/org/index.ts b/server/routers/org/index.ts index 5623823d..c9a44d8d 100644 --- a/server/routers/org/index.ts +++ b/server/routers/org/index.ts @@ -6,3 +6,4 @@ export * from "./listUserOrgs"; export * from "./checkId"; export * from "./getOrgOverview"; export * from "./listOrgs"; +export * from "./pickOrgDefaults"; diff --git a/server/routers/org/listUserOrgs.ts b/server/routers/org/listUserOrgs.ts index 694a4fb2..e3c0d06f 100644 --- a/server/routers/org/listUserOrgs.ts +++ b/server/routers/org/listUserOrgs.ts @@ -5,7 +5,7 @@ import { Org, orgs, userOrgs } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { sql, inArray, eq } from "drizzle-orm"; +import { sql, inArray, eq, and } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; @@ -40,8 +40,10 @@ const listOrgsSchema = z.object({ // responses: {} // }); +type ResponseOrg = Org & { isOwner?: boolean }; + export type ListUserOrgsResponse = { - orgs: Org[]; + orgs: ResponseOrg[]; pagination: { total: number; limit: number; offset: number }; }; @@ -106,6 +108,10 @@ export async function listUserOrgs( .select() .from(orgs) .where(inArray(orgs.orgId, userOrgIds)) + .leftJoin( + userOrgs, + and(eq(userOrgs.orgId, orgs.orgId), eq(userOrgs.userId, userId)) + ) .limit(limit) .offset(offset); @@ -115,9 +121,19 @@ export async function listUserOrgs( .where(inArray(orgs.orgId, userOrgIds)); const totalCount = totalCountResult[0].count; + const responseOrgs = organizations.map((val) => { + const res = { + ...val.orgs + } as ResponseOrg; + if (val.userOrgs && val.userOrgs.isOwner) { + res.isOwner = val.userOrgs.isOwner; + } + return res; + }); + return response(res, { data: { - orgs: organizations, + orgs: responseOrgs, pagination: { total: totalCount, limit, diff --git a/server/routers/org/pickOrgDefaults.ts b/server/routers/org/pickOrgDefaults.ts new file mode 100644 index 00000000..771b0d99 --- /dev/null +++ b/server/routers/org/pickOrgDefaults.ts @@ -0,0 +1,39 @@ +import { Request, Response, NextFunction } from "express"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { getNextAvailableOrgSubnet } from "@server/lib/ip"; +import config from "@server/lib/config"; + +export type PickOrgDefaultsResponse = { + subnet: string; +}; + +export async function pickOrgDefaults( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + // TODO: Why would each org have to have its own subnet? + // const subnet = await getNextAvailableOrgSubnet(); + // Just hard code the subnet for now for everyone + const subnet = config.getRawConfig().orgs.subnet_group; + + return response(res, { + data: { + subnet: subnet + }, + success: true, + error: false, + message: "Organization defaults created successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index 06c92fad..6dcd1016 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -19,7 +19,6 @@ const updateOrgParamsSchema = z const updateOrgBodySchema = z .object({ name: z.string().min(1).max(255).optional() - // domain: z.string().min(1).max(255).optional(), }) .strict() .refine((data) => Object.keys(data).length > 0, { diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 1cbfa38e..8c80c90c 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -21,6 +21,7 @@ import logger from "@server/logger"; import { subdomainSchema } from "@server/lib/schemas"; import config from "@server/lib/config"; import { OpenAPITags, registry } from "@server/openApi"; +import { build } from "@server/build"; const createResourceParamsSchema = z .object({ @@ -32,11 +33,7 @@ const createResourceParamsSchema = z const createHttpResourceSchema = z .object({ name: z.string().min(1).max(255), - subdomain: z - .string() - .optional() - .transform((val) => val?.toLowerCase()), - isBaseDomain: z.boolean().optional(), + subdomain: z.string().nullable().optional(), siteId: z.number(), http: z.boolean(), protocol: z.enum(["tcp", "udp"]), @@ -51,19 +48,6 @@ const createHttpResourceSchema = z return true; }, { message: "Invalid subdomain" } - ) - .refine( - (data) => { - if (!config.getRawConfig().flags?.allow_base_domain_resources) { - if (data.isBaseDomain) { - return false; - } - } - return true; - }, - { - message: "Base domain resources are not allowed" - } ); const createRawResourceSchema = z @@ -72,7 +56,8 @@ const createRawResourceSchema = z siteId: z.number(), http: z.boolean(), protocol: z.enum(["tcp", "udp"]), - proxyPort: z.number().int().min(1).max(65535) + proxyPort: z.number().int().min(1).max(65535), + enableProxy: z.boolean().default(true) }) .strict() .refine( @@ -101,9 +86,7 @@ registry.registerPath({ body: { content: { "application/json": { - schema: createHttpResourceSchema.or( - createRawResourceSchema - ) + schema: createHttpResourceSchema.or(createRawResourceSchema) } } } @@ -166,6 +149,17 @@ export async function createResource( { siteId, orgId } ); } else { + if ( + !config.getRawConfig().flags?.allow_raw_resources && + build == "oss" + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Raw resources are not allowed" + ) + ); + } return await createRawResource( { req, res, next }, { siteId, orgId } @@ -203,35 +197,78 @@ async function createHttpResource( ); } - const { name, subdomain, isBaseDomain, http, protocol, domainId } = - parsedBody.data; + const { name, domainId } = parsedBody.data; + let subdomain = parsedBody.data.subdomain; - const [orgDomain] = await db + const [domainRes] = await db .select() - .from(orgDomains) - .where( + .from(domains) + .where(eq(domains.domainId, domainId)) + .leftJoin( + orgDomains, and(eq(orgDomains.orgId, orgId), eq(orgDomains.domainId, domainId)) - ) - .leftJoin(domains, eq(orgDomains.domainId, domains.domainId)); + ); - if (!orgDomain || !orgDomain.domains) { + if (!domainRes || !domainRes.domains) { return next( createHttpError( HttpCode.NOT_FOUND, - `Domain with ID ${parsedBody.data.domainId} not found` + `Domain with ID ${domainId} not found` ) ); } - const domain = orgDomain.domains; + if (domainRes.orgDomains && domainRes.orgDomains.orgId !== orgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `Organization does not have access to domain with ID ${domainId}` + ) + ); + } + + if (!domainRes.domains.verified) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Domain with ID ${domainRes.domains.domainId} is not verified` + ) + ); + } let fullDomain = ""; - if (isBaseDomain) { - fullDomain = domain.baseDomain; - } else { - fullDomain = `${subdomain}.${domain.baseDomain}`; + if (domainRes.domains.type == "ns") { + if (subdomain) { + fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`; + } else { + fullDomain = domainRes.domains.baseDomain; + } + } else if (domainRes.domains.type == "cname") { + fullDomain = domainRes.domains.baseDomain; + } else if (domainRes.domains.type == "wildcard") { + if (subdomain) { + // the subdomain cant have a dot in it + const parsedSubdomain = subdomainSchema.safeParse(subdomain); + if (!parsedSubdomain.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedSubdomain.error).toString() + ) + ); + } + fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`; + } else { + fullDomain = domainRes.domains.baseDomain; + } } + if (fullDomain === domainRes.domains.baseDomain) { + subdomain = null; + } + + fullDomain = fullDomain.toLowerCase(); + logger.debug(`Full domain: ${fullDomain}`); // make sure the full domain is unique @@ -261,10 +298,9 @@ async function createHttpResource( orgId, name, subdomain, - http, - protocol, - ssl: true, - isBaseDomain + http: true, + protocol: "tcp", + ssl: true }) .returning(); @@ -338,7 +374,7 @@ async function createRawResource( ); } - const { name, http, protocol, proxyPort } = parsedBody.data; + const { name, http, protocol, proxyPort, enableProxy } = parsedBody.data; // if http is false check to see if there is already a resource with the same port and protocol const existingResource = await db @@ -371,7 +407,8 @@ async function createRawResource( name, http, protocol, - proxyPort + proxyPort, + enableProxy }) .returning(); diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index bb9a6f32..99adc5f7 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -103,7 +103,8 @@ export async function deleteResource( removeTargets( newt.newtId, targetsToBeRemoved, - deletedResource.protocol + deletedResource.protocol, + deletedResource.proxyPort ); } } diff --git a/server/routers/resource/getUserResources.ts b/server/routers/resource/getUserResources.ts new file mode 100644 index 00000000..681ec4d0 --- /dev/null +++ b/server/routers/resource/getUserResources.ts @@ -0,0 +1,168 @@ +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { and, eq, or, inArray } from "drizzle-orm"; +import { + resources, + userResources, + roleResources, + userOrgs, + roles, + resourcePassword, + resourcePincode, + resourceWhitelist, + sites +} from "@server/db"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { response } from "@server/lib/response"; + +export async function getUserResources( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const { orgId } = req.params; + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + // First get the user's role in the organization + const userOrgResult = await db + .select({ + roleId: userOrgs.roleId + }) + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, orgId) + ) + ) + .limit(1); + + if (userOrgResult.length === 0) { + return next( + createHttpError(HttpCode.FORBIDDEN, "User not in organization") + ); + } + + const userRoleId = userOrgResult[0].roleId; + + // Get resources accessible through direct assignment or role assignment + const directResourcesQuery = db + .select({ resourceId: userResources.resourceId }) + .from(userResources) + .where(eq(userResources.userId, userId)); + + const roleResourcesQuery = db + .select({ resourceId: roleResources.resourceId }) + .from(roleResources) + .where(eq(roleResources.roleId, userRoleId)); + + const [directResources, roleResourceResults] = await Promise.all([ + directResourcesQuery, + roleResourcesQuery + ]); + + // Combine all accessible resource IDs + const accessibleResourceIds = [ + ...directResources.map(r => r.resourceId), + ...roleResourceResults.map(r => r.resourceId) + ]; + + if (accessibleResourceIds.length === 0) { + return response(res, { + data: { resources: [] }, + success: true, + error: false, + message: "No resources found", + status: HttpCode.OK + }); + } + + // Get resource details for accessible resources + const resourcesData = await db + .select({ + resourceId: resources.resourceId, + name: resources.name, + fullDomain: resources.fullDomain, + ssl: resources.ssl, + enabled: resources.enabled, + sso: resources.sso, + protocol: resources.protocol, + emailWhitelistEnabled: resources.emailWhitelistEnabled, + siteName: sites.name + }) + .from(resources) + .leftJoin(sites, eq(sites.siteId, resources.siteId)) + .where( + and( + inArray(resources.resourceId, accessibleResourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true) + ) + ); + + // Check for password, pincode, and whitelist protection for each resource + const resourcesWithAuth = await Promise.all( + resourcesData.map(async (resource) => { + const [passwordCheck, pincodeCheck, whitelistCheck] = await Promise.all([ + db.select().from(resourcePassword).where(eq(resourcePassword.resourceId, resource.resourceId)).limit(1), + db.select().from(resourcePincode).where(eq(resourcePincode.resourceId, resource.resourceId)).limit(1), + db.select().from(resourceWhitelist).where(eq(resourceWhitelist.resourceId, resource.resourceId)).limit(1) + ]); + + const hasPassword = passwordCheck.length > 0; + const hasPincode = pincodeCheck.length > 0; + const hasWhitelist = whitelistCheck.length > 0 || resource.emailWhitelistEnabled; + + return { + resourceId: resource.resourceId, + name: resource.name, + domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`, + enabled: resource.enabled, + protected: !!(resource.sso || hasPassword || hasPincode || hasWhitelist), + protocol: resource.protocol, + sso: resource.sso, + password: hasPassword, + pincode: hasPincode, + whitelist: hasWhitelist, + siteName: resource.siteName + }; + }) + ); + + return response(res, { + data: { resources: resourcesWithAuth }, + success: true, + error: false, + message: "User resources retrieved successfully", + status: HttpCode.OK + }); + + } catch (error) { + console.error("Error fetching user resources:", error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Internal server error") + ); + } +} + +export type GetUserResourcesResponse = { + success: boolean; + data: { + resources: Array<{ + resourceId: number; + name: string; + domain: string; + enabled: boolean; + protected: boolean; + protocol: string; + }>; + }; +}; \ No newline at end of file diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 03c9ffbe..f97fcdf4 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -21,4 +21,5 @@ export * from "./getExchangeToken"; export * from "./createResourceRule"; export * from "./deleteResourceRule"; export * from "./listResourceRules"; -export * from "./updateResourceRule"; \ No newline at end of file +export * from "./updateResourceRule"; +export * from "./getUserResources"; \ No newline at end of file diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 6dc852e4..3fb2a733 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -69,7 +69,8 @@ function queryResources( http: resources.http, protocol: resources.protocol, proxyPort: resources.proxyPort, - enabled: resources.enabled + enabled: resources.enabled, + domainId: resources.domainId }) .from(resources) .leftJoin(sites, eq(resources.siteId, sites.siteId)) @@ -103,7 +104,8 @@ function queryResources( http: resources.http, protocol: resources.protocol, proxyPort: resources.proxyPort, - enabled: resources.enabled + enabled: resources.enabled, + domainId: resources.domainId }) .from(resources) .leftJoin(sites, eq(resources.siteId, sites.siteId)) diff --git a/server/routers/resource/transferResource.ts b/server/routers/resource/transferResource.ts index e0fce278..a99405df 100644 --- a/server/routers/resource/transferResource.ts +++ b/server/routers/resource/transferResource.ts @@ -168,7 +168,8 @@ export async function transferResource( removeTargets( newt.newtId, resourceTargets, - updatedResource.protocol + updatedResource.protocol, + updatedResource.proxyPort ); } } @@ -190,7 +191,8 @@ export async function transferResource( addTargets( newt.newtId, resourceTargets, - updatedResource.protocol + updatedResource.protocol, + updatedResource.proxyPort ); } } diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 68e38a3e..5cf68c2b 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -20,6 +20,7 @@ import { tlsNameSchema } from "@server/lib/schemas"; import { subdomainSchema } from "@server/lib/schemas"; import { registry } from "@server/openApi"; import { OpenAPITags } from "@server/openApi"; +import { build } from "@server/build"; const updateResourceParamsSchema = z .object({ @@ -33,14 +34,11 @@ const updateResourceParamsSchema = z const updateHttpResourceBodySchema = z .object({ name: z.string().min(1).max(255).optional(), - subdomain: subdomainSchema - .optional() - .transform((val) => val?.toLowerCase()), + subdomain: subdomainSchema.nullable().optional(), ssl: z.boolean().optional(), sso: z.boolean().optional(), blockAccess: z.boolean().optional(), emailWhitelistEnabled: z.boolean().optional(), - isBaseDomain: z.boolean().optional(), applyRules: z.boolean().optional(), domainId: z.string().optional(), enabled: z.boolean().optional(), @@ -61,19 +59,6 @@ const updateHttpResourceBodySchema = z }, { message: "Invalid subdomain" } ) - .refine( - (data) => { - if (!config.getRawConfig().flags?.allow_base_domain_resources) { - if (data.isBaseDomain) { - return false; - } - } - return true; - }, - { - message: "Base domain resources are not allowed" - } - ) .refine( (data) => { if (data.tlsServerName) { @@ -106,7 +91,8 @@ const updateRawResourceBodySchema = z name: z.string().min(1).max(255).optional(), proxyPort: z.number().int().min(1).max(65535).optional(), stickySession: z.boolean().optional(), - enabled: z.boolean().optional() + enabled: z.boolean().optional(), + enableProxy: z.boolean().optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -242,86 +228,118 @@ async function updateHttpResource( const updateData = parsedBody.data; if (updateData.domainId) { - const [existingDomain] = await db - .select() - .from(orgDomains) - .where( - and( - eq(orgDomains.orgId, org.orgId), - eq(orgDomains.domainId, updateData.domainId) - ) - ) - .leftJoin(domains, eq(orgDomains.domainId, domains.domainId)); + const domainId = updateData.domainId; - if (!existingDomain) { + const [domainRes] = await db + .select() + .from(domains) + .where(eq(domains.domainId, domainId)) + .leftJoin( + orgDomains, + and( + eq(orgDomains.orgId, resource.orgId), + eq(orgDomains.domainId, domainId) + ) + ); + + if (!domainRes || !domainRes.domains) { return next( - createHttpError(HttpCode.NOT_FOUND, `Domain not found`) + createHttpError( + HttpCode.NOT_FOUND, + `Domain with ID ${updateData.domainId} not found` + ) ); } - } - - const domainId = updateData.domainId || resource.domainId!; - const subdomain = updateData.subdomain || resource.subdomain; - - const [domain] = await db - .select() - .from(domains) - .where(eq(domains.domainId, domainId)); - - const isBaseDomain = - updateData.isBaseDomain !== undefined - ? updateData.isBaseDomain - : resource.isBaseDomain; - - let fullDomain: string | null = null; - if (isBaseDomain) { - fullDomain = domain.baseDomain; - } else if (subdomain && domain) { - fullDomain = `${subdomain}.${domain.baseDomain}`; - } - - if (fullDomain) { - const [existingDomain] = await db - .select() - .from(resources) - .where(eq(resources.fullDomain, fullDomain)); if ( - existingDomain && - existingDomain.resourceId !== resource.resourceId + domainRes.orgDomains && + domainRes.orgDomains.orgId !== resource.orgId ) { return next( createHttpError( - HttpCode.CONFLICT, - "Resource with that domain already exists" + HttpCode.FORBIDDEN, + `You do not have permission to use domain with ID ${updateData.domainId}` ) ); } - } - const updatePayload = { - ...updateData, - fullDomain - }; + if (!domainRes.domains.verified) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Domain with ID ${updateData.domainId} is not verified` + ) + ); + } + + let fullDomain = ""; + if (domainRes.domains.type == "ns") { + if (updateData.subdomain) { + fullDomain = `${updateData.subdomain}.${domainRes.domains.baseDomain}`; + } else { + fullDomain = domainRes.domains.baseDomain; + } + } else if (domainRes.domains.type == "cname") { + fullDomain = domainRes.domains.baseDomain; + } else if (domainRes.domains.type == "wildcard") { + if (updateData.subdomain !== undefined) { + // the subdomain cant have a dot in it + const parsedSubdomain = subdomainSchema.safeParse( + updateData.subdomain + ); + if (!parsedSubdomain.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedSubdomain.error).toString() + ) + ); + } + fullDomain = `${updateData.subdomain}.${domainRes.domains.baseDomain}`; + } else { + fullDomain = domainRes.domains.baseDomain; + } + } + + fullDomain = fullDomain.toLowerCase(); + + logger.debug(`Full domain: ${fullDomain}`); + + if (fullDomain) { + const [existingDomain] = await db + .select() + .from(resources) + .where(eq(resources.fullDomain, fullDomain)); + + if ( + existingDomain && + existingDomain.resourceId !== resource.resourceId + ) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource with that domain already exists" + ) + ); + } + } + + // update the full domain if it has changed + if (fullDomain && fullDomain !== resource.fullDomain) { + await db + .update(resources) + .set({ fullDomain }) + .where(eq(resources.resourceId, resource.resourceId)); + } + + if (fullDomain === domainRes.domains.baseDomain) { + updateData.subdomain = null; + } + } const updatedResource = await db .update(resources) - .set({ - name: updatePayload.name, - subdomain: updatePayload.subdomain, - ssl: updatePayload.ssl, - sso: updatePayload.sso, - blockAccess: updatePayload.blockAccess, - emailWhitelistEnabled: updatePayload.emailWhitelistEnabled, - isBaseDomain: updatePayload.isBaseDomain, - applyRules: updatePayload.applyRules, - domainId: updatePayload.domainId, - enabled: updatePayload.enabled, - stickySession: updatePayload.stickySession, - tlsServerName: updatePayload.tlsServerName, - setHostHeader: updatePayload.setHostHeader, - fullDomain: updatePayload.fullDomain - }) + .set({ ...updateData }) .where(eq(resources.resourceId, resource.resourceId)) .returning(); diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index b950644a..fb1170cd 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { clients, db } from "@server/db"; import { roles, userSites, sites, roleSites, Site, orgs } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -14,6 +14,9 @@ import { newts } from "@server/db"; import moment from "moment"; import { OpenAPITags, registry } from "@server/openApi"; import { hashPassword } from "@server/auth/password"; +import { isValidIP } from "@server/lib/validators"; +import { isIpInCidr } from "@server/lib/ip"; +import config from "@server/lib/config"; const createSiteParamsSchema = z .object({ @@ -35,9 +38,18 @@ const createSiteSchema = z subnet: z.string().optional(), newtId: z.string().optional(), secret: z.string().optional(), + address: z.string().optional(), type: z.enum(["newt", "wireguard", "local"]) }) - .strict(); + .strict() + .refine((data) => { + if (data.type === "local") { + return !config.getRawConfig().flags?.disable_local_sites; + } else if (data.type === "wireguard") { + return !config.getRawConfig().flags?.disable_basic_wireguard_sites; + } + return true; + }); export type CreateSiteBody = z.infer; @@ -84,7 +96,8 @@ export async function createSite( pubKey, subnet, newtId, - secret + secret, + address } = parsedBody.data; const parsedParams = createSiteParamsSchema.safeParse(req.params); @@ -116,6 +129,78 @@ export async function createSite( ); } + let updatedAddress = null; + if (address) { + if (!org.subnet) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Organization with ID ${orgId} has no subnet defined` + ) + ); + } + + if (!isValidIP(address)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid subnet format. Please provide a valid CIDR notation." + ) + ); + } + + if (!isIpInCidr(address, org.subnet)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IP is not in the CIDR range of the subnet." + ) + ); + } + + updatedAddress = `${address}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org + + // make sure the subnet is unique + const addressExistsSites = await db + .select() + .from(sites) + .where( + and( + eq(sites.address, updatedAddress), + eq(sites.orgId, orgId) + ) + ) + .limit(1); + + if (addressExistsSites.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Subnet ${updatedAddress} already exists in sites` + ) + ); + } + + const addressExistsClients = await db + .select() + .from(clients) + .where( + and( + eq(clients.subnet, updatedAddress), + eq(clients.orgId, orgId) + ) + ) + .limit(1); + if (addressExistsClients.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Subnet ${updatedAddress} already exists in clients` + ) + ); + } + } + const niceId = await getUniqueSiteName(orgId); await db.transaction(async (trx) => { @@ -139,6 +224,7 @@ export async function createSite( exitNodeId, name, niceId, + address: updatedAddress || null, subnet, type, dockerSocketEnabled: type == "newt", @@ -154,6 +240,7 @@ export async function createSite( orgId, name, niceId, + address: updatedAddress || null, type, dockerSocketEnabled: type == "newt", subnet: "0.0.0.0/0" diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 1554ad2b..4af2feae 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -62,6 +62,8 @@ export async function deleteSite( ); } + let deletedNewtId: string | null = null; + await db.transaction(async (trx) => { if (site.pubKey) { if (site.type == "wireguard") { @@ -73,11 +75,7 @@ export async function deleteSite( .where(eq(newts.siteId, siteId)) .returning(); if (deletedNewt) { - const payload = { - type: `newt/terminate`, - data: {} - }; - sendToClient(deletedNewt.newtId, payload); + deletedNewtId = deletedNewt.newtId; // delete all of the sessions for the newt await trx @@ -90,6 +88,18 @@ export async function deleteSite( await trx.delete(sites).where(eq(sites.siteId, siteId)); }); + // Send termination message outside of transaction to prevent blocking + if (deletedNewtId) { + const payload = { + type: `newt/terminate`, + data: {} + }; + // Don't await this to prevent blocking the response + sendToClient(deletedNewtId, payload).catch(error => { + logger.error("Failed to send termination message to newt:", error); + }); + } + return response(res, { data: null, success: true, diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 9114c395..6227ef28 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -1,4 +1,4 @@ -import { db } from "@server/db"; +import { db, newts } from "@server/db"; import { orgs, roleSites, sites, userSites } from "@server/db"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; @@ -9,6 +9,42 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import NodeCache from "node-cache"; +import semver from "semver"; + +const newtVersionCache = new NodeCache({ stdTTL: 3600 }); // 1 hours in seconds + +async function getLatestNewtVersion(): Promise { + try { + const cachedVersion = newtVersionCache.get("latestNewtVersion"); + if (cachedVersion) { + return cachedVersion; + } + + const response = await fetch( + "https://api.github.com/repos/fosrl/newt/tags" + ); + if (!response.ok) { + logger.warn("Failed to fetch latest Newt version from GitHub"); + return null; + } + + const tags = await response.json(); + if (!Array.isArray(tags) || tags.length === 0) { + logger.warn("No tags found for Newt repository"); + return null; + } + + const latestVersion = tags[0].name; + + newtVersionCache.set("latestNewtVersion", latestVersion); + + return latestVersion; + } catch (error) { + logger.error("Error fetching latest Newt version:", error); + return null; + } +} const listSitesParamsSchema = z .object({ @@ -43,10 +79,13 @@ function querySites(orgId: string, accessibleSiteIds: number[]) { megabytesOut: sites.megabytesOut, orgName: orgs.name, type: sites.type, - online: sites.online + online: sites.online, + address: sites.address, + newtVersion: newts.version }) .from(sites) .leftJoin(orgs, eq(sites.orgId, orgs.orgId)) + .leftJoin(newts, eq(newts.siteId, sites.siteId)) .where( and( inArray(sites.siteId, accessibleSiteIds), @@ -55,8 +94,12 @@ function querySites(orgId: string, accessibleSiteIds: number[]) { ); } +type SiteWithUpdateAvailable = Awaited>[0] & { + newtUpdateAvailable?: boolean; +}; + export type ListSitesResponse = { - sites: Awaited>; + sites: SiteWithUpdateAvailable[]; pagination: { total: number; limit: number; offset: number }; }; @@ -147,9 +190,36 @@ export async function listSites( const totalCountResult = await countQuery; const totalCount = totalCountResult[0].count; + const latestNewtVersion = await getLatestNewtVersion(); + + const sitesWithUpdates: SiteWithUpdateAvailable[] = sitesList.map( + (site) => { + const siteWithUpdate: SiteWithUpdateAvailable = { ...site }; + + if ( + site.type === "newt" && + site.newtVersion && + latestNewtVersion + ) { + try { + siteWithUpdate.newtUpdateAvailable = semver.lt( + site.newtVersion, + latestNewtVersion + ); + } catch (error) { + siteWithUpdate.newtUpdateAvailable = false; + } + } else { + siteWithUpdate.newtUpdateAvailable = false; + } + + return siteWithUpdate; + } + ); + return response(res, { data: { - sites: sitesList, + sites: sitesWithUpdates, pagination: { total: totalCount, limit, diff --git a/server/routers/site/pickSiteDefaults.ts b/server/routers/site/pickSiteDefaults.ts index 00e0d58b..2ae25c11 100644 --- a/server/routers/site/pickSiteDefaults.ts +++ b/server/routers/site/pickSiteDefaults.ts @@ -6,10 +6,11 @@ import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; -import { findNextAvailableCidr } from "@server/lib/ip"; +import { findNextAvailableCidr, getNextAvailableClientSubnet } from "@server/lib/ip"; import { generateId } from "@server/auth/sessions/app"; import config from "@server/lib/config"; import { OpenAPITags, registry } from "@server/openApi"; +import { fromError } from "zod-validation-error"; import { z } from "zod"; export type PickSiteDefaultsResponse = { @@ -19,9 +20,10 @@ export type PickSiteDefaultsResponse = { name: string; listenPort: number; endpoint: string; - subnet: string; + subnet: string; // TODO: make optional? newtId: string; newtSecret: string; + clientAddress?: string; }; registry.registerPath({ @@ -38,12 +40,29 @@ registry.registerPath({ responses: {} }); +const pickSiteDefaultsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + export async function pickSiteDefaults( req: Request, res: Response, next: NextFunction ): Promise { try { + const parsedParams = pickSiteDefaultsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; // TODO: more intelligent way to pick the exit node // make sure there is an exit node by counting the exit nodes table @@ -67,7 +86,7 @@ export async function pickSiteDefaults( .where(eq(sites.exitNodeId, exitNode.exitNodeId)); // TODO: we need to lock this subnet for some time so someone else does not take it - let subnets = sitesQuery.map((site) => site.subnet); + let subnets = sitesQuery.map((site) => site.subnet).filter((subnet) => subnet !== null); // exclude the exit node address by replacing after the / with a site block size subnets.push( exitNode.address.replace( @@ -89,6 +108,18 @@ export async function pickSiteDefaults( ); } + const newClientAddress = await getNextAvailableClientSubnet(orgId); + if (!newClientAddress) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "No available subnet found" + ) + ); + } + + const clientAddress = newClientAddress.split("/")[0]; + const newtId = generateId(15); const secret = generateId(48); @@ -100,7 +131,9 @@ export async function pickSiteDefaults( name: exitNode.name, listenPort: exitNode.listenPort, endpoint: exitNode.endpoint, + // subnet: `${newSubnet.split("/")[0]}/${config.getRawConfig().gerbil.block_size}`, // we want the block size of the whole subnet subnet: newSubnet, + clientAddress: clientAddress, newtId, newtSecret: secret }, diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts index a5a5f7c0..e3724f36 100644 --- a/server/routers/site/updateSite.ts +++ b/server/routers/site/updateSite.ts @@ -9,6 +9,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { isValidCIDR } from "@server/lib/validators"; const updateSiteParamsSchema = z .object({ @@ -20,6 +21,9 @@ const updateSiteBodySchema = z .object({ name: z.string().min(1).max(255).optional(), dockerSocketEnabled: z.boolean().optional(), + remoteSubnets: z + .string() + .optional() // subdomain: z // .string() // .min(1) @@ -85,6 +89,21 @@ export async function updateSite( const { siteId } = parsedParams.data; const updateData = parsedBody.data; + // if remoteSubnets is provided, ensure it's a valid comma-separated list of cidrs + if (updateData.remoteSubnets) { + const subnets = updateData.remoteSubnets.split(",").map((s) => s.trim()); + for (const subnet of subnets) { + if (!isValidCIDR(subnet)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Invalid CIDR format: ${subnet}` + ) + ); + } + } + } + const updatedSite = await db .update(sites) .set(updateData) diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 52bd0417..ffea1571 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -173,7 +173,7 @@ export async function createTarget( .where(eq(newts.siteId, site.siteId)) .limit(1); - addTargets(newt.newtId, newTarget, resource.protocol); + addTargets(newt.newtId, newTarget, resource.protocol, resource.proxyPort); } } } diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index 17a9c5ee..6eadeccd 100644 --- a/server/routers/target/deleteTarget.ts +++ b/server/routers/target/deleteTarget.ts @@ -105,7 +105,7 @@ export async function deleteTarget( .where(eq(newts.siteId, site.siteId)) .limit(1); - removeTargets(newt.newtId, [deletedTarget], resource.protocol); + removeTargets(newt.newtId, [deletedTarget], resource.protocol, resource.proxyPort); } } diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 0138520b..0b7c4692 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -157,7 +157,7 @@ export async function updateTarget( .where(eq(newts.siteId, site.siteId)) .limit(1); - addTargets(newt.newtId, [updatedTarget], resource.protocol); + addTargets(newt.newtId, [updatedTarget], resource.protocol, resource.proxyPort); } } return response(res, { diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 7f70dbc7..882a296a 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -1,11 +1,12 @@ import { Request, Response } from "express"; -import { db } from "@server/db"; -import { and, eq, inArray } from "drizzle-orm"; +import { db, exitNodes } from "@server/db"; +import { and, eq, inArray, or, isNull } from "drizzle-orm"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import config from "@server/lib/config"; import { orgs, resources, sites, Target, targets } from "@server/db"; -import { sql } from "drizzle-orm"; + +let currentExitNodeId: number; export async function traefikConfigProvider( _: Request, @@ -15,39 +16,67 @@ export async function traefikConfigProvider( // Get all resources with related data const allResources = await db.transaction(async (tx) => { // First query to get resources with site and org info + // Get the current exit node name from config + if (!currentExitNodeId) { + if (config.getRawConfig().gerbil.exit_node_name) { + const exitNodeName = + config.getRawConfig().gerbil.exit_node_name!; + const [exitNode] = await tx + .select({ + exitNodeId: exitNodes.exitNodeId + }) + .from(exitNodes) + .where(eq(exitNodes.name, exitNodeName)); + if (exitNode) { + currentExitNodeId = exitNode.exitNodeId; + } + } else { + const [exitNode] = await tx + .select({ + exitNodeId: exitNodes.exitNodeId + }) + .from(exitNodes) + .limit(1); + + if (exitNode) { + currentExitNodeId = exitNode.exitNodeId; + } + } + } + + // Get the site(s) on this exit node const resourcesWithRelations = await tx .select({ // Resource fields resourceId: resources.resourceId, - subdomain: resources.subdomain, fullDomain: resources.fullDomain, ssl: resources.ssl, - blockAccess: resources.blockAccess, - sso: resources.sso, - emailWhitelistEnabled: resources.emailWhitelistEnabled, http: resources.http, proxyPort: resources.proxyPort, protocol: resources.protocol, - isBaseDomain: resources.isBaseDomain, + subdomain: resources.subdomain, domainId: resources.domainId, // Site fields site: { siteId: sites.siteId, type: sites.type, - subnet: sites.subnet - }, - // Org fields - org: { - orgId: orgs.orgId + subnet: sites.subnet, + exitNodeId: sites.exitNodeId }, enabled: resources.enabled, stickySession: resources.stickySession, tlsServerName: resources.tlsServerName, - setHostHeader: resources.setHostHeader + setHostHeader: resources.setHostHeader, + enableProxy: resources.enableProxy }) .from(resources) .innerJoin(sites, eq(sites.siteId, resources.siteId)) - .innerJoin(orgs, eq(resources.orgId, orgs.orgId)); + .where( + or( + eq(sites.exitNodeId, currentExitNodeId), + isNull(sites.exitNodeId) + ) + ); // Get all resource IDs from the first query const resourceIds = resourcesWithRelations.map((r) => r.resourceId); @@ -140,7 +169,6 @@ export async function traefikConfigProvider( for (const resource of allResources) { const targets = resource.targets as Target[]; const site = resource.site; - const org = resource.org; const routerName = `${resource.resourceId}-router`; const serviceName = `${resource.resourceId}-service`; @@ -164,11 +192,6 @@ export async function traefikConfigProvider( continue; } - // HTTP configuration remains the same - if (!resource.subdomain && !resource.isBaseDomain) { - continue; - } - // add routers and services empty objects if they don't exist if (!config_output.http.routers) { config_output.http.routers = {}; @@ -186,22 +209,25 @@ export async function traefikConfigProvider( wildCard = `*.${domainParts.slice(1).join(".")}`; } - if (resource.isBaseDomain) { + if (!resource.subdomain) { wildCard = resource.fullDomain; } const configDomain = config.getDomain(resource.domainId); + let certResolver: string, preferWildcardCert: boolean; if (!configDomain) { - logger.error( - `Failed to get domain from config for resource ${resource.resourceId}` - ); - continue; + certResolver = config.getRawConfig().traefik.cert_resolver; + preferWildcardCert = + config.getRawConfig().traefik.prefer_wildcard_cert; + } else { + certResolver = configDomain.cert_resolver; + preferWildcardCert = configDomain.prefer_wildcard_cert; } const tls = { - certResolver: configDomain.cert_resolver, - ...(configDomain.prefer_wildcard_cert + certResolver: certResolver, + ...(preferWildcardCert ? { domains: [ { @@ -227,6 +253,7 @@ export async function traefikConfigProvider( ], service: serviceName, rule: `Host(\`${fullDomain}\`)`, + priority: 100, ...(resource.ssl ? { tls } : {}) }; @@ -237,7 +264,8 @@ export async function traefikConfigProvider( ], middlewares: [redirectHttpsMiddlewareName], service: serviceName, - rule: `Host(\`${fullDomain}\`)` + rule: `Host(\`${fullDomain}\`)`, + priority: 100 }; } @@ -262,7 +290,8 @@ export async function traefikConfigProvider( } else if (site.type === "newt") { if ( !target.internalPort || - !target.method + !target.method || + !site.subnet ) { return false; } @@ -278,7 +307,7 @@ export async function traefikConfigProvider( url: `${target.method}://${target.ip}:${target.port}` }; } else if (site.type === "newt") { - const ip = site.subnet.split("/")[0]; + const ip = site.subnet!.split("/")[0]; return { url: `${target.method}://${ip}:${target.internalPort}` }; @@ -309,7 +338,9 @@ export async function traefikConfigProvider( // if defined in the static config and here. if not set, self-signed certs won't work insecureSkipVerify: true }; - config_output.http.services![serviceName].loadBalancer.serversTransport = transportName; + config_output.http.services![ + serviceName + ].loadBalancer.serversTransport = transportName; } // Add the host header middleware @@ -317,25 +348,28 @@ export async function traefikConfigProvider( if (!config_output.http.middlewares) { config_output.http.middlewares = {}; } - config_output.http.middlewares[hostHeaderMiddlewareName] = - { - headers: { - customRequestHeaders: { - Host: resource.setHostHeader - } + config_output.http.middlewares[hostHeaderMiddlewareName] = { + headers: { + customRequestHeaders: { + Host: resource.setHostHeader } - }; + } + }; if (!config_output.http.routers![routerName].middlewares) { - config_output.http.routers![routerName].middlewares = []; + config_output.http.routers![routerName].middlewares = + []; } config_output.http.routers![routerName].middlewares = [ ...config_output.http.routers![routerName].middlewares, hostHeaderMiddlewareName ]; } - } else { // Non-HTTP (TCP/UDP) configuration + if (!resource.enableProxy) { + continue; + } + const protocol = resource.protocol.toLowerCase(); const port = resource.proxyPort; @@ -371,7 +405,7 @@ export async function traefikConfigProvider( return false; } } else if (site.type === "newt") { - if (!target.internalPort) { + if (!target.internalPort || !site.subnet) { return false; } } @@ -386,7 +420,7 @@ export async function traefikConfigProvider( address: `${target.ip}:${target.port}` }; } else if (site.type === "newt") { - const ip = site.subnet.split("/")[0]; + const ip = site.subnet!.split("/")[0]; return { address: `${ip}:${target.internalPort}` }; diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index 115168b9..73bed018 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, UserOrg } from "@server/db"; import { roles, userInvites, userOrgs, users } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; @@ -92,6 +92,7 @@ export async function acceptInvite( } let roleId: number; + let totalUsers: UserOrg[] | undefined; // get the role to make sure it exists const existingRole = await db .select() @@ -122,6 +123,12 @@ export async function acceptInvite( await trx .delete(userInvites) .where(eq(userInvites.inviteId, inviteId)); + + // Get the total number of users in the org now + totalUsers = await db + .select() + .from(userOrgs) + .where(eq(userOrgs.orgId, existingInvite.orgId)); }); return response(res, { diff --git a/server/routers/user/adminGetUser.ts b/server/routers/user/adminGetUser.ts new file mode 100644 index 00000000..0a961bec --- /dev/null +++ b/server/routers/user/adminGetUser.ts @@ -0,0 +1,94 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { idp, users } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; + +const adminGetUserSchema = z + .object({ + userId: z.string().min(1) + }) + .strict(); + +registry.registerPath({ + method: "get", + path: "/user/{userId}", + description: "Get a user by ID.", + tags: [OpenAPITags.User], + request: { + params: adminGetUserSchema + }, + responses: {} +}); + +async function queryUser(userId: string) { + const [user] = await db + .select({ + userId: users.userId, + email: users.email, + username: users.username, + name: users.name, + type: users.type, + twoFactorEnabled: users.twoFactorEnabled, + twoFactorSetupRequested: users.twoFactorSetupRequested, + emailVerified: users.emailVerified, + serverAdmin: users.serverAdmin, + idpName: idp.name, + idpId: users.idpId, + dateCreated: users.dateCreated + }) + .from(users) + .leftJoin(idp, eq(users.idpId, idp.idpId)) + .where(eq(users.userId, userId)) + .limit(1); + return user; +} + +export type AdminGetUserResponse = NonNullable< + Awaited> +>; + +export async function adminGetUser( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = adminGetUserSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid user ID") + ); + } + const { userId } = parsedParams.data; + + const user = await queryUser(userId); + + if (!user) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `User with ID ${userId} not found` + ) + ); + } + + return response(res, { + data: user, + success: true, + error: false, + message: "User retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/user/adminListUsers.ts b/server/routers/user/adminListUsers.ts index cb1e21fb..308b9def 100644 --- a/server/routers/user/adminListUsers.ts +++ b/server/routers/user/adminListUsers.ts @@ -37,7 +37,9 @@ async function queryUsers(limit: number, offset: number) { serverAdmin: users.serverAdmin, type: users.type, idpName: idp.name, - idpId: users.idpId + idpId: users.idpId, + twoFactorEnabled: users.twoFactorEnabled, + twoFactorSetupRequested: users.twoFactorSetupRequested }) .from(users) .leftJoin(idp, eq(users.idpId, idp.idpId)) diff --git a/server/routers/user/adminUpdateUser2FA.ts b/server/routers/user/adminUpdateUser2FA.ts new file mode 100644 index 00000000..becd2091 --- /dev/null +++ b/server/routers/user/adminUpdateUser2FA.ts @@ -0,0 +1,133 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { users, userOrgs } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const updateUser2FAParamsSchema = z + .object({ + userId: z.string() + }) + .strict(); + +const updateUser2FABodySchema = z + .object({ + twoFactorSetupRequested: z.boolean() + }) + .strict(); + +export type UpdateUser2FAResponse = { + userId: string; + twoFactorRequested: boolean; +}; + +registry.registerPath({ + method: "post", + path: "/user/{userId}/2fa", + description: "Update a user's 2FA status.", + tags: [OpenAPITags.User], + request: { + params: updateUser2FAParamsSchema, + body: { + content: { + "application/json": { + schema: updateUser2FABodySchema + } + } + } + }, + responses: {} +}); + +export async function updateUser2FA( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = updateUser2FAParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = updateUser2FABodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { userId } = parsedParams.data; + const { twoFactorSetupRequested } = parsedBody.data; + + // Verify the user exists in the organization + const existingUser = await db + .select() + .from(users) + .where(eq(users.userId, userId)) + .limit(1); + + if (existingUser.length === 0) { + return next(createHttpError(HttpCode.NOT_FOUND, "User not found")); + } + + if (existingUser[0].type !== "internal") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Two-factor authentication is not supported for external users" + ) + ); + } + + logger.debug(`Updating 2FA for user ${userId} to ${twoFactorSetupRequested}`); + + if (twoFactorSetupRequested) { + await db + .update(users) + .set({ + twoFactorSetupRequested: true, + }) + .where(eq(users.userId, userId)); + } else { + await db + .update(users) + .set({ + twoFactorSetupRequested: false, + twoFactorEnabled: false, + twoFactorSecret: null + }) + .where(eq(users.userId, userId)); + } + + return response(res, { + data: { + userId: existingUser[0].userId, + twoFactorRequested: twoFactorSetupRequested + }, + success: true, + error: false, + message: `2FA ${twoFactorSetupRequested ? "enabled" : "disabled"} for user successfully`, + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index 264ea3d9..4419772a 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -6,7 +6,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { db } from "@server/db"; +import { db, UserOrg } from "@server/db"; import { and, eq } from "drizzle-orm"; import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db"; import { generateId } from "@server/auth/sessions/app"; @@ -135,65 +135,76 @@ export async function createOrgUser( ); } - const [existingUser] = await db - .select() - .from(users) - .where(eq(users.username, username)); + let orgUsers: UserOrg[] | undefined; - if (existingUser) { - const [existingOrgUser] = await db + await db.transaction(async (trx) => { + const [existingUser] = await trx .select() - .from(userOrgs) - .where( - and( - eq(userOrgs.orgId, orgId), - eq(userOrgs.userId, existingUser.userId) - ) - ); + .from(users) + .where(eq(users.username, username)); - if (existingOrgUser) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "User already exists in this organization" - ) - ); + if (existingUser) { + const [existingOrgUser] = await trx + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.orgId, orgId), + eq(userOrgs.userId, existingUser.userId) + ) + ); + + if (existingOrgUser) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User already exists in this organization" + ) + ); + } + + await trx + .insert(userOrgs) + .values({ + orgId, + userId: existingUser.userId, + roleId: role.roleId + }) + .returning(); + } else { + const userId = generateId(15); + + const [newUser] = await trx + .insert(users) + .values({ + userId: userId, + email, + username, + name, + type: "oidc", + idpId, + dateCreated: new Date().toISOString(), + emailVerified: true + }) + .returning(); + + await trx + .insert(userOrgs) + .values({ + orgId, + userId: newUser.userId, + roleId: role.roleId + }) + .returning(); } - await db - .insert(userOrgs) - .values({ - orgId, - userId: existingUser.userId, - roleId: role.roleId - }) - .returning(); - } else { - const userId = generateId(15); + // List all of the users in the org + orgUsers = await trx + .select() + .from(userOrgs) + .where(eq(userOrgs.orgId, orgId)); + }); - const [newUser] = await db - .insert(users) - .values({ - userId: userId, - email, - username, - name, - type: "oidc", - idpId, - dateCreated: new Date().toISOString(), - emailVerified: true - }) - .returning(); - - await db - .insert(userOrgs) - .values({ - orgId, - userId: newUser.userId, - roleId: role.roleId - }) - .returning(); - } } else { return next( createHttpError(HttpCode.BAD_REQUEST, "User type is required") diff --git a/server/routers/user/getOrgUser.ts b/server/routers/user/getOrgUser.ts index 562ef34e..05e231c9 100644 --- a/server/routers/user/getOrgUser.ts +++ b/server/routers/user/getOrgUser.ts @@ -23,7 +23,8 @@ async function queryUser(orgId: string, userId: string) { roleId: userOrgs.roleId, roleName: roles.name, isOwner: userOrgs.isOwner, - isAdmin: roles.isAdmin + isAdmin: roles.isAdmin, + twoFactorEnabled: users.twoFactorEnabled, }) .from(userOrgs) .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 49278c14..6d342ad3 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -7,6 +7,9 @@ export * from "./acceptInvite"; export * from "./getOrgUser"; export * from "./adminListUsers"; export * from "./adminRemoveUser"; +export * from "./adminGetUser"; export * from "./listInvitations"; export * from "./removeInvitation"; export * from "./createOrgUser"; +export * from "./adminUpdateUser2FA"; +export * from "./adminGetUser"; diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 5b2e8d1e..837ef179 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -99,6 +99,7 @@ export async function inviteUser( regenerate } = parsedBody.data; + // Check if the organization exists const org = await db .select() diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts index 2e23f401..83c1e492 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -49,7 +49,8 @@ async function queryUsers(orgId: string, limit: number, offset: number) { roleName: roles.name, isOwner: userOrgs.isOwner, idpName: idp.name, - idpId: users.idpId + idpId: users.idpId, + twoFactorEnabled: users.twoFactorEnabled, }) .from(users) .leftJoin(userOrgs, eq(users.userId, userOrgs.userId)) diff --git a/server/routers/user/removeUserOrg.ts b/server/routers/user/removeUserOrg.ts index 6e57a218..dcd8c6f2 100644 --- a/server/routers/user/removeUserOrg.ts +++ b/server/routers/user/removeUserOrg.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, resources, sites } from "@server/db"; +import { db, resources, sites, UserOrg } from "@server/db"; import { userOrgs, userResources, users, userSites } from "@server/db"; import { and, eq, exists } from "drizzle-orm"; import response from "@server/lib/response"; @@ -65,6 +65,8 @@ export async function removeUserOrg( ); } + let userCount: UserOrg[] | undefined; + await db.transaction(async (trx) => { await trx .delete(userOrgs) @@ -108,6 +110,11 @@ export async function removeUserOrg( ) ) ); + + userCount = await trx + .select() + .from(userOrgs) + .where(eq(userOrgs.orgId, orgId)); }); return response(res, { diff --git a/server/routers/ws.ts b/server/routers/ws.ts deleted file mode 100644 index 377047f1..00000000 --- a/server/routers/ws.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { Router, Request, Response } from "express"; -import { Server as HttpServer } from "http"; -import { WebSocket, WebSocketServer } from "ws"; -import { IncomingMessage } from "http"; -import { Socket } from "net"; -import { Newt, newts, NewtSession } from "@server/db"; -import { eq } from "drizzle-orm"; -import { db } from "@server/db"; -import { validateNewtSessionToken } from "@server/auth/sessions/newt"; -import { messageHandlers } from "./messageHandlers"; -import logger from "@server/logger"; - -// Custom interfaces -interface WebSocketRequest extends IncomingMessage { - token?: string; -} - -interface AuthenticatedWebSocket extends WebSocket { - newt?: Newt; -} - -interface TokenPayload { - newt: Newt; - session: NewtSession; -} - -interface WSMessage { - type: string; - data: any; -} - -interface HandlerResponse { - message: WSMessage; - broadcast?: boolean; - excludeSender?: boolean; - targetNewtId?: string; -} - -interface HandlerContext { - message: WSMessage; - senderWs: WebSocket; - newt: Newt | undefined; - sendToClient: (newtId: string, message: WSMessage) => boolean; - broadcastToAllExcept: (message: WSMessage, excludeNewtId?: string) => void; - connectedClients: Map; -} - -export type MessageHandler = (context: HandlerContext) => Promise; - -const router: Router = Router(); -const wss: WebSocketServer = new WebSocketServer({ noServer: true }); - -// Client tracking map -let connectedClients: Map = new Map(); - -// Helper functions for client management -const addClient = (newtId: string, ws: AuthenticatedWebSocket): void => { - const existingClients = connectedClients.get(newtId) || []; - existingClients.push(ws); - connectedClients.set(newtId, existingClients); - logger.info(`Client added to tracking - Newt ID: ${newtId}, Total connections: ${existingClients.length}`); -}; - -const removeClient = (newtId: string, ws: AuthenticatedWebSocket): void => { - const existingClients = connectedClients.get(newtId) || []; - const updatedClients = existingClients.filter(client => client !== ws); - - if (updatedClients.length === 0) { - connectedClients.delete(newtId); - logger.info(`All connections removed for Newt ID: ${newtId}`); - } else { - connectedClients.set(newtId, updatedClients); - logger.info(`Connection removed - Newt ID: ${newtId}, Remaining connections: ${updatedClients.length}`); - } -}; - -// Helper functions for sending messages -const sendToClient = (newtId: string, message: WSMessage): boolean => { - const clients = connectedClients.get(newtId); - if (!clients || clients.length === 0) { - logger.info(`No active connections found for Newt ID: ${newtId}`); - return false; - } - - const messageString = JSON.stringify(message); - clients.forEach(client => { - if (client.readyState === WebSocket.OPEN) { - client.send(messageString); - } - }); - return true; -}; - -const broadcastToAllExcept = (message: WSMessage, excludeNewtId?: string): void => { - connectedClients.forEach((clients, newtId) => { - if (newtId !== excludeNewtId) { - clients.forEach(client => { - if (client.readyState === WebSocket.OPEN) { - client.send(JSON.stringify(message)); - } - }); - } - }); -}; - -// Token verification middleware (unchanged) -const verifyToken = async (token: string): Promise => { - try { - const { session, newt } = await validateNewtSessionToken(token); - - if (!session || !newt) { - return null; - } - - const existingNewt = await db - .select() - .from(newts) - .where(eq(newts.newtId, newt.newtId)); - - if (!existingNewt || !existingNewt[0]) { - return null; - } - - return { newt: existingNewt[0], session }; - } catch (error) { - logger.error("Token verification failed:", error); - return null; - } -}; - -const setupConnection = (ws: AuthenticatedWebSocket, newt: Newt): void => { - logger.info("Establishing websocket connection"); - - if (!newt) { - logger.error("Connection attempt without newt"); - return ws.terminate(); - } - - ws.newt = newt; - - // Add client to tracking - addClient(newt.newtId, ws); - - ws.on("message", async (data) => { - try { - const message: WSMessage = JSON.parse(data.toString()); - // logger.info(`Message received from Newt ID ${newtId}:`, message); - - // Validate message format - if (!message.type || typeof message.type !== "string") { - throw new Error("Invalid message format: missing or invalid type"); - } - - // Get the appropriate handler for the message type - const handler = messageHandlers[message.type]; - if (!handler) { - throw new Error(`Unsupported message type: ${message.type}`); - } - - // Process the message and get response - const response = await handler({ - message, - senderWs: ws, - newt: ws.newt, - sendToClient, - broadcastToAllExcept, - connectedClients - }); - - // Send response if one was returned - if (response) { - if (response.broadcast) { - // Broadcast to all clients except sender if specified - broadcastToAllExcept(response.message, response.excludeSender ? newt.newtId : undefined); - } else if (response.targetNewtId) { - // Send to specific client if targetNewtId is provided - sendToClient(response.targetNewtId, response.message); - } else { - // Send back to sender - ws.send(JSON.stringify(response.message)); - } - } - - } catch (error) { - logger.error("Message handling error:", error); - ws.send(JSON.stringify({ - type: "error", - data: { - message: error instanceof Error ? error.message : "Unknown error occurred", - originalMessage: data.toString() - } - })); - } - }); - - ws.on("close", () => { - removeClient(newt.newtId, ws); - logger.info(`Client disconnected - Newt ID: ${newt.newtId}`); - }); - - ws.on("error", (error: Error) => { - logger.error(`WebSocket error for Newt ID ${newt.newtId}:`, error); - }); - - logger.info(`WebSocket connection established - Newt ID: ${newt.newtId}`); -}; - -// Router endpoint (unchanged) -router.get("/ws", (req: Request, res: Response) => { - res.status(200).send("WebSocket endpoint"); -}); - -// WebSocket upgrade handler -const handleWSUpgrade = (server: HttpServer): void => { - server.on("upgrade", async (request: WebSocketRequest, socket: Socket, head: Buffer) => { - try { - const token = request.url?.includes("?") - ? new URLSearchParams(request.url.split("?")[1]).get("token") || "" - : request.headers["sec-websocket-protocol"]; - - if (!token) { - logger.warn("Unauthorized connection attempt: no token..."); - socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); - socket.destroy(); - return; - } - - const tokenPayload = await verifyToken(token); - if (!tokenPayload) { - logger.warn("Unauthorized connection attempt: invalid token..."); - socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); - socket.destroy(); - return; - } - - wss.handleUpgrade(request, socket, head, (ws: AuthenticatedWebSocket) => { - setupConnection(ws, tokenPayload.newt); - }); - } catch (error) { - logger.error("WebSocket upgrade error:", error); - socket.write("HTTP/1.1 500 Internal Server Error\r\n\r\n"); - socket.destroy(); - } - }); -}; - -export { - router, - handleWSUpgrade, - sendToClient, - broadcastToAllExcept, - connectedClients -}; diff --git a/server/routers/ws/index.ts b/server/routers/ws/index.ts new file mode 100644 index 00000000..cf95932c --- /dev/null +++ b/server/routers/ws/index.ts @@ -0,0 +1 @@ +export * from "./ws"; \ No newline at end of file diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts new file mode 100644 index 00000000..d85cc277 --- /dev/null +++ b/server/routers/ws/messageHandlers.ts @@ -0,0 +1,29 @@ +import { + handleNewtRegisterMessage, + handleReceiveBandwidthMessage, + handleGetConfigMessage, + handleDockerStatusMessage, + handleDockerContainersMessage, + handleNewtPingRequestMessage +} from "../newt"; +import { + handleOlmRegisterMessage, + handleOlmRelayMessage, + handleOlmPingMessage, + startOfflineChecker +} from "../olm"; +import { MessageHandler } from "./ws"; + +export const messageHandlers: Record = { + "newt/wg/register": handleNewtRegisterMessage, + "olm/wg/register": handleOlmRegisterMessage, + "newt/wg/get-config": handleGetConfigMessage, + "newt/receive-bandwidth": handleReceiveBandwidthMessage, + "olm/wg/relay": handleOlmRelayMessage, + "olm/ping": handleOlmPingMessage, + "newt/socket/status": handleDockerStatusMessage, + "newt/socket/containers": handleDockerContainersMessage, + "newt/ping/request": handleNewtPingRequestMessage, +}; + +startOfflineChecker(); // this is to handle the offline check for olms diff --git a/server/routers/ws/ws.ts b/server/routers/ws/ws.ts new file mode 100644 index 00000000..0d9f84d3 --- /dev/null +++ b/server/routers/ws/ws.ts @@ -0,0 +1,340 @@ +import { Router, Request, Response } from "express"; +import { Server as HttpServer } from "http"; +import { WebSocket, WebSocketServer } from "ws"; +import { IncomingMessage } from "http"; +import { Socket } from "net"; +import { Newt, newts, NewtSession, olms, Olm, OlmSession } from "@server/db"; +import { eq } from "drizzle-orm"; +import { db } from "@server/db"; +import { validateNewtSessionToken } from "@server/auth/sessions/newt"; +import { validateOlmSessionToken } from "@server/auth/sessions/olm"; +import { messageHandlers } from "./messageHandlers"; +import logger from "@server/logger"; +import { v4 as uuidv4 } from "uuid"; + +// Custom interfaces +interface WebSocketRequest extends IncomingMessage { + token?: string; +} + +type ClientType = 'newt' | 'olm'; + +interface AuthenticatedWebSocket extends WebSocket { + client?: Newt | Olm; + clientType?: ClientType; + connectionId?: string; +} + +interface TokenPayload { + client: Newt | Olm; + session: NewtSession | OlmSession; + clientType: ClientType; +} + +interface WSMessage { + type: string; + data: any; +} + +interface HandlerResponse { + message: WSMessage; + broadcast?: boolean; + excludeSender?: boolean; + targetClientId?: string; +} + +interface HandlerContext { + message: WSMessage; + senderWs: WebSocket; + client: Newt | Olm | undefined; + clientType: ClientType; + sendToClient: (clientId: string, message: WSMessage) => Promise; + broadcastToAllExcept: (message: WSMessage, excludeClientId?: string) => Promise; + connectedClients: Map; +} + +export type MessageHandler = (context: HandlerContext) => Promise; + +const router: Router = Router(); +const wss: WebSocketServer = new WebSocketServer({ noServer: true }); + +// Generate unique node ID for this instance +const NODE_ID = uuidv4(); + +// Client tracking map (local to this node) +let connectedClients: Map = new Map(); +// Helper to get map key +const getClientMapKey = (clientId: string) => clientId; + +// Helper functions for client management +const addClient = async (clientType: ClientType, clientId: string, ws: AuthenticatedWebSocket): Promise => { + // Generate unique connection ID + const connectionId = uuidv4(); + ws.connectionId = connectionId; + + // Add to local tracking + const mapKey = getClientMapKey(clientId); + const existingClients = connectedClients.get(mapKey) || []; + existingClients.push(ws); + connectedClients.set(mapKey, existingClients); + + logger.info(`Client added to tracking - ${clientType.toUpperCase()} ID: ${clientId}, Connection ID: ${connectionId}, Total connections: ${existingClients.length}`); +}; + +const removeClient = async (clientType: ClientType, clientId: string, ws: AuthenticatedWebSocket): Promise => { + const mapKey = getClientMapKey(clientId); + const existingClients = connectedClients.get(mapKey) || []; + const updatedClients = existingClients.filter(client => client !== ws); + if (updatedClients.length === 0) { + connectedClients.delete(mapKey); + + logger.info(`All connections removed for ${clientType.toUpperCase()} ID: ${clientId}`); + } else { + connectedClients.set(mapKey, updatedClients); + + logger.info(`Connection removed - ${clientType.toUpperCase()} ID: ${clientId}, Remaining connections: ${updatedClients.length}`); + } +}; + +// Local message sending (within this node) +const sendToClientLocal = async (clientId: string, message: WSMessage): Promise => { + const mapKey = getClientMapKey(clientId); + const clients = connectedClients.get(mapKey); + if (!clients || clients.length === 0) { + return false; + } + const messageString = JSON.stringify(message); + clients.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + client.send(messageString); + } + }); + return true; +}; + +const broadcastToAllExceptLocal = async (message: WSMessage, excludeClientId?: string): Promise => { + connectedClients.forEach((clients, mapKey) => { + const [type, id] = mapKey.split(":"); + if (!(excludeClientId && id === excludeClientId)) { + clients.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + client.send(JSON.stringify(message)); + } + }); + } + }); +}; + +// Cross-node message sending +const sendToClient = async (clientId: string, message: WSMessage): Promise => { + // Try to send locally first + const localSent = await sendToClientLocal(clientId, message); + + return localSent; +}; + +const broadcastToAllExcept = async (message: WSMessage, excludeClientId?: string): Promise => { + // Broadcast locally + await broadcastToAllExceptLocal(message, excludeClientId); +}; + +// Check if a client has active connections across all nodes +const hasActiveConnections = async (clientId: string): Promise => { + const mapKey = getClientMapKey(clientId); + const clients = connectedClients.get(mapKey); + return !!(clients && clients.length > 0); +}; + +// Get all active nodes for a client +const getActiveNodes = async (clientType: ClientType, clientId: string): Promise => { + const mapKey = getClientMapKey(clientId); + const clients = connectedClients.get(mapKey); + return (clients && clients.length > 0) ? [NODE_ID] : []; +}; + +// Token verification middleware +const verifyToken = async (token: string, clientType: ClientType): Promise => { + +try { + if (clientType === 'newt') { + const { session, newt } = await validateNewtSessionToken(token); + if (!session || !newt) { + return null; + } + const existingNewt = await db + .select() + .from(newts) + .where(eq(newts.newtId, newt.newtId)); + if (!existingNewt || !existingNewt[0]) { + return null; + } + return { client: existingNewt[0], session, clientType }; + } else { + const { session, olm } = await validateOlmSessionToken(token); + if (!session || !olm) { + return null; + } + const existingOlm = await db + .select() + .from(olms) + .where(eq(olms.olmId, olm.olmId)); + if (!existingOlm || !existingOlm[0]) { + return null; + } + return { client: existingOlm[0], session, clientType }; + } + } catch (error) { + logger.error("Token verification failed:", error); + return null; + } +}; + +const setupConnection = async (ws: AuthenticatedWebSocket, client: Newt | Olm, clientType: ClientType): Promise => { + logger.info("Establishing websocket connection"); + if (!client) { + logger.error("Connection attempt without client"); + return ws.terminate(); + } + + ws.client = client; + ws.clientType = clientType; + + // Add client to tracking + const clientId = clientType === 'newt' ? (client as Newt).newtId : (client as Olm).olmId; + await addClient(clientType, clientId, ws); + + ws.on("message", async (data) => { + try { + const message: WSMessage = JSON.parse(data.toString()); + + if (!message.type || typeof message.type !== "string") { + throw new Error("Invalid message format: missing or invalid type"); + } + + const handler = messageHandlers[message.type]; + if (!handler) { + throw new Error(`Unsupported message type: ${message.type}`); + } + + const response = await handler({ + message, + senderWs: ws, + client: ws.client, + clientType: ws.clientType!, + sendToClient, + broadcastToAllExcept, + connectedClients + }); + + if (response) { + if (response.broadcast) { + await broadcastToAllExcept( + response.message, + response.excludeSender ? clientId : undefined + ); + } else if (response.targetClientId) { + await sendToClient(response.targetClientId, response.message); + } else { + ws.send(JSON.stringify(response.message)); + } + } + } catch (error) { + logger.error("Message handling error:", error); + ws.send(JSON.stringify({ + type: "error", + data: { + message: error instanceof Error ? error.message : "Unknown error occurred", + originalMessage: data.toString() + } + })); + } + }); + + ws.on("close", () => { + removeClient(clientType, clientId, ws); + logger.info(`Client disconnected - ${clientType.toUpperCase()} ID: ${clientId}`); + }); + + ws.on("error", (error: Error) => { + logger.error(`WebSocket error for ${clientType.toUpperCase()} ID ${clientId}:`, error); + }); + + logger.info(`WebSocket connection established - ${clientType.toUpperCase()} ID: ${clientId}`); +}; + +// Router endpoint +router.get("/ws", (req: Request, res: Response) => { + res.status(200).send("WebSocket endpoint"); +}); + +// WebSocket upgrade handler +const handleWSUpgrade = (server: HttpServer): void => { + server.on("upgrade", async (request: WebSocketRequest, socket: Socket, head: Buffer) => { + try { + const url = new URL(request.url || '', `http://${request.headers.host}`); + const token = url.searchParams.get('token') || request.headers["sec-websocket-protocol"] || ''; + let clientType = url.searchParams.get('clientType') as ClientType; + + if (!clientType) { + clientType = "newt"; + } + + if (!token || !clientType || !['newt', 'olm'].includes(clientType)) { + logger.warn("Unauthorized connection attempt: invalid token or client type..."); + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return; + } + + const tokenPayload = await verifyToken(token, clientType); + if (!tokenPayload) { + logger.warn("Unauthorized connection attempt: invalid token..."); + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return; + } + + wss.handleUpgrade(request, socket, head, (ws: AuthenticatedWebSocket) => { + setupConnection(ws, tokenPayload.client, tokenPayload.clientType); + }); + } catch (error) { + logger.error("WebSocket upgrade error:", error); + socket.write("HTTP/1.1 500 Internal Server Error\r\n\r\n"); + socket.destroy(); + } + }); +}; + +// Cleanup function for graceful shutdown +const cleanup = async (): Promise => { + try { + // Close all WebSocket connections + connectedClients.forEach((clients) => { + clients.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + client.terminate(); + } + }); + }); + + logger.info('WebSocket cleanup completed'); + } catch (error) { + logger.error('Error during WebSocket cleanup:', error); + } +}; + +// Handle process termination +process.on('SIGTERM', cleanup); +process.on('SIGINT', cleanup); + +export { + router, + handleWSUpgrade, + sendToClient, + broadcastToAllExcept, + connectedClients, + hasActiveConnections, + getActiveNodes, + NODE_ID, + cleanup +}; diff --git a/server/setup/copyInConfig.ts b/server/setup/copyInConfig.ts index 6ab8d446..eccee475 100644 --- a/server/setup/copyInConfig.ts +++ b/server/setup/copyInConfig.ts @@ -8,8 +8,31 @@ export async function copyInConfig() { const endpoint = config.getRawConfig().gerbil.base_endpoint; const listenPort = config.getRawConfig().gerbil.start_port; + if (!config.getRawConfig().flags?.disable_config_managed_domains) { + await copyInDomains(); + } + + const exitNodeName = config.getRawConfig().gerbil.exit_node_name; + if (exitNodeName) { + await db + .update(exitNodes) + .set({ endpoint, listenPort }) + .where(eq(exitNodes.name, exitNodeName)); + } else { + await db + .update(exitNodes) + .set({ endpoint }) + .where(ne(exitNodes.endpoint, endpoint)); + await db + .update(exitNodes) + .set({ listenPort }) + .where(ne(exitNodes.listenPort, listenPort)); + } +} + +async function copyInDomains() { await db.transaction(async (trx) => { - const rawDomains = config.getRawConfig().domains; + const rawDomains = config.getRawConfig().domains!; // always defined if disable flag is not set const configDomains = Object.entries(rawDomains).map( ([key, value]) => ({ @@ -40,13 +63,19 @@ export async function copyInConfig() { if (existingDomainKeys.has(domainId)) { await trx .update(domains) - .set({ baseDomain }) + .set({ baseDomain, verified: true, type: "wildcard" }) .where(eq(domains.domainId, domainId)) .execute(); } else { await trx .insert(domains) - .values({ domainId, baseDomain, configManaged: true }) + .values({ + domainId, + baseDomain, + configManaged: true, + type: "wildcard", + verified: true + }) .execute(); } } @@ -92,7 +121,7 @@ export async function copyInConfig() { } let fullDomain = ""; - if (resource.isBaseDomain) { + if (!resource.subdomain) { fullDomain = domain.baseDomain; } else { fullDomain = `${resource.subdomain}.${domain.baseDomain}`; @@ -104,15 +133,4 @@ export async function copyInConfig() { .where(eq(resources.resourceId, resource.resourceId)); } }); - - // TODO: eventually each exit node could have a different endpoint - await db - .update(exitNodes) - .set({ endpoint }) - .where(ne(exitNodes.endpoint, endpoint)); - // TODO: eventually each exit node could have a different port - await db - .update(exitNodes) - .set({ listenPort }) - .where(ne(exitNodes.listenPort, listenPort)); } diff --git a/server/setup/index.ts b/server/setup/index.ts index 05971893..d126869a 100644 --- a/server/setup/index.ts +++ b/server/setup/index.ts @@ -1,15 +1,9 @@ import { ensureActions } from "./ensureActions"; import { copyInConfig } from "./copyInConfig"; -import logger from "@server/logger"; import { clearStaleData } from "./clearStaleData"; export async function runSetupFunctions() { - try { - await copyInConfig(); // copy in the config to the db as needed - await ensureActions(); // make sure all of the actions are in the db and the roles - await clearStaleData(); - } catch (error) { - logger.error("Error running setup functions:", error); - process.exit(1); - } + await copyInConfig(); // copy in the config to the db as needed + await ensureActions(); // make sure all of the actions are in the db and the roles + await clearStaleData(); } diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index c0de0f09..07ece65b 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -1,3 +1,4 @@ +#! /usr/bin/env node import { migrate } from "drizzle-orm/node-postgres/migrator"; import { db } from "../db/pg"; import semver from "semver"; @@ -5,13 +6,17 @@ import { versionMigrations } from "../db/pg"; import { __DIRNAME, APP_VERSION } from "@server/lib/consts"; import path from "path"; import m1 from "./scriptsPg/1.6.0"; +import m2 from "./scriptsPg/1.7.0"; +import m3 from "./scriptsPg/1.8.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA // Define the migration list with versions and their corresponding functions const migrations = [ - { version: "1.6.0", run: m1 } + { version: "1.6.0", run: m1 }, + { version: "1.7.0", run: m2 }, + { version: "1.8.0", run: m3 } // Add new migrations here as they are created ] as { version: string; @@ -26,6 +31,10 @@ async function run() { } export async function runMigrations() { + if (process.env.DISABLE_MIGRATIONS) { + console.log("Migrations are disabled. Skipping..."); + return; + } try { const appVersion = APP_VERSION; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 1e279bae..15dd28d2 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -1,3 +1,4 @@ +#! /usr/bin/env node import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { db, exists } from "../db/sqlite"; import path from "path"; @@ -22,6 +23,8 @@ import m18 from "./scriptsSqlite/1.2.0"; import m19 from "./scriptsSqlite/1.3.0"; import m20 from "./scriptsSqlite/1.5.0"; import m21 from "./scriptsSqlite/1.6.0"; +import m22 from "./scriptsSqlite/1.7.0"; +import m23 from "./scriptsSqlite/1.8.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -43,7 +46,9 @@ const migrations = [ { version: "1.2.0", run: m18 }, { version: "1.3.0", run: m19 }, { version: "1.5.0", run: m20 }, - { version: "1.6.0", run: m21 } + { version: "1.6.0", run: m21 }, + { version: "1.7.0", run: m22 }, + { version: "1.8.0", run: m23 }, // Add new migrations here as they are created ] as const; @@ -76,6 +81,10 @@ function backupDb() { } export async function runMigrations() { + if (process.env.DISABLE_MIGRATIONS) { + console.log("Migrations are disabled. Skipping..."); + return; + } try { const appVersion = APP_VERSION; diff --git a/server/setup/scriptsPg/1.7.0.ts b/server/setup/scriptsPg/1.7.0.ts new file mode 100644 index 00000000..3cb799e0 --- /dev/null +++ b/server/setup/scriptsPg/1.7.0.ts @@ -0,0 +1,163 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; + +const version = "1.7.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + await db.execute(sql` + BEGIN; + + CREATE TABLE "clientSites" ( + "clientId" integer NOT NULL, + "siteId" integer NOT NULL, + "isRelayed" boolean DEFAULT false NOT NULL + ); + + CREATE TABLE "clients" ( + "id" serial PRIMARY KEY NOT NULL, + "orgId" varchar NOT NULL, + "exitNode" integer, + "name" varchar NOT NULL, + "pubKey" varchar, + "subnet" varchar NOT NULL, + "bytesIn" integer, + "bytesOut" integer, + "lastBandwidthUpdate" varchar, + "lastPing" varchar, + "type" varchar NOT NULL, + "online" boolean DEFAULT false NOT NULL, + "endpoint" varchar, + "lastHolePunch" integer, + "maxConnections" integer + ); + + CREATE TABLE "clientSession" ( + "id" varchar PRIMARY KEY NOT NULL, + "olmId" varchar NOT NULL, + "expiresAt" integer NOT NULL + ); + + CREATE TABLE "olms" ( + "id" varchar PRIMARY KEY NOT NULL, + "secretHash" varchar NOT NULL, + "dateCreated" varchar NOT NULL, + "clientId" integer + ); + + CREATE TABLE "roleClients" ( + "roleId" integer NOT NULL, + "clientId" integer NOT NULL + ); + + CREATE TABLE "webauthnCredentials" ( + "credentialId" varchar PRIMARY KEY NOT NULL, + "userId" varchar NOT NULL, + "publicKey" varchar NOT NULL, + "signCount" integer NOT NULL, + "transports" varchar, + "name" varchar, + "lastUsed" varchar NOT NULL, + "dateCreated" varchar NOT NULL, + "securityKeyName" varchar + ); + + CREATE TABLE "userClients" ( + "userId" varchar NOT NULL, + "clientId" integer NOT NULL + ); + + CREATE TABLE "webauthnChallenge" ( + "sessionId" varchar PRIMARY KEY NOT NULL, + "challenge" varchar NOT NULL, + "securityKeyName" varchar, + "userId" varchar, + "expiresAt" bigint NOT NULL + ); + + ALTER TABLE "limits" DISABLE ROW LEVEL SECURITY; + DROP TABLE "limits" CASCADE; + ALTER TABLE "sites" ALTER COLUMN "subnet" DROP NOT NULL; + ALTER TABLE "sites" ALTER COLUMN "bytesIn" SET DEFAULT 0; + ALTER TABLE "sites" ALTER COLUMN "bytesOut" SET DEFAULT 0; + ALTER TABLE "domains" ADD COLUMN "type" varchar; + ALTER TABLE "domains" ADD COLUMN "verified" boolean DEFAULT false NOT NULL; + ALTER TABLE "domains" ADD COLUMN "failed" boolean DEFAULT false NOT NULL; + ALTER TABLE "domains" ADD COLUMN "tries" integer DEFAULT 0 NOT NULL; + ALTER TABLE "exitNodes" ADD COLUMN "maxConnections" integer; + ALTER TABLE "newt" ADD COLUMN "version" varchar; + ALTER TABLE "orgs" ADD COLUMN "subnet" varchar; + ALTER TABLE "sites" ADD COLUMN "address" varchar; + ALTER TABLE "sites" ADD COLUMN "endpoint" varchar; + ALTER TABLE "sites" ADD COLUMN "publicKey" varchar; + ALTER TABLE "sites" ADD COLUMN "lastHolePunch" bigint; + ALTER TABLE "sites" ADD COLUMN "listenPort" integer; + ALTER TABLE "user" ADD COLUMN "twoFactorSetupRequested" boolean DEFAULT false; + ALTER TABLE "clientSites" ADD CONSTRAINT "clientSites_clientId_clients_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "clientSites" ADD CONSTRAINT "clientSites_siteId_sites_siteId_fk" FOREIGN KEY ("siteId") REFERENCES "public"."sites"("siteId") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "clients" ADD CONSTRAINT "clients_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "clients" ADD CONSTRAINT "clients_exitNode_exitNodes_exitNodeId_fk" FOREIGN KEY ("exitNode") REFERENCES "public"."exitNodes"("exitNodeId") ON DELETE set null ON UPDATE no action; + ALTER TABLE "clientSession" ADD CONSTRAINT "clientSession_olmId_olms_id_fk" FOREIGN KEY ("olmId") REFERENCES "public"."olms"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "olms" ADD CONSTRAINT "olms_clientId_clients_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "roleClients" ADD CONSTRAINT "roleClients_roleId_roles_roleId_fk" FOREIGN KEY ("roleId") REFERENCES "public"."roles"("roleId") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "roleClients" ADD CONSTRAINT "roleClients_clientId_clients_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "webauthnCredentials" ADD CONSTRAINT "webauthnCredentials_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "userClients" ADD CONSTRAINT "userClients_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "userClients" ADD CONSTRAINT "userClients_clientId_clients_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "webauthnChallenge" ADD CONSTRAINT "webauthnChallenge_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "resources" DROP COLUMN "isBaseDomain"; + + COMMIT; + `); + + console.log(`Migrated database schema`); + } catch (e) { + console.log("Unable to migrate database schema"); + console.log(e); + throw e; + } + + try { + await db.execute(sql`BEGIN`); + + // Update all existing orgs to have the default subnet + await db.execute(sql`UPDATE "orgs" SET "subnet" = '100.90.128.0/24'`); + + // Get all orgs and their sites to assign sequential IP addresses + const orgsQuery = await db.execute(sql`SELECT "orgId" FROM "orgs"`); + + const orgs = orgsQuery.rows as { orgId: string }[]; + + for (const org of orgs) { + const sitesQuery = await db.execute(sql` + SELECT "siteId" FROM "sites" + WHERE "orgId" = ${org.orgId} + ORDER BY "siteId" + `); + + const sites = sitesQuery.rows as { siteId: number }[]; + + let ipIndex = 1; + for (const site of sites) { + const address = `100.90.128.${ipIndex}/24`; + await db.execute(sql` + UPDATE "sites" SET "address" = ${address} + WHERE "siteId" = ${site.siteId} + `); + ipIndex++; + } + } + + await db.execute(sql`COMMIT`); + console.log(`Updated org subnets and site addresses`); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to update org subnets"); + console.log(e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsPg/1.8.0.ts b/server/setup/scriptsPg/1.8.0.ts new file mode 100644 index 00000000..7c0b181b --- /dev/null +++ b/server/setup/scriptsPg/1.8.0.ts @@ -0,0 +1,32 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; + +const version = "1.8.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + await db.execute(sql` + BEGIN; + + ALTER TABLE "clients" ALTER COLUMN "bytesIn" SET DATA TYPE real; + ALTER TABLE "clients" ALTER COLUMN "bytesOut" SET DATA TYPE real; + ALTER TABLE "clientSession" ALTER COLUMN "expiresAt" SET DATA TYPE bigint; + ALTER TABLE "resources" ADD COLUMN "enableProxy" boolean DEFAULT true; + ALTER TABLE "sites" ADD COLUMN "remoteSubnets" text; + ALTER TABLE "user" ADD COLUMN "termsAcceptedTimestamp" varchar; + ALTER TABLE "user" ADD COLUMN "termsVersion" varchar; + + COMMIT; + `); + + console.log(`Migrated database schema`); + } catch (e) { + console.log("Unable to migrate database schema"); + console.log(e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.0.0-beta9.ts b/server/setup/scriptsSqlite/1.0.0-beta9.ts index c731996b..bbd61484 100644 --- a/server/setup/scriptsSqlite/1.0.0-beta9.ts +++ b/server/setup/scriptsSqlite/1.0.0-beta9.ts @@ -78,7 +78,7 @@ export default async function migration() { fs.writeFileSync(filePath, updatedYaml, "utf8"); } catch (e) { console.log( - `Failed to add resource_session_request_param to config. Please add it manually. https://docs.fossorial.io/Pangolin/Configuration/config` + `Failed to add resource_session_request_param to config. Please add it manually. https://docs.digpangolin.com/self-host/advanced/config-file` ); trx.rollback(); return; diff --git a/server/setup/scriptsSqlite/1.2.0.ts b/server/setup/scriptsSqlite/1.2.0.ts index 940d38e6..38bb90b8 100644 --- a/server/setup/scriptsSqlite/1.2.0.ts +++ b/server/setup/scriptsSqlite/1.2.0.ts @@ -63,7 +63,7 @@ export default async function migration() { console.log(`Added new config option: resource_access_token_headers`); } catch (e) { console.log( - `Unable to add new config option: resource_access_token_headers. Please add it manually. https://docs.fossorial.io/Pangolin/Configuration/config` + `Unable to add new config option: resource_access_token_headers. Please add it manually. https://docs.digpangolin.com/self-host/advanced/config-file` ); console.error(e); } diff --git a/server/setup/scriptsSqlite/1.7.0.ts b/server/setup/scriptsSqlite/1.7.0.ts new file mode 100644 index 00000000..f173d12e --- /dev/null +++ b/server/setup/scriptsSqlite/1.7.0.ts @@ -0,0 +1,187 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.7.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + + db.transaction(() => { + db.exec(` + CREATE TABLE 'clientSites' ( + 'clientId' integer NOT NULL, + 'siteId' integer NOT NULL, + 'isRelayed' integer DEFAULT 0 NOT NULL, + FOREIGN KEY ('clientId') REFERENCES 'clients'('id') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('siteId') REFERENCES 'sites'('siteId') ON UPDATE no action ON DELETE cascade + ); + + CREATE TABLE 'clients' ( + 'id' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'orgId' text NOT NULL, + 'exitNode' integer, + 'name' text NOT NULL, + 'pubKey' text, + 'subnet' text NOT NULL, + 'bytesIn' integer, + 'bytesOut' integer, + 'lastBandwidthUpdate' text, + 'lastPing' text, + 'type' text NOT NULL, + 'online' integer DEFAULT 0 NOT NULL, + 'endpoint' text, + 'lastHolePunch' integer, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('exitNode') REFERENCES 'exitNodes'('exitNodeId') ON UPDATE no action ON DELETE set null + ); + + CREATE TABLE 'clientSession' ( + 'id' text PRIMARY KEY NOT NULL, + 'olmId' text NOT NULL, + 'expiresAt' integer NOT NULL, + FOREIGN KEY ('olmId') REFERENCES 'olms'('id') ON UPDATE no action ON DELETE cascade + ); + + CREATE TABLE 'olms' ( + 'id' text PRIMARY KEY NOT NULL, + 'secretHash' text NOT NULL, + 'dateCreated' text NOT NULL, + 'clientId' integer, + FOREIGN KEY ('clientId') REFERENCES 'clients'('id') ON UPDATE no action ON DELETE cascade + ); + + CREATE TABLE 'roleClients' ( + 'roleId' integer NOT NULL, + 'clientId' integer NOT NULL, + FOREIGN KEY ('roleId') REFERENCES 'roles'('roleId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('clientId') REFERENCES 'clients'('id') ON UPDATE no action ON DELETE cascade + ); + + CREATE TABLE 'webauthnCredentials' ( + 'credentialId' text PRIMARY KEY NOT NULL, + 'userId' text NOT NULL, + 'publicKey' text NOT NULL, + 'signCount' integer NOT NULL, + 'transports' text, + 'name' text, + 'lastUsed' text NOT NULL, + 'dateCreated' text NOT NULL, + FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade + ); + + CREATE TABLE 'userClients' ( + 'userId' text NOT NULL, + 'clientId' integer NOT NULL, + FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('clientId') REFERENCES 'clients'('id') ON UPDATE no action ON DELETE cascade + ); + + CREATE TABLE 'userDomains' ( + 'userId' text NOT NULL, + 'domainId' text NOT NULL, + FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('domainId') REFERENCES 'domains'('domainId') ON UPDATE no action ON DELETE cascade + ); + + CREATE TABLE 'webauthnChallenge' ( + 'sessionId' text PRIMARY KEY NOT NULL, + 'challenge' text NOT NULL, + 'securityKeyName' text, + 'userId' text, + 'expiresAt' integer NOT NULL, + FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade + ); + + `); + + db.exec(` + CREATE TABLE '__new_sites' ( + 'siteId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'orgId' text NOT NULL, + 'niceId' text NOT NULL, + 'exitNode' integer, + 'name' text NOT NULL, + 'pubKey' text, + 'subnet' text, + 'bytesIn' integer DEFAULT 0, + 'bytesOut' integer DEFAULT 0, + 'lastBandwidthUpdate' text, + 'type' text NOT NULL, + 'online' integer DEFAULT 0 NOT NULL, + 'address' text, + 'endpoint' text, + 'publicKey' text, + 'lastHolePunch' integer, + 'listenPort' integer, + 'dockerSocketEnabled' integer DEFAULT 1 NOT NULL, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('exitNode') REFERENCES 'exitNodes'('exitNodeId') ON UPDATE no action ON DELETE set null + ); + + INSERT INTO '__new_sites' ( + 'siteId', 'orgId', 'niceId', 'exitNode', 'name', 'pubKey', 'subnet', 'bytesIn', 'bytesOut', 'lastBandwidthUpdate', 'type', 'online', 'address', 'endpoint', 'publicKey', 'lastHolePunch', 'listenPort', 'dockerSocketEnabled' + ) + SELECT siteId, orgId, niceId, exitNode, name, pubKey, subnet, bytesIn, bytesOut, lastBandwidthUpdate, type, online, NULL, NULL, NULL, NULL, NULL, dockerSocketEnabled + FROM sites; + + DROP TABLE 'sites'; + ALTER TABLE '__new_sites' RENAME TO 'sites'; + `); + + db.exec(` + ALTER TABLE 'domains' ADD 'type' text; + ALTER TABLE 'domains' ADD 'verified' integer DEFAULT 0 NOT NULL; + ALTER TABLE 'domains' ADD 'failed' integer DEFAULT 0 NOT NULL; + ALTER TABLE 'domains' ADD 'tries' integer DEFAULT 0 NOT NULL; + ALTER TABLE 'exitNodes' ADD 'maxConnections' integer; + ALTER TABLE 'newt' ADD 'version' text; + ALTER TABLE 'orgs' ADD 'subnet' text; + ALTER TABLE 'user' ADD 'twoFactorSetupRequested' integer DEFAULT 0; + ALTER TABLE 'resources' DROP COLUMN 'isBaseDomain'; + `); + })(); + + db.pragma("foreign_keys = ON"); + + console.log(`Migrated database schema`); + } catch (e) { + console.log("Unable to migrate database schema"); + throw e; + } + + db.transaction(() => { + // Update all existing orgs to have the default subnet + db.exec(`UPDATE 'orgs' SET 'subnet' = '100.90.128.0/24'`); + + // Get all orgs and their sites to assign sequential IP addresses + const orgs = db.prepare(`SELECT orgId FROM 'orgs'`).all() as { + orgId: string; + }[]; + + for (const org of orgs) { + const sites = db + .prepare( + `SELECT siteId FROM 'sites' WHERE orgId = ? ORDER BY siteId` + ) + .all(org.orgId) as { siteId: number }[]; + + let ipIndex = 1; + for (const site of sites) { + const address = `100.90.128.${ipIndex}/24`; + db.prepare( + `UPDATE 'sites' SET 'address' = ? WHERE siteId = ?` + ).run(address, site.siteId); + ipIndex++; + } + } + })(); + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.8.0.ts b/server/setup/scriptsSqlite/1.8.0.ts new file mode 100644 index 00000000..f8ac7c95 --- /dev/null +++ b/server/setup/scriptsSqlite/1.8.0.ts @@ -0,0 +1,30 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.8.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.transaction(() => { + db.exec(` + ALTER TABLE 'resources' ADD 'enableProxy' integer DEFAULT 1; + ALTER TABLE 'sites' ADD 'remoteSubnets' text; + ALTER TABLE 'user' ADD 'termsAcceptedTimestamp' text; + ALTER TABLE 'user' ADD 'termsVersion' text; + `); + })(); + + console.log("Migrated database schema"); + } catch (e) { + console.log("Unable to migrate database schema"); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/src/app/[orgId]/MemberResourcesPortal.tsx b/src/app/[orgId]/MemberResourcesPortal.tsx new file mode 100644 index 00000000..4d3a7717 --- /dev/null +++ b/src/app/[orgId]/MemberResourcesPortal.tsx @@ -0,0 +1,718 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useTranslations } from "next-intl"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; +import { + ExternalLink, + Globe, + Search, + RefreshCw, + AlertCircle, + ChevronLeft, + ChevronRight, + Key, + KeyRound, + Fingerprint, + AtSign, + Copy, + InfoIcon, + Combine +} from "lucide-react"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { GetUserResourcesResponse } from "@server/routers/resource/getUserResources"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { useToast } from "@app/hooks/useToast"; +import { InfoPopup } from "@/components/ui/info-popup"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@/components/ui/tooltip"; + +// Update Resource type to include site information +type Resource = { + resourceId: number; + name: string; + domain: string; + enabled: boolean; + protected: boolean; + protocol: string; + // Auth method fields + sso?: boolean; + password?: boolean; + pincode?: boolean; + whitelist?: boolean; + // Site information + siteName?: string | null; +}; + +type MemberResourcesPortalProps = { + orgId: string; +}; + +// Favicon component with fallback +const ResourceFavicon = ({ + domain, + enabled +}: { + domain: string; + enabled: boolean; +}) => { + const [faviconError, setFaviconError] = useState(false); + const [faviconLoaded, setFaviconLoaded] = useState(false); + + // Extract domain for favicon URL + const cleanDomain = domain.replace(/^https?:\/\//, "").split("/")[0]; + const faviconUrl = `https://www.google.com/s2/favicons?domain=${cleanDomain}&sz=32`; + + const handleFaviconLoad = () => { + setFaviconLoaded(true); + setFaviconError(false); + }; + + const handleFaviconError = () => { + setFaviconError(true); + setFaviconLoaded(false); + }; + + if (faviconError || !enabled) { + return ( + + ); + } + + return ( +
+ {!faviconLoaded && ( +
+ )} + {`${cleanDomain} +
+ ); +}; + +// Resource Info component +const ResourceInfo = ({ resource }: { resource: Resource }) => { + const hasAuthMethods = + resource.sso || + resource.password || + resource.pincode || + resource.whitelist; + + const infoContent = ( +
+ {/* Site Information */} + {resource.siteName && ( +
+
Site
+
+ + {resource.siteName} +
+
+ )} + + {/* Authentication Methods */} + {hasAuthMethods && ( +
+
+ Authentication Methods +
+
+ {resource.sso && ( +
+
+ +
+ + Single Sign-On (SSO) + +
+ )} + {resource.password && ( +
+
+ +
+ + Password Protected + +
+ )} + {resource.pincode && ( +
+
+ +
+ PIN Code +
+ )} + {resource.whitelist && ( +
+
+ +
+ Email Whitelist +
+ )} +
+
+ )} + + {/* Resource Status - if disabled */} + {!resource.enabled && ( +
+
+ + + Resource Disabled + +
+
+ )} +
+ ); + + return {infoContent}; +}; + +// Pagination component +const PaginationControls = ({ + currentPage, + totalPages, + onPageChange, + totalItems, + itemsPerPage +}: { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + totalItems: number; + itemsPerPage: number; +}) => { + const startItem = (currentPage - 1) * itemsPerPage + 1; + const endItem = Math.min(currentPage * itemsPerPage, totalItems); + + if (totalPages <= 1) return null; + + return ( +
+
+ Showing {startItem}-{endItem} of {totalItems} resources +
+ +
+ + +
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map( + (page) => { + // Show first page, last page, current page, and 2 pages around current + const showPage = + page === 1 || + page === totalPages || + Math.abs(page - currentPage) <= 1; + + const showEllipsis = + (page === 2 && currentPage > 4) || + (page === totalPages - 1 && + currentPage < totalPages - 3); + + if (!showPage && !showEllipsis) return null; + + if (showEllipsis) { + return ( + + ... + + ); + } + + return ( + + ); + } + )} +
+ + +
+
+ ); +}; + +// Loading skeleton component +const ResourceCardSkeleton = () => ( + + +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+); + +export default function MemberResourcesPortal({ + orgId +}: MemberResourcesPortalProps) { + const t = useTranslations(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { toast } = useToast(); + + const [resources, setResources] = useState([]); + const [filteredResources, setFilteredResources] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [sortBy, setSortBy] = useState("name-asc"); + const [refreshing, setRefreshing] = useState(false); + + // Pagination state + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 12; // 3x4 grid on desktop + + const fetchUserResources = async (isRefresh = false) => { + try { + if (isRefresh) { + setRefreshing(true); + } else { + setLoading(true); + } + setError(null); + + const response = await api.get( + `/org/${orgId}/user-resources` + ); + + if (response.data.success) { + setResources(response.data.data.resources); + setFilteredResources(response.data.data.resources); + } else { + setError("Failed to load resources"); + } + } catch (err) { + console.error("Error fetching user resources:", err); + setError( + "Failed to load resources. Please check your connection and try again." + ); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + useEffect(() => { + fetchUserResources(); + }, [orgId, api]); + + // Filter and sort resources + useEffect(() => { + const filtered = resources.filter( + (resource) => + resource.name + .toLowerCase() + .includes(searchQuery.toLowerCase()) || + resource.domain + .toLowerCase() + .includes(searchQuery.toLowerCase()) + ); + + // Sort resources + filtered.sort((a, b) => { + switch (sortBy) { + case "name-asc": + return a.name.localeCompare(b.name); + case "name-desc": + return b.name.localeCompare(a.name); + case "domain-asc": + return a.domain.localeCompare(b.domain); + case "domain-desc": + return b.domain.localeCompare(a.domain); + case "status-enabled": + // Enabled first, then protected vs unprotected + if (a.enabled !== b.enabled) return b.enabled ? 1 : -1; + return b.protected ? 1 : -1; + case "status-disabled": + // Disabled first, then unprotected vs protected + if (a.enabled !== b.enabled) return a.enabled ? 1 : -1; + return a.protected ? 1 : -1; + default: + return a.name.localeCompare(b.name); + } + }); + + setFilteredResources(filtered); + + // Reset to first page when search/sort changes + setCurrentPage(1); + }, [resources, searchQuery, sortBy]); + + // Calculate pagination + const totalPages = Math.ceil(filteredResources.length / itemsPerPage); + const startIndex = (currentPage - 1) * itemsPerPage; + const paginatedResources = filteredResources.slice( + startIndex, + startIndex + itemsPerPage + ); + + const handleOpenResource = (resource: Resource) => { + // Open the resource in a new tab + window.open(resource.domain, "_blank"); + }; + + const handleRefresh = () => { + fetchUserResources(true); + }; + + const handleRetry = () => { + fetchUserResources(); + }; + + const handlePageChange = (page: number) => { + setCurrentPage(page); + // Scroll to top when page changes + window.scrollTo({ top: 0, behavior: "smooth" }); + }; + + if (loading) { + return ( +
+ + + {/* Search and Sort Controls - Skeleton */} +
+
+
+
+
+
+
+
+ + {/* Loading Skeletons */} +
+ {Array.from({ length: 12 }).map((_, index) => ( + + ))} +
+
+ ); + } + + if (error) { + return ( +
+ + + +
+ +
+

+ Unable to Load Resources +

+

+ {error} +

+ +
+
+
+ ); + } + + return ( +
+ + + {/* Search and Sort Controls with Refresh */} +
+
+ {/* Search */} +
+ setSearchQuery(e.target.value)} + className="w-full pl-8 bg-card" + /> + +
+ + {/* Sort */} +
+ +
+
+ + {/* Refresh Button */} + +
+ + {/* Resources Content */} + {filteredResources.length === 0 ? ( + /* Enhanced Empty State */ + + +
+ {searchQuery ? ( + + ) : ( + + )} +
+

+ {searchQuery + ? "No Resources Found" + : "No Resources Available"} +

+

+ {searchQuery + ? `No resources match "${searchQuery}". Try adjusting your search terms or clearing the search to see all resources.` + : "You don't have access to any resources yet. Contact your administrator to get access to resources you need."} +

+
+ {searchQuery ? ( + + ) : ( + + )} +
+
+
+ ) : ( + <> + {/* Resources Grid */} +
+ {paginatedResources.map((resource) => ( + +
+
+
+ + + + + {resource.name} + + + +

+ {resource.name} +

+
+
+
+
+ +
+ +
+
+ +
+ + +
+
+ +
+ +
+
+ ))} +
+ + {/* Pagination Controls */} + + + )} +
+ ); +} diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx index fa41beb2..3ab0b92e 100644 --- a/src/app/[orgId]/layout.tsx +++ b/src/app/[orgId]/layout.tsx @@ -8,6 +8,7 @@ import { GetOrgUserResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { cache } from "react"; +import SetLastOrgCookie from "@app/components/SetLastOrgCookie"; export default async function OrgLayout(props: { children: React.ReactNode; @@ -52,6 +53,7 @@ export default async function OrgLayout(props: { return ( <> {props.children} + ); } diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index 5f91fb62..4740198b 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -2,14 +2,17 @@ import { verifySession } from "@app/lib/auth/verifySession"; import UserProvider from "@app/providers/UserProvider"; import { cache } from "react"; import OrganizationLandingCard from "./OrganizationLandingCard"; +import MemberResourcesPortal from "./MemberResourcesPortal"; import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; import { internal } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { authCookieHeader } from "@app/lib/api/cookies"; import { redirect } from "next/navigation"; import { Layout } from "@app/components/Layout"; -import { orgLangingNavItems, orgNavItems, rootNavItems } from "../navigation"; import { ListUserOrgsResponse } from "@server/routers/org"; +import { pullEnv } from "@app/lib/pullEnv"; +import EnvProvider from "@app/providers/EnvProvider"; +import { orgLangingNavItems } from "@app/app/navigation"; type OrgPageProps = { params: Promise<{ orgId: string }>; @@ -18,6 +21,7 @@ type OrgPageProps = { export default async function OrgPage(props: OrgPageProps) { const params = await props.params; const orgId = params.orgId; + const env = pullEnv(); const getUser = cache(verifySession); const user = await getUser(); @@ -26,7 +30,6 @@ export default async function OrgPage(props: OrgPageProps) { redirect("/"); } - let redirectToSettings = false; let overview: GetOrgOverviewResponse | undefined; try { const res = await internal.get>( @@ -34,16 +37,14 @@ export default async function OrgPage(props: OrgPageProps) { await authCookieHeader() ); overview = res.data.data; - - if (overview.isAdmin || overview.isOwner) { - redirectToSettings = true; - } } catch (e) {} - if (redirectToSettings) { + // If user is admin or owner, redirect to settings + if (overview?.isAdmin || overview?.isOwner) { redirect(`/${orgId}/settings`); } + // For non-admin users, show the member resources portal let orgs: ListUserOrgsResponse["orgs"] = []; try { const getOrgs = cache(async () => @@ -60,25 +61,8 @@ export default async function OrgPage(props: OrgPageProps) { return ( - - {overview && ( -
- -
- )} + + {overview && }
); diff --git a/src/app/[orgId]/settings/access/invitations/InvitationsTable.tsx b/src/app/[orgId]/settings/access/invitations/InvitationsTable.tsx index 322d67fa..dfb3d263 100644 --- a/src/app/[orgId]/settings/access/invitations/InvitationsTable.tsx +++ b/src/app/[orgId]/settings/access/invitations/InvitationsTable.tsx @@ -18,6 +18,7 @@ import { toast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; +import moment from "moment"; export type InvitationRow = { id: string; @@ -46,63 +47,69 @@ export default function InvitationsTable({ const { org } = useOrgContext(); const columns: ColumnDef[] = [ - { - id: "dots", - cell: ({ row }) => { - const invitation = row.original; - return ( - - - - - - { - setIsRegenerateModalOpen(true); - setSelectedInvitation(invitation); - }} - > - {t('inviteRegenerate')} - - { - setIsDeleteModalOpen(true); - setSelectedInvitation(invitation); - }} - > - - {t('inviteRemove')} - - - - - ); - } - }, { accessorKey: "email", - header: t('email') + header: t("email") }, { accessorKey: "expiresAt", - header: t('expiresAt'), + header: t("expiresAt"), cell: ({ row }) => { const expiresAt = new Date(row.original.expiresAt); const isExpired = expiresAt < new Date(); return ( - {expiresAt.toLocaleString()} + {moment(expiresAt).format("lll")} ); } }, { accessorKey: "role", - header: t('role') + header: t("role") + }, + { + id: "dots", + cell: ({ row }) => { + const invitation = row.original; + return ( +
+ + + + + + { + setIsDeleteModalOpen(true); + setSelectedInvitation(invitation); + }} + > + + {t("inviteRemove")} + + + + + + +
+ ); + } } ]; @@ -115,16 +122,18 @@ export default function InvitationsTable({ .catch((e) => { toast({ variant: "destructive", - title: t('inviteRemoveError'), - description: t('inviteRemoveErrorDescription') + title: t("inviteRemoveError"), + description: t("inviteRemoveErrorDescription") }); }); if (res && res.status === 200) { toast({ variant: "default", - title: t('inviteRemoved'), - description: t('inviteRemovedDescription', {email: selectedInvitation.email}) + title: t("inviteRemoved"), + description: t("inviteRemovedDescription", { + email: selectedInvitation.email + }) }); setInvitations((prev) => @@ -148,20 +157,18 @@ export default function InvitationsTable({ dialog={

- {t('inviteQuestionRemove', {email: selectedInvitation?.email || ""})} -

-

- {t('inviteMessageRemove')} -

-

- {t('inviteMessageConfirm')} + {t("inviteQuestionRemove", { + email: selectedInvitation?.email || "" + })}

+

{t("inviteMessageRemove")}

+

{t("inviteMessageConfirm")}

} - buttonText={t('inviteRemoveConfirm')} + buttonText={t("inviteRemoveConfirm")} onConfirm={removeInvitation} string={selectedInvitation?.email ?? ""} - title={t('inviteRemove')} + title={t("inviteRemove")} /> - + diff --git a/src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx b/src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx index 1e910e29..f3042f71 100644 --- a/src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx +++ b/src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx @@ -159,7 +159,6 @@ export default function DeleteRoleForm({ -

{t('accessRoleQuestionRemove', {name: roleToDelete.name})} @@ -210,13 +209,13 @@ export default function DeleteRoleForm({ /> -

- - - { - setIsDeleteModalOpen(true); - setUserToRemove(roleRow); - }} - > - - {t('accessRoleDelete')} - - - - - )} -
- - ); - } - }, { accessorKey: "name", header: ({ column }) => { @@ -95,7 +52,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('name')} + {t("name")} ); @@ -103,7 +60,29 @@ export default function UsersTable({ roles: r }: RolesTableProps) { }, { accessorKey: "description", - header: t('description') + header: t("description") + }, + { + id: "actions", + cell: ({ row }) => { + const roleRow = row.original; + + return ( +
+ +
+ ); + } } ]; diff --git a/src/app/[orgId]/settings/access/roles/page.tsx b/src/app/[orgId]/settings/access/roles/page.tsx index fed52c26..8faedbf8 100644 --- a/src/app/[orgId]/settings/access/roles/page.tsx +++ b/src/app/[orgId]/settings/access/roles/page.tsx @@ -6,8 +6,6 @@ import { cache } from "react"; import OrgProvider from "@app/providers/OrgProvider"; import { ListRolesResponse } from "@server/routers/role"; import RolesTable, { RoleRow } from "./RolesTable"; -import { SidebarSettings } from "@app/components/SidebarSettings"; -import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from 'next-intl/server'; diff --git a/src/app/[orgId]/settings/access/users/UsersTable.tsx b/src/app/[orgId]/settings/access/users/UsersTable.tsx index d3ee404e..6b8af509 100644 --- a/src/app/[orgId]/settings/access/users/UsersTable.tsx +++ b/src/app/[orgId]/settings/access/users/UsersTable.tsx @@ -20,7 +20,7 @@ import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useUserContext } from "@app/hooks/useUserContext"; -import { useTranslations } from 'next-intl'; +import { useTranslations } from "next-intl"; export type UserRow = { id: string; @@ -51,65 +51,6 @@ export default function UsersTable({ users: u }: UsersTableProps) { const t = useTranslations(); const columns: ColumnDef[] = [ - { - id: "dots", - cell: ({ row }) => { - const userRow = row.original; - return ( - <> -
- {userRow.isOwner && ( - - )} - {!userRow.isOwner && ( - <> - - - - - - - - {t('accessUsersManage')} - - - {`${userRow.username}-${userRow.idpId}` !== - `${user?.username}-${userRow.idpId}` && ( - { - setIsDeleteModalOpen( - true - ); - setSelectedUser( - userRow - ); - }} - > - - {t('accessUserRemove')} - - - )} - - - - )} -
- - ); - } - }, { accessorKey: "displayUsername", header: ({ column }) => { @@ -120,7 +61,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('username')} + {t("username")} ); @@ -136,7 +77,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('identityProvider')} + {t("identityProvider")} ); @@ -152,7 +93,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('role')} + {t("role")} ); @@ -176,12 +117,68 @@ export default function UsersTable({ users: u }: UsersTableProps) { const userRow = row.original; return (
+ <> +
+ {userRow.isOwner && ( + + )} + {!userRow.isOwner && ( + <> + + + + + + + + {t("accessUsersManage")} + + + {`${userRow.username}-${userRow.idpId}` !== + `${user?.username}-${userRow.idpId}` && ( + { + setIsDeleteModalOpen( + true + ); + setSelectedUser( + userRow + ); + }} + > + + {t( + "accessUserRemove" + )} + + + )} + + + + )} +
+ {userRow.isOwner && ( )} {!userRow.isOwner && ( @@ -189,10 +186,12 @@ export default function UsersTable({ users: u }: UsersTableProps) { href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`} > @@ -210,10 +209,10 @@ export default function UsersTable({ users: u }: UsersTableProps) { .catch((e) => { toast({ variant: "destructive", - title: t('userErrorOrgRemove'), + title: t("userErrorOrgRemove"), description: formatAxiosError( e, - t('userErrorOrgRemoveDescription') + t("userErrorOrgRemoveDescription") ) }); }); @@ -221,8 +220,10 @@ export default function UsersTable({ users: u }: UsersTableProps) { if (res && res.status === 200) { toast({ variant: "default", - title: t('userOrgRemoved'), - description: t('userOrgRemovedDescription', {email: selectedUser.email || ""}) + title: t("userOrgRemoved"), + description: t("userOrgRemovedDescription", { + email: selectedUser.email || "" + }) }); setUsers((prev) => @@ -244,19 +245,21 @@ export default function UsersTable({ users: u }: UsersTableProps) { dialog={

- {t('userQuestionOrgRemove', {email: selectedUser?.email || selectedUser?.name || selectedUser?.username || ""})} + {t("userQuestionOrgRemove", { + email: + selectedUser?.email || + selectedUser?.name || + selectedUser?.username || + "" + })}

-

- {t('userMessageOrgRemove')} -

+

{t("userMessageOrgRemove")}

-

- {t('userMessageOrgConfirm')} -

+

{t("userMessageOrgConfirm")}

} - buttonText={t('userRemoveOrgConfirm')} + buttonText={t("userRemoveOrgConfirm")} onConfirm={removeUser} string={ selectedUser?.email || @@ -264,14 +267,16 @@ export default function UsersTable({ users: u }: UsersTableProps) { selectedUser?.username || "" } - title={t('userRemoveOrg')} + title={t("userRemoveOrg")} /> { - router.push(`/${org?.org.orgId}/settings/access/users/create`); + router.push( + `/${org?.org.orgId}/settings/access/users/create` + ); }} /> diff --git a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx index 82fbba86..7d527f84 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx @@ -5,17 +5,9 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { GetOrgUserResponse } from "@server/routers/user"; import OrgUserProvider from "@app/providers/OrgUserProvider"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator -} from "@app/components/ui/breadcrumb"; -import Link from "next/link"; import { cache } from "react"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { getTranslations } from 'next-intl/server'; +import { getTranslations } from "next-intl/server"; interface UserLayoutProps { children: React.ReactNode; @@ -45,7 +37,7 @@ export default async function UserLayoutProps(props: UserLayoutProps) { const navItems = [ { - title: t('accessControls'), + title: t("accessControls"), href: "/{orgId}/settings/access/users/{userId}/access-controls" } ]; @@ -54,12 +46,10 @@ export default async function UserLayoutProps(props: UserLayoutProps) { <> - - {children} - + {children} ); diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index e4ea99fe..b3ce8984 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -9,7 +9,7 @@ import { SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; -import { StrategySelect } from "@app/components/StrategySelect"; +import { StrategyOption, StrategySelect } from "@app/components/StrategySelect"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { Button } from "@app/components/ui/button"; import { useParams, useRouter } from "next/navigation"; @@ -45,15 +45,10 @@ import { createApiClient } from "@app/lib/api"; import { Checkbox } from "@app/components/ui/checkbox"; import { ListIdpsResponse } from "@server/routers/idp"; import { useTranslations } from "next-intl"; +import { build } from "@server/build"; type UserType = "internal" | "oidc"; -interface UserTypeOption { - id: UserType; - title: string; - description: string; -} - interface IdpOption { idpId: number; name: string; @@ -78,40 +73,42 @@ export default function Page() { const [dataLoaded, setDataLoaded] = useState(false); const internalFormSchema = z.object({ - email: z.string().email({ message: t('emailInvalid') }), - validForHours: z.string().min(1, { message: t('inviteValidityDuration') }), - roleId: z.string().min(1, { message: t('accessRoleSelectPlease') }) + email: z.string().email({ message: t("emailInvalid") }), + validForHours: z + .string() + .min(1, { message: t("inviteValidityDuration") }), + roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }) }); const externalFormSchema = z.object({ - username: z.string().min(1, { message: t('usernameRequired') }), + username: z.string().min(1, { message: t("usernameRequired") }), email: z .string() - .email({ message: t('emailInvalid') }) + .email({ message: t("emailInvalid") }) .optional() .or(z.literal("")), name: z.string().optional(), - roleId: z.string().min(1, { message: t('accessRoleSelectPlease') }), - idpId: z.string().min(1, { message: t('idpSelectPlease') }) + roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }), + idpId: z.string().min(1, { message: t("idpSelectPlease") }) }); const formatIdpType = (type: string) => { switch (type.toLowerCase()) { case "oidc": - return t('idpGenericOidc'); + return t("idpGenericOidc"); default: return type; } }; const validFor = [ - { hours: 24, name: t('day', {count: 1}) }, - { hours: 48, name: t('day', {count: 2}) }, - { hours: 72, name: t('day', {count: 3}) }, - { hours: 96, name: t('day', {count: 4}) }, - { hours: 120, name: t('day', {count: 5}) }, - { hours: 144, name: t('day', {count: 6}) }, - { hours: 168, name: t('day', {count: 7}) } + { hours: 24, name: t("day", { count: 1 }) }, + { hours: 48, name: t("day", { count: 2 }) }, + { hours: 72, name: t("day", { count: 3 }) }, + { hours: 96, name: t("day", { count: 4 }) }, + { hours: 120, name: t("day", { count: 5 }) }, + { hours: 144, name: t("day", { count: 6 }) }, + { hours: 168, name: t("day", { count: 7 }) } ]; const internalForm = useForm>({ @@ -145,6 +142,21 @@ export default function Page() { } }, [userType, env.email.emailEnabled, internalForm, externalForm]); + const [userTypes, setUserTypes] = useState[]>([ + { + id: "internal", + title: t("userTypeInternal"), + description: t("userTypeInternalDescription"), + disabled: false + }, + { + id: "oidc", + title: t("userTypeExternal"), + description: t("userTypeExternalDescription"), + disabled: true + } + ]); + useEffect(() => { if (!userType) { return; @@ -157,19 +169,16 @@ export default function Page() { console.error(e); toast({ variant: "destructive", - title: t('accessRoleErrorFetch'), + title: t("accessRoleErrorFetch"), description: formatAxiosError( e, - t('accessRoleErrorFetchDescription') + t("accessRoleErrorFetchDescription") ) }); }); if (res?.status === 200) { setRoles(res.data.data.roles); - if (userType === "internal") { - setDataLoaded(true); - } } } @@ -180,26 +189,42 @@ export default function Page() { console.error(e); toast({ variant: "destructive", - title: t('idpErrorFetch'), + title: t("idpErrorFetch"), description: formatAxiosError( e, - t('idpErrorFetchDescription') + t("idpErrorFetchDescription") ) }); }); if (res?.status === 200) { setIdps(res.data.data.idps); - setDataLoaded(true); + + if (res.data.data.idps.length) { + setUserTypes((prev) => + prev.map((type) => { + if (type.id === "oidc") { + return { + ...type, + disabled: false + }; + } + return type; + }) + ); + } } } - setDataLoaded(false); - fetchRoles(); - if (userType !== "internal") { - fetchIdps(); + async function fetchInitialData() { + setDataLoaded(false); + await fetchRoles(); + await fetchIdps(); + setDataLoaded(true); } - }, [userType]); + + fetchInitialData(); + }, []); async function onSubmitInternal( values: z.infer @@ -220,16 +245,16 @@ export default function Page() { if (e.response?.status === 409) { toast({ variant: "destructive", - title: t('userErrorExists'), - description: t('userErrorExistsDescription') + title: t("userErrorExists"), + description: t("userErrorExistsDescription") }); } else { toast({ variant: "destructive", - title: t('inviteError'), + title: t("inviteError"), description: formatAxiosError( e, - t('inviteErrorDescription') + t("inviteErrorDescription") ) }); } @@ -239,8 +264,8 @@ export default function Page() { setInviteLink(res.data.data.inviteLink); toast({ variant: "default", - title: t('userInvited'), - description: t('userInvitedDescription') + title: t("userInvited"), + description: t("userInvitedDescription") }); setExpiresInDays(parseInt(values.validForHours) / 24); @@ -266,10 +291,10 @@ export default function Page() { .catch((e) => { toast({ variant: "destructive", - title: t('userErrorCreate'), + title: t("userErrorCreate"), description: formatAxiosError( e, - t('userErrorCreateDescription') + t("userErrorCreateDescription") ) }); }); @@ -277,8 +302,8 @@ export default function Page() { if (res && res.status === 201) { toast({ variant: "default", - title: t('userCreated'), - description: t('userCreatedDescription') + title: t("userCreated"), + description: t("userCreatedDescription") }); router.push(`/${orgId}/settings/access/users`); } @@ -286,25 +311,12 @@ export default function Page() { setLoading(false); } - const userTypes: ReadonlyArray = [ - { - id: "internal", - title: t('userTypeInternal'), - description: t('userTypeInternalDescription') - }, - { - id: "oidc", - title: t('userTypeExternal'), - description: t('userTypeExternalDescription') - } - ]; - return ( <>
- - - - {t('userTypeTitle')} - - - {t('userTypeDescription')} - - - - { - setUserType(value as UserType); - if (value === "internal") { - internalForm.reset(); - } else if (value === "oidc") { - externalForm.reset(); - setSelectedIdp(null); - } - }} - cols={2} - /> - - + {!inviteLink && build !== "saas" ? ( + + + + {t("userTypeTitle")} + + + {t("userTypeDescription")} + + + + { + setUserType(value as UserType); + if (value === "internal") { + internalForm.reset(); + } else if (value === "oidc") { + externalForm.reset(); + setSelectedIdp(null); + } + }} + cols={2} + /> + + + ) : null} {userType === "internal" && dataLoaded && ( <> - - - - {t('userSettings')} - - - {t('userSettingsDescription')} - - - - -
- - ( - - - {t('email')} - - - - - - + {!inviteLink ? ( + + + + {t("userSettings")} + + + {t("userSettingsDescription")} + + + + + + - - {env.email.emailEnabled && ( -
- - setSendEmail( - e as boolean - ) - } - /> - -
- )} - - ( - - - {t('inviteValid')} - - - - {validFor.map( - ( - option - ) => ( - - { - option.name - } - - ) - )} - - - - - )} - /> + + + )} + /> - ( - - - {t('role')} - - + + + + + + + {validFor.map( + ( + option + ) => ( + + { + option.name + } + + ) + )} + + + + + )} + /> + + ( + + + {t("role")} + + + + + )} + /> + + {env.email.emailEnabled && ( +
+ + setSendEmail( + e as boolean + ) + } + /> + +
)} - /> - - {inviteLink && ( -
- {sendEmail && ( -

- {t('inviteEmailSentDescription')} -

- )} - {!sendEmail && ( -

- {t('inviteSentDescription')} -

- )} -

- {t('inviteExpiresIn', {days: expiresInDays})} -

- -
- )} - - -
-
-
+ + +
+
+
+ ) : ( + + + + {t("userInvited")} + + + {sendEmail + ? t( + "inviteEmailSentDescription" + ) + : t("inviteSentDescription")} + + + +
+

+ {t("inviteExpiresIn", { + days: expiresInDays + })} +

+ +
+
+
+ )} )} @@ -533,16 +569,16 @@ export default function Page() { - {t('idpTitle')} + {t("idpTitle")} - {t('idpSelect')} + {t("idpSelect")} {idps.length === 0 ? (

- {t('idpNotConfigured')} + {t("idpNotConfigured")}

) : (
@@ -581,7 +617,7 @@ export default function Page() { idp || null ); }} - cols={3} + cols={2} /> @@ -596,10 +632,10 @@ export default function Page() { - {t('userSettings')} + {t("userSettings")} - {t('userSettingsDescription')} + {t("userSettingsDescription")} @@ -620,7 +656,9 @@ export default function Page() { render={({ field }) => ( - {t('username')} + {t( + "username" + )}

- {t('usernameUniq')} + {t( + "usernameUniq" + )}

@@ -643,7 +683,9 @@ export default function Page() { render={({ field }) => ( - {t('emailOptional')} + {t( + "emailOptional" + )} ( - {t('nameOptional')} + {t( + "nameOptional" + )} ( - {t('role')} + {t("role")} + + + + )} + /> + + ( + + {t("sites")} + { + form.setValue( + "siteIds", + newTags as [Tag, ...Tag[]] + ); + }} + enableAutocomplete={true} + autocompleteOptions={sites} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={true} + sortTags={true} + /> + + {t("sitesDescription")} + + + + )} + /> + + + +
+ + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/clients/[clientId]/layout.tsx b/src/app/[orgId]/settings/clients/[clientId]/layout.tsx new file mode 100644 index 00000000..804162a2 --- /dev/null +++ b/src/app/[orgId]/settings/clients/[clientId]/layout.tsx @@ -0,0 +1,57 @@ +import { internal } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { GetClientResponse } from "@server/routers/client"; +import ClientInfoCard from "./ClientInfoCard"; +import ClientProvider from "@app/providers/ClientProvider"; +import { redirect } from "next/navigation"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; + +type SettingsLayoutProps = { + children: React.ReactNode; + params: Promise<{ clientId: number; orgId: string }>; +} + +export default async function SettingsLayout(props: SettingsLayoutProps) { + const params = await props.params; + + const { children } = props; + + let client = null; + try { + const res = await internal.get>( + `/org/${params.orgId}/client/${params.clientId}`, + await authCookieHeader() + ); + client = res.data.data; + } catch (error) { + console.error("Error fetching client data:", error); + redirect(`/${params.orgId}/settings/clients`); + } + + const navItems = [ + { + title: "General", + href: `/{orgId}/settings/clients/{clientId}/general` + } + ]; + + return ( + <> + + + +
+ + + {children} + +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/clients/[clientId]/page.tsx b/src/app/[orgId]/settings/clients/[clientId]/page.tsx new file mode 100644 index 00000000..c484ec8c --- /dev/null +++ b/src/app/[orgId]/settings/clients/[clientId]/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from "next/navigation"; + +export default async function ClientPage(props: { + params: Promise<{ orgId: string; clientId: number }>; +}) { + const params = await props.params; + redirect(`/${params.orgId}/settings/clients/${params.clientId}/general`); +} diff --git a/src/app/[orgId]/settings/clients/create/page.tsx b/src/app/[orgId]/settings/clients/create/page.tsx new file mode 100644 index 00000000..2497d3f8 --- /dev/null +++ b/src/app/[orgId]/settings/clients/create/page.tsx @@ -0,0 +1,708 @@ +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { StrategySelect } from "@app/components/StrategySelect"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; +import { z } from "zod"; +import { createElement, useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@app/components/ui/input"; +import { InfoIcon, Terminal } from "lucide-react"; +import { Button } from "@app/components/ui/button"; +import CopyTextBox from "@app/components/CopyTextBox"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { + FaApple, + FaCubes, + FaDocker, + FaFreebsd, + FaWindows +} from "react-icons/fa"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { + CreateClientBody, + CreateClientResponse, + PickClientDefaultsResponse +} from "@server/routers/client"; +import { ListSitesResponse } from "@server/routers/site"; +import { toast } from "@app/hooks/useToast"; +import { AxiosResponse } from "axios"; +import { useParams, useRouter } from "next/navigation"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; + +import { useTranslations } from "next-intl"; + +type ClientType = "olm"; + +interface TunnelTypeOption { + id: ClientType; + title: string; + description: string; + disabled?: boolean; +} + +type Commands = { + mac: Record; + linux: Record; + windows: Record; +}; + +const platforms = ["linux", "mac", "windows"] as const; + +type Platform = (typeof platforms)[number]; + +export default function Page() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId } = useParams(); + const router = useRouter(); + const t = useTranslations(); + + const createClientFormSchema = z.object({ + name: z + .string() + .min(2, { message: t("nameMin", { len: 2 }) }) + .max(30, { message: t("nameMax", { len: 30 }) }), + method: z.enum(["olm"]), + siteIds: z + .array( + z.object({ + id: z.string(), + text: z.string() + }) + ) + .refine((val) => val.length > 0, { + message: t("siteRequired") + }), + subnet: z.string().ip().min(1, { + message: t("subnetRequired") + }) + }); + + type CreateClientFormValues = z.infer; + + const [tunnelTypes, setTunnelTypes] = useState< + ReadonlyArray + >([ + { + id: "olm", + title: t("olmTunnel"), + description: t("olmTunnelDescription"), + disabled: true + } + ]); + + const [loadingPage, setLoadingPage] = useState(true); + const [sites, setSites] = useState([]); + const [activeSitesTagIndex, setActiveSitesTagIndex] = useState< + number | null + >(null); + + const [platform, setPlatform] = useState("linux"); + const [architecture, setArchitecture] = useState("amd64"); + const [commands, setCommands] = useState(null); + + const [olmId, setOlmId] = useState(""); + const [olmSecret, setOlmSecret] = useState(""); + const [olmCommand, setOlmCommand] = useState(""); + + const [createLoading, setCreateLoading] = useState(false); + + const [clientDefaults, setClientDefaults] = + useState(null); + + const hydrateCommands = ( + id: string, + secret: string, + endpoint: string, + version: string + ) => { + const commands = { + mac: { + "Apple Silicon (arm64)": [ + `curl -L -o olm "https://github.com/fosrl/olm/releases/download/${version}/olm_darwin_arm64" && chmod +x ./olm`, + `sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ], + "Intel x64 (amd64)": [ + `curl -L -o olm "https://github.com/fosrl/olm/releases/download/${version}/olm_darwin_amd64" && chmod +x ./olm`, + `sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ] + }, + linux: { + amd64: [ + `wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_amd64" && chmod +x ./olm`, + `sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ], + arm64: [ + `wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_arm64" && chmod +x ./olm`, + `sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ], + arm32: [ + `wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_arm32" && chmod +x ./olm`, + `sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ], + arm32v6: [ + `wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_arm32v6" && chmod +x ./olm`, + `sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ], + riscv64: [ + `wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_riscv64" && chmod +x ./olm`, + `sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ] + }, + windows: { + x64: [ + `curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`, + `# Run the installer to install olm and wintun`, + `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}` + ] + } + }; + setCommands(commands); + }; + + const getArchitectures = () => { + switch (platform) { + case "linux": + return ["amd64", "arm64", "arm32", "arm32v6", "riscv64"]; + case "mac": + return ["Apple Silicon (arm64)", "Intel x64 (amd64)"]; + case "windows": + return ["x64"]; + default: + return ["x64"]; + } + }; + + const getPlatformName = (platformName: string) => { + switch (platformName) { + case "windows": + return "Windows"; + case "mac": + return "macOS"; + case "docker": + return "Docker"; + default: + return "Linux"; + } + }; + + const getCommand = () => { + const placeholder = [t("unknownCommand")]; + if (!commands) { + return placeholder; + } + let platformCommands = commands[platform as keyof Commands]; + + if (!platformCommands) { + // get first key + const firstPlatform = Object.keys(commands)[0] as Platform; + platformCommands = commands[firstPlatform as keyof Commands]; + + setPlatform(firstPlatform); + } + + let architectureCommands = platformCommands[architecture]; + if (!architectureCommands) { + // get first key + const firstArchitecture = Object.keys(platformCommands)[0]; + architectureCommands = platformCommands[firstArchitecture]; + + setArchitecture(firstArchitecture); + } + + return architectureCommands || placeholder; + }; + + const getPlatformIcon = (platformName: string) => { + switch (platformName) { + case "windows": + return ; + case "mac": + return ; + case "docker": + return ; + case "podman": + return ; + case "freebsd": + return ; + default: + return ; + } + }; + + const form = useForm({ + resolver: zodResolver(createClientFormSchema), + defaultValues: { + name: "", + method: "olm", + siteIds: [], + subnet: "" + } + }); + + async function onSubmit(data: CreateClientFormValues) { + setCreateLoading(true); + + if (!clientDefaults) { + toast({ + variant: "destructive", + title: t("errorCreatingClient"), + description: t("clientDefaultsNotFound") + }); + setCreateLoading(false); + return; + } + + let payload: CreateClientBody = { + name: data.name, + type: data.method as "olm", + siteIds: data.siteIds.map((site) => parseInt(site.id)), + olmId: clientDefaults.olmId, + secret: clientDefaults.olmSecret, + subnet: data.subnet + }; + + const res = await api + .put< + AxiosResponse + >(`/org/${orgId}/client`, payload) + .catch((e) => { + toast({ + variant: "destructive", + title: t("errorCreatingClient"), + description: formatAxiosError(e) + }); + }); + + if (res && res.status === 201) { + const data = res.data.data; + router.push(`/${orgId}/settings/clients/${data.clientId}`); + } + + setCreateLoading(false); + } + + useEffect(() => { + const load = async () => { + setLoadingPage(true); + + // Fetch available sites + + const res = await api.get>( + `/org/${orgId}/sites/` + ); + const sites = res.data.data.sites.filter( + (s) => s.type === "newt" && s.subnet + ); + setSites( + sites.map((site) => ({ + id: site.siteId.toString(), + text: site.name + })) + ); + + let olmVersion = "latest"; + + try { + const response = await fetch( + `https://api.github.com/repos/fosrl/olm/releases/latest` + ); + if (!response.ok) { + throw new Error( + t("olmErrorFetchReleases", { + err: response.statusText + }) + ); + } + const data = await response.json(); + const latestVersion = data.tag_name; + olmVersion = latestVersion; + } catch (error) { + console.error( + t("olmErrorFetchLatest", { + err: + error instanceof Error + ? error.message + : String(error) + }) + ); + } + + await api + .get(`/org/${orgId}/pick-client-defaults`) + .catch((e) => { + form.setValue("method", "olm"); + }) + .then((res) => { + if (res && res.status === 200) { + const data = res.data.data; + + setClientDefaults(data); + + const olmId = data.olmId; + const olmSecret = data.olmSecret; + const olmCommand = `olm --id ${olmId} --secret ${olmSecret} --endpoint ${env.app.dashboardUrl}`; + + setOlmId(olmId); + setOlmSecret(olmSecret); + setOlmCommand(olmCommand); + + hydrateCommands( + olmId, + olmSecret, + env.app.dashboardUrl, + olmVersion + ); + + if (data.subnet) { + form.setValue("subnet", data.subnet); + } + + setTunnelTypes((prev: any) => { + return prev.map((item: any) => { + return { ...item, disabled: false }; + }); + }); + } + }); + + setLoadingPage(false); + }; + + load(); + }, []); + + return ( + <> +
+ + +
+ + {!loadingPage && ( +
+ + + + + {t("clientInformation")} + + + + +
+ + ( + + + {t("name")} + + + + + + + )} + /> + + ( + + + {t("address")} + + + + + + + {t("addressDescription")} + + + )} + /> + + ( + + + {t("sites")} + + { + form.setValue( + "siteIds", + olmags as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + sites + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + {t("sitesDescription")} + + + + )} + /> + + +
+
+
+ + {form.watch("method") === "olm" && ( + <> + + + + {t("clientOlmCredentials")} + + + {t("clientOlmCredentialsDescription")} + + + + + + + {t("olmEndpoint")} + + + + + + + + {t("olmId")} + + + + + + + + {t("olmSecretKey")} + + + + + + + + + + + {t("clientCredentialsSave")} + + + {t( + "clientCredentialsSaveDescription" + )} + + + + + + + + {t("clientInstallOlm")} + + + {t("clientInstallOlmDescription")} + + + +
+

+ {t("operatingSystem")} +

+
+ {platforms.map((os) => ( + + ))} +
+
+ +
+

+ {["docker", "podman"].includes( + platform + ) + ? t("method") + : t("architecture")} +

+
+ {getArchitectures().map( + (arch) => ( + + ) + )} +
+
+

+ {t("commands")} +

+
+ +
+
+
+
+
+ + )} +
+ +
+ + +
+
+ )} + + ); +} diff --git a/src/app/[orgId]/settings/clients/layout.tsx b/src/app/[orgId]/settings/clients/layout.tsx new file mode 100644 index 00000000..59a46414 --- /dev/null +++ b/src/app/[orgId]/settings/clients/layout.tsx @@ -0,0 +1,21 @@ +import { redirect } from "next/navigation"; +import { pullEnv } from "@app/lib/pullEnv"; + +export const dynamic = "force-dynamic"; + +interface SettingsLayoutProps { + children: React.ReactNode; + params: Promise<{ orgId: string }>; +} + +export default async function SettingsLayout(props: SettingsLayoutProps) { + const params = await props.params; + const { children } = props; + const env = pullEnv(); + + if (!env.flags.enableClients) { + redirect(`/${params.orgId}/settings`); + } + + return children; +} diff --git a/src/app/[orgId]/settings/clients/page.tsx b/src/app/[orgId]/settings/clients/page.tsx new file mode 100644 index 00000000..83cc11e3 --- /dev/null +++ b/src/app/[orgId]/settings/clients/page.tsx @@ -0,0 +1,58 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import { ClientRow } from "./ClientsTable"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { ListClientsResponse } from "@server/routers/client"; +import ClientsTable from "./ClientsTable"; + +type ClientsPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function ClientsPage(props: ClientsPageProps) { + const params = await props.params; + let clients: ListClientsResponse["clients"] = []; + try { + const res = await internal.get>( + `/org/${params.orgId}/clients`, + await authCookieHeader() + ); + clients = res.data.data.clients; + } catch (e) {} + + function formatSize(mb: number): string { + if (mb >= 1024 * 1024) { + return `${(mb / (1024 * 1024)).toFixed(2)} TB`; + } else if (mb >= 1024) { + return `${(mb / 1024).toFixed(2)} GB`; + } else { + return `${mb.toFixed(2)} MB`; + } + } + + const clientRows: ClientRow[] = clients.map((client) => { + return { + name: client.name, + id: client.clientId, + subnet: client.subnet.split("/")[0], + mbIn: formatSize(client.megabytesIn || 0), + mbOut: formatSize(client.megabytesOut || 0), + orgId: params.orgId, + online: client.online + }; + }); + + return ( + <> + + + + + ); +} diff --git a/src/app/[orgId]/settings/domains/CreateDomainForm.tsx b/src/app/[orgId]/settings/domains/CreateDomainForm.tsx new file mode 100644 index 00000000..31bf82f1 --- /dev/null +++ b/src/app/[orgId]/settings/domains/CreateDomainForm.tsx @@ -0,0 +1,516 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { useToast } from "@app/hooks/useToast"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; +import { formatAxiosError } from "@app/lib/api"; +import { CreateDomainResponse } from "@server/routers/domain/createOrgDomain"; +import { StrategySelect } from "@app/components/StrategySelect"; +import { AxiosResponse } from "axios"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { InfoIcon, AlertTriangle } from "lucide-react"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { build } from "@server/build"; + +const formSchema = z.object({ + baseDomain: z.string().min(1, "Domain is required"), + type: z.enum(["ns", "cname", "wildcard"]) +}); + +type FormValues = z.infer; + +type CreateDomainFormProps = { + open: boolean; + setOpen: (open: boolean) => void; + onCreated?: (domain: CreateDomainResponse) => void; +}; + +export default function CreateDomainForm({ + open, + setOpen, + onCreated +}: CreateDomainFormProps) { + const [loading, setLoading] = useState(false); + const [createdDomain, setCreatedDomain] = + useState(null); + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + const { toast } = useToast(); + const { org } = useOrgContext(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + baseDomain: "", + type: build == "oss" ? "wildcard" : "ns" + } + }); + + function reset() { + form.reset(); + setLoading(false); + setCreatedDomain(null); + } + + async function onSubmit(values: FormValues) { + setLoading(true); + try { + const response = await api.put>( + `/org/${org.org.orgId}/domain`, + values + ); + const domainData = response.data.data; + setCreatedDomain(domainData); + toast({ + title: t("success"), + description: t("domainCreatedDescription") + }); + onCreated?.(domainData); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setLoading(false); + } + } + + const domainType = form.watch("type"); + const baseDomain = form.watch("baseDomain"); + + let domainOptions: any = []; + if (build == "enterprise" || build == "saas") { + domainOptions = [ + { + id: "ns", + title: t("selectDomainTypeNsName"), + description: t("selectDomainTypeNsDescription") + }, + { + id: "cname", + title: t("selectDomainTypeCnameName"), + description: t("selectDomainTypeCnameDescription") + } + ]; + } else if (build == "oss") { + domainOptions = [ + { + id: "wildcard", + title: t("selectDomainTypeWildcardName"), + description: t("selectDomainTypeWildcardDescription") + } + ]; + } + + return ( + { + setOpen(val); + reset(); + }} + > + + + {t("domainAdd")} + + {t("domainAddDescription")} + + + + {!createdDomain ? ( +
+ + ( + + + + + )} + /> + ( + + {t("domain")} + + + + + + )} + /> + + + ) : ( +
+ + + + {t("createDomainAddDnsRecords")} + + + {t("createDomainAddDnsRecordsDescription")} + + + +
+ {createdDomain.nsRecords && + createdDomain.nsRecords.length > 0 && ( +
+

+ {t("createDomainNsRecords")} +

+ + + + {t("createDomainRecord")} + + +
+
+ + {t( + "createDomainType" + )} + + + NS + +
+
+ + {t( + "createDomainName" + )} + + + {baseDomain} + +
+ + {t( + "createDomainValue" + )} + + {createdDomain.nsRecords.map( + ( + nsRecord, + index + ) => ( +
+ +
+ ) + )} +
+
+
+
+
+ )} + + {createdDomain.cnameRecords && + createdDomain.cnameRecords.length > 0 && ( +
+

+ {t("createDomainCnameRecords")} +

+ + {createdDomain.cnameRecords.map( + (cnameRecord, index) => ( + + + {t( + "createDomainRecordNumber", + { + number: + index + + 1 + } + )} + + +
+
+ + {t( + "createDomainType" + )} + + + CNAME + +
+
+ + {t( + "createDomainName" + )} + + + { + cnameRecord.baseDomain + } + +
+
+ + {t( + "createDomainValue" + )} + + +
+
+
+
+ ) + )} +
+
+ )} + + {createdDomain.aRecords && + createdDomain.aRecords.length > 0 && ( +
+

+ {t("createDomainARecords")} +

+ + {createdDomain.aRecords.map( + (aRecord, index) => ( + + + {t( + "createDomainRecordNumber", + { + number: + index + + 1 + } + )} + + +
+
+ + {t( + "createDomainType" + )} + + + A + +
+
+ + {t( + "createDomainName" + )} + + + { + aRecord.baseDomain + } + +
+
+ + {t( + "createDomainValue" + )} + + + { + aRecord.value + } + +
+
+
+
+ ) + )} +
+
+ )} + {createdDomain.txtRecords && + createdDomain.txtRecords.length > 0 && ( +
+

+ {t("createDomainTxtRecords")} +

+ + {createdDomain.txtRecords.map( + (txtRecord, index) => ( + + + {t( + "createDomainRecordNumber", + { + number: + index + + 1 + } + )} + + +
+
+ + {t( + "createDomainType" + )} + + + TXT + +
+
+ + {t( + "createDomainName" + )} + + + { + txtRecord.baseDomain + } + +
+
+ + {t( + "createDomainValue" + )} + + +
+
+
+
+ ) + )} +
+
+ )} +
+ + {build == "saas" || + (build == "enterprise" && ( + + + + {t("createDomainSaveTheseRecords")} + + + {t( + "createDomainSaveTheseRecordsDescription" + )} + + + ))} + + + + + {t("createDomainDnsPropagation")} + + + {t("createDomainDnsPropagationDescription")} + + +
+ )} +
+ + + + + {!createdDomain && ( + + )} + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/domains/DomainsDataTable.tsx b/src/app/[orgId]/settings/domains/DomainsDataTable.tsx new file mode 100644 index 00000000..2008f0e8 --- /dev/null +++ b/src/app/[orgId]/settings/domains/DomainsDataTable.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; +import { useTranslations } from "next-intl"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + onAdd?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; +} + +export function DomainsDataTable({ + columns, + data, + onAdd, + onRefresh, + isRefreshing +}: DataTableProps) { + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/app/[orgId]/settings/domains/DomainsTable.tsx b/src/app/[orgId]/settings/domains/DomainsTable.tsx new file mode 100644 index 00000000..84bc8bc6 --- /dev/null +++ b/src/app/[orgId]/settings/domains/DomainsTable.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DomainsDataTable } from "./DomainsDataTable"; +import { Button } from "@app/components/ui/button"; +import { ArrowUpDown } from "lucide-react"; +import { useState } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { Badge } from "@app/components/ui/badge"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import CreateDomainForm from "./CreateDomainForm"; +import { useToast } from "@app/hooks/useToast"; +import { useOrgContext } from "@app/hooks/useOrgContext"; + +export type DomainRow = { + domainId: string; + baseDomain: string; + type: string; + verified: boolean; + failed: boolean; + tries: number; + configManaged: boolean; +}; + +type Props = { + domains: DomainRow[]; +}; + +export default function DomainsTable({ domains }: Props) { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [selectedDomain, setSelectedDomain] = useState( + null + ); + const [isRefreshing, setIsRefreshing] = useState(false); + const [restartingDomains, setRestartingDomains] = useState>( + new Set() + ); + const api = createApiClient(useEnvContext()); + const router = useRouter(); + const t = useTranslations(); + const { toast } = useToast(); + const { org } = useOrgContext(); + + const refreshData = async () => { + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + + const deleteDomain = async (domainId: string) => { + try { + await api.delete(`/org/${org.org.orgId}/domain/${domainId}`); + toast({ + title: t("success"), + description: t("domainDeletedDescription") + }); + setIsDeleteModalOpen(false); + refreshData(); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } + }; + + const restartDomain = async (domainId: string) => { + setRestartingDomains((prev) => new Set(prev).add(domainId)); + try { + await api.post(`/org/${org.org.orgId}/domain/${domainId}/restart`); + toast({ + title: t("success"), + description: t("domainRestartedDescription", { + fallback: "Domain verification restarted successfully" + }) + }); + refreshData(); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setRestartingDomains((prev) => { + const newSet = new Set(prev); + newSet.delete(domainId); + return newSet; + }); + } + }; + + const getTypeDisplay = (type: string) => { + switch (type) { + case "ns": + return t("selectDomainTypeNsName"); + case "cname": + return t("selectDomainTypeCnameName"); + case "wildcard": + return t("selectDomainTypeWildcardName"); + default: + return type; + } + }; + + const columns: ColumnDef[] = [ + { + accessorKey: "baseDomain", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "type", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const type = row.original.type; + return ( + {getTypeDisplay(type)} + ); + } + }, + { + accessorKey: "verified", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const { verified, failed } = row.original; + if (verified) { + return {t("verified")}; + } else if (failed) { + return ( + + {t("failed", { fallback: "Failed" })} + + ); + } else { + return {t("pending")}; + } + } + }, + { + id: "actions", + cell: ({ row }) => { + const domain = row.original; + const isRestarting = restartingDomains.has(domain.domainId); + + return ( +
+ {domain.failed && ( + + )} + +
+ ); + } + } + ]; + + return ( + <> + {selectedDomain && ( + { + setIsDeleteModalOpen(val); + setSelectedDomain(null); + }} + dialog={ +
+

+ {t("domainQuestionRemove", { + domain: selectedDomain.baseDomain + })} +

+

+ {t("domainMessageRemove")} +

+

{t("domainMessageConfirm")}

+
+ } + buttonText={t("domainConfirmDelete")} + onConfirm={async () => + deleteDomain(selectedDomain.domainId) + } + string={selectedDomain.baseDomain} + title={t("domainDelete")} + /> + )} + + { + refreshData(); + }} + /> + + setIsCreateModalOpen(true)} + onRefresh={refreshData} + isRefreshing={isRefreshing} + /> + + ); +} diff --git a/src/app/[orgId]/settings/domains/page.tsx b/src/app/[orgId]/settings/domains/page.tsx new file mode 100644 index 00000000..d20e431f --- /dev/null +++ b/src/app/[orgId]/settings/domains/page.tsx @@ -0,0 +1,60 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import DomainsTable, { DomainRow } from "./DomainsTable"; +import { getTranslations } from "next-intl/server"; +import { cache } from "react"; +import { GetOrgResponse } from "@server/routers/org"; +import { redirect } from "next/navigation"; +import OrgProvider from "@app/providers/OrgProvider"; +import { ListDomainsResponse } from "@server/routers/domain"; + +type Props = { + params: Promise<{ orgId: string }>; +}; + +export default async function DomainsPage(props: Props) { + const params = await props.params; + + let domains: DomainRow[] = []; + try { + const res = await internal.get< + AxiosResponse + >(`/org/${params.orgId}/domains`, await authCookieHeader()); + domains = res.data.data.domains as DomainRow[]; + } catch (e) { + console.error(e); + } + + let org = null; + try { + const getOrg = cache(async () => + internal.get>( + `/org/${params.orgId}`, + await authCookieHeader() + ) + ); + const res = await getOrg(); + org = res.data.data; + } catch { + redirect(`/${params.orgId}`); + } + + if (!org) { + } + + const t = await getTranslations(); + + return ( + <> + + + + + + ); +} diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index c692fbc9..0eba0a3d 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -1,5 +1,4 @@ "use client"; - import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { Button } from "@app/components/ui/button"; import { useOrgContext } from "@app/hooks/useOrgContext"; @@ -22,17 +21,9 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { formatAxiosError } from "@app/lib/api"; -import { AlertTriangle, Trash2 } from "lucide-react"; -import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle -} from "@/components/ui/card"; import { AxiosResponse } from "axios"; import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org"; -import { redirect, useRouter } from "next/navigation"; +import { useRouter } from "next/navigation"; import { SettingsContainer, SettingsSection, @@ -44,23 +35,26 @@ import { SettingsSectionFooter } from "@app/components/Settings"; import { useUserContext } from "@app/hooks/useUserContext"; -import { useTranslations } from 'next-intl'; +import { useTranslations } from "next-intl"; +import { build } from "@server/build"; +// Updated schema to include subnet field const GeneralFormSchema = z.object({ - name: z.string() + name: z.string(), + subnet: z.string().optional() }); type GeneralFormValues = z.infer; export default function GeneralPage() { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const { orgUser } = userOrgUserContext(); const router = useRouter(); const { org } = useOrgContext(); const api = createApiClient(useEnvContext()); const { user } = useUserContext(); const t = useTranslations(); + const { env } = useEnvContext(); const [loadingDelete, setLoadingDelete] = useState(false); const [loadingSave, setLoadingSave] = useState(false); @@ -68,7 +62,8 @@ export default function GeneralPage() { const form = useForm({ resolver: zodResolver(GeneralFormSchema), defaultValues: { - name: org?.org.name + name: org?.org.name, + subnet: org?.org.subnet || "" // Add default value for subnet }, mode: "onChange" }); @@ -79,12 +74,10 @@ export default function GeneralPage() { const res = await api.delete>( `/org/${org?.org.orgId}` ); - toast({ - title: t('orgDeleted'), - description: t('orgDeletedMessage') + title: t("orgDeleted"), + description: t("orgDeletedMessage") }); - if (res.status === 200) { pickNewOrgAndNavigate(); } @@ -92,8 +85,8 @@ export default function GeneralPage() { console.error(err); toast({ variant: "destructive", - title: t('orgErrorDelete'), - description: formatAxiosError(err, t('orgErrorDeleteMessage')) + title: t("orgErrorDelete"), + description: formatAxiosError(err, t("orgErrorDeleteMessage")) }); } finally { setLoadingDelete(false); @@ -120,8 +113,8 @@ export default function GeneralPage() { console.error(err); toast({ variant: "destructive", - title: t('orgErrorFetch'), - description: formatAxiosError(err, t('orgErrorFetchMessage')) + title: t("orgErrorFetch"), + description: formatAxiosError(err, t("orgErrorFetchMessage")) }); } } @@ -130,21 +123,21 @@ export default function GeneralPage() { setLoadingSave(true); await api .post(`/org/${org?.org.orgId}`, { - name: data.name + name: data.name, + // subnet: data.subnet // Include subnet in the API request }) .then(() => { toast({ - title: t('orgUpdated'), - description: t('orgUpdatedDescription') + title: t("orgUpdated"), + description: t("orgUpdatedDescription") }); - router.refresh(); }) .catch((e) => { toast({ variant: "destructive", - title: t('orgErrorUpdate'), - description: formatAxiosError(e, t('orgErrorUpdateMessage')) + title: t("orgErrorUpdate"), + description: formatAxiosError(e, t("orgErrorUpdateMessage")) }); }) .finally(() => { @@ -162,32 +155,28 @@ export default function GeneralPage() { dialog={

- {t('orgQuestionRemove', {selectedOrg: org?.org.name})} -

-

- {t('orgMessageRemove')} -

-

- {t('orgMessageConfirm')} + {t("orgQuestionRemove", { + selectedOrg: org?.org.name + })}

+

{t("orgMessageRemove")}

+

{t("orgMessageConfirm")}

} - buttonText={t('orgDeleteConfirm')} + buttonText={t("orgDeleteConfirm")} onConfirm={deleteOrg} string={org?.org.name || ""} - title={t('orgDelete')} + title={t("orgDelete")} /> - - {t('orgGeneralSettings')} + {t("orgGeneralSettings")} - {t('orgGeneralSettingsDescription')} + {t("orgGeneralSettingsDescription")} -
@@ -201,22 +190,44 @@ export default function GeneralPage() { name="name" render={({ field }) => ( - {t('name')} + {t("name")} - {t('orgDisplayName')} + {t("orgDisplayName")} )} /> + {env.flags.enableClients && ( + ( + + Subnet + + + + + + The subnet for this + organization's network + configuration. + + + )} + /> + )}
- - -
- - - - {t('orgDangerZone')} - - {t('orgDangerZoneDescription')} - - - - - + {build === "oss" && ( + + + + {t("orgDangerZone")} + + + {t("orgDangerZoneDescription")} + + + + + + + )} ); } diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index 215f554f..7db530dd 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -1,16 +1,18 @@ import { Metadata } from "next"; import { - Cog, Combine, + KeyRound, LinkIcon, Settings, Users, - Waypoints + Waypoints, + Workflow } from "lucide-react"; import { verifySession } from "@app/lib/auth/verifySession"; import { redirect } from "next/navigation"; import { internal } from "@app/lib/api"; import { AxiosResponse } from "axios"; +import { ListOrgsResponse } from "@server/routers/org"; import { GetOrgResponse, ListUserOrgsResponse } from "@server/routers/org"; import { authCookieHeader } from "@app/lib/api/cookies"; import { cache } from "react"; @@ -18,8 +20,9 @@ import { GetOrgUserResponse } from "@server/routers/user"; import UserProvider from "@app/providers/UserProvider"; import { Layout } from "@app/components/Layout"; import { SidebarNavItem, SidebarNavProps } from "@app/components/SidebarNav"; -import { orgNavItems } from "@app/app/navigation"; -import { getTranslations } from 'next-intl/server'; +import { getTranslations } from "next-intl/server"; +import { pullEnv } from "@app/lib/pullEnv"; +import { orgNavSections } from "@app/app/navigation"; export const dynamic = "force-dynamic"; @@ -41,6 +44,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { const getUser = cache(verifySession); const user = await getUser(); + const env = pullEnv(); + if (!user) { redirect(`/`); } @@ -59,7 +64,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { const orgUser = await getOrgUser(); if (!orgUser.data.data.isAdmin && !orgUser.data.data.isOwner) { - throw new Error(t('userErrorNotAdminOrOwner')); + throw new Error(t("userErrorNotAdminOrOwner")); } } catch { redirect(`/${params.orgId}`); @@ -81,7 +86,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { return ( - + {children} diff --git a/src/app/[orgId]/settings/resources/ResourcesTable.tsx b/src/app/[orgId]/settings/resources/ResourcesTable.tsx index 7c6f4340..e64fb4e3 100644 --- a/src/app/[orgId]/settings/resources/ResourcesTable.tsx +++ b/src/app/[orgId]/settings/resources/ResourcesTable.tsx @@ -31,7 +31,9 @@ import CopyToClipboard from "@app/components/CopyToClipboard"; import { Switch } from "@app/components/ui/switch"; import { AxiosResponse } from "axios"; import { UpdateResourceResponse } from "@server/routers/resource"; -import { useTranslations } from 'next-intl'; +import { useTranslations } from "next-intl"; +import { InfoPopup } from "@app/components/ui/info-popup"; +import { Badge } from "@app/components/ui/badge"; export type ResourceRow = { id: number; @@ -45,6 +47,7 @@ export type ResourceRow = { protocol: string; proxyPort: number | null; enabled: boolean; + domainId?: string; }; type ResourcesTableProps = { @@ -65,11 +68,11 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { const deleteResource = (resourceId: number) => { api.delete(`/resource/${resourceId}`) .catch((e) => { - console.error(t('resourceErrorDelte'), e); + console.error(t("resourceErrorDelte"), e); toast({ variant: "destructive", - title: t('resourceErrorDelte'), - description: formatAxiosError(e, t('resourceErrorDelte')) + title: t("resourceErrorDelte"), + description: formatAxiosError(e, t("resourceErrorDelte")) }); }) .then(() => { @@ -89,50 +92,16 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { .catch((e) => { toast({ variant: "destructive", - title: t('resourcesErrorUpdate'), - description: formatAxiosError(e, t('resourcesErrorUpdateDescription')) + title: t("resourcesErrorUpdate"), + description: formatAxiosError( + e, + t("resourcesErrorUpdateDescription") + ) }); }); } const columns: ColumnDef[] = [ - { - accessorKey: "dots", - header: "", - cell: ({ row }) => { - const resourceRow = row.original; - const router = useRouter(); - - return ( - - - - - - - - {t('viewSettings')} - - - { - setSelectedResource(resourceRow); - setIsDeleteModalOpen(true); - }} - > - {t('delete')} - - - - ); - } - }, { accessorKey: "name", header: ({ column }) => { @@ -143,7 +112,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('name')} + {t("name")} ); @@ -159,7 +128,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('site')} + {t("site")} ); @@ -170,7 +139,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { - @@ -180,7 +149,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { }, { accessorKey: "protocol", - header: t('protocol'), + header: t("protocol"), cell: ({ row }) => { const resourceRow = row.original; return {resourceRow.protocol.toUpperCase()}; @@ -188,16 +157,21 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { }, { accessorKey: "domain", - header: t('access'), + header: t("access"), cell: ({ row }) => { const resourceRow = row.original; return ( -
+
{!resourceRow.http ? ( + ) : !resourceRow.domainId ? ( + ) : ( - {t('authentication')} + {t("authentication")} ); @@ -230,12 +204,12 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { {resourceRow.authState === "protected" ? ( - {t('protected')} + {t("protected")} ) : resourceRow.authState === "not_protected" ? ( - {t('notProtected')} + {t("notProtected")} ) : ( - @@ -246,10 +220,15 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { }, { accessorKey: "enabled", - header: t('enabled'), + header: t("enabled"), cell: ({ row }) => ( toggleResourceEnabled(val, row.original.id) } @@ -262,11 +241,45 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { const resourceRow = row.original; return (
+ + + + + + + + {t("viewSettings")} + + + { + setSelectedResource(resourceRow); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + - @@ -288,22 +301,22 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { dialog={

- {t('resourceQuestionRemove', {selectedResource: selectedResource?.name || selectedResource?.id})} + {t("resourceQuestionRemove", { + selectedResource: + selectedResource?.name || + selectedResource?.id + })}

-

- {t('resourceMessageRemove')} -

+

{t("resourceMessageRemove")}

-

- {t('resourceMessageConfirm')} -

+

{t("resourceMessageConfirm")}

} - buttonText={t('resourceDeleteConfirm')} + buttonText={t("resourceDeleteConfirm")} onConfirm={async () => deleteResource(selectedResource!.id)} string={selectedResource.name} - title={t('resourceDelete')} + title={t("resourceDelete")} /> )} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx index 7ccc5e50..cc4408b2 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx @@ -10,10 +10,15 @@ import { InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; -import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useDockerSocket } from "@app/hooks/useDockerSocket"; import { useTranslations } from "next-intl"; +import { AxiosResponse } from "axios"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { RotateCw } from "lucide-react"; +import { createApiClient } from "@app/lib/api"; +import { build } from "@server/build"; type ResourceInfoBoxType = {}; @@ -30,7 +35,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { - {t('resourceInfo')} + {t("resourceInfo")} @@ -38,7 +43,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { <> - {t('authentication')} + {t("authentication")} {authInfo.password || @@ -47,12 +52,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { authInfo.whitelist ? (
- {t('protected')} + {t("protected")}
) : (
- {t('notProtected')} + {t("notProtected")}
)}
@@ -67,7 +72,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
- {t('site')} + {t("site")} {resource.siteName} @@ -94,7 +99,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { ) : ( <> - {t('protocol')} + + {t("protocol")} + {resource.protocol.toUpperCase()} @@ -102,7 +109,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { - {t('port')} + {t("port")} + {build == "oss" && ( + + + {t("externalProxyEnabled")} + + + + {resource.enableProxy + ? t("enabled") + : t("disabled")} + + + + )} )} - {t('visibility')} + {t("visibility")} - {resource.enabled ? t('enabled') : t('disabled')} + {resource.enabled + ? t("enabled") + : t("disabled")} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx index 6182c04a..c8f6255c 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx @@ -205,10 +205,10 @@ export default function ResourceAuthenticationPage() { console.error(e); toast({ variant: "destructive", - title: t('resourceErrorAuthFetch'), + title: t("resourceErrorAuthFetch"), description: formatAxiosError( e, - t('resourceErrorAuthFetchDescription') + t("resourceErrorAuthFetchDescription") ) }); } @@ -235,18 +235,18 @@ export default function ResourceAuthenticationPage() { }); toast({ - title: t('resourceWhitelistSave'), - description: t('resourceWhitelistSaveDescription') + title: t("resourceWhitelistSave"), + description: t("resourceWhitelistSaveDescription") }); router.refresh(); } catch (e) { console.error(e); toast({ variant: "destructive", - title: t('resourceErrorWhitelistSave'), + title: t("resourceErrorWhitelistSave"), description: formatAxiosError( e, - t('resourceErrorWhitelistSaveDescription') + t("resourceErrorWhitelistSaveDescription") ) }); } finally { @@ -283,18 +283,18 @@ export default function ResourceAuthenticationPage() { }); toast({ - title: t('resourceAuthSettingsSave'), - description: t('resourceAuthSettingsSaveDescription') + title: t("resourceAuthSettingsSave"), + description: t("resourceAuthSettingsSaveDescription") }); router.refresh(); } catch (e) { console.error(e); toast({ variant: "destructive", - title: t('resourceErrorUsersRolesSave'), + title: t("resourceErrorUsersRolesSave"), description: formatAxiosError( e, - t('resourceErrorUsersRolesSaveDescription') + t("resourceErrorUsersRolesSaveDescription") ) }); } finally { @@ -310,8 +310,8 @@ export default function ResourceAuthenticationPage() { }) .then(() => { toast({ - title: t('resourcePasswordRemove'), - description: t('resourcePasswordRemoveDescription') + title: t("resourcePasswordRemove"), + description: t("resourcePasswordRemoveDescription") }); updateAuthInfo({ @@ -322,10 +322,10 @@ export default function ResourceAuthenticationPage() { .catch((e) => { toast({ variant: "destructive", - title: t('resourceErrorPasswordRemove'), + title: t("resourceErrorPasswordRemove"), description: formatAxiosError( e, - t('resourceErrorPasswordRemoveDescription') + t("resourceErrorPasswordRemoveDescription") ) }); }) @@ -340,8 +340,8 @@ export default function ResourceAuthenticationPage() { }) .then(() => { toast({ - title: t('resourcePincodeRemove'), - description: t('resourcePincodeRemoveDescription') + title: t("resourcePincodeRemove"), + description: t("resourcePincodeRemoveDescription") }); updateAuthInfo({ @@ -352,10 +352,10 @@ export default function ResourceAuthenticationPage() { .catch((e) => { toast({ variant: "destructive", - title: t('resourceErrorPincodeRemove'), + title: t("resourceErrorPincodeRemove"), description: formatAxiosError( e, - t('resourceErrorPincodeRemoveDescription') + t("resourceErrorPincodeRemoveDescription") ) }); }) @@ -400,140 +400,151 @@ export default function ResourceAuthenticationPage() { - {t('resourceUsersRoles')} + {t("resourceUsersRoles")} - {t('resourceUsersRolesDescription')} + {t("resourceUsersRolesDescription")} - setSsoEnabled(val)} - /> + + setSsoEnabled(val)} + /> -
- - {ssoEnabled && ( - <> - ( - - {t('roles')} - - { - usersRolesForm.setValue( - "roles", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allRoles - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - {t('resourceRoleDescription')} - - - )} - /> - ( - - {t('users')} - - { - usersRolesForm.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allUsers - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - )} - /> - - )} - - +
+ + {ssoEnabled && ( + <> + ( + + + {t("roles")} + + + { + usersRolesForm.setValue( + "roles", + newRoles as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + allRoles + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + {t( + "resourceRoleDescription" + )} + + + )} + /> + ( + + + {t("users")} + + + { + usersRolesForm.setValue( + "users", + newUsers as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + allUsers + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + )} + /> + + )} + + +
@@ -550,170 +561,195 @@ export default function ResourceAuthenticationPage() { - {t('resourceAuthMethods')} + {t("resourceAuthMethods")} - {t('resourceAuthMethodsDescriptions')} + {t("resourceAuthMethodsDescriptions")} - {/* Password Protection */} -
-
- - - {t('resourcePasswordProtection', {status: authInfo.password? t('enabled') : t('disabled')})} - + + {/* Password Protection */} +
+
+ + + {t("resourcePasswordProtection", { + status: authInfo.password + ? t("enabled") + : t("disabled") + })} + +
+
- -
- {/* PIN Code Protection */} -
-
- - - {t('resourcePincodeProtection', {status: authInfo.pincode ? t('enabled') : t('disabled')})} - + {/* PIN Code Protection */} +
+
+ + + {t("resourcePincodeProtection", { + status: authInfo.pincode + ? t("enabled") + : t("disabled") + })} + +
+
- -
+ - {t('otpEmailTitle')} + {t("otpEmailTitle")} - {t('otpEmailTitleDescription')} + {t("otpEmailTitleDescription")} - {!env.email.emailEnabled && ( - - - - {t('otpEmailSmtpRequired')} - - - {t('otpEmailSmtpRequiredDescription')} - - - )} - + + {!env.email.emailEnabled && ( + + + + {t("otpEmailSmtpRequired")} + + + {t("otpEmailSmtpRequiredDescription")} + + + )} + - {whitelistEnabled && env.email.emailEnabled && ( -
- - ( - - - - - - {/* @ts-ignore */} - { - return z - .string() - .email() - .or( - z - .string() - .regex( - /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, - { - message: t('otpEmailErrorInvalid') - } - ) - ) - .safeParse( - tag - ).success; - }} - setActiveTagIndex={ - setActiveEmailTagIndex - } - placeholder={t('otpEmailEnter')} - tags={ - whitelistForm.getValues() - .emails - } - setTags={( - newRoles - ) => { - whitelistForm.setValue( - "emails", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - allowDuplicates={ - false - } - sortTags={true} - /> - - - {t('otpEmailEnterDescription')} - - - )} - /> - - - )} + {whitelistEnabled && env.email.emailEnabled && ( +
+ + ( + + + + + + {/* @ts-ignore */} + { + return z + .string() + .email() + .or( + z + .string() + .regex( + /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, + { + message: + t( + "otpEmailErrorInvalid" + ) + } + ) + ) + .safeParse( + tag + ).success; + }} + setActiveTagIndex={ + setActiveEmailTagIndex + } + placeholder={t( + "otpEmailEnter" + )} + tags={ + whitelistForm.getValues() + .emails + } + setTags={( + newRoles + ) => { + whitelistForm.setValue( + "emails", + newRoles as [ + Tag, + ...Tag[] + ] + ); + }} + allowDuplicates={ + false + } + sortTags={true} + /> + + + {t( + "otpEmailEnterDescription" + )} + + + )} + /> + + + )} +
diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index a0c89773..b4e14d64 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -66,6 +66,20 @@ import { } from "@server/routers/resource"; import { SwitchInput } from "@app/components/SwitchInput"; import { useTranslations } from "next-intl"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import DomainPicker from "@app/components/DomainPicker"; +import { Globe } from "lucide-react"; +import { build } from "@server/build"; const TransferFormSchema = z.object({ siteId: z.number() @@ -80,6 +94,7 @@ export default function GeneralForm() { const { org } = useOrgContext(); const router = useRouter(); const t = useTranslations(); + const [editDomainOpen, setEditDomainOpen] = useState(false); const { env } = useEnvContext(); @@ -96,47 +111,39 @@ export default function GeneralForm() { >([]); const [loadingPage, setLoadingPage] = useState(true); - const [domainType, setDomainType] = useState<"subdomain" | "basedomain">( - resource.isBaseDomain ? "basedomain" : "subdomain" + const [resourceFullDomain, setResourceFullDomain] = useState( + `${resource.ssl ? "https" : "http"}://${resource.fullDomain}` ); + const [selectedDomain, setSelectedDomain] = useState<{ + domainId: string; + subdomain?: string; + fullDomain: string; + } | null>(null); const GeneralFormSchema = z .object({ + enabled: z.boolean(), subdomain: z.string().optional(), name: z.string().min(1).max(255), - proxyPort: z.number().optional(), - http: z.boolean(), - isBaseDomain: z.boolean().optional(), - domainId: z.string().optional() + domainId: z.string().optional(), + proxyPort: z.number().int().min(1).max(65535).optional(), + enableProxy: z.boolean().optional() }) .refine( (data) => { - if (!data.http) { - return z - .number() - .int() - .min(1) - .max(65535) - .safeParse(data.proxyPort).success; + // For non-HTTP resources, proxyPort should be defined + if (!resource.http) { + return data.proxyPort !== undefined; } - return true; + // For HTTP resources, proxyPort should be undefined + return data.proxyPort === undefined; }, { - message: t("proxyErrorInvalidPort"), + message: !resource.http + ? "Port number is required for non-HTTP resources" + : "Port number should not be set for HTTP resources", path: ["proxyPort"] } - ) - .refine( - (data) => { - if (data.http && !data.isBaseDomain) { - return subdomainSchema.safeParse(data.subdomain).success; - } - return true; - }, - { - message: t("subdomainErrorInvalid"), - path: ["subdomain"] - } ); type GeneralFormValues = z.infer; @@ -144,12 +151,12 @@ export default function GeneralForm() { const form = useForm({ resolver: zodResolver(GeneralFormSchema), defaultValues: { + enabled: resource.enabled, name: resource.name, subdomain: resource.subdomain ? resource.subdomain : undefined, - proxyPort: resource.proxyPort ? resource.proxyPort : undefined, - http: resource.http, - isBaseDomain: resource.isBaseDomain ? true : false, - domainId: resource.domainId || undefined + domainId: resource.domainId || undefined, + proxyPort: resource.proxyPort || undefined, + enableProxy: resource.enableProxy || false }, mode: "onChange" }); @@ -209,11 +216,14 @@ export default function GeneralForm() { .post>( `resource/${resource?.resourceId}`, { + enabled: data.enabled, name: data.name, - subdomain: data.http ? data.subdomain : undefined, + subdomain: data.subdomain, + domainId: data.domainId, proxyPort: data.proxyPort, - isBaseDomain: data.http ? data.isBaseDomain : undefined, - domainId: data.http ? data.domainId : undefined + ...(!resource.http && { + enableProxy: data.enableProxy + }) } ) .catch((e) => { @@ -236,11 +246,14 @@ export default function GeneralForm() { const resource = res.data.data; updateResource({ + enabled: data.enabled, name: data.name, subdomain: data.subdomain, + fullDomain: resource.fullDomain, proxyPort: data.proxyPort, - isBaseDomain: data.isBaseDomain, - fullDomain: resource.fullDomain + ...(!resource.http && { + enableProxy: data.enableProxy + }), }); router.refresh(); @@ -282,469 +295,390 @@ export default function GeneralForm() { setTransferLoading(false); } - async function toggleResourceEnabled(val: boolean) { - const res = await api - .post>( - `resource/${resource.resourceId}`, - { - enabled: val - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("resourceErrorToggle"), - description: formatAxiosError( - e, - t("resourceErrorToggleDescription") - ) - }); - }); - - updateResource({ - enabled: val - }); - } - return ( !loadingPage && ( - - - - - {t("resourceVisibilityTitle")} - - - {t("resourceVisibilityTitleDescription")} - - - - { - await toggleResourceEnabled(val); - }} - /> - - + <> + + + + + {t("resourceGeneral")} + + + {t("resourceGeneralDescription")} + + - - - - {t("resourceGeneral")} - - - {t("resourceGeneralDescription")} - - - - - -
- - ( - - - {t("name")} - - - - - - - )} - /> - - {resource.http && ( - <> - {env.flags - .allowBaseDomainResources && ( - ( - - - {t( - "domainType" - )} - - - - - )} - /> - )} - -
- {domainType === "subdomain" ? ( -
- - {t("subdomain")} - -
-
- ( - - - - - - - )} - /> -
-
- ( - - - - - )} - /> -
-
-
- ) : ( - ( - - - {t( - "baseDomain" - )} - - - - - )} - /> - )} -
- - )} - - {!resource.http && ( + + + + ( + +
+ + + form.setValue( + "enabled", + val + ) + } + /> + +
+ +
+ )} + /> + + ( - {t( - "resourcePortNumber" - )} + {t("name")} - - field.onChange( - e.target - .value - ? parseInt( - e - .target - .value - ) - : null - ) - } - /> + )} /> - )} - - -
-
- - - -
- - - - - {t("resourceTransfer")} - - - {t("resourceTransferDescription")} - - - - - -
- - ( - - - {t("siteDestination")} - - - - - - - - - - - + {!resource.http && ( + <> + ( + + {t( - "sitesNotFound" + "resourcePortNumber" )} - - - {sites.map( - (site) => ( - { - transferForm.setValue( - "siteId", - site.siteId - ); - setOpen( - false - ); - }} - > - { - site.name - } - - - ) + + + + field.onChange( + e + .target + .value + ? parseInt( + e + .target + .value + ) + : undefined + ) + } + /> + + + + {t( + "resourcePortNumberDescription" )} - - - - - - - )} - /> - - -
-
+ + + )} + /> - - - -
-
+ {build == "oss" && ( + ( + + + + +
+ + {t( + "resourceEnableProxy" + )} + + + {t( + "resourceEnableProxyDescription" + )} + +
+
+ )} + /> + )} + + )} + + {resource.http && ( +
+ +
+ + + {resourceFullDomain} + + +
+
+ )} + + + + + + + + + + + + + + {t("resourceTransfer")} + + + {t("resourceTransferDescription")} + + + + + +
+ + ( + + + {t("siteDestination")} + + + + + + + + + + + + {t( + "sitesNotFound" + )} + + + {sites.map( + ( + site + ) => ( + { + transferForm.setValue( + "siteId", + site.siteId + ); + setOpen( + false + ); + }} + > + { + site.name + } + + + ) + )} + + + + + + + )} + /> + + +
+
+ + + + +
+
+ + setEditDomainOpen(setOpen)} + > + + + Edit Domain + + Select a domain for your resource + + + + { + const selected = { + domainId: res.domainId, + subdomain: res.subdomain, + fullDomain: res.fullDomain + }; + setSelectedDomain(selected); + }} + /> + + + + + + + + + + ) ); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx index c9c5eea6..dee0dd66 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx @@ -75,6 +75,7 @@ import { } from "@app/components/ui/collapsible"; import { ContainersSelector } from "@app/components/ContainersSelector"; import { useTranslations } from "next-intl"; +import { build } from "@server/build"; const addTargetSchema = z.object({ ip: z.string().refine(isTargetValid), @@ -319,10 +320,13 @@ export default function ReverseProxyTargets(props: { ); } - async function saveTargets() { + async function saveAllSettings() { try { setTargetsLoading(true); + setHttpsTlsLoading(true); + setProxySettingsLoading(true); + // Save targets for (let target of targets) { const data = { ip: target.ip, @@ -347,16 +351,36 @@ export default function ReverseProxyTargets(props: { await api.delete(`/target/${targetId}`); } - // Save sticky session setting - const stickySessionData = targetsSettingsForm.getValues(); - await api.post(`/resource/${params.resourceId}`, { - stickySession: stickySessionData.stickySession - }); - updateResource({ stickySession: stickySessionData.stickySession }); + if (resource.http) { + // Gather all settings + const stickySessionData = targetsSettingsForm.getValues(); + const tlsData = tlsSettingsForm.getValues(); + const proxyData = proxySettingsForm.getValues(); + + // Combine into one payload + const payload = { + stickySession: stickySessionData.stickySession, + ssl: tlsData.ssl, + tlsServerName: tlsData.tlsServerName || null, + setHostHeader: proxyData.setHostHeader || null + }; + + // Single API call to update all settings + await api.post(`/resource/${params.resourceId}`, payload); + + // Update local resource context + updateResource({ + ...resource, + stickySession: stickySessionData.stickySession, + ssl: tlsData.ssl, + tlsServerName: tlsData.tlsServerName || null, + setHostHeader: proxyData.setHostHeader || null + }); + } toast({ - title: t("targetsUpdated"), - description: t("targetsUpdatedDescription") + title: t("settingsUpdated"), + description: t("settingsUpdatedDescription") }); setTargetsToRemove([]); @@ -365,73 +389,15 @@ export default function ReverseProxyTargets(props: { console.error(err); toast({ variant: "destructive", - title: t("targetsErrorUpdate"), + title: t("settingsErrorUpdate"), description: formatAxiosError( err, - t("targetsErrorUpdateDescription") + t("settingsErrorUpdateDescription") ) }); } finally { setTargetsLoading(false); - } - } - - async function saveTlsSettings(data: TlsSettingsValues) { - try { - setHttpsTlsLoading(true); - await api.post(`/resource/${params.resourceId}`, { - ssl: data.ssl, - tlsServerName: data.tlsServerName || null - }); - updateResource({ - ...resource, - ssl: data.ssl, - tlsServerName: data.tlsServerName || null - }); - toast({ - title: t("targetTlsUpdate"), - description: t("targetTlsUpdateDescription") - }); - } catch (err) { - console.error(err); - toast({ - variant: "destructive", - title: t("targetErrorTlsUpdate"), - description: formatAxiosError( - err, - t("targetErrorTlsUpdateDescription") - ) - }); - } finally { setHttpsTlsLoading(false); - } - } - - async function saveProxySettings(data: ProxySettingsValues) { - try { - setProxySettingsLoading(true); - await api.post(`/resource/${params.resourceId}`, { - setHostHeader: data.setHostHeader || null - }); - updateResource({ - ...resource, - setHostHeader: data.setHostHeader || null - }); - toast({ - title: t("proxyUpdated"), - description: t("proxyUpdatedDescription") - }); - } catch (err) { - console.error(err); - toast({ - variant: "destructive", - title: t("proxyErrorUpdate"), - description: formatAxiosError( - err, - t("proxyErrorUpdateDescription") - ) - }); - } finally { setProxySettingsLoading(false); } } @@ -583,7 +549,7 @@ export default function ReverseProxyTargets(props: {
- + - {resource.http && ( - - - - - {t("targetTlsSettings")} - - - {t("targetTlsSettingsDescription")} - - - - - - + + + + {t("proxyAdditional")} + + + {t("proxyAdditionalDescription")} + + + + + + + {build == "oss" && ( )} /> - -
- - - -
- - ( - - - {t( - "targetTlsSni" - )} - - - - - - {t( - "targetTlsSniDescription" - )} - - - + )} + ( + + + {t("targetTlsSni")} + + + + + + {t( + "targetTlsSniDescription" )} - /> - -
- - -
-
- - - -
- - - - {t("proxyAdditional")} - - - {t("proxyAdditionalDescription")} - - - - -
- + + )} - className="space-y-4" - id="proxy-settings-form" - > - ( - - - {t("proxyCustomHeader")} - - - - - - {t( - "proxyCustomHeaderDescription" - )} - - - - )} - /> - - -
-
- - - -
-
+ /> + + + + + +
+ + ( + + + {t("proxyCustomHeader")} + + + + + + {t( + "proxyCustomHeaderDescription" + )} + + + + )} + /> + + +
+ + )} + +
+ +
); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx index 90c86aea..2f7d03ee 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx @@ -36,7 +36,6 @@ import { TableBody, TableCaption, TableCell, - TableContainer, TableHead, TableHeader, TableRow @@ -234,35 +233,6 @@ export default function ResourceRules(props: { ); } - async function saveApplyRules(val: boolean) { - const res = await api - .post(`/resource/${params.resourceId}`, { - applyRules: val - }) - .catch((err) => { - console.error(err); - toast({ - variant: "destructive", - title: t('rulesErrorUpdate'), - description: formatAxiosError( - err, - t('rulesErrorUpdateDescription') - ) - }); - }); - - if (res && res.status === 200) { - setRulesEnabled(val); - updateResource({ applyRules: val }); - - toast({ - title: t('rulesUpdated'), - description: t('rulesUpdatedDescription') - }); - router.refresh(); - } - } - function getValueHelpText(type: string) { switch (type) { case "CIDR": @@ -274,9 +244,33 @@ export default function ResourceRules(props: { } } - async function saveRules() { + async function saveAllSettings() { try { setLoading(true); + + // Save rules enabled state + const res = await api + .post(`/resource/${params.resourceId}`, { + applyRules: rulesEnabled + }) + .catch((err) => { + console.error(err); + toast({ + variant: "destructive", + title: t('rulesErrorUpdate'), + description: formatAxiosError( + err, + t('rulesErrorUpdateDescription') + ) + }); + throw err; + }); + + if (res && res.status === 200) { + updateResource({ applyRules: rulesEnabled }); + } + + // Save rules for (let rule of rules) { const data = { action: rule.action, @@ -543,67 +537,48 @@ export default function ResourceRules(props: { return ( - - - {t('rulesAbout')} - -
-

- {t('rulesAboutDescription')} -

-
- - - {t('rulesActions')} -
    -
  • - - {t('rulesActionAlwaysAllow')} -
  • -
  • - - {t('rulesActionAlwaysDeny')} -
  • -
-
- - - {t('rulesMatchCriteria')} - -
    -
  • - {t('rulesMatchCriteriaIpAddress')} -
  • -
  • - {t('rulesMatchCriteriaIpAddressRange')} -
  • -
  • - {t('rulesMatchCriteriaUrl')} -
  • -
-
-
-
-
- - - - {t('rulesEnable')} - - {t('rulesEnableDescription')} - - - - { - await saveApplyRules(val); - }} - /> - - + {/* */} + {/* */} + {/* {t('rulesAbout')} */} + {/* */} + {/*
*/} + {/*

*/} + {/* {t('rulesAboutDescription')} */} + {/*

*/} + {/*
*/} + {/* */} + {/* */} + {/* {t('rulesActions')} */} + {/*
    */} + {/*
  • */} + {/* */} + {/* {t('rulesActionAlwaysAllow')} */} + {/*
  • */} + {/*
  • */} + {/* */} + {/* {t('rulesActionAlwaysDeny')} */} + {/*
  • */} + {/*
*/} + {/*
*/} + {/* */} + {/* */} + {/* {t('rulesMatchCriteria')} */} + {/* */} + {/*
    */} + {/*
  • */} + {/* {t('rulesMatchCriteriaIpAddress')} */} + {/*
  • */} + {/*
  • */} + {/* {t('rulesMatchCriteriaIpAddressRange')} */} + {/*
  • */} + {/*
  • */} + {/* {t('rulesMatchCriteriaUrl')} */} + {/*
  • */} + {/*
*/} + {/*
*/} + {/*
*/} + {/*
*/} + {/*
*/} @@ -615,168 +590,179 @@ export default function ResourceRules(props: { -
- -
- ( - - {t('rulesAction')} - - - - - - )} - /> - ( - - {t('rulesMatchType')} - - + + + + + + {RuleAction.ACCEPT} - )} - - {RuleMatch.IP} - - - {RuleMatch.CIDR} - - - - - - - )} - /> - ( - - - - - - - - )} - /> - -
-
- - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef - .header, - header.getContext() - )} - - ))} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - + + {RuleAction.DROP} + + + + + + + )} + /> + ( + + {t('rulesMatchType')} + + + + + + )} + /> + ( + + + + + + + + )} + /> + + + + +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext() + )} + ))} - )) - ) : ( - - - {t('rulesNoOne')} - - - )} - - - {t('rulesOrder')} - -
+ ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + {t('rulesNoOne')} + + + )} + + {/* */} + {/* {t('rulesOrder')} */} + {/* */} + +
- - - + +
+ +
); } diff --git a/src/app/[orgId]/settings/resources/create/page.tsx b/src/app/[orgId]/settings/resources/create/page.tsx index 72d4a4d6..a8d926fe 100644 --- a/src/app/[orgId]/settings/resources/create/page.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -25,6 +25,7 @@ import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Input } from "@app/components/ui/input"; import { Button } from "@app/components/ui/button"; +import { Checkbox } from "@app/components/ui/checkbox"; import { useParams, useRouter } from "next/navigation"; import { ListSitesResponse } from "@server/routers/site"; import { formatAxiosError } from "@app/lib/api"; @@ -63,6 +64,8 @@ import { SquareArrowOutUpRight } from "lucide-react"; import CopyTextBox from "@app/components/CopyTextBox"; import Link from "next/link"; import { useTranslations } from "next-intl"; +import DomainPicker from "@app/components/DomainPicker"; +import { build } from "@server/build"; const baseResourceFormSchema = z.object({ name: z.string().min(1).max(255), @@ -70,21 +73,15 @@ const baseResourceFormSchema = z.object({ http: z.boolean() }); -const httpResourceFormSchema = z.discriminatedUnion("isBaseDomain", [ - z.object({ - isBaseDomain: z.literal(true), - domainId: z.string().min(1) - }), - z.object({ - isBaseDomain: z.literal(false), - domainId: z.string().min(1), - subdomain: z.string().pipe(subdomainSchema) - }) -]); +const httpResourceFormSchema = z.object({ + domainId: z.string().nonempty(), + subdomain: z.string().optional() +}); const tcpUdpResourceFormSchema = z.object({ protocol: z.string(), - proxyPort: z.number().int().min(1).max(65535) + proxyPort: z.number().int().min(1).max(65535), + enableProxy: z.boolean().default(false) }); type BaseResourceFormValues = z.infer; @@ -119,15 +116,18 @@ export default function Page() { const resourceTypes: ReadonlyArray = [ { id: "http", - title: t('resourceHTTP'), - description: t('resourceHTTPDescription') + title: t("resourceHTTP"), + description: t("resourceHTTPDescription") }, - { - id: "raw", - title: t('resourceRaw'), - description: t('resourceRawDescription'), - disabled: !env.flags.allowRawResources - } + ...(!env.flags.allowRawResources + ? [] + : [ + { + id: "raw" as ResourceType, + title: t("resourceRaw"), + description: t("resourceRawDescription") + } + ]) ]; const baseForm = useForm({ @@ -140,18 +140,15 @@ export default function Page() { const httpForm = useForm({ resolver: zodResolver(httpResourceFormSchema), - defaultValues: { - subdomain: "", - domainId: "", - isBaseDomain: false - } + defaultValues: {} }); const tcpUdpForm = useForm({ resolver: zodResolver(tcpUdpResourceFormSchema), defaultValues: { protocol: "tcp", - proxyPort: undefined + proxyPort: undefined, + enableProxy: false } }); @@ -170,25 +167,17 @@ export default function Page() { if (isHttp) { const httpData = httpForm.getValues(); - if (httpData.isBaseDomain) { - Object.assign(payload, { - domainId: httpData.domainId, - isBaseDomain: true, - protocol: "tcp" - }); - } else { - Object.assign(payload, { - subdomain: httpData.subdomain, - domainId: httpData.domainId, - isBaseDomain: false, - protocol: "tcp" - }); - } + Object.assign(payload, { + subdomain: httpData.subdomain, + domainId: httpData.domainId, + protocol: "tcp" + }); } else { const tcpUdpData = tcpUdpForm.getValues(); Object.assign(payload, { protocol: tcpUdpData.protocol, - proxyPort: tcpUdpData.proxyPort + proxyPort: tcpUdpData.proxyPort, + enableProxy: tcpUdpData.enableProxy }); } @@ -199,10 +188,10 @@ export default function Page() { .catch((e) => { toast({ variant: "destructive", - title: t('resourceErrorCreate'), + title: t("resourceErrorCreate"), description: formatAxiosError( e, - t('resourceErrorCreateDescription') + t("resourceErrorCreateDescription") ) }); }); @@ -214,16 +203,23 @@ export default function Page() { if (isHttp) { router.push(`/${orgId}/settings/resources/${id}`); } else { - setShowSnippets(true); - router.refresh(); + const tcpUdpData = tcpUdpForm.getValues(); + // Only show config snippets if enableProxy is explicitly true + if (tcpUdpData.enableProxy === true) { + setShowSnippets(true); + router.refresh(); + } else { + // If enableProxy is false or undefined, go directly to resource page + router.push(`/${orgId}/settings/resources/${id}`); + } } } } catch (e) { - console.error(t('resourceErrorCreateMessage'), e); + console.error(t("resourceErrorCreateMessage"), e); toast({ variant: "destructive", - title: t('resourceErrorCreate'), - description:t('resourceErrorCreateMessageDescription') + title: t("resourceErrorCreate"), + description: t("resourceErrorCreateMessageDescription") }); } @@ -242,10 +238,10 @@ export default function Page() { .catch((e) => { toast({ variant: "destructive", - title: t('sitesErrorFetch'), + title: t("sitesErrorFetch"), description: formatAxiosError( e, - t('sitesErrorFetchDescription') + t("sitesErrorFetchDescription") ) }); }); @@ -270,10 +266,10 @@ export default function Page() { .catch((e) => { toast({ variant: "destructive", - title: t('domainsErrorFetch'), + title: t("domainsErrorFetch"), description: formatAxiosError( e, - t('domainsErrorFetchDescription') + t("domainsErrorFetchDescription") ) }); }); @@ -281,9 +277,9 @@ export default function Page() { if (res?.status === 200) { const domains = res.data.data.domains; setBaseDomains(domains); - if (domains.length) { - httpForm.setValue("domainId", domains[0].domainId); - } + // if (domains.length) { + // httpForm.setValue("domainId", domains[0].domainId); + // } } }; @@ -300,8 +296,8 @@ export default function Page() { <>
@@ -320,7 +316,7 @@ export default function Page() { - {t('resourceInfo')} + {t("resourceInfo")} @@ -336,7 +332,7 @@ export default function Page() { render={({ field }) => ( - {t('name')} + {t("name")} - {t('resourceNameDescription')} + {t( + "resourceNameDescription" + )} )} @@ -357,7 +355,7 @@ export default function Page() { render={({ field }) => ( - {t('site')} + {t("site")} - + - {t('siteNotFound')} + {t( + "siteNotFound" + )} {sites.map( @@ -433,7 +439,9 @@ export default function Page() { - {t('siteSelectionDescription')} + {t( + "siteSelectionDescription" + )} )} @@ -444,253 +452,74 @@ export default function Page() { - - - - {t('resourceType')} - - - {t('resourceTypeDescription')} - - - - { - baseForm.setValue( - "http", - value === "http" - ); - }} - cols={2} - /> - - + {resourceTypes.length > 1 && ( + + + + {t("resourceType")} + + + {t("resourceTypeDescription")} + + + + { + baseForm.setValue( + "http", + value === "http" + ); + }} + cols={2} + /> + + + )} {baseForm.watch("http") ? ( - {t('resourceHTTPSSettings')} + {t("resourceHTTPSSettings")} - {t('resourceHTTPSSettingsDescription')} + {t( + "resourceHTTPSSettingsDescription" + )} - -
- - {env.flags - .allowBaseDomainResources && ( - ( - - - {t('domainType')} - - - - - )} - /> - )} - - {!httpForm.watch( - "isBaseDomain" - ) && ( - - - {t('subdomain')} - -
-
- ( - - - - - - - )} - /> -
-
- ( - - - - - )} - /> -
-
- - {t('subdomnainDescription')} - -
- )} - - {httpForm.watch( - "isBaseDomain" - ) && ( - ( - - - {t('baseDomain')} - - - - - )} - /> - )} - - -
+ { + httpForm.setValue( + "subdomain", + res.subdomain + ); + httpForm.setValue( + "domainId", + res.domainId + ); + console.log( + "Domain changed:", + res + ); + }} + />
) : ( - {t('resourceRawSettings')} + {t("resourceRawSettings")} - {t('resourceRawSettingsDescription')} + {t( + "resourceRawSettingsDescription" + )} @@ -708,7 +537,9 @@ export default function Page() { render={({ field }) => ( - {t('protocol')} + {t( + "protocol" + )} - {t('resourcePortNumberDescription')} + {t( + "resourcePortNumberDescription" + )} )} /> + + {build == "oss" && ( + ( + + + + +
+ + {t( + "resourceEnableProxy" + )} + + + {t( + "resourceEnableProxyDescription" + )} + +
+
+ )} + /> + )} @@ -788,27 +667,32 @@ export default function Page() { type="button" variant="outline" onClick={() => - router.push(`/${orgId}/settings/resources`) + router.push( + `/${orgId}/settings/resources` + ) } > - {t('cancel')} + {t("cancel")}
@@ -817,17 +701,17 @@ export default function Page() { - {t('resourceConfig')} + {t("resourceConfig")} - {t('resourceConfigDescription')} + {t("resourceConfigDescription")}

- {t('resourceAddEntrypoints')} + {t("resourceAddEntrypoints")}

- {t('resourceExposePorts')} + {t("resourceExposePorts")}

- - {t('resourceLearnRaw')} - + {t("resourceLearnRaw")}
@@ -868,20 +750,22 @@ export default function Page() { type="button" variant="outline" onClick={() => - router.push(`/${orgId}/settings/resources`) + router.push( + `/${orgId}/settings/resources` + ) } > - {t('resourceBack')} + {t("resourceBack")}
diff --git a/src/app/[orgId]/settings/resources/page.tsx b/src/app/[orgId]/settings/resources/page.tsx index bbd2a582..371b4404 100644 --- a/src/app/[orgId]/settings/resources/page.tsx +++ b/src/app/[orgId]/settings/resources/page.tsx @@ -67,7 +67,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) { resource.whitelist ? "protected" : "not_protected", - enabled: resource.enabled + enabled: resource.enabled, + domainId: resource.domainId || undefined }; }); diff --git a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx index cce81da7..b4b03fc5 100644 --- a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx +++ b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx @@ -392,7 +392,7 @@ export default function CreateShareLinkForm({ defaultValue={field.value.toString()} > - + diff --git a/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx b/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx index de419319..5c6ace73 100644 --- a/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx +++ b/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx @@ -69,11 +69,8 @@ export default function ShareLinksTable({ async function deleteSharelink(id: string) { await api.delete(`/access-token/${id}`).catch((e) => { toast({ - title: t('shareErrorDelete'), - description: formatAxiosError( - e, - t('shareErrorDeleteMessage') - ) + title: t("shareErrorDelete"), + description: formatAxiosError(e, t("shareErrorDeleteMessage")) }); }); @@ -81,53 +78,12 @@ export default function ShareLinksTable({ setRows(newRows); toast({ - title: t('shareDeleted'), - description: t('shareDeletedDescription') + title: t("shareDeleted"), + description: t("shareDeletedDescription") }); } const columns: ColumnDef[] = [ - { - id: "actions", - cell: ({ row }) => { - const router = useRouter(); - - const resourceRow = row.original; - - return ( - <> -
- - - - - - { - deleteSharelink( - resourceRow.accessTokenId - ); - }} - > - - - - -
- - ); - } - }, { accessorKey: "resourceName", header: ({ column }) => { @@ -138,7 +94,7 @@ export default function ShareLinksTable({ column.toggleSorting(column.getIsSorted() === "asc") } > - {t('resource')} + {t("resource")} ); @@ -147,7 +103,7 @@ export default function ShareLinksTable({ const r = row.original; return ( - ); @@ -245,7 +201,7 @@ export default function ShareLinksTable({ column.toggleSorting(column.getIsSorted() === "asc") } > - {t('created')} + {t("created")} ); @@ -265,7 +221,7 @@ export default function ShareLinksTable({ column.toggleSorting(column.getIsSorted() === "asc") } > - {t('expires')} + {t("expires")} ); @@ -275,23 +231,50 @@ export default function ShareLinksTable({ if (r.expiresAt) { return moment(r.expiresAt).format("lll"); } - return t('never'); + return t("never"); } }, { id: "delete", - cell: ({ row }) => ( -
- -
- ) + cell: ({ row }) => { + const resourceRow = row.original; + return ( +
+ {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* { */} + {/* deleteSharelink( */} + {/* resourceRow.accessTokenId */} + {/* ); */} + {/* }} */} + {/* > */} + {/* */} + {/* */} + {/* */} + {/* */} + +
+ ); + } } ]; diff --git a/src/app/[orgId]/settings/sites/CreateSiteForm.tsx b/src/app/[orgId]/settings/sites/CreateSiteForm.tsx deleted file mode 100644 index b5633268..00000000 --- a/src/app/[orgId]/settings/sites/CreateSiteForm.tsx +++ /dev/null @@ -1,461 +0,0 @@ -"use client"; - -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; -import { toast } from "@app/hooks/useToast"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { useParams, useRouter } from "next/navigation"; -import { - CreateSiteBody, - CreateSiteResponse, - PickSiteDefaultsResponse -} from "@server/routers/site"; -import { generateKeypair } from "./[niceId]/wireguardConfig"; -import CopyTextBox from "@app/components/CopyTextBox"; -import { Checkbox } from "@app/components/ui/checkbox"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { formatAxiosError } from "@app/lib/api"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { SiteRow } from "./SitesTable"; -import { AxiosResponse } from "axios"; -import { Button } from "@app/components/ui/button"; -import Link from "next/link"; -import { - ArrowUpRight, - ChevronsUpDown, - Loader2, - SquareArrowOutUpRight -} from "lucide-react"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger -} from "@app/components/ui/collapsible"; -import LoaderPlaceholder from "@app/components/PlaceHolderLoader"; -import { useTranslations } from "next-intl"; - -type CreateSiteFormProps = { - onCreate?: (site: SiteRow) => void; - setLoading?: (loading: boolean) => void; - setChecked?: (checked: boolean) => void; - orgId: string; -}; - -export default function CreateSiteForm({ - onCreate, - setLoading, - setChecked, - orgId -}: CreateSiteFormProps) { - const api = createApiClient(useEnvContext()); - const { env } = useEnvContext(); - - const [isLoading, setIsLoading] = useState(false); - const [isChecked, setIsChecked] = useState(false); - - const [isOpen, setIsOpen] = useState(false); - - const [keypair, setKeypair] = useState<{ - publicKey: string; - privateKey: string; - } | null>(null); - - const t = useTranslations(); - - const createSiteFormSchema = z.object({ - name: z - .string() - .min(2, { - message: t('nameMin', {len: 2}) - }) - .max(30, { - message: t('nameMax', {len: 30}) - }), - method: z.enum(["wireguard", "newt", "local"]) - }); - - type CreateSiteFormValues = z.infer; - - const defaultValues: Partial = { - name: "", - method: "newt" - }; - - const [siteDefaults, setSiteDefaults] = - useState(null); - - const [loadingPage, setLoadingPage] = useState(true); - - const handleCheckboxChange = (checked: boolean) => { - // setChecked?.(checked); - setIsChecked(checked); - }; - - const form = useForm({ - resolver: zodResolver(createSiteFormSchema), - defaultValues - }); - - const nameField = form.watch("name"); - const methodField = form.watch("method"); - - useEffect(() => { - const nameIsValid = nameField?.length >= 2 && nameField?.length <= 30; - const isFormValid = methodField === "local" || isChecked; - - // Only set checked to true if name is valid AND (method is local OR checkbox is checked) - setChecked?.(nameIsValid && isFormValid); - }, [nameField, methodField, isChecked, setChecked]); - - useEffect(() => { - if (!open) return; - - const load = async () => { - setLoadingPage(true); - // reset all values - setLoading?.(false); - setIsLoading(false); - form.reset(); - setChecked?.(false); - setKeypair(null); - setSiteDefaults(null); - - const generatedKeypair = generateKeypair(); - setKeypair(generatedKeypair); - - await api - .get(`/org/${orgId}/pick-site-defaults`) - .catch((e) => { - // update the default value of the form to be local method - form.setValue("method", "local"); - }) - .then((res) => { - if (res && res.status === 200) { - setSiteDefaults(res.data.data); - } - }); - await new Promise((resolve) => setTimeout(resolve, 200)); - - setLoadingPage(false); - }; - - load(); - }, [open]); - - async function onSubmit(data: CreateSiteFormValues) { - setLoading?.(true); - setIsLoading(true); - let payload: CreateSiteBody = { - name: data.name, - type: data.method - }; - - if (data.method == "wireguard") { - if (!keypair || !siteDefaults) { - toast({ - variant: "destructive", - title: t('siteErrorCreate'), - description: t('siteErrorCreateKeyPair') - }); - setLoading?.(false); - setIsLoading(false); - return; - } - - payload = { - ...payload, - subnet: siteDefaults.subnet, - exitNodeId: siteDefaults.exitNodeId, - pubKey: keypair.publicKey - }; - } - if (data.method === "newt") { - if (!siteDefaults) { - toast({ - variant: "destructive", - title: t('siteErrorCreate'), - description: t('siteErrorCreateDefaults') - }); - setLoading?.(false); - setIsLoading(false); - return; - } - - payload = { - ...payload, - subnet: siteDefaults.subnet, - exitNodeId: siteDefaults.exitNodeId, - secret: siteDefaults.newtSecret, - newtId: siteDefaults.newtId - }; - } - - const res = await api - .put< - AxiosResponse - >(`/org/${orgId}/site/`, payload) - .catch((e) => { - toast({ - variant: "destructive", - title: t('siteErrorCreate'), - description: formatAxiosError(e) - }); - }); - - if (res && res.status === 201) { - const data = res.data.data; - - onCreate?.({ - name: data.name, - id: data.siteId, - nice: data.niceId.toString(), - mbIn: - data.type == "wireguard" || data.type == "newt" - ? t('megabytes', {count: 0}) - : "-", - mbOut: - data.type == "wireguard" || data.type == "newt" - ? t('megabytes', {count: 0}) - : "-", - orgId: orgId as string, - type: data.type as any, - online: false - }); - } - - setLoading?.(false); - setIsLoading(false); - } - - const wgConfig = - keypair && siteDefaults - ? `[Interface] -Address = ${siteDefaults.subnet} -ListenPort = 51820 -PrivateKey = ${keypair.privateKey} - -[Peer] -PublicKey = ${siteDefaults.publicKey} -AllowedIPs = ${siteDefaults.address.split("/")[0]}/32 -Endpoint = ${siteDefaults.endpoint}:${siteDefaults.listenPort} -PersistentKeepalive = 5` - : ""; - - const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`; - - const newtConfigDockerCompose = `services: - newt: - image: fosrl/newt - container_name: newt - restart: unless-stopped - environment: - - PANGOLIN_ENDPOINT=${env.app.dashboardUrl} - - NEWT_ID=${siteDefaults?.newtId} - - NEWT_SECRET=${siteDefaults?.newtSecret}`; - - const newtConfigDockerRun = `docker run -dit fosrl/newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`; - - return loadingPage ? ( - - ) : ( -
-
- - ( - - {t('name')} - - - - - - {t('siteNameDescription')} - - - )} - /> - ( - - {t('method')} - - - - - - {t('siteMethodDescription')} - - - )} - /> - - {form.watch("method") === "newt" && ( - - - {t('siteLearnNewt')} - - - - )} - -
- {form.watch("method") === "wireguard" && !isLoading ? ( - <> - - - {t('siteSeeConfigOnce')} - - - ) : form.watch("method") === "wireguard" && - isLoading ? ( -

{t('siteLoadWGConfig')}

- ) : form.watch("method") === "newt" && siteDefaults ? ( - <> -
- -
- -
- - {t('siteSeeConfigOnce')} - -
- - - -
- -
- {t('dockerCompose')} - -
-
- {t('dockerRun')} - - -
-
-
-
- - ) : null} -
- - {form.watch("method") === "local" && ( - - {t('siteLearnLocal')} - - - )} - - {(form.watch("method") === "newt" || - form.watch("method") === "wireguard") && ( -
- - -
- )} - - -
- ); -} diff --git a/src/app/[orgId]/settings/sites/CreateSiteModal.tsx b/src/app/[orgId]/settings/sites/CreateSiteModal.tsx deleted file mode 100644 index 8ecee55c..00000000 --- a/src/app/[orgId]/settings/sites/CreateSiteModal.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import { Button } from "@app/components/ui/button"; -import { useState } from "react"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import { SiteRow } from "./SitesTable"; -import CreateSiteForm from "./CreateSiteForm"; -import { useTranslations } from "next-intl"; - -type CreateSiteFormProps = { - open: boolean; - setOpen: (open: boolean) => void; - onCreate?: (site: SiteRow) => void; - orgId: string; -}; - -export default function CreateSiteFormModal({ - open, - setOpen, - onCreate, - orgId -}: CreateSiteFormProps) { - const [loading, setLoading] = useState(false); - const [isChecked, setIsChecked] = useState(false); - const t = useTranslations(); - - return ( - <> - { - setOpen(val); - setLoading(false); - }} - > - - - {t('siteCreate')} - - {t('siteCreateDescription')} - - - -
- setLoading(val)} - setChecked={(val) => setIsChecked(val)} - onCreate={onCreate} - orgId={orgId} - /> -
-
- - - - - - -
-
- - ); -} diff --git a/src/app/[orgId]/settings/sites/SitesDataTable.tsx b/src/app/[orgId]/settings/sites/SitesDataTable.tsx index 99445dea..60c395c7 100644 --- a/src/app/[orgId]/settings/sites/SitesDataTable.tsx +++ b/src/app/[orgId]/settings/sites/SitesDataTable.tsx @@ -8,12 +8,16 @@ interface DataTableProps { columns: ColumnDef[]; data: TData[]; createSite?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; } export function SitesDataTable({ columns, data, - createSite + createSite, + onRefresh, + isRefreshing }: DataTableProps) { const t = useTranslations(); @@ -27,6 +31,8 @@ export function SitesDataTable({ searchColumn="name" onAdd={createSite} addButtonText={t('siteAdd')} + onRefresh={onRefresh} + isRefreshing={isRefreshing} defaultSort={{ id: "name", desc: false diff --git a/src/app/[orgId]/settings/sites/SitesSplashCard.tsx b/src/app/[orgId]/settings/sites/SitesSplashCard.tsx index 7484a15c..35d7bd83 100644 --- a/src/app/[orgId]/settings/sites/SitesSplashCard.tsx +++ b/src/app/[orgId]/settings/sites/SitesSplashCard.tsx @@ -5,10 +5,12 @@ import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { ArrowRight, DockIcon as Docker, Globe, Server, X } from "lucide-react"; import Link from "next/link"; +import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from 'next-intl'; export const SitesSplashCard = () => { const [isDismissed, setIsDismissed] = useState(true); + const { env } = useEnvContext(); const key = "sites-splash-card-dismissed"; const t = useTranslations(); @@ -62,7 +64,7 @@ export const SitesSplashCard = () => {
diff --git a/src/app/[orgId]/settings/sites/SitesTable.tsx b/src/app/[orgId]/settings/sites/SitesTable.tsx index 06ecadcb..8387ab7c 100644 --- a/src/app/[orgId]/settings/sites/SitesTable.tsx +++ b/src/app/[orgId]/settings/sites/SitesTable.tsx @@ -1,6 +1,6 @@ "use client"; -import { ColumnDef } from "@tanstack/react-table"; +import { Column, ColumnDef } from "@tanstack/react-table"; import { SitesDataTable } from "./SitesDataTable"; import { DropdownMenu, @@ -19,16 +19,16 @@ import { import Link from "next/link"; import { useRouter } from "next/navigation"; import { AxiosResponse } from "axios"; -import { useState } from "react"; -import CreateSiteForm from "./CreateSiteForm"; +import { useState, useEffect } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { toast } from "@app/hooks/useToast"; import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import CreateSiteFormModal from "./CreateSiteModal"; import { useTranslations } from "next-intl"; -import { parseDataSize } from '@app/lib/dataSize'; +import { parseDataSize } from "@app/lib/dataSize"; +import { Badge } from "@app/components/ui/badge"; +import { InfoPopup } from "@app/components/ui/info-popup"; export type SiteRow = { id: number; @@ -38,7 +38,10 @@ export type SiteRow = { mbOut: string; orgId: string; type: "newt" | "wireguard"; + newtVersion?: string; + newtUpdateAvailable?: boolean; online: boolean; + address?: string; }; type SitesTableProps = { @@ -52,18 +55,42 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); const [rows, setRows] = useState(sites); + const [isRefreshing, setIsRefreshing] = useState(false); const api = createApiClient(useEnvContext()); const t = useTranslations(); + const { env } = useEnvContext(); + + // Update local state when props change (e.g., after refresh) + useEffect(() => { + setRows(sites); + }, [sites]); + + const refreshData = async () => { + console.log("Data refreshed"); + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; const deleteSite = (siteId: number) => { api.delete(`/site/${siteId}`) .catch((e) => { - console.error(t('siteErrorDelete'), e); + console.error(t("siteErrorDelete"), e); toast({ variant: "destructive", - title: t('siteErrorDelete'), - description: formatAxiosError(e, t('siteErrorDelete')) + title: t("siteErrorDelete"), + description: formatAxiosError(e, t("siteErrorDelete")) }); }) .then(() => { @@ -77,42 +104,6 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { }; const columns: ColumnDef[] = [ - { - id: "dots", - cell: ({ row }) => { - const siteRow = row.original; - const router = useRouter(); - - return ( - - - - - - - - {t('viewSettings')} - - - { - setSelectedSite(siteRow); - setIsDeleteModalOpen(true); - }} - > - {t('delete')} - - - - ); - } - }, { accessorKey: "name", header: ({ column }) => { @@ -123,7 +114,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('name')} + {t("name")} ); @@ -139,7 +130,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('online')} + {t("online")} ); @@ -154,14 +145,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { return (
- {t('online')} + {t("online")}
); } else { return (
- {t('offline')} + {t("offline")}
); } @@ -179,11 +170,19 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { onClick={() => column.toggleSorting(column.getIsSorted() === "asc") } + className="hidden md:flex whitespace-nowrap" > - {t('site')} + {t("site")} ); + }, + cell: ({ row }) => { + return ( +
+ {row.original.nice} +
+ ); } }, { @@ -196,13 +195,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('dataIn')} + {t("dataIn")} ); }, - sortingFn: (rowA, rowB) => - parseDataSize(rowA.original.mbIn) - parseDataSize(rowB.original.mbIn) + sortingFn: (rowA, rowB) => + parseDataSize(rowA.original.mbIn) - + parseDataSize(rowB.original.mbIn) }, { accessorKey: "mbOut", @@ -214,13 +214,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('dataOut')} + {t("dataOut")} ); }, sortingFn: (rowA, rowB) => - parseDataSize(rowA.original.mbOut) - parseDataSize(rowB.original.mbOut), + parseDataSize(rowA.original.mbOut) - + parseDataSize(rowB.original.mbOut) }, { accessorKey: "type", @@ -232,7 +233,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('connectionType')} + {t("connectionType")} ); @@ -242,8 +243,22 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { if (originalRow.type === "newt") { return ( -
- Newt +
+ +
+ Newt + {originalRow.newtVersion && ( + + v{originalRow.newtVersion} + + )} +
+
+ {originalRow.newtUpdateAvailable && ( + + )}
); } @@ -259,23 +274,68 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { if (originalRow.type === "local") { return (
- {t('local')} + {t("local")}
); } } }, + ...(env.flags.enableClients ? [{ + accessorKey: "address", + header: ({ column }: { column: Column }) => { + return ( + + ); + } + }] : []), { id: "actions", cell: ({ row }) => { const siteRow = row.original; return ( -
+
+ + + + + + + + {t("viewSettings")} + + + { + setSelectedSite(siteRow); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + - @@ -297,22 +357,21 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { dialog={

- {t('siteQuestionRemove', {selectedSite: selectedSite?.name || selectedSite?.id})} + {t("siteQuestionRemove", { + selectedSite: + selectedSite?.name || selectedSite?.id + })}

-

- {t('siteMessageRemove')} -

+

{t("siteMessageRemove")}

-

- {t('siteMessageConfirm')} -

+

{t("siteMessageConfirm")}

} - buttonText={t('siteConfirmDelete')} + buttonText={t("siteConfirmDelete")} onConfirm={async () => deleteSite(selectedSite!.id)} string={selectedSite.name} - title={t('siteDelete')} + title={t("siteDelete")} /> )} @@ -322,6 +381,8 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { createSite={() => router.push(`/${orgId}/settings/sites/create`) } + onRefresh={refreshData} + isRefreshing={isRefreshing} /> ); diff --git a/src/app/[orgId]/settings/sites/[niceId]/SiteInfoCard.tsx b/src/app/[orgId]/settings/sites/[niceId]/SiteInfoCard.tsx index 2803e987..6094f167 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/SiteInfoCard.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/SiteInfoCard.tsx @@ -10,12 +10,14 @@ import { InfoSectionTitle } from "@app/components/InfoSection"; import { useTranslations } from "next-intl"; +import { useEnvContext } from "@app/hooks/useEnvContext"; type SiteInfoCardProps = {}; export default function SiteInfoCard({}: SiteInfoCardProps) { const { site, updateSite } = useSiteContext(); const t = useTranslations(); + const { env } = useEnvContext(); const getConnectionTypeString = (type: string) => { if (type === "newt") { @@ -23,32 +25,34 @@ export default function SiteInfoCard({}: SiteInfoCardProps) { } else if (type === "wireguard") { return "WireGuard"; } else if (type === "local") { - return t('local'); + return t("local"); } else { - return t('unknown'); + return t("unknown"); } }; return ( - {t('siteInfo')} + {t("siteInfo")} - + {(site.type == "newt" || site.type == "wireguard") && ( <> - {t('status')} + + {t("status")} + {site.online ? (
- {t('online')} + {t("online")}
) : (
- {t('offline')} + {t("offline")}
)}
@@ -56,11 +60,22 @@ export default function SiteInfoCard({}: SiteInfoCardProps) { )} - {t('connectionType')} + + {t("connectionType")} + {getConnectionTypeString(site.type)} + + {env.flags.enableClients && ( + + Address + + {site.address?.split("/")[0]} + + + )}
diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index 7b97f99f..f92a5090 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -24,8 +24,7 @@ import { SettingsSectionTitle, SettingsSectionDescription, SettingsSectionBody, - SettingsSectionForm, - SettingsSectionFooter + SettingsSectionForm } from "@app/components/Settings"; import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; @@ -34,10 +33,17 @@ import { useState } from "react"; import { SwitchInput } from "@app/components/SwitchInput"; import { useTranslations } from "next-intl"; import Link from "next/link"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; const GeneralFormSchema = z.object({ name: z.string().nonempty("Name is required"), - dockerSocketEnabled: z.boolean().optional() + dockerSocketEnabled: z.boolean().optional(), + remoteSubnets: z.array( + z.object({ + id: z.string(), + text: z.string() + }) + ).optional() }); type GeneralFormValues = z.infer; @@ -45,9 +51,11 @@ type GeneralFormValues = z.infer; export default function GeneralPage() { const { site, updateSite } = useSiteContext(); + const { env } = useEnvContext(); const api = createApiClient(useEnvContext()); const [loading, setLoading] = useState(false); + const [activeCidrTagIndex, setActiveCidrTagIndex] = useState(null); const router = useRouter(); const t = useTranslations(); @@ -56,7 +64,13 @@ export default function GeneralPage() { resolver: zodResolver(GeneralFormSchema), defaultValues: { name: site?.name, - dockerSocketEnabled: site?.dockerSocketEnabled ?? false + dockerSocketEnabled: site?.dockerSocketEnabled ?? false, + remoteSubnets: site?.remoteSubnets + ? site.remoteSubnets.split(',').map((subnet, index) => ({ + id: subnet.trim(), + text: subnet.trim() + })) + : [] }, mode: "onChange" }); @@ -67,7 +81,8 @@ export default function GeneralPage() { await api .post(`/site/${site?.siteId}`, { name: data.name, - dockerSocketEnabled: data.dockerSocketEnabled + dockerSocketEnabled: data.dockerSocketEnabled, + remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || '' }) .catch((e) => { toast({ @@ -82,7 +97,8 @@ export default function GeneralPage() { updateSite({ name: data.name, - dockerSocketEnabled: data.dockerSocketEnabled + dockerSocketEnabled: data.dockerSocketEnabled, + remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || '' }); toast({ @@ -125,12 +141,47 @@ export default function GeneralPage() { - - {t("siteNameDescription")} - )} /> + + ( + + {t("remoteSubnets")} + + { + form.setValue( + "remoteSubnets", + newSubnets as Tag[] + ); + }} + validateTag={(tag) => { + // Basic CIDR validation regex + const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/; + return cidrRegex.test(tag); + }} + allowDuplicates={false} + sortTags={true} + /> + + + {t("remoteSubnetsDescription")} + + + + )} + /> + {site && site.type === "newt" && ( - - - - + +
+ +
); } diff --git a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx index 2eb3bd13..597cc852 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx @@ -5,16 +5,7 @@ import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { authCookieHeader } from "@app/lib/api/cookies"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; -import Link from "next/link"; -import { ArrowLeft } from "lucide-react"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator -} from "@app/components/ui/breadcrumb"; import SiteInfoCard from "./SiteInfoCard"; import { getTranslations } from "next-intl/server"; diff --git a/src/app/[orgId]/settings/sites/create/page.tsx b/src/app/[orgId]/settings/sites/create/page.tsx index 7cca7454..f84416a7 100644 --- a/src/app/[orgId]/settings/sites/create/page.tsx +++ b/src/app/[orgId]/settings/sites/create/page.tsx @@ -21,7 +21,7 @@ import { } from "@app/components/ui/form"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { z } from "zod"; -import { useEffect, useState } from "react"; +import { createElement, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Input } from "@app/components/ui/input"; @@ -42,6 +42,7 @@ import { FaFreebsd, FaWindows } from "react-icons/fa"; +import { SiNixos } from "react-icons/si"; import { Checkbox } from "@app/components/ui/checkbox"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { generateKeypair } from "../[niceId]/wireguardConfig"; @@ -55,15 +56,8 @@ import { import { toast } from "@app/hooks/useToast"; import { AxiosResponse } from "axios"; import { useParams, useRouter } from "next/navigation"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator -} from "@app/components/ui/breadcrumb"; -import Link from "next/link"; import { QRCodeCanvas } from "qrcode.react"; + import { useTranslations } from "next-intl"; type SiteType = "newt" | "wireguard" | "local"; @@ -81,6 +75,7 @@ type Commands = { windows: Record; docker: Record; podman: Record; + nixos: Record; }; const platforms = [ @@ -89,7 +84,8 @@ const platforms = [ "podman", "mac", "windows", - "freebsd" + "freebsd", + "nixos" ] as const; type Platform = (typeof platforms)[number]; @@ -105,22 +101,24 @@ export default function Page() { .object({ name: z .string() - .min(2, { message: t('nameMin', {len: 2}) }) + .min(2, { message: t("nameMin", { len: 2 }) }) .max(30, { - message: t('nameMax', {len: 30}) + message: t("nameMax", { len: 30 }) }), method: z.enum(["newt", "wireguard", "local"]), - copied: z.boolean() + copied: z.boolean(), + clientAddress: z.string().optional() }) .refine( (data) => { if (data.method !== "local") { - return data.copied; + // return data.copied; + return true; } return true; }, { - message: t('sitesConfirmCopy'), + message: t("sitesConfirmCopy"), path: ["copied"] } ); @@ -132,21 +130,29 @@ export default function Page() { >([ { id: "newt", - title: t('siteNewtTunnel'), - description: t('siteNewtTunnelDescription'), + title: t("siteNewtTunnel"), + description: t("siteNewtTunnelDescription"), disabled: true }, - { - id: "wireguard", - title: t('siteWg'), - description: t('siteWgDescription'), - disabled: true - }, - { - id: "local", - title: t('local'), - description: t('siteLocalDescription') - } + ...(env.flags.disableBasicWireguardSites + ? [] + : [ + { + id: "wireguard" as SiteType, + title: t("siteWg"), + description: t("siteWgDescription"), + disabled: true + } + ]), + ...(env.flags.disableLocalSites + ? [] + : [ + { + id: "local" as SiteType, + title: t("local"), + description: t("siteLocalDescription") + } + ]) ]); const [loadingPage, setLoadingPage] = useState(true); @@ -158,7 +164,7 @@ export default function Page() { const [newtId, setNewtId] = useState(""); const [newtSecret, setNewtSecret] = useState(""); const [newtEndpoint, setNewtEndpoint] = useState(""); - + const [clientAddress, setClientAddress] = useState(""); const [publicKey, setPublicKey] = useState(""); const [privateKey, setPrivateKey] = useState(""); const [wgConfig, setWgConfig] = useState(""); @@ -282,6 +288,14 @@ WantedBy=default.target` "Podman Run": [ `podman run -dit docker.io/fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}` ] + }, + nixos: { + x86_64: [ + `nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}` + ], + aarch64: [ + `nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}` + ] } }; setCommands(commands); @@ -301,6 +315,8 @@ WantedBy=default.target` return ["Podman Quadlet", "Podman Run"]; case "freebsd": return ["amd64", "arm64"]; + case "nixos": + return ["x86_64", "aarch64"]; default: return ["x64"]; } @@ -318,13 +334,15 @@ WantedBy=default.target` return "Podman"; case "freebsd": return "FreeBSD"; + case "nixos": + return "NixOS"; default: return "Linux"; } }; const getCommand = () => { - const placeholder = [t('unknownCommand')]; + const placeholder = [t("unknownCommand")]; if (!commands) { return placeholder; } @@ -362,6 +380,8 @@ WantedBy=default.target` return ; case "freebsd": return ; + case "nixos": + return ; default: return ; } @@ -369,20 +389,28 @@ WantedBy=default.target` const form = useForm({ resolver: zodResolver(createSiteFormSchema), - defaultValues: { name: "", copied: false, method: "newt" } + defaultValues: { + name: "", + copied: false, + method: "newt", + clientAddress: "" + } }); async function onSubmit(data: CreateSiteFormValues) { setCreateLoading(true); - let payload: CreateSiteBody = { name: data.name, type: data.method }; + let payload: CreateSiteBody = { + name: data.name, + type: data.method as "newt" | "wireguard" | "local" + }; if (data.method == "wireguard") { if (!siteDefaults || !wgConfig) { toast({ variant: "destructive", - title: t('siteErrorCreate'), - description: t('siteErrorCreateKeyPair') + title: t("siteErrorCreate"), + description: t("siteErrorCreateKeyPair") }); setCreateLoading(false); return; @@ -399,8 +427,8 @@ WantedBy=default.target` if (!siteDefaults) { toast({ variant: "destructive", - title: t('siteErrorCreate'), - description: t('siteErrorCreateDefaults') + title: t("siteErrorCreate"), + description: t("siteErrorCreateDefaults") }); setCreateLoading(false); return; @@ -411,7 +439,8 @@ WantedBy=default.target` subnet: siteDefaults.subnet, exitNodeId: siteDefaults.exitNodeId, secret: siteDefaults.newtSecret, - newtId: siteDefaults.newtId + newtId: siteDefaults.newtId, + address: clientAddress }; } @@ -422,7 +451,7 @@ WantedBy=default.target` .catch((e) => { toast({ variant: "destructive", - title: t('siteErrorCreate'), + title: t("siteErrorCreate"), description: formatAxiosError(e) }); }); @@ -448,14 +477,23 @@ WantedBy=default.target` ); if (!response.ok) { throw new Error( - t('newtErrorFetchReleases', {err: response.statusText}) + t("newtErrorFetchReleases", { + err: response.statusText + }) ); } const data = await response.json(); const latestVersion = data.tag_name; newtVersion = latestVersion; } catch (error) { - console.error(t('newtErrorFetchLatest', {err: error instanceof Error ? error.message : String(error)})); + console.error( + t("newtErrorFetchLatest", { + err: + error instanceof Error + ? error.message + : String(error) + }) + ); } const generatedKeypair = generateKeypair(); @@ -481,10 +519,12 @@ WantedBy=default.target` const newtId = data.newtId; const newtSecret = data.newtSecret; const newtEndpoint = data.endpoint; + const clientAddress = data.clientAddress; setNewtId(newtId); setNewtSecret(newtSecret); setNewtEndpoint(newtEndpoint); + setClientAddress(clientAddress); hydrateCommands( newtId, @@ -520,8 +560,8 @@ WantedBy=default.target` <>
@@ -539,7 +579,7 @@ WantedBy=default.target` - {t('siteInfo')} + {t("siteInfo")} @@ -555,7 +595,7 @@ WantedBy=default.target` render={({ field }) => ( - {t('name')} + {t("name")} - - {t('siteNameDescription')} - )} /> + {env.flags.enableClients && + form.watch("method") === + "newt" && ( + ( + + + Site Address + + + { + setClientAddress( + e + .target + .value + ); + field.onChange( + e + .target + .value + ); + }} + /> + + + + Specify the + IP address + of the host + for clients + to connect + to. + + + )} + /> + )} - - - - {t('tunnelType')} - - - {t('siteTunnelDescription')} - - - - { - form.setValue("method", value); - }} - cols={3} - /> - - + {tunnelTypes.length > 1 && ( + + + + {t("tunnelType")} + + + {t("siteTunnelDescription")} + + + + { + form.setValue("method", value); + }} + cols={3} + /> + + + )} {form.watch("method") === "newt" && ( <> - {t('siteNewtCredentials')} + {t("siteNewtCredentials")} - {t('siteNewtCredentialsDescription')} + {t( + "siteNewtCredentialsDescription" + )} - {t('newtEndpoint')} + {t("newtEndpoint")} - {t('newtId')} + {t("newtId")} - {t('newtSecretKey')} + {t("newtSecretKey")} - {t('siteCredentialsSave')} + {t("siteCredentialsSave")} - {t('siteCredentialsSaveDescription')} + {t( + "siteCredentialsSaveDescription" + )} -
- - ( - -
- { - form.setValue( - "copied", - e as boolean - ); - }} - /> - -
- -
- )} - /> - - + {/*
*/} + {/* */} + {/* ( */} + {/* */} + {/*
*/} + {/* { */} + {/* form.setValue( */} + {/* "copied", */} + {/* e as boolean */} + {/* ); */} + {/* }} */} + {/* /> */} + {/* */} + {/*
*/} + {/* */} + {/*
*/} + {/* )} */} + {/* /> */} + {/* */} + {/* */}
- - {t('siteInstallNewt')} + {t("siteInstallNewt")} - {t('siteInstallNewtDescription')} + {t("siteInstallNewtDescription")}

- {t('operatingSystem')} + {t("operatingSystem")}

{platforms.map((os) => ( @@ -720,7 +808,7 @@ WantedBy=default.target` ? "squareOutlinePrimary" : "squareOutline" } - className={`flex-1 min-w-[120px] ${platform === os ? "bg-primary/10" : ""}`} + className={`flex-1 min-w-[120px] ${platform === os ? "bg-primary/10" : ""} shadow-none`} onClick={() => { setPlatform(os); }} @@ -737,8 +825,8 @@ WantedBy=default.target` {["docker", "podman"].includes( platform ) - ? t('method') - : t('architecture')} + ? t("method") + : t("architecture")}

{getArchitectures().map( @@ -751,7 +839,7 @@ WantedBy=default.target` ? "squareOutlinePrimary" : "squareOutline" } - className={`flex-1 min-w-[120px] ${architecture === arch ? "bg-primary/10" : ""}`} + className={`flex-1 min-w-[120px] ${architecture === arch ? "bg-primary/10" : ""} shadow-none`} onClick={() => setArchitecture( arch @@ -765,7 +853,7 @@ WantedBy=default.target`

- {t('commands')} + {t("commands")}

- {t('WgConfiguration')} + {t("WgConfiguration")} - {t('WgConfigurationDescription')} + {t("WgConfigurationDescription")} @@ -810,53 +898,14 @@ WantedBy=default.target` - {t('siteCredentialsSave')} + {t("siteCredentialsSave")} - {t('siteCredentialsSaveDescription')} + {t( + "siteCredentialsSaveDescription" + )} - -
- - ( - -
- { - form.setValue( - "copied", - e as boolean - ); - }} - /> - -
- -
- )} - /> - -
)} @@ -870,15 +919,17 @@ WantedBy=default.target` router.push(`/${orgId}/settings/sites`); }} > - {t('cancel')} + {t("cancel")}
diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 401fb2e5..10bcad53 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -44,11 +44,14 @@ export default async function SitesPage(props: SitesPageProps) { name: site.name, id: site.siteId, nice: site.niceId.toString(), + address: site.address?.split("/")[0], mbIn: formatSize(site.megabytesIn || 0, site.type), mbOut: formatSize(site.megabytesOut || 0, site.type), orgId: params.orgId, type: site.type as any, - online: site.online + online: site.online, + newtVersion: site.newtVersion || undefined, + newtUpdateAvailable: site.newtUpdateAvailable || false, }; }); diff --git a/src/app/admin/api-keys/ApiKeysTable.tsx b/src/app/admin/api-keys/ApiKeysTable.tsx index 517505ef..02aead9e 100644 --- a/src/app/admin/api-keys/ApiKeysTable.tsx +++ b/src/app/admin/api-keys/ApiKeysTable.tsx @@ -46,11 +46,14 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { const deleteSite = (apiKeyId: string) => { api.delete(`/api-key/${apiKeyId}`) .catch((e) => { - console.error(t('apiKeysErrorDelete'), e); + console.error(t("apiKeysErrorDelete"), e); toast({ variant: "destructive", - title: t('apiKeysErrorDelete'), - description: formatAxiosError(e, t('apiKeysErrorDeleteMessage')) + title: t("apiKeysErrorDelete"), + description: formatAxiosError( + e, + t("apiKeysErrorDeleteMessage") + ) }); }) .then(() => { @@ -64,41 +67,6 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { }; const columns: ColumnDef[] = [ - { - id: "dots", - cell: ({ row }) => { - const apiKeyROw = row.original; - const router = useRouter(); - - return ( - - - - - - { - setSelected(apiKeyROw); - }} - > - {t('viewSettings')} - - { - setSelected(apiKeyROw); - setIsDeleteModalOpen(true); - }} - > - {t('delete')} - - - - ); - } - }, { accessorKey: "name", header: ({ column }) => { @@ -109,7 +77,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('name')} + {t("name")} ); @@ -117,7 +85,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { }, { accessorKey: "key", - header: t('key'), + header: t("key"), cell: ({ row }) => { const r = row.original; return {r.key}; @@ -125,7 +93,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { }, { accessorKey: "createdAt", - header: t('createdAt'), + header: t("createdAt"), cell: ({ row }) => { const r = row.original; return {moment(r.createdAt).format("lll")} ; @@ -136,13 +104,44 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { cell: ({ row }) => { const r = row.original; return ( -
- - - +
+ + + + + + { + setSelected(r); + }} + > + {t("viewSettings")} + + { + setSelected(r); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + +
+ + + +
); } @@ -161,24 +160,23 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { dialog={

- {t('apiKeysQuestionRemove', {selectedApiKey: selected?.name || selected?.id})} + {t("apiKeysQuestionRemove", { + selectedApiKey: + selected?.name || selected?.id + })}

- - {t('apiKeysMessageRemove')} - + {t("apiKeysMessageRemove")}

-

- {t('apiKeysMessageConfirm')} -

+

{t("apiKeysMessageConfirm")}

} - buttonText={t('apiKeysDeleteConfirm')} + buttonText={t("apiKeysDeleteConfirm")} onConfirm={async () => deleteSite(selected!.id)} string={selected.name} - title={t('apiKeysDelete')} + title={t("apiKeysDelete")} /> )} diff --git a/src/app/admin/api-keys/[apiKeyId]/layout.tsx b/src/app/admin/api-keys/[apiKeyId]/layout.tsx index 0d6f7bdb..7e9e579f 100644 --- a/src/app/admin/api-keys/[apiKeyId]/layout.tsx +++ b/src/app/admin/api-keys/[apiKeyId]/layout.tsx @@ -2,16 +2,7 @@ import { internal } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { authCookieHeader } from "@app/lib/api/cookies"; -import { SidebarSettings } from "@app/components/SidebarSettings"; -import Link from "next/link"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator -} from "@app/components/ui/breadcrumb"; import { GetApiKeyResponse } from "@server/routers/apiKeys"; import ApiKeyProvider from "@app/providers/ApiKeyProvider"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; diff --git a/src/app/admin/api-keys/create/page.tsx b/src/app/admin/api-keys/create/page.tsx index 5ca647c5..3b6aac82 100644 --- a/src/app/admin/api-keys/create/page.tsx +++ b/src/app/admin/api-keys/create/page.tsx @@ -25,21 +25,12 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Input } from "@app/components/ui/input"; import { InfoIcon } from "lucide-react"; import { Button } from "@app/components/ui/button"; -import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { AxiosResponse } from "axios"; -import { useParams, useRouter } from "next/navigation"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator -} from "@app/components/ui/breadcrumb"; -import Link from "next/link"; +import { useRouter } from "next/navigation"; import { CreateOrgApiKeyBody, CreateOrgApiKeyResponse @@ -108,7 +99,7 @@ export default function Page() { const copiedForm = useForm({ resolver: zodResolver(copiedFormSchema), defaultValues: { - copied: false + copied: true } }); @@ -299,54 +290,54 @@ export default function Page() { -

- {t('apiKeysInfo')} -

+ {/*

*/} + {/* {t('apiKeysInfo')} */} + {/*

*/} -
- - ( - -
- { - copiedForm.setValue( - "copied", - e as boolean - ); - }} - /> - -
- -
- )} - /> - - + {/*
*/} + {/* */} + {/* ( */} + {/* */} + {/*
*/} + {/* { */} + {/* copiedForm.setValue( */} + {/* "copied", */} + {/* e as boolean */} + {/* ); */} + {/* }} */} + {/* /> */} + {/* */} + {/*
*/} + {/* */} + {/*
*/} + {/* )} */} + {/* /> */} + {/* */} + {/* */} )} diff --git a/src/app/admin/idp/AdminIdpTable.tsx b/src/app/admin/idp/AdminIdpTable.tsx index c55a2b35..fa7de6da 100644 --- a/src/app/admin/idp/AdminIdpTable.tsx +++ b/src/app/admin/idp/AdminIdpTable.tsx @@ -43,14 +43,14 @@ export default function IdpTable({ idps }: Props) { try { await api.delete(`/idp/${idpId}`); toast({ - title: t('success'), - description: t('idpDeletedDescription') + title: t("success"), + description: t("idpDeletedDescription") }); setIsDeleteModalOpen(false); router.refresh(); } catch (e) { toast({ - title: t('error'), + title: t("error"), description: formatAxiosError(e), variant: "destructive" }); @@ -67,41 +67,6 @@ export default function IdpTable({ idps }: Props) { }; const columns: ColumnDef[] = [ - { - id: "dots", - cell: ({ row }) => { - const r = row.original; - - return ( - - - - - - - - {t('viewSettings')} - - - { - setSelectedIdp(r); - setIsDeleteModalOpen(true); - }} - > - {t('delete')} - - - - ); - } - }, { accessorKey: "idpId", header: ({ column }) => { @@ -128,7 +93,7 @@ export default function IdpTable({ idps }: Props) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('name')} + {t("name")} ); @@ -144,7 +109,7 @@ export default function IdpTable({ idps }: Props) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('type')} + {t("type")} ); @@ -162,9 +127,43 @@ export default function IdpTable({ idps }: Props) { const siteRow = row.original; return (
+ + + + + + + + {t("viewSettings")} + + + { + setSelectedIdp(siteRow); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + - @@ -186,22 +185,20 @@ export default function IdpTable({ idps }: Props) { dialog={

- {t('idpQuestionRemove', {name: selectedIdp.name})} + {t("idpQuestionRemove", { + name: selectedIdp.name + })}

- - {t('idpMessageRemove')} - -

-

- {t('idpMessageConfirm')} + {t("idpMessageRemove")}

+

{t("idpMessageConfirm")}

} - buttonText={t('idpConfirmDelete')} + buttonText={t("idpConfirmDelete")} onConfirm={async () => deleteIdp(selectedIdp.idpId)} string={selectedIdp.name} - title={t('idpDelete')} + title={t("idpDelete")} /> )} diff --git a/src/app/admin/idp/[idpId]/layout.tsx b/src/app/admin/idp/[idpId]/layout.tsx index 79b1e196..af64e440 100644 --- a/src/app/admin/idp/[idpId]/layout.tsx +++ b/src/app/admin/idp/[idpId]/layout.tsx @@ -4,17 +4,7 @@ import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { authCookieHeader } from "@app/lib/api/cookies"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; -import { ProfessionalContentOverlay } from "@app/components/ProfessionalContentOverlay"; -import Link from "next/link"; -import { ArrowLeft } from "lucide-react"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator -} from "@app/components/ui/breadcrumb"; import { getTranslations } from "next-intl/server"; interface SettingsLayoutProps { diff --git a/src/app/admin/idp/[idpId]/policies/PolicyTable.tsx b/src/app/admin/idp/[idpId]/policies/PolicyTable.tsx index 9c11f9b9..f68a00c7 100644 --- a/src/app/admin/idp/[idpId]/policies/PolicyTable.tsx +++ b/src/app/admin/idp/[idpId]/policies/PolicyTable.tsx @@ -140,7 +140,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props return (
); @@ -100,7 +108,7 @@ export default function UsersTable({ users }: Props) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('email')} + {t("email")} ); @@ -116,7 +124,7 @@ export default function UsersTable({ users }: Props) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('name')} + {t("name")} ); @@ -132,28 +140,85 @@ export default function UsersTable({ users }: Props) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('identityProvider')} + {t("identityProvider")} ); } }, + { + accessorKey: "twoFactorEnabled", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const userRow = row.original; + + return ( +
+ + {userRow.twoFactorEnabled || + userRow.twoFactorSetupRequested ? ( + + {t("enabled")} + + ) : ( + {t("disabled")} + )} + +
+ ); + } + }, { id: "actions", cell: ({ row }) => { const r = row.original; return ( <> -
+
+ + + + + + { + setSelected(r); + setIsDeleteModalOpen(true); + }} + > + {t("delete")} + + +
@@ -174,26 +239,27 @@ export default function UsersTable({ users }: Props) { dialog={

- {t('userQuestionRemove', {selectedUser: selected?.email || selected?.name || selected?.username})} + {t("userQuestionRemove", { + selectedUser: + selected?.email || + selected?.name || + selected?.username + })}

- - {t('userMessageRemove')} - + {t("userMessageRemove")}

-

- {t('userMessageConfirm')} -

+

{t("userMessageConfirm")}

} - buttonText={t('userDeleteConfirm')} + buttonText={t("userDeleteConfirm")} onConfirm={async () => deleteUser(selected!.id)} string={ selected.email || selected.name || selected.username } - title={t('userDeleteServer')} + title={t("userDeleteServer")} /> )} diff --git a/src/app/admin/users/[userId]/general/page.tsx b/src/app/admin/users/[userId]/general/page.tsx new file mode 100644 index 00000000..ae720a6f --- /dev/null +++ b/src/app/admin/users/[userId]/general/page.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Button } from "@app/components/ui/button"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; +import { useParams } from "next/navigation"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm +} from "@app/components/Settings"; +import { UserType } from "@server/types/UserTypes"; + +export default function GeneralPage() { + const { userId } = useParams(); + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + + const [loadingData, setLoadingData] = useState(true); + const [loading, setLoading] = useState(false); + const [twoFactorEnabled, setTwoFactorEnabled] = useState(false); + const [userType, setUserType] = useState(null); + + useEffect(() => { + // Fetch current user 2FA status + const fetchUserData = async () => { + setLoadingData(true); + try { + const response = await api.get(`/user/${userId}`); + if (response.status === 200) { + const userData = response.data.data; + setTwoFactorEnabled( + userData.twoFactorEnabled || + userData.twoFactorSetupRequested + ); + setUserType(userData.type); + } + } catch (error) { + console.error("Failed to fetch user data:", error); + toast({ + variant: "destructive", + title: t("userErrorDelete"), + description: formatAxiosError(error, t("userErrorDelete")) + }); + } + setLoadingData(false); + }; + + fetchUserData(); + }, [userId]); + + const handleTwoFactorToggle = (enabled: boolean) => { + setTwoFactorEnabled(enabled); + }; + + const handleSaveSettings = async () => { + setLoading(true); + + try { + console.log("twoFactorEnabled", twoFactorEnabled); + await api.post(`/user/${userId}/2fa`, { + twoFactorSetupRequested: twoFactorEnabled + }); + + setTwoFactorEnabled(twoFactorEnabled); + } catch (error) { + toast({ + variant: "destructive", + title: t("otpErrorEnable"), + description: formatAxiosError( + error, + t("otpErrorEnableDescription") + ) + }); + } finally { + setLoading(false); + } + }; + + if (loadingData) { + return null; + } + + return ( + <> + + + + + {t("general")} + + + {t("userDescription2")} + + + + + +
+ +
+
+
+
+
+ +
+ +
+ + ); +} diff --git a/src/app/admin/users/[userId]/layout.tsx b/src/app/admin/users/[userId]/layout.tsx new file mode 100644 index 00000000..062b40d8 --- /dev/null +++ b/src/app/admin/users/[userId]/layout.tsx @@ -0,0 +1,55 @@ +import { internal } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AdminGetUserResponse } from "@server/routers/user/adminGetUser"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { cache } from "react"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { getTranslations } from 'next-intl/server'; + +interface UserLayoutProps { + children: React.ReactNode; + params: Promise<{ userId: string }>; +} + +export default async function UserLayoutProps(props: UserLayoutProps) { + const params = await props.params; + + const { children } = props; + + const t = await getTranslations(); + + let user = null; + try { + const getUser = cache(async () => + internal.get>( + `/user/${params.userId}`, + await authCookieHeader() + ) + ); + const res = await getUser(); + user = res.data.data; + } catch { + redirect(`/admin/users`); + } + + const navItems = [ + { + title: t('general'), + href: "/admin/users/{userId}/general" + } + ]; + + return ( + <> + + + {children} + + + ); +} \ No newline at end of file diff --git a/src/app/admin/users/[userId]/page.tsx b/src/app/admin/users/[userId]/page.tsx new file mode 100644 index 00000000..edf5aaed --- /dev/null +++ b/src/app/admin/users/[userId]/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from "next/navigation"; + +export default async function UserPage(props: { + params: Promise<{ userId: string }>; +}) { + const { userId } = await props.params; + redirect(`/admin/users/${userId}/general`); +} \ No newline at end of file diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 1e29a311..e9673374 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -38,7 +38,9 @@ export default async function UsersPage(props: PageProps) { idpId: row.idpId, idpName: row.idpName || t('idpNameInternal'), dateCreated: row.dateCreated, - serverAdmin: row.serverAdmin + serverAdmin: row.serverAdmin, + twoFactorEnabled: row.twoFactorEnabled, + twoFactorSetupRequested: row.twoFactorSetupRequested }; }); diff --git a/src/app/auth/2fa/setup/page.tsx b/src/app/auth/2fa/setup/page.tsx new file mode 100644 index 00000000..64a6cf57 --- /dev/null +++ b/src/app/auth/2fa/setup/page.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from "@/components/ui/card"; +import TwoFactorSetupForm from "@app/components/TwoFactorSetupForm"; +import { useTranslations } from "next-intl"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; + +export default function Setup2FAPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const redirect = searchParams?.get("redirect"); + const email = searchParams?.get("email"); + + const t = useTranslations(); + + // Redirect to login if no email is provided + useEffect(() => { + if (!email) { + router.push("/auth/login"); + } + }, [email, router]); + + const handleComplete = () => { + console.log("2FA setup complete", redirect, email); + if (redirect) { + const cleanUrl = cleanRedirect(redirect); + console.log("Redirecting to:", cleanUrl); + router.push(cleanUrl); + } else { + router.push("/"); + } + }; + + return ( +
+ + + {t("otpSetup")} + + {t("adminEnabled2FaOnYourAccount", { email: email || "your account" })} + + + + + + +
+ ); +} diff --git a/src/app/auth/layout.tsx b/src/app/auth/layout.tsx index 05a65fce..d8763ba5 100644 --- a/src/app/auth/layout.tsx +++ b/src/app/auth/layout.tsx @@ -1,4 +1,5 @@ import ProfileIcon from "@app/components/ProfileIcon"; +import ThemeSwitcher from "@app/components/ThemeSwitcher"; import { Separator } from "@app/components/ui/separator"; import { priv } from "@app/lib/api"; import { verifySession } from "@app/lib/auth/verifySession"; @@ -23,6 +24,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { const getUser = cache(verifySession); const user = await getUser(); const t = await getTranslations(); + const hideFooter = true; const licenseStatusRes = await cache( async () => @@ -34,20 +36,18 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { return (
- {user && ( - -
- -
-
- )} +
+ +
{children}
{!( - licenseStatus.isHostLicensed && licenseStatus.isLicenseValid + hideFooter || ( + licenseStatus.isHostLicensed && + licenseStatus.isLicenseValid) ) && (