diff --git a/.dockerignore b/.dockerignore index 74bedb17..042dcf2f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -27,3 +27,4 @@ bruno/ LICENSE CONTRIBUTING.md dist +.git 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..179cd86d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,10 +6,6 @@ Please see the contribution and local development guide on the docs page before 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 - ### Licensing Considerations Please note that your contributions will be distributed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us. @@ -21,4 +17,4 @@ By creating this pull request, I grant the project maintainers an unlimited, perpetual license to use, modify, and redistribute these contributions under any terms they choose, including both the AGPLv3 and the Fossorial Commercial license terms. I represent that I have the right to grant this license for all contributed content. -``` +``` \ No newline at end of file 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 f074c380..0e0394b4 100644 --- a/Makefile +++ b/Makefile @@ -5,8 +5,8 @@ build-release: 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,8 @@ build-arm: build-x86: docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest . -build: - docker build -t fosrl/pangolin:latest -f Dockerfile . +build-sqlite: + docker build -t fosrl/pangolin:latest -f Dockerfile.sqlite . build-pg: docker build -t fosrl/pangolin:postgresql-latest -f Dockerfile.pg . diff --git a/README.md b/README.md index 8723542c..7de13050 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,16 @@ -

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 | @@ -36,22 +36,32 @@ _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 +75,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.fossorial.io/Getting%20Started/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.io/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). + 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..5a78ae5e 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -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/docker-compose.pgr.yml b/docker-compose.pgr.yml new file mode 100644 index 00000000..aeffc2cf --- /dev/null +++ b/docker-compose.pgr.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..49713379 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +services: + # Development application service + app: + build: + context: . + dockerfile: Dockerfile.dev + container_name: dev_pangolin + ports: + - "3000:3000" + - "3001:3001" + - "3002:3002" + 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 \ No newline at end of file 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/config/config.yml b/install/config/config.yml index 92448ca9..a7c02dc9 100644 --- a/install/config/config.yml +++ b/install/config/config.yml @@ -22,10 +22,14 @@ gerbil: start_port: 51820 base_endpoint: "{{.DashboardDomain}}" +orgs: + block_size: 24 + subnet_group: 100.89.138.0/20 + {{if .EnableEmail}} email: smtp_host: "{{.EmailSMTPHost}}" - smtp_port: {{.EmailSMTPPort}} + smtp_port: "{{.EmailSMTPPort}}" smtp_user: "{{.EmailSMTPUser}}" smtp_pass: "{{.EmailSMTPPass}}" no_reply: "{{.EmailNoReply}}" @@ -36,4 +40,4 @@ flags: disable_signup_without_invite: true disable_user_create_org: false allow_raw_resources: true - allow_base_domain_resources: true \ No newline at end of file + allow_base_domain_resources: true diff --git a/install/config/crowdsec/profiles.yaml b/install/config/crowdsec/profiles.yaml index 3796b47f..0632f51d 100644 --- a/install/config/crowdsec/profiles.yaml +++ b/install/config/crowdsec/profiles.yaml @@ -1,4 +1,4 @@ -name: captcha_remediation +iame: captcha_remediation filters: - Alert.Remediation == true && Alert.GetScope() == "Ip" && Alert.GetScenario() contains "http" decisions: diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json new file mode 100644 index 00000000..1416cede --- /dev/null +++ b/messages/cs-CZ.json @@ -0,0 +1,1277 @@ +{ + "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", + "siteNameDescription": "Toto je zobrazovaný název lokality.", + "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", + "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", + "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 first level of 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": "Enter your 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 for \"{userInput}\". 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", + "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" +} diff --git a/messages/de-DE.json b/messages/de-DE.json index 377ec94b..d139b61d 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -11,8 +11,9 @@ "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.", @@ -206,6 +207,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 +251,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", @@ -347,7 +349,7 @@ "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.", + "licenseSitesUsed": "{count, plural, =0 {# Seiten} one {# Seite} other {# Seiten}} 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", @@ -436,7 +438,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.", @@ -958,6 +960,8 @@ "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", @@ -1090,6 +1094,8 @@ "sidebarAllUsers": "Alle Benutzer", "sidebarIdentityProviders": "Identitätsanbieter", "sidebarLicense": "Lizenz", + "sidebarClients": "Kunden", + "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 +1108,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 +1120,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,10 +1135,89 @@ "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.", + "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 erste Ebene der 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": "Geben Sie Ihre Domain ein", + "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 für \"{userInput}\" 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", @@ -1142,13 +1227,51 @@ "securityKeyRemove": "Entfernen", "securityKeyLastUsed": "Zuletzt verwendet: {date}", "securityKeyNameLabel": "Name", - "securityKeyNamePlaceholder": "Geben Sie einen Namen für diesen Sicherheitsschlüssel ein", "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 Sicherheitsschlüssel anmelden", + "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." + "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", + "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" } diff --git a/messages/en-US.json b/messages/en-US.json index 2c81f4fd..75760164 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -10,7 +10,8 @@ "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} one {one organization} other {# organizations}}.", "componentsInvalidKey": "Invalid or expired license keys detected. Follow license terms to continue using all features.", @@ -206,6 +207,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", @@ -1107,6 +1109,8 @@ "sidebarAllUsers": "All Users", "sidebarIdentityProviders": "Identity Providers", "sidebarLicense": "License", + "sidebarClients": "Clients", + "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", @@ -1146,11 +1150,12 @@ "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.", - "passwordReset": "Password Reset", +"passwordReset": "Password Reset", "passwordResetAdminInstructions": "Clicking the button below will send a password reset email to the user. They will be able to set a new password using the link provided.", "passwordResetSent": "Password Reset Sent", "passwordResetSentDescription": "A password reset email has been sent to {email}.", @@ -1177,6 +1182,84 @@ "sendEmailNotification": "Send Email Notification", "linkCopied": "Link Copied", "linkCopiedDescription": "The reset link has been copied to your clipboard", + "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 first level of 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": "Enter your 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 for \"{userInput}\". 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", @@ -1185,16 +1268,15 @@ "securityKeyNameRequired": "Name is required", "securityKeyRemove": "Remove", "securityKeyLastUsed": "Last used: {date}", - "securityKeyNameLabel": "Name", - "securityKeyNamePlaceholder": "Enter a name for this security key", + "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": "Sign in with security key", + "securityKeyLogin": "Continue with security key", "securityKeyAuthError": "Failed to authenticate with security key", - "securityKeyRecommendation": "Tip: Register a backup security key on another device to ensure you always have access to your account.", + "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.", @@ -1205,5 +1287,34 @@ "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" + "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" } diff --git a/messages/es-ES.json b/messages/es-ES.json index 226c02b6..f7c208bb 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.", @@ -206,6 +207,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 +251,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 +349,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 +438,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 +960,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", @@ -1090,6 +1094,8 @@ "sidebarAllUsers": "Todos los usuarios", "sidebarIdentityProviders": "Proveedores de identidad", "sidebarLicense": "Licencia", + "sidebarClients": "Clientes", + "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 +1108,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 +1120,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,10 +1135,89 @@ "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.", + "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 su primer nivel de 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": "Ingresa tu 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 coincidentes para \"{userInput}\". Prueba con un dominio diferente o revisa la configuración de dominio de tu 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", @@ -1142,13 +1227,51 @@ "securityKeyRemove": "Eliminar", "securityKeyLastUsed": "Último uso: {date}", "securityKeyNameLabel": "Nombre", - "securityKeyNamePlaceholder": "Ingrese un nombre para esta llave de seguridad", "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": "Iniciar sesión con llave 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." + "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", + "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" } diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 4681f0cc..235c6a7c 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.", @@ -206,6 +207,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 +251,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 +349,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 +438,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 +960,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", @@ -1090,6 +1094,8 @@ "sidebarAllUsers": "Tous les utilisateurs", "sidebarIdentityProviders": "Fournisseurs d'identité", "sidebarLicense": "Licence", + "sidebarClients": "Clients", + "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 +1108,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 +1120,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,10 +1135,89 @@ "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.", + "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 son premier niveau de 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": "Entrez votre 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é pour \"{userInput}\". 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é", @@ -1142,13 +1227,51 @@ "securityKeyRemove": "Supprimer", "securityKeyLastUsed": "Dernière utilisation : {date}", "securityKeyNameLabel": "Nom", - "securityKeyNamePlaceholder": "Entrez un nom pour cette clé de sécurité", "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": "Se connecter avec une clé 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." + "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", + "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" } diff --git a/messages/it-IT.json b/messages/it-IT.json index 0af5e8e4..ce13ab23 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à.", @@ -206,6 +207,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 +251,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 +349,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 +438,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 +960,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", @@ -1090,6 +1094,8 @@ "sidebarAllUsers": "Tutti Gli Utenti", "sidebarIdentityProviders": "Fornitori Di Identità", "sidebarLicense": "Licenza", + "sidebarClients": "Clienti", + "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 +1108,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 +1120,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,10 +1135,89 @@ "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.", + "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 il suo primo livello di 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": "Inserisci il tuo 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 per \"{userInput}\". Prova un altro dominio o controlla 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", @@ -1142,13 +1227,51 @@ "securityKeyRemove": "Rimuovi", "securityKeyLastUsed": "Ultimo utilizzo: {date}", "securityKeyNameLabel": "Nome", - "securityKeyNamePlaceholder": "Inserisci un nome per questa chiave di sicurezza", "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": "Accedi con chiave 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." + "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", + "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" } diff --git a/messages/ko-KR.json b/messages/ko-KR.json index 62b1dfc1..1406a624 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -1,19 +1,34 @@ { "setupCreate": "조직, 사이트 및 리소스를 생성하십시오.", + "setupNewOrg": "새 조직", + "setupCreateOrg": "조직 생성", + "setupCreateResources": "리소스 생성", + "setupOrgName": "조직 이름", "orgDisplayName": "이것은 귀하의 조직의 표시 이름입니다.", - "setupIdentifierMessage": "이것은 귀하의 조직에 대한 고유 식별자입니다. 표시 이름과는 별개입니다.", - "componentsErrorNoMemberCreate": "현재 어떤 조직의 구성원도 아닙니다. 시작하려면 조직을 생성하세요.", - "componentsInvalidKey": "유효하지 않거나 만료된 라이센스 키가 감지되었습니다. 모든 기능을 계속 사용하려면 라이센스 조건을 따르십시오.", "orgId": "조직 ID", - "siteQuestionRemove": "조직에서 사이트 {selectedSite}를 제거하시겠습니까?", - "siteCreateDescription2": "아래 단계를 따라 새 사이트를 생성하고 연결하십시오", + "setupIdentifierMessage": "이것은 귀하의 조직에 대한 고유 식별자입니다. 표시 이름과는 별개입니다.", + "setupErrorIdentifier": "조직 ID가 이미 사용 중입니다. 다른 것을 선택해 주세요.", + "componentsErrorNoMemberCreate": "현재 어떤 조직의 구성원도 아닙니다. 시작하려면 조직을 생성하세요.", + "componentsErrorNoMember": "현재 어떤 조직의 구성원도 아닙니다.", + "welcome": "판골린에 오신 것을 환영합니다.", + "welcomeTo": "환영합니다", + "componentsCreateOrg": "조직 생성", + "componentsMember": "당신은 {count, plural, =0 {조직이 없습니다} one {하나의 조직} other {# 개의 조직}}의 구성원입니다.", + "componentsInvalidKey": "유효하지 않거나 만료된 라이센스 키가 감지되었습니다. 모든 기능을 계속 사용하려면 라이센스 조건을 따르십시오.", + "dismiss": "해제", "componentsLicenseViolation": "라이센스 위반: 이 서버는 {usedSites} 사이트를 사용하고 있으며, 이는 {maxSites} 사이트의 라이센스 한도를 초과합니다. 모든 기능을 계속 사용하려면 라이센스 조건을 따르십시오.", - "years": "연도", - "hours": "시간", - "days": "일", - "weeks": "주", - "months": "개월", + "componentsSupporterMessage": "{tier}로 판골린을 지원해 주셔서 감사합니다!", + "inviteErrorNotValid": "죄송하지만, 접근하려는 초대가 수락되지 않았거나 더 이상 유효하지 않은 것 같습니다.", + "inviteErrorUser": "죄송하지만, 접근하려는 초대가 이 사용자에게 해당되지 않는 것 같습니다.", + "inviteLoginUser": "올바른 사용자로 로그인했는지 확인하십시오.", + "inviteErrorNoUser": "죄송하지만, 접근하려는 초대가 존재하지 않는 사용자에 대한 것인 것 같습니다.", + "inviteCreateUser": "먼저 계정을 생성해 주세요.", + "goHome": "홈으로 가기", + "inviteLogInOtherUser": "다른 사용자로 로그인", + "createAnAccount": "계정 만들기", + "inviteNotAccepted": "초대가 수락되지 않음", "authCreateAccount": "시작하려면 계정을 생성하세요.", + "authNoAccount": "계정이 없으신가요?", "email": "이메일", "password": "비밀번호", "confirmPassword": "비밀번호 확인", @@ -34,31 +49,12 @@ "siteDelete": "사이트 삭제", "siteMessageRemove": "제거되면 사이트에 더 이상 접근할 수 없습니다. 사이트와 관련된 모든 리소스와 대상도 제거됩니다.", "siteMessageConfirm": "확인을 위해 아래에 사이트 이름을 입력해 주세요.", - "setupNewOrg": "새 조직", - "setupCreateOrg": "조직 생성", - "setupCreateResources": "리소스 생성", - "setupOrgName": "조직 이름", - "setupErrorIdentifier": "조직 ID가 이미 사용 중입니다. 다른 것을 선택해 주세요.", - "componentsErrorNoMember": "현재 어떤 조직의 구성원도 아닙니다.", - "welcome": "판골린에 오신 것을 환영합니다.", - "componentsCreateOrg": "조직 생성", - "componentsMember": "당신은 {count, plural, =0 {조직이 없습니다} one {하나의 조직} other {# 개의 조직}}의 구성원입니다.", - "componentsSupporterMessage": "{tier}로 판골린을 지원해 주셔서 감사합니다!", - "inviteErrorNotValid": "죄송하지만, 접근하려는 초대가 수락되지 않았거나 더 이상 유효하지 않은 것 같습니다.", - "inviteErrorUser": "죄송하지만, 접근하려는 초대가 이 사용자에게 해당되지 않는 것 같습니다.", - "inviteLoginUser": "올바른 사용자로 로그인했는지 확인하십시오.", - "inviteErrorNoUser": "죄송하지만, 접근하려는 초대가 존재하지 않는 사용자에 대한 것인 것 같습니다.", - "inviteCreateUser": "먼저 계정을 생성해 주세요.", - "goHome": "홈으로 가기", - "inviteLogInOtherUser": "다른 사용자로 로그인", - "createAnAccount": "계정 만들기", + "siteQuestionRemove": "조직에서 사이트 {selectedSite}를 제거하시겠습니까?", "siteManageSites": "사이트 관리", "siteDescription": "안전한 터널을 통해 네트워크에 연결할 수 있도록 허용", "siteCreate": "사이트 생성", - "inviteNotAccepted": "초대가 수락되지 않음", - "authNoAccount": "계정이 없으신가요?", + "siteCreateDescription2": "아래 단계를 따라 새 사이트를 생성하고 연결하십시오", "siteCreateDescription": "리소스를 연결하기 위해 새 사이트를 생성하십시오.", - "dismiss": "해제", "close": "닫기", "siteErrorCreate": "사이트 생성 오류", "siteErrorCreateKeyPair": "키 쌍 또는 사이트 기본값을 찾을 수 없습니다", @@ -104,15 +100,6 @@ "siteTunnelDescription": "사이트에 연결하는 방법을 결정하세요", "siteNewtCredentials": "Newt 자격 증명", "siteNewtCredentialsDescription": "이것이 Newt가 서버와 인증하는 방법입니다", - "orgPolicyDeletedDescription": "정책이 성공적으로 삭제되었습니다", - "actionCreateResourceRule": "리소스 규칙 생성", - "defaultMappingsUpdatedDescription": "기본 매핑이 성공적으로 업데이트되었습니다.", - "orgPoliciesAbout": "조직 정책에 대하여", - "orgPoliciesAboutDescription": "조직 정책은 사용자의 ID 토큰에 따라 조직에 대한 액세스를 제어하는 데 사용됩니다. ID 토큰에서 역할 및 조직 정보를 추출하기 위해 JMESPath 표현식을 지정할 수 있습니다.", - "orgPoliciesAboutDescriptionLink": "자세한 내용은 문서를 참조하십시오.", - "actionDeleteResourceRule": "리소스 규칙 삭제", - "defaultMappingsOptional": "기본 매핑(선택 사항)", - "signupError": "가입하는 동안 오류가 발생했습니다.", "siteCredentialsSave": "자격 증명 저장", "siteCredentialsSaveDescription": "이것은 한 번만 볼 수 있습니다. 안전한 장소에 복사해 두세요.", "siteInfo": "사이트 정보", @@ -144,7 +131,6 @@ "expireIn": "만료됨", "neverExpire": "만료되지 않음", "shareExpireDescription": "만료 시간은 링크가 사용 가능하고 리소스에 접근할 수 있는 기간입니다. 이 시간이 지나면 링크는 더 이상 작동하지 않으며, 이 링크를 사용한 사용자는 리소스에 대한 접근 권한을 잃게 됩니다.", - "pangolinLogoAlt": "판골린 로고", "shareSeeOnce": "이 링크는 한 번만 볼 수 있습니다. 반드시 복사해 두세요.", "shareAccessHint": "이 링크가 있는 누구나 리소스에 접근할 수 있습니다. 주의해서 공유하세요.", "shareTokenUsage": "액세스 토큰 사용 보기", @@ -166,10 +152,8 @@ "authentication": "인증", "protected": "보호됨", "notProtected": "보호되지 않음", - "inviteAlready": "초대받은 것 같습니다!", "resourceMessageRemove": "제거되면 리소스에 더 이상 접근할 수 없습니다. 리소스와 연결된 모든 대상도 제거됩니다.", "resourceMessageConfirm": "확인을 위해 아래에 리소스의 이름을 입력하세요.", - "tagsEnteredDescription": "입력한 태그는 다음과 같습니다.", "resourceQuestionRemove": "조직에서 리소스 {selectedResource}를 제거하시겠습니까?", "resourceHTTP": "HTTPS 리소스", "resourceHTTPDescription": "서브도메인 또는 기본 도메인을 사용하여 HTTPS를 통해 앱에 대한 요청을 프록시합니다.", @@ -183,7 +167,6 @@ "siteSelect": "사이트 선택", "siteSearch": "사이트 검색", "siteNotFound": "사이트를 찾을 수 없습니다.", - "otpEnable": "이중 인증 활성화", "siteSelectionDescription": "이 사이트는 리소스에 대한 연결을 제공합니다.", "resourceType": "리소스 유형", "resourceTypeDescription": "리소스에 접근하는 방법을 결정하세요", @@ -194,16 +177,13 @@ "baseDomain": "기본 도메인", "subdomnainDescription": "리소스에 접근할 수 있는 하위 도메인입니다.", "resourceRawSettings": "TCP/UDP 설정", - "otpDisable": "이중 인증 비활성화", "resourceRawSettingsDescription": "TCP/UDP를 통해 리소스에 접근하는 방법을 구성하세요.", "protocol": "프로토콜", "protocolSelect": "프로토콜 선택", "resourcePortNumber": "포트 번호", - "logout": "로그 아웃", "resourcePortNumberDescription": "요청을 프록시하기 위한 외부 포트 번호입니다.", "cancel": "취소", "resourceConfig": "구성 스니펫", - "inviteAlreadyDescription": "초대를 수락하려면 로그인하거나 계정을 생성해야 합니다.", "resourceConfigDescription": "TCP/UDP 리소스를 설정하기 위해 이 구성 스니펫을 복사하여 붙여넣으십시오.", "resourceAddEntrypoints": "Traefik: 엔트리포인트 추가", "resourceExposePorts": "Gerbil: Docker Compose에서 포트 노출", @@ -220,7 +200,6 @@ "proxy": "프록시", "rules": "규칙", "resourceSettingDescription": "리소스의 설정을 구성하세요.", - "sidebarApiKeys": "API 키", "resourceSetting": "{resourceName} 설정", "alwaysAllow": "항상 허용", "alwaysDeny": "항상 거부", @@ -228,6 +207,7 @@ "orgGeneralSettings": "조직 설정", "orgGeneralSettingsDescription": "조직 세부정보 및 구성을 관리하세요.", "saveGeneralSettings": "일반 설정 저장", + "saveSettings": "설정 저장", "orgDangerZone": "위험 구역", "orgDangerZoneDescription": "이 조직을 삭제하면 되돌릴 수 없습니다. 확실히 하세요.", "orgDelete": "조직 삭제", @@ -239,7 +219,6 @@ "orgUpdatedDescription": "조직이 업데이트되었습니다.", "orgErrorUpdate": "조직 업데이트에 실패했습니다.", "orgErrorUpdateMessage": "조직을 업데이트하는 동안 오류가 발생했습니다.", - "sidebarSettings": "설정", "orgErrorFetch": "조직을 가져오는 데 실패했습니다.", "orgErrorFetchMessage": "조직을 나열하는 동안 오류가 발생했습니다", "orgErrorDelete": "조직 삭제에 실패했습니다.", @@ -267,9 +246,13 @@ "inviteDescription": "다른 사용자에 대한 초대를 관리하세요", "inviteSearch": "초대 검색...", "minutes": "분", + "hours": "시간", + "days": "일", + "weeks": "주", + "months": "개월", + "years": "연도", "day": "{count, plural, one {#일} other {#일}}", "apiKeysTitle": "API 키 정보", - "signupQuestion": "이미 계정이 있습니까?", "apiKeysConfirmCopy2": "API 키를 복사했음을 확인해야 합니다.", "apiKeysErrorCreate": "API 키 생성 오류", "apiKeysErrorSetPermission": "권한 설정 오류", @@ -288,7 +271,6 @@ "apiKeysPermissionsErrorLoadingActions": "API 키 작업 로드 오류", "apiKeysPermissionsErrorUpdate": "권한 설정 오류", "apiKeysPermissionsUpdated": "권한이 업데이트되었습니다", - "login": "로그인", "apiKeysPermissionsUpdatedDescription": "권한이 업데이트되었습니다.", "apiKeysPermissionsGeneralSettings": "권한", "apiKeysPermissionsGeneralSettingsDescription": "이 API 키가 수행할 수 있는 작업 결정", @@ -330,7 +312,6 @@ "licenseErrorKeyLoad": "라이센스 키를 로드하는 데 실패했습니다.", "licenseErrorKeyLoadDescription": "라이센스 키 로드 중 오류가 발생했습니다.", "licenseErrorKeyDelete": "라이센스 키 삭제에 실패했습니다.", - "resourceNotFound": "리소스를 찾을 수 없습니다", "licenseErrorKeyDeleteDescription": "라이센스 키 삭제 중 오류가 발생했습니다.", "licenseKeyDeleted": "라이센스 키가 삭제되었습니다.", "licenseKeyDeletedDescription": "라이센스 키가 삭제되었습니다.", @@ -351,7 +332,6 @@ "licenseAgreement": "이 상자를 체크함으로써, 귀하는 귀하의 라이선스 키와 관련된 계층에 해당하는 라이선스 조건을 읽고 동의했음을 확인합니다.", "fossorialLicense": "Fossorial 상업 라이선스 및 구독 약관 보기", "licenseMessageRemove": "이 작업은 라이센스 키와 그에 의해 부여된 모든 관련 권한을 제거합니다.", - "sidebarAllUsers": "모든 사용자", "licenseMessageConfirm": "확인을 위해 아래에 라이센스 키를 입력하세요.", "licenseQuestionRemove": "라이센스 키 {selectedKey}를 삭제하시겠습니까?", "licenseKeyDelete": "라이센스 키 삭제", @@ -365,7 +345,6 @@ "licenseReckeckAll": "모든 키 재확인", "licenseSiteUsage": "사이트 사용량", "licenseSiteUsageDecsription": "이 라이센스를 사용하는 사이트 수를 확인하세요.", - "noResults": "결과를 찾을 수 없습니다.", "licenseNoSiteLimit": "라이선스가 없는 호스트를 사용하는 사이트 수에 제한이 없습니다.", "licensePurchase": "라이센스 구매", "licensePurchaseSites": "추가 사이트 구매", @@ -434,7 +413,6 @@ "idpErrorFetch": "신원 제공자를 가져오는 데 실패했습니다", "idpErrorFetchDescription": "신원 공급자를 가져오는 중 오류가 발생했습니다.", "userErrorExists": "사용자가 이미 존재합니다.", - "terabytes": "{count} TB", "userErrorExistsDescription": "이 사용자는 이미 조직의 구성원입니다.", "inviteError": "사용자 초대에 실패했습니다", "inviteErrorDescription": "사용자를 초대하는 동안 오류가 발생했습니다.", @@ -692,7 +670,6 @@ "resourceErrorTransferDescription": "리소스를 전송하는 동안 오류가 발생했습니다", "resourceTransferred": "리소스가 전송되었습니다.", "resourceTransferredDescription": "리소스가 성공적으로 전송되었습니다.", - "gigabytes": "{count} GB", "resourceErrorToggle": "리소스를 전환하는 데 실패했습니다.", "resourceErrorToggleDescription": "리소스를 업데이트하는 동안 오류가 발생했습니다.", "resourceVisibilityTitle": "가시성", @@ -794,8 +771,6 @@ "idpSubmit": "아이덴티티 공급자 생성", "orgPolicies": "조직 정책", "idpSettings": "{idpName} 설정", - "megabytes": "{count} MB", - "actionCheckOrgId": "ID 확인", "idpCreateSettingsDescription": "아이덴티티 공급자의 설정을 구성하십시오", "roleMapping": "역할 매핑", "orgMapping": "조직 매핑", @@ -806,7 +781,12 @@ "success": "성공", "orgPolicyAddedDescription": "정책이 성공적으로 추가되었습니다", "orgPolicyUpdatedDescription": "정책이 성공적으로 업데이트되었습니다.", - "tagsEntered": "입력된 태그", + "orgPolicyDeletedDescription": "정책이 성공적으로 삭제되었습니다", + "defaultMappingsUpdatedDescription": "기본 매핑이 성공적으로 업데이트되었습니다.", + "orgPoliciesAbout": "조직 정책에 대하여", + "orgPoliciesAboutDescription": "조직 정책은 사용자의 ID 토큰에 따라 조직에 대한 액세스를 제어하는 데 사용됩니다. ID 토큰에서 역할 및 조직 정보를 추출하기 위해 JMESPath 표현식을 지정할 수 있습니다.", + "orgPoliciesAboutDescriptionLink": "자세한 내용은 문서를 참조하십시오.", + "defaultMappingsOptional": "기본 매핑(선택 사항)", "defaultMappingsOptionalDescription": "조직에 대해 정의된 정책이 없을 때 기본 매핑이 사용됩니다. 여기에서 기본 역할 및 조직 매핑을 지정하여 대체할 수 있습니다.", "defaultMappingsRole": "기본 역할 매핑", "defaultMappingsRoleDescription": "이 표현식의 결과는 조직에서 정의된 역할 이름을 문자열로 반환해야 합니다.", @@ -843,6 +823,13 @@ "emailVerifyResendProgress": "재전송 중...", "emailVerifyResend": "코드를 받지 못하셨나요? 여기 클릭하여 재전송하세요", "passwordNotMatch": "비밀번호가 일치하지 않습니다.", + "signupError": "가입하는 동안 오류가 발생했습니다.", + "pangolinLogoAlt": "판골린 로고", + "inviteAlready": "초대받은 것 같습니다!", + "inviteAlreadyDescription": "초대를 수락하려면 로그인하거나 계정을 생성해야 합니다.", + "signupQuestion": "이미 계정이 있습니까?", + "login": "로그인", + "resourceNotFound": "리소스를 찾을 수 없습니다", "resourceNotFoundDescription": "접근하려는 리소스가 존재하지 않습니다.", "pincodeRequirementsLength": "PIN은 정확히 6자리여야 합니다", "pincodeRequirementsChars": "PIN은 숫자만 포함해야 합니다.", @@ -923,6 +910,12 @@ "usersAll": "모든 사용자", "license": "라이선스", "pangolinDashboard": "대시보드 - 판골린", + "noResults": "결과를 찾을 수 없습니다.", + "terabytes": "{count} TB", + "gigabytes": "{count} GB", + "megabytes": "{count} MB", + "tagsEntered": "입력된 태그", + "tagsEnteredDescription": "입력한 태그는 다음과 같습니다.", "tagsWarnCannotBeLessThanZero": "maxTags와 minTags는 0보다 작을 수 없습니다", "tagsWarnNotAllowedAutocompleteOptions": "자동 완성 옵션에 따라 태그가 허용되지 않습니다", "tagsWarnInvalid": "validateTag에 따라 유효하지 않은 태그입니다", @@ -960,10 +953,15 @@ "logoutError": "로그아웃 중 오류 발생", "signingAs": "로그인한 사용자", "serverAdmin": "서버 관리자", + "otpEnable": "이중 인증 활성화", + "otpDisable": "이중 인증 비활성화", + "logout": "로그 아웃", "licenseTierProfessionalRequired": "전문 에디션이 필요합니다.", "licenseTierProfessionalRequiredDescription": "이 기능은 Professional Edition에서만 사용할 수 있습니다.", "actionGetOrg": "조직 가져오기", "actionUpdateOrg": "조직 업데이트", + "actionUpdateUser": "사용자 업데이트", + "actionGetUser": "사용자 조회", "actionGetOrgUser": "조직 사용자 가져오기", "actionListOrgDomains": "조직 도메인 목록", "actionCreateSite": "사이트 생성", @@ -1000,13 +998,15 @@ "actionRemoveUser": "사용자 제거", "actionListUsers": "사용자 목록", "actionAddUserRole": "사용자 역할 추가", - "containersIn": "{siteName}의 컨테이너", "actionGenerateAccessToken": "액세스 토큰 생성", "actionDeleteAccessToken": "액세스 토큰 삭제", "actionListAccessTokens": "액세스 토큰 목록", + "actionCreateResourceRule": "리소스 규칙 생성", + "actionDeleteResourceRule": "리소스 규칙 삭제", "actionListResourceRules": "리소스 규칙 목록", "actionUpdateResourceRule": "리소스 규칙 업데이트", "actionListOrgs": "조직 목록", + "actionCheckOrgId": "ID 확인", "actionCreateOrg": "조직 생성", "actionDeleteOrg": "조직 삭제", "actionListApiKeys": "API 키 목록", @@ -1089,12 +1089,18 @@ "sidebarInvitations": "초대", "sidebarRoles": "역할", "sidebarShareableLinks": "공유 가능한 링크", + "sidebarApiKeys": "API 키", + "sidebarSettings": "설정", + "sidebarAllUsers": "모든 사용자", "sidebarIdentityProviders": "신원 공급자", "sidebarLicense": "라이선스", + "sidebarClients": "클라이언트", + "sidebarDomains": "도메인", "enableDockerSocket": "Docker 소켓 활성화", "enableDockerSocketDescription": "컨테이너 정보를 채우기 위해 Docker 소켓 검색을 활성화합니다. 소켓 경로는 Newt에 제공되어야 합니다.", "enableDockerSocketLink": "자세히 알아보기", "viewDockerContainers": "도커 컨테이너 보기", + "containersIn": "{siteName}의 컨테이너", "selectContainerDescription": "이 대상을 위한 호스트 이름으로 사용할 컨테이너를 선택하세요. 포트를 사용하려면 포트를 클릭하세요.", "containerName": "이름", "containerImage": "이미지", @@ -1129,8 +1135,143 @@ "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": "판골린 호스팅", + "fossorial": "지하 서식", + "completeAccountSetup": "계정 설정 완료", + "completeAccountSetupDescription": "시작하려면 비밀번호를 설정하세요", + "accountSetupSent": "이 이메일 주소로 계정 설정 코드를 보내드리겠습니다.", + "accountSetupCode": "설정 코드", + "accountSetupCodeDescription": "설정 코드를 확인하기 위해 이메일을 확인하세요.", + "passwordCreate": "비밀번호 생성", + "passwordCreateConfirm": "비밀번호 확인", + "accountSetupSubmit": "설정 코드 전송", + "completeSetup": "설정 완료", + "accountSetupSuccess": "계정 설정이 완료되었습니다! 판골린에 오신 것을 환영합니다!", + "documentation": "문서", + "saveAllSettings": "모든 설정 저장", + "settingsUpdated": "설정이 업데이트되었습니다", + "settingsUpdatedDescription": "모든 설정이 성공적으로 업데이트되었습니다", + "settingsErrorUpdate": "설정 업데이트 실패", + "settingsErrorUpdateDescription": "설정을 업데이트하는 동안 오류가 발생했습니다", + "sidebarCollapse": "줄이기", + "sidebarExpand": "확장하기", + "newtUpdateAvailable": "업데이트 가능", + "newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", + "domainPickerEnterDomain": "도메인 입력", + "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": "\"{userInput}\"에 해당하는 도메인을 찾을 수 없습니다. 다른 도메인을 시도하거나 조직의 도메인 설정을 확인하세요.", + "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 레코드", + "createDomainRecordNumber": "레코드 {number}", + "createDomainTxtRecords": "TXT 레코드", + "createDomainSaveTheseRecords": "이 레코드 저장", + "createDomainSaveTheseRecordsDescription": "이 DNS 레코드를 저장하여 이후에 다시 볼 수 없습니다.", + "createDomainDnsPropagation": "DNS 전파", + "createDomainDnsPropagationDescription": "DNS 변경 사항은 인터넷 전체에 전파되는 데 시간이 걸립니다. DNS 제공자와 TTL 설정에 따라 몇 분에서 48시간까지 걸릴 수 있습니다.", + "resourcePortRequired": "HTTP 리소스가 아닌 경우 포트 번호가 필요합니다", + "resourcePortNotAllowed": "HTTP 리소스에 대해 포트 번호를 설정하지 마세요" } diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 39aaa9b6..e756e281 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.", @@ -206,6 +207,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 +251,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 +349,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 +438,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 +960,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", @@ -1090,6 +1094,8 @@ "sidebarAllUsers": "Alle gebruikers", "sidebarIdentityProviders": "Identiteit aanbieders", "sidebarLicense": "Licentie", + "sidebarClients": "Cliënten", + "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 +1108,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 +1120,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,10 +1135,89 @@ "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.", + "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 eerste niveau van 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": "Voer je domein in", + "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 voor \"{userInput}\". Probeer een ander domein of controleer de domeininstellingen van je 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", @@ -1142,13 +1227,51 @@ "securityKeyRemove": "Verwijderen", "securityKeyLastUsed": "Laatst gebruikt: {date}", "securityKeyNameLabel": "Naam", - "securityKeyNamePlaceholder": "Voer een naam in voor deze beveiligingssleutel", "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": "Inloggen met beveiligingssleutel", + "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." + "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", + "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" } diff --git a/messages/pl-PL.json b/messages/pl-PL.json index e21902ea..966dea6a 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", @@ -138,7 +139,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 +207,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 +251,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 +349,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 +438,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 +960,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ę", @@ -1090,19 +1094,21 @@ "sidebarAllUsers": "Wszyscy użytkownicy", "sidebarIdentityProviders": "Dostawcy tożsamości", "sidebarLicense": "Licencja", + "sidebarClients": "Klienci", + "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 +1120,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,10 +1135,89 @@ "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.", + "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 pierwsza warstwa subdomen.", + "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": "Wprowadź swoją domenę", + "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 żadnych pasujących domen dla \"{userInput}\". 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", @@ -1142,7 +1227,6 @@ "securityKeyRemove": "Usuń", "securityKeyLastUsed": "Ostatnio używany: {date}", "securityKeyNameLabel": "Nazwa", - "securityKeyNamePlaceholder": "Wprowadź nazwę dla tego klucza bezpieczeństwa", "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", @@ -1150,5 +1234,44 @@ "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." + "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", + "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" } diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 1d1b9ba1..762689f9 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.", @@ -206,6 +207,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 +251,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 +349,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 +438,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 +960,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", @@ -1090,6 +1094,8 @@ "sidebarAllUsers": "Todos os usuários", "sidebarIdentityProviders": "Provedores de identidade", "sidebarLicense": "Tipo:", + "sidebarClients": "Clientes", + "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 +1108,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 +1120,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,10 +1135,89 @@ "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.", + "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 seu primeiro nível de 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": "Insira seu 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 para \"{userInput}\". Tente um domínio diferente ou verifique as configurações de 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", @@ -1142,13 +1227,51 @@ "securityKeyRemove": "Remover", "securityKeyLastUsed": "Último uso: {date}", "securityKeyNameLabel": "Nome", - "securityKeyNamePlaceholder": "Digite um nome para esta chave de segurança", "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": "Entrar com chave 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." + "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", + "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" } diff --git a/messages/ru-RU.json b/messages/ru-RU.json new file mode 100644 index 00000000..d72ad989 --- /dev/null +++ b/messages/ru-RU.json @@ -0,0 +1,1277 @@ +{ + "setupCreate": "Создайте свою организацию, сайт и ресурсы", + "setupNewOrg": "Новая организация", + "setupCreateOrg": "Создать организацию", + "setupCreateResources": "Создать ресурсы", + "setupOrgName": "Название организации", + "orgDisplayName": "Это отображаемое имя вашей организации.", + "orgId": "ID организации", + "setupIdentifierMessage": "Это уникальный идентификатор вашей организации. Он отличается от отображаемого имени.", + "setupErrorIdentifier": "ID организации уже занят. Выберите другой.", + "componentsErrorNoMemberCreate": "Вы пока не состоите ни в одной организации. Создайте организацию для начала работы.", + "componentsErrorNoMember": "Вы пока не состоите ни в одной организации.", + "welcome": "Welcome!", + "welcomeTo": "Welcome to", + "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": "Настройки сайта по умолчанию не найдены", + "siteNameDescription": "Отображаемое имя сайта.", + "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": "Save Settings", + "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": "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": "Существующим пользователям нужно будет войти только один раз для всех ресурсов с включенной этой опцией.", + "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": "Вы собираетесь удалить роль {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": "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": "При включении пользователи будут автоматически создаваться в системе при первом входе с возможностью сопоставления пользователей с ролями и организациями.", + "licenseBadge": "Professional", + "idpType": "Provider Type", + "idpTypeDescription": "Select the type of identity provider you want to configure", + "idpOidcConfigure": "OAuth2/OIDC Configuration", + "idpOidcConfigureDescription": "Настройте конечные точки и учётные данные поставщика OAuth2/OIDC", + "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": "После создания поставщика удостоверений вам нужно будет настроить URL обратного вызова в настройках вашего поставщика удостоверений. URL обратного вызова будет предоставлен после успешного создания.", + "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}", + "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": "Политики организации используются для контроля доступа к организациям на основе ID токена пользователя. Вы можете указать выражения JMESPath для извлечения информации о роли и организации из ID токена.", + "orgPoliciesAboutDescriptionLink": "See documentation, for more information.", + "defaultMappingsOptional": "Default Mappings (Optional)", + "defaultMappingsOptionalDescription": "Сопоставления по умолчанию используются, когда для организации не определена политика организации. Здесь вы можете указать сопоставления ролей и организаций по умолчанию.", + "defaultMappingsRole": "Default Role Mapping", + "defaultMappingsRoleDescription": "Результат этого выражения должен возвращать имя роли, как определено в организации, в виде строки.", + "defaultMappingsOrg": "Default Organization Mapping", + "defaultMappingsOrgDescription": "Это выражение должно возвращать ID организации или true для разрешения доступа пользователя к организации.", + "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": "Это 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": "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": "Подключение к {name}", + "idpConnectingToDescription": "Validating your identity", + "idpConnectingToProcess": "Connecting...", + "idpConnectingToFinished": "Connected", + "idpErrorConnectingTo": "Возникла проблема при подключении к {name}. Пожалуйста, свяжитесь с вашим администратором.", + "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} ТБ", + "gigabytes": "{count} ГБ", + "megabytes": "{count} МБ", + "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": "Приобретите ключ поддержки, чтобы помочь нам продолжать разработку Pangolin для сообщества. Ваш вклад позволяет нам уделять больше времени поддержке и добавлению новых функций в приложение для всех. Мы никогда не будем использовать это для платного доступа к функциям. Это отдельно от любой коммерческой версии.", + "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": "Эта функция доступна только в профессиональной версии.", + "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": "Обновить IDP", + "actionDeleteIdp": "Удалить IDP", + "actionListIdps": "Список IDP", + "actionGetIdp": "Получить IDP", + "actionCreateIdpOrg": "Создать политику IDP организации", + "actionDeleteIdpOrg": "Удалить политику IDP организации", + "actionListIdpOrgs": "Список организаций IDP", + "actionUpdateIdpOrg": "Обновить организацию IDP", + "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": "Two-Factor Authentication Disabled", + "otpRemoveSuccessMessage": "Двухфакторная аутентификация была отключена для вашей учётной записи. Вы можете включить её снова в любое время.", + "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": "Разрешить всё", + "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", + "sidebarDomains": "Domains", + "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": "Subnet is required", + "initialSetupTitle": "Начальная настройка сервера", + "initialSetupDescription": "Создайте первоначальную учётную запись администратора сервера. Может существовать только один администратор сервера. Вы всегда можете изменить эти учётные данные позже.", + "createAdminAccount": "Создать учётную запись администратора", + "setupErrorCreateAdmin": "Произошла ошибка при создании учётной записи администратора сервера.", + "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 first level of 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": "Enter your 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 for \"{userInput}\". 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", + "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" +} diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 085505b4..a547a44b 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.", @@ -206,6 +207,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 +251,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 +349,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 +438,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 +960,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", @@ -1090,6 +1094,8 @@ "sidebarAllUsers": "Tüm Kullanıcılar", "sidebarIdentityProviders": "Kimlik Sağlayıcılar", "sidebarLicense": "Lisans", + "sidebarClients": "Müşteriler", + "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 +1108,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 +1120,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,10 +1135,89 @@ "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.", + "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 alan adı ve onun ilk alt alan düzeyi.", + "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": "Alan adınızı girin", + "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": "\"{userInput}\" için uygun alan adı bulunamadı. Farklı bir alan adı deneyin veya organizasyonunuzun alan adı 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", @@ -1142,13 +1227,51 @@ "securityKeyRemove": "Kaldır", "securityKeyLastUsed": "Son kullanım: {date}", "securityKeyNameLabel": "İsim", - "securityKeyNamePlaceholder": "Bu güvenlik anahtarı için bir isim girin", "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 giriş yap", + "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." + "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ı", + "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ı" } diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 8076ebda..4f7d779e 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} 个。请遵守许可证条款以继续使用全部功能。", @@ -206,6 +207,7 @@ "orgGeneralSettings": "组织设置", "orgGeneralSettingsDescription": "管理您的机构详细信息和配置", "saveGeneralSettings": "保存常规设置", + "saveSettings": "保存设置", "orgDangerZone": "危险区域", "orgDangerZoneDescription": "一旦删除该组织,将无法恢复,请务必确认。", "orgDelete": "删除组织", @@ -249,7 +251,7 @@ "weeks": "周", "months": "月", "years": "年", - "day": "{count, plural, =1 {# 天} other {# 天}}", + "day": "{count, plural, other {# 天}}", "apiKeysTitle": "API 密钥", "apiKeysConfirmCopy2": "您必须确认您已复制 API 密钥。", "apiKeysErrorCreate": "创建 API 密钥出错", @@ -347,7 +349,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 +438,7 @@ "accessRoleSelect": "选择角色", "inviteEmailSentDescription": "一封电子邮件已经发送给用户,带有下面的访问链接。他们必须访问该链接才能接受邀请。", "inviteSentDescription": "用户已被邀请。他们必须访问下面的链接才能接受邀请。", - "inviteExpiresIn": "邀请将于 {days, plural, =1 {# 天} other {# 天}}", + "inviteExpiresIn": "邀请将在{days, plural, other {# 天}}后过期。", "idpTitle": "身份提供商", "idpSelect": "为外部用户选择身份提供商", "idpNotConfigured": "没有配置身份提供者。请在创建外部用户之前配置身份提供者。", @@ -958,6 +960,8 @@ "licenseTierProfessionalRequiredDescription": "此功能仅在专业版可用。", "actionGetOrg": "获取组织", "actionUpdateOrg": "更新组织", + "actionUpdateUser": "更新用户", + "actionGetUser": "获取用户", "actionGetOrgUser": "获取组织用户", "actionListOrgDomains": "列出组织域", "actionCreateSite": "创建站点", @@ -1090,6 +1094,8 @@ "sidebarAllUsers": "所有用户", "sidebarIdentityProviders": "身份提供商", "sidebarLicense": "证书", + "sidebarClients": "客户", + "sidebarDomains": "域", "enableDockerSocket": "启用停靠套接字", "enableDockerSocketDescription": "启用 Docker Socket 发现以填充容器信息。必须向 Newt 提供 Socket 路径。", "enableDockerSocketLink": "了解更多", @@ -1102,7 +1108,7 @@ "containerNetworks": "网络", "containerHostnameIp": "主机名/IP", "containerLabels": "标签", - "containerLabelsCount": "{count} label{s,plural,one{} other{s}}", + "containerLabelsCount": "{count, plural, other {# 标签}}", "containerLabelsTitle": "容器标签", "containerLabelEmpty": "", "containerPorts": "端口", @@ -1114,7 +1120,7 @@ "showStoppedContainers": "显示已停止的容器", "noContainersFound": "未找到容器。请确保Docker容器正在运行。", "searchContainersPlaceholder": "在 {count} 个容器中搜索...", - "searchResultsCount": "{count} result{s,plural,one{} other{s}}", + "searchResultsCount": "{count, plural, other {# 个结果}}", "filters": "筛选器", "filterOptions": "过滤器选项", "filterPorts": "端口", @@ -1129,10 +1135,89 @@ "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": "此域及其第一级子域。", + "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": "未找到 \"{userInput}\" 的匹配域。尝试其他域或检查您组织的域设置。", + "domainPickerOrganizationDomains": "组织域", + "domainPickerProvidedDomains": "提供的域", + "domainPickerSubdomain": "子域:{subdomain}", + "domainPickerNamespace": "命名空间:{namespace}", + "domainPickerShowMore": "显示更多", + "domainNotFound": "域未找到", + "domainNotFoundDescription": "此资源已禁用,因为该域不再在我们的系统中存在。请为此资源设置一个新域。", + "failed": "失败", + "createNewOrgDescription": "创建一个新组织", + "organization": "组织", + "port": "端口", "securityKeyManage": "管理安全密钥", "securityKeyDescription": "添加或删除用于无密码认证的安全密钥", "securityKeyRegister": "注册新的安全密钥", @@ -1142,13 +1227,51 @@ "securityKeyRemove": "删除", "securityKeyLastUsed": "上次使用:{date}", "securityKeyNameLabel": "名称", - "securityKeyNamePlaceholder": "为此安全密钥输入名称", "securityKeyRegisterSuccess": "安全密钥注册成功", "securityKeyRegisterError": "注册安全密钥失败", "securityKeyRemoveSuccess": "安全密钥删除成功", "securityKeyRemoveError": "删除安全密钥失败", "securityKeyLoadError": "加载安全密钥失败", - "securityKeyLogin": "使用安全密钥登录", + "securityKeyLogin": "使用安全密钥继续", "securityKeyAuthError": "使用安全密钥认证失败", - "securityKeyRecommendation": "考虑在其他设备上注册另一个安全密钥,以确保不会被锁定在您的账户之外。" + "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 记录", + "createDomainRecordNumber": "记录 {number}", + "createDomainTxtRecords": "TXT 记录", + "createDomainSaveTheseRecords": "保存这些记录", + "createDomainSaveTheseRecordsDescription": "务必保存这些 DNS 记录,因为您将无法再次查看它们。", + "createDomainDnsPropagation": "DNS 传播", + "createDomainDnsPropagationDescription": "DNS 更改可能需要一些时间才能在互联网上传播。这可能需要从几分钟到 48 小时,具体取决于您的 DNS 提供商和 TTL 设置。", + "resourcePortRequired": "非 HTTP 资源必须输入端口号", + "resourcePortNotAllowed": "HTTP 资源不应设置端口号" } diff --git a/package-lock.json b/package-lock.json index cb44266b..baec0b2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@radix-ui/react-switch": "1.2.5", "@radix-ui/react-tabs": "1.1.12", "@radix-ui/react-toast": "1.2.14", + "@radix-ui/react-tooltip": "^1.2.7", "@react-email/components": "0.3.1", "@react-email/render": "^1.1.2", "@react-email/tailwind": "1.2.1", @@ -60,6 +61,7 @@ "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", @@ -75,6 +77,7 @@ "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", @@ -95,7 +98,7 @@ "zod-validation-error": "3.5.2" }, "devDependencies": { - "@dotenvx/dotenvx": "1.47.3", + "@dotenvx/dotenvx": "1.47.6", "@esbuild-plugins/tsconfig-paths": "0.1.2", "@tailwindcss/postcss": "^4.1.10", "@types/better-sqlite3": "7.6.12", @@ -109,6 +112,7 @@ "@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", @@ -198,6 +202,16 @@ "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", @@ -250,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.28.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", - "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", + "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": { @@ -313,9 +317,9 @@ } }, "node_modules/@dotenvx/dotenvx": { - "version": "1.47.3", - "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.47.3.tgz", - "integrity": "sha512-V0jxoEgyTrP6INJYBXxR6qkaS1qUXmrWTz7FZVx706TgXnMnR7LVRi5Bf9z/o0UmZlkavJD13PLediPi4QvUTQ==", + "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": { @@ -344,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": { @@ -359,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": { @@ -380,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": { @@ -1589,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" ], @@ -1607,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" ], @@ -1629,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" ], @@ -1649,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" ], @@ -1665,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" ], @@ -1681,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" ], @@ -1697,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" ], @@ -1713,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" ], @@ -1729,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" ], @@ -1745,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" ], @@ -1761,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" ], @@ -1777,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" ], @@ -1795,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" ], @@ -1817,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" ], @@ -1839,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" ], @@ -1861,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" ], @@ -1883,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" ], @@ -1905,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" @@ -1928,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" ], @@ -1947,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" ], @@ -1966,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" ], @@ -1984,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", @@ -2026,7 +2058,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, "license": "ISC", "dependencies": { "minipass": "^7.0.4" @@ -2057,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": { @@ -2081,15 +2112,15 @@ "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz", - "integrity": "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==", + "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": { @@ -3810,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", @@ -4312,9 +4377,9 @@ } }, "node_modules/@simplewebauthn/browser": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.1.0.tgz", - "integrity": "sha512-WuHZ/PYvyPJ9nxSzgHtOEjogBhwJfC8xzYkPC+rR/+8chl/ft4ngjiK8kSU5HtRJfczupyOh33b25TjYbvwAcg==", + "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": { @@ -4687,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": { @@ -4774,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": { @@ -4855,9 +4920,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.0.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.12.tgz", - "integrity": "sha512-LtOrbvDf5ndC9Xi+4QZjVL0woFymF/xSTKZKPgrrl7H7XoeDvnD+E2IclKVDyaK9UM756W/3BXqSU+JEHopA9g==", + "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": { @@ -4874,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", @@ -4983,16 +5060,16 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.36.0.tgz", - "integrity": "sha512-lZNihHUVB6ZZiPBNgOQGSxUASI7UJWhT8nHyUGCnaQ28XFCw98IfrMCG3rUl1uwUWoAvodJQby2KTs79UTcrAg==", + "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.36.0", - "@typescript-eslint/type-utils": "8.36.0", - "@typescript-eslint/utils": "8.36.0", - "@typescript-eslint/visitor-keys": "8.36.0", + "@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", @@ -5006,7 +5083,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.36.0", + "@typescript-eslint/parser": "^8.37.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } @@ -5021,15 +5098,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.36.0.tgz", - "integrity": "sha512-FuYgkHwZLuPbZjQHzJXrtXreJdFMKl16BFYyRrLxDhWr6Qr7Kbcu2s1Yhu8tsiMXw1S0W1pjfFfYEt+R604s+Q==", + "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.36.0", - "@typescript-eslint/types": "8.36.0", - "@typescript-eslint/typescript-estree": "8.36.0", - "@typescript-eslint/visitor-keys": "8.36.0", + "@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": { @@ -5045,13 +5122,13 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.36.0.tgz", - "integrity": "sha512-JAhQFIABkWccQYeLMrHadu/fhpzmSQ1F1KXkpzqiVxA/iYI6UnRt2trqXHt1sYEcw1mxLnB9rKMsOxXPxowN/g==", + "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.36.0", - "@typescript-eslint/types": "^8.36.0", + "@typescript-eslint/tsconfig-utils": "^8.37.0", + "@typescript-eslint/types": "^8.37.0", "debug": "^4.3.4" }, "engines": { @@ -5066,13 +5143,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.36.0.tgz", - "integrity": "sha512-wCnapIKnDkN62fYtTGv2+RY8FlnBYA3tNm0fm91kc2BjPhV2vIjwwozJ7LToaLAyb1ca8BxrS7vT+Pvvf7RvqA==", + "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.36.0", - "@typescript-eslint/visitor-keys": "8.36.0" + "@typescript-eslint/types": "8.37.0", + "@typescript-eslint/visitor-keys": "8.37.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5083,9 +5160,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.36.0.tgz", - "integrity": "sha512-Nhh3TIEgN18mNbdXpd5Q8mSCBnrZQeY9V7Ca3dqYvNDStNIGRmJA6dmrIPMJ0kow3C7gcQbpsG2rPzy1Ks/AnA==", + "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" @@ -5099,13 +5176,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.36.0.tgz", - "integrity": "sha512-5aaGYG8cVDd6cxfk/ynpYzxBRZJk7w/ymto6uiyUFtdCozQIsQWh7M28/6r57Fwkbweng8qAzoMCPwSJfWlmsg==", + "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.36.0", - "@typescript-eslint/utils": "8.36.0", + "@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" }, @@ -5122,9 +5200,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.36.0.tgz", - "integrity": "sha512-xGms6l5cTJKQPZOKM75Dl9yBfNdGeLRsIyufewnxT4vZTrjC0ImQT4fj8QmtJK84F58uSh5HVBSANwcfiXxABQ==", + "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" @@ -5135,15 +5213,15 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.36.0.tgz", - "integrity": "sha512-JaS8bDVrfVJX4av0jLpe4ye0BpAaUW7+tnS4Y4ETa3q7NoZgzYbN9zDQTJ8kPb5fQ4n0hliAt9tA4Pfs2zA2Hg==", + "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.36.0", - "@typescript-eslint/tsconfig-utils": "8.36.0", - "@typescript-eslint/types": "8.36.0", - "@typescript-eslint/visitor-keys": "8.36.0", + "@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", @@ -5215,15 +5293,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.36.0.tgz", - "integrity": "sha512-VOqmHu42aEMT+P2qYjylw6zP/3E/HvptRwdn/PZxyV27KhZg2IOszXod4NcXisWzPAGSS4trE/g4moNj6XmH2g==", + "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.36.0", - "@typescript-eslint/types": "8.36.0", - "@typescript-eslint/typescript-estree": "8.36.0" + "@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" @@ -5238,12 +5316,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.36.0.tgz", - "integrity": "sha512-vZrhV2lRPWDuGoxcmrzRZyxAggPL+qp3WzUrlZD+slFueDiYHxeBa34dUXPuC0RmGKzl4lS5kFJYvKCq9cnNDA==", + "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.36.0", + "@typescript-eslint/types": "8.37.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -5255,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" ], @@ -5268,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" ], @@ -5281,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" ], @@ -5294,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" ], @@ -5307,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" ], @@ -5320,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" ], @@ -5333,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" ], @@ -5346,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" ], @@ -5359,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" ], @@ -5372,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" ], @@ -5385,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" ], @@ -5398,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" ], @@ -5411,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" ], @@ -5424,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" ], @@ -5437,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" ], @@ -5450,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" ], @@ -5466,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" ], @@ -5479,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" ], @@ -5492,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" ], @@ -6166,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", @@ -6231,7 +6309,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -6378,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", @@ -6861,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", @@ -8740,7 +8835,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -9028,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", @@ -9488,7 +9606,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "dev": true, "license": "ISC", "engines": { "node": ">=16" @@ -9995,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", @@ -10352,7 +10481,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", - "dev": true, "license": "MIT", "dependencies": { "minipass": "^7.1.2" @@ -10365,7 +10493,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "dev": true, "license": "MIT", "bin": { "mkdirp": "dist/cjs/src/bin.js" @@ -10437,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" @@ -14027,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" @@ -14343,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", @@ -14689,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", @@ -15081,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, @@ -15099,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": { @@ -15467,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", @@ -15801,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" @@ -15854,7 +16021,6 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", - "dev": true, "license": "ISC", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -16256,15 +16422,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.36.0.tgz", - "integrity": "sha512-fTCqxthY+h9QbEgSIBfL9iV6CvKDFuoxg6bHPNpJ9HIUzS+jy2lCEyCmGyZRWEBSaykqcDPf1SJ+BfCI8DRopA==", + "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.36.0", - "@typescript-eslint/parser": "8.36.0", - "@typescript-eslint/utils": "8.36.0" + "@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" @@ -16313,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": { @@ -16500,7 +16667,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^3.1.1" @@ -16806,7 +16972,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" diff --git a/package.json b/package.json index 04851288..e769bab2 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@radix-ui/react-switch": "1.2.5", "@radix-ui/react-tabs": "1.1.12", "@radix-ui/react-toast": "1.2.14", + "@radix-ui/react-tooltip": "^1.2.7", "@react-email/components": "0.3.1", "@react-email/render": "^1.1.2", "@simplewebauthn/browser": "^13.1.0", @@ -113,7 +114,7 @@ "yargs": "18.0.0" }, "devDependencies": { - "@dotenvx/dotenvx": "1.47.3", + "@dotenvx/dotenvx": "1.47.6", "@esbuild-plugins/tsconfig-paths": "0.1.2", "@tailwindcss/postcss": "^4.1.10", "@types/better-sqlite3": "7.6.12", @@ -127,6 +128,7 @@ "@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", 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 d483c33f..7fd92f74 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -69,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", @@ -88,7 +93,10 @@ export enum ActionsEnum { listApiKeyActions = "listApiKeyActions", listApiKeys = "listApiKeys", getApiKey = "getApiKey", - resetUserPassword = "resetUserPassword" + resetUserPassword = "resetUserPassword", + 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 bc6d3d4d..c8a768a8 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -12,13 +12,18 @@ 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(), passwordResetTokenExpiryHours: integer("passwordResetTokenExpiryHours").notNull().default(1) + subnet: varchar("subnet") }); export const orgDomains = pgTable("orgDomains", { @@ -43,12 +48,17 @@ 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), + address: varchar("address"), + endpoint: varchar("endpoint"), + publicKey: varchar("publicKey"), + lastHolePunch: bigint("lastHolePunch", { mode: "number" }), + listenPort: integer("listenPort"), dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true) }); @@ -79,7 +89,6 @@ 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), @@ -108,7 +117,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", { @@ -133,6 +143,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" }) @@ -275,18 +286,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") @@ -493,6 +492,75 @@ 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: integer("bytesIn"), + megabytesOut: integer("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: integer("expiresAt").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, { @@ -539,7 +607,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; @@ -556,3 +623,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 51e3db08..124bd885 100644 --- a/server/db/sqlite/driver.ts +++ b/server/db/sqlite/driver.ts @@ -2,28 +2,26 @@ 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(); function createDb() { const sqlite = new Database(location); - sqlite.pragma('foreign_keys = ON'); - sqlite.exec('VACUUM;'); // This will initialize the database file with a valid SQLite header return DrizzleSqlite(sqlite, { schema }); } 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 15be0891..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"); @@ -7,7 +7,7 @@ const migrationsFolder = path.join("server/migrations"); const runMigrations = async () => { console.log("Running migrations..."); try { - await migrate(db as any, { + migrate(db as any, { migrationsFolder: migrationsFolder, }); console.log("Migrations completed successfully."); diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 456419af..c62fb53f 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -6,13 +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(), passwordResetTokenExpiryHours: integer("passwordResetTokenExpiryHours").notNull().default(1) + subnet: text("subnet") +}); + +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", { @@ -37,12 +51,19 @@ 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) @@ -77,7 +98,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), @@ -110,7 +130,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", { @@ -166,11 +187,54 @@ 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") @@ -195,6 +259,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() @@ -290,6 +362,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() @@ -548,6 +638,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 >; @@ -573,8 +665,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/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..caebff06 --- /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 a07bbc93..55b34543 100644 --- a/server/index.ts +++ b/server/index.ts @@ -9,6 +9,7 @@ import { createIntegrationApiServer } from "./integrationApiServer"; import config from "@server/lib/config"; async function startServers() { + await config.initServer(); await runSetupFunctions(); // Start all servers 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..0a964469 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 { @@ -85,22 +81,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 +127,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..2fffc282 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.7.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..f738b986 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,243 @@ 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(), + 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() + }) + .optional() + }) + .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) => { 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/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/auth/requestTotpSecret.ts b/server/routers/auth/requestTotpSecret.ts index 475e2dac..753867b6 100644 --- a/server/routers/auth/requestTotpSecret.ts +++ b/server/routers/auth/requestTotpSecret.ts @@ -112,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 index 4e642ece..dad3c692 100644 --- a/server/routers/auth/securityKey.ts +++ b/server/routers/auth/securityKey.ts @@ -107,7 +107,8 @@ async function clearChallenge(sessionId: string) { export const registerSecurityKeyBody = z.object({ name: z.string().min(1), - password: z.string().min(1) + password: z.string().min(1), + code: z.string().optional() }).strict(); export const verifyRegistrationBody = z.object({ @@ -143,7 +144,7 @@ export async function startRegistration( ); } - const { name, password } = parsedBody.data; + const { name, password, code } = parsedBody.data; const user = req.user as User; // Only allow internal users to use security keys @@ -163,6 +164,39 @@ export async function startRegistration( 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() diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index a248ff07..aabebbbe 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"; @@ -57,9 +56,6 @@ export async function signup( } const { name, email, password, inviteToken, inviteId } = parsedBody.data; - - logger.debug("signup", { name, email, password, inviteToken, inviteId }); - const passwordHash = await hashPassword(password); const userId = generateId(15); @@ -144,15 +140,21 @@ 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)); 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 b2dfaf7d..35aae8d1 100644 --- a/server/routers/auth/verifyTotp.ts +++ b/server/routers/auth/verifyTotp.ts @@ -75,6 +75,14 @@ export async function verifyTotp( ) ); user = res; + + const validPassword = await verifyPassword( + password, + user.passwordHash! + ); + if (!validPassword) { + return next(unauthorized()); + } } if (!user) { @@ -90,25 +98,6 @@ export async function verifyTotp( ) ); } - - // Add type guard to ensure password is defined - if (!password) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Password is required for two-factor authentication" - ) - ); - } - - const validPassword = await verifyPassword( - password, - user.passwordHash! - ); - if (!validPassword) { - return next(unauthorized()); - } - if (user.type !== UserType.Internal) { return next( createHttpError( diff --git a/server/routers/client/createClient.ts b/server/routers/client/createClient.ts new file mode 100644 index 00000000..34aef346 --- /dev/null +++ b/server/routers/client/createClient.ts @@ -0,0 +1,261 @@ +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(eq(clients.subnet, updatedSubnet)) + .limit(1); + + if (subnetExistsClients.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Subnet ${subnet} already exists` + ) + ); + } + + const subnetExistsSites = await db + .select() + .from(sites) + .where(eq(sites.address, updatedSubnet)) + .limit(1); + + if (subnetExistsSites.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Subnet ${subnet} already exists` + ) + ); + } + + 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..46f31b8c --- /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().optional() + }) + .strict(); + +async function query(clientId: number) { + // Get the client + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .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 } = parsedParams.data; + + const client = await query(clientId); + + 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") + ); + } +} \ No newline at end of file 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..b1459400 --- /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: "/site/{siteId}/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..87bb3c47 --- /dev/null +++ b/server/routers/client/updateClient.ts @@ -0,0 +1,225 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } 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"; + +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: {} +}); + +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; + } + + const site = await newtAddPeer(siteId, { + publicKey: client.pubKey, + allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client + endpoint: 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; + } + await olmAddPeer(client.clientId, { + siteId: siteId, + endpoint: site.endpoint, + publicKey: site.publicKey, + serverIP: site.address, + serverPort: site.listenPort + }); + } + + 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 + })) + ); + } + } + + // 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..b401409b --- /dev/null +++ b/server/routers/domain/createOrgDomain.ts @@ -0,0 +1,290 @@ +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"; + +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 = ["ns-east.fossorial.io", "ns-west.fossorial.io"]; + } else if (type === "cname") { + cnameRecords = [ + { + value: `${domainId}.cname.fossorial.io`, + baseDomain: baseDomain + }, + { + value: `_acme-challenge.${domainId}.cname.fossorial.io`, + 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 b13d46ac..11ce07b2 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); @@ -111,6 +120,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, @@ -731,42 +789,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", auth.verifyTotp); -authRouter.post("/2fa/request", auth.requestTotpSecret); -authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa); -authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail); +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", + 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, + 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 ); @@ -781,31 +975,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 ); @@ -826,33 +1064,59 @@ authRouter.get("/initial-setup-complete", auth.initialSetupComplete); // Security Key routes authRouter.post( - "/security-key/register/start", + "/security-key/register/start", + verifySessionUserMiddleware, rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes - max: 5, // Allow 5 security key registrations per 15 minutes per IP - keyGenerator: (req) => `securityKeyRegister:${req.ip}:${req.user?.userId}`, + 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 ${5} security keys every ${15} minutes. Please try again later.`; + 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() }), - verifySessionUserMiddleware, auth.startRegistration ); -authRouter.post("/security-key/register/verify", verifySessionUserMiddleware, auth.verifyRegistration); +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) => `securityKeyAuth:${req.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, auth.deleteSecurityKey); +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/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..c48f7551 --- /dev/null +++ b/server/routers/gerbil/updateHolePunch.ts @@ -0,0 +1,242 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { clients, 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"; +import { validateNewtSessionToken } from "@server/auth/sessions/newt"; +import { validateOlmSessionToken } from "@server/auth/sessions/olm"; + +// 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() +}); + +// 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 } = 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 + }); + } + } + + } else if (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)); + + // 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..6003c63d 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 @@ -307,6 +308,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 +413,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/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..8d79d4fd --- /dev/null +++ b/server/routers/newt/handleGetConfigMessage.ts @@ -0,0 +1,165 @@ +import { z } from "zod"; +import { MessageHandler } from "../ws"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { db } from "@server/db"; +import { clients, clientSites, Newt, sites } from "@server/db"; +import { eq } from "drizzle-orm"; +import { updatePeer } from "../olm/peers"; + +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; + } + + // 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; + } + if (!client.clients.online) { + return false; + } + + return true; + }) + .map(async (client) => { + // Add or update this peer on the olm if it is connected + try { + if (site.endpoint && site.publicKey) { + await updatePeer(client.clients.clientId, { + siteId: site.siteId, + endpoint: site.endpoint, + publicKey: site.publicKey, + serverIP: site.address, + serverPort: site.listenPort + }); + } + } catch (error) { + logger.error( + `Failed to add/update peer ${client.clients.pubKey} to newt ${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); + + // Build the configuration response + const configResponse = { + ipAddress: site.address, + peers: validPeers + }; + + 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/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..9f626a7b --- /dev/null +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -0,0 +1,181 @@ +import { db } 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 } = message.data; + 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 + } + }); + } + + 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: false + }) + .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 + const siteConfigurations = []; + + // 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) { + 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: client.endpoint + }); + } else { + logger.warn( + `Client ${client.clientId} has no endpoint, skipping peer addition` + ); + } + + // Add site configuration to the array + siteConfigurations.push({ + siteId: site.siteId, + endpoint: site.endpoint, + publicKey: site.publicKey, + serverIP: site.address, + serverPort: site.listenPort + }); + } + + // If we have no valid site configurations, don't send a connect message + 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..83a97a41 --- /dev/null +++ b/server/routers/olm/handleOlmRelayMessage.ts @@ -0,0 +1,58 @@ +import { db } from "@server/db"; +import { MessageHandler } from "../ws"; +import { clients, clientSites, Olm } from "@server/db"; +import { 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("Site not found or does not have exit node"); + 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("Site or client has no endpoint or listen port"); + return; + } + + const { siteId } = message.data; + + await db + .update(clientSites) + .set({ + isRelayed: true + }) + .where(eq(clientSites.clientId, olm.clientId)); + + // update the peer on the exit node + await updatePeer(siteId, client.pubKey, { + endpoint: "" // this removes the endpoint + }); + + 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..48a915aa --- /dev/null +++ b/server/routers/olm/peers.ts @@ -0,0 +1,92 @@ +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; + } +) { + 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 + } + }); + + 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; + } +) { + 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 + } + }); + + 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..9ac65115 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,8 @@ export async function createOrg( .insert(orgs) .values({ orgId, - name + name, + subnet }) .returning(); @@ -142,25 +169,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({ @@ -187,7 +214,7 @@ export async function createOrg( orgId: newOrg[0].orgId, roleId: roleId, isOwner: true - }); + }); } const memberRole = await trx diff --git a/server/routers/org/deleteOrg.ts b/server/routers/org/deleteOrg.ts index 5b2accce..41b491a2 100644 --- a/server/routers/org/deleteOrg.ts +++ b/server/routers/org/deleteOrg.ts @@ -89,6 +89,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 +104,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 @@ -131,6 +129,18 @@ export async function deleteOrg( 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 772f0cec..c0100059 100644 --- a/server/routers/org/index.ts +++ b/server/routers/org/index.ts @@ -7,3 +7,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..1c4ace3b 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({ @@ -34,9 +35,8 @@ const createHttpResourceSchema = z name: z.string().min(1).max(255), subdomain: z .string() - .optional() - .transform((val) => val?.toLowerCase()), - isBaseDomain: z.boolean().optional(), + .nullable() + .optional(), siteId: z.number(), http: z.boolean(), protocol: z.enum(["tcp", "udp"]), @@ -52,19 +52,6 @@ const createHttpResourceSchema = 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" - } - ); const createRawResourceSchema = z .object({ @@ -101,9 +88,12 @@ registry.registerPath({ body: { content: { "application/json": { - schema: createHttpResourceSchema.or( - createRawResourceSchema - ) + schema: + build == "oss" + ? createHttpResourceSchema.or( + createRawResourceSchema + ) + : createHttpResourceSchema } } } @@ -166,6 +156,14 @@ 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 +201,86 @@ 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() + ) + ); + } + if (parsedSubdomain.data.includes(".")) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Subdomain cannot contain a dot when using wildcard domains" + ) + ); + } + 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 +310,9 @@ async function createHttpResource( orgId, name, subdomain, - http, - protocol, - ssl: true, - isBaseDomain + http: true, + protocol: "tcp", + ssl: true }) .returning(); 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/updateResource.ts b/server/routers/resource/updateResource.ts index 68e38a3e..fda24f47 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({ @@ -34,13 +35,12 @@ const updateHttpResourceBodySchema = z .object({ name: z.string().min(1).max(255).optional(), subdomain: subdomainSchema - .optional() - .transform((val) => val?.toLowerCase()), + .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 +61,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) { @@ -134,9 +121,12 @@ registry.registerPath({ body: { content: { "application/json": { - schema: updateHttpResourceBodySchema.and( - updateRawResourceBodySchema - ) + schema: + build == "oss" + ? updateHttpResourceBodySchema.and( + updateRawResourceBodySchema + ) + : updateHttpResourceBodySchema } } } @@ -242,86 +232,124 @@ 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() + ) + ); + } + if (parsedSubdomain.data.includes(".")) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Subdomain cannot contain a dot when using wildcard domains" + ) + ); + } + 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..56811c6e 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -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,68 @@ export async function createSite( ); } + if (!org.subnet) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Organization with ID ${orgId} has no subnet defined` + ) + ); + } + + let updatedAddress = null; + if (address) { + 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(eq(sites.address, updatedAddress)) + .limit(1); + + if (addressExistsSites.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Subnet ${subnet} already exists` + ) + ); + } + + const addressExistsClients = await db + .select() + .from(sites) + .where(eq(sites.subnet, updatedAddress)) + .limit(1); + if (addressExistsClients.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Subnet ${subnet} already exists` + ) + ); + } + } + const niceId = await getUniqueSiteName(orgId); await db.transaction(async (trx) => { @@ -139,6 +214,7 @@ export async function createSite( exitNodeId, name, niceId, + address: updatedAddress || null, subnet, type, dockerSocketEnabled: type == "newt", @@ -154,6 +230,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/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 7f70dbc7..c876de22 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,30 +16,52 @@ 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, @@ -47,7 +70,12 @@ export async function traefikConfigProvider( }) .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 +168,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 +191,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 +208,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 +252,7 @@ export async function traefikConfigProvider( ], service: serviceName, rule: `Host(\`${fullDomain}\`)`, + priority: 100, ...(resource.ssl ? { tls } : {}) }; @@ -237,7 +263,8 @@ export async function traefikConfigProvider( ], middlewares: [redirectHttpsMiddlewareName], service: serviceName, - rule: `Host(\`${fullDomain}\`)` + rule: `Host(\`${fullDomain}\`)`, + priority: 100 }; } @@ -262,7 +289,8 @@ export async function traefikConfigProvider( } else if (site.type === "newt") { if ( !target.internalPort || - !target.method + !target.method || + !site.subnet ) { return false; } @@ -278,7 +306,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 +337,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,23 +347,22 @@ 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 const protocol = resource.protocol.toLowerCase(); @@ -371,7 +400,7 @@ export async function traefikConfigProvider( return false; } } else if (site.type === "newt") { - if (!target.internalPort) { + if (!target.internalPort || !site.subnet) { return false; } } @@ -386,7 +415,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/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/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/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 075c7d1b..bf9e75ef 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -30,6 +30,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 aa6649ea..68da0a27 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -23,7 +23,6 @@ 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 @@ -47,7 +46,6 @@ const migrations = [ { version: "1.5.0", run: m20 }, { 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; @@ -80,24 +78,24 @@ function backupDb() { } export async function runMigrations() { + if (process.env.DISABLE_MIGRATIONS) { + console.log("Migrations are disabled. Skipping..."); + return; + } try { const appVersion = APP_VERSION; - // Check if the database file exists and has tables - const hasTables = await db.select().from(versionMigrations).limit(1).catch(() => false); - - if (hasTables) { + if (exists) { await executeScripts(); } else { - console.log("Running initial migrations..."); + console.log("Running migrations..."); try { migrate(db, { - migrationsFolder: path.join(APP_PATH, "server", "migrations") + migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build }); - console.log("Initial migrations completed successfully."); + console.log("Migrations completed successfully."); } catch (error) { - console.error("Error running initial migrations:", error); - throw error; + console.error("Error running migrations:", error); } await db diff --git a/server/setup/scriptsPg/1.7.0.ts b/server/setup/scriptsPg/1.7.0.ts index 432ab1c1..c0245871 100644 --- a/server/setup/scriptsPg/1.7.0.ts +++ b/server/setup/scriptsPg/1.7.0.ts @@ -1,21 +1,163 @@ import { db } from "@server/db/pg"; - +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 PostgreSQL setup script ${version}...`); + console.log(`Running setup script ${version}...`); try { - // Add passwordResetTokenExpiryHours column to orgs table with default value of 1 - await db.execute(` - ALTER TABLE orgs ADD COLUMN passwordResetTokenExpiryHours INTEGER NOT NULL DEFAULT 1; + 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(`Added passwordResetTokenExpiryHours column to orgs table`); + + console.log(`Migrated database schema`); } catch (e) { - console.log("Error adding passwordResetTokenExpiryHours column to orgs table:"); + console.log("Unable to migrate database schema"); console.log(e); throw e; } - console.log(`${version} PostgreSQL migration complete`); -} \ No newline at end of file + 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/scriptsSqlite/1.4.0.ts b/server/setup/scriptsSqlite/1.4.0.ts deleted file mode 100644 index 3885b4bf..00000000 --- a/server/setup/scriptsSqlite/1.4.0.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { db } from "../../db/sqlite"; -import { sql } from "drizzle-orm"; - -const version = "1.4.0"; - -export default async function migration() { - console.log(`Running setup script ${version}...`); - - try { - db.transaction((trx) => { - trx.run(sql`CREATE TABLE 'securityKey' ( - '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 DELETE CASCADE - );`); - }); - - console.log(`Migrated database schema`); - } catch (e) { - console.log("Unable to migrate database schema"); - throw e; - } - - console.log(`${version} migration complete`); -} \ No newline at end of file diff --git a/server/setup/scriptsSqlite/1.7.0.ts b/server/setup/scriptsSqlite/1.7.0.ts index d597196b..4a7ef9cc 100644 --- a/server/setup/scriptsSqlite/1.7.0.ts +++ b/server/setup/scriptsSqlite/1.7.0.ts @@ -12,6 +12,7 @@ export default async function migration() { try { db.pragma("foreign_keys = OFF"); + db.transaction(() => { // Add passwordResetTokenExpiryHours column to orgs table with default value of 1 db.exec(` @@ -29,26 +30,173 @@ export default async function migration() { db.pragma("foreign_keys = OFF"); db.transaction(() => { db.exec(` - CREATE TABLE IF NOT EXISTS securityKey ( - credentialId TEXT PRIMARY KEY, - 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 DELETE CASCADE + 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 + ); + `); - })(); // executes the transaction immediately + + 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(`Created securityKey table`); + + console.log(`Migrated database schema`); } catch (e) { - console.error("Unable to create securityKey table"); - console.error(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`); -} \ No newline at end of file +} diff --git a/server/setup/scriptsSqlite/1.8.0.ts b/server/setup/scriptsSqlite/1.8.0.ts deleted file mode 100644 index 7777d50e..00000000 --- a/server/setup/scriptsSqlite/1.8.0.ts +++ /dev/null @@ -1,38 +0,0 @@ -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.pragma("foreign_keys = OFF"); - db.transaction(() => { - db.exec(` - CREATE TABLE IF NOT EXISTS securityKeyChallenge ( - sessionId TEXT PRIMARY KEY, - challenge TEXT NOT NULL, - securityKeyName TEXT, - userId TEXT, - expiresAt INTEGER NOT NULL, - FOREIGN KEY (userId) REFERENCES user(id) ON DELETE CASCADE - ); - - CREATE INDEX IF NOT EXISTS idx_securityKeyChallenge_expiresAt ON securityKeyChallenge(expiresAt); - `); - })(); // executes the transaction immediately - db.pragma("foreign_keys = ON"); - console.log(`Created securityKeyChallenge table`); - } catch (e) { - console.error("Unable to create securityKeyChallenge table"); - console.error(e); - throw e; - } - - console.log(`${version} migration complete`); -} \ No newline at end of file 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 d3e60bd9..da24eca2 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -9,7 +9,6 @@ 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"; @@ -100,11 +99,26 @@ export default async function OrgPage(props: OrgPageProps) { return ( - - - - - + + {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 948b1749..44e606f1 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,87 +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: "name", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const userRow = row.original; - return ( - {userRow.name || "-"} - ); - } - }, { accessorKey: "displayUsername", header: ({ column }) => { @@ -142,7 +61,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('email')} + {t("username")} ); @@ -158,7 +77,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('identityProvider')} + {t("identityProvider")} ); @@ -174,7 +93,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('role')} + {t("role")} ); @@ -198,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 && ( @@ -211,10 +186,12 @@ export default function UsersTable({ users: u }: UsersTableProps) { href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`} > @@ -232,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") ) }); }); @@ -243,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) => @@ -266,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 || @@ -286,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 d9af9910..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,8 +37,8 @@ export default async function UserLayoutProps(props: UserLayoutProps) { const navItems = [ { - title: t('accessControls'), - href: `/${params.orgId}/settings/access/users/${params.userId}/access-controls` + 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 0804b22e..4d723e6f 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -78,40 +78,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 +147,14 @@ export default function Page() { } }, [userType, env.email.emailEnabled, internalForm, externalForm]); + const userTypes: UserTypeOption[] = [ + { + id: "internal", + title: t("userTypeInternal"), + description: t("userTypeInternalDescription") + } + ]; + useEffect(() => { if (!userType) { return; @@ -157,10 +167,10 @@ export default function Page() { console.error(e); toast({ variant: "destructive", - title: t('accessRoleErrorFetch'), + title: t("accessRoleErrorFetch"), description: formatAxiosError( e, - t('accessRoleErrorFetchDescription') + t("accessRoleErrorFetchDescription") ) }); }); @@ -180,10 +190,10 @@ export default function Page() { console.error(e); toast({ variant: "destructive", - title: t('idpErrorFetch'), + title: t("idpErrorFetch"), description: formatAxiosError( e, - t('idpErrorFetchDescription') + t("idpErrorFetchDescription") ) }); }); @@ -191,6 +201,14 @@ export default function Page() { if (res?.status === 200) { setIdps(res.data.data.idps); setDataLoaded(true); + + if (res.data.data.idps.length) { + userTypes.push({ + id: "oidc", + title: t("userTypeExternal"), + description: t("userTypeExternalDescription") + }); + } } } @@ -220,16 +238,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 +257,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 +284,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 +295,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 +304,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 && userTypes.length > 1 ? ( + + + + {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 +562,16 @@ export default function Page() { - {t('idpTitle')} + {t("idpTitle")} - {t('idpSelect')} + {t("idpSelect")} {idps.length === 0 ? (

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

) : (
@@ -596,10 +625,10 @@ export default function Page() { - {t('userSettings')} + {t("userSettings")} - {t('userSettingsDescription')} + {t("userSettingsDescription")} @@ -620,7 +649,9 @@ export default function Page() { render={({ field }) => ( - {t('username')} + {t( + "username" + )}

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

@@ -643,7 +676,9 @@ export default function Page() { render={({ field }) => ( - {t('nameOptional')} + {t( + "emailOptional" + )} ( - {t('emailOptional')} + "nameOptional" + )} ( - {t('role')} + {t("role")} + + + + )} + /> + + ( + + Address + + + + + The address that this client will use for + connectivity. + + + + )} + /> + + ( + + Sites + { + form.setValue( + "siteIds", + newTags as [Tag, ...Tag[]] + ); + }} + enableAutocomplete={true} + autocompleteOptions={sites} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={true} + sortTags={true} + /> + + The client will have connectivity to the + selected sites. The sites must be configured + to accept client connections. + + + + )} + /> + + {olmCommand && ( +
+
+
+ +
+
+ + You will only be able to see the configuration + once. + +
+ )} + +
+ + +
+ + +
+ ); +} diff --git a/src/app/[orgId]/settings/sites/CreateSiteModal.tsx b/src/app/[orgId]/settings/clients/CreateClientsModal.tsx similarity index 75% rename from src/app/[orgId]/settings/sites/CreateSiteModal.tsx rename to src/app/[orgId]/settings/clients/CreateClientsModal.tsx index 8ecee55c..a8921cb1 100644 --- a/src/app/[orgId]/settings/sites/CreateSiteModal.tsx +++ b/src/app/[orgId]/settings/clients/CreateClientsModal.tsx @@ -12,26 +12,24 @@ import { CredenzaHeader, CredenzaTitle } from "@app/components/Credenza"; -import { SiteRow } from "./SitesTable"; -import CreateSiteForm from "./CreateSiteForm"; -import { useTranslations } from "next-intl"; +import CreateClientForm from "./CreateClientsForm"; +import { ClientRow } from "./ClientsTable"; -type CreateSiteFormProps = { +type CreateClientFormProps = { open: boolean; setOpen: (open: boolean) => void; - onCreate?: (site: SiteRow) => void; + onCreate?: (client: ClientRow) => void; orgId: string; }; -export default function CreateSiteFormModal({ +export default function CreateClientFormModal({ open, setOpen, onCreate, orgId -}: CreateSiteFormProps) { +}: CreateClientFormProps) { const [loading, setLoading] = useState(false); const [isChecked, setIsChecked] = useState(false); - const t = useTranslations(); return ( <> @@ -44,14 +42,14 @@ export default function CreateSiteFormModal({ > - {t('siteCreate')} + Create Client - {t('siteCreateDescription')} + Create a new client to connect to your sites
- setLoading(val)} setChecked={(val) => setIsChecked(val)} onCreate={onCreate} @@ -60,20 +58,20 @@ export default function CreateSiteFormModal({
- - - + + +
diff --git a/src/app/[orgId]/settings/clients/[clientId]/ClientInfoCard.tsx b/src/app/[orgId]/settings/clients/[clientId]/ClientInfoCard.tsx new file mode 100644 index 00000000..7117b4d5 --- /dev/null +++ b/src/app/[orgId]/settings/clients/[clientId]/ClientInfoCard.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { InfoIcon } from "lucide-react"; +import { useClientContext } from "@app/hooks/useClientContext"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; + +type ClientInfoCardProps = {}; + +export default function SiteInfoCard({}: ClientInfoCardProps) { + const { client, updateClient } = useClientContext(); + + return ( + + + Client Information + + + <> + + Status + + {client.online ? ( +
+
+ Online +
+ ) : ( +
+
+ Offline +
+ )} +
+
+ + + Address + + {client.subnet.split("/")[0]} + + +
+
+
+ ); +} diff --git a/src/app/[orgId]/settings/clients/[clientId]/general/page.tsx b/src/app/[orgId]/settings/clients/[clientId]/general/page.tsx new file mode 100644 index 00000000..e02e3aaa --- /dev/null +++ b/src/app/[orgId]/settings/clients/[clientId]/general/page.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useClientContext } from "@app/hooks/useClientContext"; +import { useForm } from "react-hook-form"; +import { toast } from "@app/hooks/useToast"; +import { useRouter } from "next/navigation"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm, + SettingsSectionFooter +} from "@app/components/Settings"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useEffect, useState } from "react"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { AxiosResponse } from "axios"; +import { ListSitesResponse } from "@server/routers/site"; + +const GeneralFormSchema = z.object({ + name: z.string().nonempty("Name is required"), + siteIds: z.array( + z.object({ + id: z.string(), + text: z.string() + }) + ) +}); + +type GeneralFormValues = z.infer; + +export default function GeneralPage() { + const { client, updateClient } = useClientContext(); + const api = createApiClient(useEnvContext()); + const [loading, setLoading] = useState(false); + const router = useRouter(); + const [sites, setSites] = useState([]); + const [clientSites, setClientSites] = useState([]); + const [activeSitesTagIndex, setActiveSitesTagIndex] = useState(null); + + const form = useForm({ + resolver: zodResolver(GeneralFormSchema), + defaultValues: { + name: client?.name, + siteIds: [] + }, + mode: "onChange" + }); + + // Fetch available sites and client's assigned sites + useEffect(() => { + const fetchSites = async () => { + try { + // Fetch all available sites + const res = await api.get>( + `/org/${client?.orgId}/sites/` + ); + + const availableSites = res.data.data.sites + .filter((s) => s.type === "newt" && s.subnet) + .map((site) => ({ + id: site.siteId.toString(), + text: site.name + })); + + setSites(availableSites); + + // Filter sites to only include those assigned to the client + const assignedSites = availableSites.filter((site) => + client?.siteIds?.includes(parseInt(site.id)) + ); + setClientSites(assignedSites); + // Set the default values for the form + form.setValue("siteIds", assignedSites); + } catch (e) { + toast({ + variant: "destructive", + title: "Failed to fetch sites", + description: formatAxiosError( + e, + "An error occurred while fetching sites." + ) + }); + } + }; + + if (client?.clientId) { + fetchSites(); + } + }, [client?.clientId, client?.orgId, api, form]); + + async function onSubmit(data: GeneralFormValues) { + setLoading(true); + + try { + await api.post(`/client/${client?.clientId}`, { + name: data.name, + siteIds: data.siteIds.map(site => site.id) + }); + + updateClient({ name: data.name }); + + toast({ + title: "Client updated", + description: "The client has been updated." + }); + + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: "Failed to update client", + description: formatAxiosError( + e, + "An error occurred while updating the client." + ) + }); + } finally { + setLoading(false); + } + } + + return ( + + + + + General Settings + + + Configure the general settings for this client + + + + + +
+ + ( + + Name + + + + + + This is the display name of the + client. + + + )} + /> + + ( + + Sites + { + form.setValue( + "siteIds", + newTags as [Tag, ...Tag[]] + ); + }} + enableAutocomplete={true} + autocompleteOptions={sites} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={true} + sortTags={true} + /> + + The client will have connectivity to the + selected sites. The sites must be configured + to accept client connections. + + + + )} + /> + + +
+
+ + + + +
+
+ ); +} \ 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/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..b798bf93 --- /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 33f5a71a..8656a608 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,6 +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 { AxiosResponse } from "axios"; +import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org"; +import { useRouter } from "next/navigation"; import { SettingsContainer, SettingsSection, @@ -37,22 +39,25 @@ import { useTranslations } from 'next-intl'; import { AxiosResponse } from "axios"; import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org"; import { useRouter } from "next/navigation"; +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); @@ -60,7 +65,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" }); @@ -71,12 +77,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(); } @@ -84,8 +88,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); @@ -110,8 +114,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")) }); } } @@ -120,21 +124,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(() => { @@ -152,32 +156,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")} -
@@ -191,22 +191,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..717e4d49 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx @@ -10,10 +10,14 @@ 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"; type ResourceInfoBoxType = {}; 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..efda61c3 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -66,6 +66,18 @@ import { } from "@server/routers/resource"; import { SwitchInput } from "@app/components/SwitchInput"; import { useTranslations } from "next-intl"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import DomainPicker from "@app/components/DomainPicker"; +import { Globe } from "lucide-react"; const TransferFormSchema = z.object({ siteId: z.number() @@ -80,6 +92,7 @@ export default function GeneralForm() { const { org } = useOrgContext(); const router = useRouter(); const t = useTranslations(); + const [editDomainOpen, setEditDomainOpen] = useState(false); const { env } = useEnvContext(); @@ -96,60 +109,45 @@ 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({ - 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() - }) - .refine( - (data) => { - if (!data.http) { - return z - .number() - .int() - .min(1) - .max(65535) - .safeParse(data.proxyPort).success; - } - return true; - }, - { - message: t("proxyErrorInvalidPort"), - path: ["proxyPort"] - } - ) - .refine( - (data) => { - if (data.http && !data.isBaseDomain) { - return subdomainSchema.safeParse(data.subdomain).success; - } - return true; - }, - { - message: t("subdomainErrorInvalid"), - path: ["subdomain"] - } - ); + const GeneralFormSchema = z.object({ + enabled: z.boolean(), + subdomain: z.string().optional(), + name: z.string().min(1).max(255), + domainId: z.string().optional(), + proxyPort: z.number().int().min(1).max(65535).optional() + }).refine((data) => { + // For non-HTTP resources, proxyPort should be defined + if (!resource.http) { + return data.proxyPort !== undefined; + } + // For HTTP resources, proxyPort should be undefined + return data.proxyPort === undefined; + }, { + message: !resource.http + ? "Port number is required for non-HTTP resources" + : "Port number should not be set for HTTP resources", + path: ["proxyPort"] + }); type GeneralFormValues = z.infer; 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 }, mode: "onChange" }); @@ -209,11 +207,11 @@ export default function GeneralForm() { .post>( `resource/${resource?.resourceId}`, { + enabled: data.enabled, name: data.name, - subdomain: data.http ? data.subdomain : undefined, - proxyPort: data.proxyPort, - isBaseDomain: data.http ? data.isBaseDomain : undefined, - domainId: data.http ? data.domainId : undefined + subdomain: data.subdomain, + domainId: data.domainId, + proxyPort: data.proxyPort } ) .catch((e) => { @@ -236,11 +234,11 @@ export default function GeneralForm() { const resource = res.data.data; updateResource({ + enabled: data.enabled, name: data.name, subdomain: data.subdomain, - proxyPort: data.proxyPort, - isBaseDomain: data.isBaseDomain, - fullDomain: resource.fullDomain + fullDomain: resource.fullDomain, + proxyPort: data.proxyPort }); router.refresh(); @@ -282,469 +280,338 @@ 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")} - - - - - - - - - - - - {t( - "sitesNotFound" - )} - - - {sites.map( - (site) => ( - { - transferForm.setValue( - "siteId", - site.siteId - ); - setOpen( - false - ); - }} - > - { - site.name - } - - - ) - )} - - - - - - + {!resource.http && ( + <> + ( + + + {t("resourcePortNumber")} + + + + field.onChange( + e.target.value + ? parseInt(e.target.value) + : undefined + ) + } + /> + + + + {t("resourcePortNumberDescription")} + + + )} + /> + )} - /> - - -
-
- - - -
-
+ {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..22e9d90c 100644 --- a/src/app/[orgId]/settings/resources/create/page.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -63,6 +63,7 @@ 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"; const baseResourceFormSchema = z.object({ name: z.string().min(1).max(255), @@ -70,17 +71,10 @@ 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().optional(), + subdomain: z.string().optional() +}); const tcpUdpResourceFormSchema = z.object({ protocol: z.string(), @@ -119,15 +113,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,11 +137,7 @@ export default function Page() { const httpForm = useForm({ resolver: zodResolver(httpResourceFormSchema), - defaultValues: { - subdomain: "", - domainId: "", - isBaseDomain: false - } + defaultValues: {} }); const tcpUdpForm = useForm({ @@ -170,20 +163,11 @@ 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" + protocol: "tcp", }); - } } else { const tcpUdpData = tcpUdpForm.getValues(); Object.assign(payload, { @@ -199,10 +183,10 @@ export default function Page() { .catch((e) => { toast({ variant: "destructive", - title: t('resourceErrorCreate'), + title: t("resourceErrorCreate"), description: formatAxiosError( e, - t('resourceErrorCreateDescription') + t("resourceErrorCreateDescription") ) }); }); @@ -219,11 +203,11 @@ export default function Page() { } } } 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 +226,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 +254,10 @@ export default function Page() { .catch((e) => { toast({ variant: "destructive", - title: t('domainsErrorFetch'), + title: t("domainsErrorFetch"), description: formatAxiosError( e, - t('domainsErrorFetchDescription') + t("domainsErrorFetchDescription") ) }); }); @@ -300,8 +284,8 @@ export default function Page() { <>
@@ -320,7 +304,7 @@ export default function Page() { - {t('resourceInfo')} + {t("resourceInfo")} @@ -336,7 +320,7 @@ export default function Page() { render={({ field }) => ( - {t('name')} + {t("name")} - {t('resourceNameDescription')} + {t( + "resourceNameDescription" + )} )} @@ -357,7 +343,7 @@ export default function Page() { render={({ field }) => ( - {t('site')} + {t("site")} - + - {t('siteNotFound')} + {t( + "siteNotFound" + )} {sites.map( @@ -433,7 +427,9 @@ export default function Page() { - {t('siteSelectionDescription')} + {t( + "siteSelectionDescription" + )} )} @@ -444,253 +440,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 +525,9 @@ export default function Page() { render={({ field }) => ( - {t('protocol')} + {t( + "protocol" + )} - {t('resourcePortNumberDescription')} + {t( + "resourcePortNumberDescription" + )} )} @@ -788,16 +615,19 @@ 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 +647,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 +696,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/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..8bab93e6 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(); 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..ba1f877c 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"; @@ -177,18 +176,18 @@ export default function GeneralPage() { - - - - + +
+ +
); } 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..454f609e 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"; @@ -55,15 +55,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"; @@ -105,22 +98,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 +127,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 +161,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(""); @@ -324,7 +327,7 @@ WantedBy=default.target` }; const getCommand = () => { - const placeholder = [t('unknownCommand')]; + const placeholder = [t("unknownCommand")]; if (!commands) { return placeholder; } @@ -369,20 +372,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 +410,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 +422,8 @@ WantedBy=default.target` subnet: siteDefaults.subnet, exitNodeId: siteDefaults.exitNodeId, secret: siteDefaults.newtSecret, - newtId: siteDefaults.newtId + newtId: siteDefaults.newtId, + address: clientAddress }; } @@ -422,7 +434,7 @@ WantedBy=default.target` .catch((e) => { toast({ variant: "destructive", - title: t('siteErrorCreate'), + title: t("siteErrorCreate"), description: formatAxiosError(e) }); }); @@ -448,14 +460,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 +502,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 +543,8 @@ WantedBy=default.target` <>
@@ -539,7 +562,7 @@ WantedBy=default.target` - {t('siteInfo')} + {t("siteInfo")} @@ -555,7 +578,7 @@ WantedBy=default.target` render={({ field }) => ( - {t('name')} + {t("name")} - {t('siteNameDescription')} + {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 +796,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 +813,8 @@ WantedBy=default.target` {["docker", "podman"].includes( platform ) - ? t('method') - : t('architecture')} + ? t("method") + : t("architecture")}

{getArchitectures().map( @@ -751,7 +827,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 +841,7 @@ WantedBy=default.target`

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

- {t('WgConfiguration')} + {t("WgConfiguration")} - {t('WgConfigurationDescription')} + {t("WgConfigurationDescription")} @@ -810,53 +886,14 @@ WantedBy=default.target` - {t('siteCredentialsSave')} + {t("siteCredentialsSave")} - {t('siteCredentialsSaveDescription')} + {t( + "siteCredentialsSaveDescription" + )} - -
- - ( - -
- { - form.setValue( - "copied", - e as boolean - ); - }} - /> - -
- -
- )} - /> - -
)} @@ -870,15 +907,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 (
)} @@ -422,7 +500,10 @@ export default function ResetPasswordForm({ {isSubmitting && ( )} - {t('passwordResetSubmit')} + {quickstart + ? t('accountSetupSubmit') + : t('passwordResetSubmit') + } )} diff --git a/src/app/auth/reset-password/page.tsx b/src/app/auth/reset-password/page.tsx index a0466208..f06c7c4c 100644 --- a/src/app/auth/reset-password/page.tsx +++ b/src/app/auth/reset-password/page.tsx @@ -13,6 +13,7 @@ export default async function Page(props: { redirect: string | undefined; email: string | undefined; token: string | undefined; + quickstart?: string | undefined; }>; }) { const searchParams = await props.searchParams; @@ -35,6 +36,9 @@ export default async function Page(props: { redirect={searchParams.redirect} tokenParam={searchParams.token} emailParam={searchParams.email} + quickstart={ + searchParams.quickstart === "true" ? true : undefined + } />

@@ -46,7 +50,7 @@ export default async function Page(props: { } className="underline" > - {t('loginBack')} + {t("loginBack")}

diff --git a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx index 3c4e8023..c77c9e0b 100644 --- a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx +++ b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx @@ -43,6 +43,7 @@ import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import Link from "next/link"; +import Image from "next/image"; import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext"; import { useTranslations } from "next-intl"; @@ -185,8 +186,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { setOtpState("otp_sent"); submitOtpForm.setValue("email", values.email); toast({ - title: t('otpEmailSent'), - description: t('otpEmailSentDescription') + title: t("otpEmailSent"), + description: t("otpEmailSentDescription") }); return; } @@ -202,7 +203,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { .catch((e) => { console.error(e); setWhitelistError( - formatAxiosError(e, t('otpEmailErrorAuthenticate')) + formatAxiosError(e, t("otpEmailErrorAuthenticate")) ); }) .then(() => setLoadingLogin(false)); @@ -227,7 +228,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { .catch((e) => { console.error(e); setPincodeError( - formatAxiosError(e, t('pincodeErrorAuthenticate')) + formatAxiosError(e, t("pincodeErrorAuthenticate")) ); }) .then(() => setLoadingLogin(false)); @@ -255,7 +256,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { .catch((e) => { console.error(e); setPasswordError( - formatAxiosError(e, t('passwordErrorAuthenticate')) + formatAxiosError(e, t("passwordErrorAuthenticate")) ); }) .finally(() => setLoadingLogin(false)); @@ -276,13 +277,23 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { } } + function getTitle() { + return t("authenticationRequired"); + } + + function getSubtitle(resourceName: string) { + return numMethods > 1 + ? t("authenticationMethodChoose", { name: props.resource.name }) + : t("authenticationRequest", { name: props.resource.name }); + } + return (
{!accessDenied ? (
- {t('poweredBy')}{" "} + {t("poweredBy")}{" "} - {t('authenticationRequired')} + {getTitle()} - {numMethods > 1 - ? t('authenticationMethodChoose', {name: props.resource.name}) - : t('authenticationRequest', {name: props.resource.name})} + {getSubtitle(props.resource.name)} @@ -329,19 +338,19 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { {props.methods.password && ( {" "} - {t('password')} + {t("password")} )} {props.methods.sso && ( {" "} - {t('user')} + {t("user")} )} {props.methods.whitelist && ( {" "} - {t('email')} + {t("email")} )} @@ -364,7 +373,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { render={({ field }) => ( - {t('pincodeInput')} + {t( + "pincodeInput" + )}
@@ -433,7 +444,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { disabled={loadingLogin} > - {t('pincodeSubmit')} + {t("pincodeSubmit")} @@ -459,7 +470,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { render={({ field }) => ( - {t('password')} + {t("password")} - {t('passwordSubmit')} + {t("passwordSubmit")} @@ -528,7 +539,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { render={({ field }) => ( - {t('email')} + {t("email")} - {t('otpEmailDescription')} + {t( + "otpEmailDescription" + )} @@ -559,7 +572,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { disabled={loadingLogin} > - {t('otpEmailSend')} + {t("otpEmailSend")} @@ -581,7 +594,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { render={({ field }) => ( - {t('otpEmail')} + {t( + "otpEmail" + )} - {t('otpEmailSubmit')} + {t("otpEmailSubmit")} @@ -634,7 +649,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { {supporterStatus?.visible && (
- {t('noSupportKey')} + {t("noSupportKey")}
)} diff --git a/src/app/auth/signup/SignupForm.tsx b/src/app/auth/signup/SignupForm.tsx index 9ae8450e..52fbe824 100644 --- a/src/app/auth/signup/SignupForm.tsx +++ b/src/app/auth/signup/SignupForm.tsx @@ -32,6 +32,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import Image from "next/image"; import { cleanRedirect } from "@app/lib/cleanRedirect"; import { useTranslations } from "next-intl"; +import BrandingLogo from "@app/components/BrandingLogo"; type SignupFormProps = { redirect?: string; @@ -58,7 +59,9 @@ export default function SignupForm({ }: SignupFormProps) { const router = useRouter(); - const api = createApiClient(useEnvContext()); + const { env } = useEnvContext(); + + const api = createApiClient({ env }); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -89,9 +92,7 @@ export default function SignupForm({ }) .catch((e) => { console.error(e); - setError( - formatAxiosError(e, t('signupError')) - ); + setError(formatAxiosError(e, t("signupError"))); }); if (res && res.status === 200) { @@ -118,27 +119,24 @@ export default function SignupForm({ setLoading(false); } + function getSubtitle() { + return t("authCreateAccount"); + } + return ( - - + +
- {t('pangolinLogoAlt')}
-
-

- {t('welcome')} -

-

- {t('authCreateAccount')} -

+
+

{getSubtitle()}

- +
( - {t('email')} + {t("email")} @@ -175,12 +173,9 @@ export default function SignupForm({ name="password" render={({ field }) => ( - {t('password')} + {t("password")} - + @@ -191,12 +186,11 @@ export default function SignupForm({ name="confirmPassword" render={({ field }) => ( - {t('confirmPassword')} + + {t("confirmPassword")} + - + @@ -210,7 +204,7 @@ export default function SignupForm({ )} diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index 242bf10b..debd7c58 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -6,7 +6,7 @@ import { Mail } from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; import { cache } from "react"; -import { getTranslations } from 'next-intl/server'; +import { getTranslations } from "next-intl/server"; export const dynamic = "force-dynamic"; @@ -15,7 +15,7 @@ export default async function Page(props: { }) { const searchParams = await props.searchParams; const getUser = cache(verifySession); - const user = await getUser(); + const user = await getUser({ skipCheckVerifyEmail: true }); const t = await getTranslations(); const env = pullEnv(); @@ -56,10 +56,10 @@ export default async function Page(props: {

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

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

@@ -72,7 +72,7 @@ export default async function Page(props: { />

- {t('signupQuestion')}{" "} + {t("signupQuestion")}{" "} - {t('login')} + {t("login")}

diff --git a/src/app/auth/verify-email/VerifyEmailForm.tsx b/src/app/auth/verify-email/VerifyEmailForm.tsx index cbe1e5fb..e9761eef 100644 --- a/src/app/auth/verify-email/VerifyEmailForm.tsx +++ b/src/app/auth/verify-email/VerifyEmailForm.tsx @@ -10,7 +10,7 @@ import { CardContent, CardDescription, CardHeader, - CardTitle, + CardTitle } from "@/components/ui/card"; import { Form, @@ -19,21 +19,21 @@ import { FormField, FormItem, FormLabel, - FormMessage, + FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { InputOTP, InputOTPGroup, - InputOTPSlot, + InputOTPSlot } from "@/components/ui/input-otp"; import { AxiosResponse } from "axios"; import { VerifyEmailResponse } from "@server/routers/auth"; -import { Loader2 } from "lucide-react"; +import { ArrowRight, IdCard, Loader2 } from "lucide-react"; import { Alert, AlertDescription } from "../../../components/ui/alert"; import { toast } from "@app/hooks/useToast"; import { useRouter } from "next/navigation"; -import { formatAxiosError } from "@app/lib/api";; +import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { cleanRedirect } from "@app/lib/cleanRedirect"; @@ -46,7 +46,7 @@ export type VerifyEmailFormProps = { export default function VerifyEmailForm({ email, - redirect, + redirect }: VerifyEmailFormProps) { const router = useRouter(); const t = useTranslations(); @@ -58,19 +58,34 @@ export default function VerifyEmailForm({ const api = createApiClient(useEnvContext()); + function logout() { + api.post("/auth/logout") + .catch((e) => { + console.error(t("logoutError"), e); + toast({ + title: t("logoutError"), + description: formatAxiosError(e, t("logoutError")) + }); + }) + .then(() => { + router.push("/auth/login"); + router.refresh(); + }); + } + const FormSchema = z.object({ - email: z.string().email({ message: t('emailInvalid') }), + email: z.string().email({ message: t("emailInvalid") }), pin: z.string().min(8, { - message: t('verificationCodeLengthRequirements'), - }), + message: t("verificationCodeLengthRequirements") + }) }); const form = useForm>({ resolver: zodResolver(FormSchema), defaultValues: { email: email, - pin: "", - }, + pin: "" + } }); async function onSubmit(data: z.infer) { @@ -78,19 +93,17 @@ export default function VerifyEmailForm({ const res = await api .post>("/auth/verify-email", { - code: data.pin, + code: data.pin }) .catch((e) => { - setError(formatAxiosError(e, t('errorOccurred'))); - console.error(t('emailErrorVerify'), e); + setError(formatAxiosError(e, t("errorOccurred"))); + console.error(t("emailErrorVerify"), e); setIsSubmitting(false); }); if (res && res.data?.data?.valid) { setError(null); - setSuccessMessage( - t('emailVerified') - ); + setSuccessMessage(t("emailVerified")); setTimeout(() => { if (redirect) { const safe = cleanRedirect(redirect); @@ -107,16 +120,16 @@ export default function VerifyEmailForm({ setIsResending(true); const res = await api.post("/auth/verify-email/request").catch((e) => { - setError(formatAxiosError(e, t('errorOccurred'))); - console.error(t('verificationCodeErrorResend'), e); + setError(formatAxiosError(e, t("errorOccurred"))); + console.error(t("verificationCodeErrorResend"), e); }); if (res) { setError(null); toast({ variant: "default", - title: t('verificationCodeResend'), - description: t('verificationCodeResendDescription'), + title: t("verificationCodeResend"), + description: t("verificationCodeResendDescription") }); } @@ -127,40 +140,26 @@ export default function VerifyEmailForm({
- {t('emailVerify')} + {t("emailVerify")} - {t('emailVerifyDescription')} + {t("emailVerifyDescription")} +

+ {email} +

- ( - - {t('email')} - - - - - - )} - /> - ( - {t('verificationCode')}
- - {t('verificationCodeEmailSent')} - )} /> +
+ +
+ {error && ( {error} @@ -222,29 +231,26 @@ export default function VerifyEmailForm({ type="submit" className="w-full" disabled={isSubmitting} + form="verify-email-form" > {isSubmitting && ( )} - {t('submit')} + {t("submit")} + + + - -
- -
); } diff --git a/src/app/globals.css b/src/app/globals.css index 9b6c18bc..e643cfb6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,125 +1,142 @@ @import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&display=swap"); -@import 'tw-animate-css'; -@import 'tailwindcss'; +@import "tw-animate-css"; +@import "tailwindcss"; @custom-variant dark (&:is(.dark *)); :root { - --background: hsl(0 0% 98%); - --foreground: hsl(20 0% 10%); - --card: hsl(0 0% 100%); - --card-foreground: hsl(20 0% 10%); - --popover: hsl(0 0% 100%); - --popover-foreground: hsl(20 0% 10%); - --primary: hsl(24.6 95% 53.1%); - --primary-foreground: hsl(60 9.1% 97.8%); - --secondary: hsl(60 4.8% 95.9%); - --secondary-foreground: hsl(24 9.8% 10%); - --muted: hsl(60 4.8% 85%); - --muted-foreground: hsl(25 5.3% 44.7%); - --accent: hsl(60 4.8% 90%); - --accent-foreground: hsl(24 9.8% 10%); - --destructive: hsl(0 84.2% 60.2%); - --destructive-foreground: hsl(60 9.1% 97.8%); - --border: hsl(20 5.9% 90%); - --input: hsl(20 5.9% 75%); - --ring: hsl(24.6 95% 53.1%); - --radius: 0.75rem; - --chart-1: hsl(12 76% 61%); - --chart-2: hsl(173 58% 39%); - --chart-3: hsl(197 37% 24%); - --chart-4: hsl(43 74% 66%); - --chart-5: hsl(27 87% 67%); + --radius: 0.65rem; + --background: oklch(0.99 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.6717 0.1946 41.93); + --primary-foreground: oklch(0.98 0.016 73.684); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.213 47.604); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.705 0.213 47.604); + --sidebar-primary-foreground: oklch(0.98 0.016 73.684); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.705 0.213 47.604); } .dark { - --background: hsl(20 0% 8%); - --foreground: hsl(60 9.1% 97.8%); - --card: hsl(20 0% 10%); - --card-foreground: hsl(60 9.1% 97.8%); - --popover: hsl(20 0% 10%); - --popover-foreground: hsl(60 9.1% 97.8%); - --primary: hsl(20.5 90.2% 48.2%); - --primary-foreground: hsl(60 9.1% 97.8%); - --secondary: hsl(12 6.5% 15%); - --secondary-foreground: hsl(60 9.1% 97.8%); - --muted: hsl(12 6.5% 25%); - --muted-foreground: hsl(24 5.4% 63.9%); - --accent: hsl(12 2.5% 15%); - --accent-foreground: hsl(60 9.1% 97.8%); - --destructive: hsl(0 72.2% 50.6%); - --destructive-foreground: hsl(60 9.1% 97.8%); - --border: hsl(12 6.5% 15%); - --input: hsl(12 6.5% 35%); - --ring: hsl(20.5 90.2% 48.2%); - --chart-1: hsl(220 70% 50%); - --chart-2: hsl(160 60% 45%); - --chart-3: hsl(30 80% 55%); - --chart-4: hsl(280 65% 60%); - --chart-5: hsl(340 75% 55%); + --background: oklch(0.20 0.006 285.885); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.6717 0.1946 41.93); + --primary-foreground: oklch(0.98 0.016 73.684); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.646 0.222 41.116); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.646 0.222 41.116); + --sidebar-primary-foreground: oklch(0.98 0.016 73.684); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.646 0.222 41.116); } @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); + --color-background: var(--background); + --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); - --radius-lg: var(--radius); - --radius-md: calc(var(--radius) - 2px); - --radius-sm: calc(var(--radius) - 4px); + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); + + --shadow-2xs: 0 1px 1px rgba(0, 0, 0, 0.03); + --inset-shadow-2xs: inset 0 1px 1px rgba(0, 0, 1, 0.03); } @layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); - } + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentcolor); + } } @layer base { - * { - @apply border-border; - } + * { + @apply border-border; + } - body { - @apply bg-background text-foreground; - } + body { + @apply bg-background text-foreground; + } } p { - word-break: keep-all; - white-space: normal; -} \ No newline at end of file + word-break: keep-all; + white-space: normal; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index dd02c489..1ad8d10a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; import { Inter } from "next/font/google"; -import { Toaster } from "@/components/ui/toaster"; import { ThemeProvider } from "@app/providers/ThemeProvider"; import EnvProvider from "@app/providers/EnvProvider"; import { pullEnv } from "@app/lib/pullEnv"; @@ -11,14 +10,15 @@ import { AxiosResponse } from "axios"; import { IsSupporterKeyVisibleResponse } from "@server/routers/supporterKey"; import LicenseStatusProvider from "@app/providers/LicenseStatusProvider"; import { GetLicenseStatusResponse } from "@server/routers/license"; -import LicenseViolation from "./components/LicenseViolation"; +import LicenseViolation from "@app/components/LicenseViolation"; import { cache } from "react"; import { NextIntlClientProvider } from "next-intl"; import { getLocale } from "next-intl/server"; +import { Toaster } from "@app/components/ui/toaster"; export const metadata: Metadata = { title: `Dashboard - Pangolin`, - description: "" + description: "", }; export const dynamic = "force-dynamic"; @@ -83,4 +83,4 @@ export default async function RootLayout({ ); -} +} \ No newline at end of file diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 06a29bb0..b8f60abd 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -1,4 +1,5 @@ import { SidebarNavItem } from "@app/components/SidebarNav"; +import { build } from "@server/build"; import { Home, Settings, @@ -7,11 +8,20 @@ import { Waypoints, Combine, Fingerprint, + Workflow, KeyRound, TicketCheck, - User + User, + Globe, // Added from 'dev' branch + MonitorUp // Added from 'dev' branch } from "lucide-react"; +export type SidebarNavSection = { // Added from 'dev' branch + heading: string; + items: SidebarNavItem[]; +}; + +// Merged from 'user-management-and-resources' branch export const orgLangingNavItems: SidebarNavItem[] = [ { title: "sidebarAccount", @@ -27,83 +37,108 @@ export const orgLangingNavItems: SidebarNavItem[] = [ } ]; -export const rootNavItems: SidebarNavItem[] = [ +export const orgNavSections = ( + enableClients: boolean = true +): SidebarNavSection[] => [ { - title: "sidebarHome", - href: "/", - icon: - } -]; - -export const orgNavItems: SidebarNavItem[] = [ - { - title: "sidebarSites", - href: "/{orgId}/settings/sites", - icon: - }, - { - title: "sidebarResources", - href: "/{orgId}/settings/resources", - icon: - }, - { - title: "sidebarAccessControl", - href: "/{orgId}/settings/access", - icon: , - autoExpand: true, - children: [ + heading: "General", + items: [ { - title: "sidebarUsers", - href: "/{orgId}/settings/access/users", - children: [ - { - title: "sidebarInvitations", - href: "/{orgId}/settings/access/invitations" - } - ] + title: "sidebarSites", + href: "/{orgId}/settings/sites", + icon: }, { - title: "sidebarRoles", - href: "/{orgId}/settings/access/roles" + title: "sidebarResources", + href: "/{orgId}/settings/resources", + icon: + }, + ...(enableClients + ? [ + { + title: "sidebarClients", + href: "/{orgId}/settings/clients", + icon: + } + ] + : []), + { + title: "sidebarDomains", + href: "/{orgId}/settings/domains", + icon: } ] }, { - title: "sidebarShareableLinks", - href: "/{orgId}/settings/share-links", - icon: + heading: "Access Control", + items: [ + { + title: "sidebarUsers", + href: "/{orgId}/settings/access/users", + icon: + }, + { + title: "sidebarRoles", + href: "/{orgId}/settings/access/roles", + icon: + }, + { + title: "sidebarInvitations", + href: "/{orgId}/settings/access/invitations", + icon: + }, + { + title: "sidebarShareableLinks", + href: "/{orgId}/settings/share-links", + icon: + } + ] }, { - title: "sidebarApiKeys", - href: "/{orgId}/settings/api-keys", - icon: - }, - { - title: "sidebarSettings", - href: "/{orgId}/settings/general", - icon: + heading: "Organization", + items: [ + { + title: "sidebarApiKeys", + href: "/{orgId}/settings/api-keys", + icon: + }, + { + title: "sidebarSettings", + href: "/{orgId}/settings/general", + icon: + } + ] } ]; -export const adminNavItems: SidebarNavItem[] = [ +export const adminNavSections: SidebarNavSection[] = [ { - title: "sidebarAllUsers", - href: "/admin/users", - icon: - }, - { - title: "sidebarApiKeys", - href: "/admin/api-keys", - icon: - }, - { - title: "sidebarIdentityProviders", - href: "/admin/idp", - icon: - }, - { - title: "sidebarLicense", - href: "/admin/license", - icon: + heading: "Admin", + items: [ + { + title: "sidebarAllUsers", + href: "/admin/users", + icon: + }, + { + title: "sidebarApiKeys", + href: "/admin/api-keys", + icon: + }, + { + title: "sidebarIdentityProviders", + href: "/admin/idp", + icon: + }, + ...(build == "enterprise" + ? [ + { + title: "sidebarLicense", + href: "/admin/license", + icon: + } + ] + : []) + ] } -]; +]; \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index 14738ac7..91ca5686 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,12 +6,12 @@ import { ListUserOrgsResponse } from "@server/routers/org"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { cache } from "react"; -import OrganizationLanding from "./components/OrganizationLanding"; +import OrganizationLanding from "@app/components/OrganizationLanding"; import { pullEnv } from "@app/lib/pullEnv"; import { cleanRedirect } from "@app/lib/cleanRedirect"; import { Layout } from "@app/components/Layout"; -import { rootNavItems } from "./navigation"; import { InitialSetupCompleteResponse } from "@server/routers/auth"; +import { cookies } from "next/headers"; export const dynamic = "force-dynamic"; @@ -72,21 +72,36 @@ export default async function Page(props: { } } - return ( - - -
- ({ - name: org.name, - id: org.orgId - }))} - /> -
-
-
- ); + const allCookies = await cookies(); + const lastOrgCookie = allCookies.get("pangolin-last-org")?.value; + + const lastOrgExists = orgs.some((org) => org.orgId === lastOrgCookie); + if (lastOrgExists) { + redirect(`/${lastOrgCookie}`); + } else { + const ownedOrg = orgs.find((org) => org.isOwner); + if (ownedOrg) { + redirect(`/${ownedOrg.orgId}`); + } else { + redirect("/setup"); + } + } + + // return ( + // + // + //
+ // ({ + // name: org.name, + // id: org.orgId + // }))} + // /> + //
+ //
+ //
+ // ); } diff --git a/src/app/setup/layout.tsx b/src/app/setup/layout.tsx index e254037d..06dd3300 100644 --- a/src/app/setup/layout.tsx +++ b/src/app/setup/layout.tsx @@ -6,7 +6,6 @@ import UserProvider from "@app/providers/UserProvider"; import { Metadata } from "next"; import { redirect } from "next/navigation"; import { cache } from "react"; -import { rootNavItems } from "../navigation"; import { ListUserOrgsResponse } from "@server/routers/org"; import { internal } from "@app/lib/api"; import { AxiosResponse } from "axios"; @@ -54,11 +53,7 @@ export default async function SetupLayout({ return ( <> - +
{children}
diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx index 2a1447ee..42c64b16 100644 --- a/src/app/setup/page.tsx +++ b/src/app/setup/page.tsx @@ -2,8 +2,6 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import Link from "next/link"; import { toast } from "@app/hooks/useToast"; import { useCallback, useEffect, useState } from "react"; import { @@ -13,8 +11,7 @@ import { CardHeader, CardTitle } from "@app/components/ui/card"; -import CopyTextBox from "@app/components/CopyTextBox"; -import { formatAxiosError } from "@app/lib/api";; +import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { Separator } from "@/components/ui/separator"; @@ -32,7 +29,6 @@ import { FormMessage } from "@app/components/ui/form"; import { Alert, AlertDescription } from "@app/components/ui/alert"; -import CreateSiteForm from "../[orgId]/settings/sites/CreateSiteForm"; import { useTranslations } from "next-intl"; type Step = "org" | "site" | "resources"; @@ -41,6 +37,7 @@ export default function StepperForm() { const [currentStep, setCurrentStep] = useState("org"); const [orgIdTaken, setOrgIdTaken] = useState(false); const t = useTranslations(); + const { env } = useEnvContext(); const [loading, setLoading] = useState(false); const [isChecked, setIsChecked] = useState(false); @@ -48,36 +45,62 @@ export default function StepperForm() { const [orgCreated, setOrgCreated] = useState(false); const orgSchema = z.object({ - orgName: z.string().min(1, { message: t('orgNameRequired') }), - orgId: z.string().min(1, { message: t('orgIdRequired') }) + orgName: z.string().min(1, { message: t("orgNameRequired") }), + orgId: z.string().min(1, { message: t("orgIdRequired") }), + subnet: z.string().min(1, { message: t("subnetRequired") }) }); const orgForm = useForm>({ resolver: zodResolver(orgSchema), defaultValues: { orgName: "", - orgId: "" + orgId: "", + subnet: "" } }); const api = createApiClient(useEnvContext()); const router = useRouter(); - const checkOrgIdAvailability = useCallback(async (value: string) => { - if (loading || orgCreated) { - return; - } + // Fetch default subnet on component mount + useEffect(() => { + fetchDefaultSubnet(); + }, []); + + const fetchDefaultSubnet = async () => { try { - const res = await api.get(`/org/checkId`, { - params: { - orgId: value - } + const res = await api.get(`/pick-org-defaults`); + if (res && res.data && res.data.data) { + orgForm.setValue("subnet", res.data.data.subnet); + } + } catch (e) { + console.error("Failed to fetch default subnet:", e); + toast({ + title: "Error", + description: "Failed to fetch default subnet", + variant: "destructive" }); - setOrgIdTaken(res.status !== 404); - } catch (error) { - setOrgIdTaken(false); } - }, [loading, orgCreated, api]); + }; + + const checkOrgIdAvailability = useCallback( + async (value: string) => { + if (loading || orgCreated) { + return; + } + try { + const res = await api.get(`/org/checkId`, { + params: { + orgId: value + } + }); + setOrgIdTaken(res.status !== 404); + } catch (error) { + setOrgIdTaken(false); + } + }, + [loading, orgCreated, api] + ); const debouncedCheckOrgIdAvailability = useCallback( debounce(checkOrgIdAvailability, 300), @@ -105,7 +128,8 @@ export default function StepperForm() { try { const res = await api.put(`/org`, { orgId: values.orgId, - name: values.orgName + name: values.orgName, + subnet: values.subnet }); if (res && res.status === 201) { @@ -114,9 +138,7 @@ export default function StepperForm() { } } catch (e) { console.error(e); - setError( - formatAxiosError(e, t('orgErrorCreate')) - ); + setError(formatAxiosError(e, t("orgErrorCreate"))); } setLoading(false); @@ -126,10 +148,8 @@ export default function StepperForm() { <> - {t('setupNewOrg')} - - {t('setupCreate')} - + {t("setupNewOrg")} + {t("setupCreate")}
@@ -151,7 +171,7 @@ export default function StepperForm() { : "text-muted-foreground" }`} > - {t('setupCreateOrg')} + {t("setupCreateOrg")}
@@ -171,7 +191,7 @@ export default function StepperForm() { : "text-muted-foreground" }`} > - {t('siteCreate')} + {t("siteCreate")}
@@ -191,7 +211,7 @@ export default function StepperForm() { : "text-muted-foreground" }`} > - {t('setupCreateResources')} + {t("setupCreateResources")}
@@ -210,7 +230,7 @@ export default function StepperForm() { render={({ field }) => ( - {t('setupOrgName')} + {t("setupOrgName")} { // Prevent "/" in orgName input - const sanitizedValue = e.target.value.replace(/\//g, "-"); - const orgId = generateId(sanitizedValue); + const sanitizedValue = + e.target.value.replace( + /\//g, + "-" + ); + const orgId = + generateId( + sanitizedValue + ); orgForm.setValue( "orgId", orgId @@ -232,12 +259,15 @@ export default function StepperForm() { orgId ); }} - value={field.value.replace(/\//g, "-")} + value={field.value.replace( + /\//g, + "-" + )} /> - {t('orgDisplayName')} + {t("orgDisplayName")} )} @@ -248,7 +278,7 @@ export default function StepperForm() { render={({ field }) => ( - {t('orgId')} + {t("orgId")} - {t('setupIdentifierMessage')} + {t( + "setupIdentifierMessage" + )} )} /> + {env.flags.enableClients && ( + ( + + + Subnet + + + + + + + Network subnet for this + organization. A default + value has been provided. + + + )} + /> + )} + {orgIdTaken && !orgCreated ? ( - {t('setupErrorIdentifier')} + {t("setupErrorIdentifier")} ) : null} @@ -290,7 +348,7 @@ export default function StepperForm() { orgIdTaken } > - {t('setupCreateOrg')} + {t("setupCreateOrg")}
diff --git a/src/components/AuthFooter.tsx b/src/components/AuthFooter.tsx deleted file mode 100644 index a1c0795e..00000000 --- a/src/components/AuthFooter.tsx +++ /dev/null @@ -1,3 +0,0 @@ -"use client"; - -export function AuthFooter() {} diff --git a/src/components/BrandingLogo.tsx b/src/components/BrandingLogo.tsx new file mode 100644 index 00000000..34771333 --- /dev/null +++ b/src/components/BrandingLogo.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTheme } from "next-themes"; +import Image from "next/image"; +import { useEffect, useState } from "react"; + +type BrandingLogoProps = { + width: number; + height: number; +}; + +export default function BrandingLogo(props: BrandingLogoProps) { + const { env } = useEnvContext(); + const { theme } = useTheme(); + const [path, setPath] = useState(""); // Default logo path + + useEffect(() => { + function getPath() { + let lightOrDark = theme; + + if (theme === "system" || !theme) { + lightOrDark = window.matchMedia("(prefers-color-scheme: dark)") + .matches + ? "dark" + : "light"; + } + + if (lightOrDark === "light") { + return "/logo/word_mark_black.png"; + } + + return "/logo/word_mark_white.png"; + } + + const path = getPath(); + setPath(path); + }, [theme, env]); + + return ( + path && ( + Logo + ) + ); +} diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx deleted file mode 100644 index 25366ffa..00000000 --- a/src/components/Breadcrumbs.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import { usePathname } from "next/navigation"; -import Link from "next/link"; -import { ChevronRight } from "lucide-react"; -import { cn } from "@app/lib/cn"; - -interface BreadcrumbItem { - label: string; - href: string; -} - -export function Breadcrumbs() { - const pathname = usePathname(); - const segments = pathname.split("/").filter(Boolean); - - const breadcrumbs: BreadcrumbItem[] = segments.map((segment, index) => { - const href = `/${segments.slice(0, index + 1).join("/")}`; - let label = decodeURIComponent(segment); - return { label, href }; - }); - - return ( - - ); -} diff --git a/src/components/ConfirmDeleteDialog.tsx b/src/components/ConfirmDeleteDialog.tsx index 5ca8ca8d..cd053a14 100644 --- a/src/components/ConfirmDeleteDialog.tsx +++ b/src/components/ConfirmDeleteDialog.tsx @@ -72,7 +72,7 @@ export default function InviteUserForm({ const formSchema = z.object({ string: z.string().refine((val) => val === string, { - message: t('inviteErrorInvalidConfirmation') + message: t("inviteErrorInvalidConfirmation") }) }); @@ -108,7 +108,9 @@ export default function InviteUserForm({ {title} -
{dialog}
+
+ {dialog} +
- + +
+ + {/* Loading State */} + {isChecking && ( +
+
+
+ {t("domainPickerCheckingAvailability")} +
+
+ )} + + {/* No Options */} + {!isChecking && + filteredOptions.length === 0 && + userInput.trim() && ( + + + + {t("domainPickerNoMatchingDomains", { userInput })} + + + )} + + {/* Domain Options */} + {!isChecking && filteredOptions.length > 0 && ( +
+ {/* Organization Domains */} + {organizationOptions.length > 0 && ( +
+
+ +

+ {t("domainPickerOrganizationDomains")} +

+
+
+ {organizationOptions.map((option) => ( +
+ option.verified && + handleOptionSelect(option) + } + > +
+
+
+

+ {option.domain} +

+ {/* */} + {/* {option.domainType} */} + {/* */} + {option.verified ? ( + + ) : ( + + )} +
+ {option.subdomain && ( +

+ {t( + "domainPickerSubdomain", + { + subdomain: + option.subdomain + } + )} +

+ )} + {!option.verified && ( +

+ Domain is unverified +

+ )} +
+ {selectedOption?.id === + option.id && ( + + )} +
+
+ ))} +
+
+ )} + + {/* Provided Domains */} + {providedOptions.length > 0 && ( +
+
+ +
+ {t("domainPickerProvidedDomains")} +
+
+
+ {providedOptions.map((option) => ( +
+ handleOptionSelect(option) + } + > +
+
+

+ {option.domain} +

+

+ {t( + "domainPickerNamespace", + { + namespace: + option.domainNamespaceId as string + } + )} +

+
+ {selectedOption?.id === + option.id && ( + + )} +
+
+ ))} +
+ {hasMoreProvided && ( + + )} +
+ )} +
+ )} +
+ ); +} + +function debounce any>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: NodeJS.Timeout | null = null; + + return (...args: Parameters) => { + if (timeout) clearTimeout(timeout); + + timeout = setTimeout(() => { + func(...args); + }, wait); + }; +} diff --git a/src/components/HorizontalTabs.tsx b/src/components/HorizontalTabs.tsx index 258bace3..b529dbba 100644 --- a/src/components/HorizontalTabs.tsx +++ b/src/components/HorizontalTabs.tsx @@ -38,6 +38,7 @@ export function HorizontalTabs({ .replace("{resourceId}", params.resourceId as string) .replace("{niceId}", params.niceId as string) .replace("{userId}", params.userId as string) + .replace("{clientId}", params.clientId as string) .replace("{apiKeyId}", params.apiKeyId as string); } diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 28ef307b..7d99a773 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,276 +1,82 @@ -"use client"; - -import React, { useEffect, useState } from "react"; -import { SidebarNav } from "@app/components/SidebarNav"; -import { OrgSelector } from "@app/components/OrgSelector"; +import React from "react"; import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; -import SupporterStatus from "@app/components/SupporterStatus"; -import { Button } from "@app/components/ui/button"; -import { ExternalLink, Menu, X, Server } from "lucide-react"; -import Image from "next/image"; -import ProfileIcon from "@app/components/ProfileIcon"; -import { - Sheet, - SheetContent, - SheetTrigger, - SheetTitle, - SheetDescription -} from "@app/components/ui/sheet"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { Breadcrumbs } from "@app/components/Breadcrumbs"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { useUserContext } from "@app/hooks/useUserContext"; -import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; -import { useTheme } from "next-themes"; -import { useTranslations } from "next-intl"; +import type { SidebarNavSection } from "@app/app/navigation"; +import { LayoutSidebar } from "@app/components/LayoutSidebar"; +import { LayoutHeader } from "@app/components/LayoutHeader"; +import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu"; +import { cookies } from "next/headers"; interface LayoutProps { children: React.ReactNode; orgId?: string; orgs?: ListUserOrgsResponse["orgs"]; - navItems?: Array<{ - title: string; - href: string; - icon?: React.ReactNode; - children?: Array<{ - title: string; - href: string; - icon?: React.ReactNode; - }>; - }>; + navItems?: SidebarNavSection[]; showSidebar?: boolean; - showBreadcrumbs?: boolean; showHeader?: boolean; showTopBar?: boolean; + defaultSidebarCollapsed?: boolean; } -export function Layout({ +export async function Layout({ children, orgId, orgs, navItems = [], showSidebar = true, - showBreadcrumbs = true, showHeader = true, - showTopBar = true + showTopBar = true, + defaultSidebarCollapsed = false }: LayoutProps) { - const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); - const { env } = useEnvContext(); - const pathname = usePathname(); - const isAdminPage = pathname?.startsWith("/admin"); - const { user } = useUserContext(); - const { isUnlocked } = useLicenseStatusContext(); + const allCookies = await cookies(); + const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value; - const { theme } = useTheme(); - const [path, setPath] = useState(""); // Default logo path - - useEffect(() => { - function getPath() { - let lightOrDark = theme; - - if (theme === "system" || !theme) { - lightOrDark = window.matchMedia("(prefers-color-scheme: dark)") - .matches - ? "dark" - : "light"; - } - - if (lightOrDark === "light") { - // return "/logo/word_mark_black.png"; - return "/logo/pangolin_orange.svg"; - } - - // return "/logo/word_mark_white.png"; - return "/logo/pangolin_orange.svg"; - } - - setPath(getPath()); - }, [theme, env]); - - const t = useTranslations(); + const initialSidebarCollapsed = + sidebarStateCookie === "collapsed" || + (sidebarStateCookie !== "expanded" && defaultSidebarCollapsed); return ( -
- {/* Full width header */} - {showHeader && ( -
-
-
- {showSidebar && ( -
- - - - - - - {t('navbar')} - - - {t('navbarDescription')} - -
-
- - setIsMobileMenuOpen( - false - ) - } - /> -
- {!isAdminPage && - user.serverAdmin && ( -
- - setIsMobileMenuOpen( - false - ) - } - > - - {t('serverAdmin')} - -
- )} -
-
- - - {env?.app?.version && ( -
- v{env.app.version} -
- )} -
-
-
-
- )} - - {path && ( - Pangolin Logo - )} - - {showBreadcrumbs && ( -
- -
- )} -
- {showTopBar && ( -
-
- - {t('navbarDocsLink')} - -
-
- -
-
- )} -
- {showBreadcrumbs && ( -
- -
- )} -
+
+ {/* Desktop Sidebar */} + {showSidebar && ( + )} -
- {/* Desktop Sidebar */} - {showSidebar && ( -
-
-
- -
- {!isAdminPage && user.serverAdmin && ( -
- - - {t('serverAdmin')} - -
- )} -
-
- - -
-
- - {!isUnlocked() - ? t('communityEdition') - : t('commercialEdition')} - - -
- {env?.app?.version && ( -
- v{env.app.version} -
- )} -
-
-
+ {/* Main content area */} +
+ {/* Mobile header */} + {showHeader && ( + )} + {/* Desktop header */} + {showHeader && } + {/* Main content */} -
-
-
- {children} -
-
-
+
+
+ {children} +
+
); diff --git a/src/components/LayoutHeader.tsx b/src/components/LayoutHeader.tsx new file mode 100644 index 00000000..2584b259 --- /dev/null +++ b/src/components/LayoutHeader.tsx @@ -0,0 +1,72 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import ProfileIcon from "@app/components/ProfileIcon"; +import ThemeSwitcher from "@app/components/ThemeSwitcher"; +import { useTheme } from "next-themes"; + +interface LayoutHeaderProps { + showTopBar: boolean; +} + +export function LayoutHeader({ showTopBar }: LayoutHeaderProps) { + const { theme } = useTheme(); + const [path, setPath] = useState(""); + + useEffect(() => { + function getPath() { + let lightOrDark = theme; + + if (theme === "system" || !theme) { + lightOrDark = window.matchMedia("(prefers-color-scheme: dark)") + .matches + ? "dark" + : "light"; + } + + if (lightOrDark === "light") { + return "/logo/word_mark_black.png"; + } + + return "/logo/word_mark_white.png"; + } + + setPath(getPath()); + }, [theme]); + + return ( +
+
+
+
+
+
+ + {path && ( + Pangolin + )} + +
+ + {showTopBar && ( +
+ + +
+ )} +
+
+
+
+ ); +} + +export default LayoutHeader; diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx new file mode 100644 index 00000000..cdec0d98 --- /dev/null +++ b/src/components/LayoutMobileMenu.tsx @@ -0,0 +1,142 @@ +"use client"; + +import React, { useState } from "react"; +import { SidebarNav } from "@app/components/SidebarNav"; +import { OrgSelector } from "@app/components/OrgSelector"; +import { cn } from "@app/lib/cn"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import SupporterStatus from "@app/components/SupporterStatus"; +import { Button } from "@app/components/ui/button"; +import { Menu, Server } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; +import ProfileIcon from "@app/components/ProfileIcon"; +import ThemeSwitcher from "@app/components/ThemeSwitcher"; +import type { SidebarNavSection } from "@app/app/navigation"; +import { + Sheet, + SheetContent, + SheetTrigger, + SheetTitle, + SheetDescription +} from "@app/components/ui/sheet"; +import { Abel } from "next/font/google"; + +interface LayoutMobileMenuProps { + orgId?: string; + orgs?: ListUserOrgsResponse["orgs"]; + navItems: SidebarNavSection[]; + showSidebar: boolean; + showTopBar: boolean; +} + +export function LayoutMobileMenu({ + orgId, + orgs, + navItems, + showSidebar, + showTopBar +}: LayoutMobileMenuProps) { + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const pathname = usePathname(); + const isAdminPage = pathname?.startsWith("/admin"); + const { user } = useUserContext(); + const { env } = useEnvContext(); + const t = useTranslations(); + + return ( +
+
+
+ {showSidebar && ( +
+ + + + + + + {t("navbar")} + + + {t("navbarDescription")} + +
+
+ +
+
+ {!isAdminPage && + user.serverAdmin && ( +
+ + setIsMobileMenuOpen( + false + ) + } + > + + + + + {t( + "serverAdmin" + )} + + +
+ )} + + setIsMobileMenuOpen(false) + } + /> +
+
+
+ + {env?.app?.version && ( +
+ v{env.app.version} +
+ )} +
+
+
+
+ )} +
+ {showTopBar && ( +
+
+ + +
+
+ )} +
+
+ ); +} + +export default LayoutMobileMenu; diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx new file mode 100644 index 00000000..98ef87eb --- /dev/null +++ b/src/components/LayoutSidebar.tsx @@ -0,0 +1,178 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { SidebarNav } from "@app/components/SidebarNav"; +import { OrgSelector } from "@app/components/OrgSelector"; +import { cn } from "@app/lib/cn"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import SupporterStatus from "@app/components/SupporterStatus"; +import { ExternalLink, Server, BookOpenText } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; +import type { SidebarNavSection } from "@app/app/navigation"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@app/components/ui/tooltip"; + +interface LayoutSidebarProps { + orgId?: string; + orgs?: ListUserOrgsResponse["orgs"]; + navItems: SidebarNavSection[]; + defaultSidebarCollapsed: boolean; +} + +export function LayoutSidebar({ + orgId, + orgs, + navItems, + defaultSidebarCollapsed +}: LayoutSidebarProps) { + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState( + defaultSidebarCollapsed + ); + const pathname = usePathname(); + const isAdminPage = pathname?.startsWith("/admin"); + const { user } = useUserContext(); + const { isUnlocked } = useLicenseStatusContext(); + const { env } = useEnvContext(); + const t = useTranslations(); + + const setSidebarStateCookie = (collapsed: boolean) => { + if (typeof window !== "undefined") { + const isSecure = window.location.protocol === "https:"; + document.cookie = `pangolin-sidebar-state=${collapsed ? "collapsed" : "expanded"}; path=/; max-age=${60 * 60 * 24 * 30}; samesite=lax${isSecure ? "; secure" : ""}`; + } + }; + + useEffect(() => { + setSidebarStateCookie(isSidebarCollapsed); + }, [isSidebarCollapsed]); + + return ( +
+
+
+ +
+
+ {!isAdminPage && user.serverAdmin && ( +
+ + + + + {!isSidebarCollapsed && ( + {t("serverAdmin")} + )} + +
+ )} + +
+
+
+ + {!isSidebarCollapsed && ( +
+
+ + {!isUnlocked() + ? t("communityEdition") + : t("commercialEdition")} + + +
+
+ + {t("documentation")} + + +
+ {env?.app?.version && ( +
+ v{env.app.version} +
+ )} +
+ )} +
+ + {/* Collapse button */} + + + + + + +

+ {isSidebarCollapsed + ? t("sidebarExpand") + : t("sidebarCollapse")} +

+
+
+
+
+ ); +} + +export default LayoutSidebar; diff --git a/src/app/components/LicenseViolation.tsx b/src/components/LicenseViolation.tsx similarity index 100% rename from src/app/components/LicenseViolation.tsx rename to src/components/LicenseViolation.tsx diff --git a/src/components/LocaleSwitcher.tsx b/src/components/LocaleSwitcher.tsx index fe1dece5..0e883a3c 100644 --- a/src/components/LocaleSwitcher.tsx +++ b/src/components/LocaleSwitcher.tsx @@ -1,59 +1,59 @@ import { useLocale } from "next-intl"; -import LocaleSwitcherSelect from './LocaleSwitcherSelect'; +import LocaleSwitcherSelect from "./LocaleSwitcherSelect"; export default function LocaleSwitcher() { - const locale = useLocale(); + const locale = useLocale(); - return ( - - ); + return ( + + ); } diff --git a/src/components/LocaleSwitcherSelect.tsx b/src/components/LocaleSwitcherSelect.tsx index 53c25d8f..201aeb18 100644 --- a/src/components/LocaleSwitcherSelect.tsx +++ b/src/components/LocaleSwitcherSelect.tsx @@ -1,71 +1,71 @@ -'use client'; +"use client"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from '@app/components/ui/dropdown-menu'; -import { Button } from '@app/components/ui/button'; -import { Check, Globe, Languages } from 'lucide-react'; -import clsx from 'clsx'; -import { useTransition } from 'react'; -import { Locale } from '@/i18n/config'; -import { setUserLocale } from '@/services/locale'; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Button } from "@app/components/ui/button"; +import { Check, Globe, Languages } from "lucide-react"; +import clsx from "clsx"; +import { useTransition } from "react"; +import { Locale } from "@/i18n/config"; +import { setUserLocale } from "@/services/locale"; type Props = { - defaultValue: string; - items: Array<{ value: string; label: string }>; - label: string; + defaultValue: string; + items: Array<{ value: string; label: string }>; + label: string; }; export default function LocaleSwitcherSelect({ - defaultValue, - items, - label + defaultValue, + items, + label }: Props) { - const [isPending, startTransition] = useTransition(); + const [isPending, startTransition] = useTransition(); - function onChange(value: string) { - const locale = value as Locale; - startTransition(() => { - setUserLocale(locale); - }); - } + function onChange(value: string) { + const locale = value as Locale; + startTransition(() => { + setUserLocale(locale); + }); + } - const selected = items.find((item) => item.value === defaultValue); + const selected = items.find((item) => item.value === defaultValue); - return ( - - - - - - {items.map((item) => ( - onChange(item.value)} - className="flex items-center gap-2" - > - {item.value === defaultValue && ( - - )} - {item.label} - - ))} - - - ); + return ( + + + + + + {items.map((item) => ( + onChange(item.value)} + className="flex items-center gap-2" + > + {item.value === defaultValue && ( + + )} + {item.label} + + ))} + + + ); } diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 04ed25fb..153b7eb7 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -63,6 +63,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const [securityKeyLoading, setSecurityKeyLoading] = useState(false); const hasIdp = idps && idps.length > 0; const [mfaRequested, setMfaRequested] = useState(false); @@ -98,7 +99,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { async function initiateSecurityKeyAuth() { setShowSecurityKeyPrompt(true); - setLoading(true); + setSecurityKeyLoading(true); setError(null); try { @@ -117,7 +118,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { // Perform WebAuthn authentication try { const credential = await startAuthentication(options); - + // Verify authentication const verifyRes = await api.post( "/auth/security-key/authenticate/verify", @@ -167,7 +168,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { })); } } finally { - setLoading(false); + setSecurityKeyLoading(false); setShowSecurityKeyPrompt(false); } } @@ -432,8 +433,8 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { variant="outline" className="w-full" onClick={initiateSecurityKeyAuth} - loading={loading} - disabled={loading || showSecurityKeyPrompt} + loading={securityKeyLoading} + disabled={securityKeyLoading || showSecurityKeyPrompt} > {t('securityKeyLogin', { diff --git a/src/components/OrgSelector.tsx b/src/components/OrgSelector.tsx index b402e0de..abf34a45 100644 --- a/src/components/OrgSelector.tsx +++ b/src/components/OrgSelector.tsx @@ -15,10 +15,16 @@ import { PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@app/components/ui/tooltip"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; -import { Check, ChevronsUpDown, Plus } from "lucide-react"; +import { Check, ChevronsUpDown, Plus, Building2, Users } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { useUserContext } from "@app/hooks/useUserContext"; @@ -27,94 +33,110 @@ import { useTranslations } from "next-intl"; interface OrgSelectorProps { orgId?: string; orgs?: ListUserOrgsResponse["orgs"]; + isCollapsed?: boolean; } -export function OrgSelector({ orgId, orgs }: OrgSelectorProps) { +export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorProps) { const { user } = useUserContext(); const [open, setOpen] = useState(false); const router = useRouter(); const { env } = useEnvContext(); const t = useTranslations(); - return ( + const selectedOrg = orgs?.find((org) => org.orgId === orgId); + + const orgSelectorContent = ( - - - - - {t('orgNotFound2')} + + + + +
+ {t('orgNotFound2')} +
- {(!env.flags.disableUserCreateOrg || - user.serverAdmin) && ( + {(!env.flags.disableUserCreateOrg || user.serverAdmin) && ( <> - + { - router.push( - "/setup" - ); + onSelect={() => { + setOpen(false); + router.push("/setup"); }} + className="mx-2 rounded-md" > - - {t('setupNewOrg')} +
+ +
+
+ {t('setupNewOrg')} + {t('createNewOrgDescription')} +
- + )} - + {orgs?.map((org) => ( { - router.push( - `/${org.orgId}/settings` - ); + onSelect={() => { + setOpen(false); + router.push(`/${org.orgId}/settings`); }} + className="mx-2 rounded-md" > +
+ +
+
+ {org.name} + {t('organization')} +
- {org.name}
))}
@@ -123,4 +145,24 @@ export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
); + + if (isCollapsed) { + return ( + + + + {orgSelectorContent} + + +
+

{selectedOrg?.name || t('noneSelected')}

+

{t('org')}

+
+
+
+
+ ); + } + + return orgSelectorContent; } diff --git a/src/app/components/OrganizationLanding.tsx b/src/components/OrganizationLanding.tsx similarity index 97% rename from src/app/components/OrganizationLanding.tsx rename to src/components/OrganizationLanding.tsx index a443fcf3..2f3125b0 100644 --- a/src/app/components/OrganizationLanding.tsx +++ b/src/components/OrganizationLanding.tsx @@ -11,6 +11,7 @@ import { import { Button } from "@/components/ui/button"; import Link from "next/link"; import { ArrowRight, Plus } from "lucide-react"; +import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; interface Organization { @@ -29,6 +30,8 @@ export default function OrganizationLanding({ }: OrganizationLandingProps) { const [selectedOrg, setSelectedOrg] = useState(null); + const { env } = useEnvContext(); + const handleOrgClick = (orgId: string) => { setSelectedOrg(orgId); }; diff --git a/src/components/ProfileIcon.tsx b/src/components/ProfileIcon.tsx index 275f9f71..0348058f 100644 --- a/src/components/ProfileIcon.tsx +++ b/src/components/ProfileIcon.tsx @@ -24,7 +24,7 @@ import SecurityKeyForm from "./SecurityKeyForm"; import Enable2FaDialog from "./Enable2FaDialog"; import SupporterStatus from "./SupporterStatus"; import { UserType } from "@server/types/UserTypes"; -import LocaleSwitcher from '@app/components/LocaleSwitcher'; +import LocaleSwitcher from "@app/components/LocaleSwitcher"; import { useTranslations } from "next-intl"; export default function ProfileIcon() { @@ -58,10 +58,10 @@ export default function ProfileIcon() { function logout() { api.post("/auth/logout") .catch((e) => { - console.error(t('logoutError'), e); + console.error(t("logoutError"), e); toast({ - title: t('logoutError'), - description: formatAxiosError(e, t('logoutError')) + title: t("logoutError"), + description: formatAxiosError(e, t("logoutError")) }); }) .then(() => { @@ -74,111 +74,102 @@ export default function ProfileIcon() { <> - - -
- - {user.name || user.email || user.username} - - - - - - + + + + + + +
+

+ {t("signingAs")} +

+

+ {user.email || user.name || user.username} +

+
+ {user.serverAdmin ? ( +

+ {t("serverAdmin")} +

+ ) : ( +

+ {user.idpName || t("idpNameInternal")} +

+ )} +
+ + {user?.type === UserType.Internal && ( + <> + {!user.twoFactorEnabled && ( + setOpenEnable2fa(true)} + > + {t("otpEnable")} + )} - - - {user?.type === UserType.Internal && ( - <> - {!user.twoFactorEnabled && ( - setOpenEnable2fa(true)} - > - {t('otpEnable')} - - )} - {user.twoFactorEnabled && ( - setOpenDisable2fa(true)} - > - {t('otpDisable')} - - )} + {user.twoFactorEnabled && ( setOpenSecurityKey(true)} + onClick={() => setOpenDisable2fa(true)} > - {t('securityKeyManage')} + {t("otpDisable")} - - - )} - {t("theme")} - {(["light", "dark", "system"] as const).map( - (themeOption) => ( - - handleThemeChange(themeOption) - } - > - {themeOption === "light" && ( - - )} - {themeOption === "dark" && ( - - )} - {themeOption === "system" && ( - - )} - - {t(themeOption)} + )} + setOpenSecurityKey(true)} + > + {t("securityKeyManage")} + + + + )} + {t("theme")} + {(["light", "dark", "system"] as const).map( + (themeOption) => ( + handleThemeChange(themeOption)} + > + {themeOption === "light" && ( + + )} + {themeOption === "dark" && ( + + )} + {themeOption === "system" && ( + + )} + + {t(themeOption)} + + {userTheme === themeOption && ( + + - {userTheme === themeOption && ( - - - - )} - - ) - )} - - - - logout()}> - {/* */} - {t('logout')} - -
-
-
+ )} + + ) + )} + + + + logout()}> + {/* */} + {t("logout")} + + + ); } diff --git a/src/components/SecurityKeyForm.tsx b/src/components/SecurityKeyForm.tsx index 5b5ab3bb..d4970947 100644 --- a/src/components/SecurityKeyForm.tsx +++ b/src/components/SecurityKeyForm.tsx @@ -15,23 +15,25 @@ import { FormField, FormItem, FormLabel, - FormMessage, + FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; import { Alert, AlertDescription } from "@app/components/ui/alert"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@app/components/ui/dialog"; + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; import { startRegistration } from "@simplewebauthn/browser"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { Card, CardContent } from "@app/components/ui/card"; import { Badge } from "@app/components/ui/badge"; -import { Loader2, KeyRound, Trash2, Plus, Shield } from "lucide-react"; +import { Loader2, KeyRound, Trash2, Plus, Shield, Info } from "lucide-react"; import { cn } from "@app/lib/cn"; type SecurityKeyFormProps = { @@ -53,6 +55,7 @@ type DeleteSecurityKeyData = { type RegisterFormValues = { name: string; password: string; + code?: string; }; type DeleteFormValues = { @@ -70,30 +73,47 @@ type FieldProps = { }; }; -export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) { +export default function SecurityKeyForm({ + open, + setOpen +}: SecurityKeyFormProps) { const t = useTranslations(); const { env } = useEnvContext(); const api = createApiClient({ env }); const [securityKeys, setSecurityKeys] = useState([]); const [isRegistering, setIsRegistering] = useState(false); - const [showRegisterDialog, setShowRegisterDialog] = useState(false); - const [selectedSecurityKey, setSelectedSecurityKey] = useState(null); - const [show2FADialog, setShow2FADialog] = useState(false); + const [dialogState, setDialogState] = useState< + "list" | "register" | "register2fa" | "delete" | "delete2fa" + >("list"); + const [selectedSecurityKey, setSelectedSecurityKey] = + useState(null); const [deleteInProgress, setDeleteInProgress] = useState(false); - const [pendingDeleteCredentialId, setPendingDeleteCredentialId] = useState(null); - const [pendingDeletePassword, setPendingDeletePassword] = useState(null); + const [pendingDeleteCredentialId, setPendingDeleteCredentialId] = useState< + string | null + >(null); + const [pendingDeletePassword, setPendingDeletePassword] = useState< + string | null + >(null); + const [pendingRegisterData, setPendingRegisterData] = useState<{ + name: string; + password: string; + } | null>(null); + const [register2FAForm, setRegister2FAForm] = useState<{ code: string }>({ + code: "" + }); useEffect(() => { loadSecurityKeys(); }, []); const registerSchema = z.object({ - name: z.string().min(1, { message: t('securityKeyNameRequired') }), - password: z.string().min(1, { message: t('passwordRequired') }), + name: z.string().min(1, { message: t("securityKeyNameRequired") }), + password: z.string().min(1, { message: t("passwordRequired") }), + code: z.string().optional() }); const deleteSchema = z.object({ - password: z.string().min(1, { message: t('passwordRequired') }), + password: z.string().min(1, { message: t("passwordRequired") }), code: z.string().optional() }); @@ -102,7 +122,8 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) defaultValues: { name: "", password: "", - }, + code: "" + } }); const deleteForm = useForm({ @@ -110,7 +131,7 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) defaultValues: { password: "", code: "" - }, + } }); const loadSecurityKeys = async () => { @@ -120,7 +141,7 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) } catch (error) { toast({ variant: "destructive", - description: formatAxiosError(error, t('securityKeyLoadError')), + description: formatAxiosError(error, t("securityKeyLoadError")) }); } }; @@ -131,88 +152,101 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) if (!window.PublicKeyCredential) { toast({ variant: "destructive", - description: t('securityKeyBrowserNotSupported', { - defaultValue: "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari." + description: t("securityKeyBrowserNotSupported", { + defaultValue: + "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari." }) }); return; } setIsRegistering(true); - const startRes = await api.post("/auth/security-key/register/start", { - name: values.name, - password: values.password, - }); + const startRes = await api.post( + "/auth/security-key/register/start", + { + name: values.name, + password: values.password, + code: values.code + } + ); - if (startRes.status === 202) { - toast({ - variant: "destructive", - description: t('twoFactorRequired', { - defaultValue: "Two-factor authentication is required to register a security key." - }) + // If 2FA is required + if (startRes.status === 202 && startRes.data.data?.codeRequested) { + setPendingRegisterData({ + name: values.name, + password: values.password }); + setDialogState("register2fa"); + setIsRegistering(false); return; } const options = startRes.data.data; - + try { const credential = await startRegistration(options); await api.post("/auth/security-key/register/verify", { - credential, + credential }); toast({ - description: t('securityKeyRegisterSuccess', { + description: t("securityKeyRegisterSuccess", { defaultValue: "Security key registered successfully" }) }); registerForm.reset(); - setShowRegisterDialog(false); + setDialogState("list"); await loadSecurityKeys(); } catch (error: any) { - if (error.name === 'NotAllowedError') { - if (error.message.includes('denied permission')) { + if (error.name === "NotAllowedError") { + if (error.message.includes("denied permission")) { toast({ variant: "destructive", - description: t('securityKeyPermissionDenied', { - defaultValue: "Please allow access to your security key to continue registration." + description: t("securityKeyPermissionDenied", { + defaultValue: + "Please allow access to your security key to continue registration." }) }); } else { toast({ variant: "destructive", - description: t('securityKeyRemovedTooQuickly', { - defaultValue: "Please keep your security key connected until the registration process completes." + description: t("securityKeyRemovedTooQuickly", { + defaultValue: + "Please keep your security key connected until the registration process completes." }) }); } - } else if (error.name === 'NotSupportedError') { + } else if (error.name === "NotSupportedError") { toast({ variant: "destructive", - description: t('securityKeyNotSupported', { - defaultValue: "Your security key may not be compatible. Please try a different security key." + description: t("securityKeyNotSupported", { + defaultValue: + "Your security key may not be compatible. Please try a different security key." }) }); } else { toast({ variant: "destructive", - description: t('securityKeyUnknownError', { - defaultValue: "There was a problem registering your security key. Please try again." + description: t("securityKeyUnknownError", { + defaultValue: + "There was a problem registering your security key. Please try again." }) }); } throw error; // Re-throw to be caught by outer catch } } catch (error) { - console.error('Security key registration error:', error); + console.error("Security key registration error:", error); toast({ variant: "destructive", - description: formatAxiosError(error, t('securityKeyRegisterError', { - defaultValue: "Failed to register security key" - })) + description: formatAxiosError( + error, + t("securityKeyRegisterError", { + defaultValue: "Failed to register security key" + }) + ) }); } finally { setIsRegistering(false); @@ -224,33 +258,42 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) try { setDeleteInProgress(true); - const encodedCredentialId = encodeURIComponent(selectedSecurityKey.credentialId); - const response = await api.delete(`/auth/security-key/${encodedCredentialId}`, { - data: { - password: values.password, - code: values.code + const encodedCredentialId = encodeURIComponent( + selectedSecurityKey.credentialId + ); + const response = await api.delete( + `/auth/security-key/${encodedCredentialId}`, + { + data: { + password: values.password, + code: values.code + } } - }); + ); // If 2FA is required if (response.status === 202 && response.data.data.codeRequested) { setPendingDeleteCredentialId(encodedCredentialId); setPendingDeletePassword(values.password); - setShow2FADialog(true); + setDialogState("delete2fa"); return; } toast({ - description: t('securityKeyRemoveSuccess') + description: t("securityKeyRemoveSuccess") }); deleteForm.reset(); setSelectedSecurityKey(null); + setDialogState("list"); await loadSecurityKeys(); } catch (error) { toast({ variant: "destructive", - description: formatAxiosError(error, t('securityKeyRemoveError')), + description: formatAxiosError( + error, + t("securityKeyRemoveError") + ) }); } finally { setDeleteInProgress(false); @@ -262,33 +305,128 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) try { setDeleteInProgress(true); - await api.delete(`/auth/security-key/${pendingDeleteCredentialId}`, { - data: { - password: pendingDeletePassword, - code: values.code + await api.delete( + `/auth/security-key/${pendingDeleteCredentialId}`, + { + data: { + password: pendingDeletePassword, + code: values.code + } } - }); + ); toast({ - description: t('securityKeyRemoveSuccess') + description: t("securityKeyRemoveSuccess") }); deleteForm.reset(); setSelectedSecurityKey(null); - setShow2FADialog(false); + setDialogState("list"); setPendingDeleteCredentialId(null); setPendingDeletePassword(null); await loadSecurityKeys(); } catch (error) { toast({ variant: "destructive", - description: formatAxiosError(error, t('securityKeyRemoveError')), + description: formatAxiosError( + error, + t("securityKeyRemoveError") + ) }); } finally { setDeleteInProgress(false); } }; + const handleRegister2FASubmit = async (values: { code: string }) => { + if (!pendingRegisterData) return; + + try { + setIsRegistering(true); + const startRes = await api.post( + "/auth/security-key/register/start", + { + name: pendingRegisterData.name, + password: pendingRegisterData.password, + code: values.code + } + ); + + const options = startRes.data.data; + + try { + const credential = await startRegistration(options); + + await api.post("/auth/security-key/register/verify", { + credential + }); + + toast({ + description: t("securityKeyRegisterSuccess", { + defaultValue: "Security key registered successfully" + }) + }); + + registerForm.reset(); + setDialogState("list"); + setPendingRegisterData(null); + setRegister2FAForm({ code: "" }); + await loadSecurityKeys(); + } catch (error: any) { + if (error.name === "NotAllowedError") { + if (error.message.includes("denied permission")) { + toast({ + variant: "destructive", + description: t("securityKeyPermissionDenied", { + defaultValue: + "Please allow access to your security key to continue registration." + }) + }); + } else { + toast({ + variant: "destructive", + description: t("securityKeyRemovedTooQuickly", { + defaultValue: + "Please keep your security key connected until the registration process completes." + }) + }); + } + } else if (error.name === "NotSupportedError") { + toast({ + variant: "destructive", + description: t("securityKeyNotSupported", { + defaultValue: + "Your security key may not be compatible. Please try a different security key." + }) + }); + } else { + toast({ + variant: "destructive", + description: t("securityKeyUnknownError", { + defaultValue: + "There was a problem registering your security key. Please try again." + }) + }); + } + throw error; // Re-throw to be caught by outer catch + } + } catch (error) { + console.error("Security key registration error:", error); + toast({ + variant: "destructive", + description: formatAxiosError( + error, + t("securityKeyRegisterError", { + defaultValue: "Failed to register security key" + }) + ) + }); + setRegister2FAForm({ code: "" }); + } finally { + setIsRegistering(false); + } + }; + const onOpenChange = (open: boolean) => { if (open) { loadSecurityKeys(); @@ -296,292 +434,423 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) registerForm.reset(); deleteForm.reset(); setSelectedSecurityKey(null); - setShowRegisterDialog(false); + setDialogState("list"); + setPendingRegisterData(null); + setRegister2FAForm({ code: "" }); } setOpen(open); }; return ( <> - - - - - - {t('securityKeyManage')} - - - {t('securityKeyDescription')} - - + + + {dialogState === "list" && ( + <> + + + {t("securityKeyManage")} + + + {t("securityKeyDescription")} + + + +
+
+

+ {t("securityKeyList")} +

+ +
-
-
-

{t('securityKeyList')}

- -
+ {securityKeys.length > 0 ? ( +
+ {securityKeys.map((securityKey) => ( + + +
+
+ +
+
+

+ { + securityKey.name + } +

+

+ {t( + "securityKeyLastUsed", + { + date: new Date( + securityKey.lastUsed + ).toLocaleDateString() + } + )} +

+
+
+ +
+
+ ))} +
+ ) : ( +
+ +

+ {t("securityKeyNoKeysRegistered")} +

+

+ {t("securityKeyNoKeysDescription")} +

+
+ )} - {securityKeys.length > 0 ? ( -
- {securityKeys.map((securityKey) => ( - - -
-
- -
-
-

{securityKey.name}

-

- {t('securityKeyLastUsed', { - date: new Date(securityKey.lastUsed).toLocaleDateString() - })} -

-
-
- -
-
- ))} -
- ) : ( -
- -

No security keys registered

-

Add a security key to enhance your account security

-
- )} + {securityKeys.length === 1 && ( + + + + {t("securityKeyRecommendation")} + + + )} +
+ + + )} - {securityKeys.length === 1 && ( - - {t('securityKeyRecommendation')} - - )} -
-
-
- - - - - Register New Security Key - - Connect your security key and enter a name to identify it - - - - - - ( - - {t('securityKeyNameLabel')} - - - - - - )} - /> - ( - - {t('password')} - - - - - - )} - /> - - + {dialogState === "register" && ( + <> + + + {t("securityKeyRegisterTitle")} + + + {t("securityKeyRegisterDescription")} + + + + + + ( + + + {t( + "securityKeyNameLabel" + )} + + + + + + + )} + /> + ( + + + {t("password")} + + + + + + + )} + /> + + + + + + + - - - - - - + + + )} - !open && setSelectedSecurityKey(null)}> - - - - - Remove Security Key - - - Enter your password to remove the security key "{selectedSecurityKey?.name}" - - - -
- - ( - - {t('password')} - - - - - - )} - /> - - + {dialogState === "register2fa" && ( + <> + + + {t("securityKeyTwoFactorRequired")} + + + {t("securityKeyTwoFactorDescription")} + + + +
+
+ + + setRegister2FAForm({ + code: e.target.value + }) + } + maxLength={6} + disabled={isRegistering} + /> +
+
+
+ + + + + + + )} + + {dialogState === "delete" && ( + <> + + + {t("securityKeyRemoveTitle")} + + + {t("securityKeyRemoveDescription", { name: selectedSecurityKey!.name! })} + + + + + + ( + + + {t("password")} + + + + + + + )} + /> + + + + + + + -
- - -
-
+ + + )} - !open && setShow2FADialog(false)}> - - - Two-Factor Authentication Required - - Please enter your two-factor authentication code to remove the security key - - - -
- - ( - - Two-Factor Code - - - - - - )} - /> - - - + {dialogState === "delete2fa" && ( + <> + + + {t("securityKeyTwoFactorRequired")} + + + {t("securityKeyTwoFactorRemoveDescription")} + + + + + + ( + + + {t("securityKeyTwoFactorCode")} + + + + + + + )} + /> + + + + + + + - - - -
-
+ + + )} + + ); -} \ No newline at end of file +} diff --git a/src/components/SetLastOrgCookie.tsx b/src/components/SetLastOrgCookie.tsx new file mode 100644 index 00000000..a142df9e --- /dev/null +++ b/src/components/SetLastOrgCookie.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useEffect } from "react"; + +interface SetLastOrgCookieProps { + orgId: string; +} + +export default function SetLastOrgCookie({ orgId }: SetLastOrgCookieProps) { + useEffect(() => { + const isSecure = + typeof window !== "undefined" && + window.location.protocol === "https:"; + + document.cookie = `pangolin-last-org=${orgId}; path=/; max-age=${60 * 60 * 24 * 30}; samesite=lax${isSecure ? "; secure" : ""}`; + }, [orgId]); + + return null; +} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 410d3093..796a4bc8 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -19,7 +19,7 @@ export function SettingsSectionForm({ }: { children: React.ReactNode; }) { - return
{children}
; + return
{children}
; } export function SettingsSectionTitle({ diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index d90a5093..7e8ad336 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -1,35 +1,45 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React from "react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; import { cn } from "@app/lib/cn"; -import { ChevronDown, ChevronRight } from "lucide-react"; import { useUserContext } from "@app/hooks/useUserContext"; import { Badge } from "@app/components/ui/badge"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { useTranslations } from "next-intl"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@app/components/ui/tooltip"; -export interface SidebarNavItem { +export type SidebarNavItem = { href: string; title: string; icon?: React.ReactNode; - children?: SidebarNavItem[]; - autoExpand?: boolean; showProfessional?: boolean; -} +}; + +export type SidebarNavSection = { + heading: string; + items: SidebarNavItem[]; +}; export interface SidebarNavProps extends React.HTMLAttributes { - items: SidebarNavItem[]; + sections: SidebarNavSection[]; disabled?: boolean; onItemClick?: () => void; + isCollapsed?: boolean; } export function SidebarNav({ className, - items, + sections, disabled = false, onItemClick, + isCollapsed = false, ...props }: SidebarNavProps) { const pathname = usePathname(); @@ -38,34 +48,10 @@ export function SidebarNav({ const niceId = params.niceId as string; const resourceId = params.resourceId as string; const userId = params.userId as string; - const [expandedItems, setExpandedItems] = useState>(() => { - const autoExpanded = new Set(); - - function findAutoExpandedAndActivePath( - items: SidebarNavItem[], - parentHrefs: string[] = [] - ) { - items.forEach((item) => { - const hydratedHref = hydrateHref(item.href); - const currentPath = [...parentHrefs, hydratedHref]; - - if (item.autoExpand || pathname.startsWith(hydratedHref)) { - currentPath.forEach((href) => autoExpanded.add(href)); - } - - if (item.children) { - findAutoExpandedAndActivePath(item.children, currentPath); - } - }); - } - - findAutoExpandedAndActivePath(items); - return autoExpanded; - }); + const apiKeyId = params.apiKeyId as string; + const clientId = params.clientId as string; const { licenseStatus, isUnlocked } = useLicenseStatusContext(); - const { user } = useUserContext(); - const t = useTranslations(); function hydrateHref(val: string): string { @@ -73,119 +59,114 @@ export function SidebarNav({ .replace("{orgId}", orgId) .replace("{niceId}", niceId) .replace("{resourceId}", resourceId) - .replace("{userId}", userId); + .replace("{userId}", userId) + .replace("{apiKeyId}", apiKeyId) + .replace("{clientId}", clientId); } - function toggleItem(href: string) { - setExpandedItems((prev) => { - const newSet = new Set(prev); - if (newSet.has(href)) { - newSet.delete(href); - } else { - newSet.add(href); - } - return newSet; - }); - } + const renderNavItem = ( + item: SidebarNavItem, + hydratedHref: string, + isActive: boolean, + isDisabled: boolean + ) => { + const tooltipText = + item.showProfessional && !isUnlocked() + ? `${t(item.title)} (${t("licenseBadge")})` + : t(item.title); - function renderItems(items: SidebarNavItem[], level = 0) { - return items.map((item) => { - const hydratedHref = hydrateHref(item.href); - const isActive = pathname.startsWith(hydratedHref); - const hasChildren = item.children && item.children.length > 0; - const isExpanded = expandedItems.has(hydratedHref); - const indent = level * 28; // Base indent for each level - const isProfessional = item.showProfessional && !isUnlocked(); - const isDisabled = disabled || isProfessional; - - return ( -
-
{ + if (isDisabled) { + e.preventDefault(); + } else if (onItemClick) { + onItemClick(); + } + }} + tabIndex={isDisabled ? -1 : undefined} + aria-disabled={isDisabled} + > + {item.icon && ( + -
- { - if (isDisabled) { - e.preventDefault(); - } else if (onItemClick) { - onItemClick(); - } - }} - tabIndex={isDisabled ? -1 : undefined} - aria-disabled={isDisabled} - > -
- {item.icon && ( - - {item.icon} - - )} - {t(item.title)} -
- {isProfessional && ( - - {t('licenseBadge')} - - )} - - {hasChildren && ( - - )} -
-
- {hasChildren && isExpanded && ( -
- {renderItems(item.children || [], level + 1)} -
- )} -
+ {item.icon} + + )} + {!isCollapsed && ( + <> + {t(item.title)} + {item.showProfessional && !isUnlocked() && ( + + {t("licenseBadge")} + + )} + + )} + + ); + + if (isCollapsed) { + return ( + + + {itemContent} + +

{tooltipText}

+
+
+
); - }); - } + } + + return ( + {itemContent} + ); + }; return ( ); } diff --git a/src/components/SidebarSettings.tsx b/src/components/SidebarSettings.tsx deleted file mode 100644 index 04b68810..00000000 --- a/src/components/SidebarSettings.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -import { SidebarNav } from "@app/components/SidebarNav"; -import React from "react"; - -interface SideBarSettingsProps { - children: React.ReactNode; - sidebarNavItems: Array<{ - title: string; - href: string; - icon?: React.ReactNode; - }>; - disabled?: boolean; - limitWidth?: boolean; -} - -export function SidebarSettings({ - children, - sidebarNavItems, - disabled, - limitWidth -}: SideBarSettingsProps) { - return ( -
-
- -
- {children} -
-
-
- ); -} diff --git a/src/app/components/SupporterMessage.tsx b/src/components/SupporterMessage.tsx similarity index 100% rename from src/app/components/SupporterMessage.tsx rename to src/components/SupporterMessage.tsx diff --git a/src/components/SupporterStatus.tsx b/src/components/SupporterStatus.tsx index a17b9b9f..74d4bf8b 100644 --- a/src/components/SupporterStatus.tsx +++ b/src/components/SupporterStatus.tsx @@ -9,6 +9,12 @@ import { PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@app/components/ui/tooltip"; import { Button } from "./ui/button"; import { Credenza, @@ -46,18 +52,23 @@ import { CardHeader, CardTitle } from "./ui/card"; -import { Check, ExternalLink } from "lucide-react"; +import { Check, ExternalLink, Heart } from "lucide-react"; import confetti from "canvas-confetti"; import { useTranslations } from "next-intl"; -export default function SupporterStatus() { +interface SupporterStatusProps { + isCollapsed?: boolean; +} + +export default function SupporterStatus({ isCollapsed = false }: SupporterStatusProps) { const { supporterStatus, updateSupporterStatus } = useSupporterStatusContext(); const [supportOpen, setSupportOpen] = useState(false); const [keyOpen, setKeyOpen] = useState(false); const [purchaseOptionsOpen, setPurchaseOptionsOpen] = useState(false); - const api = createApiClient(useEnvContext()); + const { env } = useEnvContext(); + const api = createApiClient({ env }); const t = useTranslations(); const formSchema = z.object({ @@ -411,16 +422,36 @@ export default function SupporterStatus() { {supporterStatus?.visible ? ( - + isCollapsed ? ( + + + + + + +

{t('supportKeyBuy')}

+
+
+
+ ) : ( + + ) ) : null} ); diff --git a/src/components/SwitchInput.tsx b/src/components/SwitchInput.tsx index fd312115..a2291c2e 100644 --- a/src/components/SwitchInput.tsx +++ b/src/components/SwitchInput.tsx @@ -4,7 +4,7 @@ import { Label } from "./ui/label"; interface SwitchComponentProps { id: string; - label: string; + label?: string; description?: string; checked?: boolean; defaultChecked?: boolean; @@ -31,7 +31,7 @@ export function SwitchInput({ onCheckedChange={onCheckedChange} disabled={disabled} /> - + {label && }
{description && ( diff --git a/src/components/ThemeSwitcher.tsx b/src/components/ThemeSwitcher.tsx new file mode 100644 index 00000000..5605ec31 --- /dev/null +++ b/src/components/ThemeSwitcher.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { Laptop, Moon, Sun } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; + +export default function ThemeSwitcher() { + const { setTheme, theme, resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + const t = useTranslations(); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return ( + + ); + } + + function cycleTheme() { + const currentTheme = theme || "system"; + + if (currentTheme === "light") { + setTheme("dark"); + } else if (currentTheme === "dark") { + setTheme("system"); + } else { + setTheme("light"); + } + } + + function getThemeIcon() { + const currentTheme = theme || "system"; + + if (currentTheme === "light") { + return ; + } else if (currentTheme === "dark") { + return ; + } else { + // When theme is "system", show icon based on resolved theme + if (resolvedTheme === "light") { + return ; + } else if (resolvedTheme === "dark") { + return ; + } else { + // Fallback to laptop icon if resolvedTheme is not available + return ; + } + } + } + + function getThemeText() { + const currentTheme = theme || "system"; + const translated = t(currentTheme); + return translated.charAt(0).toUpperCase() + translated.slice(1); + } + + return ( + + ); +} diff --git a/src/components/tags/tag-input.tsx b/src/components/tags/tag-input.tsx index ad8c0d03..789a127c 100644 --- a/src/components/tags/tag-input.tsx +++ b/src/components/tags/tag-input.tsx @@ -173,7 +173,7 @@ const TagInput = React.forwardRef( (maxTags !== undefined && maxTags < 0) || (props.minTags !== undefined && props.minTags < 0) ) { - console.warn(t('tagsWarnCannotBeLessThanZero')); + console.warn(t("tagsWarnCannotBeLessThanZero")); // error return null; } @@ -197,22 +197,28 @@ const TagInput = React.forwardRef( (option) => option.text === newTagText ) ) { - console.warn(t('tagsWarnNotAllowedAutocompleteOptions')); + console.warn( + t("tagsWarnNotAllowedAutocompleteOptions") + ); return; } if (validateTag && !validateTag(newTagText)) { - console.warn(t('tagsWarnInvalid')); + console.warn(t("tagsWarnInvalid")); return; } if (minLength && newTagText.length < minLength) { - console.warn(t('tagWarnTooShort', {tagText: newTagText})); + console.warn( + t("tagWarnTooShort", { tagText: newTagText }) + ); return; } if (maxLength && newTagText.length > maxLength) { - console.warn(t('tagWarnTooLong', {tagText: newTagText})); + console.warn( + t("tagWarnTooLong", { tagText: newTagText }) + ); return; } @@ -229,10 +235,12 @@ const TagInput = React.forwardRef( setTags((prevTags) => [...prevTags, newTag]); onTagAdd?.(newTagText); } else { - console.warn(t('tagsWarnReachedMaxNumber')); + console.warn(t("tagsWarnReachedMaxNumber")); } } else { - console.warn(t('tagWarnDuplicate', {tagText: newTagText})); + console.warn( + t("tagWarnDuplicate", { tagText: newTagText }) + ); } }); setInputValue(""); @@ -258,12 +266,12 @@ const TagInput = React.forwardRef( } if (minLength && newTagText.length < minLength) { - console.warn(t('tagWarnTooShort')); + console.warn(t("tagWarnTooShort")); return; } if (maxLength && newTagText.length > maxLength) { - console.warn(t('tagWarnTooLong')); + console.warn(t("tagWarnTooLong")); return; } @@ -308,7 +316,7 @@ const TagInput = React.forwardRef( } if (minLength && newTagText.length < minLength) { - console.warn(t('tagWarnTooShort')); + console.warn(t("tagWarnTooShort")); // error return; } @@ -316,7 +324,7 @@ const TagInput = React.forwardRef( // Validate maxLength if (maxLength && newTagText.length > maxLength) { // error - console.warn(t('tagWarnTooLong')); + console.warn(t("tagWarnTooLong")); return; } @@ -489,7 +497,7 @@ const TagInput = React.forwardRef(
@@ -536,7 +544,7 @@ const TagInput = React.forwardRef( onBlur={handleInputBlur} {...inputProps} className={cn( - "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit", + "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", // className, styleClasses?.input )} @@ -622,7 +630,7 @@ const TagInput = React.forwardRef( onBlur={handleInputBlur} {...inputProps} className={cn( - "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit", + "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", // className, styleClasses?.input )} @@ -643,7 +651,7 @@ const TagInput = React.forwardRef( ) : (
@@ -710,7 +718,7 @@ const TagInput = React.forwardRef( onBlur={handleInputBlur} {...inputProps} className={cn( - "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit", + "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", // className, styleClasses?.input )} @@ -791,7 +799,7 @@ const TagInput = React.forwardRef( onBlur={handleInputBlur} {...inputProps} className={cn( - "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit", + "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", // className, styleClasses?.input )} @@ -834,7 +842,8 @@ const TagInput = React.forwardRef( onBlur={handleInputBlur} {...inputProps} className={cn( - styleClasses?.input + styleClasses?.input, + "shadow-none inset-shadow-none" // className )} autoComplete={ @@ -908,7 +917,7 @@ const TagInput = React.forwardRef( tags.length >= maxTags) } className={cn( - "border-0 w-full", + "border-0 w-full shadow-none inset-shadow-none", styleClasses?.input // className )} diff --git a/src/components/tags/tag.tsx b/src/components/tags/tag.tsx index ccc489e4..938a2669 100644 --- a/src/components/tags/tag.tsx +++ b/src/components/tags/tag.tsx @@ -127,7 +127,7 @@ export const Tag: React.FC = ({ { "justify-between w-full": direction === "column", "cursor-pointer": draggable, - "ring-ring ring-offset-2 ring-2 ring-offset-background": + "ring-ring ring-offset-0 ring-2 ring-offset-background": isActiveTag }, tagClasses?.body diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx index 3783ecfe..e6fad743 100644 --- a/src/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -14,14 +14,13 @@ const alertVariants = cva( "border-destructive/50 border bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive", success: "border-green-500/50 border bg-green-500/10 text-green-500 dark:border-success [&>svg]:text-green-500", - info: - "border-blue-500/50 border bg-blue-500/10 text-blue-500 dark:border-blue-400 [&>svg]:text-blue-500", - }, + info: "border-blue-500/50 border bg-blue-500/10 text-blue-500 dark:border-blue-400 [&>svg]:text-blue-500" + } }, defaultVariants: { - variant: "default", - }, - }, + variant: "default" + } + } ); const Alert = React.forwardRef< @@ -45,7 +44,7 @@ const AlertTitle = React.forwardRef< ref={ref} className={cn( "mb-1 font-medium leading-none tracking-tight", - className, + className )} {...props} /> diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index 222a234f..3bcf2bea 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@app/lib/cn"; const badgeVariants = cva( - "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-0", { variants: { variant: { @@ -16,9 +16,9 @@ const badgeVariants = cva( destructive: "border-transparent bg-destructive text-destructive-foreground", outline: "text-foreground", - green: "border-transparent bg-green-500", - yellow: "border-transparent bg-yellow-500", - red: "border-transparent bg-red-300", + green: "border-green-600 bg-green-500/20 text-green-700 dark:text-green-300", + yellow: "border-yellow-600 bg-yellow-500/20 text-yellow-700 dark:text-yellow-300", + red: "border-red-400 bg-red-300/20 text-red-600 dark:text-red-300", }, }, defaultVariants: { diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 43f420d7..d77bc39a 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -6,35 +6,35 @@ import { cn } from "@app/lib/cn"; import { Loader2 } from "lucide-react"; const buttonVariants = cva( - "cursor-pointer inline-flex items-center justify-center rounded-full whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + "cursor-pointer inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50", { variants: { variant: { default: - "bg-primary text-primary-foreground hover:bg-primary/90", + "bg-primary text-primary-foreground hover:bg-primary/90 shadow-2xs", destructive: - "bg-destructive text-destructive-foreground hover:bg-destructive/90", + "bg-destructive text-white dark:text-destructive-foreground hover:bg-destructive/90 shadow-2xs", outline: - "border border-input bg-card hover:bg-accent hover:text-accent-foreground", + "border border-input bg-card hover:bg-accent hover:text-accent-foreground shadow-2xs", outlinePrimary: - "border border-primary bg-card hover:bg-primary/10 text-primary", + "border border-primary bg-card hover:bg-primary/10 text-primary shadow-2xs", secondary: - "bg-secondary border border-input border text-secondary-foreground hover:bg-secondary/80", + "bg-secondary border border-input border text-secondary-foreground hover:bg-secondary/80 shadow-2xs", ghost: "hover:bg-accent hover:text-accent-foreground", squareOutlinePrimary: - "border border-primary bg-card hover:bg-primary/10 text-primary rounded-md", + "border border-primary bg-card hover:bg-primary/10 text-primary rounded-md shadow-2xs", squareOutline: - "border border-input bg-card hover:bg-accent hover:text-accent-foreground rounded-md", + "border border-input bg-card hover:bg-accent hover:text-accent-foreground rounded-md shadow-2xs", squareDefault: - "bg-primary text-primary-foreground hover:bg-primary/90 rounded-md", + "bg-primary text-primary-foreground hover:bg-primary/90 rounded-md shadow-2xs", text: "", link: "text-primary underline-offset-4 hover:underline" }, size: { - default: "h-9 px-4 py-2", + default: "h-9 rounded-md px-3", sm: "h-8 rounded-md px-3", lg: "h-10 rounded-md px-8", - icon: "h-9 w-9" + icon: "h-9 w-9 rounded-md" } }, defaultVariants: { diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index ad22b8fa..e1bd4ddc 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -9,7 +9,7 @@ import { cva, type VariantProps } from "class-variance-authority"; // Define checkbox variants const checkboxVariants = cva( - "peer h-4 w-4 shrink-0 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", + "peer h-4 w-4 shrink-0 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50", { variants: { variant: { diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 447bbb44..59e98ed3 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -1,155 +1,183 @@ "use client"; import * as React from "react"; -import { type DialogProps } from "@radix-ui/react-dialog"; import { Command as CommandPrimitive } from "cmdk"; -import { Search } from "lucide-react"; +import { SearchIcon } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; import { cn } from "@app/lib/cn"; -import { Dialog, DialogContent } from "@/components/ui/dialog"; -const Command = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -Command.displayName = CommandPrimitive.displayName; +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} -interface CommandDialogProps extends DialogProps {} +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string; + description?: string; + className?: string; + showCloseButton?: boolean; +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ); +} -const CommandDialog = ({ children, ...props }: CommandDialogProps) => { - return ( - - - - {children} - - - - ); -}; +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ); +} -const CommandInput = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( -
- - -
-)); +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} -CommandInput.displayName = CommandPrimitive.Input.displayName; +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ); +} -const CommandList = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} -CommandList.displayName = CommandPrimitive.List.displayName; +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} -const CommandEmpty = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->((props, ref) => ( - -)); +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} -CommandEmpty.displayName = CommandPrimitive.Empty.displayName; - -const CommandGroup = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); - -CommandGroup.displayName = CommandPrimitive.Group.displayName; - -const CommandSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -CommandSeparator.displayName = CommandPrimitive.Separator.displayName; - -const CommandItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); - -CommandItem.displayName = CommandPrimitive.Item.displayName; - -const CommandShortcut = ({ - className, - ...props -}: React.HTMLAttributes) => { - return ( - - ); -}; -CommandShortcut.displayName = "CommandShortcut"; +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} export { - Command, - CommandDialog, - CommandInput, - CommandList, - CommandEmpty, - CommandGroup, - CommandItem, - CommandShortcut, - CommandSeparator, + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator }; diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index c9556052..6b22ddfe 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -23,13 +23,14 @@ import { Button } from "@app/components/ui/button"; import { useState } from "react"; import { Input } from "@app/components/ui/input"; import { DataTablePagination } from "@app/components/DataTablePagination"; -import { Plus, Search } from "lucide-react"; +import { Plus, Search, RefreshCw } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@app/components/ui/card"; +import { useTranslations } from "next-intl"; type DataTableProps = { columns: ColumnDef[]; @@ -37,6 +38,8 @@ type DataTableProps = { title?: string; addButtonText?: string; onAdd?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; searchPlaceholder?: string; searchColumn?: string; defaultSort?: { @@ -51,6 +54,8 @@ export function DataTable({ title, addButtonText, onAdd, + onRefresh, + isRefreshing, searchPlaceholder = "Search...", searchColumn = "name", defaultSort @@ -60,6 +65,7 @@ export function DataTable({ ); const [columnFilters, setColumnFilters] = useState([]); const [globalFilter, setGlobalFilter] = useState([]); + const t = useTranslations(); const table = useReactTable({ data, @@ -87,8 +93,8 @@ export function DataTable({ return (
- -
+ +
({ />
- {onAdd && addButtonText && ( - - )} +
+ {onRefresh && ( + + )} + {onAdd && addButtonText && ( + + )} +
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 33dc0438..da9e6504 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -38,13 +38,13 @@ const DialogContent = React.forwardRef< {children} - + Close diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index efe369d8..be0861d8 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -2,199 +2,263 @@ import * as React from "react"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; -import { Check, ChevronRight, Circle } from "lucide-react"; - +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; import { cn } from "@app/lib/cn"; -const DropdownMenu = DropdownMenuPrimitive.Root; +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return ; +} -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} -const DropdownMenuGroup = DropdownMenuPrimitive.Group; +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} -const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} -const DropdownMenuSub = DropdownMenuPrimitive.Sub; +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} -const DropdownMenuSubTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean - } ->(({ className, inset, children, ...props }, ref) => ( - - {children} - - -)); -DropdownMenuSubTrigger.displayName = - DropdownMenuPrimitive.SubTrigger.displayName; +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} -const DropdownMenuSubContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DropdownMenuSubContent.displayName = - DropdownMenuPrimitive.SubContent.displayName; +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} -const DropdownMenuContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - - - -)); -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} -const DropdownMenuItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean - } ->(({ className, inset, ...props }, ref) => ( - -)); -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} -const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, checked, ...props }, ref) => ( - - - - - - - {children} - -)); -DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName; +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} -const DropdownMenuRadioItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - - - - {children} - -)); -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} -const DropdownMenuLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean - } ->(({ className, inset, ...props }, ref) => ( - -)); -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return ( + + ); +} -const DropdownMenuSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} -const DropdownMenuShortcut = ({ - className, - ...props -}: React.HTMLAttributes) => { - return ( - - ); -}; -DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} export { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuRadioGroup, + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent }; diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx index 963404e7..1d9a7250 100644 --- a/src/components/ui/form.tsx +++ b/src/components/ui/form.tsx @@ -5,15 +5,16 @@ import * as LabelPrimitive from "@radix-ui/react-label"; import { Slot } from "@radix-ui/react-slot"; import { Controller, - ControllerProps, - FieldPath, - FieldValues, FormProvider, - useFormContext + useFormContext, + useFormState, + type ControllerProps, + type FieldPath, + type FieldValues } from "react-hook-form"; -import { cn } from "@app/lib/cn"; import { Label } from "@/components/ui/label"; +import { cn } from "@app/lib/cn"; const Form = FormProvider; @@ -44,8 +45,8 @@ const FormField = < const useFormField = () => { const fieldContext = React.useContext(FormFieldContext); const itemContext = React.useContext(FormItemContext); - const { getFieldState, formState } = useFormContext(); - + const { getFieldState } = useFormContext(); + const formState = useFormState({ name: fieldContext.name }); const fieldState = getFieldState(fieldContext.name, formState); if (!fieldContext) { @@ -72,47 +73,44 @@ const FormItemContext = React.createContext( {} as FormItemContextValue ); -const FormItem = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => { +function FormItem({ className, ...props }: React.ComponentProps<"div">) { const id = React.useId(); return ( -
+
); -}); -FormItem.displayName = "FormItem"; +} -const FormLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { +function FormLabel({ + className, + ...props +}: React.ComponentProps) { const { error, formItemId } = useFormField(); return (