diff --git a/.gitignore b/.gitignore index 2fc6b10b..700963cc 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,5 @@ postgres/ dynamic/ *.mmdb scratch/ -tsconfig.json \ No newline at end of file +tsconfig.json +hydrateSaas.ts \ No newline at end of file diff --git a/.nvmrc b/.nvmrc index 2bd5a0a9..7273c0fa 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22 +25 diff --git a/Dockerfile b/Dockerfile index 57c40198..0644a1c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,12 @@ -FROM node:22-alpine AS builder +FROM node:25-alpine AS builder WORKDIR /app ARG BUILD=oss ARG DATABASE=sqlite +RUN apk add --no-cache curl tzdata python3 make g++ + # COPY package.json package-lock.json ./ COPY package*.json ./ RUN npm ci @@ -41,12 +43,13 @@ RUN test -f dist/server.mjs RUN npm run build:cli -FROM node:22-alpine AS runner +FROM node:25-alpine AS runner WORKDIR /app # Curl used for the health checks -RUN apk add --no-cache curl tzdata +# Python and build tools needed for better-sqlite3 native compilation +RUN apk add --no-cache curl tzdata python3 make g++ # COPY package.json package-lock.json ./ COPY package*.json ./ diff --git a/blueprint.yaml b/blueprint.yaml index 0a524f12..adc25055 100644 --- a/blueprint.yaml +++ b/blueprint.yaml @@ -31,6 +31,7 @@ proxy-resources: # - owen@pangolin.net # whitelist-users: # - owen@pangolin.net + # auto-login-idp: 1 headers: - name: X-Example-Header value: example-value diff --git a/bruno/Auth/login.bru b/bruno/Auth/login.bru index 4ca7b412..3825a252 100644 --- a/bruno/Auth/login.bru +++ b/bruno/Auth/login.bru @@ -5,14 +5,14 @@ meta { } post { - url: http://localhost:4000/api/v1/auth/login + url: http://localhost:3000/api/v1/auth/login body: json auth: none } body:json { { - "email": "owen@pangolin.net", + "email": "admin@fosrl.io", "password": "Password123!" } } diff --git a/bruno/Olm/createOlm.bru b/bruno/Olm/createOlm.bru new file mode 100644 index 00000000..ca755dea --- /dev/null +++ b/bruno/Olm/createOlm.bru @@ -0,0 +1,15 @@ +meta { + name: createOlm + type: http + seq: 1 +} + +put { + url: http://localhost:3000/api/v1/olm + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/bruno/Olm/folder.bru b/bruno/Olm/folder.bru new file mode 100644 index 00000000..d245e6d1 --- /dev/null +++ b/bruno/Olm/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Olm + seq: 15 +} + +auth { + mode: inherit +} diff --git a/bruno/bruno.json b/bruno/bruno.json index f0ed66b3..f19d936a 100644 --- a/bruno/bruno.json +++ b/bruno/bruno.json @@ -1,6 +1,6 @@ { "version": "1", - "name": "Pangolin Saas", + "name": "Pangolin", "type": "collection", "ignore": [ "node_modules", diff --git a/config/config.example.yml b/config/config.example.yml index e4af586c..7eeebf81 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -25,4 +25,3 @@ flags: disable_user_create_org: true allow_raw_resources: true enable_integration_api: true - enable_clients: true diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 21a5134f..84a5140b 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -35,7 +35,7 @@ services: - 80:80 # Port for traefik because of the network_mode traefik: - image: traefik:v3.5 + image: traefik:v3.6 container_name: traefik restart: unless-stopped network_mode: service:gerbil # Ports appear on the gerbil service @@ -52,4 +52,4 @@ networks: default: driver: bridge name: pangolin - enable_ipv6: true \ No newline at end of file + enable_ipv6: true diff --git a/install/config/docker-compose.yml b/install/config/docker-compose.yml index b507e914..90613b2a 100644 --- a/install/config/docker-compose.yml +++ b/install/config/docker-compose.yml @@ -35,7 +35,7 @@ services: - 80:80 {{end}} traefik: - image: docker.io/traefik:v3.5 + image: docker.io/traefik:v3.6 container_name: traefik restart: unless-stopped {{if .InstallGerbil}} @@ -59,4 +59,4 @@ networks: default: driver: bridge name: pangolin -{{if .EnableIPv6}} enable_ipv6: true{{end}} \ No newline at end of file +{{if .EnableIPv6}} enable_ipv6: true{{end}} diff --git a/messages/de-DE.json b/messages/de-DE.json index 15a56f2d..29dfab10 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1080,11 +1080,11 @@ "actionDeleteIdpOrg": "IDP-Organisationsrichtlinie löschen", "actionListIdpOrgs": "IDP-Organisationen auflisten", "actionUpdateIdpOrg": "IDP-Organisation aktualisieren", - "actionCreateClient": "Client anlegen", - "actionDeleteClient": "Client löschen", - "actionUpdateClient": "Client aktualisieren", + "actionCreateClient": "Kunde erstellen", + "actionDeleteClient": "Kunde löschen", + "actionUpdateClient": "Kunde aktualisieren", "actionListClients": "Clients auflisten", - "actionGetClient": "Clients abrufen", + "actionGetClient": "Kunde holen", "actionCreateSiteResource": "Site-Ressource erstellen", "actionDeleteSiteResource": "Site-Ressource löschen", "actionGetSiteResource": "Site-Ressource abrufen", @@ -1432,14 +1432,14 @@ }, "siteRequired": "Standort ist erforderlich.", "olmTunnel": "Olm-Tunnel", - "olmTunnelDescription": "Nutzen Sie Olm für die Kundenverbindung", + "olmTunnelDescription": "Nutzen Sie Olm für die Client-Verbindung", "errorCreatingClient": "Fehler beim Erstellen des Clients", "clientDefaultsNotFound": "Standardeinstellungen des Clients nicht gefunden", "createClient": "Client erstellen", "createClientDescription": "Erstellen Sie einen neuen Client für die Verbindung zu Ihren Standorten.", "seeAllClients": "Alle Clients anzeigen", - "clientInformation": "Client Informationen", - "clientNamePlaceholder": "Client Name", + "clientInformation": "Client-Informationen", + "clientNamePlaceholder": "Client-Name", "address": "Adresse", "subnetPlaceholder": "Subnetz", "addressDescription": "Die Adresse, die dieser Client für die Verbindung verwenden wird.", @@ -2110,7 +2110,6 @@ "selectedResources": "Ausgewählte Ressourcen", "enableSelected": "Ausgewählte aktivieren", "disableSelected": "Ausgewählte deaktivieren", - "checkSelectedStatus": "Status der Auswahl überprüfen", "credentials": "Zugangsdaten", "savecredentials": "Zugangsdaten speichern", "regeneratecredentials": "Re-Key", @@ -2136,5 +2135,6 @@ "niceIdUpdateErrorDescription": "Beim Aktualisieren der Nizza-ID ist ein Fehler aufgetreten.", "niceIdCannotBeEmpty": "Nizza-ID darf nicht leer sein", "enterIdentifier": "Identifikator eingeben", - "identifier": "Identifier" + "identifier": "Identifier", + "checkSelectedStatus": "Status der Auswahl überprüfen" } diff --git a/messages/en-US.json b/messages/en-US.json index a6284724..2dc3ea88 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1,12 +1,12 @@ { - "setupCreate": "Create your organization, site, and resources", + "setupCreate": "Create the organization, site, and resources", "setupNewOrg": "New Organization", "setupCreateOrg": "Create Organization", "setupCreateResources": "Create Resources", "setupOrgName": "Organization Name", - "orgDisplayName": "This is the display name of your organization.", + "orgDisplayName": "This is the display name of the organization.", "orgId": "Organization ID", - "setupIdentifierMessage": "This is the unique identifier for your organization. This is separate from the display name.", + "setupIdentifierMessage": "This is the unique identifier for the organization.", "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.", @@ -50,10 +50,10 @@ "siteMessageRemove": "Once removed the site will no longer be accessible. All targets associated with the site will also be removed.", "siteQuestionRemove": "Are you sure you want to remove the site from the organization?", "siteManageSites": "Manage Sites", - "siteDescription": "Allow connectivity to your network through secure tunnels", + "siteDescription": "Create and manage sites to enable connectivity to private networks", "siteCreate": "Create Site", "siteCreateDescription2": "Follow the steps below to create and connect a new site", - "siteCreateDescription": "Create a new site to start connecting your resources", + "siteCreateDescription": "Create a new site to start connecting resources", "close": "Close", "siteErrorCreate": "Error creating site", "siteErrorCreateKeyPair": "Key pair or site defaults not found", @@ -74,7 +74,7 @@ "siteInstallNewt": "Install Newt", "siteInstallNewtDescription": "Get Newt running on your system", "WgConfiguration": "WireGuard Configuration", - "WgConfigurationDescription": "Use the following configuration to connect to your network", + "WgConfigurationDescription": "Use the following configuration to connect to the network", "operatingSystem": "Operating System", "commands": "Commands", "recommended": "Recommended", @@ -87,32 +87,32 @@ "siteUpdated": "Site updated", "siteUpdatedDescription": "The site has been updated.", "siteGeneralDescription": "Configure the general settings for this site", - "siteSettingDescription": "Configure the settings on your site", + "siteSettingDescription": "Configure the settings on the site", "siteSetting": "{siteName} Settings", - "siteNewtTunnel": "Newt Tunnel (Recommended)", - "siteNewtTunnelDescription": "Easiest way to create an entrypoint into your network. No extra setup.", + "siteNewtTunnel": "Newt Site (Recommended)", + "siteNewtTunnelDescription": "Easiest way to create an entrypoint into any network. No extra setup.", "siteWg": "Basic WireGuard", "siteWgDescription": "Use any WireGuard client to establish a tunnel. Manual NAT setup required.", "siteWgDescriptionSaas": "Use any WireGuard client to establish a tunnel. Manual NAT setup required.", "siteLocalDescription": "Local resources only. No tunneling.", "siteLocalDescriptionSaas": "Local resources only. No tunneling. Only available on remote nodes.", "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", + "siteTunnelDescription": "Determine how you want to connect to the site", + "siteNewtCredentials": "Credentials", + "siteNewtCredentialsDescription": "This is how the site will authenticate with the server", + "siteCredentialsSave": "Save the 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", + "shareDescription": "Create shareable links to grant temporary or permanent access to proxy 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.", + "shareTokenDescription": "The 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", @@ -121,7 +121,7 @@ "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.", + "shareTokenSecurety": "Keep the 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", @@ -144,8 +144,10 @@ "expires": "Expires", "never": "Never", "shareErrorSelectResource": "Please select a resource", - "resourceTitle": "Manage Resources", - "resourceDescription": "Create secure proxies to your private applications", + "proxyResourceTitle": "Manage Proxy Resources", + "proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser", + "clientResourceTitle": "Manage Client Resources", + "clientResourceDescription": "Create and manage resources that are only accessible through a connected client", "resourcesSearch": "Search resources...", "resourceAdd": "Add Resource", "resourceErrorDelte": "Error deleting resource", @@ -155,9 +157,9 @@ "resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.", "resourceQuestionRemove": "Are you sure you want to remove the resource from the organization?", "resourceHTTP": "HTTPS Resource", - "resourceHTTPDescription": "Proxy requests to your app over HTTPS using a subdomain or base domain.", + "resourceHTTPDescription": "Proxy requests to the 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. This only works when sites are connected to nodes.", + "resourceRawDescription": "Proxy requests to the app over TCP/UDP using a port number. This only works when sites are connected to nodes.", "resourceCreate": "Create Resource", "resourceCreateDescription": "Follow the steps below to create a new resource", "resourceSeeAll": "See All Resources", @@ -171,22 +173,22 @@ "noCountryFound": "No country found.", "siteSelectionDescription": "This site will provide connectivity to the target.", "resourceType": "Resource Type", - "resourceTypeDescription": "Determine how you want to access your resource", + "resourceTypeDescription": "Determine how to access the resource", "resourceHTTPSSettings": "HTTPS Settings", - "resourceHTTPSSettingsDescription": "Configure how your resource will be accessed over HTTPS", + "resourceHTTPSSettingsDescription": "Configure how the resource will be accessed over HTTPS", "domainType": "Domain Type", "subdomain": "Subdomain", "baseDomain": "Base Domain", - "subdomnainDescription": "The subdomain where your resource will be accessible.", + "subdomnainDescription": "The subdomain where the resource will be accessible.", "resourceRawSettings": "TCP/UDP Settings", - "resourceRawSettingsDescription": "Configure how your resource will be accessed over TCP/UDP. You map the resource to a port on the host Pangolin server, so you can access the resource from server-public-ip:mapped-port.", + "resourceRawSettingsDescription": "Configure how the resource will be accessed over TCP/UDP. You map the resource to a port on the host Pangolin server, so you can access the resource from server-public-ip:mapped-port.", "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", + "resourceConfigDescription": "Copy and paste these configuration snippets to set up the TCP/UDP resource", "resourceAddEntrypoints": "Traefik: Add Entrypoints", "resourceExposePorts": "Gerbil: Expose Ports in Docker Compose", "resourceLearnRaw": "Learn how to configure TCP/UDP resources", @@ -202,14 +204,14 @@ "proxy": "Proxy", "internal": "Internal", "rules": "Rules", - "resourceSettingDescription": "Configure the settings on your resource", + "resourceSettingDescription": "Configure the settings on the resource", "resourceSetting": "{resourceName} Settings", - "alwaysAllow": "Always Allow", - "alwaysDeny": "Always Deny", + "alwaysAllow": "Bypass Auth", + "alwaysDeny": "Block Access", "passToAuth": "Pass to Auth", - "orgSettingsDescription": "Configure your organization's settings", + "orgSettingsDescription": "Configure the organization's settings", "orgGeneralSettings": "Organization Settings", - "orgGeneralSettingsDescription": "Manage your organization details and configuration", + "orgGeneralSettingsDescription": "Manage the organization's details and configuration", "saveGeneralSettings": "Save General Settings", "saveSettings": "Save Settings", "orgDangerZone": "Danger Zone", @@ -232,7 +234,7 @@ "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", + "accessUsersDescription": "Invite and manage users with access to this organization", "accessUsersSearch": "Search users...", "accessUserCreate": "Create User", "accessUserRemove": "Remove User", @@ -241,13 +243,13 @@ "role": "Role", "nameRequired": "Name is required", "accessRolesManage": "Manage Roles", - "accessRolesDescription": "Configure roles to manage access to your organization", + "accessRolesDescription": "Create and manage roles for users in the organization", "accessRolesSearch": "Search roles...", "accessRolesAdd": "Add Role", "accessRoleDelete": "Delete Role", "description": "Description", "inviteTitle": "Open Invitations", - "inviteDescription": "Manage your invitations to other users", + "inviteDescription": "Manage invitations for other users to join the organization", "inviteSearch": "Search invitations...", "minutes": "Minutes", "hours": "Hours", @@ -261,13 +263,13 @@ "apiKeysErrorCreate": "Error creating API key", "apiKeysErrorSetPermission": "Error setting permissions", "apiKeysCreate": "Generate API Key", - "apiKeysCreateDescription": "Generate a new API key for your organization", + "apiKeysCreateDescription": "Generate a new API key for the organization", "apiKeysGeneralSettings": "Permissions", "apiKeysGeneralSettingsDescription": "Determine what this API key can do", - "apiKeysList": "Your API Key", - "apiKeysSave": "Save Your API Key", + "apiKeysList": "New API Key", + "apiKeysSave": "Save the 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:", + "apiKeysInfo": "The API key is:", "apiKeysConfirmCopy": "I have copied the API key", "generate": "Generate", "done": "Done", @@ -424,7 +426,7 @@ "userCreated": "User created", "userCreatedDescription": "The user has been successfully created.", "userTypeInternal": "Internal User", - "userTypeInternalDescription": "Invite a user to join your organization directly.", + "userTypeInternalDescription": "Invite a user to join the organization directly.", "userTypeExternal": "External User", "userTypeExternalDescription": "Create a user with an external identity provider.", "accessUserCreateDescription": "Follow the steps below to create a new user", @@ -468,13 +470,13 @@ "accessControlsSubmit": "Save Access Controls", "roles": "Roles", "accessUsersRoles": "Manage Users & Roles", - "accessUsersRolesDescription": "Invite users and add them to roles to manage access to your organization", + "accessUsersRolesDescription": "Invite users and add them to roles to manage access to the 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", - "proxyEnableSSLDescription": "Enable SSL/TLS encryption for secure HTTPS connections to your targets.", + "proxyEnableSSLDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the targets.", "target": "Target", "configureTarget": "Configure Targets", "targetErrorFetch": "Failed to fetch targets", @@ -490,29 +492,29 @@ "targetsErrorUpdate": "Failed to update targets", "targetsErrorUpdateDescription": "An error occurred while updating targets", "targetTlsUpdate": "TLS settings updated", - "targetTlsUpdateDescription": "Your TLS settings have been updated successfully", + "targetTlsUpdateDescription": "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", + "proxyUpdatedDescription": "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", + "targetTlsSettingsDescription": "Configure SSL/TLS settings for the resource", "targetTlsSettingsAdvanced": "Advanced TLS Settings", "targetTlsSni": "TLS Server Name", "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 backend services", + "targetsDescription": "Set up targets to route traffic to backend services", "targetStickySessions": "Enable Sticky Sessions", "targetStickySessionsDescription": "Keep connections on the same backend target for their entire session.", "methodSelect": "Select method", "targetSubmit": "Add Target", - "targetNoOne": "This resource doesn't have any targets. Add a target to configure where to send requests to your backend.", + "targetNoOne": "This resource doesn't have any targets. Add a target to configure where to send requests to the backend.", "targetNoOneDescription": "Adding more than one target above will enable load balancing.", "targetsSubmit": "Save Targets", "addTarget": "Add Target", @@ -530,7 +532,7 @@ "tlsServerNameDescription": "The TLS server name to use for SNI", "save": "Save", "proxyAdditional": "Additional Proxy Settings", - "proxyAdditionalDescription": "Configure how your resource handles proxy settings", + "proxyAdditionalDescription": "Configure how the 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", @@ -570,7 +572,7 @@ "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.", + "rulesAboutDescription": "Rules allow you to control access to the 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", @@ -582,7 +584,7 @@ "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", + "rulesResourceDescription": "Configure rules to control access to the resource", "ruleSubmit": "Add Rule", "rulesNoOne": "No rules. Add a rule using the form.", "rulesOrder": "Rules are evaluated by priority in ascending order.", @@ -598,7 +600,7 @@ "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.", + "resourcesDescription": "Resources are proxies to applications running on the 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", @@ -609,7 +611,7 @@ "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.", + "shareDescription2": "Create shareable links to 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", @@ -619,19 +621,19 @@ "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", + "newtEndpoint": "Endpoint", + "newtId": "ID", + "newtSecretKey": "Secret", "architecture": "Architecture", "sites": "Sites", - "siteWgAnyClients": "Use any WireGuard client to connect. You will have to address your internal resources using the peer IP.", + "siteWgAnyClients": "Use any WireGuard client to connect. You will have to address 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", + "accessRoleSelect2": "Select roles", + "accessUserSelect": "Select users", "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.", @@ -683,7 +685,7 @@ "resourcePincodeSetupTitle": "Set Pincode", "resourcePincodeSetupTitleDescription": "Set a pincode to protect this resource", "resourceRoleDescription": "Admins can always access this resource.", - "resourceUsersRoles": "Users & Roles", + "resourceUsersRoles": "Access Controls", "resourceUsersRolesDescription": "Configure which users and roles can visit this resource", "resourceUsersRolesSubmit": "Save Users & Roles", "resourceWhitelistSave": "Saved successfully", @@ -779,15 +781,15 @@ "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", + "idpClientIdDescription": "The OAuth2 client ID from the identity provider", "idpClientSecret": "Client Secret", - "idpClientSecretDescription": "The OAuth2 client secret from your identity provider", + "idpClientSecretDescription": "The OAuth2 client secret from the 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.", + "idpOidcConfigureAlertDescription": "After creating the identity provider, you will need to configure the callback URL in the 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", @@ -804,7 +806,7 @@ "idpSubmit": "Create Identity Provider", "orgPolicies": "Organization Policies", "idpSettings": "{idpName} Settings", - "idpCreateSettingsDescription": "Configure the settings for your identity provider", + "idpCreateSettingsDescription": "Configure the settings for the identity provider", "roleMapping": "Role Mapping", "orgMapping": "Organization Mapping", "orgPoliciesSearch": "Search organization policies...", @@ -839,7 +841,7 @@ "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.", + "redirectUrlAboutDescription": "This is the URL to which users will be redirected after authentication. You need to configure this URL in the identity provider's settings.", "pangolinAuth": "Auth - Pangolin", "verificationCodeLengthRequirements": "Your verification code must be 8 characters.", "errorOccurred": "An error occurred", @@ -922,6 +924,10 @@ "passwordResetSent": "We'll send a password reset code to this email address.", "passwordResetCode": "Reset Code", "passwordResetCodeDescription": "Check your email for the reset code.", + "generatePasswordResetCode": "Generate Password Reset Code", + "passwordResetCodeGenerated": "Password Reset Code Generated", + "passwordResetCodeGeneratedDescription": "Share this code with the user. They can use it to reset their password.", + "passwordResetUrl": "Reset URL", "passwordNew": "New Password", "passwordNewConfirm": "Confirm New Password", "changePassword": "Change Password", @@ -939,6 +945,9 @@ "pincodeAuth": "Authenticator Code", "pincodeSubmit2": "Submit Code", "passwordResetSubmit": "Request Reset", + "passwordResetAlreadyHaveCode": "Enter Password Reset Code", + "passwordResetSmtpRequired": "Please contact your administrator", + "passwordResetSmtpRequiredDescription": "A password reset code is required to reset your password. Please contact your administrator for assistance.", "passwordBack": "Back to Password", "loginBack": "Go back to log in", "signup": "Sign up", @@ -1104,12 +1113,15 @@ "actionListSiteResources": "List Site Resources", "actionUpdateSiteResource": "Update Site Resource", "actionListInvitations": "List Invitations", + "actionExportLogs": "Export Logs", + "actionViewLogs": "View Logs", "noneSelected": "None selected", "orgNotFound2": "No organizations found.", "searchProgress": "Search...", "create": "Create", "orgs": "Organizations", "loginError": "An error occurred while logging in", + "loginRequiredForDevice": "Login is required to authenticate your device.", "passwordForgot": "Forgot your password?", "otpAuth": "Two-Factor Authentication", "otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.", @@ -1164,17 +1176,23 @@ "sidebarHome": "Home", "sidebarSites": "Sites", "sidebarResources": "Resources", + "sidebarProxyResources": "Proxy Resources", + "sidebarClientResources": "Client Resources", "sidebarAccessControl": "Access Control", + "sidebarLogsAndAnalytics": "Logs & Analytics", "sidebarUsers": "Users", + "sidebarAdmin": "Admin", "sidebarInvitations": "Invitations", "sidebarRoles": "Roles", - "sidebarShareableLinks": "Shareable Links", + "sidebarShareableLinks": "Links", "sidebarApiKeys": "API Keys", "sidebarSettings": "Settings", "sidebarAllUsers": "All Users", "sidebarIdentityProviders": "Identity Providers", "sidebarLicense": "License", "sidebarClients": "Clients", + "sidebarUserDevices": "User Devices", + "sidebarMachineClients": "Machine Clients", "sidebarDomains": "Domains", "sidebarGeneral": "General", "sidebarLogAndAnalytics": "Log & Analytics", @@ -1191,7 +1209,7 @@ "blueprintDetailsDescription": "See the result of the applied blueprint and any errors that occurred", "blueprintInfo": "Blueprint Information", "message": "Message", - "blueprintContentsDescription": "Define the YAML content describing your infrastructure", + "blueprintContentsDescription": "Define the YAML content describing the infrastructure", "blueprintErrorCreateDescription": "An error occurred when applying the blueprint", "blueprintErrorCreate": "Error creating blueprint", "searchBlueprintProgress": "Search blueprints...", @@ -1247,15 +1265,15 @@ "loading": "Loading", "restart": "Restart", "domains": "Domains", - "domainsDescription": "Manage domains for your organization", + "domainsDescription": "Create and manage domains available in the organization", "domainsSearch": "Search domains...", "domainAdd": "Add Domain", - "domainAddDescription": "Register a new domain with your organization", + "domainAddDescription": "Register a new domain with to the organization", "domainCreate": "Create Domain", "domainCreatedDescription": "Domain created successfully", "domainDeletedDescription": "Domain deleted successfully", - "domainQuestionRemove": "Are you sure you want to remove the domain from your account?", - "domainMessageRemove": "Once removed, the domain will no longer be associated with your account.", + "domainQuestionRemove": "Are you sure you want to remove the domain?", + "domainMessageRemove": "Once removed, the domain will no longer be associated with the organization.", "domainConfirmDelete": "Confirm Delete Domain", "domainDelete": "Delete Domain", "domain": "Domain", @@ -1274,7 +1292,7 @@ "pending": "Pending", "sidebarBilling": "Billing", "billing": "Billing", - "orgBillingDescription": "Manage your billing information and subscriptions", + "orgBillingDescription": "Manage billing information and subscriptions", "github": "GitHub", "pangolinHosted": "Pangolin Hosted", "fossorial": "Fossorial", @@ -1317,7 +1335,7 @@ "domainPickerSortAsc": "A-Z", "domainPickerSortDesc": "Z-A", "domainPickerCheckingAvailability": "Checking availability...", - "domainPickerNoMatchingDomains": "No matching domains found. Try a different domain or check your organization's domain settings.", + "domainPickerNoMatchingDomains": "No matching domains found. Try a different domain or check the organization's domain settings.", "domainPickerOrganizationDomains": "Organization Domains", "domainPickerProvidedDomains": "Provided Domains", "domainPickerSubdomain": "Subdomain: {subdomain}", @@ -1351,7 +1369,7 @@ "billingModifySubscription": "Modify Subscription", "billingStartSubscription": "Start Subscription", "billingRecurringCharge": "Recurring Charge", - "billingManageSubscriptionSettings": "Manage your subscription settings and preferences", + "billingManageSubscriptionSettings": "Manage subscription settings and preferences", "billingNoActiveSubscription": "You don't have an active subscription. Start your subscription to increase usage limits.", "billingFailedToLoadSubscription": "Failed to load subscription", "billingFailedToLoadUsage": "Failed to load usage", @@ -1362,9 +1380,9 @@ "billingPortalError": "Portal Error", "billingDataUsageInfo": "You're charged for all data transferred through your secure tunnels when connected to the cloud. This includes both incoming and outgoing traffic across all your sites. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Data is not charged when using nodes.", "billingOnlineTimeInfo": "You're charged based on how long your sites stay connected to the cloud. For example, 44,640 minutes equals one site running 24/7 for a full month. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Time is not charged when using nodes.", - "billingUsersInfo": "You're charged for each user in your organization. Billing is calculated daily based on the number of active user accounts in your org.", - "billingDomainInfo": "You're charged for each domain in your organization. Billing is calculated daily based on the number of active domain accounts in your org.", - "billingRemoteExitNodesInfo": "You're charged for each managed Node in your organization. Billing is calculated daily based on the number of active managed Nodes in your org.", + "billingUsersInfo": "You're charged for each user in the organization. Billing is calculated daily based on the number of active user accounts in your org.", + "billingDomainInfo": "You're charged for each domain in the organization. Billing is calculated daily based on the number of active domain accounts in your org.", + "billingRemoteExitNodesInfo": "You're charged for each managed Node in the organization. Billing is calculated daily based on the number of active managed Nodes in your org.", "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", @@ -1456,23 +1474,23 @@ "errorCreatingClient": "Error creating client", "clientDefaultsNotFound": "Client defaults not found", "createClient": "Create Client", - "createClientDescription": "Create a new client for connecting to your sites", + "createClientDescription": "Create a new client to access private resources", "seeAllClients": "See All Clients", "clientInformation": "Client Information", "clientNamePlaceholder": "Client name", "address": "Address", "subnetPlaceholder": "Subnet", - "addressDescription": "The address that this client will use for connectivity", + "addressDescription": "The internal address of the client. Must fall within the organization's subnet.", "selectSites": "Select sites", "sitesDescription": "The client will have connectivity to the selected sites", "clientInstallOlm": "Install Olm", "clientInstallOlmDescription": "Get Olm running on your system", - "clientOlmCredentials": "Olm Credentials", - "clientOlmCredentialsDescription": "This is how Olm will authenticate with the server", - "olmEndpoint": "Olm Endpoint", - "olmId": "Olm ID", - "olmSecretKey": "Olm Secret Key", - "clientCredentialsSave": "Save Your Credentials", + "clientOlmCredentials": "Credentials", + "clientOlmCredentialsDescription": "This is how the client will authenticate with the server", + "olmEndpoint": "Endpoint", + "olmId": "ID", + "olmSecretKey": "Secret", + "clientCredentialsSave": "Save the Credentials", "clientCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.", "generalSettingsDescription": "Configure the general settings for this client", "clientUpdated": "Client updated", @@ -1483,9 +1501,7 @@ "sitesFetchError": "An error occurred while fetching sites.", "olmErrorFetchReleases": "An error occurred while fetching Olm releases.", "olmErrorFetchLatest": "An error occurred while fetching the latest Olm release.", - "remoteSubnets": "Remote Subnets", "enterCidrRange": "Enter CIDR range", - "remoteSubnetsDescription": "Add CIDR ranges that can be accessed from this site remotely using clients. Use format like 10.0.0.0/24. This ONLY applies to VPN client connectivity.", "resourceEnableProxy": "Enable Public Proxy", "resourceEnableProxyDescription": "Enable public proxying to this resource. This allows access to the resource from outside the network through the cloud on an open port. Requires Traefik config.", "externalProxyEnabled": "External Proxy Enabled", @@ -1503,14 +1519,15 @@ "enableHealthChecksDescription": "Monitor the health of this target. You can monitor a different endpoint than the target if required.", "healthScheme": "Method", "healthSelectScheme": "Select Method", + "healthCheckPortInvalid": "Health check port must be between 1 and 65535", "healthCheckPath": "Path", "healthHostname": "IP / Host", "healthPort": "Port", "healthCheckPathDescription": "The path to check for health status.", - "healthyIntervalSeconds": "Healthy Interval", - "unhealthyIntervalSeconds": "Unhealthy Interval", + "healthyIntervalSeconds": "Healthy Interval (sec)", + "unhealthyIntervalSeconds": "Unhealthy Interval (sec)", "IntervalSeconds": "Healthy Interval", - "timeoutSeconds": "Timeout", + "timeoutSeconds": "Timeout (sec)", "timeIsInSeconds": "Time is in seconds", "retryAttempts": "Retry Attempts", "expectedResponseCodes": "Expected Response Codes", @@ -1546,12 +1563,12 @@ "resourceEditDomain": "Edit Domain", "siteName": "Site Name", "proxyPort": "Port", - "resourcesTableProxyResources": "Proxy Resources", - "resourcesTableClientResources": "Client Resources", + "resourcesTableProxyResources": "Public", + "resourcesTableClientResources": "Private", "resourcesTableNoProxyResourcesFound": "No proxy resources found.", "resourcesTableNoInternalResourcesFound": "No internal resources found.", "resourcesTableDestination": "Destination", - "resourcesTableTheseResourcesForUseWith": "These resources are for use with", + "resourcesTableAlias": "Alias", "resourcesTableClients": "Clients", "resourcesTableAndOnlyAccessibleInternally": "and are only accessible internally when connected with a client.", "resourcesTableNoTargets": "No targets", @@ -1561,7 +1578,7 @@ "resourcesTableUnknown": "Unknown", "resourcesTableNotMonitored": "Not monitored", "editInternalResourceDialogEditClientResource": "Edit Client Resource", - "editInternalResourceDialogUpdateResourceProperties": "Update the resource properties and target configuration for {resourceName}.", + "editInternalResourceDialogUpdateResourceProperties": "Update the resource configuration and access controls for {resourceName}", "editInternalResourceDialogResourceProperties": "Resource Properties", "editInternalResourceDialogName": "Name", "editInternalResourceDialogProtocol": "Protocol", @@ -1580,11 +1597,22 @@ "editInternalResourceDialogInvalidIPAddressFormat": "Invalid IP address format", "editInternalResourceDialogDestinationPortMin": "Destination port must be at least 1", "editInternalResourceDialogDestinationPortMax": "Destination port must be less than 65536", + "editInternalResourceDialogPortModeRequired": "Protocol, proxy port, and destination port are required for port mode", + "editInternalResourceDialogMode": "Mode", + "editInternalResourceDialogModePort": "Port", + "editInternalResourceDialogModeHost": "Host", + "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogDestination": "Destination", + "editInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.", + "editInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.", + "editInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.", + "editInternalResourceDialogAlias": "Alias", + "editInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.", "createInternalResourceDialogNoSitesAvailable": "No Sites Available", "createInternalResourceDialogNoSitesAvailableDescription": "You need to have at least one Newt site with a subnet configured to create internal resources.", "createInternalResourceDialogClose": "Close", "createInternalResourceDialogCreateClientResource": "Create Client Resource", - "createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will be accessible to clients connected to the selected site.", + "createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization", "createInternalResourceDialogResourceProperties": "Resource Properties", "createInternalResourceDialogName": "Name", "createInternalResourceDialogSite": "Site", @@ -1613,11 +1641,22 @@ "createInternalResourceDialogInvalidIPAddressFormat": "Invalid IP address format", "createInternalResourceDialogDestinationPortMin": "Destination port must be at least 1", "createInternalResourceDialogDestinationPortMax": "Destination port must be less than 65536", + "createInternalResourceDialogPortModeRequired": "Protocol, proxy port, and destination port are required for port mode", + "createInternalResourceDialogMode": "Mode", + "createInternalResourceDialogModePort": "Port", + "createInternalResourceDialogModeHost": "Host", + "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogDestination": "Destination", + "createInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.", + "createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.", + "createInternalResourceDialogAlias": "Alias", + "createInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.", "siteConfiguration": "Configuration", "siteAcceptClientConnections": "Accept Client Connections", - "siteAcceptClientConnectionsDescription": "Allow other devices to connect through this Newt instance as a gateway using clients.", - "siteAddress": "Site Address", - "siteAddressDescription": "Specify the IP address of the host for clients to connect to. This is the internal address of the site in the Pangolin network for clients to address. Must fall within the Org subnet.", + "siteAcceptClientConnectionsDescription": "Allow user devices and clients to access resources on this site. This can be changed later.", + "siteAddress": "Site Address (Advanced)", + "siteAddressDescription": "The internal address of the site. Must fall within the organization's subnet.", + "siteNameDescription": "The display name of the site that can be changed later.", "autoLoginExternalIdp": "Auto Login with External IDP", "autoLoginExternalIdpDescription": "Immediately redirect the user to the external IDP for authentication.", "selectIdp": "Select IDP", @@ -1631,7 +1670,7 @@ "autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.", "autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.", "remoteExitNodeManageRemoteExitNodes": "Remote Nodes", - "remoteExitNodeDescription": "Self-host one or more remote nodes to extend your network connectivity and reduce reliance on the cloud", + "remoteExitNodeDescription": "Self-host one or more remote nodes to extend network connectivity and reduce reliance on the cloud", "remoteExitNodes": "Nodes", "searchRemoteExitNodes": "Search nodes...", "remoteExitNodeAdd": "Add Node", @@ -1643,11 +1682,11 @@ "sidebarRemoteExitNodes": "Remote Nodes", "remoteExitNodeCreate": { "title": "Create Node", - "description": "Create a new node to extend your network connectivity", + "description": "Create a new node to extend network connectivity", "viewAllButton": "View All Nodes", "strategy": { "title": "Creation Strategy", - "description": "Choose this to manually configure your node or generate new credentials.", + "description": "Choose this to manually configure the node or generate new credentials.", "adopt": { "title": "Adopt Node", "description": "Choose this if you already have the credentials for the node." @@ -1668,7 +1707,7 @@ }, "generate": { "title": "Generated Credentials", - "description": "Use these generated credentials to configure your node", + "description": "Use these generated credentials to configure the node", "nodeIdTitle": "Node ID", "secretTitle": "Secret", "saveCredentialsTitle": "Add Credentials to Config", @@ -1744,16 +1783,16 @@ "idpTypeLabel": "Identity Provider Type", "roleMappingExpressionPlaceholder": "e.g., contains(groups, 'admin') && 'Admin' || 'Member'", "idpGoogleConfiguration": "Google Configuration", - "idpGoogleConfigurationDescription": "Configure your Google OAuth2 credentials", - "idpGoogleClientIdDescription": "Your Google OAuth2 Client ID", - "idpGoogleClientSecretDescription": "Your Google OAuth2 Client Secret", + "idpGoogleConfigurationDescription": "Configure the Google OAuth2 credentials", + "idpGoogleClientIdDescription": "Google OAuth2 Client ID", + "idpGoogleClientSecretDescription": "Google OAuth2 Client Secret", "idpAzureConfiguration": "Azure Entra ID Configuration", - "idpAzureConfigurationDescription": "Configure your Azure Entra ID OAuth2 credentials", + "idpAzureConfigurationDescription": "Configure Azure Entra ID OAuth2 credentials", "idpTenantId": "Tenant ID", - "idpTenantIdPlaceholder": "your-tenant-id", - "idpAzureTenantIdDescription": "Your Azure tenant ID (found in Azure Active Directory overview)", - "idpAzureClientIdDescription": "Your Azure App Registration Client ID", - "idpAzureClientSecretDescription": "Your Azure App Registration Client Secret", + "idpTenantIdPlaceholder": "tenant-id", + "idpAzureTenantIdDescription": "Azure tenant ID (found in Azure Active Directory overview)", + "idpAzureClientIdDescription": "Azure App Registration Client ID", + "idpAzureClientSecretDescription": "Azure App Registration Client Secret", "idpGoogleTitle": "Google", "idpGoogleAlt": "Google", "idpAzureTitle": "Azure Entra ID", @@ -1761,14 +1800,14 @@ "idpGoogleConfigurationTitle": "Google Configuration", "idpAzureConfigurationTitle": "Azure Entra ID Configuration", "idpTenantIdLabel": "Tenant ID", - "idpAzureClientIdDescription2": "Your Azure App Registration Client ID", - "idpAzureClientSecretDescription2": "Your Azure App Registration Client Secret", + "idpAzureClientIdDescription2": "Azure App Registration Client ID", + "idpAzureClientSecretDescription2": "Azure App Registration Client Secret", "idpGoogleDescription": "Google OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Subnet", "subnetDescription": "The subnet for this organization's network configuration.", "authPage": "Auth Page", - "authPageDescription": "Configure the auth page for your organization", + "authPageDescription": "Configure the auth page for the organization", "authPageDomain": "Auth Page Domain", "authPageBranding": "Branding", "authPageBrandingDescription": "Configure the branding for the auth page for your organization", @@ -1798,7 +1837,7 @@ "setAuthPageDomain": "Set Auth Page Domain", "failedToFetchCertificate": "Failed to fetch certificate", "failedToRestartCertificate": "Failed to restart certificate", - "addDomainToEnableCustomAuthPages": "Add a domain to enable custom authentication pages for your organization", + "addDomainToEnableCustomAuthPages": "Add a domain to enable custom authentication pages for the organization", "selectDomainForOrgAuthPage": "Select a domain for the organization's authentication page", "domainPickerProvidedDomain": "Provided Domain", "domainPickerFreeProvidedDomain": "Free Provided Domain", @@ -1813,7 +1852,7 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" could not be made valid for {domain}.", "domainPickerSubdomainSanitized": "Subdomain sanitized", "domainPickerSubdomainCorrected": "\"{sub}\" was corrected to \"{sanitized}\"", - "orgAuthSignInTitle": "Sign in to your organization", + "orgAuthSignInTitle": "Sign in to the organization", "orgAuthChooseIdpDescription": "Choose your identity provider to continue", "orgAuthNoIdpConfigured": "This organization doesn't have any identity providers configured. You can log in with your Pangolin identity instead.", "orgAuthSignInWithPangolin": "Sign in with Pangolin", @@ -1831,7 +1870,7 @@ "enableTwoFactorAuthentication": "Enable two-factor authentication", "completeSecuritySteps": "Complete Security Steps", "securitySettings": "Security Settings", - "securitySettingsDescription": "Configure security policies for your organization", + "securitySettingsDescription": "Configure security policies for the organization", "requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users", "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", "requireTwoFactorDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", @@ -1894,8 +1933,12 @@ "enterpriseEdition": "Enterprise Edition", "unlicensed": "Unlicensed", "beta": "Beta", - "manageClients": "Manage Clients", - "manageClientsDescription": "Clients are devices that can connect to your sites", + "manageUserDevices": "User Devices", + "manageUserDevicesDescription": "View and manage devices that users use to privately connect to resources", + "manageMachineClients": "Manage Machine Clients", + "manageMachineClientsDescription": "Create and manage clients that servers and systems use to privately connect to resources", + "clientsTableUserClients": "User", + "clientsTableMachineClients": "Machine", "licenseTableValidUntil": "Valid Until", "saasLicenseKeysSettingsTitle": "Enterprise Licenses", "saasLicenseKeysSettingsDescription": "Generate and manage Enterprise license keys for self-hosted Pangolin instances", @@ -2095,7 +2138,7 @@ "preferWildcardCert": "Prefer Wildcard Certificate", "unverified": "Unverified", "domainSetting": "Domain Settings", - "domainSettingDescription": "Configure settings for your domain", + "domainSettingDescription": "Configure settings for the domain", "preferWildcardCertDescription": "Attempt to generate a wildcard certificate (require a properly configured certificate resolver).", "recordName": "Record Name", "auto": "Auto", @@ -2109,15 +2152,15 @@ "olmUpdateAvailableInfo": "An updated version of Olm is available. Please update to the latest version for the best experience.", "client": "Client", "proxyProtocol": "Proxy Protocol Settings", - "proxyProtocolDescription": "Configure Proxy Protocol to preserve client IP addresses for TCP/UDP services.", + "proxyProtocolDescription": "Configure Proxy Protocol to preserve client IP addresses for TCP services.", "enableProxyProtocol": "Enable Proxy Protocol", - "proxyProtocolInfo": "Preserve client IP addresses for TCP/UDP backends", + "proxyProtocolInfo": "Preserve client IP addresses for TCP backends", "proxyProtocolVersion": "Proxy Protocol Version", "version1": " Version 1 (Recommended)", "version2": "Version 2", "versionDescription": "Version 1 is text-based and widely supported. Version 2 is binary and more efficient but less compatible. Make sure servers transport is added to dynamic config.", "warning": "Warning", - "proxyProtocolWarning": "Your backend application must be configured to accept Proxy Protocol connections. If your backend doesn't support Proxy Protocol, enabling this will break all connections so only enable this if you know what you're doing. Make sure to configure your backend to trust Proxy Protocol headers from Traefik.", + "proxyProtocolWarning": "The backend application must be configured to accept Proxy Protocol connections. If your backend doesn't support Proxy Protocol, enabling this will break all connections so only enable this if you know what you're doing. Make sure to configure your backend to trust Proxy Protocol headers from Traefik.", "restarting": "Restarting...", "manual": "Manual", "messageSupport": "Message Support", @@ -2140,6 +2183,43 @@ "supportMessageSent": "Message Sent!", "supportWillContact": "We'll be in touch shortly!", "selectLogRetention": "Select log retention", + "terms": "Terms", + "privacy": "Privacy", + "security": "Security", + "docs": "Docs", + "deviceActivation": "Device activation", + "deviceCodeInvalidFormat": "Code must be 9 characters (e.g., A1AJ-N5JD)", + "deviceCodeInvalidOrExpired": "Invalid or expired code", + "deviceCodeVerifyFailed": "Failed to verify device code", + "signedInAs": "Signed in as", + "deviceCodeEnterPrompt": "Enter the code displayed on the device", + "continue": "Continue", + "deviceUnknownLocation": "Unknown location", + "deviceAuthorizationRequested": "This authorization was requested from {location} on {date}. Make sure you trust this device as it will get access to the account.", + "deviceLabel": "Device: {deviceName}", + "deviceWantsAccess": "wants to access your account", + "deviceExistingAccess": "Existing access:", + "deviceFullAccess": "Full access to your account", + "deviceOrganizationsAccess": "Access to all organizations your account has access to", + "deviceAuthorize": "Authorize {applicationName}", + "deviceConnected": "Device Connected!", + "deviceAuthorizedMessage": "Device is authorized to access your account.", + "pangolinCloud": "Pangolin Cloud", + "viewDevices": "View Devices", + "viewDevicesDescription": "Manage your connected devices", + "noDevices": "No devices found", + "dateCreated": "Date Created", + "unnamedDevice": "Unnamed Device", + "deviceQuestionRemove": "Are you sure you want to delete this device?", + "deviceMessageRemove": "This action cannot be undone.", + "deviceDeleteConfirm": "Delete Device", + "deleteDevice": "Delete Device", + "errorLoadingDevices": "Error loading devices", + "failedToLoadDevices": "Failed to load devices", + "deviceDeleted": "Device deleted", + "deviceDeletedDescription": "The device has been successfully deleted.", + "errorDeletingDevice": "Error deleting device", + "failedToDeleteDevice": "Failed to delete device", "showColumns": "Show Columns", "hideColumns": "Hide Columns", "columnVisibility": "Column Visibility", @@ -2154,6 +2234,10 @@ "enableSelected": "Enable Selected", "disableSelected": "Disable Selected", "checkSelectedStatus": "Check Status of Selected", + "clients": "Clients", + "accessClientSelect": "Select machine clients", + "resourceClientDescription": "Machine clients that can access this resource", + "regenerate": "Regenerate", "credentials": "Credentials", "savecredentials": "Save Credentials", "regeneratecredentials": "Re-key", @@ -2161,7 +2245,7 @@ "generatedcredentials": "Generated Credentials", "copyandsavethesecredentials": "Copy and save these credentials", "copyandsavethesecredentialsdescription": "These credentials will not be shown again after you leave this page. Save them securely now.", - "credentialsSaved" : "Credentials Saved", + "credentialsSaved": "Credentials Saved", "credentialsSavedDescription": "Credentials have been regenerated and saved successfully.", "credentialsSaveError": "Credentials Save Error", "credentialsSaveErrorDescription": "An error occurred while regenerating and saving the credentials.", @@ -2180,5 +2264,15 @@ "niceIdCannotBeEmpty": "Nice ID cannot be empty", "enterIdentifier": "Enter identifier", "identifier": "Identifier", - "noData": "No Data" + "deviceLoginUseDifferentAccount": "Not you? Use a different account.", + "deviceLoginDeviceRequestingAccessToAccount": "A device is requesting access to this account.", + "noData": "No Data", + "machineClients": "Machine Clients", + "install": "Install", + "run": "Run", + "clientNameDescription": "The display name of the client that can be changed later.", + "clientAddress": "Client Address (Advanced)", + "setupFailedToFetchSubnet": "Failed to fetch default subnet", + "setupSubnetAdvanced": "Subnet (Advanced)", + "setupSubnetDescription": "The subnet for this organization's internal network." } diff --git a/messages/zh-TW.json b/messages/zh-TW.json new file mode 100644 index 00000000..a7e11f60 --- /dev/null +++ b/messages/zh-TW.json @@ -0,0 +1,2099 @@ +{ + "setupCreate": "創建您的第一個組織、網站和資源", + "setupNewOrg": "新建組織", + "setupCreateOrg": "創建組織", + "setupCreateResources": "創建資源", + "setupOrgName": "組織名稱", + "orgDisplayName": "這是您組織的顯示名稱。", + "orgId": "組織ID", + "setupIdentifierMessage": "這是您組織的唯一標識符。這是與顯示名稱分開的。", + "setupErrorIdentifier": "組織ID 已被使用。請另選一個。", + "componentsErrorNoMemberCreate": "您目前不是任何組織的成員。創建組織以開始操作。", + "componentsErrorNoMember": "您目前不是任何組織的成員。", + "welcome": "歡迎使用 Pangolin", + "welcomeTo": "歡迎來到", + "componentsCreateOrg": "創建組織", + "componentsMember": "您屬於{count, plural, =0 {沒有組織} one {一個組織} other {# 個組織}}。", + "componentsInvalidKey": "檢測到無效或過期的許可證金鑰。按照許可證條款操作以繼續使用所有功能。", + "dismiss": "忽略", + "componentsLicenseViolation": "許可證超限:該伺服器使用了 {usedSites} 個站點,已超過授權的 {maxSites} 個。請遵守許可證條款以繼續使用全部功能。", + "componentsSupporterMessage": "感謝您的支持!您現在是 Pangolin 的 {tier} 用戶。", + "inviteErrorNotValid": "很抱歉,但看起來你試圖訪問的邀請尚未被接受或不再有效。", + "inviteErrorUser": "很抱歉,但看起來你想要訪問的邀請不是這個用戶。", + "inviteLoginUser": "請確保您以正確的用戶登錄。", + "inviteErrorNoUser": "很抱歉,但看起來你想訪問的邀請不是一個存在的用戶。", + "inviteCreateUser": "請先創建一個帳戶。", + "goHome": "返回首頁", + "inviteLogInOtherUser": "以不同的用戶登錄", + "createAnAccount": "創建帳戶", + "inviteNotAccepted": "邀請未接受", + "authCreateAccount": "創建一個帳戶以開始", + "authNoAccount": "沒有帳戶?", + "email": "電子郵件地址", + "password": "密碼", + "confirmPassword": "確認密碼", + "createAccount": "創建帳戶", + "viewSettings": "查看設置", + "delete": "刪除", + "name": "名稱", + "online": "在線", + "offline": "離線的", + "site": "站點", + "dataIn": "數據輸入", + "dataOut": "數據輸出", + "connectionType": "連接類型", + "tunnelType": "隧道類型", + "local": "本地的", + "edit": "編輯", + "siteConfirmDelete": "確認刪除站點", + "siteDelete": "刪除站點", + "siteMessageRemove": "一旦移除,站點將無法訪問。與站點相關的所有目標也將被移除。", + "siteQuestionRemove": "您確定要從組織中刪除該站點嗎?", + "siteManageSites": "管理站點", + "siteDescription": "允許通過安全隧道連接到您的網路", + "siteCreate": "創建站點", + "siteCreateDescription2": "按照下面的步驟創建和連接一個新站點", + "siteCreateDescription": "創建一個新站點開始連接您的資源", + "close": "關閉", + "siteErrorCreate": "創建站點出錯", + "siteErrorCreateKeyPair": "找不到金鑰對或站點預設值", + "siteErrorCreateDefaults": "未找到站點預設值", + "method": "方法", + "siteMethodDescription": "這是您將如何顯示連接。", + "siteLearnNewt": "學習如何在您的系統上安裝 Newt", + "siteSeeConfigOnce": "您只能看到一次配置。", + "siteLoadWGConfig": "正在載入 WireGuard 配置...", + "siteDocker": "擴展 Docker 部署詳細資訊", + "toggle": "切換", + "dockerCompose": "Docker 配置", + "dockerRun": "停靠欄", + "siteLearnLocal": "本地站點不需要隧道連接,點擊了解更多", + "siteConfirmCopy": "我已經複製了配置資訊", + "searchSitesProgress": "搜索站點...", + "siteAdd": "添加站點", + "siteInstallNewt": "安裝 Newt", + "siteInstallNewtDescription": "在您的系統中運行 Newt", + "WgConfiguration": "WireGuard 配置", + "WgConfigurationDescription": "使用以下配置連接到您的網路", + "operatingSystem": "操作系統", + "commands": "命令", + "recommended": "推薦", + "siteNewtDescription": "為獲得最佳用戶體驗,請使用 Newt。其底層採用 WireGuard 技術,可直接通過 Pangolin 控制台,使用區域網路地址訪問您私有網路中的資源。", + "siteRunsInDocker": "在 Docker 中運行", + "siteRunsInShell": "在 macOS 、 Linux 和 Windows 的 Shell 中運行", + "siteErrorDelete": "刪除站點出錯", + "siteErrorUpdate": "更新站點失敗", + "siteErrorUpdateDescription": "更新站點時出錯。", + "siteUpdated": "站點已更新", + "siteUpdatedDescription": "網站已更新。", + "siteGeneralDescription": "配置此站點的常規設置", + "siteSettingDescription": "配置您網站上的設置", + "siteSetting": "{siteName} 設置", + "siteNewtTunnel": "Newt 隧道 (推薦)", + "siteNewtTunnelDescription": "最簡單的方式來連接到您的網路。不需要任何額外設置。", + "siteWg": "基本 WireGuard", + "siteWgDescription": "使用任何 WireGuard 用戶端來建立隧道。需要手動配置 NAT。", + "siteWgDescriptionSaas": "使用任何WireGuard用戶端建立隧道。需要手動配置NAT。僅適用於自託管節點。", + "siteLocalDescription": "僅限本地資源。不需要隧道。", + "siteLocalDescriptionSaas": "僅本地資源。沒有隧道。僅在遠程節點上可用。", + "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": "一旦刪除,資源將不再可訪問。與該資源相關的所有目標也將被刪除。", + "resourceQuestionRemove": "您確定要從組織中刪除資源嗎?", + "resourceHTTP": "HTTPS 資源", + "resourceHTTPDescription": "使用子域或根域名通過 HTTPS 向您的應用程式提出代理請求。", + "resourceRaw": "TCP/UDP 資源", + "resourceRawDescription": "使用 TCP/UDP 使用埠號向您的應用提出代理請求。", + "resourceCreate": "創建資源", + "resourceCreateDescription": "按照下面的步驟創建新資源", + "resourceSeeAll": "查看所有資源", + "resourceInfo": "資源資訊", + "resourceNameDescription": "這是資源的顯示名稱。", + "siteSelect": "選擇站點", + "siteSearch": "搜索站點", + "siteNotFound": "未找到站點。", + "selectCountry": "選擇國家", + "searchCountries": "搜索國家...", + "noCountryFound": "找不到國家。", + "siteSelectionDescription": "此站點將為目標提供連接。", + "resourceType": "資源類型", + "resourceTypeDescription": "確定如何訪問您的資源", + "resourceHTTPSSettings": "HTTPS 設置", + "resourceHTTPSSettingsDescription": "配置如何通過 HTTPS 訪問您的資源", + "domainType": "域類型", + "subdomain": "子域名", + "baseDomain": "根域名", + "subdomnainDescription": "您的資源可以訪問的子域名。", + "resourceRawSettings": "TCP/UDP 設置", + "resourceRawSettingsDescription": "配置如何通過 TCP/UDP 訪問您的資源。 您映射資源到主機Pangolin伺服器上的埠,這樣您就可以訪問伺服器-公共-ip:mapped埠的資源。", + "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": "代理伺服器", + "internal": "內部設置", + "rules": "規則", + "resourceSettingDescription": "配置您資源上的設置", + "resourceSetting": "{resourceName} 設置", + "alwaysAllow": "一律允許", + "alwaysDeny": "一律拒絕", + "passToAuth": "傳遞至認證", + "orgSettingsDescription": "配置您組織的一般設定", + "orgGeneralSettings": "組織設置", + "orgGeneralSettingsDescription": "管理您的機構詳細資訊和配置", + "saveGeneralSettings": "保存常規設置", + "saveSettings": "保存設置", + "orgDangerZone": "危險區域", + "orgDangerZoneDescription": "一旦刪除該組織,將無法恢復,請務必確認。", + "orgDelete": "刪除組織", + "orgDeleteConfirm": "確認刪除組織", + "orgMessageRemove": "此操作不可逆,這將刪除所有相關數據。", + "orgMessageConfirm": "要確認,請在下面輸入組織名稱。", + "orgQuestionRemove": "您確定要刪除組織嗎?", + "orgUpdated": "組織已更新", + "orgUpdatedDescription": "組織已更新。", + "orgErrorUpdate": "更新組織失敗", + "orgErrorUpdateMessage": "更新組織時出錯。", + "orgErrorFetch": "獲取組織失敗", + "orgErrorFetchMessage": "列出您的組織時出錯", + "orgErrorDelete": "刪除組織失敗", + "orgErrorDeleteMessage": "刪除組織時出錯。", + "orgDeleted": "組織已刪除", + "orgDeletedMessage": "組織及其數據已被刪除。", + "orgMissing": "缺少組織 ID", + "orgMissingMessage": "沒有組織ID,無法重新生成邀請。", + "accessUsersManage": "管理用戶", + "accessUsersDescription": "邀請用戶並位他們添加角色以管理訪問您的組織", + "accessUsersSearch": "搜索用戶...", + "accessUserCreate": "創建用戶", + "accessUserRemove": "刪除用戶", + "username": "使用者名稱", + "identityProvider": "身份提供商", + "role": "角色", + "nameRequired": "名稱是必填項", + "accessRolesManage": "管理角色", + "accessRolesDescription": "配置角色來管理訪問您的組織", + "accessRolesSearch": "搜索角色...", + "accessRolesAdd": "添加角色", + "accessRoleDelete": "刪除角色", + "description": "描述", + "inviteTitle": "打開邀請", + "inviteDescription": "管理您給其他用戶的邀請", + "inviteSearch": "搜索邀請...", + "minutes": "分鐘", + "hours": "小時", + "days": "天", + "weeks": "周", + "months": "月", + "years": "年", + "day": "{count, plural, 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 金鑰嗎?", + "apiKeysMessageRemove": "一旦刪除,此API金鑰將無法被使用。", + "apiKeysDeleteConfirm": "確認刪除 API 金鑰", + "apiKeysDelete": "刪除 API 金鑰", + "apiKeysManage": "管理 API 金鑰", + "apiKeysDescription": "API 金鑰用於認證集成 API", + "apiKeysSettings": "{apiKeyName} 設置", + "userTitle": "管理所有用戶", + "userDescription": "查看和管理系統中的所有用戶", + "userAbount": "關於用戶管理", + "userAbountDescription": "此表格顯示系統中所有根用戶對象。每個用戶可能屬於多個組織。 從組織中刪除用戶不會刪除其根用戶對象 - 他們將保留在系統中。 要從系統中完全刪除用戶,您必須使用此表格中的刪除操作刪除其根用戶對象。", + "userServer": "伺服器用戶", + "userSearch": "搜索伺服器用戶...", + "userErrorDelete": "刪除用戶時出錯", + "userDeleteConfirm": "確認刪除用戶", + "userDeleteServer": "從伺服器刪除用戶", + "userMessageRemove": "該用戶將被從所有組織中刪除並完全從伺服器中刪除。", + "userQuestionRemove": "您確定要從伺服器永久刪除用戶嗎?", + "licenseKey": "許可證金鑰", + "valid": "有效", + "numberOfSites": "站點數量", + "licenseKeySearch": "搜索許可證金鑰...", + "licenseKeyAdd": "添加許可證金鑰", + "type": "類型", + "licenseKeyRequired": "需要許可證金鑰", + "licenseTermsAgree": "您必須同意許可條款", + "licenseErrorKeyLoad": "載入許可證金鑰失敗", + "licenseErrorKeyLoadDescription": "載入許可證金鑰時出錯。", + "licenseErrorKeyDelete": "刪除許可證金鑰失敗", + "licenseErrorKeyDeleteDescription": "刪除許可證金鑰時出錯。", + "licenseKeyDeleted": "許可證金鑰已刪除", + "licenseKeyDeletedDescription": "許可證金鑰已被刪除。", + "licenseErrorKeyActivate": "啟用許可證金鑰失敗", + "licenseErrorKeyActivateDescription": "啟用許可證金鑰時出錯。", + "licenseAbout": "關於許可協議", + "communityEdition": "社區版", + "licenseAboutDescription": "這是針對商業環境中使用Pangolin的商業和企業用戶。 如果您正在使用 Pangolin 供個人使用,您可以忽略此部分。", + "licenseKeyActivated": "授權金鑰已啟用", + "licenseKeyActivatedDescription": "已成功啟用許可證金鑰。", + "licenseErrorKeyRecheck": "重新檢查許可證金鑰失敗", + "licenseErrorKeyRecheckDescription": "重新檢查許可證金鑰時出錯。", + "licenseErrorKeyRechecked": "重新檢查許可證金鑰", + "licenseErrorKeyRecheckedDescription": "已重新檢查所有許可證金鑰", + "licenseActivateKey": "啟用許可證金鑰", + "licenseActivateKeyDescription": "輸入一個許可金鑰來啟用它。", + "licenseActivate": "啟用許可證", + "licenseAgreement": "通過檢查此框,您確認您已經閱讀並同意與您的許可證金鑰相關的許可條款。", + "fossorialLicense": "查看Fossorial Commercial License和訂閱條款", + "licenseMessageRemove": "這將刪除許可證金鑰和它授予的所有相關權限。", + "licenseMessageConfirm": "要確認,請在下面輸入許可證金鑰。", + "licenseQuestionRemove": "您確定要刪除許可證金鑰?", + "licenseKeyDelete": "刪除許可證金鑰", + "licenseKeyDeleteConfirm": "確認刪除許可證金鑰", + "licenseTitle": "管理許可證狀態", + "licenseTitleDescription": "查看和管理系統中的許可證金鑰", + "licenseHost": "主機許可證", + "licenseHostDescription": "管理主機的主許可證金鑰。", + "licensedNot": "未授權", + "hostId": "主機 ID", + "licenseReckeckAll": "重新檢查所有金鑰", + "licenseSiteUsage": "站點使用情況", + "licenseSiteUsageDecsription": "查看使用此許可的站點數量。", + "licenseNoSiteLimit": "使用未經許可主機的站點數量沒有限制。", + "licensePurchase": "購買許可證", + "licensePurchaseSites": "購買更多站點", + "licenseSitesUsedMax": "使用了 {usedSites}/{maxSites} 個站點", + "licenseSitesUsed": "{count, plural, =0 {# 站點} one {# 站點} other {# 站點}}", + "licensePurchaseDescription": "請選擇您希望 {selectedMode, select, license {直接購買許可證,您可以隨時增加更多站點。} other {為現有許可證購買更多站點}}", + "licenseFee": "許可證費用", + "licensePriceSite": "每個站點的價格", + "total": "總計", + "licenseContinuePayment": "繼續付款", + "pricingPage": "定價頁面", + "pricingPortal": "前往付款頁面", + "licensePricingPage": "關於最新的價格和折扣,請訪問 ", + "invite": "邀請", + "inviteRegenerate": "重新生成邀請", + "inviteRegenerateDescription": "撤銷以前的邀請並創建一個新的邀請", + "inviteRemove": "移除邀請", + "inviteRemoveError": "刪除邀請失敗", + "inviteRemoveErrorDescription": "刪除邀請時出錯。", + "inviteRemoved": "邀請已刪除", + "inviteRemovedDescription": "為 {email} 創建的邀請已刪除", + "inviteQuestionRemove": "您確定要刪除邀請嗎?", + "inviteMessageRemove": "一旦刪除,這個邀請將不再有效。", + "inviteMessageConfirm": "要確認,請在下面輸入邀請的電子郵件地址。", + "inviteQuestionRegenerate": "您確定要重新邀請 {email} 嗎?這將會撤銷掉之前的邀請", + "inviteRemoveConfirm": "確認刪除邀請", + "inviteRegenerated": "重新生成邀請", + "inviteSent": "邀請郵件已成功發送至 {email}。", + "inviteSentEmail": "發送電子郵件通知給用戶", + "inviteGenerate": "已為 {email} 創建新的邀請。", + "inviteDuplicateError": "重複的邀請", + "inviteDuplicateErrorDescription": "此用戶的邀請已存在。", + "inviteRateLimitError": "超出速率限制", + "inviteRateLimitErrorDescription": "您超過了每小時3次再生的限制。請稍後再試。", + "inviteRegenerateError": "重新生成邀請失敗", + "inviteRegenerateErrorDescription": "重新生成邀請時出錯。", + "inviteValidityPeriod": "有效期", + "inviteValidityPeriodSelect": "選擇有效期", + "inviteRegenerateMessage": "邀請已重新生成。用戶必須訪問下面的連結才能接受邀請。", + "inviteRegenerateButton": "重新生成", + "expiresAt": "到期於", + "accessRoleUnknown": "未知角色", + "placeholder": "占位符", + "userErrorOrgRemove": "刪除用戶失敗", + "userErrorOrgRemoveDescription": "刪除用戶時出錯。", + "userOrgRemoved": "用戶已刪除", + "userOrgRemovedDescription": "已將 {email} 從組織中移除。", + "userQuestionOrgRemove": "您確定要從組織中刪除此用戶嗎?", + "userMessageOrgRemove": "一旦刪除,這個用戶將不再能夠訪問組織。 你總是可以稍後重新邀請他們,但他們需要再次接受邀請。", + "userRemoveOrgConfirm": "確認刪除用戶", + "userRemoveOrg": "從組織中刪除用戶", + "users": "用戶", + "accessRoleMember": "成員", + "accessRoleOwner": "所有者", + "userConfirmed": "已確認", + "idpNameInternal": "內部設置", + "emailInvalid": "無效的電子郵件地址", + "inviteValidityDuration": "請選擇持續時間", + "accessRoleSelectPlease": "請選擇一個角色", + "usernameRequired": "必須輸入使用者名稱", + "idpSelectPlease": "請選擇身份提供商", + "idpGenericOidc": "通用的 OAuth2/OIDC 提供商。", + "accessRoleErrorFetch": "獲取角色失敗", + "accessRoleErrorFetchDescription": "獲取角色時出錯", + "idpErrorFetch": "獲取身份提供者失敗", + "idpErrorFetchDescription": "獲取身份提供者時出錯", + "userErrorExists": "用戶已存在", + "userErrorExistsDescription": "此用戶已經是組織成員。", + "inviteError": "邀請用戶失敗", + "inviteErrorDescription": "邀請用戶時出錯", + "userInvited": "用戶邀請", + "userInvitedDescription": "用戶已被成功邀請。", + "userErrorCreate": "創建用戶失敗", + "userErrorCreateDescription": "創建用戶時出錯", + "userCreated": "用戶已創建", + "userCreatedDescription": "用戶已成功創建。", + "userTypeInternal": "內部用戶", + "userTypeInternalDescription": "邀請用戶直接加入您的組織。", + "userTypeExternal": "外部用戶", + "userTypeExternalDescription": "創建一個具有外部身份提供商的用戶。", + "accessUserCreateDescription": "按照下面的步驟創建一個新用戶", + "userSeeAll": "查看所有用戶", + "userTypeTitle": "用戶類型", + "userTypeDescription": "確定如何創建用戶", + "userSettings": "用戶資訊", + "userSettingsDescription": "輸入新用戶的詳細資訊", + "inviteEmailSent": "發送邀請郵件給用戶", + "inviteValid": "有效", + "selectDuration": "選擇持續時間", + "accessRoleSelect": "選擇角色", + "inviteEmailSentDescription": "一封電子郵件已經發送給用戶,帶有下面的訪問連結。他們必須訪問該連結才能接受邀請。", + "inviteSentDescription": "用戶已被邀請。他們必須訪問下面的連結才能接受邀請。", + "inviteExpiresIn": "邀請將在{days, plural, other {# 天}}後過期。", + "idpTitle": "身份提供商", + "idpSelect": "為外部用戶選擇身份提供商", + "idpNotConfigured": "沒有配置身份提供者。請在創建外部用戶之前配置身份提供者。", + "usernameUniq": "這必須匹配所選身份提供者中存在的唯一使用者名稱。", + "emailOptional": "電子郵件(可選)", + "nameOptional": "名稱(可選)", + "accessControls": "訪問控制", + "userDescription2": "管理此用戶的設置", + "accessRoleErrorAdd": "添加用戶到角色失敗", + "accessRoleErrorAddDescription": "添加用戶到角色時出錯。", + "userSaved": "用戶已保存", + "userSavedDescription": "用戶已更新。", + "autoProvisioned": "自動設置", + "autoProvisionedDescription": "允許此用戶由身份提供商自動管理", + "accessControlsDescription": "管理此用戶在組織中可以訪問和做什麼", + "accessControlsSubmit": "保存訪問控制", + "roles": "角色", + "accessUsersRoles": "管理用戶和角色", + "accessUsersRolesDescription": "邀請用戶並將他們添加到角色以管理訪問您的組織", + "key": "關鍵字", + "createdAt": "創建於", + "proxyErrorInvalidHeader": "無效的自訂主機 Header。使用域名格式,或將空保存為取消自訂 Header。", + "proxyErrorTls": "無效的 TLS 伺服器名稱。使用域名格式,或保存空以刪除 TLS 伺服器名稱。", + "proxyEnableSSL": "啟用 SSL", + "proxyEnableSSLDescription": "啟用 SSL/TLS 加密以確保您目標的 HTTPS 連接。", + "target": "Target", + "configureTarget": "配置目標", + "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 伺服器名稱", + "targetTlsSniDescription": "SNI使用的 TLS 伺服器名稱。留空使用預設值。", + "targetTlsSubmit": "保存設置", + "targets": "目標配置", + "targetsDescription": "設置目標來路由流量到您的後端服務", + "targetStickySessions": "啟用置頂會話", + "targetStickySessionsDescription": "將連接保持在同一個後端目標的整個會話中。", + "methodSelect": "選擇方法", + "targetSubmit": "添加目標", + "targetNoOne": "此資源沒有任何目標。添加目標來配置向您後端發送請求的位置。", + "targetNoOneDescription": "在上面添加多個目標將啟用負載平衡。", + "targetsSubmit": "保存目標", + "addTarget": "添加目標", + "targetErrorInvalidIp": "無效的 IP 地址", + "targetErrorInvalidIpDescription": "請輸入有效的IP位址或主機名", + "targetErrorInvalidPort": "無效的埠", + "targetErrorInvalidPortDescription": "請輸入有效的埠號", + "targetErrorNoSite": "沒有選擇站點", + "targetErrorNoSiteDescription": "請選擇目標站點", + "targetCreated": "目標已創建", + "targetCreatedDescription": "目標已成功創建", + "targetErrorCreate": "創建目標失敗", + "targetErrorCreateDescription": "創建目標時出錯", + "save": "保存", + "proxyAdditional": "附加代理設置", + "proxyAdditionalDescription": "配置你的資源如何處理代理設置", + "proxyCustomHeader": "自訂主機 Header", + "proxyCustomHeaderDescription": "代理請求時設置的 Header。留空則使用預設值。", + "proxyAdditionalSubmit": "保存代理設置", + "subnetMaskErrorInvalid": "子網掩碼無效。必須在 0 和 32 之間。", + "ipAddressErrorInvalidFormat": "無效的 IP 地址格式", + "ipAddressErrorInvalidOctet": "無效的 IP 地址", + "path": "路徑", + "matchPath": "匹配路徑", + "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": "總是拒絕:阻止所有請求;無法嘗試驗證", + "rulesActionPassToAuth": "傳遞至認證:允許嘗試身份驗證方法", + "rulesMatchCriteria": "匹配條件", + "rulesMatchCriteriaIpAddress": "匹配一個指定的 IP 地址", + "rulesMatchCriteriaIpAddressRange": "在 CIDR 符號中匹配一系列IP位址", + "rulesMatchCriteriaUrl": "匹配一個 URL 路徑或模式", + "rulesEnable": "啟用規則", + "rulesEnableDescription": "啟用或禁用此資源的規則評估", + "rulesResource": "資源規則配置", + "rulesResourceDescription": "配置規則來控制對您資源的訪問", + "ruleSubmit": "添加規則", + "rulesNoOne": "沒有規則。使用表單添加規則。", + "rulesOrder": "規則按優先順序評定。", + "rulesSubmit": "保存規則", + "resourceErrorCreate": "創建資源時出錯", + "resourceErrorCreateDescription": "創建資源時出錯", + "resourceErrorCreateMessage": "創建資源時發生錯誤:", + "resourceErrorCreateMessageDescription": "發生意外錯誤", + "sitesErrorFetch": "獲取站點出錯", + "sitesErrorFetchDescription": "獲取站點時出錯", + "domainsErrorFetch": "獲取域名出錯", + "domainsErrorFetchDescription": "獲取域時出錯", + "none": "無", + "unknown": "未知", + "resources": "資源", + "resourcesDescription": "資源是您私有網路中運行的應用程式的代理。您可以為私有網路中的任何 HTTP/HTTPS 或 TCP/UDP 服務創建資源。每個資源都必須連接到一個站點,以通過加密的 WireGuard 隧道實現私密且安全的連接。", + "resourcesWireGuardConnect": "採用 WireGuard 提供的加密安全連接", + "resourcesMultipleAuthenticationMethods": "配置多個身份驗證方法", + "resourcesUsersRolesAccess": "基於用戶和角色的訪問控制", + "resourcesErrorUpdate": "切換資源失敗", + "resourcesErrorUpdateDescription": "更新資源時出錯", + "access": "訪問權限", + "shareLink": "{resource} 的分享連結", + "resourceSelect": "選擇資源", + "shareLinks": "分享連結", + "share": "分享連結", + "shareDescription2": "創建資源共享連結。連結提供對資源的臨時或無限制訪問。 當您創建連結時,您可以配置連結的到期時間。", + "shareEasyCreate": "輕鬆創建和分享", + "shareConfigurableExpirationDuration": "可配置的過期時間", + "shareSecureAndRevocable": "安全和可撤銷的", + "nameMin": "名稱長度必須大於 {len} 字元。", + "nameMax": "名稱長度必須小於 {len} 字元。", + "sitesConfirmCopy": "請確認您已經複製了配置。", + "unknownCommand": "未知命令", + "newtErrorFetchReleases": "無法獲取版本資訊: {err}", + "newtErrorFetchLatest": "無法獲取最新版資訊: {err}", + "newtEndpoint": "Newt 端點", + "newtId": "Newt ID", + "newtSecretKey": "Newt 私鑰", + "architecture": "架構", + "sites": "站點", + "siteWgAnyClients": "使用任何 WireGuard 用戶端連接。您必須使用對等IP解決您的內部資源。", + "siteWgCompatibleAllClients": "與所有 WireGuard 用戶端相容", + "siteWgManualConfigurationRequired": "需要手動配置", + "userErrorNotAdminOrOwner": "用戶不是管理員或所有者", + "pangolinSettings": "設置 - Pangolin", + "accessRoleYour": "您的角色:", + "accessRoleSelect2": "選擇角色", + "accessUserSelect": "選擇一個用戶", + "otpEmailEnter": "輸入電子郵件", + "otpEmailEnterDescription": "在輸入欄位輸入後按 Enter 鍵添加電子郵件。", + "otpEmailErrorInvalid": "無效的信箱地址。通配符(*)必須占據整個開頭部分。", + "otpEmailSmtpRequired": "需要先配置 SMTP", + "otpEmailSmtpRequiredDescription": "必須在伺服器上啟用 SMTP 才能使用一次性密碼驗證。", + "otpEmailTitle": "一次性密碼", + "otpEmailTitleDescription": "資源訪問需要基於電子郵件的身份驗證", + "otpEmailWhitelist": "電子郵件白名單", + "otpEmailWhitelistList": "白名單郵件", + "otpEmailWhitelistListDescription": "只有擁有這些電子郵件地址的用戶才能訪問此資源。 他們將被提示輸入一次性密碼發送到他們的電子郵件。 通配符 (*@example.com) 可以用來允許來自一個域名的任何電子郵件地址。", + "otpEmailWhitelistSave": "保存白名單", + "passwordAdd": "添加密碼", + "passwordRemove": "刪除密碼", + "pincodeAdd": "添加 PIN 碼", + "pincodeRemove": "移除 PIN 碼", + "resourceAuthMethods": "身份驗證方法", + "resourceAuthMethodsDescriptions": "允許透過額外的認證方法訪問資源", + "resourceAuthSettingsSave": "保存成功", + "resourceAuthSettingsSaveDescription": "已保存身份驗證設置", + "resourceErrorAuthFetch": "獲取數據失敗", + "resourceErrorAuthFetchDescription": "獲取數據時出錯", + "resourceErrorPasswordRemove": "刪除資源密碼出錯", + "resourceErrorPasswordRemoveDescription": "刪除資源密碼時出錯", + "resourceErrorPasswordSetup": "設置資源密碼出錯", + "resourceErrorPasswordSetupDescription": "設置資源密碼時出錯", + "resourceErrorPincodeRemove": "刪除資源固定碼時出錯", + "resourceErrorPincodeRemoveDescription": "刪除資源PIN碼時出錯", + "resourceErrorPincodeSetup": "設置資源 PIN 碼時出錯", + "resourceErrorPincodeSetupDescription": "設置資源 PIN 碼時發生錯誤", + "resourceErrorUsersRolesSave": "設置角色失敗", + "resourceErrorUsersRolesSaveDescription": "設置角色時出錯", + "resourceErrorWhitelistSave": "保存白名單失敗", + "resourceErrorWhitelistSaveDescription": "保存白名單時出錯", + "resourcePasswordSubmit": "啟用密碼保護", + "resourcePasswordProtection": "密碼保護 {status}", + "resourcePasswordRemove": "已刪除資源密碼", + "resourcePasswordRemoveDescription": "已成功刪除資源密碼", + "resourcePasswordSetup": "設置資源密碼", + "resourcePasswordSetupDescription": "已成功設置資源密碼", + "resourcePasswordSetupTitle": "設置密碼", + "resourcePasswordSetupTitleDescription": "設置密碼來保護此資源", + "resourcePincode": "PIN 碼", + "resourcePincodeSubmit": "啟用 PIN 碼保護", + "resourcePincodeProtection": "PIN 碼保護 {status}", + "resourcePincodeRemove": "資源 PIN 碼已刪除", + "resourcePincodeRemoveDescription": "已成功刪除資源 PIN 碼", + "resourcePincodeSetup": "資源 PIN 碼已設置", + "resourcePincodeSetupDescription": "資源 PIN 碼已成功設置", + "resourcePincodeSetupTitle": "設置 PIN 碼", + "resourcePincodeSetupTitleDescription": "設置 PIN 碼來保護此資源", + "resourceRoleDescription": "管理員總是可以訪問此資源。", + "resourceUsersRoles": "用戶和角色", + "resourceUsersRolesDescription": "配置用戶和角色可以訪問此資源", + "resourceUsersRolesSubmit": "保存用戶和角色", + "resourceWhitelistSave": "保存成功", + "resourceWhitelistSaveDescription": "白名單設置已保存", + "ssoUse": "使用平台 SSO", + "ssoUseDescription": "對於所有啟用此功能的資源,現有用戶只需登錄一次。", + "proxyErrorInvalidPort": "無效的埠號", + "subdomainErrorInvalid": "無效的子域", + "domainErrorFetch": "獲取域名失敗", + "domainErrorFetchDescription": "獲取域名時出錯", + "resourceErrorUpdate": "更新資源失敗", + "resourceErrorUpdateDescription": "更新資源時出錯", + "resourceUpdated": "資源已更新", + "resourceUpdatedDescription": "資源已成功更新", + "resourceErrorTransfer": "轉移資源失敗", + "resourceErrorTransferDescription": "轉移資源時出錯", + "resourceTransferred": "資源已傳輸", + "resourceTransferredDescription": "資源已成功傳輸", + "resourceErrorToggle": "切換資源失敗", + "resourceErrorToggleDescription": "更新資源時出錯", + "resourceVisibilityTitle": "可見性", + "resourceVisibilityTitleDescription": "完全啟用或禁用資源可見性", + "resourceGeneral": "常規設置", + "resourceGeneralDescription": "配置此資源的常規設置", + "resourceEnable": "啟用資源", + "resourceTransfer": "轉移資源", + "resourceTransferDescription": "將此資源轉移到另一個站點", + "resourceTransferSubmit": "轉移資源", + "siteDestination": "目標站點", + "searchSites": "搜索站點", + "accessRoleCreate": "創建角色", + "accessRoleCreateDescription": "創建一個新角色來分組用戶並管理他們的權限。", + "accessRoleCreateSubmit": "創建角色", + "accessRoleCreated": "角色已創建", + "accessRoleCreatedDescription": "角色已成功創建。", + "accessRoleErrorCreate": "創建角色失敗", + "accessRoleErrorCreateDescription": "創建角色時出錯。", + "accessRoleErrorNewRequired": "需要新角色", + "accessRoleErrorRemove": "刪除角色失敗", + "accessRoleErrorRemoveDescription": "刪除角色時出錯。", + "accessRoleName": "角色名稱", + "accessRoleQuestionRemove": "您即將刪除 {name} 角色。 此操作無法撤銷。", + "accessRoleRemove": "刪除角色", + "accessRoleRemoveDescription": "從組織中刪除角色", + "accessRoleRemoveSubmit": "刪除角色", + "accessRoleRemoved": "角色已刪除", + "accessRoleRemovedDescription": "角色已成功刪除。", + "accessRoleRequiredRemove": "刪除此角色之前,請選擇一個新角色來轉移現有成員。", + "manage": "管理", + "sitesNotFound": "未找到站點。", + "pangolinServerAdmin": "伺服器管理員 - Pangolin", + "licenseTierProfessional": "專業許可證", + "licenseTierEnterprise": "企業許可證", + "licenseTierPersonal": "個人許可證", + "licensed": "已授權", + "yes": "是", + "no": "否", + "sitesAdditional": "其他站點", + "licenseKeys": "許可證金鑰", + "sitestCountDecrease": "減少站點數量", + "sitestCountIncrease": "增加站點數量", + "idpManage": "管理身份提供商", + "idpManageDescription": "查看和管理系統中的身份提供商", + "idpDeletedDescription": "身份提供商刪除成功", + "idpOidc": "OAuth2/OIDC", + "idpQuestionRemove": "您確定要永久刪除身份提供者嗎?", + "idpMessageRemove": "這將刪除身份提供者和所有相關的配置。透過此提供者進行身份驗證的用戶將無法登錄。", + "idpMessageConfirm": "要確認,請在下面輸入身份提供者的名稱。", + "idpConfirmDelete": "確認刪除身份提供商", + "idpDelete": "刪除身份提供商", + "idp": "身份提供商", + "idpSearch": "搜索身份提供者...", + "idpAdd": "添加身份提供商", + "idpClientIdRequired": "用戶端 ID 是必需的。", + "idpClientSecretRequired": "用戶端金鑰是必需的。", + "idpErrorAuthUrlInvalid": "身份驗證 URL 必須是有效的 URL。", + "idpErrorTokenUrlInvalid": "令牌 URL 必須是有效的 URL。", + "idpPathRequired": "標識符路徑是必需的。", + "idpScopeRequired": "授權範圍是必需的。", + "idpOidcDescription": "配置 OpenID 連接身份提供商", + "idpCreatedDescription": "身份提供商創建成功", + "idpCreate": "創建身份提供商", + "idpCreateDescription": "配置用戶身份驗證的新身份提供商", + "idpSeeAll": "查看所有身份提供商", + "idpSettingsDescription": "配置身份提供者的基本資訊", + "idpDisplayName": "此身份提供商的顯示名稱", + "idpAutoProvisionUsers": "自動提供用戶", + "idpAutoProvisionUsersDescription": "如果啟用,用戶將在首次登錄時自動在系統中創建,並且能夠映射用戶到角色和組織。", + "licenseBadge": "EE", + "idpType": "提供者類型", + "idpTypeDescription": "選擇您想要配置的身份提供者類型", + "idpOidcConfigure": "OAuth2/OIDC 配置", + "idpOidcConfigureDescription": "配置 OAuth2/OIDC 供應商端點和憑據", + "idpClientId": "用戶端ID", + "idpClientIdDescription": "來自您身份提供商的 OAuth2 用戶端 ID", + "idpClientSecret": "用戶端金鑰", + "idpClientSecretDescription": "來自身份提供商的 OAuth2 用戶端金鑰", + "idpAuthUrl": "授權 URL", + "idpAuthUrlDescription": "OAuth2 授權端點的 URL", + "idpTokenUrl": "令牌 URL", + "idpTokenUrlDescription": "OAuth2 令牌端點的 URL", + "idpOidcConfigureAlert": "重要提示", + "idpOidcConfigureAlertDescription": "創建身份提供方後,您需要在其設置中配置回調 URL。回調 URL 會在創建成功後提供。", + "idpToken": "令牌配置", + "idpTokenDescription": "配置如何從 ID 令牌中提取用戶資訊", + "idpJmespathAbout": "關於 JMESPath", + "idpJmespathAboutDescription": "以下路徑使用 JMESPath 語法從 ID 令牌中提取值。", + "idpJmespathAboutDescriptionLink": "了解更多 JMESPath 資訊", + "idpJmespathLabel": "標識符路徑", + "idpJmespathLabelDescription": "ID 令牌中用戶標識符的路徑", + "idpJmespathEmailPathOptional": "信箱路徑(可選)", + "idpJmespathEmailPathOptionalDescription": "ID 令牌中用戶信箱的路徑", + "idpJmespathNamePathOptional": "使用者名稱路徑(可選)", + "idpJmespathNamePathOptionalDescription": "ID 令牌中使用者名稱的路徑", + "idpOidcConfigureScopes": "作用域(Scopes)", + "idpOidcConfigureScopesDescription": "以空格分隔的 OAuth2 請求作用域列表", + "idpSubmit": "創建身份提供商", + "orgPolicies": "組織策略", + "idpSettings": "{idpName} 設置", + "idpCreateSettingsDescription": "配置身份提供商的設置", + "roleMapping": "角色映射", + "orgMapping": "組織映射", + "orgPoliciesSearch": "搜索組織策略...", + "orgPoliciesAdd": "添加組織策略", + "orgRequired": "組織是必填項", + "error": "錯誤", + "success": "成功", + "orgPolicyAddedDescription": "策略添加成功", + "orgPolicyUpdatedDescription": "策略更新成功", + "orgPolicyDeletedDescription": "已成功刪除策略", + "defaultMappingsUpdatedDescription": "默認映射更新成功", + "orgPoliciesAbout": "關於組織政策", + "orgPoliciesAboutDescription": "組織策略用於根據用戶的 ID 令牌來控制對組織的訪問。 您可以指定 JMESPath 表達式來提取角色和組織資訊從 ID 令牌中提取資訊。", + "orgPoliciesAboutDescriptionLink": "欲了解更多資訊,請參閱文件。", + "defaultMappingsOptional": "默認映射(可選)", + "defaultMappingsOptionalDescription": "當沒有為某個組織定義組織的政策時,使用默認映射。 您可以指定默認角色和組織映射回到這裡。", + "defaultMappingsRole": "默認角色映射", + "defaultMappingsRoleDescription": "此表達式的結果必須返回組織中定義的角色名稱作為字串。", + "defaultMappingsOrg": "默認組織映射", + "defaultMappingsOrgDescription": "此表達式必須返回 組織ID 或 true 才能允許用戶訪問組織。", + "defaultMappingsSubmit": "保存默認映射", + "orgPoliciesEdit": "編輯組織策略", + "org": "組織", + "orgSelect": "選擇組織", + "orgSearch": "搜索", + "orgNotFound": "找不到組織。", + "roleMappingPathOptional": "角色映射路徑(可選)", + "orgMappingPathOptional": "組織映射路徑(可選)", + "orgPolicyUpdate": "更新策略", + "orgPolicyAdd": "添加策略", + "orgPolicyConfig": "配置組織訪問權限", + "idpUpdatedDescription": "身份提供商更新成功", + "redirectUrl": "重定向網址", + "redirectUrlAbout": "關於重定向網址", + "redirectUrlAboutDescription": "這是用戶在驗證後將被重定向到的URL。您需要在身份提供商設置中配置此URL。", + "pangolinAuth": "認證 - Pangolin", + "verificationCodeLengthRequirements": "您的驗證碼必須是 8 個字元。", + "errorOccurred": "發生錯誤", + "emailErrorVerify": "驗證電子郵件失敗:", + "emailVerified": "電子郵件驗證成功!重定向您...", + "verificationCodeErrorResend": "無法重新發送驗證碼:", + "verificationCodeResend": "驗證碼已重新發送", + "verificationCodeResendDescription": "我們已將驗證碼重新發送到您的電子郵件地址。請檢查您的收件箱。", + "emailVerify": "驗證電子郵件", + "emailVerifyDescription": "輸入驗證碼發送到您的電子郵件地址。", + "verificationCode": "驗證碼", + "verificationCodeEmailSent": "我們向您的電子郵件地址發送了驗證碼。", + "submit": "提交", + "emailVerifyResendProgress": "正在重新發送...", + "emailVerifyResend": "沒有收到代碼?點擊此處重新發送", + "passwordNotMatch": "密碼不匹配", + "signupError": "註冊時出錯", + "pangolinLogoAlt": "Pangolin 標誌", + "inviteAlready": "看起來您已被邀請!", + "inviteAlreadyDescription": "要接受邀請,您必須登錄或創建一個帳戶。", + "signupQuestion": "已經有一個帳戶?", + "login": "登錄", + "resourceNotFound": "找不到資源", + "resourceNotFoundDescription": "您要訪問的資源不存在。", + "pincodeRequirementsLength": "PIN碼必須是 6 位數字", + "pincodeRequirementsChars": "PIN 必須只包含數字", + "passwordRequirementsLength": "密碼必須至少 1 個字元長", + "passwordRequirementsTitle": "密碼要求:", + "passwordRequirementLength": "至少 8 個字元長", + "passwordRequirementUppercase": "至少一個大寫字母", + "passwordRequirementLowercase": "至少一個小寫字母", + "passwordRequirementNumber": "至少一個數字", + "passwordRequirementSpecial": "至少一個特殊字元", + "passwordRequirementsMet": "✓ 密碼滿足所有要求", + "passwordStrength": "密碼強度", + "passwordStrengthWeak": "弱", + "passwordStrengthMedium": "中", + "passwordStrengthStrong": "強", + "passwordRequirements": "要求:", + "passwordRequirementLengthText": "8+ 個字元", + "passwordRequirementUppercaseText": "大寫字母 (A-Z)", + "passwordRequirementLowercaseText": "小寫字母 (a-z)", + "passwordRequirementNumberText": "數字 (0-9)", + "passwordRequirementSpecialText": "特殊字元 (!@#$%...)", + "passwordsDoNotMatch": "密碼不匹配", + "otpEmailRequirementsLength": "OTP 必須至少 1 個字元長", + "otpEmailSent": "OTP 已發送", + "otpEmailSentDescription": "OTP 已經發送到您的電子郵件", + "otpEmailErrorAuthenticate": "通過電子郵件身份驗證失敗", + "pincodeErrorAuthenticate": "Pincode 驗證失敗", + "passwordErrorAuthenticate": "密碼驗證失敗", + "poweredBy": "支持者:", + "authenticationRequired": "需要身份驗證", + "authenticationMethodChoose": "請選擇您偏好的方式來訪問 {name}", + "authenticationRequest": "您必須通過身份驗證才能訪問 {name}", + "user": "用戶", + "pincodeInput": "6 位數字 PIN 碼", + "pincodeSubmit": "使用 PIN 登錄", + "passwordSubmit": "使用密碼登錄", + "otpEmailDescription": "一次性代碼將發送到此電子郵件。", + "otpEmailSend": "發送一次性代碼", + "otpEmail": "一次性密碼 (OTP)", + "otpEmailSubmit": "提交 OTP", + "backToEmail": "回到電子郵件", + "noSupportKey": "伺服器當前未使用支持者金鑰,歡迎支持本項目!", + "accessDenied": "訪問被拒絕", + "accessDeniedDescription": "當前帳戶無權訪問此資源。如認為這是錯誤,請與管理員聯繫。", + "accessTokenError": "檢查訪問令牌時出錯", + "accessGranted": "已授予訪問", + "accessUrlInvalid": "訪問 URL 無效", + "accessGrantedDescription": "您已獲准訪問此資源,正在為您跳轉...", + "accessUrlInvalidDescription": "此共享訪問URL無效。請聯絡資源所有者獲取新URL。", + "tokenInvalid": "無效的令牌", + "pincodeInvalid": "無效的代碼", + "passwordErrorRequestReset": "請求重設失敗:", + "passwordErrorReset": "重設密碼失敗:", + "passwordResetSuccess": "密碼重設成功!返回登錄...", + "passwordReset": "重設密碼", + "passwordResetDescription": "按照步驟重設您的密碼", + "passwordResetSent": "我們將發送一個驗證碼到這個電子郵件地址。", + "passwordResetCode": "驗證碼", + "passwordResetCodeDescription": "請檢查您的電子郵件以獲取驗證碼。", + "passwordNew": "新密碼", + "passwordNewConfirm": "確認新密碼", + "changePassword": "更改密碼", + "changePasswordDescription": "更新您的帳戶密碼", + "oldPassword": "當前密碼", + "newPassword": "新密碼", + "confirmNewPassword": "確認新密碼", + "changePasswordError": "更改密碼失敗", + "changePasswordErrorDescription": "更改您的密碼時出錯", + "changePasswordSuccess": "密碼修改成功", + "changePasswordSuccessDescription": "您的密碼已成功更新", + "passwordExpiryRequired": "需要密碼過期", + "passwordExpiryDescription": "該機構要求您每 {maxDays} 天更改一次密碼。", + "changePasswordNow": "現在更改密碼", + "pincodeAuth": "驗證器代碼", + "pincodeSubmit2": "提交代碼", + "passwordResetSubmit": "請求重設", + "passwordBack": "回到密碼", + "loginBack": "返回登錄", + "signup": "註冊", + "loginStart": "登錄以開始", + "idpOidcTokenValidating": "正在驗證 OIDC 令牌", + "idpOidcTokenResponse": "驗證 OIDC 令牌響應", + "idpErrorOidcTokenValidating": "驗證 OIDC 令牌出錯", + "idpConnectingTo": "連接到{name}", + "idpConnectingToDescription": "正在驗證您的身份", + "idpConnectingToProcess": "正在連接...", + "idpConnectingToFinished": "已連接", + "idpErrorConnectingTo": "無法連接到 {name},請聯絡管理員協助處理。", + "idpErrorNotFound": "找不到 IdP", + "inviteInvalid": "無效邀請", + "inviteInvalidDescription": "邀請連結無效。", + "inviteErrorWrongUser": "邀請不是該用戶的", + "inviteErrorUserNotExists": "用戶不存在。請先創建帳戶。", + "inviteErrorLoginRequired": "您必須登錄才能接受邀請", + "inviteErrorExpired": "邀請可能已過期", + "inviteErrorRevoked": "邀請可能已被吊銷了", + "inviteErrorTypo": "邀請連結中可能有一個類型", + "pangolinSetup": "認證 - Pangolin", + "orgNameRequired": "組織名稱是必需的", + "orgIdRequired": "組織ID是必需的", + "orgErrorCreate": "創建組織時出錯", + "pageNotFound": "找不到頁面", + "pageNotFoundDescription": "哎呀!您正在尋找的頁面不存在。", + "overview": "概覽", + "home": "首頁", + "accessControl": "訪問控制", + "settings": "設置", + "usersAll": "所有用戶", + "license": "許可協議", + "pangolinDashboard": "儀錶板 - Pangolin", + "noResults": "未找到任何結果。", + "terabytes": "{count} TB", + "gigabytes": "{count} GB", + "megabytes": "{count} MB", + "tagsEntered": "已輸入的標籤", + "tagsEnteredDescription": "這些是您輸入的標籤。", + "tagsWarnCannotBeLessThanZero": "最大標籤和最小標籤不能小於 0", + "tagsWarnNotAllowedAutocompleteOptions": "標記不允許為每個自動完成選項", + "tagsWarnInvalid": "無效的標籤,每個有效標籤", + "tagWarnTooShort": "標籤 {tagText} 太短", + "tagWarnTooLong": "標籤 {tagText} 太長", + "tagsWarnReachedMaxNumber": "已達到允許標籤的最大數量", + "tagWarnDuplicate": "未添加重複標籤 {tagText}", + "supportKeyInvalid": "無效金鑰", + "supportKeyInvalidDescription": "您的支持者金鑰無效。", + "supportKeyValid": "有效的金鑰", + "supportKeyValidDescription": "您的支持者金鑰已被驗證。感謝您的支持!", + "supportKeyErrorValidationDescription": "驗證支持者金鑰失敗。", + "supportKey": "支持開發和通過一個 Pangolin !", + "supportKeyDescription": "購買支持者鑰匙,幫助我們繼續為社區發展 Pangolin 。 您的貢獻使我們能夠投入更多的時間來維護和添加所有人的新功能。 我們永遠不會用這個來支付牆上的功能。這與任何商業版是分開的。", + "supportKeyPet": "您還可以領養並見到屬於自己的 Pangolin!", + "supportKeyPurchase": "付款通過 GitHub 進行處理,之後您可以在以下位置獲取您的金鑰:", + "supportKeyPurchaseLink": "我們的網站", + "supportKeyPurchase2": "並在這裡兌換。", + "supportKeyLearnMore": "了解更多。", + "supportKeyOptions": "請選擇最適合您的選項。", + "supportKetOptionFull": "完全支持者", + "forWholeServer": "適用於整個伺服器", + "lifetimePurchase": "終身購買", + "supporterStatus": "支持者狀態", + "buy": "購買", + "supportKeyOptionLimited": "有限支持者", + "forFiveUsers": "適用於 5 或更少用戶", + "supportKeyRedeem": "兌換支持者金鑰", + "supportKeyHideSevenDays": "隱藏 7 天", + "supportKeyEnter": "輸入支持者金鑰", + "supportKeyEnterDescription": "見到你自己的 Pangolin!", + "githubUsername": "GitHub 使用者名稱", + "supportKeyInput": "支持者金鑰", + "supportKeyBuy": "購買支持者金鑰", + "logoutError": "註銷錯誤", + "signingAs": "登錄為", + "serverAdmin": "伺服器管理員", + "managedSelfhosted": "託管自託管", + "otpEnable": "啟用雙因子認證", + "otpDisable": "禁用雙因子認證", + "logout": "登出", + "licenseTierProfessionalRequired": "需要專業版", + "licenseTierProfessionalRequiredDescription": "此功能僅在專業版可用。", + "actionGetOrg": "獲取組織", + "updateOrgUser": "更新組織用戶", + "createOrgUser": "創建組織用戶", + "actionUpdateOrg": "更新組織", + "actionUpdateUser": "更新用戶", + "actionGetUser": "獲取用戶", + "actionGetOrgUser": "獲取組織用戶", + "actionListOrgDomains": "列出組織域", + "actionCreateSite": "創建站點", + "actionDeleteSite": "刪除站點", + "actionGetSite": "獲取站點", + "actionListSites": "站點列表", + "actionApplyBlueprint": "應用藍圖", + "setupToken": "設置令牌", + "setupTokenDescription": "從伺服器控制台輸入設定令牌。", + "setupTokenRequired": "需要設置令牌", + "actionUpdateSite": "更新站點", + "actionListSiteRoles": "允許站點角色列表", + "actionCreateResource": "創建資源", + "actionDeleteResource": "刪除資源", + "actionGetResource": "獲取資源", + "actionListResource": "列出資源", + "actionUpdateResource": "更新資源", + "actionListResourceUsers": "列出資源用戶", + "actionSetResourceUsers": "設置資源用戶", + "actionSetAllowedResourceRoles": "設置允許的資源角色", + "actionListAllowedResourceRoles": "列出允許的資源角色", + "actionSetResourcePassword": "設置資源密碼", + "actionSetResourcePincode": "設置資源粉碼", + "actionSetResourceEmailWhitelist": "設置資源電子郵件白名單", + "actionGetResourceEmailWhitelist": "獲取資源電子郵件白名單", + "actionCreateTarget": "創建目標", + "actionDeleteTarget": "刪除目標", + "actionGetTarget": "獲取目標", + "actionListTargets": "列表目標", + "actionUpdateTarget": "更新目標", + "actionCreateRole": "創建角色", + "actionDeleteRole": "刪除角色", + "actionGetRole": "獲取角色", + "actionListRole": "角色列表", + "actionUpdateRole": "更新角色", + "actionListAllowedRoleResources": "列表允許的角色資源", + "actionInviteUser": "邀請用戶", + "actionRemoveUser": "刪除用戶", + "actionListUsers": "列出用戶", + "actionAddUserRole": "添加用戶角色", + "actionGenerateAccessToken": "生成訪問令牌", + "actionDeleteAccessToken": "刪除訪問令牌", + "actionListAccessTokens": "訪問令牌", + "actionCreateResourceRule": "創建資源規則", + "actionDeleteResourceRule": "刪除資源規則", + "actionListResourceRules": "列出資源規則", + "actionUpdateResourceRule": "更新資源規則", + "actionListOrgs": "列出組織", + "actionCheckOrgId": "檢查組織ID", + "actionCreateOrg": "創建組織", + "actionDeleteOrg": "刪除組織", + "actionListApiKeys": "列出 API 金鑰", + "actionListApiKeyActions": "列出 API 金鑰動作", + "actionSetApiKeyActions": "設置 API 金鑰允許的操作", + "actionCreateApiKey": "創建 API 金鑰", + "actionDeleteApiKey": "刪除 API 金鑰", + "actionCreateIdp": "創建 IDP", + "actionUpdateIdp": "更新 IDP", + "actionDeleteIdp": "刪除 IDP", + "actionListIdps": "列出 IDP", + "actionGetIdp": "獲取 IDP", + "actionCreateIdpOrg": "創建 IDP 組織策略", + "actionDeleteIdpOrg": "刪除 IDP 組織策略", + "actionListIdpOrgs": "列出 IDP 組織", + "actionUpdateIdpOrg": "更新 IDP 組織", + "actionCreateClient": "創建用戶端", + "actionDeleteClient": "刪除用戶端", + "actionUpdateClient": "更新用戶端", + "actionListClients": "列出用戶端", + "actionGetClient": "獲取用戶端", + "actionCreateSiteResource": "創建站點資源", + "actionDeleteSiteResource": "刪除站點資源", + "actionGetSiteResource": "獲取站點資源", + "actionListSiteResources": "列出站點資源", + "actionUpdateSiteResource": "更新站點資源", + "actionListInvitations": "邀請列表", + "noneSelected": "未選擇", + "orgNotFound2": "未找到組織。", + "searchProgress": "搜索中...", + "create": "創建", + "orgs": "組織", + "loginError": "登錄時出錯", + "passwordForgot": "忘記密碼?", + "otpAuth": "兩步驗證", + "otpAuthDescription": "從您的身份驗證程序中輸入代碼或您的單次備份代碼。", + "otpAuthSubmit": "提交代碼", + "idpContinue": "或者繼續", + "otpAuthBack": "返回登錄", + "navbar": "導航菜單", + "navbarDescription": "應用程式的主導航菜單", + "navbarDocsLink": "文件", + "otpErrorEnable": "無法啟用 2FA", + "otpErrorEnableDescription": "啟用 2FA 時出錯", + "otpSetupCheckCode": "請輸入您的 6 位數字代碼", + "otpSetupCheckCodeRetry": "無效的代碼。請重試。", + "otpSetup": "啟用兩步驗證", + "otpSetupDescription": "用額外的保護層來保護您的帳戶", + "otpSetupScanQr": "用您的身份驗證程序掃描此二維碼或手動輸入金鑰:", + "otpSetupSecretCode": "驗證器代碼", + "otpSetupSuccess": "啟用兩步驗證", + "otpSetupSuccessStoreBackupCodes": "您的帳戶現在更加安全。不要忘記保存您的備份代碼。", + "otpErrorDisable": "無法禁用 2FA", + "otpErrorDisableDescription": "禁用 2FA 時出錯", + "otpRemove": "禁用兩步驗證", + "otpRemoveDescription": "為您的帳戶禁用兩步驗證", + "otpRemoveSuccess": "雙重身份驗證已禁用", + "otpRemoveSuccessMessage": "您的帳戶已禁用雙重身份驗證。您可以隨時再次啟用它。", + "otpRemoveSubmit": "禁用兩步驗證", + "paginator": "第 {current} 頁,共 {last} 頁", + "paginatorToFirst": "轉到第一頁", + "paginatorToPrevious": "轉到上一頁", + "paginatorToNext": "轉到下一頁", + "paginatorToLast": "轉到最後一頁", + "copyText": "複製文本", + "copyTextFailed": "複製文本失敗: ", + "copyTextClipboard": "複製到剪貼簿", + "inviteErrorInvalidConfirmation": "無效確認", + "passwordRequired": "必須填寫密碼", + "allowAll": "允許所有", + "permissionsAllowAll": "允許所有權限", + "githubUsernameRequired": "必須填寫 GitHub 使用者名稱", + "supportKeyRequired": "必須填寫支持者金鑰", + "passwordRequirementsChars": "密碼至少需要 8 個字元", + "language": "語言", + "verificationCodeRequired": "必須輸入代碼", + "userErrorNoUpdate": "沒有要更新的用戶", + "siteErrorNoUpdate": "沒有要更新的站點", + "resourceErrorNoUpdate": "沒有可更新的資源", + "authErrorNoUpdate": "沒有要更新的身份驗證資訊", + "orgErrorNoUpdate": "沒有要更新的組織", + "orgErrorNoProvided": "未提供組織", + "apiKeysErrorNoUpdate": "沒有要更新的 API 金鑰", + "sidebarOverview": "概覽", + "sidebarHome": "首頁", + "sidebarSites": "站點", + "sidebarResources": "資源", + "sidebarAccessControl": "訪問控制", + "sidebarUsers": "用戶", + "sidebarInvitations": "邀請", + "sidebarRoles": "角色", + "sidebarShareableLinks": "分享連結", + "sidebarApiKeys": "API 金鑰", + "sidebarSettings": "設置", + "sidebarAllUsers": "所有用戶", + "sidebarIdentityProviders": "身份提供商", + "sidebarLicense": "證書", + "sidebarClients": "用戶端", + "sidebarDomains": "域", + "sidebarBluePrints": "藍圖", + "blueprints": "藍圖", + "blueprintsDescription": "應用聲明配置並查看先前運行的", + "blueprintAdd": "添加藍圖", + "blueprintGoBack": "查看所有藍圖", + "blueprintCreate": "創建藍圖", + "blueprintCreateDescription2": "按照下面的步驟創建和應用新的藍圖", + "blueprintDetails": "藍圖詳細資訊", + "blueprintDetailsDescription": "查看應用藍圖的結果和發生的任何錯誤", + "blueprintInfo": "藍圖資訊", + "message": "留言", + "blueprintContentsDescription": "定義描述您基礎設施的 YAML 內容", + "blueprintErrorCreateDescription": "應用藍圖時出錯", + "blueprintErrorCreate": "創建藍圖時出錯", + "searchBlueprintProgress": "搜索藍圖...", + "appliedAt": "應用於", + "source": "來源", + "contents": "目錄", + "parsedContents": "解析內容 (只讀)", + "enableDockerSocket": "啟用 Docker 藍圖", + "enableDockerSocketDescription": "啟用 Docker Socket 標籤擦除藍圖標籤。套接字路徑必須提供給新的。", + "enableDockerSocketLink": "了解更多", + "viewDockerContainers": "查看停靠容器", + "containersIn": "{siteName} 中的容器", + "selectContainerDescription": "選擇任何容器作為目標的主機名。點擊埠使用埠。", + "containerName": "名稱", + "containerImage": "圖片", + "containerState": "狀態", + "containerNetworks": "網路", + "containerHostnameIp": "主機名/IP", + "containerLabels": "標籤", + "containerLabelsCount": "{count, plural, other {# 標籤}}", + "containerLabelsTitle": "容器標籤", + "containerLabelEmpty": "<為空>", + "containerPorts": "埠", + "containerPortsMore": "+{count} 更多", + "containerActions": "行動", + "select": "選擇", + "noContainersMatchingFilters": "沒有找到匹配當前過濾器的容器。", + "showContainersWithoutPorts": "顯示沒有埠的容器", + "showStoppedContainers": "顯示已停止的容器", + "noContainersFound": "未找到容器。請確保 Docker 容器正在運行。", + "searchContainersPlaceholder": "在 {count} 個容器中搜索...", + "searchResultsCount": "{count, plural, other {# 個結果}}", + "filters": "篩選器", + "filterOptions": "過濾器選項", + "filterPorts": "埠", + "filterStopped": "已停止", + "clearAllFilters": "清除所有過濾器", + "columns": "列", + "toggleColumns": "切換列", + "refreshContainersList": "刷新容器列表", + "searching": "搜索中...", + "noContainersFoundMatching": "未找到與 \"{filter}\" 匹配的容器。", + "light": "淺色", + "dark": "深色", + "system": "系統", + "theme": "主題", + "subnetRequired": "子網是必填項", + "initialSetupTitle": "初始伺服器設置", + "initialSetupDescription": "創建初始伺服器管理員帳戶。 只能存在一個伺服器管理員。 您可以隨時更改這些憑據。", + "createAdminAccount": "創建管理員帳戶", + "setupErrorCreateAdmin": "創建伺服器管理員帳戶時發生錯誤。", + "certificateStatus": "證書狀態", + "loading": "載入中", + "restart": "重啟", + "domains": "域", + "domainsDescription": "管理您的組織域", + "domainsSearch": "搜索域...", + "domainAdd": "添加域", + "domainAddDescription": "在您的組織中註冊新域", + "domainCreate": "創建域", + "domainCreatedDescription": "域創建成功", + "domainDeletedDescription": "成功刪除域", + "domainQuestionRemove": "您確定要從您的帳戶中刪除域名嗎?", + "domainMessageRemove": "移除後,該域將不再與您的帳戶關聯。", + "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": "example.com", + "domainPickerDescription": "輸入資源的完整域名以查看可用選項。", + "domainPickerDescriptionSaas": "輸入完整域名、子域或名稱以查看可用選項。", + "domainPickerTabAll": "所有", + "domainPickerTabOrganization": "組織", + "domainPickerTabProvided": "提供的", + "domainPickerSortAsc": "A-Z", + "domainPickerSortDesc": "Z-A", + "domainPickerCheckingAvailability": "檢查可用性...", + "domainPickerNoMatchingDomains": "未找到匹配的域名。嘗試不同的域名或檢查您組織的域名設置。", + "domainPickerOrganizationDomains": "組織域", + "domainPickerProvidedDomains": "提供的域", + "domainPickerSubdomain": "子域:{subdomain}", + "domainPickerNamespace": "命名空間:{namespace}", + "domainPickerShowMore": "顯示更多", + "regionSelectorTitle": "選擇區域", + "regionSelectorInfo": "選擇區域以幫助提升您所在地的性能。您不必與伺服器在相同的區域。", + "regionSelectorPlaceholder": "選擇一個區域", + "regionSelectorComingSoon": "即將推出", + "billingLoadingSubscription": "正在載入訂閱...", + "billingFreeTier": "免費層", + "billingWarningOverLimit": "警告:您已超出一個或多個使用限制。在您修改訂閱或調整使用情況之前,您的站點將無法連接。", + "billingUsageLimitsOverview": "使用限制概覽", + "billingMonitorUsage": "監控您的使用情況以對比已配置的限制。如需提高限制請聯絡我們 support@pangolin.net。", + "billingDataUsage": "數據使用情況", + "billingOnlineTime": "站點在線時間", + "billingUsers": "活躍用戶", + "billingDomains": "活躍域", + "billingRemoteExitNodes": "活躍自託管節點", + "billingNoLimitConfigured": "未配置限制", + "billingEstimatedPeriod": "估計結算週期", + "billingIncludedUsage": "包含的使用量", + "billingIncludedUsageDescription": "您當前訂閱計劃中包含的使用量", + "billingFreeTierIncludedUsage": "免費層使用額度", + "billingIncluded": "包含", + "billingEstimatedTotal": "預計總額:", + "billingNotes": "備註", + "billingEstimateNote": "這是根據您當前使用情況的估算。", + "billingActualChargesMayVary": "實際費用可能會有變化。", + "billingBilledAtEnd": "您將在結算週期結束時被計費。", + "billingModifySubscription": "修改訂閱", + "billingStartSubscription": "開始訂閱", + "billingRecurringCharge": "週期性收費", + "billingManageSubscriptionSettings": "管理您的訂閱設置和偏好", + "billingNoActiveSubscription": "您沒有活躍的訂閱。開始訂閱以增加使用限制。", + "billingFailedToLoadSubscription": "無法載入訂閱", + "billingFailedToLoadUsage": "無法載入使用情況", + "billingFailedToGetCheckoutUrl": "無法獲取結帳網址", + "billingPleaseTryAgainLater": "請稍後再試。", + "billingCheckoutError": "結帳錯誤", + "billingFailedToGetPortalUrl": "無法獲取門戶網址", + "billingPortalError": "門戶錯誤", + "billingDataUsageInfo": "當連接到雲端時,您將為透過安全隧道傳輸的所有數據收取費用。 這包括您所有站點的進出流量。 當您達到上限時,您的站點將斷開連接,直到您升級計劃或減少使用。使用節點時不收取數據。", + "billingOnlineTimeInfo": "您要根據您的網站連接到雲端的時間長短收取費用。 例如,44,640 分鐘等於一個 24/7 全月運行的網站。 當您達到上限時,您的站點將斷開連接,直到您升級計劃或減少使用。使用節點時不收取費用。", + "billingUsersInfo": "根據您組織中的活躍用戶數量收費。按日計算帳單。", + "billingDomainInfo": "根據組織中活躍域的數量收費。按日計算帳單。", + "billingRemoteExitNodesInfo": "根據您組織中已管理節點的數量收費。按日計算帳單。", + "domainNotFound": "域未找到", + "domainNotFoundDescription": "此資源已禁用,因為該域不再在我們的系統中存在。請為此資源設置一個新域。", + "failed": "失敗", + "createNewOrgDescription": "創建一個新組織", + "organization": "組織", + "port": "埠", + "securityKeyManage": "管理安全金鑰", + "securityKeyDescription": "添加或刪除用於無密碼認證的安全金鑰", + "securityKeyRegister": "註冊新的安全金鑰", + "securityKeyList": "您的安全金鑰", + "securityKeyNone": "尚未註冊安全金鑰", + "securityKeyNameRequired": "名稱為必填項", + "securityKeyRemove": "刪除", + "securityKeyLastUsed": "上次使用:{date}", + "securityKeyNameLabel": "名稱", + "securityKeyRegisterSuccess": "安全金鑰註冊成功", + "securityKeyRegisterError": "註冊安全金鑰失敗", + "securityKeyRemoveSuccess": "安全金鑰刪除成功", + "securityKeyRemoveError": "刪除安全金鑰失敗", + "securityKeyLoadError": "載入安全金鑰失敗", + "securityKeyLogin": "使用安全金鑰繼續", + "securityKeyAuthError": "使用安全金鑰認證失敗", + "securityKeyRecommendation": "考慮在其他設備上註冊另一個安全金鑰,以確保不會被鎖定在您的帳戶之外。", + "registering": "註冊中...", + "securityKeyPrompt": "請使用您的安全金鑰驗證身份。確保您的安全金鑰已連接並準備好。", + "securityKeyBrowserNotSupported": "您的瀏覽器不支持安全金鑰。請使用像 Chrome、Firefox 或 Safari 這樣的現代瀏覽器。", + "securityKeyPermissionDenied": "請允許訪問您的安全金鑰以繼續登錄。", + "securityKeyRemovedTooQuickly": "請保持您的安全金鑰連接,直到登錄過程完成。", + "securityKeyNotSupported": "您的安全金鑰可能不相容。請嘗試不同的安全金鑰。", + "securityKeyUnknownError": "使用安全金鑰時出現問題。請再試一次。", + "twoFactorRequired": "註冊安全金鑰需要兩步驗證。", + "twoFactor": "兩步驗證", + "twoFactorAuthentication": "兩步驗證", + "twoFactorDescription": "這個組織需要雙重身份驗證。", + "enableTwoFactor": "啟用兩步驗證", + "organizationSecurityPolicy": "組織安全政策", + "organizationSecurityPolicyDescription": "此機構擁有安全要求,您必須先滿足才能訪問", + "securityRequirements": "安全要求", + "allRequirementsMet": "已滿足所有要求", + "completeRequirementsToContinue": "完成下面的要求以繼續訪問此組織", + "youCanNowAccessOrganization": "您現在可以訪問此組織", + "reauthenticationRequired": "會話長度", + "reauthenticationDescription": "該機構要求您每 {maxDays} 天登錄一次。", + "reauthenticationDescriptionHours": "該機構要求您每 {maxHours} 小時登錄一次。", + "reauthenticateNow": "再次登錄", + "adminEnabled2FaOnYourAccount": "管理員已為 {email} 啟用兩步驗證。請完成設置以繼續。", + "securityKeyAdd": "添加安全金鑰", + "securityKeyRegisterTitle": "註冊新安全金鑰", + "securityKeyRegisterDescription": "連接您的安全金鑰並輸入名稱以便識別", + "securityKeyTwoFactorRequired": "要求兩步驗證", + "securityKeyTwoFactorDescription": "請輸入你的兩步驗證代碼以註冊安全金鑰", + "securityKeyTwoFactorRemoveDescription": "請輸入你的兩步驗證代碼以移除安全金鑰", + "securityKeyTwoFactorCode": "雙因素代碼", + "securityKeyRemoveTitle": "移除安全金鑰", + "securityKeyRemoveDescription": "輸入您的密碼以移除安全金鑰 \"{name}\"", + "securityKeyNoKeysRegistered": "沒有註冊安全金鑰", + "securityKeyNoKeysDescription": "添加安全金鑰以加強您的帳戶安全", + "createDomainRequired": "必須輸入域", + "createDomainAddDnsRecords": "添加 DNS 記錄", + "createDomainAddDnsRecordsDescription": "將以下 DNS 記錄添加到您的域名提供商以完成設置。", + "createDomainNsRecords": "NS 記錄", + "createDomainRecord": "記錄", + "createDomainType": "類型:", + "createDomainName": "名稱:", + "createDomainValue": "值:", + "createDomainCnameRecords": "CNAME 記錄", + "createDomainARecords": "A記錄", + "createDomainRecordNumber": "記錄 {number}", + "createDomainTxtRecords": "TXT 記錄", + "createDomainSaveTheseRecords": "保存這些記錄", + "createDomainSaveTheseRecordsDescription": "務必保存這些 DNS 記錄,因為您將無法再次查看它們。", + "createDomainDnsPropagation": "DNS 傳播", + "createDomainDnsPropagationDescription": "DNS 更改可能需要一些時間才能在網路上傳播。這可能需要從幾分鐘到 48 小時,具體取決於您的 DNS 提供商和 TTL 設置。", + "resourcePortRequired": "非 HTTP 資源必須輸入埠號", + "resourcePortNotAllowed": "HTTP 資源不應設置埠號", + "billingPricingCalculatorLink": "價格計算機", + "signUpTerms": { + "IAgreeToThe": "我同意", + "termsOfService": "服務條款", + "and": "和", + "privacyPolicy": "隱私政策" + }, + "siteRequired": "需要站點。", + "olmTunnel": "Olm 隧道", + "olmTunnelDescription": "使用 Olm 進行用戶端連接", + "errorCreatingClient": "創建用戶端出錯", + "clientDefaultsNotFound": "未找到用戶端預設值", + "createClient": "創建用戶端", + "createClientDescription": "創建一個新用戶端來連接您的站點", + "seeAllClients": "查看所有用戶端", + "clientInformation": "用戶端資訊", + "clientNamePlaceholder": "用戶端名稱", + "address": "地址", + "subnetPlaceholder": "子網", + "addressDescription": "此用戶端將用於連接的地址", + "selectSites": "選擇站點", + "sitesDescription": "用戶端將與所選站點進行連接", + "clientInstallOlm": "安裝 Olm", + "clientInstallOlmDescription": "在您的系統上運行 Olm", + "clientOlmCredentials": "Olm 憑據", + "clientOlmCredentialsDescription": "這是 Olm 伺服器的身份驗證方式", + "olmEndpoint": "Olm 端點", + "olmId": "Olm ID", + "olmSecretKey": "Olm 私鑰", + "clientCredentialsSave": "保存您的憑據", + "clientCredentialsSaveDescription": "該資訊僅會顯示一次,請確保將其複製到安全位置。", + "generalSettingsDescription": "配置此用戶端的常規設置", + "clientUpdated": "用戶端已更新", + "clientUpdatedDescription": "用戶端已更新。", + "clientUpdateFailed": "更新用戶端失敗", + "clientUpdateError": "更新用戶端時出錯。", + "sitesFetchFailed": "獲取站點失敗", + "sitesFetchError": "獲取站點時出錯。", + "olmErrorFetchReleases": "獲取 Olm 發布版本時出錯。", + "olmErrorFetchLatest": "獲取最新 Olm 發布版本時出錯。", + "remoteSubnets": "遠程子網", + "enterCidrRange": "輸入 CIDR 範圍", + "remoteSubnetsDescription": "添加可以通過用戶端遠端存取該站點的 CIDR 範圍。使用類似 10.0.0.0/24 的格式。這僅適用於 VPN 用戶端連接。", + "resourceEnableProxy": "啟用公共代理", + "resourceEnableProxyDescription": "啟用到此資源的公共代理。這允許外部網路通過開放埠訪問資源。需要 Traefik 配置。", + "externalProxyEnabled": "外部代理已啟用", + "addNewTarget": "添加新目標", + "targetsList": "目標列表", + "advancedMode": "高級模式", + "targetErrorDuplicateTargetFound": "找到重複的目標", + "healthCheckHealthy": "正常", + "healthCheckUnhealthy": "不正常", + "healthCheckUnknown": "未知", + "healthCheck": "健康檢查", + "configureHealthCheck": "配置健康檢查", + "configureHealthCheckDescription": "為 {target} 設置健康監控", + "enableHealthChecks": "啟用健康檢查", + "enableHealthChecksDescription": "監視此目標的健康狀況。如果需要,您可以監視一個不同的終點。", + "healthScheme": "方法", + "healthSelectScheme": "選擇方法", + "healthCheckPath": "路徑", + "healthHostname": "IP / 主機", + "healthPort": "埠", + "healthCheckPathDescription": "用於檢查健康狀態的路徑。", + "healthyIntervalSeconds": "正常間隔", + "unhealthyIntervalSeconds": "不正常間隔", + "IntervalSeconds": "正常間隔", + "timeoutSeconds": "超時", + "timeIsInSeconds": "時間以秒為單位", + "retryAttempts": "重試次數", + "expectedResponseCodes": "期望響應代碼", + "expectedResponseCodesDescription": "HTTP 狀態碼表示健康狀態。如留空,200-300 被視為健康。", + "customHeaders": "自訂 Headers", + "customHeadersDescription": "Header 斷行分隔:Header 名稱:值", + "headersValidationError": "Header 必須是格式:Header 名稱:值。", + "saveHealthCheck": "保存健康檢查", + "healthCheckSaved": "健康檢查已保存", + "healthCheckSavedDescription": "健康檢查配置已成功保存。", + "healthCheckError": "健康檢查錯誤", + "healthCheckErrorDescription": "保存健康檢查配置時出錯", + "healthCheckPathRequired": "健康檢查路徑為必填項", + "healthCheckMethodRequired": "HTTP 方法為必填項", + "healthCheckIntervalMin": "檢查間隔必須至少為 5 秒", + "healthCheckTimeoutMin": "超時必須至少為 1 秒", + "healthCheckRetryMin": "重試次數必須至少為 1 次", + "httpMethod": "HTTP 方法", + "selectHttpMethod": "選擇 HTTP 方法", + "domainPickerSubdomainLabel": "子域名", + "domainPickerBaseDomainLabel": "根域名", + "domainPickerSearchDomains": "搜索域名...", + "domainPickerNoDomainsFound": "未找到域名", + "domainPickerLoadingDomains": "載入域名...", + "domainPickerSelectBaseDomain": "選擇根域名...", + "domainPickerNotAvailableForCname": "不適用於 CNAME 域", + "domainPickerEnterSubdomainOrLeaveBlank": "輸入子域名或留空以使用根域名。", + "domainPickerEnterSubdomainToSearch": "輸入一個子域名以搜索並從可用免費域名中選擇。", + "domainPickerFreeDomains": "免費域名", + "domainPickerSearchForAvailableDomains": "搜索可用域名", + "domainPickerNotWorkSelfHosted": "注意:自託管實例當前不提供免費的域名。", + "resourceDomain": "域名", + "resourceEditDomain": "編輯域名", + "siteName": "站點名稱", + "proxyPort": "埠", + "resourcesTableProxyResources": "代理資源", + "resourcesTableClientResources": "用戶端資源", + "resourcesTableNoProxyResourcesFound": "未找到代理資源。", + "resourcesTableNoInternalResourcesFound": "未找到內部資源。", + "resourcesTableDestination": "目標", + "resourcesTableTheseResourcesForUseWith": "這些資源供...使用", + "resourcesTableClients": "用戶端", + "resourcesTableAndOnlyAccessibleInternally": "且僅在與用戶端連接時可內部訪問。", + "editInternalResourceDialogEditClientResource": "編輯用戶端資源", + "editInternalResourceDialogUpdateResourceProperties": "更新 {resourceName} 的資源屬性和目標配置。", + "editInternalResourceDialogResourceProperties": "資源屬性", + "editInternalResourceDialogName": "名稱", + "editInternalResourceDialogProtocol": "協議", + "editInternalResourceDialogSitePort": "站點埠", + "editInternalResourceDialogTargetConfiguration": "目標配置", + "editInternalResourceDialogCancel": "取消", + "editInternalResourceDialogSaveResource": "保存資源", + "editInternalResourceDialogSuccess": "成功", + "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "內部資源更新成功", + "editInternalResourceDialogError": "錯誤", + "editInternalResourceDialogFailedToUpdateInternalResource": "更新內部資源失敗", + "editInternalResourceDialogNameRequired": "名稱為必填項", + "editInternalResourceDialogNameMaxLength": "名稱長度必須小於 255 個字元", + "editInternalResourceDialogProxyPortMin": "代理埠必須至少為 1", + "editInternalResourceDialogProxyPortMax": "代理埠必須小於 65536", + "editInternalResourceDialogInvalidIPAddressFormat": "無效的 IP 位址格式", + "editInternalResourceDialogDestinationPortMin": "目標埠必須至少為 1", + "editInternalResourceDialogDestinationPortMax": "目標埠必須小於 65536", + "createInternalResourceDialogNoSitesAvailable": "暫無可用站點", + "createInternalResourceDialogNoSitesAvailableDescription": "您需要至少配置一個子網的 Newt 站點來創建內部資源。", + "createInternalResourceDialogClose": "關閉", + "createInternalResourceDialogCreateClientResource": "創建用戶端資源", + "createInternalResourceDialogCreateClientResourceDescription": "創建一個新資源,該資源將可供連接到所選站點的用戶端訪問。", + "createInternalResourceDialogResourceProperties": "資源屬性", + "createInternalResourceDialogName": "名稱", + "createInternalResourceDialogSite": "站點", + "createInternalResourceDialogSelectSite": "選擇站點...", + "createInternalResourceDialogSearchSites": "搜索站點...", + "createInternalResourceDialogNoSitesFound": "未找到站點。", + "createInternalResourceDialogProtocol": "協議", + "createInternalResourceDialogTcp": "TCP", + "createInternalResourceDialogUdp": "UDP", + "createInternalResourceDialogSitePort": "站點埠", + "createInternalResourceDialogSitePortDescription": "使用此埠在連接到用戶端時訪問站點上的資源。", + "createInternalResourceDialogTargetConfiguration": "目標配置", + "createInternalResourceDialogDestinationIPDescription": "站點網路上資源的 IP 或主機名地址。", + "createInternalResourceDialogDestinationPortDescription": "資源在目標 IP 上可訪問的埠。", + "createInternalResourceDialogCancel": "取消", + "createInternalResourceDialogCreateResource": "創建資源", + "createInternalResourceDialogSuccess": "成功", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "內部資源創建成功", + "createInternalResourceDialogError": "錯誤", + "createInternalResourceDialogFailedToCreateInternalResource": "創建內部資源失敗", + "createInternalResourceDialogNameRequired": "名稱為必填項", + "createInternalResourceDialogNameMaxLength": "名稱長度必須小於 255 個字元", + "createInternalResourceDialogPleaseSelectSite": "請選擇一個站點", + "createInternalResourceDialogProxyPortMin": "代理埠必須至少為 1", + "createInternalResourceDialogProxyPortMax": "代理埠必須小於 65536", + "createInternalResourceDialogInvalidIPAddressFormat": "無效的 IP 位址格式", + "createInternalResourceDialogDestinationPortMin": "目標埠必須至少為 1", + "createInternalResourceDialogDestinationPortMax": "目標埠必須小於 65536", + "siteConfiguration": "配置", + "siteAcceptClientConnections": "接受用戶端連接", + "siteAcceptClientConnectionsDescription": "允許其他設備透過此 Newt 實例使用用戶端作為閘道器連接。", + "siteAddress": "站點地址", + "siteAddressDescription": "指定主機的 IP 位址以供用戶端連接。這是 Pangolin 網路中站點的內部地址,供用戶端訪問。必須在 Org 子網內。", + "autoLoginExternalIdp": "自動使用外部 IDP 登錄", + "autoLoginExternalIdpDescription": "立即將用戶重定向到外部 IDP 進行身份驗證。", + "selectIdp": "選擇 IDP", + "selectIdpPlaceholder": "選擇一個 IDP...", + "selectIdpRequired": "在啟用自動登錄時,請選擇一個 IDP。", + "autoLoginTitle": "重定向中", + "autoLoginDescription": "正在將您重定向到外部身份提供商進行身份驗證。", + "autoLoginProcessing": "準備身份驗證...", + "autoLoginRedirecting": "重定向到登錄...", + "autoLoginError": "自動登錄錯誤", + "autoLoginErrorNoRedirectUrl": "未從身份提供商收到重定向 URL。", + "autoLoginErrorGeneratingUrl": "生成身份驗證 URL 失敗。", + "remoteExitNodeManageRemoteExitNodes": "遠程節點", + "remoteExitNodeDescription": "自我主機一個或多個遠程節點來擴展您的網路連接並減少對雲的依賴性", + "remoteExitNodes": "節點", + "searchRemoteExitNodes": "搜索節點...", + "remoteExitNodeAdd": "添加節點", + "remoteExitNodeErrorDelete": "刪除節點時出錯", + "remoteExitNodeQuestionRemove": "您確定要從組織中刪除該節點嗎?", + "remoteExitNodeMessageRemove": "一旦刪除,該節點將不再能夠訪問。", + "remoteExitNodeConfirmDelete": "確認刪除節點", + "remoteExitNodeDelete": "刪除節點", + "sidebarRemoteExitNodes": "遠程節點", + "remoteExitNodeCreate": { + "title": "創建節點", + "description": "創建一個新節點來擴展您的網路連接", + "viewAllButton": "查看所有節點", + "strategy": { + "title": "創建策略", + "description": "選擇此選項以手動配置您的節點或生成新憑據。", + "adopt": { + "title": "採納節點", + "description": "如果您已經擁有該節點的憑據,請選擇此項。" + }, + "generate": { + "title": "生成金鑰", + "description": "如果您想為節點生成新金鑰,請選擇此選項" + } + }, + "adopt": { + "title": "採納現有節點", + "description": "輸入您想要採用的現有節點的憑據", + "nodeIdLabel": "節點 ID", + "nodeIdDescription": "您想要採用的現有節點的 ID", + "secretLabel": "金鑰", + "secretDescription": "現有節點的秘密金鑰", + "submitButton": "採用節點" + }, + "generate": { + "title": "生成的憑據", + "description": "使用這些生成的憑據來配置您的節點", + "nodeIdTitle": "節點 ID", + "secretTitle": "金鑰", + "saveCredentialsTitle": "將憑據添加到配置中", + "saveCredentialsDescription": "將這些憑據添加到您的自託管 Pangolin 節點設定檔中以完成連接。", + "submitButton": "創建節點" + }, + "validation": { + "adoptRequired": "在通過現有節點時需要節點ID和金鑰" + }, + "errors": { + "loadDefaultsFailed": "無法載入預設值", + "defaultsNotLoaded": "預設值未載入", + "createFailed": "創建節點失敗" + }, + "success": { + "created": "節點創建成功" + } + }, + "remoteExitNodeSelection": "節點選擇", + "remoteExitNodeSelectionDescription": "為此本地站點選擇要路由流量的節點", + "remoteExitNodeRequired": "必須為本地站點選擇節點", + "noRemoteExitNodesAvailable": "無可用節點", + "noRemoteExitNodesAvailableDescription": "此組織沒有可用的節點。首先創建一個節點來使用本地站點。", + "exitNode": "出口節點", + "country": "國家", + "rulesMatchCountry": "當前基於源 IP", + "managedSelfHosted": { + "title": "託管自託管", + "description": "更可靠、維護成本更低的自架 Pangolin 伺服器,並附帶額外的附加功能", + "introTitle": "託管式自架 Pangolin", + "introDescription": "這是一種部署選擇,為那些希望簡潔和額外可靠的人設計,同時仍然保持他們的數據的私密性和自我託管性。", + "introDetail": "通過此選項,您仍然運行您自己的 Pangolin 節點 — — 您的隧道、SSL 終止,並且流量在您的伺服器上保持所有狀態。 不同之處在於,管理和監測是通過我們的雲層儀錶板進行的,該儀錶板開啟了一些好處:", + "benefitSimplerOperations": { + "title": "簡單的操作", + "description": "無需運行您自己的郵件伺服器或設置複雜的警報。您將從方框中獲得健康檢查和下限提醒。" + }, + "benefitAutomaticUpdates": { + "title": "自動更新", + "description": "雲儀錶板快速演化,所以您可以獲得新的功能和錯誤修復,而不必每次手動拉取新的容器。" + }, + "benefitLessMaintenance": { + "title": "減少維護時間", + "description": "沒有要管理的資料庫遷移、備份或額外的基礎設施。我們在雲端處理這個問題。" + }, + "benefitCloudFailover": { + "title": "雲端故障轉移", + "description": "如果您的節點發生故障,您的隧道可以暫時故障轉移到我們的雲端存取點,直到您將節點恢復線上狀態。" + }, + "benefitHighAvailability": { + "title": "高可用率(PoPs)", + "description": "您還可以將多個節點添加到您的帳戶中以獲取冗餘和更好的性能。" + }, + "benefitFutureEnhancements": { + "title": "將來的改進", + "description": "我們正在計劃添加更多的分析、警報和管理工具,使你的部署更加有力。" + }, + "docsAlert": { + "text": "在我們中更多地了解管理下的自託管選項", + "documentation": "文件" + }, + "convertButton": "將此節點轉換為管理自託管的" + }, + "internationaldomaindetected": "檢測到國際域", + "willbestoredas": "儲存為:", + "roleMappingDescription": "確定當用戶啟用自動配送時如何分配他們的角色。", + "selectRole": "選擇角色", + "roleMappingExpression": "表達式", + "selectRolePlaceholder": "選擇角色", + "selectRoleDescription": "選擇一個角色,從此身份提供商分配給所有用戶", + "roleMappingExpressionDescription": "輸入一個 JMESPath 表達式來從 ID 令牌提取角色資訊", + "idpTenantIdRequired": "租戶 ID 是必需的", + "invalidValue": "無效的值", + "idpTypeLabel": "身份提供者類型", + "roleMappingExpressionPlaceholder": "例如: contains(group, 'admin' &'Admin' || 'Member'", + "idpGoogleConfiguration": "Google 配置", + "idpGoogleConfigurationDescription": "配置您的 Google OAuth2 憑據", + "idpGoogleClientIdDescription": "您的 Google OAuth2 用戶端 ID", + "idpGoogleClientSecretDescription": "您的 Google OAuth2 用戶端金鑰", + "idpAzureConfiguration": "Azure Entra ID 配置", + "idpAzureConfigurationDescription": "配置您的 Azure Entra ID OAuth2 憑據", + "idpTenantId": "租戶 ID", + "idpTenantIdPlaceholder": "您的租戶 ID", + "idpAzureTenantIdDescription": "您的 Azure 租戶ID (在 Azure Active Directory 概覽中發現)", + "idpAzureClientIdDescription": "您的 Azure 應用程式註冊用戶端 ID", + "idpAzureClientSecretDescription": "您的 Azure 應用程式註冊用戶端金鑰", + "idpGoogleTitle": "Google", + "idpGoogleAlt": "Google", + "idpAzureTitle": "Azure Entra ID", + "idpAzureAlt": "Azure", + "idpGoogleConfigurationTitle": "Google 配置", + "idpAzureConfigurationTitle": "Azure Entra ID 配置", + "idpTenantIdLabel": "租戶 ID", + "idpAzureClientIdDescription2": "您的 Azure 應用程式註冊用戶端 ID", + "idpAzureClientSecretDescription2": "您的 Azure 應用程式註冊用戶端金鑰", + "idpGoogleDescription": "Google OAuth2/OIDC 提供商", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", + "subnet": "子網", + "subnetDescription": "此組織網路配置的子網。", + "authPage": "認證頁面", + "authPageDescription": "配置您的組織認證頁面", + "authPageDomain": "認證頁面域", + "noDomainSet": "沒有域設置", + "changeDomain": "更改域", + "selectDomain": "選擇域", + "restartCertificate": "重新啟動證書", + "editAuthPageDomain": "編輯認證頁面域", + "setAuthPageDomain": "設置認證頁面域", + "failedToFetchCertificate": "獲取證書失敗", + "failedToRestartCertificate": "重新啟動證書失敗", + "addDomainToEnableCustomAuthPages": "為您的組織添加域名以啟用自訂認證頁面", + "selectDomainForOrgAuthPage": "選擇組織認證頁面的域", + "domainPickerProvidedDomain": "提供的域", + "domainPickerFreeProvidedDomain": "免費提供的域", + "domainPickerVerified": "已驗證", + "domainPickerUnverified": "未驗證", + "domainPickerInvalidSubdomainStructure": "此子域包含無效的字元或結構。當您保存時,它將被自動清除。", + "domainPickerError": "錯誤", + "domainPickerErrorLoadDomains": "載入組織域名失敗", + "domainPickerErrorCheckAvailability": "檢查域可用性失敗", + "domainPickerInvalidSubdomain": "無效的子域", + "domainPickerInvalidSubdomainRemoved": "輸入 \"{sub}\" 已被移除,因為其無效。", + "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" 無法為 {domain} 變為有效。", + "domainPickerSubdomainSanitized": "子域已淨化", + "domainPickerSubdomainCorrected": "\"{sub}\" 已被更正為 \"{sanitized}\"", + "orgAuthSignInTitle": "登錄到您的組織", + "orgAuthChooseIdpDescription": "選擇您的身份提供商以繼續", + "orgAuthNoIdpConfigured": "此機構沒有配置任何身份提供者。您可以使用您的 Pangolin 身份登錄。", + "orgAuthSignInWithPangolin": "使用 Pangolin 登錄", + "subscriptionRequiredToUse": "需要訂閱才能使用此功能。", + "idpDisabled": "身份提供者已禁用。", + "orgAuthPageDisabled": "組織認證頁面已禁用。", + "domainRestartedDescription": "域驗證重新啟動成功", + "resourceAddEntrypointsEditFile": "編輯文件:config/traefik/traefik_config.yml", + "resourceExposePortsEditFile": "編輯文件:docker-compose.yml", + "emailVerificationRequired": "需要電子郵件驗證。 請通過 {dashboardUrl}/auth/login 再次登錄以完成此步驟。 然後,回到這裡。", + "twoFactorSetupRequired": "需要設置雙因素身份驗證。 請通過 {dashboardUrl}/auth/login 再次登錄以完成此步驟。 然後,回到這裡。", + "additionalSecurityRequired": "需要額外的安全", + "organizationRequiresAdditionalSteps": "這個組織需要額外的安全步驟才能訪問資源。", + "completeTheseSteps": "完成這些步驟", + "enableTwoFactorAuthentication": "啟用兩步驗證", + "completeSecuritySteps": "完成安全步驟", + "securitySettings": "安全設定", + "securitySettingsDescription": "配置您組織的安全策略", + "requireTwoFactorForAllUsers": "所有用戶需要兩步驗證", + "requireTwoFactorDescription": "如果啟用,此組織的所有內部用戶必須啟用雙重身份驗證才能訪問組織。", + "requireTwoFactorDisabledDescription": "此功能需要有效的許可證(企業)或活動訂閱(SaS)", + "requireTwoFactorCannotEnableDescription": "您必須為您的帳戶啟用雙重身份驗證才能對所有用戶", + "maxSessionLength": "最大會話長度", + "maxSessionLengthDescription": "設置用戶會話的最長時間。此後用戶需要重新驗證。", + "maxSessionLengthDisabledDescription": "此功能需要有效的許可證(企業)或活動訂閱(SaS)", + "selectSessionLength": "選擇會話長度", + "unenforced": "未執行", + "1Hour": "1 小時", + "3Hours": "3 小時", + "6Hours": "6 小時", + "12Hours": "12 小時", + "1DaySession": "1天", + "3Days": "3 天", + "7Days": "7 天", + "14Days": "14 天", + "30DaysSession": "30 天", + "90DaysSession": "90 天", + "180DaysSession": "180天", + "passwordExpiryDays": "密碼過期", + "editPasswordExpiryDescription": "設置用戶需要更改密碼之前的天數。", + "selectPasswordExpiry": "選擇密碼過期", + "30Days": "30 天", + "1Day": "1天", + "60Days": "60天", + "90Days": "90 天", + "180Days": "180天", + "1Year": "1 年", + "subscriptionBadge": "需要訂閱", + "securityPolicyChangeWarning": "安全政策更改警告", + "securityPolicyChangeDescription": "您即將更改安全政策設置。保存後,您可能需要重新認證以遵守這些政策更新。 所有不符合要求的用戶也需要重新認證。", + "securityPolicyChangeConfirmMessage": "我確認", + "securityPolicyChangeWarningText": "這將影響組織中的所有用戶", + "authPageErrorUpdateMessage": "更新身份驗證頁面設置時出錯", + "authPageErrorUpdate": "無法更新認證頁面", + "authPageUpdated": "身份驗證頁面更新成功", + "healthCheckNotAvailable": "本地的", + "rewritePath": "重寫路徑", + "rewritePathDescription": "在轉發到目標之前,可以選擇重寫路徑。", + "continueToApplication": "繼續應用", + "checkingInvite": "正在檢查邀請", + "setResourceHeaderAuth": "設置 ResourceHeaderAuth", + "resourceHeaderAuthRemove": "移除 Header 身份驗證", + "resourceHeaderAuthRemoveDescription": "已成功刪除 Header 身份驗證。", + "resourceErrorHeaderAuthRemove": "刪除 Header 身份驗證失敗", + "resourceErrorHeaderAuthRemoveDescription": "無法刪除資源的 Header 身份驗證。", + "resourceHeaderAuthProtectionEnabled": "Header 認證已啟用", + "resourceHeaderAuthProtectionDisabled": "Header 身份驗證已禁用", + "headerAuthRemove": "刪除 Header 認證", + "headerAuthAdd": "添加頁首認證", + "resourceErrorHeaderAuthSetup": "設置頁首認證失敗", + "resourceErrorHeaderAuthSetupDescription": "無法設置資源的 Header 身份驗證。", + "resourceHeaderAuthSetup": "Header 認證設置成功", + "resourceHeaderAuthSetupDescription": "Header 認證已成功設置。", + "resourceHeaderAuthSetupTitle": "設置 Header 身份驗證", + "resourceHeaderAuthSetupTitleDescription": "使用 HTTP 頭身份驗證來設置基本身份驗證資訊(使用者名稱和密碼)。使用 https://username:password@resource.example.com 訪問它", + "resourceHeaderAuthSubmit": "設置 Header 身份驗證", + "actionSetResourceHeaderAuth": "設置 Header 身份驗證", + "enterpriseEdition": "企業版", + "unlicensed": "未授權", + "beta": "測試版", + "manageClients": "管理用戶端", + "manageClientsDescription": "用戶端是可以連接到您的站點的設備", + "licenseTableValidUntil": "有效期至", + "saasLicenseKeysSettingsTitle": "企業許可證", + "saasLicenseKeysSettingsDescription": "為自我託管的 Pangolin 實例生成和管理企業許可證金鑰", + "sidebarEnterpriseLicenses": "許可協議", + "generateLicenseKey": "生成許可證金鑰", + "generateLicenseKeyForm": { + "validation": { + "emailRequired": "請輸入一個有效的電子郵件地址", + "useCaseTypeRequired": "請選擇一個使用的案例類型", + "firstNameRequired": "必填名", + "lastNameRequired": "姓氏是必填項", + "primaryUseRequired": "請描述您的主要使用", + "jobTitleRequiredBusiness": "企業使用必須有職位頭銜。", + "industryRequiredBusiness": "商業使用需要工業", + "stateProvinceRegionRequired": "州/省/地區是必填項", + "postalZipCodeRequired": "郵政編碼是必需的", + "companyNameRequiredBusiness": "企業使用需要公司名稱", + "countryOfResidenceRequiredBusiness": "商業使用必須是居住國", + "countryRequiredPersonal": "國家需要個人使用", + "agreeToTermsRequired": "您必須同意條款", + "complianceConfirmationRequired": "您必須確認遵守 Fossorial Commercial License" + }, + "useCaseOptions": { + "personal": { + "title": "個人使用", + "description": "個人非商業用途,如學習、個人項目或實驗。" + }, + "business": { + "title": "商業使用", + "description": "供組織、公司或商業或創收活動使用。" + } + }, + "steps": { + "emailLicenseType": { + "title": "電子郵件和許可證類型", + "description": "輸入您的電子郵件並選擇您的許可證類型" + }, + "personalInformation": { + "title": "個人資訊", + "description": "告訴我們自己的資訊" + }, + "contactInformation": { + "title": "聯繫資訊", + "description": "您的聯繫資訊" + }, + "termsGenerate": { + "title": "條款並生成", + "description": "審閱並接受條款生成您的許可證" + } + }, + "alerts": { + "commercialUseDisclosure": { + "title": "使用情況披露", + "description": "選擇能準確反映您預定用途的許可等級。 個人許可證允許對個人、非商業性或小型商業活動免費使用軟體,年收入毛額不到 100,000 美元。 超出這些限度的任何用途,包括在企業、組織內的用途。 或其他創收環境——需要有效的企業許可證和支付適用的許可證費用。 所有用戶,不論是個人還是企業,都必須遵守寄養商業許可證條款。" + }, + "trialPeriodInformation": { + "title": "試用期資訊", + "description": "此許可證金鑰使企業特性能夠持續 7 天的評價。 在評估期過後繼續訪問付費功能需要在有效的個人或企業許可證下啟用。對於企業許可證,請聯絡 Sales@pangolin.net。" + } + }, + "form": { + "useCaseQuestion": "您是否正在使用 Pangolin 進行個人或商業使用?", + "firstName": "名字", + "lastName": "名字", + "jobTitle": "工作頭銜:", + "primaryUseQuestion": "您主要計劃使用 Pangolin 嗎?", + "industryQuestion": "您的行業是什麼?", + "prospectiveUsersQuestion": "您期望有多少預期用戶?", + "prospectiveSitesQuestion": "您期望有多少站點(隧道)?", + "companyName": "公司名稱", + "countryOfResidence": "居住國", + "stateProvinceRegion": "州/省/地區", + "postalZipCode": "郵政編碼", + "companyWebsite": "公司網站", + "companyPhoneNumber": "公司電話號碼", + "country": "國家", + "phoneNumberOptional": "電話號碼 (可選)", + "complianceConfirmation": "我確認我提供的資料是準確的,我遵守了寄養商業許可證。 報告不準確的資訊或錯誤的產品使用是違反許可證的行為,可能導致您的金鑰被撤銷。" + }, + "buttons": { + "close": "關閉", + "previous": "上一個", + "next": "下一個", + "generateLicenseKey": "生成許可證金鑰" + }, + "toasts": { + "success": { + "title": "許可證金鑰生成成功", + "description": "您的許可證金鑰已經生成並準備使用。" + }, + "error": { + "title": "生成許可證金鑰失敗", + "description": "生成許可證金鑰時出錯。" + } + } + }, + "priority": "優先權", + "priorityDescription": "先評估更高優先度線路。優先度 = 100 意味著自動排序(系統決定). 使用另一個數字強制執行手動優先度。", + "instanceName": "實例名稱", + "pathMatchModalTitle": "配置路徑匹配", + "pathMatchModalDescription": "根據傳入請求的路徑設置匹配方式。", + "pathMatchType": "匹配類型", + "pathMatchPrefix": "前綴", + "pathMatchExact": "精準的", + "pathMatchRegex": "正則表達式", + "pathMatchValue": "路徑值", + "clear": "清空", + "saveChanges": "保存更改", + "pathMatchRegexPlaceholder": "^/api/.*", + "pathMatchDefaultPlaceholder": "/路徑", + "pathMatchPrefixHelp": "範例: /api 匹配/api, /api/users 等。", + "pathMatchExactHelp": "範例:/api 匹配僅限/api", + "pathMatchRegexHelp": "例如:^/api/.* 匹配/api/why", + "pathRewriteModalTitle": "配置路徑重寫", + "pathRewriteModalDescription": "在轉發到目標之前變換匹配的路徑。", + "pathRewriteType": "重寫類型", + "pathRewritePrefixOption": "前綴 - 替換前綴", + "pathRewriteExactOption": "精確-替換整個路徑", + "pathRewriteRegexOption": "正則表達式 - 替換模式", + "pathRewriteStripPrefixOption": "刪除前綴 - 刪除前綴", + "pathRewriteValue": "重寫值", + "pathRewriteRegexPlaceholder": "/new/$1", + "pathRewriteDefaultPlaceholder": "/new-path", + "pathRewritePrefixHelp": "用此值替換匹配的前綴", + "pathRewriteExactHelp": "當路徑匹配時用此值替換整個路徑", + "pathRewriteRegexHelp": "使用抓取組,如$1,$2來替換", + "pathRewriteStripPrefixHelp": "留空以脫離前綴或提供新的前綴", + "pathRewritePrefix": "前綴", + "pathRewriteExact": "精準的", + "pathRewriteRegex": "正則表達式", + "pathRewriteStrip": "帶狀圖", + "pathRewriteStripLabel": "條形圖", + "sidebarEnableEnterpriseLicense": "啟用企業許可證", + "cannotbeUndone": "無法撤消。", + "toConfirm": "確認", + "deleteClientQuestion": "您確定要從站點和組織中刪除客戶嗎?", + "clientMessageRemove": "一旦刪除,用戶端將無法連接到站點。", + "sidebarLogs": "日誌", + "request": "請求", + "logs": "日誌", + "logsSettingsDescription": "監視從此 orginization 中收集的日誌", + "searchLogs": "搜索日誌...", + "action": "行動", + "actor": "執行者", + "timestamp": "時間戳", + "accessLogs": "訪問日誌", + "exportCsv": "導出 CSV", + "actorId": "執行者 ID", + "allowedByRule": "根據規則允許", + "allowedNoAuth": "無認證", + "validAccessToken": "有效訪問令牌", + "validHeaderAuth": "有效的 Header 身份驗證", + "validPincode": "有效的 Pincode", + "validPassword": "有效密碼", + "validEmail": "有效的 email", + "validSSO": "有效的 SSO", + "resourceBlocked": "資源被阻止", + "droppedByRule": "被規則刪除", + "noSessions": "無會話", + "temporaryRequestToken": "臨時請求令牌", + "noMoreAuthMethods": "無有效授權", + "ip": "IP", + "reason": "原因", + "requestLogs": "請求日誌", + "host": "主機", + "location": "地點", + "actionLogs": "操作日誌", + "sidebarLogsRequest": "請求日誌", + "sidebarLogsAccess": "訪問日誌", + "sidebarLogsAction": "操作日誌", + "logRetention": "日誌保留", + "logRetentionDescription": "管理不同類型的日誌為這個機構保留多長時間或禁用這些日誌", + "requestLogsDescription": "查看此機構資源的詳細請求日誌", + "logRetentionRequestLabel": "請求日誌保留", + "logRetentionRequestDescription": "保留請求日誌的時間", + "logRetentionAccessLabel": "訪問日誌保留", + "logRetentionAccessDescription": "保留訪問日誌的時間", + "logRetentionActionLabel": "動作日誌保留", + "logRetentionActionDescription": "保留操作日誌的時間", + "logRetentionDisabled": "已禁用", + "logRetention3Days": "3 天", + "logRetention7Days": "7 天", + "logRetention14Days": "14 天", + "logRetention30Days": "30 天", + "logRetention90Days": "90 天", + "logRetentionForever": "永遠的", + "actionLogsDescription": "查看此機構執行的操作歷史", + "accessLogsDescription": "查看此機構資源的訪問認證請求", + "licenseRequiredToUse": "需要企業許可證才能使用此功能。", + "certResolver": "證書解決器", + "certResolverDescription": "選擇用於此資源的證書解析器。", + "selectCertResolver": "選擇證書解析", + "enterCustomResolver": "輸入自訂解析器", + "preferWildcardCert": "喜歡通配符證書", + "unverified": "未驗證", + "domainSetting": "域設置", + "domainSettingDescription": "配置您的域的設置", + "preferWildcardCertDescription": "嘗試生成通配符證書(需要正確配置的證書解析器)。", + "recordName": "記錄名稱", + "auto": "自動操作", + "TTL": "TTL", + "howToAddRecords": "如何添加記錄", + "dnsRecord": "DNS 記錄", + "required": "必填", + "domainSettingsUpdated": "域設置更新成功", + "orgOrDomainIdMissing": "缺少機構或域 ID", + "loadingDNSRecords": "正在載入 DNS 記錄...", + "olmUpdateAvailableInfo": "有最新版本的 Olm 可用。請更新到最新版本以獲取最佳體驗。", + "client": "用戶端:", + "proxyProtocol": "代理協議設置", + "proxyProtocolDescription": "配置代理協議以保留 TCP/UDP 服務的用戶端 IP 位址。", + "enableProxyProtocol": "啟用代理協議", + "proxyProtocolInfo": "為 TCP/UDP 後端保留用戶端 IP 位址", + "proxyProtocolVersion": "代理協議版本", + "version1": " 版本 1 (推薦)", + "version2": "版本 2", + "versionDescription": "版本 1 是基於文本和廣泛支持的版本。版本 2 是二進制和更有效率但不那麼相容。", + "warning": "警告", + "proxyProtocolWarning": "您的後端應用程式必須配置為接受代理協議連接。如果您的後端不支持代理協議,啟用這將會中斷所有連接。 請務必從 Traefik 配置您的後端到信任代理協議標題。", + "restarting": "正在重啟...", + "manual": "手動模式", + "messageSupport": "消息支持", + "supportNotAvailableTitle": "支持不可用", + "supportNotAvailableDescription": "支持現在不可用。您可以發送電子郵件到 support@pangolin.net。", + "supportRequestSentTitle": "支持請求已發送", + "supportRequestSentDescription": "您的消息已成功發送。", + "supportRequestFailedTitle": "發送請求失敗", + "supportRequestFailedDescription": "發送您的支持請求時出錯。", + "supportSubjectRequired": "主題是必填項", + "supportSubjectMaxLength": "主題必須是 255 個或更少的字元", + "supportMessageRequired": "消息是必填項", + "supportReplyTo": "回復給", + "supportSubject": "議題", + "supportSubjectPlaceholder": "輸入主題", + "supportMessage": "留言", + "supportMessagePlaceholder": "輸入您的消息", + "supportSending": "正在發送...", + "supportSend": "發送", + "supportMessageSent": "消息已發送!", + "supportWillContact": "我們很快就會聯繫起來!", + "selectLogRetention": "選擇保留日誌", + "showColumns": "顯示列", + "hideColumns": "隱藏列", + "columnVisibility": "列可見性", + "toggleColumn": "切換 {columnName} 列", + "allColumns": "全部列", + "defaultColumns": "默認列", + "customizeView": "自訂視圖", + "viewOptions": "查看選項", + "selectAll": "選擇所有", + "selectNone": "沒有選擇", + "selectedResources": "選定的資源", + "enableSelected": "啟用選中的", + "disableSelected": "禁用選中的", + "checkSelectedStatus": "檢查選中的狀態" +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index faada1f7..473199f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "eslint-config-next": "16.0.3", "express": "5.1.0", "express-rate-limit": "8.2.1", - "glob": "11.0.3", + "glob": "11.1.0", "helmet": "8.1.0", "http-errors": "2.0.0", "i": "^0.3.7", @@ -75,22 +75,22 @@ "lucide-react": "^0.552.0", "maxmind": "5.0.1", "moment": "2.30.1", - "next": "15.5.6", + "next": "15.5.7", "next-intl": "^4.4.0", "next-themes": "0.4.6", "nextjs-toploader": "^3.9.17", "node-cache": "5.1.2", "node-fetch": "3.3.2", "nodemailer": "7.0.10", - "npm": "^11.6.2", + "npm": "^11.6.4", "nprogress": "^0.2.0", "oslo": "1.2.1", "pg": "^8.16.2", "posthog-node": "^5.11.2", "qrcode.react": "4.2.0", - "react": "19.2.0", + "react": "19.2.1", "react-day-picker": "9.11.1", - "react-dom": "19.2.0", + "react-dom": "19.2.1", "react-easy-sort": "^1.8.0", "react-hook-form": "7.66.0", "react-icons": "^5.5.0", @@ -464,50 +464,50 @@ } }, "node_modules/@aws-sdk/client-sesv2": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.910.0.tgz", - "integrity": "sha512-YKCXrzbEhplwsvjAnMKUB9lAfawFHMz7tPLL3dCnKQYeZOQqsYiUUPjkiB2Vq8uV+ALZqJ2FCph07pW7XZHqjg==", + "version": "3.946.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.946.0.tgz", + "integrity": "sha512-JYj3BPqgyRXgBjZ3Xvo4Abd+vLxcsHe4gb0TvwiSM/k7e6MRgBZoYwDOnwbNDs/62X1sn7MPHqqB3miuO4nR5g==", "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.910.0", - "@aws-sdk/credential-provider-node": "3.910.0", - "@aws-sdk/middleware-host-header": "3.910.0", - "@aws-sdk/middleware-logger": "3.910.0", - "@aws-sdk/middleware-recursion-detection": "3.910.0", - "@aws-sdk/middleware-user-agent": "3.910.0", - "@aws-sdk/region-config-resolver": "3.910.0", - "@aws-sdk/signature-v4-multi-region": "3.910.0", - "@aws-sdk/types": "3.910.0", - "@aws-sdk/util-endpoints": "3.910.0", - "@aws-sdk/util-user-agent-browser": "3.910.0", - "@aws-sdk/util-user-agent-node": "3.910.0", - "@smithy/config-resolver": "^4.3.2", - "@smithy/core": "^3.16.1", - "@smithy/fetch-http-handler": "^5.3.3", - "@smithy/hash-node": "^4.2.2", - "@smithy/invalid-dependency": "^4.2.2", - "@smithy/middleware-content-length": "^4.2.2", - "@smithy/middleware-endpoint": "^4.3.3", - "@smithy/middleware-retry": "^4.4.3", - "@smithy/middleware-serde": "^4.2.2", - "@smithy/middleware-stack": "^4.2.2", - "@smithy/node-config-provider": "^4.3.2", - "@smithy/node-http-handler": "^4.4.1", - "@smithy/protocol-http": "^5.3.2", - "@smithy/smithy-client": "^4.8.1", - "@smithy/types": "^4.7.1", - "@smithy/url-parser": "^4.2.2", + "@aws-sdk/core": "3.946.0", + "@aws-sdk/credential-provider-node": "3.946.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.936.0", + "@aws-sdk/middleware-user-agent": "3.946.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/signature-v4-multi-region": "3.946.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.946.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.7", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.14", + "@smithy/middleware-retry": "^4.4.14", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.2", - "@smithy/util-defaults-mode-node": "^4.2.3", - "@smithy/util-endpoints": "^3.2.2", - "@smithy/util-middleware": "^4.2.2", - "@smithy/util-retry": "^4.2.2", + "@smithy/util-defaults-mode-browser": "^4.3.13", + "@smithy/util-defaults-mode-node": "^4.2.16", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, @@ -516,48 +516,48 @@ } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/client-sso": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.910.0.tgz", - "integrity": "sha512-oEWXhe2RHiSPKxhrq1qp7M4fxOsxMIJc4d75z8tTLLm5ujlmTZYU3kd0l2uBBaZSlbkrMiefntT6XrGint1ibw==", + "version": "3.946.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.946.0.tgz", + "integrity": "sha512-kGAs5iIVyUz4p6TX3pzG5q3cNxXnVpC4pwRC6DCSaSv9ozyPjc2d74FsK4fZ+J+ejtvCdJk72uiuQtWJc86Wuw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.910.0", - "@aws-sdk/middleware-host-header": "3.910.0", - "@aws-sdk/middleware-logger": "3.910.0", - "@aws-sdk/middleware-recursion-detection": "3.910.0", - "@aws-sdk/middleware-user-agent": "3.910.0", - "@aws-sdk/region-config-resolver": "3.910.0", - "@aws-sdk/types": "3.910.0", - "@aws-sdk/util-endpoints": "3.910.0", - "@aws-sdk/util-user-agent-browser": "3.910.0", - "@aws-sdk/util-user-agent-node": "3.910.0", - "@smithy/config-resolver": "^4.3.2", - "@smithy/core": "^3.16.1", - "@smithy/fetch-http-handler": "^5.3.3", - "@smithy/hash-node": "^4.2.2", - "@smithy/invalid-dependency": "^4.2.2", - "@smithy/middleware-content-length": "^4.2.2", - "@smithy/middleware-endpoint": "^4.3.3", - "@smithy/middleware-retry": "^4.4.3", - "@smithy/middleware-serde": "^4.2.2", - "@smithy/middleware-stack": "^4.2.2", - "@smithy/node-config-provider": "^4.3.2", - "@smithy/node-http-handler": "^4.4.1", - "@smithy/protocol-http": "^5.3.2", - "@smithy/smithy-client": "^4.8.1", - "@smithy/types": "^4.7.1", - "@smithy/url-parser": "^4.2.2", + "@aws-sdk/core": "3.946.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.936.0", + "@aws-sdk/middleware-user-agent": "3.946.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.946.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.7", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.14", + "@smithy/middleware-retry": "^4.4.14", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.2", - "@smithy/util-defaults-mode-node": "^4.2.3", - "@smithy/util-endpoints": "^3.2.2", - "@smithy/util-middleware": "^4.2.2", - "@smithy/util-retry": "^4.2.2", + "@smithy/util-defaults-mode-browser": "^4.3.13", + "@smithy/util-defaults-mode-node": "^4.2.16", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, @@ -566,23 +566,23 @@ } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/core": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.910.0.tgz", - "integrity": "sha512-b/FVNyPxZMmBp+xDwANDgR6o5Ehh/RTY9U/labH56jJpte196Psru/FmQULX3S6kvIiafQA9JefWUq81SfWVLg==", + "version": "3.946.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.946.0.tgz", + "integrity": "sha512-u2BkbLLVbMFrEiXrko2+S6ih5sUZPlbVyRPtXOqMHlCyzr70sE8kIiD6ba223rQeIFPcYfW/wHc6k4ihW2xxVg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.910.0", - "@aws-sdk/xml-builder": "3.910.0", - "@smithy/core": "^3.16.1", - "@smithy/node-config-provider": "^4.3.2", - "@smithy/property-provider": "^4.2.2", - "@smithy/protocol-http": "^5.3.2", - "@smithy/signature-v4": "^5.3.2", - "@smithy/smithy-client": "^4.8.1", - "@smithy/types": "^4.7.1", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/xml-builder": "3.930.0", + "@smithy/core": "^3.18.7", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/signature-v4": "^5.3.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", - "@smithy/util-middleware": "^4.2.2", + "@smithy/util-middleware": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, @@ -591,16 +591,16 @@ } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-env": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.910.0.tgz", - "integrity": "sha512-Os8I5XtTLBBVyHJLxrEB06gSAZeFMH2jVoKhAaFybjOTiV7wnjBgjvWjRfStnnXs7p9d+vc/gd6wIZHjony5YQ==", + "version": "3.946.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.946.0.tgz", + "integrity": "sha512-P4l+K6wX1tf8LmWUvZofdQ+BgCNyk6Tb9u1H10npvqpuCD+dCM4pXIBq3PQcv/juUBOvLGGREo+Govuh3lfD0Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.910.0", - "@aws-sdk/types": "3.910.0", - "@smithy/property-provider": "^4.2.2", - "@smithy/types": "^4.7.1", + "@aws-sdk/core": "3.946.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -608,21 +608,21 @@ } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-http": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.910.0.tgz", - "integrity": "sha512-3KiGsTlqMnvthv90K88Uv3SvaUbmcTShBIVWYNaHdbrhrjVRR08dm2Y6XjQILazLf1NPFkxUou1YwCWK4nae1Q==", + "version": "3.946.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.946.0.tgz", + "integrity": "sha512-/zeOJ6E7dGZQ/l2k7KytEoPJX0APIhwt0A79hPf/bUpMF4dDs2P6JmchDrotk0a0Y/MIdNF8sBQ/MEOPnBiYoQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.910.0", - "@aws-sdk/types": "3.910.0", - "@smithy/fetch-http-handler": "^5.3.3", - "@smithy/node-http-handler": "^4.4.1", - "@smithy/property-provider": "^4.2.2", - "@smithy/protocol-http": "^5.3.2", - "@smithy/smithy-client": "^4.8.1", - "@smithy/types": "^4.7.1", - "@smithy/util-stream": "^4.5.2", + "@aws-sdk/core": "3.946.0", + "@aws-sdk/types": "3.936.0", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/util-stream": "^4.5.6", "tslib": "^2.6.2" }, "engines": { @@ -630,24 +630,25 @@ } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.910.0.tgz", - "integrity": "sha512-/8x9LKKaLGarvF1++bFEFdIvd9/djBb+HTULbJAf4JVg3tUlpHtGe7uquuZaQkQGeW4XPbcpB9RMWx5YlZkw3w==", + "version": "3.946.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.946.0.tgz", + "integrity": "sha512-Pdgcra3RivWj/TuZmfFaHbqsvvgnSKO0CxlRUMMr0PgBiCnUhyl+zBktdNOeGsOPH2fUzQpYhcUjYUgVSdcSDQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.910.0", - "@aws-sdk/credential-provider-env": "3.910.0", - "@aws-sdk/credential-provider-http": "3.910.0", - "@aws-sdk/credential-provider-process": "3.910.0", - "@aws-sdk/credential-provider-sso": "3.910.0", - "@aws-sdk/credential-provider-web-identity": "3.910.0", - "@aws-sdk/nested-clients": "3.910.0", - "@aws-sdk/types": "3.910.0", - "@smithy/credential-provider-imds": "^4.2.2", - "@smithy/property-provider": "^4.2.2", - "@smithy/shared-ini-file-loader": "^4.3.2", - "@smithy/types": "^4.7.1", + "@aws-sdk/core": "3.946.0", + "@aws-sdk/credential-provider-env": "3.946.0", + "@aws-sdk/credential-provider-http": "3.946.0", + "@aws-sdk/credential-provider-login": "3.946.0", + "@aws-sdk/credential-provider-process": "3.946.0", + "@aws-sdk/credential-provider-sso": "3.946.0", + "@aws-sdk/credential-provider-web-identity": "3.946.0", + "@aws-sdk/nested-clients": "3.946.0", + "@aws-sdk/types": "3.936.0", + "@smithy/credential-provider-imds": "^4.2.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -655,23 +656,23 @@ } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-node": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.910.0.tgz", - "integrity": "sha512-Zz5tF/U4q9ir3rfVnPLlxbhMTHjPaPv78TarspFYn9mNN7cPVXBaXVVnMNu6ypZzBdTB8M44UYo827Qcw3kouA==", + "version": "3.946.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.946.0.tgz", + "integrity": "sha512-I7URUqnBPng1a5y81OImxrwERysZqMBREG6svhhGeZgxmqcpAZ8z5ywILeQXdEOCuuES8phUp/ojzxFjPXp/eA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "3.910.0", - "@aws-sdk/credential-provider-http": "3.910.0", - "@aws-sdk/credential-provider-ini": "3.910.0", - "@aws-sdk/credential-provider-process": "3.910.0", - "@aws-sdk/credential-provider-sso": "3.910.0", - "@aws-sdk/credential-provider-web-identity": "3.910.0", - "@aws-sdk/types": "3.910.0", - "@smithy/credential-provider-imds": "^4.2.2", - "@smithy/property-provider": "^4.2.2", - "@smithy/shared-ini-file-loader": "^4.3.2", - "@smithy/types": "^4.7.1", + "@aws-sdk/credential-provider-env": "3.946.0", + "@aws-sdk/credential-provider-http": "3.946.0", + "@aws-sdk/credential-provider-ini": "3.946.0", + "@aws-sdk/credential-provider-process": "3.946.0", + "@aws-sdk/credential-provider-sso": "3.946.0", + "@aws-sdk/credential-provider-web-identity": "3.946.0", + "@aws-sdk/types": "3.936.0", + "@smithy/credential-provider-imds": "^4.2.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -679,17 +680,17 @@ } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-process": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.910.0.tgz", - "integrity": "sha512-l1lZfHIl/z0SxXibt7wMQ2HmRIyIZjlOrT6a554xlO//y671uxPPwScVw7QW4fPIvwfmKbl8dYCwGI//AgQ0bA==", + "version": "3.946.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.946.0.tgz", + "integrity": "sha512-GtGHX7OGqIeVQ3DlVm5RRF43Qmf3S1+PLJv9svrdvAhAdy2bUb044FdXXqrtSsIfpzTKlHgQUiRo5MWLd35Ntw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.910.0", - "@aws-sdk/types": "3.910.0", - "@smithy/property-provider": "^4.2.2", - "@smithy/shared-ini-file-loader": "^4.3.2", - "@smithy/types": "^4.7.1", + "@aws-sdk/core": "3.946.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -697,19 +698,19 @@ } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.910.0.tgz", - "integrity": "sha512-cwc9bmomjUqPDF58THUCmEnpAIsCFV3Y9FHlQmQbMkYUm7Wlrb5E2iFrZ4WDefAHuh25R/gtj+Yo74r3gl9kbw==", + "version": "3.946.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.946.0.tgz", + "integrity": "sha512-LeGSSt2V5iwYey1ENGY75RmoDP3bA2iE/py8QBKW8EDA8hn74XBLkprhrK5iccOvU3UGWY8WrEKFAFGNjJOL9g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/client-sso": "3.910.0", - "@aws-sdk/core": "3.910.0", - "@aws-sdk/token-providers": "3.910.0", - "@aws-sdk/types": "3.910.0", - "@smithy/property-provider": "^4.2.2", - "@smithy/shared-ini-file-loader": "^4.3.2", - "@smithy/types": "^4.7.1", + "@aws-sdk/client-sso": "3.946.0", + "@aws-sdk/core": "3.946.0", + "@aws-sdk/token-providers": "3.946.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -717,18 +718,18 @@ } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.910.0.tgz", - "integrity": "sha512-HFQgZm1+7WisJ8tqcZkNRRmnoFO+So+L12wViVxneVJ+OclfL2vE/CoKqHTozP6+JCOKMlv6Vi61Lu6xDtKdTA==", + "version": "3.946.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.946.0.tgz", + "integrity": "sha512-ocBCvjWfkbjxElBI1QUxOnHldsNhoU0uOICFvuRDAZAoxvypJHN3m5BJkqb7gqorBbcv3LRgmBdEnWXOAvq+7Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.910.0", - "@aws-sdk/nested-clients": "3.910.0", - "@aws-sdk/types": "3.910.0", - "@smithy/property-provider": "^4.2.2", - "@smithy/shared-ini-file-loader": "^4.3.2", - "@smithy/types": "^4.7.1", + "@aws-sdk/core": "3.946.0", + "@aws-sdk/nested-clients": "3.946.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -736,15 +737,15 @@ } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/middleware-host-header": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.910.0.tgz", - "integrity": "sha512-F9Lqeu80/aTM6S/izZ8RtwSmjfhWjIuxX61LX+/9mxJyEkgaECRxv0chsLQsLHJumkGnXRy/eIyMLBhcTPF5vg==", + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.936.0.tgz", + "integrity": "sha512-tAaObaAnsP1XnLGndfkGWFuzrJYuk9W0b/nLvol66t8FZExIAf/WdkT2NNAWOYxljVs++oHnyHBCxIlaHrzSiw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.910.0", - "@smithy/protocol-http": "^5.3.2", - "@smithy/types": "^4.7.1", + "@aws-sdk/types": "3.936.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -752,14 +753,14 @@ } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/middleware-logger": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.910.0.tgz", - "integrity": "sha512-3LJyyfs1USvRuRDla1pGlzGRtXJBXD1zC9F+eE9Iz/V5nkmhyv52A017CvKWmYoR0DM9dzjLyPOI0BSSppEaTw==", + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.936.0.tgz", + "integrity": "sha512-aPSJ12d3a3Ea5nyEnLbijCaaYJT2QjQ9iW+zGh5QcZYXmOGWbKVyPSxmVOboZQG+c1M8t6d2O7tqrwzIq8L8qw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.910.0", - "@smithy/types": "^4.7.1", + "@aws-sdk/types": "3.936.0", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -767,16 +768,16 @@ } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.910.0.tgz", - "integrity": "sha512-m/oLz0EoCy+WoIVBnXRXJ4AtGpdl0kPE7U+VH9TsuUzHgxY1Re/176Q1HWLBRVlz4gr++lNsgsMWEC+VnAwMpw==", + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.936.0.tgz", + "integrity": "sha512-l4aGbHpXM45YNgXggIux1HgsCVAvvBoqHPkqLnqMl9QVapfuSTjJHfDYDsx1Xxct6/m7qSMUzanBALhiaGO2fA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.910.0", - "@aws/lambda-invoke-store": "^0.0.1", - "@smithy/protocol-http": "^5.3.2", - "@smithy/types": "^4.7.1", + "@aws-sdk/types": "3.936.0", + "@aws/lambda-invoke-store": "^0.2.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -784,24 +785,24 @@ } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.910.0.tgz", - "integrity": "sha512-m13TmWHjIonWkIFi4O1GSsZKPzIf2sO9rUEj9fr1VwTA7Lblp6UaOcfiQHfhWXgxqYaSouvEvCtoqA3SttdPlw==", + "version": "3.946.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.946.0.tgz", + "integrity": "sha512-0UTFmFd8PX2k/jLu/DBmR+mmLQWAtUGHYps9Rjx3dcXNwaMLaa/39NoV3qn7Dwzfpqc6JZlZzBk+NDOCJIHW9g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.910.0", - "@aws-sdk/types": "3.910.0", + "@aws-sdk/core": "3.946.0", + "@aws-sdk/types": "3.936.0", "@aws-sdk/util-arn-parser": "3.893.0", - "@smithy/core": "^3.16.1", - "@smithy/node-config-provider": "^4.3.2", - "@smithy/protocol-http": "^5.3.2", - "@smithy/signature-v4": "^5.3.2", - "@smithy/smithy-client": "^4.8.1", - "@smithy/types": "^4.7.1", + "@smithy/core": "^3.18.7", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/signature-v4": "^5.3.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", "@smithy/util-config-provider": "^4.2.0", - "@smithy/util-middleware": "^4.2.2", - "@smithy/util-stream": "^4.5.2", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, @@ -810,18 +811,18 @@ } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.910.0.tgz", - "integrity": "sha512-djpnECwDLI/4sck1wxK/cZJmZX5pAhRvjONyJqr0AaOfJyuIAG0PHLe7xwCrv2rCAvIBR9ofnNFzPIGTJPDUwg==", + "version": "3.946.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.946.0.tgz", + "integrity": "sha512-7QcljCraeaWQNuqmOoAyZs8KpZcuhPiqdeeKoRd397jVGNRehLFsZbIMOvwaluUDFY11oMyXOkQEERe1Zo2fCw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.910.0", - "@aws-sdk/types": "3.910.0", - "@aws-sdk/util-endpoints": "3.910.0", - "@smithy/core": "^3.16.1", - "@smithy/protocol-http": "^5.3.2", - "@smithy/types": "^4.7.1", + "@aws-sdk/core": "3.946.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@smithy/core": "^3.18.7", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -829,48 +830,48 @@ } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/nested-clients": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.910.0.tgz", - "integrity": "sha512-Jr/smgVrLZECQgMyP4nbGqgJwzFFbkjOVrU8wh/gbVIZy1+Gu6R7Shai7KHDkEjwkGcHpN1MCCO67jTAOoSlMw==", + "version": "3.946.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.946.0.tgz", + "integrity": "sha512-rjAtEguukeW8mlyEQMQI56vxFoyWlaNwowmz1p1rav948SUjtrzjHAp4TOQWhibb7AR7BUTHBCgIcyCRjBEf4g==", "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.910.0", - "@aws-sdk/middleware-host-header": "3.910.0", - "@aws-sdk/middleware-logger": "3.910.0", - "@aws-sdk/middleware-recursion-detection": "3.910.0", - "@aws-sdk/middleware-user-agent": "3.910.0", - "@aws-sdk/region-config-resolver": "3.910.0", - "@aws-sdk/types": "3.910.0", - "@aws-sdk/util-endpoints": "3.910.0", - "@aws-sdk/util-user-agent-browser": "3.910.0", - "@aws-sdk/util-user-agent-node": "3.910.0", - "@smithy/config-resolver": "^4.3.2", - "@smithy/core": "^3.16.1", - "@smithy/fetch-http-handler": "^5.3.3", - "@smithy/hash-node": "^4.2.2", - "@smithy/invalid-dependency": "^4.2.2", - "@smithy/middleware-content-length": "^4.2.2", - "@smithy/middleware-endpoint": "^4.3.3", - "@smithy/middleware-retry": "^4.4.3", - "@smithy/middleware-serde": "^4.2.2", - "@smithy/middleware-stack": "^4.2.2", - "@smithy/node-config-provider": "^4.3.2", - "@smithy/node-http-handler": "^4.4.1", - "@smithy/protocol-http": "^5.3.2", - "@smithy/smithy-client": "^4.8.1", - "@smithy/types": "^4.7.1", - "@smithy/url-parser": "^4.2.2", + "@aws-sdk/core": "3.946.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.936.0", + "@aws-sdk/middleware-user-agent": "3.946.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.946.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.7", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.14", + "@smithy/middleware-retry": "^4.4.14", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.2", - "@smithy/util-defaults-mode-node": "^4.2.3", - "@smithy/util-endpoints": "^3.2.2", - "@smithy/util-middleware": "^4.2.2", - "@smithy/util-retry": "^4.2.2", + "@smithy/util-defaults-mode-browser": "^4.3.13", + "@smithy/util-defaults-mode-node": "^4.2.16", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, @@ -879,17 +880,16 @@ } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/region-config-resolver": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.910.0.tgz", - "integrity": "sha512-gzQAkuHI3xyG6toYnH/pju+kc190XmvnB7X84vtN57GjgdQJICt9So/BD0U6h+eSfk9VBnafkVrAzBzWMEFZVw==", + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.936.0.tgz", + "integrity": "sha512-wOKhzzWsshXGduxO4pqSiNyL9oUtk4BEvjWm9aaq6Hmfdoydq6v6t0rAGHWPjFwy9z2haovGRi3C8IxdMB4muw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.910.0", - "@smithy/node-config-provider": "^4.3.2", - "@smithy/types": "^4.7.1", - "@smithy/util-config-provider": "^4.2.0", - "@smithy/util-middleware": "^4.2.2", + "@aws-sdk/types": "3.936.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -897,17 +897,17 @@ } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.910.0.tgz", - "integrity": "sha512-SM62pR9ozCNzbKuV315QSgR1Tkyy+0sKMzgGAufvOupuWBUaJgEuzCwfLEBhPiEODaQCdJ3UZGn0wYXxn8gXsA==", + "version": "3.946.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.946.0.tgz", + "integrity": "sha512-61FZ685lKiJuQ06g6U7K3PL9EwKCxNm51wNlxyKV57nnl1GrLD0NC8O3/hDNkCQLNBArT9y3IXl2H7TtIxP8Jg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-sdk-s3": "3.910.0", - "@aws-sdk/types": "3.910.0", - "@smithy/protocol-http": "^5.3.2", - "@smithy/signature-v4": "^5.3.2", - "@smithy/types": "^4.7.1", + "@aws-sdk/middleware-sdk-s3": "3.946.0", + "@aws-sdk/types": "3.936.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/signature-v4": "^5.3.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -915,18 +915,18 @@ } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/token-providers": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.910.0.tgz", - "integrity": "sha512-dQr3pFpzemKyrB7SEJ2ipPtWrZiL5vaimg2PkXpwyzGrigYRc8F2R9DMUckU5zi32ozvQqq4PI3bOrw6xUfcbQ==", + "version": "3.946.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.946.0.tgz", + "integrity": "sha512-a5c+rM6CUPX2ExmUZ3DlbLlS5rQr4tbdoGcgBsjnAHiYx8MuMNAI+8M7wfjF13i2yvUQj5WEIddvLpayfEZj9g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.910.0", - "@aws-sdk/nested-clients": "3.910.0", - "@aws-sdk/types": "3.910.0", - "@smithy/property-provider": "^4.2.2", - "@smithy/shared-ini-file-loader": "^4.3.2", - "@smithy/types": "^4.7.1", + "@aws-sdk/core": "3.946.0", + "@aws-sdk/nested-clients": "3.946.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -934,13 +934,13 @@ } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/types": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.910.0.tgz", - "integrity": "sha512-o67gL3vjf4nhfmuSUNNkit0d62QJEwwHLxucwVJkR/rw9mfUtAWsgBs8Tp16cdUbMgsyQtCQilL8RAJDoGtadQ==", + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.936.0.tgz", + "integrity": "sha512-uz0/VlMd2pP5MepdrHizd+T+OKfyK4r3OA9JI+L/lPKg0YFQosdJNCKisr6o70E3dh8iMpFYxF1UN/4uZsyARg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.7.1", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -948,16 +948,16 @@ } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/util-endpoints": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.910.0.tgz", - "integrity": "sha512-6XgdNe42ibP8zCQgNGDWoOF53RfEKzpU/S7Z29FTTJ7hcZv0SytC0ZNQQZSx4rfBl036YWYwJRoJMlT4AA7q9A==", + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.936.0.tgz", + "integrity": "sha512-0Zx3Ntdpu+z9Wlm7JKUBOzS9EunwKAb4KdGUQQxDqh5Lc3ta5uBoub+FgmVuzwnmBu9U1Os8UuwVTH0Lgu+P5w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.910.0", - "@smithy/types": "^4.7.1", - "@smithy/url-parser": "^4.2.2", - "@smithy/util-endpoints": "^3.2.2", + "@aws-sdk/types": "3.936.0", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-endpoints": "^3.2.5", "tslib": "^2.6.2" }, "engines": { @@ -965,29 +965,29 @@ } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.910.0.tgz", - "integrity": "sha512-iOdrRdLZHrlINk9pezNZ82P/VxO/UmtmpaOAObUN+xplCUJu31WNM2EE/HccC8PQw6XlAudpdA6HDTGiW6yVGg==", + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.936.0.tgz", + "integrity": "sha512-eZ/XF6NxMtu+iCma58GRNRxSq4lHo6zHQLOZRIeL/ghqYJirqHdenMOwrzPettj60KWlv827RVebP9oNVrwZbw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.910.0", - "@smithy/types": "^4.7.1", + "@aws-sdk/types": "3.936.0", + "@smithy/types": "^4.9.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.910.0.tgz", - "integrity": "sha512-qNV+rywWQDOOWmGpNlWLCU6zkJurocTBB2uLSdQ8b6Xg6U/i1VTJsoUQ5fbhSQpp/SuBGiIglyB1gSc0th7hPw==", + "version": "3.946.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.946.0.tgz", + "integrity": "sha512-a2UwwvzbK5AxHKUBupfg4s7VnkqRAHjYsuezHnKCniczmT4HZfP1NnfwwvLKEH8qaTrwenxjKSfq4UWmWkvG+Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "3.910.0", - "@aws-sdk/types": "3.910.0", - "@smithy/node-config-provider": "^4.3.2", - "@smithy/types": "^4.7.1", + "@aws-sdk/middleware-user-agent": "3.946.0", + "@aws-sdk/types": "3.936.0", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -1003,13 +1003,13 @@ } }, "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/xml-builder": { - "version": "3.910.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.910.0.tgz", - "integrity": "sha512-UK0NzRknzUITYlkDibDSgkWvhhC11OLhhhGajl6pYCACup+6QE4SsLvmAGMkyNtGVCJ6Q+BM6PwDCBZyBgwl9A==", + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.930.0.tgz", + "integrity": "sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.7.1", + "@smithy/types": "^4.9.0", "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" }, @@ -1017,6 +1017,16 @@ "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws/lambda-invoke-store": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz", + "integrity": "sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/client-sso": { "version": "3.922.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.922.0.tgz", @@ -1151,6 +1161,279 @@ "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.946.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.946.0.tgz", + "integrity": "sha512-5iqLNc15u2Zx+7jOdQkIbP62N7n2031tw5hkmIG0DLnozhnk64osOh2CliiOE9x3c4P9Pf4frAwgyy9GzNTk2g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.946.0", + "@aws-sdk/nested-clients": "3.946.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/core": { + "version": "3.946.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.946.0.tgz", + "integrity": "sha512-u2BkbLLVbMFrEiXrko2+S6ih5sUZPlbVyRPtXOqMHlCyzr70sE8kIiD6ba223rQeIFPcYfW/wHc6k4ihW2xxVg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@aws-sdk/xml-builder": "3.930.0", + "@smithy/core": "^3.18.7", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/signature-v4": "^5.3.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.936.0.tgz", + "integrity": "sha512-tAaObaAnsP1XnLGndfkGWFuzrJYuk9W0b/nLvol66t8FZExIAf/WdkT2NNAWOYxljVs++oHnyHBCxIlaHrzSiw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/middleware-logger": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.936.0.tgz", + "integrity": "sha512-aPSJ12d3a3Ea5nyEnLbijCaaYJT2QjQ9iW+zGh5QcZYXmOGWbKVyPSxmVOboZQG+c1M8t6d2O7tqrwzIq8L8qw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.936.0.tgz", + "integrity": "sha512-l4aGbHpXM45YNgXggIux1HgsCVAvvBoqHPkqLnqMl9QVapfuSTjJHfDYDsx1Xxct6/m7qSMUzanBALhiaGO2fA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@aws/lambda-invoke-store": "^0.2.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.946.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.946.0.tgz", + "integrity": "sha512-7QcljCraeaWQNuqmOoAyZs8KpZcuhPiqdeeKoRd397jVGNRehLFsZbIMOvwaluUDFY11oMyXOkQEERe1Zo2fCw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.946.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@smithy/core": "^3.18.7", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/nested-clients": { + "version": "3.946.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.946.0.tgz", + "integrity": "sha512-rjAtEguukeW8mlyEQMQI56vxFoyWlaNwowmz1p1rav948SUjtrzjHAp4TOQWhibb7AR7BUTHBCgIcyCRjBEf4g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.946.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.936.0", + "@aws-sdk/middleware-user-agent": "3.946.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.946.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.7", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.14", + "@smithy/middleware-retry": "^4.4.14", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.13", + "@smithy/util-defaults-mode-node": "^4.2.16", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.936.0.tgz", + "integrity": "sha512-wOKhzzWsshXGduxO4pqSiNyL9oUtk4BEvjWm9aaq6Hmfdoydq6v6t0rAGHWPjFwy9z2haovGRi3C8IxdMB4muw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/types": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.936.0.tgz", + "integrity": "sha512-uz0/VlMd2pP5MepdrHizd+T+OKfyK4r3OA9JI+L/lPKg0YFQosdJNCKisr6o70E3dh8iMpFYxF1UN/4uZsyARg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/util-endpoints": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.936.0.tgz", + "integrity": "sha512-0Zx3Ntdpu+z9Wlm7JKUBOzS9EunwKAb4KdGUQQxDqh5Lc3ta5uBoub+FgmVuzwnmBu9U1Os8UuwVTH0Lgu+P5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-endpoints": "^3.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.936.0.tgz", + "integrity": "sha512-eZ/XF6NxMtu+iCma58GRNRxSq4lHo6zHQLOZRIeL/ghqYJirqHdenMOwrzPettj60KWlv827RVebP9oNVrwZbw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/types": "^4.9.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.946.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.946.0.tgz", + "integrity": "sha512-a2UwwvzbK5AxHKUBupfg4s7VnkqRAHjYsuezHnKCniczmT4HZfP1NnfwwvLKEH8qaTrwenxjKSfq4UWmWkvG+Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.946.0", + "@aws-sdk/types": "3.936.0", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/xml-builder": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.930.0.tgz", + "integrity": "sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws/lambda-invoke-store": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz", + "integrity": "sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/credential-provider-node": { "version": "3.922.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.922.0.tgz", @@ -1344,15 +1627,6 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-recursion-detection/node_modules/@aws/lambda-invoke-store": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.1.1.tgz", - "integrity": "sha512-RcLam17LdlbSOSp9VxmUu1eI6Mwxp+OwhD2QhiSNmNCzoDb0EeUXTD2n/WbcnrAYMGlmf05th6QYq23VqvJqpA==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@aws-sdk/middleware-sdk-s3": { "version": "3.922.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.922.0.tgz", @@ -1614,10 +1888,9 @@ } }, "node_modules/@aws/lambda-invoke-store": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.0.1.tgz", - "integrity": "sha512-ORHRQ2tmvnBXc8t/X9Z8IcSbBA4xTLKuN873FopzklHMeqBst7YG0d+AX97inkvDX+NChYtSr+qGfcqGFaI8Zw==", - "dev": true, + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.1.1.tgz", + "integrity": "sha512-RcLam17LdlbSOSp9VxmUu1eI6Mwxp+OwhD2QhiSNmNCzoDb0EeUXTD2n/WbcnrAYMGlmf05th6QYq23VqvJqpA==", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" @@ -1638,9 +1911,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", - "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1686,13 +1959,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", - "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -1702,12 +1975,12 @@ } }, "node_modules/@babel/generator/node_modules/@babel/parser": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", - "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.4" + "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" @@ -1764,12 +2037,12 @@ } }, "node_modules/@babel/helper-module-imports/node_modules/@babel/parser": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", - "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.4" + "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" @@ -1779,17 +2052,17 @@ } }, "node_modules/@babel/helper-module-imports/node_modules/@babel/traverse": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", - "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", + "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.4", + "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4", + "@babel/types": "^7.28.5", "debug": "^4.3.1" }, "engines": { @@ -1814,12 +2087,12 @@ } }, "node_modules/@babel/helper-module-transforms/node_modules/@babel/parser": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", - "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.4" + "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" @@ -1829,17 +2102,17 @@ } }, "node_modules/@babel/helper-module-transforms/node_modules/@babel/traverse": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", - "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", + "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.4", + "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4", + "@babel/types": "^7.28.5", "debug": "^4.3.1" }, "engines": { @@ -1856,9 +2129,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1925,12 +2198,12 @@ } }, "node_modules/@babel/template/node_modules/@babel/parser": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", - "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.4" + "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" @@ -1958,13 +2231,13 @@ } }, "node_modules/@babel/types": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", - "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -2028,9 +2301,9 @@ "license": "Apache-2.0" }, "node_modules/@ecies/ciphers": { - "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==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.5.tgz", + "integrity": "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==", "dev": true, "license": "MIT", "engines": { @@ -2043,9 +2316,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", - "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", + "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", "license": "MIT", "optional": true, "dependencies": { @@ -2054,9 +2327,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", - "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", "license": "MIT", "optional": true, "dependencies": { @@ -2998,9 +3271,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" @@ -3045,9 +3318,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "license": "MIT", "dependencies": { "ajv": "^6.12.4", @@ -3056,7 +3329,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, @@ -3906,9 +4179,9 @@ "license": "MIT" }, "node_modules/@monaco-editor/loader": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.6.1.tgz", - "integrity": "sha512-w3tEnj9HYEC73wtjdpR089AqkUPskFRcdkxsiSFt3SoUc3OHpmu+leP94CXBm4mHfefmhsdfI0ZQu6qJ0wgtPg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", "license": "MIT", "dependencies": { "state-local": "^1.0.6" @@ -3941,9 +4214,9 @@ } }, "node_modules/@next/env": { - "version": "15.5.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.6.tgz", - "integrity": "sha512-3qBGRW+sCGzgbpc5TS1a0p7eNxnOarGVQhZxfvTdnV0gFI61lX7QNtQ4V1TSREctXzYn5NetbUsLvyqwLFJM6Q==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz", + "integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -3956,9 +4229,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.6.tgz", - "integrity": "sha512-ES3nRz7N+L5Umz4KoGfZ4XX6gwHplwPhioVRc25+QNsDa7RtUF/z8wJcbuQ2Tffm5RZwuN2A063eapoJ1u4nPg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", + "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", "cpu": [ "arm64" ], @@ -3972,9 +4245,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.6.tgz", - "integrity": "sha512-JIGcytAyk9LQp2/nuVZPAtj8uaJ/zZhsKOASTjxDug0SPU9LAM3wy6nPU735M1OqacR4U20LHVF5v5Wnl9ptTA==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", + "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", "cpu": [ "x64" ], @@ -3988,9 +4261,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.6.tgz", - "integrity": "sha512-qvz4SVKQ0P3/Im9zcS2RmfFL/UCQnsJKJwQSkissbngnB/12c6bZTCB0gHTexz1s6d/mD0+egPKXAIRFVS7hQg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", + "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", "cpu": [ "arm64" ], @@ -4004,9 +4277,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.6.tgz", - "integrity": "sha512-FsbGVw3SJz1hZlvnWD+T6GFgV9/NYDeLTNQB2MXoPN5u9VA9OEDy6fJEfePfsUKAhJufFbZLgp0cPxMuV6SV0w==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", + "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", "cpu": [ "arm64" ], @@ -4020,9 +4293,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.6.tgz", - "integrity": "sha512-3QnHGFWlnvAgyxFxt2Ny8PTpXtQD7kVEeaFat5oPAHHI192WKYB+VIKZijtHLGdBBvc16tiAkPTDmQNOQ0dyrA==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", + "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", "cpu": [ "x64" ], @@ -4036,9 +4309,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.6.tgz", - "integrity": "sha512-OsGX148sL+TqMK9YFaPFPoIaJKbFJJxFzkXZljIgA9hjMjdruKht6xDCEv1HLtlLNfkx3c5w2GLKhj7veBQizQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", + "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", "cpu": [ "x64" ], @@ -4052,9 +4325,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.6.tgz", - "integrity": "sha512-ONOMrqWxdzXDJNh2n60H6gGyKed42Ieu6UTVPZteXpuKbLZTH4G4eBMsr5qWgOBA+s7F+uB4OJbZnrkEDnZ5Fg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", + "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", "cpu": [ "arm64" ], @@ -4068,9 +4341,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.6.tgz", - "integrity": "sha512-pxK4VIjFRx1MY92UycLOOw7dTdvccWsNETQ0kDHkBlcFH1GrTLUjSiHU1ohrznnux6TqRHgv5oflhfIWZwVROQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", + "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", "cpu": [ "x64" ], @@ -4089,6 +4362,7 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -4751,111 +5025,111 @@ "license": "MIT" }, "node_modules/@peculiar/asn1-android": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.5.0.tgz", - "integrity": "sha512-t8A83hgghWQkcneRsgGs2ebAlRe54ns88p7ouv8PW2tzF1nAW4yHcL4uZKrFpIU+uszIRzTkcCuie37gpkId0A==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz", + "integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.5.0", + "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-cms": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.5.0.tgz", - "integrity": "sha512-p0SjJ3TuuleIvjPM4aYfvYw8Fk1Hn/zAVyPJZTtZ2eE9/MIer6/18ROxX6N/e6edVSfvuZBqhxAj3YgsmSjQ/A==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.0.tgz", + "integrity": "sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.5.0", - "@peculiar/asn1-x509": "^2.5.0", - "@peculiar/asn1-x509-attr": "^2.5.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509-attr": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-csr": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.5.0.tgz", - "integrity": "sha512-ioigvA6WSYN9h/YssMmmoIwgl3RvZlAYx4A/9jD2qaqXZwGcNlAxaw54eSx2QG1Yu7YyBC5Rku3nNoHrQ16YsQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.0.tgz", + "integrity": "sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.5.0", - "@peculiar/asn1-x509": "^2.5.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-ecc": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.5.0.tgz", - "integrity": "sha512-t4eYGNhXtLRxaP50h3sfO6aJebUCDGQACoeexcelL4roMFRRVgB20yBIu2LxsPh/tdW9I282gNgMOyg3ywg/mg==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.0.tgz", + "integrity": "sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.5.0", - "@peculiar/asn1-x509": "^2.5.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-pfx": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.5.0.tgz", - "integrity": "sha512-Vj0d0wxJZA+Ztqfb7W+/iu8Uasw6hhKtCdLKXLG/P3kEPIQpqGI4P4YXlROfl7gOCqFIbgsj1HzFIFwQ5s20ug==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.0.tgz", + "integrity": "sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ==", "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.5.0", - "@peculiar/asn1-pkcs8": "^2.5.0", - "@peculiar/asn1-rsa": "^2.5.0", - "@peculiar/asn1-schema": "^2.5.0", + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-pkcs8": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-pkcs8": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.5.0.tgz", - "integrity": "sha512-L7599HTI2SLlitlpEP8oAPaJgYssByI4eCwQq2C9eC90otFpm8MRn66PpbKviweAlhinWQ3ZjDD2KIVtx7PaVw==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.0.tgz", + "integrity": "sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.5.0", - "@peculiar/asn1-x509": "^2.5.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-pkcs9": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.5.0.tgz", - "integrity": "sha512-UgqSMBLNLR5TzEZ5ZzxR45Nk6VJrammxd60WMSkofyNzd3DQLSNycGWSK5Xg3UTYbXcDFyG8pA/7/y/ztVCa6A==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.0.tgz", + "integrity": "sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw==", "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.5.0", - "@peculiar/asn1-pfx": "^2.5.0", - "@peculiar/asn1-pkcs8": "^2.5.0", - "@peculiar/asn1-schema": "^2.5.0", - "@peculiar/asn1-x509": "^2.5.0", - "@peculiar/asn1-x509-attr": "^2.5.0", + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-pfx": "^2.6.0", + "@peculiar/asn1-pkcs8": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509-attr": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-rsa": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.5.0.tgz", - "integrity": "sha512-qMZ/vweiTHy9syrkkqWFvbT3eLoedvamcUdnnvwyyUNv5FgFXA3KP8td+ATibnlZ0EANW5PYRm8E6MJzEB/72Q==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.0.tgz", + "integrity": "sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.5.0", - "@peculiar/asn1-x509": "^2.5.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-schema": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.5.0.tgz", - "integrity": "sha512-YM/nFfskFJSlHqv59ed6dZlLZqtZQwjRVJ4bBAiWV08Oc+1rSd5lDZcBEx0lGDHfSoH3UziI2pXt2UM33KerPQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", "license": "MIT", "dependencies": { "asn1js": "^3.0.6", @@ -4864,63 +5138,55 @@ } }, "node_modules/@peculiar/asn1-x509": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.5.0.tgz", - "integrity": "sha512-CpwtMCTJvfvYTFMuiME5IH+8qmDe3yEWzKHe7OOADbGfq7ohxeLaXwQo0q4du3qs0AII3UbLCvb9NF/6q0oTKQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.0.tgz", + "integrity": "sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.5.0", + "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-x509-attr": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.5.0.tgz", - "integrity": "sha512-9f0hPOxiJDoG/bfNLAFven+Bd4gwz/VzrCIIWc1025LEI4BXO0U5fOCTNDPbbp2ll+UzqKsZ3g61mpBp74gk9A==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.0.tgz", + "integrity": "sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.5.0", - "@peculiar/asn1-x509": "^2.5.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/x509": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.0.tgz", - "integrity": "sha512-Yc4PDxN3OrxUPiXgU63c+ZRXKGE8YKF2McTciYhUHFtHVB0KMnjeFSU0qpztGhsp4P0uKix4+J2xEpIEDu8oXg==", + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.2.tgz", + "integrity": "sha512-r2w1Hg6pODDs0zfAKHkSS5HLkOLSeburtcgwvlLLWWCixw+MmW3U6kD5ddyvc2Y2YdbGuVwCF2S2ASoU1cFAag==", "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.5.0", - "@peculiar/asn1-csr": "^2.5.0", - "@peculiar/asn1-ecc": "^2.5.0", - "@peculiar/asn1-pkcs9": "^2.5.0", - "@peculiar/asn1-rsa": "^2.5.0", - "@peculiar/asn1-schema": "^2.5.0", - "@peculiar/asn1-x509": "^2.5.0", + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, + }, "engines": { - "node": ">=14" + "node": ">=22.0.0" } }, "node_modules/@posthog/core": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.5.2.tgz", - "integrity": "sha512-iedUP3EnOPPxTA2VaIrsrd29lSZnUV+ZrMnvY56timRVeZAXoYCkmjfIs3KBAsF8OUT5h1GXLSkoQdrV0r31OQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.7.1.tgz", + "integrity": "sha512-kjK0eFMIpKo9GXIbts8VtAknsoZ18oZorANdtuTj1CbgS28t4ZVq//HAWhnxEuXRTrtkd+SUJ6Ux3j2Af8NCuA==", "license": "MIT", "dependencies": { "cross-spawn": "^7.0.6" @@ -4968,17 +5234,13 @@ } } }, - "node_modules/@radix-ui/react-avatar": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", - "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-context": "1.1.3", - "@radix-ui/react-primitive": "2.1.4", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-is-hydrated": "0.1.0", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -4995,11 +5257,14 @@ } } }, - "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", - "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -5010,13 +5275,17 @@ } } }, - "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-primitive": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", - "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", + "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.4" + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -5063,6 +5332,62 @@ } } }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "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-checkbox/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collapsible": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", @@ -5093,6 +5418,62 @@ } } }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "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-collapsible/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -5119,6 +5500,44 @@ } } }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "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-collection/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -5153,9 +5572,9 @@ } }, "node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -5203,6 +5622,44 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "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-dialog/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -5263,6 +5720,47 @@ } } }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "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-dismissable-layer/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dropdown-menu": { "version": "2.1.16", "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", @@ -5292,6 +5790,62 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "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-dropdown-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", @@ -5332,6 +5886,47 @@ } } }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "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-focus-scope/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-icons": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", @@ -5382,29 +5977,6 @@ } } }, - "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", - "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.4" - }, - "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-menu": { "version": "2.1.16", "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", @@ -5445,6 +6017,44 @@ } } }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "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-menu/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -5500,6 +6110,44 @@ } } }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "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-popover/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -5550,6 +6198,62 @@ } } }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "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-popper/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-portal": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", @@ -5574,6 +6278,47 @@ } } }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "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-portal/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-presence": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", @@ -5599,12 +6344,12 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", @@ -5621,24 +6366,6 @@ } } }, - "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-progress": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz", @@ -5663,44 +6390,6 @@ } } }, - "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", - "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", - "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.4" - }, - "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-radio-group": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", @@ -5733,6 +6422,62 @@ } } }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "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-radio-group/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", @@ -5764,6 +6509,62 @@ } } }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "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-roving-focus/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-scroll-area": { "version": "1.2.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", @@ -5795,6 +6596,62 @@ } } }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "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-scroll-area/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", @@ -5838,6 +6695,44 @@ } } }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "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-select/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -5879,29 +6774,6 @@ } } }, - "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", - "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.4" - }, - "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-slot": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", @@ -5949,6 +6821,62 @@ } } }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "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-switch/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tabs": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", @@ -5979,6 +6907,62 @@ } } }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "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-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-toast": { "version": "1.2.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", @@ -6013,6 +6997,62 @@ } } }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "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-toast/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-toggle": { "version": "1.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", @@ -6069,6 +7109,108 @@ } } }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "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-toggle-group/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "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-toggle/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", @@ -6103,6 +7245,44 @@ } } }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "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-tooltip/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -6298,6 +7478,47 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "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-visually-hidden/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", @@ -7239,6 +8460,16 @@ } } }, + "node_modules/@react-email/preview-server/node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@react-email/preview-server/node_modules/@types/node": { "version": "22.14.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", @@ -7255,6 +8486,7 @@ "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -7363,6 +8595,7 @@ "version": "15.5.2", "resolved": "https://registry.npmjs.org/next/-/next-15.5.2.tgz", "integrity": "sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q==", + "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.", "dev": true, "license": "MIT", "dependencies": { @@ -7460,6 +8693,7 @@ "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7470,6 +8704,7 @@ "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.25.0" }, @@ -7766,12 +9001,12 @@ } }, "node_modules/@smithy/abort-controller": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.4.tgz", - "integrity": "sha512-Z4DUr/AkgyFf1bOThW2HwzREagee0sB5ycl+hDiSZOfRLW8ZgrOjDi6g8mHH19yyU5E2A/64W3z6SMIf5XiUSQ==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.5.tgz", + "integrity": "sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.1", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -7804,16 +9039,16 @@ } }, "node_modules/@smithy/config-resolver": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.1.tgz", - "integrity": "sha512-BciDJ5hkyYEGBBKMbjGB1A/Zq8bYZ41Zo9BMnGdKF6QD1fY4zIkYx6zui/0CHaVGnv6h0iy8y4rnPX9CPCAPyQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.3.tgz", + "integrity": "sha512-ezHLe1tKLUxDJo2LHtDuEDyWXolw8WGOR92qb4bQdWq/zKenO5BvctZGrVJBK08zjezSk7bmbKFOXIVyChvDLw==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.4", - "@smithy/types": "^4.8.1", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", "@smithy/util-config-provider": "^4.2.0", - "@smithy/util-endpoints": "^3.2.4", - "@smithy/util-middleware": "^4.2.4", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", "tslib": "^2.6.2" }, "engines": { @@ -7821,18 +9056,18 @@ } }, "node_modules/@smithy/core": { - "version": "3.17.2", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.17.2.tgz", - "integrity": "sha512-n3g4Nl1Te+qGPDbNFAYf+smkRVB+JhFsGy9uJXXZQEufoP4u0r+WLh6KvTDolCswaagysDc/afS1yvb2jnj1gQ==", + "version": "3.18.7", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.18.7.tgz", + "integrity": "sha512-axG9MvKhMWOhFbvf5y2DuyTxQueO0dkedY9QC3mAfndLosRI/9LJv8WaL0mw7ubNhsO4IuXX9/9dYGPFvHrqlw==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.2.4", - "@smithy/protocol-http": "^5.3.4", - "@smithy/types": "^4.8.1", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-middleware": "^4.2.4", - "@smithy/util-stream": "^4.5.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" @@ -7842,15 +9077,15 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.4.tgz", - "integrity": "sha512-YVNMjhdz2pVto5bRdux7GMs0x1m0Afz3OcQy/4Yf9DH4fWOtroGH7uLvs7ZmDyoBJzLdegtIPpXrpJOZWvUXdw==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.5.tgz", + "integrity": "sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.4", - "@smithy/property-provider": "^4.2.4", - "@smithy/types": "^4.8.1", - "@smithy/url-parser": "^4.2.4", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", "tslib": "^2.6.2" }, "engines": { @@ -7858,13 +9093,13 @@ } }, "node_modules/@smithy/eventstream-codec": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.4.tgz", - "integrity": "sha512-aV8blR9RBDKrOlZVgjOdmOibTC2sBXNiT7WA558b4MPdsLTV6sbyc1WIE9QiIuYMJjYtnPLciefoqSW8Gi+MZQ==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.5.tgz", + "integrity": "sha512-Ogt4Zi9hEbIP17oQMd68qYOHUzmH47UkK7q7Gl55iIm9oKt27MUGrC5JfpMroeHjdkOliOA4Qt3NQ1xMq/nrlA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.8.1", + "@smithy/types": "^4.9.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" }, @@ -7873,13 +9108,13 @@ } }, "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.4.tgz", - "integrity": "sha512-d5T7ZS3J/r8P/PDjgmCcutmNxnSRvPH1U6iHeXjzI50sMr78GLmFcrczLw33Ap92oEKqa4CLrkAPeSSOqvGdUA==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.5.tgz", + "integrity": "sha512-HohfmCQZjppVnKX2PnXlf47CW3j92Ki6T/vkAT2DhBR47e89pen3s4fIa7otGTtrVxmj7q+IhH0RnC5kpR8wtw==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.4", - "@smithy/types": "^4.8.1", + "@smithy/eventstream-serde-universal": "^4.2.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -7887,12 +9122,12 @@ } }, "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.4.tgz", - "integrity": "sha512-lxfDT0UuSc1HqltOGsTEAlZ6H29gpfDSdEPTapD5G63RbnYToZ+ezjzdonCCH90j5tRRCw3aLXVbiZaBW3VRVg==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.5.tgz", + "integrity": "sha512-ibjQjM7wEXtECiT6my1xfiMH9IcEczMOS6xiCQXoUIYSj5b1CpBbJ3VYbdwDy8Vcg5JHN7eFpOCGk8nyZAltNQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.1", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -7900,13 +9135,13 @@ } }, "node_modules/@smithy/eventstream-serde-node": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.4.tgz", - "integrity": "sha512-TPhiGByWnYyzcpU/K3pO5V7QgtXYpE0NaJPEZBCa1Y5jlw5SjqzMSbFiLb+ZkJhqoQc0ImGyVINqnq1ze0ZRcQ==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.5.tgz", + "integrity": "sha512-+elOuaYx6F2H6x1/5BQP5ugv12nfJl66GhxON8+dWVUEDJ9jah/A0tayVdkLRP0AeSac0inYkDz5qBFKfVp2Gg==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.4", - "@smithy/types": "^4.8.1", + "@smithy/eventstream-serde-universal": "^4.2.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -7914,13 +9149,13 @@ } }, "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.4.tgz", - "integrity": "sha512-GNI/IXaY/XBB1SkGBFmbW033uWA0tj085eCxYih0eccUe/PFR7+UBQv9HNDk2fD9TJu7UVsCWsH99TkpEPSOzQ==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.5.tgz", + "integrity": "sha512-G9WSqbST45bmIFaeNuP/EnC19Rhp54CcVdX9PDL1zyEB514WsDVXhlyihKlGXnRycmHNmVv88Bvvt4EYxWef/Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-codec": "^4.2.4", - "@smithy/types": "^4.8.1", + "@smithy/eventstream-codec": "^4.2.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -7928,14 +9163,14 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.5.tgz", - "integrity": "sha512-mg83SM3FLI8Sa2ooTJbsh5MFfyMTyNRwxqpKHmE0ICRIa66Aodv80DMsTQI02xBLVJ0hckwqTRr5IGAbbWuFLQ==", + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.6.tgz", + "integrity": "sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.4", - "@smithy/querystring-builder": "^4.2.4", - "@smithy/types": "^4.8.1", + "@smithy/protocol-http": "^5.3.5", + "@smithy/querystring-builder": "^4.2.5", + "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" }, @@ -7944,14 +9179,14 @@ } }, "node_modules/@smithy/hash-blob-browser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.5.tgz", - "integrity": "sha512-kCdgjD2J50qAqycYx0imbkA9tPtyQr1i5GwbK/EOUkpBmJGSkJe4mRJm+0F65TUSvvui1HZ5FFGFCND7l8/3WQ==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.6.tgz", + "integrity": "sha512-8P//tA8DVPk+3XURk2rwcKgYwFvwGwmJH/wJqQiSKwXZtf/LiZK+hbUZmPj/9KzM+OVSwe4o85KTp5x9DUZTjw==", "license": "Apache-2.0", "dependencies": { "@smithy/chunked-blob-reader": "^5.2.0", "@smithy/chunked-blob-reader-native": "^4.2.1", - "@smithy/types": "^4.8.1", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -7959,12 +9194,12 @@ } }, "node_modules/@smithy/hash-node": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.4.tgz", - "integrity": "sha512-kKU0gVhx/ppVMntvUOZE7WRMFW86HuaxLwvqileBEjL7PoILI8/djoILw3gPQloGVE6O0oOzqafxeNi2KbnUJw==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.5.tgz", + "integrity": "sha512-DpYX914YOfA3UDT9CN1BM787PcHfWRBB43fFGCYrZFUH0Jv+5t8yYl+Pd5PW4+QzoGEDvn5d5QIO4j2HyYZQSA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.1", + "@smithy/types": "^4.9.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" @@ -7974,12 +9209,12 @@ } }, "node_modules/@smithy/hash-stream-node": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.4.tgz", - "integrity": "sha512-amuh2IJiyRfO5MV0X/YFlZMD6banjvjAwKdeJiYGUbId608x+oSNwv3vlyW2Gt6AGAgl3EYAuyYLGRX/xU8npQ==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.5.tgz", + "integrity": "sha512-6+do24VnEyvWcGdHXomlpd0m8bfZePpUKBy7m311n+JuRwug8J4dCanJdTymx//8mi0nlkflZBvJe+dEO/O12Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.1", + "@smithy/types": "^4.9.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, @@ -7988,12 +9223,12 @@ } }, "node_modules/@smithy/invalid-dependency": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.4.tgz", - "integrity": "sha512-z6aDLGiHzsMhbS2MjetlIWopWz//K+mCoPXjW6aLr0mypF+Y7qdEh5TyJ20Onf9FbWHiWl4eC+rITdizpnXqOw==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.5.tgz", + "integrity": "sha512-2L2erASEro1WC5nV+plwIMxrTXpvpfzl4e+Nre6vBVRR2HKeGGcvpJyyL3/PpiSg+cJG2KpTmZmq934Olb6e5A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.1", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -8013,12 +9248,12 @@ } }, "node_modules/@smithy/md5-js": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.4.tgz", - "integrity": "sha512-h7kzNWZuMe5bPnZwKxhVbY1gan5+TZ2c9JcVTHCygB14buVGOZxLl+oGfpY2p2Xm48SFqEWdghpvbBdmaz3ncQ==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.5.tgz", + "integrity": "sha512-Bt6jpSTMWfjCtC0s79gZ/WZ1w90grfmopVOWqkI2ovhjpD5Q2XRXuecIPB9689L2+cCySMbaXDhBPU56FKNDNg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.1", + "@smithy/types": "^4.9.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, @@ -8027,13 +9262,13 @@ } }, "node_modules/@smithy/middleware-content-length": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.4.tgz", - "integrity": "sha512-hJRZuFS9UsElX4DJSJfoX4M1qXRH+VFiLMUnhsWvtOOUWRNvvOfDaUSdlNbjwv1IkpVjj/Rd/O59Jl3nhAcxow==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.5.tgz", + "integrity": "sha512-Y/RabVa5vbl5FuHYV2vUCwvh/dqzrEY/K2yWPSqvhFUwIY0atLqO4TienjBXakoy4zrKAMCZwg+YEqmH7jaN7A==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.4", - "@smithy/types": "^4.8.1", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -8041,18 +9276,18 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.6.tgz", - "integrity": "sha512-PXehXofGMFpDqr933rxD8RGOcZ0QBAWtuzTgYRAHAL2BnKawHDEdf/TnGpcmfPJGwonhginaaeJIKluEojiF/w==", + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.14.tgz", + "integrity": "sha512-v0q4uTKgBM8dsqGjqsabZQyH85nFaTnFcgpWU1uydKFsdyyMzfvOkNum9G7VK+dOP01vUnoZxIeRiJ6uD0kjIg==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.17.2", - "@smithy/middleware-serde": "^4.2.4", - "@smithy/node-config-provider": "^4.3.4", - "@smithy/shared-ini-file-loader": "^4.3.4", - "@smithy/types": "^4.8.1", - "@smithy/url-parser": "^4.2.4", - "@smithy/util-middleware": "^4.2.4", + "@smithy/core": "^3.18.7", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-middleware": "^4.2.5", "tslib": "^2.6.2" }, "engines": { @@ -8060,18 +9295,18 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.4.6", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.6.tgz", - "integrity": "sha512-OhLx131znrEDxZPAvH/OYufR9d1nB2CQADyYFN4C3V/NQS7Mg4V6uvxHC/Dr96ZQW8IlHJTJ+vAhKt6oxWRndA==", + "version": "4.4.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.14.tgz", + "integrity": "sha512-Z2DG8Ej7FyWG1UA+7HceINtSLzswUgs2np3sZX0YBBxCt+CXG4QUxv88ZDS3+2/1ldW7LqtSY1UO/6VQ1pND8Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.4", - "@smithy/protocol-http": "^5.3.4", - "@smithy/service-error-classification": "^4.2.4", - "@smithy/smithy-client": "^4.9.2", - "@smithy/types": "^4.8.1", - "@smithy/util-middleware": "^4.2.4", - "@smithy/util-retry": "^4.2.4", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/service-error-classification": "^4.2.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" }, @@ -8080,13 +9315,13 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.4.tgz", - "integrity": "sha512-jUr3x2CDhV15TOX2/Uoz4gfgeqLrRoTQbYAuhLS7lcVKNev7FeYSJ1ebEfjk+l9kbb7k7LfzIR/irgxys5ZTOg==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.6.tgz", + "integrity": "sha512-VkLoE/z7e2g8pirwisLz8XJWedUSY8my/qrp81VmAdyrhi94T+riBfwP+AOEEFR9rFTSonC/5D2eWNmFabHyGQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.4", - "@smithy/types": "^4.8.1", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -8094,12 +9329,12 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.4.tgz", - "integrity": "sha512-Gy3TKCOnm9JwpFooldwAboazw+EFYlC+Bb+1QBsSi5xI0W5lX81j/P5+CXvD/9ZjtYKRgxq+kkqd/KOHflzvgA==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.5.tgz", + "integrity": "sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.1", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -8107,14 +9342,14 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.4.tgz", - "integrity": "sha512-3X3w7qzmo4XNNdPKNS4nbJcGSwiEMsNsRSunMA92S4DJLLIrH5g1AyuOA2XKM9PAPi8mIWfqC+fnfKNsI4KvHw==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.5.tgz", + "integrity": "sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.4", - "@smithy/shared-ini-file-loader": "^4.3.4", - "@smithy/types": "^4.8.1", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -8122,15 +9357,15 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.4.tgz", - "integrity": "sha512-VXHGfzCXLZeKnFp6QXjAdy+U8JF9etfpUXD1FAbzY1GzsFJiDQRQIt2CnMUvUdz3/YaHNqT3RphVWMUpXTIODA==", + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.5.tgz", + "integrity": "sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.4", - "@smithy/protocol-http": "^5.3.4", - "@smithy/querystring-builder": "^4.2.4", - "@smithy/types": "^4.8.1", + "@smithy/abort-controller": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/querystring-builder": "^4.2.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -8138,12 +9373,12 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.4.tgz", - "integrity": "sha512-g2DHo08IhxV5GdY3Cpt/jr0mkTlAD39EJKN27Jb5N8Fb5qt8KG39wVKTXiTRCmHHou7lbXR8nKVU14/aRUf86w==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.5.tgz", + "integrity": "sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.1", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -8151,12 +9386,12 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.4.tgz", - "integrity": "sha512-3sfFd2MAzVt0Q/klOmjFi3oIkxczHs0avbwrfn1aBqtc23WqQSmjvk77MBw9WkEQcwbOYIX5/2z4ULj8DuxSsw==", + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.5.tgz", + "integrity": "sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.1", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -8164,12 +9399,12 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.4.tgz", - "integrity": "sha512-KQ1gFXXC+WsbPFnk7pzskzOpn4s+KheWgO3dzkIEmnb6NskAIGp/dGdbKisTPJdtov28qNDohQrgDUKzXZBLig==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.5.tgz", + "integrity": "sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.1", + "@smithy/types": "^4.9.0", "@smithy/util-uri-escape": "^4.2.0", "tslib": "^2.6.2" }, @@ -8178,12 +9413,12 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.4.tgz", - "integrity": "sha512-aHb5cqXZocdzEkZ/CvhVjdw5l4r1aU/9iMEyoKzH4eXMowT6M0YjBpp7W/+XjkBnY8Xh0kVd55GKjnPKlCwinQ==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.5.tgz", + "integrity": "sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.1", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -8191,24 +9426,24 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.4.tgz", - "integrity": "sha512-fdWuhEx4+jHLGeew9/IvqVU/fxT/ot70tpRGuOLxE3HzZOyKeTQfYeV1oaBXpzi93WOk668hjMuuagJ2/Qs7ng==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.5.tgz", + "integrity": "sha512-8fEvK+WPE3wUAcDvqDQG1Vk3ANLR8Px979te96m84CbKAjBVf25rPYSzb4xU4hlTyho7VhOGnh5i62D/JVF0JQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.1" + "@smithy/types": "^4.9.0" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.3.4.tgz", - "integrity": "sha512-y5ozxeQ9omVjbnJo9dtTsdXj9BEvGx2X8xvRgKnV+/7wLBuYJQL6dOa/qMY6omyHi7yjt1OA97jZLoVRYi8lxA==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.0.tgz", + "integrity": "sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.1", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -8216,16 +9451,16 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.4.tgz", - "integrity": "sha512-ScDCpasxH7w1HXHYbtk3jcivjvdA1VICyAdgvVqKhKKwxi+MTwZEqFw0minE+oZ7F07oF25xh4FGJxgqgShz0A==", + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.5.tgz", + "integrity": "sha512-xSUfMu1FT7ccfSXkoLl/QRQBi2rOvi3tiBZU2Tdy3I6cgvZ6SEi9QNey+lqps/sJRnogIS+lq+B1gxxbra2a/w==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.0", - "@smithy/protocol-http": "^5.3.4", - "@smithy/types": "^4.8.1", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", "@smithy/util-hex-encoding": "^4.2.0", - "@smithy/util-middleware": "^4.2.4", + "@smithy/util-middleware": "^4.2.5", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" @@ -8235,17 +9470,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.9.2.tgz", - "integrity": "sha512-gZU4uAFcdrSi3io8U99Qs/FvVdRxPvIMToi+MFfsy/DN9UqtknJ1ais+2M9yR8e0ASQpNmFYEKeIKVcMjQg3rg==", + "version": "4.9.10", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.9.10.tgz", + "integrity": "sha512-Jaoz4Jw1QYHc1EFww/E6gVtNjhoDU+gwRKqXP6C3LKYqqH2UQhP8tMP3+t/ePrhaze7fhLE8vS2q6vVxBANFTQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.17.2", - "@smithy/middleware-endpoint": "^4.3.6", - "@smithy/middleware-stack": "^4.2.4", - "@smithy/protocol-http": "^5.3.4", - "@smithy/types": "^4.8.1", - "@smithy/util-stream": "^4.5.5", + "@smithy/core": "^3.18.7", + "@smithy/middleware-endpoint": "^4.3.14", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-stream": "^4.5.6", "tslib": "^2.6.2" }, "engines": { @@ -8253,9 +9488,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.8.1.tgz", - "integrity": "sha512-N0Zn0OT1zc+NA+UVfkYqQzviRh5ucWwO7mBV3TmHHprMnfcJNfhlPicDkBHi0ewbh+y3evR6cNAW0Raxvb01NA==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.9.0.tgz", + "integrity": "sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -8265,13 +9500,13 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.4.tgz", - "integrity": "sha512-w/N/Iw0/PTwJ36PDqU9PzAwVElo4qXxCC0eCTlUtIz/Z5V/2j/cViMHi0hPukSBHp4DVwvUlUhLgCzqSJ6plrg==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.5.tgz", + "integrity": "sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.4", - "@smithy/types": "^4.8.1", + "@smithy/querystring-parser": "^4.2.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -8342,14 +9577,14 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.5.tgz", - "integrity": "sha512-GwaGjv/QLuL/QHQaqhf/maM7+MnRFQQs7Bsl6FlaeK6lm6U7mV5AAnVabw68cIoMl5FQFyKK62u7RWRzWL25OQ==", + "version": "4.3.13", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.13.tgz", + "integrity": "sha512-hlVLdAGrVfyNei+pKIgqDTxfu/ZI2NSyqj4IDxKd5bIsIqwR/dSlkxlPaYxFiIaDVrBy0he8orsFy+Cz119XvA==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.4", - "@smithy/smithy-client": "^4.9.2", - "@smithy/types": "^4.8.1", + "@smithy/property-provider": "^4.2.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -8357,17 +9592,17 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.7.tgz", - "integrity": "sha512-6hinjVqec0WYGsqN7h9hL/ywfULmJJNXGXnNZW7jrIn/cFuC/aVlVaiDfBIJEvKcOrmN8/EgsW69eY0gXABeHw==", + "version": "4.2.16", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.16.tgz", + "integrity": "sha512-F1t22IUiJLHrxW9W1CQ6B9PN+skZ9cqSuzB18Eh06HrJPbjsyZ7ZHecAKw80DQtyGTRcVfeukKaCRYebFwclbg==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.4.1", - "@smithy/credential-provider-imds": "^4.2.4", - "@smithy/node-config-provider": "^4.3.4", - "@smithy/property-provider": "^4.2.4", - "@smithy/smithy-client": "^4.9.2", - "@smithy/types": "^4.8.1", + "@smithy/config-resolver": "^4.4.3", + "@smithy/credential-provider-imds": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -8375,13 +9610,13 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.4.tgz", - "integrity": "sha512-f+nBDhgYRCmUEDKEQb6q0aCcOTXRDqH5wWaFHJxt4anB4pKHlgGoYP3xtioKXH64e37ANUkzWf6p4Mnv1M5/Vg==", + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.5.tgz", + "integrity": "sha512-3O63AAWu2cSNQZp+ayl9I3NapW1p1rR5mlVHcF6hAB1dPZUQFfRPYtplWX/3xrzWthPGj5FqB12taJJCfH6s8A==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.4", - "@smithy/types": "^4.8.1", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -8401,12 +9636,12 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.4.tgz", - "integrity": "sha512-fKGQAPAn8sgV0plRikRVo6g6aR0KyKvgzNrPuM74RZKy/wWVzx3BMk+ZWEueyN3L5v5EDg+P582mKU+sH5OAsg==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.5.tgz", + "integrity": "sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.1", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -8414,13 +9649,13 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.4.tgz", - "integrity": "sha512-yQncJmj4dtv/isTXxRb4AamZHy4QFr4ew8GxS6XLWt7sCIxkPxPzINWd7WLISEFPsIan14zrKgvyAF+/yzfwoA==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.5.tgz", + "integrity": "sha512-GBj3+EZBbN4NAqJ/7pAhsXdfzdlznOh8PydUijy6FpNIMnHPSMO2/rP4HKu+UFeikJxShERk528oy7GT79YiJg==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.4", - "@smithy/types": "^4.8.1", + "@smithy/service-error-classification": "^4.2.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -8428,14 +9663,14 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.5", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.5.tgz", - "integrity": "sha512-7M5aVFjT+HPilPOKbOmQfCIPchZe4DSBc1wf1+NvHvSoFTiFtauZzT+onZvCj70xhXd0AEmYnZYmdJIuwxOo4w==", + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.6.tgz", + "integrity": "sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.5", - "@smithy/node-http-handler": "^4.4.4", - "@smithy/types": "^4.8.1", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-hex-encoding": "^4.2.0", @@ -8472,13 +9707,13 @@ } }, "node_modules/@smithy/util-waiter": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.4.tgz", - "integrity": "sha512-roKXtXIC6fopFvVOju8VYHtguc/jAcMlK8IlDOHsrQn0ayMkHynjm/D2DCMRf7MJFXzjHhlzg2edr3QPEakchQ==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.5.tgz", + "integrity": "sha512-Dbun99A3InifQdIrsXZ+QLcC0PGBPAdrl4cj1mTgJvyc9N2zf7QSxg8TBkzsCmGJdE3TLbO9ycwpY0EkWahQ/g==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.4", - "@smithy/types": "^4.8.1", + "@smithy/abort-controller": "^4.2.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -8526,15 +9761,228 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@swc/core": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.3.tgz", + "integrity": "sha512-Qd8eBPkUFL4eAONgGjycZXj1jFCBW8Fd+xF0PzdTlBCWQIV1xnUT7B93wUANtW3KGjl3TRcOyxwSx/u/jyKw/Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.25" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.3", + "@swc/core-darwin-x64": "1.15.3", + "@swc/core-linux-arm-gnueabihf": "1.15.3", + "@swc/core-linux-arm64-gnu": "1.15.3", + "@swc/core-linux-arm64-musl": "1.15.3", + "@swc/core-linux-x64-gnu": "1.15.3", + "@swc/core-linux-x64-musl": "1.15.3", + "@swc/core-win32-arm64-msvc": "1.15.3", + "@swc/core-win32-ia32-msvc": "1.15.3", + "@swc/core-win32-x64-msvc": "1.15.3" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.3.tgz", + "integrity": "sha512-AXfeQn0CvcQ4cndlIshETx6jrAM45oeUrK8YeEY6oUZU/qzz0Id0CyvlEywxkWVC81Ajpd8TQQ1fW5yx6zQWkQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.3.tgz", + "integrity": "sha512-p68OeCz1ui+MZYG4wmfJGvcsAcFYb6Sl25H9TxWl+GkBgmNimIiRdnypK9nBGlqMZAcxngNPtnG3kEMNnvoJ2A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.3.tgz", + "integrity": "sha512-Nuj5iF4JteFgwrai97mUX+xUOl+rQRHqTvnvHMATL/l9xE6/TJfPBpd3hk/PVpClMXG3Uvk1MxUFOEzM1JrMYg==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.3.tgz", + "integrity": "sha512-2Nc/s8jE6mW2EjXWxO/lyQuLKShcmTrym2LRf5Ayp3ICEMX6HwFqB1EzDhwoMa2DcUgmnZIalesq2lG3krrUNw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.3.tgz", + "integrity": "sha512-j4SJniZ/qaZ5g8op+p1G9K1z22s/EYGg1UXIb3+Cg4nsxEpF5uSIGEE4mHUfA70L0BR9wKT2QF/zv3vkhfpX4g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.3.tgz", + "integrity": "sha512-aKttAZnz8YB1VJwPQZtyU8Uk0BfMP63iDMkvjhJzRZVgySmqt/apWSdnoIcZlUoGheBrcqbMC17GGUmur7OT5A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.3.tgz", + "integrity": "sha512-oe8FctPu1gnUsdtGJRO2rvOUIkkIIaHqsO9xxN0bTR7dFTlPTGi2Fhk1tnvXeyAvCPxLIcwD8phzKg6wLv9yug==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.3.tgz", + "integrity": "sha512-L9AjzP2ZQ/Xh58e0lTRMLvEDrcJpR7GwZqAtIeNLcTK7JVE+QineSyHp0kLkO1rttCHyCy0U74kDTj0dRz6raA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.3.tgz", + "integrity": "sha512-B8UtogMzErUPDWUoKONSVBdsgKYd58rRyv2sHJWKOIMCHfZ22FVXICR4O/VwIYtlnZ7ahERcjayBHDlBZpR0aw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.3.tgz", + "integrity": "sha512-SpZKMR9QBTecHeqpzJdYEfgw30Oo8b/Xl6rjSzBt1g0ZsXyy60KLXrp6IagQyfTYqNYE/caDvwtF2FPn7pomog==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" } }, + "node_modules/@swc/types": { + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", + "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@tailwindcss/forms": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", @@ -8770,66 +10218,6 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.6.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.6.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.0.7", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.5.0", - "@emnapi/runtime": "^1.5.0", - "@tybys/wasm-util": "^0.10.1" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", - "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true - }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", @@ -8879,9 +10267,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.90.6", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.6.tgz", - "integrity": "sha512-AnZSLF26R8uX+tqb/ivdrwbVdGemdEDm1Q19qM6pry6eOZ6bEYiY7mWhzXT1YDIPTNEVcZ5kYP9nWjoxDLiIVw==", + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", + "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==", "license": "MIT", "funding": { "type": "github", @@ -8889,9 +10277,9 @@ } }, "node_modules/@tanstack/query-devtools": { - "version": "5.90.1", - "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.90.1.tgz", - "integrity": "sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ==", + "version": "5.91.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.91.1.tgz", + "integrity": "sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==", "dev": true, "license": "MIT", "funding": { @@ -8900,12 +10288,13 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.6", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz", - "integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==", + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", + "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", "license": "MIT", + "peer": true, "dependencies": { - "@tanstack/query-core": "5.90.6" + "@tanstack/query-core": "5.90.12" }, "funding": { "type": "github", @@ -8916,20 +10305,20 @@ } }, "node_modules/@tanstack/react-query-devtools": { - "version": "5.90.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.90.2.tgz", - "integrity": "sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ==", + "version": "5.91.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.1.tgz", + "integrity": "sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ==", "dev": true, "license": "MIT", "dependencies": { - "@tanstack/query-devtools": "5.90.1" + "@tanstack/query-devtools": "5.91.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-query": "^5.90.2", + "@tanstack/react-query": "^5.90.10", "react": "^18 || ^19" } }, @@ -9009,6 +10398,7 @@ "integrity": "sha512-fnQmj8lELIj7BSrZQAdBMHEHX8OZLYIHXqAKT1O7tDfLxaINzf00PMjw22r3N/xXh0w/sGHlO6SVaCQ2mj78lg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -9370,6 +10760,7 @@ "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -9470,6 +10861,7 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -9505,6 +10897,7 @@ "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -9538,6 +10931,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -9548,6 +10942,7 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -9560,9 +10955,9 @@ "license": "MIT" }, "node_modules/@types/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz", - "integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9570,9 +10965,9 @@ } }, "node_modules/@types/serve-static": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz", - "integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==", + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", "dev": true, "license": "MIT", "dependencies": { @@ -9582,9 +10977,9 @@ } }, "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.5", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", - "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", "dev": true, "license": "MIT", "dependencies": { @@ -9630,6 +11025,13 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/webpack": { "version": "5.28.5", "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-5.28.5.tgz", @@ -9670,16 +11072,16 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz", - "integrity": "sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", + "integrity": "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==", "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.3", - "@typescript-eslint/type-utils": "8.46.3", - "@typescript-eslint/utils": "8.46.3", - "@typescript-eslint/visitor-keys": "8.46.3", + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/type-utils": "8.48.1", + "@typescript-eslint/utils": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -9693,7 +11095,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.46.3", + "@typescript-eslint/parser": "^8.48.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -9708,15 +11110,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.3.tgz", - "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.1.tgz", + "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "license": "MIT", + "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.46.3", - "@typescript-eslint/types": "8.46.3", - "@typescript-eslint/typescript-estree": "8.46.3", - "@typescript-eslint/visitor-keys": "8.46.3", + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", "debug": "^4.3.4" }, "engines": { @@ -9732,13 +11135,13 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.3.tgz", - "integrity": "sha512-Fz8yFXsp2wDFeUElO88S9n4w1I4CWDTXDqDr9gYvZgUpwXQqmZBr9+NTTql5R3J7+hrJZPdpiWaB9VNhAKYLuQ==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", + "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.3", - "@typescript-eslint/types": "^8.46.3", + "@typescript-eslint/tsconfig-utils": "^8.48.1", + "@typescript-eslint/types": "^8.48.1", "debug": "^4.3.4" }, "engines": { @@ -9753,13 +11156,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.3.tgz", - "integrity": "sha512-FCi7Y1zgrmxp3DfWfr+3m9ansUUFoy8dkEdeQSgA9gbm8DaHYvZCdkFRQrtKiedFf3Ha6VmoqoAaP68+i+22kg==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", + "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.3", - "@typescript-eslint/visitor-keys": "8.46.3" + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9770,9 +11173,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.3.tgz", - "integrity": "sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", + "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9786,14 +11189,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.3.tgz", - "integrity": "sha512-ZPCADbr+qfz3aiTTYNNkCbUt+cjNwI/5McyANNrFBpVxPt7GqpEYz5ZfdwuFyGUnJ9FdDXbGODUu6iRCI6XRXw==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.1.tgz", + "integrity": "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.3", - "@typescript-eslint/typescript-estree": "8.46.3", - "@typescript-eslint/utils": "8.46.3", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/utils": "8.48.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -9810,9 +11213,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.3.tgz", - "integrity": "sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", + "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9823,20 +11226,19 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.3.tgz", - "integrity": "sha512-f/NvtRjOm80BtNM5OQtlaBdM5BRFUv7gf381j9wygDNL+qOYSNOgtQ/DCndiYi80iIOv76QqaTmp4fa9hwI0OA==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", + "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.3", - "@typescript-eslint/tsconfig-utils": "8.46.3", - "@typescript-eslint/types": "8.46.3", - "@typescript-eslint/visitor-keys": "8.46.3", + "@typescript-eslint/project-service": "8.48.1", + "@typescript-eslint/tsconfig-utils": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", + "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "engines": { @@ -9859,34 +11261,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -9903,15 +11277,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.3.tgz", - "integrity": "sha512-VXw7qmdkucEx9WkmR3ld/u6VhRyKeiF1uxWwCy/iuNfokjJ7VhsgLSOTjsol8BunSw190zABzpwdNsze2Kpo4g==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz", + "integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.3", - "@typescript-eslint/types": "8.46.3", - "@typescript-eslint/typescript-estree": "8.46.3" + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9926,12 +11300,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.3.tgz", - "integrity": "sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", + "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/types": "8.48.1", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -10385,6 +11759,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10911,9 +12286,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.8.16", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz", - "integrity": "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==", + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.3.tgz", + "integrity": "sha512-8QdH6czo+G7uBsNo0GiUfouPN1lRzKdJTGnKXwe12gkFbnnOUaUKGN55dMkfy+mnxmvjwl9zcI4VncczcVXDhA==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -10925,6 +12300,7 @@ "integrity": "sha512-mXpa5jnIKKHeoGzBrUJrc65cXFKcILGZpU3FXR0pradUEm9MA7UZz02qfEejaMcm9iXrSOCenwwYMJ/tZ1y5Ig==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -10964,23 +12340,43 @@ } }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/boolbase": { @@ -10991,9 +12387,9 @@ "license": "ISC" }, "node_modules/bowser": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz", - "integrity": "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", + "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", "license": "MIT" }, "node_modules/brace-expansion": { @@ -11019,9 +12415,9 @@ } }, "node_modules/browserslist": { - "version": "4.26.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", - "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "funding": [ { "type": "opencollective", @@ -11037,12 +12433,13 @@ } ], "license": "MIT", + "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.8.9", - "caniuse-lite": "^1.0.30001746", - "electron-to-chromium": "^1.5.227", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -11164,9 +12561,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001750", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001750.tgz", - "integrity": "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==", + "version": "1.0.30001759", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", + "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", "funding": [ { "type": "opencollective", @@ -11225,6 +12622,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -11399,13 +12802,13 @@ } }, "node_modules/color": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/color/-/color-5.0.2.tgz", - "integrity": "sha512-e2hz5BzbUPcYlIRHo8ieAhYgoajrJr+hWoceg6E345TPsATMUKqDgzt8fSXZJJbxfpiPzkWyphz8yn8At7q3fA==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", "license": "MIT", "dependencies": { - "color-convert": "^3.0.1", - "color-string": "^2.0.0" + "color-convert": "^3.1.3", + "color-string": "^2.1.3" }, "engines": { "node": ">=18" @@ -11430,9 +12833,9 @@ "license": "MIT" }, "node_modules/color-string": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.2.tgz", - "integrity": "sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", "license": "MIT", "dependencies": { "color-name": "^2.0.0" @@ -11442,18 +12845,18 @@ } }, "node_modules/color-string/node_modules/color-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.0.2.tgz", - "integrity": "sha512-9vEt7gE16EW7Eu7pvZnR0abW9z6ufzhXxGXZEVU9IqPdlsUiMwJeJfRtq0zePUmnbHGT9zajca7mX8zgoayo4A==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", "license": "MIT", "engines": { "node": ">=12.20" } }, "node_modules/color/node_modules/color-convert": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.2.tgz", - "integrity": "sha512-UNqkvCDXstVck3kdowtOTWROIJQwafjOfXSmddoDrXo4cewMKmusCeF22Q24zvjR8nwWib/3S/dfyzPItPEiJg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", "license": "MIT", "dependencies": { "color-name": "^2.0.0" @@ -11463,9 +12866,9 @@ } }, "node_modules/color/node_modules/color-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.0.2.tgz", - "integrity": "sha512-9vEt7gE16EW7Eu7pvZnR0abW9z6ufzhXxGXZEVU9IqPdlsUiMwJeJfRtq0zePUmnbHGT9zajca7mX8zgoayo4A==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", "license": "MIT", "engines": { "node": ">=12.20" @@ -11517,15 +12920,16 @@ } }, "node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/content-type": { @@ -11544,12 +12948,16 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cookie-parser": { @@ -11691,9 +13099,9 @@ } }, "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/d3": { @@ -12469,11 +13877,13 @@ } }, "node_modules/dompurify": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", - "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } }, "node_modules/domutils": { "version": "3.2.2", @@ -13181,9 +14591,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.235", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.235.tgz", - "integrity": "sha512-i/7ntLFwOdoHY7sgjlTIDo4Sl8EdoTjWIaKinYOVfC6bOp71bmwenyZthWHcasxgHDNWbWxvG9M3Ia116zIaYQ==", + "version": "1.5.266", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.266.tgz", + "integrity": "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -13613,6 +15023,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -13709,6 +15120,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -14200,6 +15612,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -14274,9 +15687,9 @@ } }, "node_modules/exsolve": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", - "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "dev": true, "license": "MIT" }, @@ -14471,9 +15884,9 @@ } }, "node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -14484,7 +15897,11 @@ "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/find-up": { @@ -14592,9 +16009,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -14882,9 +16299,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.12.0.tgz", - "integrity": "sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -14900,14 +16317,14 @@ "license": "MIT" }, "node_modules/glob": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", - "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", - "license": "ISC", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "license": "BlueOak-1.0.0", "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", - "minimatch": "^10.0.3", + "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" @@ -14942,10 +16359,10 @@ "license": "BSD-2-Clause" }, "node_modules/glob/node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", - "license": "ISC", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/brace-expansion": "^5.0.0" }, @@ -15018,7 +16435,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": { @@ -15840,7 +17256,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" @@ -15968,7 +17383,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -15996,12 +17410,12 @@ } }, "node_modules/jsonwebtoken": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", "license": "MIT", "dependencies": { - "jws": "^3.2.2", + "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", @@ -16033,9 +17447,9 @@ } }, "node_modules/jwa": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", - "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "license": "MIT", "dependencies": { "buffer-equal-constant-time": "^1.0.1", @@ -16044,12 +17458,12 @@ } }, "node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "license": "MIT", "dependencies": { - "jwa": "^1.4.1", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, @@ -16057,6 +17471,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT", "dependencies": { "tsscmp": "1.0.6" @@ -16707,15 +18122,19 @@ } }, "node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/mimic-fn": { @@ -16829,13 +18248,12 @@ } }, "node_modules/monaco-editor": { - "version": "0.54.0", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.54.0.tgz", - "integrity": "sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==", + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", - "peer": true, "dependencies": { - "dompurify": "3.1.7", + "dompurify": "3.2.7", "marked": "14.0.0" } }, @@ -16844,7 +18262,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -16876,13 +18293,13 @@ "license": "MIT" }, "node_modules/mylas": { - "version": "2.1.13", - "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.13.tgz", - "integrity": "sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg==", + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.14.tgz", + "integrity": "sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==", "dev": true, "license": "MIT", "engines": { - "node": ">=12.0.0" + "node": ">=16.0.0" }, "funding": { "type": "github", @@ -16963,12 +18380,13 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.5.6", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.6.tgz", - "integrity": "sha512-zTxsnI3LQo3c9HSdSf91O1jMNsEzIXDShXd4wVdg9y5shwLqBXi4ZtUUJyB86KGVSJLZx0PFONvO54aheGX8QQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz", + "integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==", "license": "MIT", + "peer": true, "dependencies": { - "@next/env": "15.5.6", + "@next/env": "15.5.7", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -16981,14 +18399,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.6", - "@next/swc-darwin-x64": "15.5.6", - "@next/swc-linux-arm64-gnu": "15.5.6", - "@next/swc-linux-arm64-musl": "15.5.6", - "@next/swc-linux-x64-gnu": "15.5.6", - "@next/swc-linux-x64-musl": "15.5.6", - "@next/swc-win32-arm64-msvc": "15.5.6", - "@next/swc-win32-x64-msvc": "15.5.6", + "@next/swc-darwin-arm64": "15.5.7", + "@next/swc-darwin-x64": "15.5.7", + "@next/swc-linux-arm64-gnu": "15.5.7", + "@next/swc-linux-arm64-musl": "15.5.7", + "@next/swc-linux-x64-gnu": "15.5.7", + "@next/swc-linux-x64-musl": "15.5.7", + "@next/swc-win32-arm64-msvc": "15.5.7", + "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { @@ -17015,9 +18433,9 @@ } }, "node_modules/next-intl": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.4.0.tgz", - "integrity": "sha512-QHqnP9V9Pe7Tn0PdVQ7u1Z8k9yCkW5SJKeRy2g5gxzhSt/C01y3B9qNxuj3Fsmup/yreIHe6osxU6sFa+9WIkQ==", + "version": "4.5.8", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.5.8.tgz", + "integrity": "sha512-BdN6494nvt09WtmW5gbWdwRhDDHC/Sg7tBMhN7xfYds3vcRCngSDXat81gmJkblw9jYOv8zXzzFJyu5VYXnJzg==", "funding": [ { "type": "individual", @@ -17027,8 +18445,11 @@ "license": "MIT", "dependencies": { "@formatjs/intl-localematcher": "^0.5.4", + "@swc/core": "^1.15.2", "negotiator": "^1.0.0", - "use-intl": "^4.4.0" + "next-intl-swc-plugin-extractor": "^4.5.8", + "po-parser": "^1.0.2", + "use-intl": "^4.5.8" }, "peerDependencies": { "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", @@ -17041,6 +18462,12 @@ } } }, + "node_modules/next-intl-swc-plugin-extractor": { + "version": "4.5.8", + "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.5.8.tgz", + "integrity": "sha512-hscCKUv+5GQ0CCNbvqZ8gaxnAGToCgDTbL++jgCq8SCk/ljtZDEeQZcMk46Nm6Ynn49Q/JKF4Npo/Sq1mpbusA==", + "license": "MIT" + }, "node_modules/next-themes": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", @@ -17051,6 +18478,15 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/next/node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -17098,9 +18534,9 @@ } }, "node_modules/node-abi": { - "version": "3.78.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.78.0.tgz", - "integrity": "sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ==", + "version": "3.85.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", "license": "MIT", "dependencies": { "semver": "^7.3.5" @@ -17171,9 +18607,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.23", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", - "integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "license": "MIT" }, "node_modules/nodemailer": { @@ -17206,15 +18642,16 @@ } }, "node_modules/npm": { - "version": "11.6.2", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.6.2.tgz", - "integrity": "sha512-7iKzNfy8lWYs3zq4oFPa8EXZz5xt9gQNKJZau3B1ErLBb6bF7sBJ00x09485DOvRT2l5Gerbl3VlZNT57MxJVA==", + "version": "11.6.4", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.6.4.tgz", + "integrity": "sha512-ERjKtGoFpQrua/9bG0+h3xiv/4nVdGViCjUYA1AmlV24fFvfnSB7B7dIfZnySQ1FDLd0ZVrWPsLLp78dCtJdRQ==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", "@npmcli/config", "@npmcli/fs", "@npmcli/map-workspaces", + "@npmcli/metavuln-calculator", "@npmcli/package-json", "@npmcli/promise-spawn", "@npmcli/redact", @@ -17286,70 +18723,71 @@ ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.1.6", - "@npmcli/config": "^10.4.2", - "@npmcli/fs": "^4.0.0", - "@npmcli/map-workspaces": "^5.0.0", - "@npmcli/package-json": "^7.0.1", - "@npmcli/promise-spawn": "^8.0.3", - "@npmcli/redact": "^3.2.2", - "@npmcli/run-script": "^10.0.0", + "@npmcli/arborist": "^9.1.8", + "@npmcli/config": "^10.4.4", + "@npmcli/fs": "^5.0.0", + "@npmcli/map-workspaces": "^5.0.3", + "@npmcli/metavuln-calculator": "^9.0.3", + "@npmcli/package-json": "^7.0.4", + "@npmcli/promise-spawn": "^9.0.1", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.3", "@sigstore/tuf": "^4.0.0", - "abbrev": "^3.0.1", + "abbrev": "^4.0.0", "archy": "~1.0.0", - "cacache": "^20.0.1", + "cacache": "^20.0.3", "chalk": "^5.6.2", "ci-info": "^4.3.1", "cli-columns": "^4.0.0", "fastest-levenshtein": "^1.0.16", "fs-minipass": "^3.0.3", - "glob": "^11.0.3", + "glob": "^13.0.0", "graceful-fs": "^4.2.11", "hosted-git-info": "^9.0.2", - "ini": "^5.0.0", - "init-package-json": "^8.2.2", + "ini": "^6.0.0", + "init-package-json": "^8.2.4", "is-cidr": "^6.0.1", - "json-parse-even-better-errors": "^4.0.0", + "json-parse-even-better-errors": "^5.0.0", "libnpmaccess": "^10.0.3", - "libnpmdiff": "^8.0.9", - "libnpmexec": "^10.1.8", - "libnpmfund": "^7.0.9", + "libnpmdiff": "^8.0.11", + "libnpmexec": "^10.1.10", + "libnpmfund": "^7.0.11", "libnpmorg": "^8.0.1", - "libnpmpack": "^9.0.9", - "libnpmpublish": "^11.1.2", + "libnpmpack": "^9.0.11", + "libnpmpublish": "^11.1.3", "libnpmsearch": "^9.0.1", "libnpmteam": "^8.0.2", - "libnpmversion": "^8.0.2", - "make-fetch-happen": "^15.0.2", - "minimatch": "^10.0.3", + "libnpmversion": "^8.0.3", + "make-fetch-happen": "^15.0.3", + "minimatch": "^10.1.1", "minipass": "^7.1.1", "minipass-pipeline": "^1.2.4", "ms": "^2.1.2", - "node-gyp": "^11.4.2", - "nopt": "^8.1.0", - "npm-audit-report": "^6.0.0", - "npm-install-checks": "^7.1.2", - "npm-package-arg": "^13.0.1", - "npm-pick-manifest": "^11.0.1", - "npm-profile": "^12.0.0", - "npm-registry-fetch": "^19.0.0", - "npm-user-validate": "^3.0.0", - "p-map": "^7.0.3", - "pacote": "^21.0.3", - "parse-conflict-json": "^4.0.0", - "proc-log": "^5.0.0", + "node-gyp": "^12.1.0", + "nopt": "^9.0.0", + "npm-audit-report": "^7.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.2", + "npm-pick-manifest": "^11.0.3", + "npm-profile": "^12.0.1", + "npm-registry-fetch": "^19.1.1", + "npm-user-validate": "^4.0.0", + "p-map": "^7.0.4", + "pacote": "^21.0.4", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.1.0", "qrcode-terminal": "^0.12.0", - "read": "^4.1.0", + "read": "^5.0.1", "semver": "^7.7.3", "spdx-expression-parse": "^4.0.0", - "ssri": "^12.0.0", + "ssri": "^13.0.0", "supports-color": "^10.2.2", - "tar": "^7.5.1", + "tar": "^7.5.2", "text-table": "~0.2.0", "tiny-relative-date": "^2.0.2", "treeverse": "^3.0.0", - "validate-npm-package-name": "^6.0.2", - "which": "^5.0.0" + "validate-npm-package-name": "^7.0.0", + "which": "^6.0.0" }, "bin": { "npm": "bin/npm-cli.js", @@ -17391,68 +18829,6 @@ "node": "20 || >=22" } }, - "node_modules/npm/node_modules/@isaacs/cliui": { - "version": "8.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/npm/node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "inBundle": true, @@ -17485,41 +18861,41 @@ } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.1.6", + "version": "9.1.8", "inBundle": true, "license": "ISC", "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/fs": "^4.0.0", - "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/fs": "^5.0.0", + "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/map-workspaces": "^5.0.0", "@npmcli/metavuln-calculator": "^9.0.2", - "@npmcli/name-from-folder": "^3.0.0", - "@npmcli/node-gyp": "^4.0.0", + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", - "@npmcli/query": "^4.0.0", - "@npmcli/redact": "^3.0.0", + "@npmcli/query": "^5.0.0", + "@npmcli/redact": "^4.0.0", "@npmcli/run-script": "^10.0.0", - "bin-links": "^5.0.0", + "bin-links": "^6.0.0", "cacache": "^20.0.1", "common-ancestor-path": "^1.0.1", "hosted-git-info": "^9.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^11.2.1", "minimatch": "^10.0.3", - "nopt": "^8.0.0", - "npm-install-checks": "^7.1.0", + "nopt": "^9.0.0", + "npm-install-checks": "^8.0.0", "npm-package-arg": "^13.0.0", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "pacote": "^21.0.2", - "parse-conflict-json": "^4.0.0", - "proc-log": "^5.0.0", - "proggy": "^3.0.0", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.0.0", + "proggy": "^4.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", "semver": "^7.3.7", - "ssri": "^12.0.0", + "ssri": "^13.0.0", "treeverse": "^3.0.0", "walk-up-path": "^4.0.0" }, @@ -17531,16 +18907,16 @@ } }, "node_modules/npm/node_modules/@npmcli/config": { - "version": "10.4.2", + "version": "10.4.4", "inBundle": true, "license": "ISC", "dependencies": { "@npmcli/map-workspaces": "^5.0.0", "@npmcli/package-json": "^7.0.0", "ci-info": "^4.0.0", - "ini": "^5.0.0", - "nopt": "^8.1.0", - "proc-log": "^5.0.0", + "ini": "^6.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.5", "walk-up-path": "^4.0.0" }, @@ -17549,57 +18925,57 @@ } }, "node_modules/npm/node_modules/@npmcli/fs": { - "version": "4.0.0", + "version": "5.0.0", "inBundle": true, "license": "ISC", "dependencies": { "semver": "^7.3.5" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/git": { - "version": "7.0.0", + "version": "7.0.1", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/promise-spawn": "^8.0.0", - "ini": "^5.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "ini": "^6.0.0", "lru-cache": "^11.2.1", "npm-pick-manifest": "^11.0.1", - "proc-log": "^5.0.0", + "proc-log": "^6.0.0", "promise-retry": "^2.0.1", "semver": "^7.3.5", - "which": "^5.0.0" + "which": "^6.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/installed-package-contents": { - "version": "3.0.0", + "version": "4.0.0", "inBundle": true, "license": "ISC", "dependencies": { - "npm-bundled": "^4.0.0", - "npm-normalize-package-bin": "^4.0.0" + "npm-bundled": "^5.0.0", + "npm-normalize-package-bin": "^5.0.0" }, "bin": { "installed-package-contents": "bin/index.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/map-workspaces": { - "version": "5.0.0", + "version": "5.0.3", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/name-from-folder": "^3.0.0", + "@npmcli/name-from-folder": "^4.0.0", "@npmcli/package-json": "^7.0.0", - "glob": "^11.0.3", + "glob": "^13.0.0", "minimatch": "^10.0.3" }, "engines": { @@ -17607,14 +18983,14 @@ } }, "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { - "version": "9.0.2", + "version": "9.0.3", "inBundle": true, "license": "ISC", "dependencies": { "cacache": "^20.0.0", - "json-parse-even-better-errors": "^4.0.0", + "json-parse-even-better-errors": "^5.0.0", "pacote": "^21.0.0", - "proc-log": "^5.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.5" }, "engines": { @@ -17622,31 +18998,31 @@ } }, "node_modules/npm/node_modules/@npmcli/name-from-folder": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/node-gyp": { "version": "4.0.0", "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/node-gyp": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/package-json": { - "version": "7.0.1", + "version": "7.0.4", "inBundle": true, "license": "ISC", "dependencies": { "@npmcli/git": "^7.0.0", - "glob": "^11.0.3", + "glob": "^13.0.0", "hosted-git-info": "^9.0.0", - "json-parse-even-better-errors": "^4.0.0", - "proc-log": "^5.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", "semver": "^7.5.3", "validate-npm-package-license": "^3.0.4" }, @@ -17655,58 +19031,49 @@ } }, "node_modules/npm/node_modules/@npmcli/promise-spawn": { - "version": "8.0.3", + "version": "9.0.1", "inBundle": true, "license": "ISC", "dependencies": { - "which": "^5.0.0" + "which": "^6.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/query": { - "version": "4.0.1", + "version": "5.0.0", "inBundle": true, "license": "ISC", "dependencies": { "postcss-selector-parser": "^7.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@npmcli/redact": { - "version": "3.2.2", + "version": "4.0.0", "inBundle": true, "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@npmcli/run-script": { - "version": "10.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/node-gyp": "^4.0.0", - "@npmcli/package-json": "^7.0.0", - "@npmcli/promise-spawn": "^8.0.0", - "node-gyp": "^11.0.0", - "proc-log": "^5.0.0", - "which": "^5.0.0" - }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/@pkgjs/parseargs": { - "version": "0.11.0", + "node_modules/npm/node_modules/@npmcli/run-script": { + "version": "10.0.3", "inBundle": true, - "license": "MIT", - "optional": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "node-gyp": "^12.1.0", + "proc-log": "^6.0.0", + "which": "^6.0.0" + }, "engines": { - "node": ">=14" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/@sigstore/bundle": { @@ -17752,6 +19119,14 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/npm/node_modules/@sigstore/sign/node_modules/proc-log": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/npm/node_modules/@sigstore/tuf": { "version": "4.0.0", "inBundle": true, @@ -17812,11 +19187,11 @@ } }, "node_modules/npm/node_modules/abbrev": { - "version": "3.0.1", + "version": "4.0.0", "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/agent-base": { @@ -17835,17 +19210,6 @@ "node": ">=8" } }, - "node_modules/npm/node_modules/ansi-styles": { - "version": "6.2.3", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/npm/node_modules/aproba": { "version": "2.1.0", "inBundle": true, @@ -17862,18 +19226,18 @@ "license": "MIT" }, "node_modules/npm/node_modules/bin-links": { - "version": "5.0.0", + "version": "6.0.0", "inBundle": true, "license": "ISC", "dependencies": { - "cmd-shim": "^7.0.0", - "npm-normalize-package-bin": "^4.0.0", - "proc-log": "^5.0.0", - "read-cmd-shim": "^5.0.0", - "write-file-atomic": "^6.0.0" + "cmd-shim": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "proc-log": "^6.0.0", + "read-cmd-shim": "^6.0.0", + "write-file-atomic": "^7.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/binary-extensions": { @@ -17896,21 +19260,21 @@ } }, "node_modules/npm/node_modules/cacache": { - "version": "20.0.1", + "version": "20.0.3", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/fs": "^4.0.0", + "@npmcli/fs": "^5.0.0", "fs-minipass": "^3.0.0", - "glob": "^11.0.3", + "glob": "^13.0.0", "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", - "ssri": "^12.0.0", - "unique-filename": "^4.0.0" + "ssri": "^13.0.0", + "unique-filename": "^5.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -17973,66 +19337,18 @@ } }, "node_modules/npm/node_modules/cmd-shim": { - "version": "7.0.0", + "version": "8.0.0", "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/color-convert": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/npm/node_modules/color-name": { - "version": "1.1.4", - "inBundle": true, - "license": "MIT" - }, "node_modules/npm/node_modules/common-ancestor-path": { "version": "1.0.1", "inBundle": true, "license": "ISC" }, - "node_modules/npm/node_modules/cross-spawn": { - "version": "7.0.6", - "inBundle": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/cross-spawn/node_modules/isexe": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/cross-spawn/node_modules/which": { - "version": "2.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/npm/node_modules/cssesc": { "version": "3.0.0", "inBundle": true, @@ -18068,11 +19384,6 @@ "node": ">=0.3.1" } }, - "node_modules/npm/node_modules/eastasianwidth": { - "version": "0.2.0", - "inBundle": true, - "license": "MIT" - }, "node_modules/npm/node_modules/emoji-regex": { "version": "8.0.0", "inBundle": true, @@ -18101,7 +19412,7 @@ "license": "MIT" }, "node_modules/npm/node_modules/exponential-backoff": { - "version": "3.1.2", + "version": "3.1.3", "inBundle": true, "license": "Apache-2.0" }, @@ -18113,21 +19424,6 @@ "node": ">= 4.9.1" } }, - "node_modules/npm/node_modules/foreground-child": { - "version": "3.3.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/npm/node_modules/fs-minipass": { "version": "3.0.3", "inBundle": true, @@ -18140,20 +19436,14 @@ } }, "node_modules/npm/node_modules/glob": { - "version": "11.0.3", + "version": "13.0.0", "inBundle": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.0.3", + "minimatch": "^10.1.1", "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, "engines": { "node": "20 || >=22" }, @@ -18238,25 +19528,25 @@ } }, "node_modules/npm/node_modules/ini": { - "version": "5.0.0", + "version": "6.0.0", "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/init-package-json": { - "version": "8.2.2", + "version": "8.2.4", "inBundle": true, "license": "ISC", "dependencies": { "@npmcli/package-json": "^7.0.0", "npm-package-arg": "^13.0.0", - "promzard": "^2.0.0", - "read": "^4.0.0", + "promzard": "^3.0.1", + "read": "^5.0.1", "semver": "^7.7.2", "validate-npm-package-license": "^3.0.4", - "validate-npm-package-name": "^6.0.2" + "validate-npm-package-name": "^7.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -18308,26 +19598,12 @@ "node": ">=16" } }, - "node_modules/npm/node_modules/jackspeak": { - "version": "4.1.1", - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/npm/node_modules/json-parse-even-better-errors": { - "version": "4.0.0", + "version": "5.0.0", "inBundle": true, "license": "MIT", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/json-stringify-nice": { @@ -18369,12 +19645,12 @@ } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.0.9", + "version": "8.0.11", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.1.6", - "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/arborist": "^9.1.8", + "@npmcli/installed-package-contents": "^4.0.0", "binary-extensions": "^3.0.0", "diff": "^8.0.2", "minimatch": "^10.0.3", @@ -18387,19 +19663,19 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "10.1.8", + "version": "10.1.10", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.1.6", + "@npmcli/arborist": "^9.1.8", "@npmcli/package-json": "^7.0.0", "@npmcli/run-script": "^10.0.0", "ci-info": "^4.0.0", "npm-package-arg": "^13.0.0", "pacote": "^21.0.2", - "proc-log": "^5.0.0", + "proc-log": "^6.0.0", "promise-retry": "^2.0.1", - "read": "^4.0.0", + "read": "^5.0.1", "semver": "^7.3.7", "signal-exit": "^4.1.0", "walk-up-path": "^4.0.0" @@ -18409,11 +19685,11 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.9", + "version": "7.0.11", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.1.6" + "@npmcli/arborist": "^9.1.8" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -18432,11 +19708,11 @@ } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "9.0.9", + "version": "9.0.11", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.1.6", + "@npmcli/arborist": "^9.1.8", "@npmcli/run-script": "^10.0.0", "npm-package-arg": "^13.0.0", "pacote": "^21.0.2" @@ -18446,7 +19722,7 @@ } }, "node_modules/npm/node_modules/libnpmpublish": { - "version": "11.1.2", + "version": "11.1.3", "inBundle": true, "license": "ISC", "dependencies": { @@ -18454,10 +19730,10 @@ "ci-info": "^4.0.0", "npm-package-arg": "^13.0.0", "npm-registry-fetch": "^19.0.0", - "proc-log": "^5.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.7", "sigstore": "^4.0.0", - "ssri": "^12.0.0" + "ssri": "^13.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -18487,14 +19763,14 @@ } }, "node_modules/npm/node_modules/libnpmversion": { - "version": "8.0.2", + "version": "8.0.3", "inBundle": true, "license": "ISC", "dependencies": { "@npmcli/git": "^7.0.0", "@npmcli/run-script": "^10.0.0", - "json-parse-even-better-errors": "^4.0.0", - "proc-log": "^5.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.7" }, "engines": { @@ -18510,7 +19786,7 @@ } }, "node_modules/npm/node_modules/make-fetch-happen": { - "version": "15.0.2", + "version": "15.0.3", "inBundle": true, "license": "ISC", "dependencies": { @@ -18518,22 +19794,22 @@ "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", + "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", - "proc-log": "^5.0.0", + "proc-log": "^6.0.0", "promise-retry": "^2.0.1", - "ssri": "^12.0.0" + "ssri": "^13.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/minimatch": { - "version": "10.0.3", + "version": "10.1.1", "inBundle": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/brace-expansion": "^5.0.0" }, @@ -18564,7 +19840,7 @@ } }, "node_modules/npm/node_modules/minipass-fetch": { - "version": "4.0.1", + "version": "5.0.0", "inBundle": true, "license": "MIT", "dependencies": { @@ -18573,7 +19849,7 @@ "minizlib": "^3.0.1" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" }, "optionalDependencies": { "encoding": "^0.1.13" @@ -18662,11 +19938,11 @@ "license": "MIT" }, "node_modules/npm/node_modules/mute-stream": { - "version": "2.0.0", + "version": "3.0.0", "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/negotiator": { @@ -18678,238 +19954,113 @@ } }, "node_modules/npm/node_modules/node-gyp": { - "version": "11.4.2", + "version": "12.1.0", "inBundle": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^14.0.3", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", + "make-fetch-happen": "^15.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.5", - "tar": "^7.4.3", + "tar": "^7.5.2", "tinyglobby": "^0.2.12", - "which": "^5.0.0" + "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/@npmcli/agent": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/cacache": { - "version": "19.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^4.0.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^12.0.0", - "tar": "^7.4.3", - "unique-filename": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/glob": { - "version": "10.4.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/jackspeak": { - "version": "3.4.3", - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/lru-cache": { - "version": "10.4.3", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/node-gyp/node_modules/make-fetch-happen": { - "version": "14.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/agent": "^3.0.0", - "cacache": "^19.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "ssri": "^12.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/minimatch": { - "version": "9.0.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/path-scurry": { - "version": "1.11.1", - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/nopt": { - "version": "8.1.0", + "version": "9.0.0", "inBundle": true, "license": "ISC", "dependencies": { - "abbrev": "^3.0.0" + "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-audit-report": { - "version": "6.0.0", + "version": "7.0.0", "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-bundled": { - "version": "4.0.0", + "version": "5.0.0", "inBundle": true, "license": "ISC", "dependencies": { - "npm-normalize-package-bin": "^4.0.0" + "npm-normalize-package-bin": "^5.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-install-checks": { - "version": "7.1.2", + "version": "8.0.0", "inBundle": true, "license": "BSD-2-Clause", "dependencies": { "semver": "^7.1.1" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-normalize-package-bin": { - "version": "4.0.0", + "version": "5.0.0", "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-package-arg": { - "version": "13.0.1", + "version": "13.0.2", "inBundle": true, "license": "ISC", "dependencies": { "hosted-git-info": "^9.0.0", - "proc-log": "^5.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.5", - "validate-npm-package-name": "^6.0.0" + "validate-npm-package-name": "^7.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-packlist": { - "version": "10.0.2", + "version": "10.0.3", "inBundle": true, "license": "ISC", "dependencies": { "ignore-walk": "^8.0.0", - "proc-log": "^5.0.0" + "proc-log": "^6.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-pick-manifest": { - "version": "11.0.1", + "version": "11.0.3", "inBundle": true, "license": "ISC", "dependencies": { - "npm-install-checks": "^7.1.0", - "npm-normalize-package-bin": "^4.0.0", + "npm-install-checks": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", "npm-package-arg": "^13.0.0", "semver": "^7.3.5" }, @@ -18918,45 +20069,45 @@ } }, "node_modules/npm/node_modules/npm-profile": { - "version": "12.0.0", + "version": "12.0.1", "inBundle": true, "license": "ISC", "dependencies": { "npm-registry-fetch": "^19.0.0", - "proc-log": "^5.0.0" + "proc-log": "^6.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-registry-fetch": { - "version": "19.0.0", + "version": "19.1.1", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/redact": "^3.0.0", + "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", + "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", - "proc-log": "^5.0.0" + "proc-log": "^6.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/npm-user-validate": { - "version": "3.0.0", + "version": "4.0.0", "inBundle": true, "license": "BSD-2-Clause", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/p-map": { - "version": "7.0.3", + "version": "7.0.4", "inBundle": true, "license": "MIT", "engines": { @@ -18966,20 +20117,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/npm/node_modules/package-json-from-dist": { - "version": "1.0.1", - "inBundle": true, - "license": "BlueOak-1.0.0" - }, "node_modules/npm/node_modules/pacote": { - "version": "21.0.3", + "version": "21.0.4", "inBundle": true, "license": "ISC", "dependencies": { "@npmcli/git": "^7.0.0", - "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", - "@npmcli/promise-spawn": "^8.0.0", + "@npmcli/promise-spawn": "^9.0.0", "@npmcli/run-script": "^10.0.0", "cacache": "^20.0.0", "fs-minipass": "^3.0.0", @@ -18988,10 +20134,10 @@ "npm-packlist": "^10.0.1", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", - "proc-log": "^5.0.0", + "proc-log": "^6.0.0", "promise-retry": "^2.0.1", "sigstore": "^4.0.0", - "ssri": "^12.0.0", + "ssri": "^13.0.0", "tar": "^7.4.3" }, "bin": { @@ -19002,24 +20148,16 @@ } }, "node_modules/npm/node_modules/parse-conflict-json": { - "version": "4.0.0", + "version": "5.0.1", "inBundle": true, "license": "ISC", "dependencies": { - "json-parse-even-better-errors": "^4.0.0", + "json-parse-even-better-errors": "^5.0.0", "just-diff": "^6.0.0", "just-diff-apply": "^5.2.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/path-key": { - "version": "3.1.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/path-scurry": { @@ -19050,19 +20188,19 @@ } }, "node_modules/npm/node_modules/proc-log": { - "version": "5.0.0", + "version": "6.1.0", "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/proggy": { - "version": "3.0.0", + "version": "4.0.0", "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/promise-all-reject-late": { @@ -19094,14 +20232,14 @@ } }, "node_modules/npm/node_modules/promzard": { - "version": "2.0.0", + "version": "3.0.1", "inBundle": true, "license": "ISC", "dependencies": { - "read": "^4.0.0" + "read": "^5.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/qrcode-terminal": { @@ -19112,22 +20250,22 @@ } }, "node_modules/npm/node_modules/read": { - "version": "4.1.0", + "version": "5.0.1", "inBundle": true, "license": "ISC", "dependencies": { - "mute-stream": "^2.0.0" + "mute-stream": "^3.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/read-cmd-shim": { - "version": "5.0.0", + "version": "6.0.0", "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/retry": { @@ -19155,25 +20293,6 @@ "node": ">=10" } }, - "node_modules/npm/node_modules/shebang-command": { - "version": "2.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/shebang-regex": { - "version": "3.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/npm/node_modules/signal-exit": { "version": "4.1.0", "inBundle": true, @@ -19274,14 +20393,14 @@ "license": "CC0-1.0" }, "node_modules/npm/node_modules/ssri": { - "version": "12.0.0", + "version": "13.0.0", "inBundle": true, "license": "ISC", "dependencies": { "minipass": "^7.0.3" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/string-width": { @@ -19297,20 +20416,6 @@ "node": ">=8" } }, - "node_modules/npm/node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "inBundle": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/npm/node_modules/strip-ansi": { "version": "6.0.1", "inBundle": true, @@ -19322,18 +20427,6 @@ "node": ">=8" } }, - "node_modules/npm/node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/npm/node_modules/supports-color": { "version": "10.2.2", "inBundle": true, @@ -19346,9 +20439,9 @@ } }, "node_modules/npm/node_modules/tar": { - "version": "7.5.1", + "version": "7.5.2", "inBundle": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", @@ -19413,6 +20506,7 @@ "version": "4.0.3", "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -19442,25 +20536,25 @@ } }, "node_modules/npm/node_modules/unique-filename": { - "version": "4.0.0", + "version": "5.0.0", "inBundle": true, "license": "ISC", "dependencies": { - "unique-slug": "^5.0.0" + "unique-slug": "^6.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/unique-slug": { - "version": "5.0.0", + "version": "6.0.0", "inBundle": true, "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/util-deprecate": { @@ -19487,11 +20581,11 @@ } }, "node_modules/npm/node_modules/validate-npm-package-name": { - "version": "6.0.2", + "version": "7.0.0", "inBundle": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/walk-up-path": { @@ -19503,7 +20597,7 @@ } }, "node_modules/npm/node_modules/which": { - "version": "5.0.0", + "version": "6.0.0", "inBundle": true, "license": "ISC", "dependencies": { @@ -19513,104 +20607,11 @@ "node-which": "bin/which.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/wrap-ansi": { - "version": "8.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.2.2", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "9.2.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/string-width": { - "version": "5.1.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/write-file-atomic": { - "version": "6.0.0", + "version": "7.0.0", "inBundle": true, "license": "ISC", "dependencies": { @@ -19618,7 +20619,7 @@ "signal-exit": "^4.0.1" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/npm/node_modules/yallist": { @@ -20332,9 +21333,9 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", - "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", @@ -20348,10 +21349,10 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", - "license": "ISC", + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } @@ -20397,6 +21398,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -20544,6 +21546,12 @@ "node": ">=12" } }, + "node_modules/po-parser": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-1.0.2.tgz", + "integrity": "sha512-yTIQL8PZy7V8c0psPoJUx7fayez+Mo/53MZgX9MPuPHx+Dt+sRPNuRbI+6Oqxnddhkd68x4Nlgon/zizL1Xg+w==", + "license": "MIT" + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -20573,6 +21581,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -20713,12 +21722,12 @@ } }, "node_modules/posthog-node": { - "version": "5.11.2", - "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.11.2.tgz", - "integrity": "sha512-z+XekcBUmGePMsjPlGaEF2bJFiDHKHYPQjS4OEw4YPDQz8s7Owuim/L7xNX+6UJkyIRniBza9iC7bW8yrGTv1g==", + "version": "5.17.2", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.17.2.tgz", + "integrity": "sha512-lz3YJOr0Nmiz0yHASaINEDHqoV+0bC3eD8aZAG+Ky292dAnVYul+ga/dMX8KCBXg8hHfKdxw0SztYD5j6dgUqQ==", "license": "MIT", "dependencies": { - "@posthog/core": "1.5.2" + "@posthog/core": "1.7.1" }, "engines": { "node": ">=20" @@ -20760,9 +21769,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -20883,12 +21892,12 @@ } }, "node_modules/pvutils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", - "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", "license": "MIT", "engines": { - "node": ">=6.0.0" + "node": ">=16.0.0" } }, "node_modules/qrcode.react": { @@ -20971,20 +21980,40 @@ } }, "node_modules/raw-body": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", - "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.7.0", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.10" } }, + "node_modules/raw-body/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/raw-body/node_modules/iconv-lite": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", @@ -21026,10 +22055,11 @@ } }, "node_modules/react": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", + "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -21056,15 +22086,16 @@ } }, "node_modules/react-dom": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", + "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.0" + "react": "^19.2.1" } }, "node_modules/react-easy-sort": { @@ -21835,6 +22866,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -21877,9 +22909,9 @@ "license": "MIT" }, "node_modules/react-remove-scroll": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", - "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", "license": "MIT", "dependencies": { "react-remove-scroll-bar": "^2.3.7", @@ -22158,15 +23190,15 @@ "license": "MIT" }, "node_modules/resend": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/resend/-/resend-6.4.2.tgz", - "integrity": "sha512-YnxmwneltZtjc7Xff+8ZjG1/xPLdstCiqsedgO/JxWTf7vKRAPCx6CkhQ3ZXskG0mrmf8+I5wr/wNRd8PQMUfw==", + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.5.2.tgz", + "integrity": "sha512-Yl83UvS8sYsjgmF8dVbNPzlfpmb3DkLUk3VwsAbkaEFo9UMswpNuPGryHBXGk+Ta4uYMv5HmjVk3j9jmNkcEDg==", "license": "MIT", "dependencies": { "svix": "1.76.1" }, "engines": { - "node": ">=18" + "node": ">=20" }, "peerDependencies": { "@react-email/render": "*" @@ -22178,12 +23210,12 @@ } }, "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -22409,6 +23441,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -23399,18 +24432,18 @@ } }, "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", - "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { @@ -23421,16 +24454,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/sucrase/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/sucrase/node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -23441,83 +24464,6 @@ "node": ">= 6" } }, - "node_modules/sucrase/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sucrase/node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/sucrase/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/sucrase/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sucrase/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -23557,9 +24503,9 @@ } }, "node_modules/svix/node_modules/@types/node": { - "version": "22.19.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz", - "integrity": "sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==", + "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -23585,9 +24531,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.29.4", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.29.4.tgz", - "integrity": "sha512-gJFDz/gyLOCQtWwAgqs6Rk78z9ONnqTnlW11gimG9nLap8drKa3AJBKpzIQMIjl5PD2Ix+Tn+mc/tfoT2tgsng==", + "version": "5.30.3", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.30.3.tgz", + "integrity": "sha512-giQl7/ToPxCqnUAx2wpnSnDNGZtGzw1LyUw6ZitIpTmdrvpxKFY/94v1hihm0zYNpgp1/VY0jTDk//R0BBgnRQ==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -23628,7 +24574,8 @@ "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -23656,12 +24603,6 @@ "tar-stream": "^2.1.4" } }, - "node_modules/tar-fs/node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, "node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", @@ -23679,9 +24620,9 @@ } }, "node_modules/terser": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", - "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", + "version": "5.44.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", + "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -23698,9 +24639,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "version": "5.3.15", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.15.tgz", + "integrity": "sha512-PGkOdpRFK+rb1TzVz+msVhw4YMRT9txLF4kRqvJhGhCM324xuR3REBSHALN+l+sAhKUmz0aotnjp5D+P83mLhQ==", "dev": true, "license": "MIT", "dependencies": { @@ -24668,6 +25609,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -24677,15 +25619,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.3.tgz", - "integrity": "sha512-bAfgMavTuGo+8n6/QQDVQz4tZ4f7Soqg53RbrlZQEoAltYop/XR4RAts/I0BrO3TTClTSTFJ0wYbla+P8cEWJA==", + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.1.tgz", + "integrity": "sha512-FbOKN1fqNoXp1hIl5KYpObVrp0mCn+CLgn479nmu2IsRMrx2vyv74MmsBLVlhg8qVwNFGbXSp8fh1zp8pEoC2A==", "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.46.3", - "@typescript-eslint/parser": "8.46.3", - "@typescript-eslint/typescript-estree": "8.46.3", - "@typescript-eslint/utils": "8.46.3" + "@typescript-eslint/eslint-plugin": "8.48.1", + "@typescript-eslint/parser": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/utils": "8.48.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -24768,9 +25710,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", + "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", "funding": [ { "type": "opencollective", @@ -24851,9 +25793,9 @@ } }, "node_modules/use-intl": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.4.0.tgz", - "integrity": "sha512-smFekJWtokDRBLC5/ZumlBREzdXOkw06+56Ifj2uRe9266Mk+yWQm2PcJO+EwlOE5sHIXHixOTzN6V8E0RGUbw==", + "version": "4.5.8", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.5.8.tgz", + "integrity": "sha512-rWPV2Sirw55BQbA/7ndUBtsikh8WXwBrUkZJ1mD35+emj/ogPPqgCZdv1DdrEFK42AjF1g5w8d3x8govhqPH6Q==", "license": "MIT", "dependencies": { "@formatjs/fast-memoize": "^2.2.0", @@ -24998,9 +25940,9 @@ } }, "node_modules/webpack": { - "version": "5.102.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", - "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", + "version": "5.103.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.103.0.tgz", + "integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==", "dev": true, "license": "MIT", "dependencies": { @@ -25021,7 +25963,7 @@ "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", + "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", @@ -25107,7 +26049,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" @@ -25209,6 +26150,7 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz", "integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==", "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", @@ -25425,15 +26367,18 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yargs": { @@ -25515,6 +26460,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 6dbc81fd..c8bf202f 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "eslint-config-next": "16.0.3", "express": "5.1.0", "express-rate-limit": "8.2.1", - "glob": "11.0.3", + "glob": "11.1.0", "helmet": "8.1.0", "http-errors": "2.0.0", "i": "^0.3.7", @@ -98,22 +98,22 @@ "lucide-react": "^0.552.0", "maxmind": "5.0.1", "moment": "2.30.1", - "next": "15.5.6", + "next": "15.5.7", "next-intl": "^4.4.0", "next-themes": "0.4.6", "nextjs-toploader": "^3.9.17", "node-cache": "5.1.2", "node-fetch": "3.3.2", "nodemailer": "7.0.10", - "npm": "^11.6.2", + "npm": "^11.6.4", "nprogress": "^0.2.0", "oslo": "1.2.1", "pg": "^8.16.2", "posthog-node": "^5.11.2", "qrcode.react": "4.2.0", - "react": "19.2.0", + "react": "19.2.1", "react-day-picker": "9.11.1", - "react-dom": "19.2.0", + "react-dom": "19.2.1", "react-easy-sort": "^1.8.0", "react-hook-form": "7.66.0", "react-icons": "^5.5.0", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 4608757b..71017f8d 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -86,6 +86,7 @@ export enum ActionsEnum { updateOrgDomain = "updateOrgDomain", getDNSRecords = "getDNSRecords", createNewt = "createNewt", + createOlm = "createOlm", createIdp = "createIdp", updateIdp = "updateIdp", deleteIdp = "deleteIdp", diff --git a/server/auth/sessions/app.ts b/server/auth/sessions/app.ts index 0e3da100..73b220fa 100644 --- a/server/auth/sessions/app.ts +++ b/server/auth/sessions/app.ts @@ -36,13 +36,15 @@ export async function createSession( const sessionId = encodeHexLowerCase( sha256(new TextEncoder().encode(token)) ); - const session: Session = { - sessionId: sessionId, - userId, - expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(), - issuedAt: new Date().getTime() - }; - await db.insert(sessions).values(session); + const [session] = await db + .insert(sessions) + .values({ + sessionId: sessionId, + userId, + expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(), + issuedAt: new Date().getTime() + }) + .returning(); return session; } diff --git a/server/auth/sessions/verifySession.ts b/server/auth/sessions/verifySession.ts index ca8f18ff..01b32ef6 100644 --- a/server/auth/sessions/verifySession.ts +++ b/server/auth/sessions/verifySession.ts @@ -1,9 +1,43 @@ import { Request } from "express"; -import { validateSessionToken, SESSION_COOKIE_NAME } from "@server/auth/sessions/app"; +import { + validateSessionToken, + SESSION_COOKIE_NAME +} from "@server/auth/sessions/app"; -export async function verifySession(req: Request) { +export async function verifySession(req: Request, forceLogin?: boolean) { const res = await validateSessionToken( - req.cookies[SESSION_COOKIE_NAME] ?? "", + req.cookies[SESSION_COOKIE_NAME] ?? "" ); + + if (!forceLogin) { + return res; + } + if (!res.session || !res.user) { + return { + session: null, + user: null + }; + } + if (res.session.deviceAuthUsed) { + return { + session: null, + user: null + }; + } + if (!res.session.issuedAt) { + return { + session: null, + user: null + }; + } + const mins = 5 * 60 * 1000; + const now = new Date().getTime(); + if (now - res.session.issuedAt > mins) { + return { + session: null, + user: null + }; + } + return res; } diff --git a/server/db/names.ts b/server/db/names.ts index 2da38f10..a3da96e7 100644 --- a/server/db/names.ts +++ b/server/db/names.ts @@ -42,11 +42,17 @@ export async function getUniqueResourceName(orgId: string): Promise { } const name = generateName(); - const count = await db - .select({ niceId: resources.niceId, orgId: resources.orgId }) - .from(resources) - .where(and(eq(resources.niceId, name), eq(resources.orgId, orgId))); - if (count.length === 0) { + const [resourceCount, siteResourceCount] = await Promise.all([ + db + .select({ niceId: resources.niceId, orgId: resources.orgId }) + .from(resources) + .where(and(eq(resources.niceId, name), eq(resources.orgId, orgId))), + db + .select({ niceId: siteResources.niceId, orgId: siteResources.orgId }) + .from(siteResources) + .where(and(eq(siteResources.niceId, name), eq(siteResources.orgId, orgId))) + ]); + if (resourceCount.length === 0 && siteResourceCount.length === 0) { return name; } loops++; @@ -61,11 +67,17 @@ export async function getUniqueSiteResourceName(orgId: string): Promise } const name = generateName(); - const count = await db - .select({ niceId: siteResources.niceId, orgId: siteResources.orgId }) - .from(siteResources) - .where(and(eq(siteResources.niceId, name), eq(siteResources.orgId, orgId))); - if (count.length === 0) { + const [resourceCount, siteResourceCount] = await Promise.all([ + db + .select({ niceId: resources.niceId, orgId: resources.orgId }) + .from(resources) + .where(and(eq(resources.niceId, name), eq(resources.orgId, orgId))), + db + .select({ niceId: siteResources.niceId, orgId: siteResources.orgId }) + .from(siteResources) + .where(and(eq(siteResources.niceId, name), eq(siteResources.orgId, orgId))) + ]); + if (resourceCount.length === 0 && siteResourceCount.length === 0) { return name; } loops++; diff --git a/server/db/pg/driver.ts b/server/db/pg/driver.ts index 8a614cc3..35378961 100644 --- a/server/db/pg/driver.ts +++ b/server/db/pg/driver.ts @@ -73,7 +73,7 @@ function createDb() { return withReplicas( DrizzlePostgres(primaryPool, { - logger: process.env.NODE_ENV === "development" + logger: process.env.QUERY_LOGGING === "true" }), replicas as any ); diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 908a6cdd..55084463 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -12,6 +12,7 @@ import { } from "drizzle-orm/pg-core"; import { InferSelectModel } from "drizzle-orm"; import { randomUUID } from "crypto"; +import { alias } from "yargs"; export const domains = pgTable("domains", { domainId: varchar("domainId").primaryKey(), @@ -41,6 +42,7 @@ export const orgs = pgTable("orgs", { orgId: varchar("orgId").primaryKey(), name: varchar("name").notNull(), subnet: varchar("subnet"), + utilitySubnet: varchar("utilitySubnet"), // this is the subnet for utility addresses createdAt: text("createdAt"), requireTwoFactor: boolean("requireTwoFactor"), maxSessionLengthHours: integer("maxSessionLengthHours"), @@ -89,8 +91,7 @@ export const sites = pgTable("sites", { publicKey: varchar("publicKey"), lastHolePunch: bigint("lastHolePunch", { mode: "number" }), listenPort: integer("listenPort"), - dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), - remoteSubnets: text("remoteSubnets") // comma-separated list of subnets that this site can access + dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true) }); export const resources = pgTable("resources", { @@ -206,11 +207,41 @@ export const siteResources = pgTable("siteResources", { .references(() => orgs.orgId, { onDelete: "cascade" }), niceId: varchar("niceId").notNull(), name: varchar("name").notNull(), - protocol: varchar("protocol").notNull(), - proxyPort: integer("proxyPort").notNull(), - destinationPort: integer("destinationPort").notNull(), - destinationIp: varchar("destinationIp").notNull(), - enabled: boolean("enabled").notNull().default(true) + mode: varchar("mode").notNull(), // "host" | "cidr" | "port" + protocol: varchar("protocol"), // only for port mode + proxyPort: integer("proxyPort"), // only for port mode + destinationPort: integer("destinationPort"), // only for port mode + destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode + enabled: boolean("enabled").notNull().default(true), + alias: varchar("alias"), + aliasAddress: varchar("aliasAddress") +}); + +export const clientSiteResources = pgTable("clientSiteResources", { + clientId: integer("clientId") + .notNull() + .references(() => clients.clientId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) +}); + +export const roleSiteResources = pgTable("roleSiteResources", { + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) +}); + +export const userSiteResources = pgTable("userSiteResources", { + userId: varchar("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) }); export const users = pgTable("user", { @@ -258,7 +289,8 @@ export const sessions = pgTable("session", { .notNull() .references(() => users.userId, { onDelete: "cascade" }), expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), - issuedAt: bigint("issuedAt", { mode: "number" }) + issuedAt: bigint("issuedAt", { mode: "number" }), + deviceAuthUsed: boolean("deviceAuthUsed").notNull().default(false) }); export const newtSessions = pgTable("newtSession", { @@ -600,7 +632,7 @@ export const idpOrg = pgTable("idpOrg", { }); export const clients = pgTable("clients", { - clientId: serial("id").primaryKey(), + clientId: serial("clientId").primaryKey(), orgId: varchar("orgId") .references(() => orgs.orgId, { onDelete: "cascade" @@ -609,6 +641,11 @@ export const clients = pgTable("clients", { exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { onDelete: "set null" }), + userId: text("userId").references(() => users.userId, { + // optionally tied to a user and in this case delete when the user deletes + onDelete: "cascade" + }), + olmId: text("olmId"), // to lock it to a specific olm optionally name: varchar("name").notNull(), pubKey: varchar("pubKey"), subnet: varchar("subnet").notNull(), @@ -623,23 +660,40 @@ export const clients = pgTable("clients", { 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), - endpoint: varchar("endpoint") -}); +export const clientSitesAssociationsCache = pgTable( + "clientSitesAssociationsCache", + { + clientId: integer("clientId") // not a foreign key here so after its deleted the rebuild function can delete it and send the message + .notNull(), + siteId: integer("siteId").notNull(), + isRelayed: boolean("isRelayed").notNull().default(false), + endpoint: varchar("endpoint"), + publicKey: varchar("publicKey") // this will act as the session's public key for hole punching so we can track when it changes + } +); + +export const clientSiteResourcesAssociationsCache = pgTable( + "clientSiteResourcesAssociationsCache", + { + clientId: integer("clientId") // not a foreign key here so after its deleted the rebuild function can delete it and send the message + .notNull(), + siteResourceId: integer("siteResourceId").notNull() + } +); export const olms = pgTable("olms", { olmId: varchar("id").primaryKey(), secretHash: varchar("secretHash").notNull(), dateCreated: varchar("dateCreated").notNull(), version: text("version"), + agent: text("agent"), + name: varchar("name"), clientId: integer("clientId").references(() => clients.clientId, { + // we will switch this depending on the current org it wants to connect to + onDelete: "set null" + }), + userId: text("userId").references(() => users.userId, { + // optionally tied to a user and in this case delete when the user deletes onDelete: "cascade" }) }); @@ -755,6 +809,21 @@ export const requestAuditLog = pgTable( ] ); +export const deviceWebAuthCodes = pgTable("deviceWebAuthCodes", { + codeId: serial("codeId").primaryKey(), + code: text("code").notNull().unique(), + ip: text("ip"), + city: text("city"), + deviceName: text("deviceName"), + applicationName: text("applicationName").notNull(), + expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), + createdAt: bigint("createdAt", { mode: "number" }).notNull(), + verified: boolean("verified").notNull().default(false), + userId: varchar("userId").references(() => users.userId, { + onDelete: "cascade" + }) +}); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -795,7 +864,7 @@ export type ApiKey = InferSelectModel; export type ApiKeyAction = InferSelectModel; export type ApiKeyOrg = InferSelectModel; export type Client = InferSelectModel; -export type ClientSite = InferSelectModel; +export type ClientSite = InferSelectModel; export type Olm = InferSelectModel; export type OlmSession = InferSelectModel; export type UserClient = InferSelectModel; @@ -810,4 +879,5 @@ export type Blueprint = InferSelectModel; export type LicenseKey = InferSelectModel; export type SecurityKey = InferSelectModel; export type WebauthnChallenge = InferSelectModel; +export type DeviceWebAuthCode = InferSelectModel; export type RequestAuditLog = InferSelectModel; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 93056665..af7d021d 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -1,13 +1,12 @@ -import { - sqliteTable, - integer, - text, - real, - index -} from "drizzle-orm/sqlite-core"; import { InferSelectModel } from "drizzle-orm"; -import { domains, orgs, targets, users, exitNodes, sessions } from "./schema"; -import { metadata } from "@app/app/[orgId]/settings/layout"; +import { + index, + integer, + real, + sqliteTable, + text +} from "drizzle-orm/sqlite-core"; +import { domains, exitNodes, orgs, sessions, users } from "./schema"; export const certificates = sqliteTable("certificates", { certId: integer("certId").primaryKey({ autoIncrement: true }), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 10bf07d7..c0b4ec3a 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -7,7 +7,7 @@ import { index, uniqueIndex } from "drizzle-orm/sqlite-core"; -import { boolean } from "yargs"; +import { no } from "zod/v4/locales"; export const domains = sqliteTable("domains", { domainId: text("domainId").primaryKey(), @@ -39,6 +39,7 @@ export const orgs = sqliteTable("orgs", { orgId: text("orgId").primaryKey(), name: text("name").notNull(), subnet: text("subnet"), + utilitySubnet: text("utilitySubnet"), // this is the subnet for utility addresses createdAt: text("createdAt"), requireTwoFactor: integer("requireTwoFactor", { mode: "boolean" }), maxSessionLengthHours: integer("maxSessionLengthHours"), // hours @@ -100,8 +101,7 @@ export const sites = sqliteTable("sites", { listenPort: integer("listenPort"), dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) .notNull() - .default(true), - remoteSubnets: text("remoteSubnets") // comma-separated list of subnets that this site can access + .default(true) }); export const resources = sqliteTable("resources", { @@ -202,7 +202,7 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", { hcMethod: text("hcMethod").default("GET"), hcStatus: integer("hcStatus"), // http code hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy" - hcTlsServerName: text("hcTlsServerName"), + hcTlsServerName: text("hcTlsServerName") }); export const exitNodes = sqliteTable("exitNodes", { @@ -233,11 +233,41 @@ export const siteResources = sqliteTable("siteResources", { .references(() => orgs.orgId, { onDelete: "cascade" }), niceId: text("niceId").notNull(), name: text("name").notNull(), - protocol: text("protocol").notNull(), - proxyPort: integer("proxyPort").notNull(), - destinationPort: integer("destinationPort").notNull(), - destinationIp: text("destinationIp").notNull(), - enabled: integer("enabled", { mode: "boolean" }).notNull().default(true) + mode: text("mode").notNull(), // "host" | "cidr" | "port" + protocol: text("protocol"), // only for port mode + proxyPort: integer("proxyPort"), // only for port mode + destinationPort: integer("destinationPort"), // only for port mode + destination: text("destination").notNull(), // ip, cidr, hostname + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + alias: text("alias"), + aliasAddress: text("aliasAddress") +}); + +export const clientSiteResources = sqliteTable("clientSiteResources", { + clientId: integer("clientId") + .notNull() + .references(() => clients.clientId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) +}); + +export const roleSiteResources = sqliteTable("roleSiteResources", { + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) +}); + +export const userSiteResources = sqliteTable("userSiteResources", { + userId: text("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) }); export const users = sqliteTable("user", { @@ -313,7 +343,7 @@ export const newts = sqliteTable("newt", { }); export const clients = sqliteTable("clients", { - clientId: integer("id").primaryKey({ autoIncrement: true }), + clientId: integer("clientId").primaryKey({ autoIncrement: true }), orgId: text("orgId") .references(() => orgs.orgId, { onDelete: "cascade" @@ -322,8 +352,14 @@ export const clients = sqliteTable("clients", { exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { onDelete: "set null" }), + userId: text("userId").references(() => users.userId, { + // optionally tied to a user and in this case delete when the user deletes + onDelete: "cascade" + }), + name: text("name").notNull(), pubKey: text("pubKey"), + olmId: text("olmId"), // to lock it to a specific olm optionally subnet: text("subnet").notNull(), megabytesIn: integer("bytesIn"), megabytesOut: integer("bytesOut"), @@ -335,25 +371,42 @@ export const clients = sqliteTable("clients", { 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), - endpoint: text("endpoint") -}); +export const clientSitesAssociationsCache = sqliteTable( + "clientSitesAssociationsCache", + { + clientId: integer("clientId") // not a foreign key here so after its deleted the rebuild function can delete it and send the message + .notNull(), + siteId: integer("siteId").notNull(), + isRelayed: integer("isRelayed", { mode: "boolean" }) + .notNull() + .default(false), + endpoint: text("endpoint"), + publicKey: text("publicKey") // this will act as the session's public key for hole punching so we can track when it changes + } +); + +export const clientSiteResourcesAssociationsCache = sqliteTable( + "clientSiteResourcesAssociationsCache", + { + clientId: integer("clientId") // not a foreign key here so after its deleted the rebuild function can delete it and send the message + .notNull(), + siteResourceId: integer("siteResourceId").notNull() + } +); export const olms = sqliteTable("olms", { olmId: text("id").primaryKey(), secretHash: text("secretHash").notNull(), dateCreated: text("dateCreated").notNull(), version: text("version"), + agent: text("agent"), + name: text("name"), clientId: integer("clientId").references(() => clients.clientId, { + // we will switch this depending on the current org it wants to connect to + onDelete: "set null" + }), + userId: text("userId").references(() => users.userId, { + // optionally tied to a user and in this case delete when the user deletes onDelete: "cascade" }) }); @@ -372,7 +425,10 @@ export const sessions = sqliteTable("session", { .notNull() .references(() => users.userId, { onDelete: "cascade" }), expiresAt: integer("expiresAt").notNull(), - issuedAt: integer("issuedAt") + issuedAt: integer("issuedAt"), + deviceAuthUsed: integer("deviceAuthUsed", { mode: "boolean" }) + .notNull() + .default(false) }); export const newtSessions = sqliteTable("newtSession", { @@ -809,6 +865,21 @@ export const requestAuditLog = sqliteTable( ] ); +export const deviceWebAuthCodes = sqliteTable("deviceWebAuthCodes", { + codeId: integer("codeId").primaryKey({ autoIncrement: true }), + code: text("code").notNull().unique(), + ip: text("ip"), + city: text("city"), + deviceName: text("deviceName"), + applicationName: text("applicationName").notNull(), + expiresAt: integer("expiresAt").notNull(), + createdAt: integer("createdAt").notNull(), + verified: integer("verified", { mode: "boolean" }).notNull().default(false), + userId: text("userId").references(() => users.userId, { + onDelete: "cascade" + }) +}); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -847,7 +918,7 @@ export type ResourceRule = InferSelectModel; export type Domain = InferSelectModel; export type DnsRecord = InferSelectModel; export type Client = InferSelectModel; -export type ClientSite = InferSelectModel; +export type ClientSite = InferSelectModel; export type RoleClient = InferSelectModel; export type UserClient = InferSelectModel; export type SupporterKey = InferSelectModel; @@ -866,3 +937,4 @@ export type LicenseKey = InferSelectModel; export type SecurityKey = InferSelectModel; export type WebauthnChallenge = InferSelectModel; export type RequestAuditLog = InferSelectModel; +export type DeviceWebAuthCode = InferSelectModel; diff --git a/server/index.ts b/server/index.ts index daa4b7d3..a61daca7 100644 --- a/server/index.ts +++ b/server/index.ts @@ -11,6 +11,7 @@ import { ApiKeyOrg, RemoteExitNode, Session, + SiteResource, User, UserOrg } from "@server/db"; @@ -77,6 +78,8 @@ declare global { userOrgId?: string; userOrgIds?: string[]; remoteExitNode?: RemoteExitNode; + siteResource?: SiteResource; + orgPolicyAllowed?: boolean; } } } diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index 697e8093..7b4f3b22 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -122,19 +122,17 @@ export async function applyBlueprint({ ) .limit(1); - if (site) { - logger.debug( - `Updating client resource ${result.resource.siteResourceId} on site ${site.sites.siteId}` - ); + logger.debug( + `Updating client resource ${result.resource.siteResourceId} on site ${site.sites.siteId}` + ); - await addClientTargets( - site.newt.newtId, - result.resource.destinationIp, - result.resource.destinationPort, - result.resource.protocol, - result.resource.proxyPort - ); - } + // await addClientTargets( + // site.newt.newtId, + // result.resource.destination, + // result.resource.destinationPort, + // result.resource.protocol, + // result.resource.proxyPort + // ); } blueprintSucceeded = true; diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index 59bbc346..7b92ba21 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -75,8 +75,9 @@ export async function updateClientResources( .set({ name: resourceData.name || resourceNiceId, siteId: site.siteId, + mode: "port", proxyPort: resourceData["proxy-port"]!, - destinationIp: resourceData.hostname, + destination: resourceData.hostname, destinationPort: resourceData["internal-port"], protocol: resourceData.protocol }) @@ -98,8 +99,9 @@ export async function updateClientResources( siteId: site.siteId, niceId: resourceNiceId, name: resourceData.name || resourceNiceId, + mode: "port", proxyPort: resourceData["proxy-port"]!, - destinationIp: resourceData.hostname, + destination: resourceData.hostname, destinationPort: resourceData["internal-port"], protocol: resourceData.protocol }) diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index d85befed..9761f57d 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -221,6 +221,7 @@ export async function updateProxyResources( domainId: domain ? domain.domainId : null, enabled: resourceEnabled, sso: resourceData.auth?.["sso-enabled"] || false, + skipToIdpId: resourceData.auth?.["auto-login-idp"] || null, ssl: resourceSsl, setHostHeader: resourceData["host-header"] || null, tlsServerName: resourceData["tls-server-name"] || null, @@ -610,6 +611,7 @@ export async function updateProxyResources( domainId: domain ? domain.domainId : null, enabled: resourceEnabled, sso: resourceData.auth?.["sso-enabled"] || false, + skipToIdpId: resourceData.auth?.["auto-login-idp"] || null, setHostHeader: resourceData["host-header"] || null, tlsServerName: resourceData["tls-server-name"] || null, ssl: resourceSsl, @@ -789,10 +791,6 @@ async function syncRoleResources( .where(eq(roleResources.resourceId, resourceId)); for (const roleName of ssoRoles) { - if (roleName === "Admin") { - continue; // never add admin access - } - const [role] = await trx .select() .from(roles) @@ -803,6 +801,10 @@ async function syncRoleResources( throw new Error(`Role not found: ${roleName} in org ${orgId}`); } + if (role.isAdmin) { + continue; // never add admin access + } + const existingRoleResource = existingRoleResources.find( (rr) => rr.roleId === role.roleId ); diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index a5ee5700..75a0ae17 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -59,6 +59,7 @@ export const AuthSchema = z.object({ }), "sso-users": z.array(z.email()).optional().default([]), "whitelist-users": z.array(z.email()).optional().default([]), + "auto-login-idp": z.int().positive().optional(), }); export const RuleSchema = z.object({ diff --git a/server/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts new file mode 100644 index 00000000..4cde8657 --- /dev/null +++ b/server/lib/calculateUserClientsForOrgs.ts @@ -0,0 +1,286 @@ +import { + clients, + db, + olms, + orgs, + roleClients, + roles, + userClients, + userOrgs, + Transaction +} from "@server/db"; +import { eq, and, notInArray } from "drizzle-orm"; +import { listExitNodes } from "#dynamic/lib/exitNodes"; +import { getNextAvailableClientSubnet } from "@server/lib/ip"; +import logger from "@server/logger"; +import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations"; +import { sendTerminateClient } from "@server/routers/client/terminate"; + +export async function calculateUserClientsForOrgs( + userId: string, + trx?: Transaction +): Promise { + const execute = async (transaction: Transaction) => { + // Get all OLMs for this user + const userOlms = await transaction + .select() + .from(olms) + .where(eq(olms.userId, userId)); + + if (userOlms.length === 0) { + // No OLMs for this user, but we should still clean up any orphaned clients + await cleanupOrphanedClients(userId, transaction); + return; + } + + // Get all user orgs + const allUserOrgs = await transaction + .select() + .from(userOrgs) + .where(eq(userOrgs.userId, userId)); + + const userOrgIds = allUserOrgs.map((uo) => uo.orgId); + + // For each OLM, ensure there's a client in each org the user is in + for (const olm of userOlms) { + for (const userOrg of allUserOrgs) { + const orgId = userOrg.orgId; + + const [org] = await transaction + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)); + + if (!org) { + logger.warn( + `Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org not found` + ); + continue; + } + + if (!org.subnet) { + logger.warn( + `Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org has no subnet configured` + ); + continue; + } + + // Get admin role for this org (needed for access grants) + const [adminRole] = await transaction + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); + + if (!adminRole) { + logger.warn( + `Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no admin role found` + ); + continue; + } + + // Check if a client already exists for this OLM+user+org combination + const [existingClient] = await transaction + .select() + .from(clients) + .where( + and( + eq(clients.userId, userId), + eq(clients.orgId, orgId), + eq(clients.olmId, olm.olmId) + ) + ) + .limit(1); + + if (existingClient) { + // Ensure admin role has access to the client + const [existingRoleClient] = await transaction + .select() + .from(roleClients) + .where( + and( + eq(roleClients.roleId, adminRole.roleId), + eq( + roleClients.clientId, + existingClient.clientId + ) + ) + ) + .limit(1); + + if (!existingRoleClient) { + await transaction.insert(roleClients).values({ + roleId: adminRole.roleId, + clientId: existingClient.clientId + }); + logger.debug( + `Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})` + ); + } + + // Ensure user has access to the client + const [existingUserClient] = await transaction + .select() + .from(userClients) + .where( + and( + eq(userClients.userId, userId), + eq( + userClients.clientId, + existingClient.clientId + ) + ) + ) + .limit(1); + + if (!existingUserClient) { + await transaction.insert(userClients).values({ + userId, + clientId: existingClient.clientId + }); + logger.debug( + `Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})` + ); + } + + logger.debug( + `Client already exists for OLM ${olm.olmId} in org ${orgId} (user ${userId}), skipping creation` + ); + continue; + } + + // Get exit nodes for this org + const exitNodesList = await listExitNodes(orgId); + + if (exitNodesList.length === 0) { + logger.warn( + `Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found` + ); + continue; + } + + const randomExitNode = + exitNodesList[ + Math.floor(Math.random() * exitNodesList.length) + ]; + + // Get next available subnet + const newSubnet = await getNextAvailableClientSubnet(orgId); + if (!newSubnet) { + logger.warn( + `Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no available subnet found` + ); + continue; + } + + const subnet = newSubnet.split("/")[0]; + const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; + + // Create the client + const [newClient] = await transaction + .insert(clients) + .values({ + userId, + orgId: userOrg.orgId, + exitNodeId: randomExitNode.exitNodeId, + name: olm.name || "User Client", + subnet: updatedSubnet, + olmId: olm.olmId, + type: "olm" + }) + .returning(); + + await rebuildClientAssociationsFromClient( + newClient, + transaction + ); + + // Grant admin role access to the client + await transaction.insert(roleClients).values({ + roleId: adminRole.roleId, + clientId: newClient.clientId + }); + + // Grant user access to the client + await transaction.insert(userClients).values({ + userId, + clientId: newClient.clientId + }); + + logger.debug( + `Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user` + ); + } + } + + // Clean up clients in orgs the user is no longer in + await cleanupOrphanedClients(userId, transaction, userOrgIds); + }; + + if (trx) { + // Use provided transaction + await execute(trx); + } else { + // Create new transaction + await db.transaction(async (transaction) => { + await execute(transaction); + }); + } +} + +async function cleanupOrphanedClients( + userId: string, + trx: Transaction, + userOrgIds: string[] = [] +): Promise { + // Find all OLM clients for this user that should be deleted + // If userOrgIds is empty, delete all OLM clients (user has no orgs) + // If userOrgIds has values, delete clients in orgs they're not in + const clientsToDelete = await trx + .select({ clientId: clients.clientId }) + .from(clients) + .where( + userOrgIds.length > 0 + ? and( + eq(clients.userId, userId), + notInArray(clients.orgId, userOrgIds) + ) + : and(eq(clients.userId, userId)) + ); + + if (clientsToDelete.length > 0) { + const deletedClients = await trx + .delete(clients) + .where( + userOrgIds.length > 0 + ? and( + eq(clients.userId, userId), + notInArray(clients.orgId, userOrgIds) + ) + : and(eq(clients.userId, userId)) + ) + .returning(); + + // Rebuild associations for each deleted client to clean up related data + for (const deletedClient of deletedClients) { + await rebuildClientAssociationsFromClient(deletedClient, trx); + + if (deletedClient.olmId) { + await sendTerminateClient( + deletedClient.clientId, + deletedClient.olmId + ); + } + } + + if (userOrgIds.length === 0) { + logger.debug( + `Deleted all ${clientsToDelete.length} OLM client(s) for user ${userId} (user has no orgs)` + ); + } else { + logger.debug( + `Deleted ${clientsToDelete.length} orphaned OLM client(s) for user ${userId} in orgs they're no longer in` + ); + } + } +} diff --git a/server/lib/config.ts b/server/lib/config.ts index b49814f0..9874518e 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -85,10 +85,6 @@ export class Config { ? "true" : "false"; - process.env.FLAGS_ENABLE_CLIENTS = parsedConfig.flags?.enable_clients - ? "true" - : "false"; - process.env.PRODUCT_UPDATES_NOTIFICATION_ENABLED = parsedConfig.app .notifications.product_updates ? "true" diff --git a/server/lib/consts.ts b/server/lib/consts.ts index c2cf6698..b014f849 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.12.1"; +export const APP_VERSION = "1.12.3"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/lib/createUserAccountOrg.ts b/server/lib/createUserAccountOrg.ts index 1406b935..11f4e247 100644 --- a/server/lib/createUserAccountOrg.ts +++ b/server/lib/createUserAccountOrg.ts @@ -18,6 +18,7 @@ import { defaultRoleAllowedActions } from "@server/routers/role"; import { FeatureId, limitsService, sandboxLimitSet } from "@server/lib/billing"; import { createCustomer } from "#dynamic/lib/billing"; import { usageService } from "@server/lib/billing/usageService"; +import config from "@server/lib/config"; export async function createUserAccountOrg( userId: string, @@ -76,6 +77,8 @@ export async function createUserAccountOrg( .from(domains) .where(eq(domains.configManaged, true)); + const utilitySubnet = config.getRawConfig().orgs.utility_subnet_group; + const newOrg = await trx .insert(orgs) .values({ @@ -83,6 +86,7 @@ export async function createUserAccountOrg( name, // subnet subnet: "100.90.128.0/24", // TODO: this should not be hardcoded - or can it be the same in all orgs? + utilitySubnet: utilitySubnet, createdAt: new Date().toISOString() }) .returning(); diff --git a/server/lib/ip.ts b/server/lib/ip.ts index c929f025..f9b3cb61 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -1,7 +1,15 @@ -import { db } from "@server/db"; +import { + clientSitesAssociationsCache, + db, + SiteResource, + siteResources, + Transaction +} from "@server/db"; import { clients, orgs, sites } from "@server/db"; import { and, eq, isNotNull } from "drizzle-orm"; import config from "@server/lib/config"; +import z from "zod"; +import logger from "@server/logger"; interface IPRange { start: bigint; @@ -279,6 +287,56 @@ export async function getNextAvailableClientSubnet( return subnet; } +export async function getNextAvailableAliasAddress( + 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`); + } + + if (!org.utilitySubnet) { + throw new Error( + `Organization with ID ${orgId} has no utility subnet defined` + ); + } + + const existingAddresses = await db + .select({ + aliasAddress: siteResources.aliasAddress + }) + .from(siteResources) + .where( + and( + isNotNull(siteResources.aliasAddress), + eq(siteResources.orgId, orgId) + ) + ); + + const addresses = [ + ...existingAddresses.map( + (site) => `${site.aliasAddress?.split("/")[0]}/32` + ), + // reserve a /29 for the dns server and other stuff + `${org.utilitySubnet.split("/")[0]}/29` + ].filter((address) => address !== null) as string[]; + + let subnet = findNextAvailableCidr(addresses, 32, org.utilitySubnet); + if (!subnet) { + throw new Error("No available subnets remaining in space"); + } + + // remove the cidr + subnet = subnet.split("/")[0]; + + return subnet; +} + export async function getNextAvailableOrgSubnet(): Promise { const existingAddresses = await db .select({ @@ -300,3 +358,113 @@ export async function getNextAvailableOrgSubnet(): Promise { return subnet; } + +export function generateRemoteSubnets(allSiteResources: SiteResource[]): string[] { + const remoteSubnets = allSiteResources + .filter((sr) => { + if (sr.mode === "cidr") return true; + if (sr.mode === "host") { + // check if its a valid IP using zod + const ipSchema = z.union([z.ipv4(), z.ipv6()]); + const parseResult = ipSchema.safeParse(sr.destination); + return parseResult.success; + } + return false; + }) + .map((sr) => { + if (sr.mode === "cidr") return sr.destination; + if (sr.mode === "host") { + return `${sr.destination}/32`; + } + return ""; // This should never be reached due to filtering, but satisfies TypeScript + }) + .filter((subnet) => subnet !== ""); // Remove empty strings just to be safe + // remove duplicates + return Array.from(new Set(remoteSubnets)); +} + +export type Alias = { alias: string | null; aliasAddress: string | null }; + +export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] { + let aliasConfigs = allSiteResources + .filter((sr) => sr.alias && sr.aliasAddress && sr.mode == "host") + .map((sr) => ({ + alias: sr.alias, + aliasAddress: sr.aliasAddress + })); + return aliasConfigs; +} + +export type SubnetProxyTarget = { + sourcePrefix: string; // must be a cidr + destPrefix: string; // must be a cidr + rewriteTo?: string; // must be a cidr + portRange?: { + min: number; + max: number; + }[]; +}; + +export function generateSubnetProxyTargets( + siteResource: SiteResource, + clients: { + clientId: number; + pubKey: string | null; + subnet: string | null; + }[] +): SubnetProxyTarget[] { + const targets: SubnetProxyTarget[] = []; + + if (clients.length === 0) { + logger.debug( + `No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.` + ); + return []; + } + + for (const clientSite of clients) { + if (!clientSite.subnet) { + logger.debug( + `Client ${clientSite.clientId} has no subnet, skipping for site resource ${siteResource.siteResourceId}.` + ); + continue; + } + + const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`; + + if (siteResource.mode == "host") { + let destination = siteResource.destination; + // check if this is a valid ip + const ipSchema = z.union([z.ipv4(), z.ipv6()]); + if (ipSchema.safeParse(destination).success) { + destination = `${destination}/32`; + + targets.push({ + sourcePrefix: clientPrefix, + destPrefix: destination + }); + } + + if (siteResource.alias && siteResource.aliasAddress) { + // also push a match for the alias address + targets.push({ + sourcePrefix: clientPrefix, + destPrefix: `${siteResource.aliasAddress}/32`, + rewriteTo: destination + }); + } + } else if (siteResource.mode == "cidr") { + targets.push({ + sourcePrefix: clientPrefix, + destPrefix: siteResource.destination + }); + } + } + + // print a nice representation of the targets + // logger.debug( + // `Generated subnet proxy targets for: ${JSON.stringify(targets, null, 2)}` + // ); + + return targets; +} diff --git a/server/lib/lock.ts b/server/lib/lock.ts new file mode 100644 index 00000000..7eea8908 --- /dev/null +++ b/server/lib/lock.ts @@ -0,0 +1,111 @@ +export class LockManager { + /** + * Acquire a distributed lock using Redis SET with NX and PX options + * @param lockKey - Unique identifier for the lock + * @param ttlMs - Time to live in milliseconds + * @returns Promise - true if lock acquired, false otherwise + */ + async acquireLock( + lockKey: string, + ttlMs: number = 30000 + ): Promise { + return true; + } + + /** + * Release a lock using Lua script to ensure atomicity + * @param lockKey - Unique identifier for the lock + */ + async releaseLock(lockKey: string): Promise {} + + /** + * Force release a lock regardless of owner (use with caution) + * @param lockKey - Unique identifier for the lock + */ + async forceReleaseLock(lockKey: string): Promise {} + + /** + * Check if a lock exists and get its info + * @param lockKey - Unique identifier for the lock + * @returns Promise<{exists: boolean, ownedByMe: boolean, ttl: number}> + */ + async getLockInfo(lockKey: string): Promise<{ + exists: boolean; + ownedByMe: boolean; + ttl: number; + owner?: string; + }> { + return { exists: true, ownedByMe: true, ttl: 0 }; + } + + /** + * Extend the TTL of an existing lock owned by this worker + * @param lockKey - Unique identifier for the lock + * @param ttlMs - New TTL in milliseconds + * @returns Promise - true if extended successfully + */ + async extendLock(lockKey: string, ttlMs: number): Promise { + return true; + } + + /** + * Attempt to acquire lock with retries and exponential backoff + * @param lockKey - Unique identifier for the lock + * @param ttlMs - Time to live in milliseconds + * @param maxRetries - Maximum number of retry attempts + * @param baseDelayMs - Base delay between retries in milliseconds + * @returns Promise - true if lock acquired + */ + async acquireLockWithRetry( + lockKey: string, + ttlMs: number = 30000, + maxRetries: number = 5, + baseDelayMs: number = 100 + ): Promise { + return true; + } + + /** + * Execute a function while holding a lock + * @param lockKey - Unique identifier for the lock + * @param fn - Function to execute while holding the lock + * @param ttlMs - Lock TTL in milliseconds + * @returns Promise - Result of the executed function + */ + async withLock( + lockKey: string, + fn: () => Promise, + ttlMs: number = 30000 + ): Promise { + const acquired = await this.acquireLock(lockKey, ttlMs); + + if (!acquired) { + throw new Error(`Failed to acquire lock: ${lockKey}`); + } + + try { + return await fn(); + } finally { + await this.releaseLock(lockKey); + } + } + + /** + * Clean up expired locks - Redis handles this automatically, but this method + * can be used to get statistics about locks + * @returns Promise<{activeLocksCount: number, locksOwnedByMe: number}> + */ + async getLockStatistics(): Promise<{ + activeLocksCount: number; + locksOwnedByMe: number; + }> { + return { activeLocksCount: 0, locksOwnedByMe: 0 }; + } + + /** + * Close the Redis connection + */ + async disconnect(): Promise {} +} + +export const lockManager = new LockManager(); diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 2da8c0a7..ac819619 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -229,6 +229,11 @@ export const configSchema = z .default(51820) .transform(stoi) .pipe(portSchema), + clients_start_port: portSchema + .optional() + .default(21820) + .transform(stoi) + .pipe(portSchema), base_endpoint: z .string() .optional() @@ -249,12 +254,14 @@ export const configSchema = z orgs: z .object({ block_size: z.number().positive().gt(0).optional().default(24), - subnet_group: z.string().optional().default("100.90.128.0/24") + subnet_group: z.string().optional().default("100.90.128.0/24"), + utility_subnet_group: z.string().optional().default("100.96.128.0/24") //just hardcode this for now as well }) .optional() .default({ block_size: 24, - subnet_group: "100.90.128.0/24" + subnet_group: "100.90.128.0/24", + utility_subnet_group: "100.96.128.0/24" }), rate_limits: z .object({ @@ -318,8 +325,7 @@ export const configSchema = z enable_integration_api: z.boolean().optional(), disable_local_sites: z.boolean().optional(), disable_basic_wireguard_sites: z.boolean().optional(), - disable_config_managed_domains: z.boolean().optional(), - enable_clients: z.boolean().optional().default(true) + disable_config_managed_domains: z.boolean().optional() }) .optional(), dns: z diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts new file mode 100644 index 00000000..45a992cc --- /dev/null +++ b/server/lib/rebuildClientAssociations.ts @@ -0,0 +1,1243 @@ +import { + Client, + clients, + clientSiteResources, + clientSiteResourcesAssociationsCache, + clientSitesAssociationsCache, + db, + exitNodes, + newts, + olms, + roleSiteResources, + Site, + SiteResource, + siteResources, + sites, + Transaction, + userOrgs, + userSiteResources +} from "@server/db"; +import { and, eq, inArray, ne } from "drizzle-orm"; + +import { + addPeer as newtAddPeer, + deletePeer as newtDeletePeer +} from "@server/routers/newt/peers"; +import { + initPeerAddHandshake as holepunchSiteAdd, + deletePeer as olmDeletePeer +} from "@server/routers/olm/peers"; +import { sendToExitNode } from "#dynamic/lib/exitNodes"; +import logger from "@server/logger"; +import { + generateAliasConfig, + generateRemoteSubnets, + generateSubnetProxyTargets, +} from "@server/lib/ip"; +import { + addPeerData, + addTargets as addSubnetProxyTargets, + removePeerData, + removeTargets as removeSubnetProxyTargets +} from "@server/routers/client/targets"; + +export async function getClientSiteResourceAccess( + siteResource: SiteResource, + trx: Transaction | typeof db = db +) { + // get the site + const [site] = await trx + .select() + .from(sites) + .where(eq(sites.siteId, siteResource.siteId)) + .limit(1); + + if (!site) { + throw new Error(`Site with ID ${siteResource.siteId} not found`); + } + + const roleIds = await trx + .select() + .from(roleSiteResources) + .where( + eq(roleSiteResources.siteResourceId, siteResource.siteResourceId) + ) + .then((rows) => rows.map((row) => row.roleId)); + + const directUserIds = await trx + .select() + .from(userSiteResources) + .where( + eq(userSiteResources.siteResourceId, siteResource.siteResourceId) + ) + .then((rows) => rows.map((row) => row.userId)); + + // get all of the users in these roles + const userIdsFromRoles = await trx + .select({ + userId: userOrgs.userId + }) + .from(userOrgs) + .where(inArray(userOrgs.roleId, roleIds)) + .then((rows) => rows.map((row) => row.userId)); + + const newAllUserIds = Array.from( + new Set([...directUserIds, ...userIdsFromRoles]) + ); + + const newAllClients = await trx + .select({ + clientId: clients.clientId, + pubKey: clients.pubKey, + subnet: clients.subnet + }) + .from(clients) + .where( + and( + inArray(clients.userId, newAllUserIds), + eq(clients.orgId, siteResource.orgId) // filter by org to prevent cross-org associations + ) + ); + + const allClientSiteResources = await trx // this is for if a client is directly associated with a resource instead of implicitly via a user + .select() + .from(clientSiteResources) + .where( + eq(clientSiteResources.siteResourceId, siteResource.siteResourceId) + ); + + const directClientIds = allClientSiteResources.map((row) => row.clientId); + + // Get full client details for directly associated clients + const directClients = directClientIds.length > 0 + ? await trx + .select({ + clientId: clients.clientId, + pubKey: clients.pubKey, + subnet: clients.subnet + }) + .from(clients) + .where( + and( + inArray(clients.clientId, directClientIds), + eq(clients.orgId, siteResource.orgId) // filter by org to prevent cross-org associations + ) + ) + : []; + + // Merge user-based clients with directly associated clients + const allClientsMap = new Map( + [...newAllClients, ...directClients].map((c) => [c.clientId, c]) + ); + const mergedAllClients = Array.from(allClientsMap.values()); + const mergedAllClientIds = mergedAllClients.map((c) => c.clientId); + + return { + site, + mergedAllClients, + mergedAllClientIds + }; +} + +export async function rebuildClientAssociationsFromSiteResource( + siteResource: SiteResource, + trx: Transaction | typeof db = db +): Promise<{ + mergedAllClients: { + clientId: number; + pubKey: string | null; + subnet: string | null; + }[]; +}> { + const siteId = siteResource.siteId; + + const { site, mergedAllClients, mergedAllClientIds } = + await getClientSiteResourceAccess(siteResource, trx); + + /////////// process the client-siteResource associations /////////// + + // get all of the clients associated with other resources on this site + const allUpdatedClientsFromOtherResourcesOnThisSite = await trx + .select({ + clientId: clientSiteResourcesAssociationsCache.clientId + }) + .from(clientSiteResourcesAssociationsCache) + .innerJoin( + siteResources, + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResources.siteResourceId + ) + ) + .where( + and( + eq(siteResources.siteId, siteId), + ne(siteResources.siteResourceId, siteResource.siteResourceId) + ) + ); + + const allClientIdsFromOtherResourcesOnThisSite = Array.from( + new Set( + allUpdatedClientsFromOtherResourcesOnThisSite.map( + (row) => row.clientId + ) + ) + ); + + const existingClientSiteResources = await trx + .select({ + clientId: clientSiteResourcesAssociationsCache.clientId + }) + .from(clientSiteResourcesAssociationsCache) + .where( + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResource.siteResourceId + ) + ); + + const existingClientSiteResourceIds = existingClientSiteResources.map( + (row) => row.clientId + ); + + // Get full client details for existing resource clients (needed for sending delete messages) + const existingResourceClients = + existingClientSiteResourceIds.length > 0 + ? await trx + .select({ + clientId: clients.clientId, + pubKey: clients.pubKey, + subnet: clients.subnet + }) + .from(clients) + .where( + inArray(clients.clientId, existingClientSiteResourceIds) + ) + : []; + + const clientSiteResourcesToAdd = mergedAllClientIds.filter( + (clientId) => !existingClientSiteResourceIds.includes(clientId) + ); + + const clientSiteResourcesToInsert = clientSiteResourcesToAdd.map( + (clientId) => ({ + clientId, + siteResourceId: siteResource.siteResourceId + }) + ); + + if (clientSiteResourcesToInsert.length > 0) { + await trx + .insert(clientSiteResourcesAssociationsCache) + .values(clientSiteResourcesToInsert) + .returning(); + } + + const clientSiteResourcesToRemove = existingClientSiteResourceIds.filter( + (clientId) => !mergedAllClientIds.includes(clientId) + ); + + if (clientSiteResourcesToRemove.length > 0) { + await trx + .delete(clientSiteResourcesAssociationsCache) + .where( + and( + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResource.siteResourceId + ), + inArray( + clientSiteResourcesAssociationsCache.clientId, + clientSiteResourcesToRemove + ) + ) + ); + } + + /////////// process the client-site associations /////////// + + const existingClientSites = await trx + .select({ + clientId: clientSitesAssociationsCache.clientId + }) + .from(clientSitesAssociationsCache) + .where(eq(clientSitesAssociationsCache.siteId, siteResource.siteId)); + + const existingClientSiteIds = existingClientSites.map( + (row) => row.clientId + ); + + // Get full client details for existing clients (needed for sending delete messages) + const existingClients = await trx + .select({ + clientId: clients.clientId, + pubKey: clients.pubKey, + subnet: clients.subnet + }) + .from(clients) + .where(inArray(clients.clientId, existingClientSiteIds)); + + const clientSitesToAdd = mergedAllClientIds.filter( + (clientId) => + !existingClientSiteIds.includes(clientId) && + !allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource + ); + + const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({ + clientId, + siteId + })); + + if (clientSitesToInsert.length > 0) { + await trx + .insert(clientSitesAssociationsCache) + .values(clientSitesToInsert) + .returning(); + } + + // Now remove any client-site associations that should no longer exist + const clientSitesToRemove = existingClientSiteIds.filter( + (clientId) => + !mergedAllClientIds.includes(clientId) && + !allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource + ); + + if (clientSitesToRemove.length > 0) { + await trx + .delete(clientSitesAssociationsCache) + .where( + and( + eq(clientSitesAssociationsCache.siteId, siteId), + inArray( + clientSitesAssociationsCache.clientId, + clientSitesToRemove + ) + ) + ); + } + + /////////// send the messages /////////// + + // Now handle the messages to add/remove peers on both the newt and olm sides + await handleMessagesForSiteClients( + site, + siteId, + mergedAllClients, + existingClients, + clientSitesToAdd, + clientSitesToRemove, + trx + ); + + // Handle subnet proxy target updates for the resource associations + await handleSubnetProxyTargetUpdates( + siteResource, + mergedAllClients, + existingResourceClients, + clientSiteResourcesToAdd, + clientSiteResourcesToRemove, + trx + ); + + return { + mergedAllClients + }; +} + +async function handleMessagesForSiteClients( + site: Site, + siteId: number, + allClients: { + clientId: number; + pubKey: string | null; + subnet: string | null; + }[], + existingClients: { + clientId: number; + pubKey: string | null; + subnet: string | null; + }[], + clientSitesToAdd: number[], + clientSitesToRemove: number[], + trx: Transaction | typeof db = db +): Promise { + if (!site.exitNodeId) { + logger.warn( + `Exit node ID not on site ${site.siteId} so there is no reason to update clients because it must be offline` + ); + return; + } + + // get the exit node for the site + const [exitNode] = await trx + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, site.exitNodeId)) + .limit(1); + + if (!exitNode) { + logger.warn( + `Exit node not found for site ${site.siteId} so there is no reason to update clients because it must be offline` + ); + return; + } + + if (!site.publicKey) { + logger.warn( + `Site publicKey not set for site ${site.siteId} so cannot add peers to clients` + ); + return; + } + + const [newt] = await trx + .select({ + newtId: newts.newtId + }) + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); + if (!newt) { + logger.warn( + `Newt not found for site ${siteId} so cannot add peers to clients` + ); + return; + } + + const newtJobs: Promise[] = []; + const olmJobs: Promise[] = []; + const exitNodeJobs: Promise[] = []; + + // Combine all clients that need processing (those being added or removed) + const clientsToProcess = new Map< + number, + { + clientId: number; + pubKey: string | null; + subnet: string | null; + } + >(); + + // Add clients that are being added (from newAllClients) + for (const client of allClients) { + if (clientSitesToAdd.includes(client.clientId)) { + clientsToProcess.set(client.clientId, client); + } + } + + // Add clients that are being removed (from existingClients) + for (const client of existingClients) { + if (clientSitesToRemove.includes(client.clientId)) { + clientsToProcess.set(client.clientId, client); + } + } + + for (const client of clientsToProcess.values()) { + // UPDATE THE NEWT + if (!client.subnet || !client.pubKey) { + logger.debug("Client subnet, pubKey or endpoint is not set"); + continue; + } + + // is this an add or a delete? + const isAdd = clientSitesToAdd.includes(client.clientId); + const isDelete = clientSitesToRemove.includes(client.clientId); + + if (!isAdd && !isDelete) { + // nothing to do for this client + continue; + } + + const [olm] = await trx + .select({ + olmId: olms.olmId + }) + .from(olms) + .where(eq(olms.clientId, client.clientId)) + .limit(1); + if (!olm) { + logger.warn( + `Olm not found for client ${client.clientId} so cannot add/delete peers` + ); + continue; + } + + if (isDelete) { + newtJobs.push(newtDeletePeer(siteId, client.pubKey, newt.newtId)); + olmJobs.push( + olmDeletePeer( + client.clientId, + siteId, + site.publicKey, + olm.olmId + ) + ); + } + + if (isAdd) { + await holepunchSiteAdd( + // this will kick off the add peer process for the client + client.clientId, + { + siteId, + exitNode: { + publicKey: exitNode.publicKey, + endpoint: exitNode.endpoint + } + }, + olm.olmId + ); + } + + exitNodeJobs.push(updateClientSiteDestinations(client, trx)); + } + + await Promise.all(exitNodeJobs); + await Promise.all(newtJobs); // do the servers first to make sure they are ready? + await Promise.all(olmJobs); +} + +interface PeerDestination { + destinationIP: string; + destinationPort: number; +} + +// this updates the relay destinations for a client to point to all of the new sites +export async function updateClientSiteDestinations( + client: { + clientId: number; + pubKey: string | null; + subnet: string | null; + }, + trx: Transaction | typeof db = db +): Promise { + let exitNodeDestinations: { + reachableAt: string; + exitNodeId: number; + type: string; + name: string; + sourceIp: string; + sourcePort: number; + destinations: PeerDestination[]; + }[] = []; + + const sitesData = await trx + .select() + .from(sites) + .innerJoin( + clientSitesAssociationsCache, + eq(sites.siteId, clientSitesAssociationsCache.siteId) + ) + .leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId)) + .where(eq(clientSitesAssociationsCache.clientId, client.clientId)); + + for (const site of sitesData) { + if (!site.sites.subnet) { + logger.warn(`Site ${site.sites.siteId} has no subnet, skipping`); + continue; + } + + if (!site.clientSitesAssociationsCache.endpoint) { + logger.warn(`Site ${site.sites.siteId} has no endpoint, skipping`); // if this is a new association the endpoint is not set yet // TODO: FIX THIS + continue; + } + + // find the destinations in the array + let destinations = exitNodeDestinations.find( + (d) => d.reachableAt === site.exitNodes?.reachableAt + ); + + if (!destinations) { + destinations = { + reachableAt: site.exitNodes?.reachableAt || "", + exitNodeId: site.exitNodes?.exitNodeId || 0, + type: site.exitNodes?.type || "", + name: site.exitNodes?.name || "", + sourceIp: + site.clientSitesAssociationsCache.endpoint.split(":")[0] || + "", + sourcePort: + parseInt( + site.clientSitesAssociationsCache.endpoint.split(":")[1] + ) || 0, + destinations: [ + { + destinationIP: site.sites.subnet.split("/")[0], + destinationPort: site.sites.listenPort || 0 + } + ] + }; + } else { + // add to the existing destinations + destinations.destinations.push({ + destinationIP: site.sites.subnet.split("/")[0], + destinationPort: site.sites.listenPort || 0 + }); + } + + // update it in the array + exitNodeDestinations = exitNodeDestinations.filter( + (d) => d.reachableAt !== site.exitNodes?.reachableAt + ); + exitNodeDestinations.push(destinations); + } + + for (const destination of exitNodeDestinations) { + logger.info( + `Updating destinations for exit node at ${destination.reachableAt}` + ); + const payload = { + sourceIp: destination.sourceIp, + sourcePort: destination.sourcePort, + destinations: destination.destinations + }; + logger.info( + `Payload for update-destinations: ${JSON.stringify(payload, null, 2)}` + ); + + // Create an ExitNode-like object for sendToExitNode + const exitNodeForComm = { + exitNodeId: destination.exitNodeId, + type: destination.type, + reachableAt: destination.reachableAt, + name: destination.name + } as any; // Using 'as any' since we know sendToExitNode will handle this correctly + + await sendToExitNode(exitNodeForComm, { + remoteType: "remoteExitNode/update-destinations", + localPath: "/update-destinations", + method: "POST", + data: payload + }); + } +} + +async function handleSubnetProxyTargetUpdates( + siteResource: SiteResource, + allClients: { + clientId: number; + pubKey: string | null; + subnet: string | null; + }[], + existingClients: { + clientId: number; + pubKey: string | null; + subnet: string | null; + }[], + clientSiteResourcesToAdd: number[], + clientSiteResourcesToRemove: number[], + trx: Transaction | typeof db = db +): Promise { + // Get the newt for this site + const [newt] = await trx + .select() + .from(newts) + .where(eq(newts.siteId, siteResource.siteId)) + .limit(1); + + if (!newt) { + logger.warn( + `Newt not found for site ${siteResource.siteId}, skipping subnet proxy target updates` + ); + return; + } + + const proxyJobs = []; + const olmJobs = []; + // Generate targets for added associations + if (clientSiteResourcesToAdd.length > 0) { + const addedClients = allClients.filter((client) => + clientSiteResourcesToAdd.includes(client.clientId) + ); + + if (addedClients.length > 0) { + const targetsToAdd = generateSubnetProxyTargets( + siteResource, + addedClients + ); + + if (targetsToAdd.length > 0) { + logger.info( + `Adding ${targetsToAdd.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}` + ); + proxyJobs.push( + addSubnetProxyTargets(newt.newtId, targetsToAdd) + ); + } + + for (const client of addedClients) { + olmJobs.push( + addPeerData( + client.clientId, + siteResource.siteId, + generateRemoteSubnets([siteResource]), + generateAliasConfig([siteResource]) + ) + ); + } + } + } + + // here we use the existingSiteResource from BEFORE we updated the destination so we dont need to worry about updating destinations here + + // Generate targets for removed associations + if (clientSiteResourcesToRemove.length > 0) { + const removedClients = existingClients.filter((client) => + clientSiteResourcesToRemove.includes(client.clientId) + ); + + if (removedClients.length > 0) { + const targetsToRemove = generateSubnetProxyTargets( + siteResource, + removedClients + ); + + if (targetsToRemove.length > 0) { + logger.info( + `Removing ${targetsToRemove.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}` + ); + proxyJobs.push( + removeSubnetProxyTargets(newt.newtId, targetsToRemove) + ); + } + + for (const client of removedClients) { + olmJobs.push( + removePeerData( + client.clientId, + siteResource.siteId, + generateRemoteSubnets([siteResource]), + generateAliasConfig([siteResource]) + ) + ); + } + } + } + + await Promise.all(proxyJobs); +} + +export async function rebuildClientAssociationsFromClient( + client: Client, + trx: Transaction | typeof db = db +): Promise { + let newSiteResourceIds: number[] = []; + + // 1. Direct client associations + const directSiteResources = await trx + .select({ siteResourceId: clientSiteResources.siteResourceId }) + .from(clientSiteResources) + .innerJoin( + siteResources, + eq(siteResources.siteResourceId, clientSiteResources.siteResourceId) + ) + .where( + and( + eq(clientSiteResources.clientId, client.clientId), + eq(siteResources.orgId, client.orgId) // filter by org to prevent cross-org associations + ) + ); + + newSiteResourceIds.push( + ...directSiteResources.map((r) => r.siteResourceId) + ); + + // 2. User-based and role-based access (if client has a userId) + if (client.userId) { + // Direct user associations + const userSiteResourceIds = await trx + .select({ siteResourceId: userSiteResources.siteResourceId }) + .from(userSiteResources) + .innerJoin( + siteResources, + eq( + siteResources.siteResourceId, + userSiteResources.siteResourceId + ) + ) + .where( + and( + eq(userSiteResources.userId, client.userId), + eq(siteResources.orgId, client.orgId) + ) + ); // this needs to be locked onto this org or else cross-org access could happen + + newSiteResourceIds.push( + ...userSiteResourceIds.map((r) => r.siteResourceId) + ); + + // Role-based access + const roleIds = await trx + .select({ roleId: userOrgs.roleId }) + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, client.userId), + eq(userOrgs.orgId, client.orgId) + ) + ) // this needs to be locked onto this org or else cross-org access could happen + .then((rows) => rows.map((row) => row.roleId)); + + if (roleIds.length > 0) { + const roleSiteResourceIds = await trx + .select({ siteResourceId: roleSiteResources.siteResourceId }) + .from(roleSiteResources) + .innerJoin( + siteResources, + eq(siteResources.siteResourceId, roleSiteResources.siteResourceId) + ) + .where( + and( + inArray(roleSiteResources.roleId, roleIds), + eq(siteResources.orgId, client.orgId) // filter by org to prevent cross-org associations + ) + ); + + newSiteResourceIds.push( + ...roleSiteResourceIds.map((r) => r.siteResourceId) + ); + } + } + + // Remove duplicates + newSiteResourceIds = Array.from(new Set(newSiteResourceIds)); + + // Get full siteResource details + const newSiteResources = + newSiteResourceIds.length > 0 + ? await trx + .select() + .from(siteResources) + .where( + inArray(siteResources.siteResourceId, newSiteResourceIds) + ) + : []; + + // Group by siteId for site-level associations + const newSiteIds = Array.from( + new Set(newSiteResources.map((sr) => sr.siteId)) + ); + + /////////// Process client-siteResource associations /////////// + + // Get existing resource associations + const existingResourceAssociations = await trx + .select({ + siteResourceId: clientSiteResourcesAssociationsCache.siteResourceId + }) + .from(clientSiteResourcesAssociationsCache) + .where( + eq(clientSiteResourcesAssociationsCache.clientId, client.clientId) + ); + + const existingSiteResourceIds = existingResourceAssociations.map( + (r) => r.siteResourceId + ); + + const resourcesToAdd = newSiteResourceIds.filter( + (id) => !existingSiteResourceIds.includes(id) + ); + + const resourcesToRemove = existingSiteResourceIds.filter( + (id) => !newSiteResourceIds.includes(id) + ); + + // Insert new associations + if (resourcesToAdd.length > 0) { + await trx.insert(clientSiteResourcesAssociationsCache).values( + resourcesToAdd.map((siteResourceId) => ({ + clientId: client.clientId, + siteResourceId + })) + ); + } + + // Remove old associations + if (resourcesToRemove.length > 0) { + await trx + .delete(clientSiteResourcesAssociationsCache) + .where( + and( + eq( + clientSiteResourcesAssociationsCache.clientId, + client.clientId + ), + inArray( + clientSiteResourcesAssociationsCache.siteResourceId, + resourcesToRemove + ) + ) + ); + } + + /////////// Process client-site associations /////////// + + // Get existing site associations + const existingSiteAssociations = await trx + .select({ siteId: clientSitesAssociationsCache.siteId }) + .from(clientSitesAssociationsCache) + .where(eq(clientSitesAssociationsCache.clientId, client.clientId)); + + const existingSiteIds = existingSiteAssociations.map((s) => s.siteId); + + const sitesToAdd = newSiteIds.filter((id) => !existingSiteIds.includes(id)); + const sitesToRemove = existingSiteIds.filter( + (id) => !newSiteIds.includes(id) + ); + + // Insert new site associations + if (sitesToAdd.length > 0) { + await trx.insert(clientSitesAssociationsCache).values( + sitesToAdd.map((siteId) => ({ + clientId: client.clientId, + siteId + })) + ); + } + + // Remove old site associations + if (sitesToRemove.length > 0) { + await trx + .delete(clientSitesAssociationsCache) + .where( + and( + eq(clientSitesAssociationsCache.clientId, client.clientId), + inArray(clientSitesAssociationsCache.siteId, sitesToRemove) + ) + ); + } + + /////////// Send messages /////////// + + // Get the olm for this client + const [olm] = await trx + .select({ olmId: olms.olmId }) + .from(olms) + .where(eq(olms.clientId, client.clientId)) + .limit(1); + + if (!olm) { + logger.warn( + `Olm not found for client ${client.clientId}, skipping peer updates` + ); + return; + } + + // Handle messages for sites being added + await handleMessagesForClientSites( + client, + olm.olmId, + sitesToAdd, + sitesToRemove, + trx + ); + + // Handle subnet proxy target updates for resources + await handleMessagesForClientResources( + client, + newSiteResources, + resourcesToAdd, + resourcesToRemove, + trx + ); +} + +async function handleMessagesForClientSites( + client: { + clientId: number; + pubKey: string | null; + subnet: string | null; + userId: string | null; + orgId: string; + }, + olmId: string, + sitesToAdd: number[], + sitesToRemove: number[], + trx: Transaction | typeof db = db +): Promise { + if (!client.subnet || !client.pubKey) { + logger.warn( + `Client ${client.clientId} missing subnet or pubKey, skipping peer updates` + ); + return; + } + + const allSiteIds = [...sitesToAdd, ...sitesToRemove]; + if (allSiteIds.length === 0) { + return; + } + + // Get site details for all affected sites + const sitesData = await trx + .select() + .from(sites) + .leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId)) + .leftJoin(newts, eq(sites.siteId, newts.siteId)) + .where(inArray(sites.siteId, allSiteIds)); + + let newtJobs: Promise[] = []; + let olmJobs: Promise[] = []; + let exitNodeJobs: Promise[] = []; + + for (const siteData of sitesData) { + const site = siteData.sites; + const exitNode = siteData.exitNodes; + const newt = siteData.newt; + + if (!site.publicKey) { + logger.warn( + `Site ${site.siteId} missing publicKey, skipping peer updates` + ); + continue; + } + + if (!newt) { + logger.warn( + `Newt not found for site ${site.siteId}, skipping peer updates` + ); + continue; + } + + const isAdd = sitesToAdd.includes(site.siteId); + const isRemove = sitesToRemove.includes(site.siteId); + + if (isRemove) { + // Remove peer from newt + newtJobs.push( + newtDeletePeer(site.siteId, client.pubKey, newt.newtId) + ); + try { + // Remove peer from olm + olmJobs.push( + olmDeletePeer( + client.clientId, + site.siteId, + site.publicKey, + olmId + ) + ); + } catch (error) { + // if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send + if ( + error instanceof Error && + error.message.includes("not found") + ) { + logger.debug( + `Olm data not found for client ${client.clientId}, skipping removal` + ); + } else { + throw error; + } + } + } + + if (isAdd) { + if (!exitNode) { + logger.warn( + `Exit node not found for site ${site.siteId}, skipping peer add` + ); + continue; + } + + await holepunchSiteAdd( + // this will kick off the add peer process for the client + client.clientId, + { + siteId: site.siteId, + exitNode: { + publicKey: exitNode.publicKey, + endpoint: exitNode.endpoint + } + }, + olmId + ); + } + + // Update exit node destinations + exitNodeJobs.push( + updateClientSiteDestinations( + { + clientId: client.clientId, + pubKey: client.pubKey, + subnet: client.subnet + }, + trx + ) + ); + } + + await Promise.all(exitNodeJobs); + await Promise.all(newtJobs); + await Promise.all(olmJobs); +} + +async function handleMessagesForClientResources( + client: { + clientId: number; + pubKey: string | null; + subnet: string | null; + userId: string | null; + orgId: string; + }, + allNewResources: SiteResource[], + resourcesToAdd: number[], + resourcesToRemove: number[], + trx: Transaction | typeof db = db +): Promise { + // Group resources by site + const resourcesBySite = new Map(); + + for (const resource of allNewResources) { + if (!resourcesBySite.has(resource.siteId)) { + resourcesBySite.set(resource.siteId, []); + } + resourcesBySite.get(resource.siteId)!.push(resource); + } + + let proxyJobs: Promise[] = []; + let olmJobs: Promise[] = []; + + // Handle additions + if (resourcesToAdd.length > 0) { + const addedResources = allNewResources.filter((r) => + resourcesToAdd.includes(r.siteResourceId) + ); + + // Group by site for proxy updates + const addedBySite = new Map(); + for (const resource of addedResources) { + if (!addedBySite.has(resource.siteId)) { + addedBySite.set(resource.siteId, []); + } + addedBySite.get(resource.siteId)!.push(resource); + } + + // Add subnet proxy targets for each site + for (const [siteId, resources] of addedBySite.entries()) { + const [newt] = await trx + .select({ newtId: newts.newtId }) + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); + + if (!newt) { + logger.warn( + `Newt not found for site ${siteId}, skipping proxy updates` + ); + continue; + } + + for (const resource of resources) { + const targets = generateSubnetProxyTargets(resource, [ + { + clientId: client.clientId, + pubKey: client.pubKey, + subnet: client.subnet + } + ]); + + if (targets.length > 0) { + proxyJobs.push(addSubnetProxyTargets(newt.newtId, targets)); + } + + try { + // Add peer data to olm + olmJobs.push( + addPeerData( + client.clientId, + resource.siteId, + generateRemoteSubnets([resource]), + generateAliasConfig([resource]) + ) + ); + } catch (error) { + // if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send + if ( + error instanceof Error && + error.message.includes("not found") + ) { + logger.debug( + `Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal` + ); + } else { + throw error; + } + } + } + } + } + + // Handle removals + if (resourcesToRemove.length > 0) { + const removedResources = await trx + .select() + .from(siteResources) + .where(inArray(siteResources.siteResourceId, resourcesToRemove)); + + // Group by site for proxy updates + const removedBySite = new Map(); + for (const resource of removedResources) { + if (!removedBySite.has(resource.siteId)) { + removedBySite.set(resource.siteId, []); + } + removedBySite.get(resource.siteId)!.push(resource); + } + + // Remove subnet proxy targets for each site + for (const [siteId, resources] of removedBySite.entries()) { + const [newt] = await trx + .select({ newtId: newts.newtId }) + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); + + if (!newt) { + logger.warn( + `Newt not found for site ${siteId}, skipping proxy updates` + ); + continue; + } + + for (const resource of resources) { + const targets = generateSubnetProxyTargets(resource, [ + { + clientId: client.clientId, + pubKey: client.pubKey, + subnet: client.subnet + } + ]); + + if (targets.length > 0) { + proxyJobs.push( + removeSubnetProxyTargets(newt.newtId, targets) + ); + } + + try { + // Remove peer data from olm + olmJobs.push( + removePeerData( + client.clientId, + resource.siteId, + generateRemoteSubnets([resource]), + generateAliasConfig([resource]) + ) + ); + } catch (error) { + // if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send + if ( + error instanceof Error && + error.message.includes("not found") + ) { + logger.debug( + `Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal` + ); + } else { + throw error; + } + } + } + } + } + + await Promise.all([...proxyJobs, ...olmJobs]); +} diff --git a/server/lib/telemetry.ts b/server/lib/telemetry.ts index 9583cadd..13ba1c95 100644 --- a/server/lib/telemetry.ts +++ b/server/lib/telemetry.ts @@ -4,7 +4,7 @@ import { getHostMeta } from "./hostMeta"; import logger from "@server/logger"; import { apiKeys, db, roles } from "@server/db"; import { sites, users, orgs, resources, clients, idp } from "@server/db"; -import { eq, count, notInArray } from "drizzle-orm"; +import { eq, count, notInArray, and } from "drizzle-orm"; import { APP_VERSION } from "./consts"; import crypto from "crypto"; import { UserType } from "@server/types/UserTypes"; @@ -113,7 +113,12 @@ class TelemetryClient { const [customRoles] = await db .select({ count: count() }) .from(roles) - .where(notInArray(roles.name, ["Admin", "Member"])); + .where( + and( + eq(roles.isAdmin, false), + notInArray(roles.name, ["Member"]) + ) + ); const adminUsers = await db .select({ email: users.email }) diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 156f2e59..dc5e0081 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -345,9 +345,9 @@ export async function getTraefikConfig( routerMiddlewares.push(rewriteMiddlewareName); } - logger.debug( - `Created path rewrite middleware ${rewriteMiddlewareName}: ${resource.pathMatchType}(${resource.path}) -> ${resource.rewritePathType}(${resource.rewritePath})` - ); + // logger.debug( + // `Created path rewrite middleware ${rewriteMiddlewareName}: ${resource.pathMatchType}(${resource.path}) -> ${resource.rewritePathType}(${resource.rewritePath})` + // ); } catch (error) { logger.error( `Failed to create path rewrite middleware for resource ${resource.resourceId}: ${error}` diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index 66a92809..305abaa8 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -11,6 +11,7 @@ export * from "./verifyRoleAccess"; export * from "./verifyUserAccess"; export * from "./verifyAdmin"; export * from "./verifySetResourceUsers"; +export * from "./verifySetResourceClients"; export * from "./verifyUserInRole"; export * from "./verifyAccessTokenAccess"; export * from "./requestTimeout"; @@ -24,7 +25,7 @@ export * from "./integration"; export * from "./verifyUserHasAction"; export * from "./verifyApiKeyAccess"; export * from "./verifyDomainAccess"; -export * from "./verifyClientsEnabled"; export * from "./verifyUserIsOrgOwner"; export * from "./verifySiteResourceAccess"; -export * from "./logActionAudit"; \ No newline at end of file +export * from "./logActionAudit"; +export * from "./verifyOlmAccess"; diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts index 747cddee..d44eb5a3 100644 --- a/server/middlewares/integration/index.ts +++ b/server/middlewares/integration/index.ts @@ -7,6 +7,7 @@ export * from "./verifyApiKeyTargetAccess"; export * from "./verifyApiKeyRoleAccess"; export * from "./verifyApiKeyUserAccess"; export * from "./verifyApiKeySetResourceUsers"; +export * from "./verifyApiKeySetResourceClients"; export * from "./verifyAccessTokenAccess"; export * from "./verifyApiKeyIsRoot"; export * from "./verifyApiKeyApiKeyAccess"; diff --git a/server/middlewares/integration/verifyApiKeySetResourceClients.ts b/server/middlewares/integration/verifyApiKeySetResourceClients.ts new file mode 100644 index 00000000..cbcb33ae --- /dev/null +++ b/server/middlewares/integration/verifyApiKeySetResourceClients.ts @@ -0,0 +1,73 @@ +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { clients } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeySetResourceClients( + req: Request, + res: Response, + next: NextFunction +) { + const apiKey = req.apiKey; + const singleClientId = req.params.clientId || req.body.clientId || req.query.clientId; + const { clientIds } = req.body; + const allClientIds = clientIds || (singleClientId ? [parseInt(singleClientId as string)] : []); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (apiKey.isRoot) { + // Root keys can access any client in any org + return next(); + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + if (allClientIds.length === 0) { + return next(); + } + + try { + const orgId = req.apiKeyOrg.orgId; + const clientsData = await db + .select() + .from(clients) + .where( + and( + inArray(clients.clientId, allClientIds), + eq(clients.orgId, orgId) + ) + ); + + if (clientsData.length !== allClientIds.length) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to one or more specified clients" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error checking if key has access to the specified clients" + ) + ); + } +} + diff --git a/server/middlewares/integration/verifyApiKeySetResourceUsers.ts b/server/middlewares/integration/verifyApiKeySetResourceUsers.ts index 51a8f3fc..db73d134 100644 --- a/server/middlewares/integration/verifyApiKeySetResourceUsers.ts +++ b/server/middlewares/integration/verifyApiKeySetResourceUsers.ts @@ -11,7 +11,9 @@ export async function verifyApiKeySetResourceUsers( next: NextFunction ) { const apiKey = req.apiKey; - const userIds = req.body.userIds; + const singleUserId = req.params.userId || req.body.userId || req.query.userId; + const { userIds } = req.body; + const allUserIds = userIds || (singleUserId ? [singleUserId] : []); if (!apiKey) { return next( @@ -33,11 +35,7 @@ export async function verifyApiKeySetResourceUsers( ); } - if (!userIds) { - return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid user IDs")); - } - - if (userIds.length === 0) { + if (allUserIds.length === 0) { return next(); } @@ -48,12 +46,12 @@ export async function verifyApiKeySetResourceUsers( .from(userOrgs) .where( and( - inArray(userOrgs.userId, userIds), + inArray(userOrgs.userId, allUserIds), eq(userOrgs.orgId, orgId) ) ); - if (userOrgsData.length !== userIds.length) { + if (userOrgsData.length !== allUserIds.length) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/middlewares/integration/verifyApiKeySiteResourceAccess.ts b/server/middlewares/integration/verifyApiKeySiteResourceAccess.ts index cba94cd1..fb3d8287 100644 --- a/server/middlewares/integration/verifyApiKeySiteResourceAccess.ts +++ b/server/middlewares/integration/verifyApiKeySiteResourceAccess.ts @@ -13,8 +13,6 @@ export async function verifyApiKeySiteResourceAccess( try { const apiKey = req.apiKey; const siteResourceId = parseInt(req.params.siteResourceId); - const siteId = parseInt(req.params.siteId); - const orgId = req.params.orgId; if (!apiKey) { return next( @@ -22,11 +20,11 @@ export async function verifyApiKeySiteResourceAccess( ); } - if (!siteResourceId || !siteId || !orgId) { + if (!siteResourceId) { return next( createHttpError( HttpCode.BAD_REQUEST, - "Missing required parameters" + "Missing siteResourceId parameter" ) ); } @@ -41,9 +39,7 @@ export async function verifyApiKeySiteResourceAccess( .select() .from(siteResources) .where(and( - eq(siteResources.siteResourceId, siteResourceId), - eq(siteResources.siteId, siteId), - eq(siteResources.orgId, orgId) + eq(siteResources.siteResourceId, siteResourceId) )) .limit(1); @@ -64,11 +60,11 @@ export async function verifyApiKeySiteResourceAccess( .where( and( eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), - eq(apiKeyOrg.orgId, orgId) + eq(apiKeyOrg.orgId, siteResource.orgId) ) ) .limit(1); - + if (apiKeyOrgRes.length === 0) { return next( createHttpError( @@ -77,12 +73,11 @@ export async function verifyApiKeySiteResourceAccess( ) ); } - + req.apiKeyOrg = apiKeyOrgRes[0]; } // Attach the siteResource to the request for use in the next middleware/route - // @ts-ignore - Extending Request type req.siteResource = siteResource; return next(); diff --git a/server/middlewares/verifyAccessTokenAccess.ts b/server/middlewares/verifyAccessTokenAccess.ts index 92873524..033b326d 100644 --- a/server/middlewares/verifyAccessTokenAccess.ts +++ b/server/middlewares/verifyAccessTokenAccess.ts @@ -5,6 +5,7 @@ import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { canUserAccessResource } from "@server/auth/canUserAccessResource"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifyAccessTokenAccess( req: Request, @@ -96,6 +97,24 @@ export async function verifyAccessTokenAccess( req.userOrgId = resource[0].orgId!; } + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (policyCheck.error || "Unknown error") + ) + ); + } + } + const resourceAllowed = await canUserAccessResource({ userId, resourceId, diff --git a/server/middlewares/verifyAdmin.ts b/server/middlewares/verifyAdmin.ts index 60f7334c..253bfc2d 100644 --- a/server/middlewares/verifyAdmin.ts +++ b/server/middlewares/verifyAdmin.ts @@ -4,6 +4,7 @@ import { roles, userOrgs } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifyAdmin( req: Request, @@ -43,6 +44,24 @@ export async function verifyAdmin( ); } + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (policyCheck.error || "Unknown error") + ) + ); + } + } + const userRole = await db .select() .from(roles) diff --git a/server/middlewares/verifyApiKeyAccess.ts b/server/middlewares/verifyApiKeyAccess.ts index 8ab709b6..6edc5ab8 100644 --- a/server/middlewares/verifyApiKeyAccess.ts +++ b/server/middlewares/verifyApiKeyAccess.ts @@ -4,6 +4,7 @@ import { userOrgs, apiKeys, apiKeyOrg } from "@server/db"; import { and, eq, or } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifyApiKeyAccess( req: Request, @@ -84,6 +85,24 @@ export async function verifyApiKeyAccess( ); } + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (policyCheck.error || "Unknown error") + ) + ); + } + } + const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleId = userOrgRoleId; diff --git a/server/middlewares/verifyClientAccess.ts b/server/middlewares/verifyClientAccess.ts index df45b541..ab65ba2a 100644 --- a/server/middlewares/verifyClientAccess.ts +++ b/server/middlewares/verifyClientAccess.ts @@ -4,6 +4,7 @@ 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"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifyClientAccess( req: Request, @@ -75,6 +76,24 @@ export async function verifyClientAccess( ); } + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (policyCheck.error || "Unknown error") + ) + ); + } + } + const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleId = userOrgRoleId; req.userOrgId = client.orgId; diff --git a/server/middlewares/verifyClientsEnabled.ts b/server/middlewares/verifyClientsEnabled.ts deleted file mode 100644 index 6e8070da..00000000 --- a/server/middlewares/verifyClientsEnabled.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 index a6daf451..88ffe678 100644 --- a/server/middlewares/verifyDomainAccess.ts +++ b/server/middlewares/verifyDomainAccess.ts @@ -4,6 +4,7 @@ import { userOrgs, apiKeyOrg } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifyDomainAccess( req: Request, @@ -78,6 +79,24 @@ export async function verifyDomainAccess( ); } + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (policyCheck.error || "Unknown error") + ) + ); + } + } + const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleId = userOrgRoleId; diff --git a/server/middlewares/verifyOlmAccess.ts b/server/middlewares/verifyOlmAccess.ts new file mode 100644 index 00000000..7b31ae7d --- /dev/null +++ b/server/middlewares/verifyOlmAccess.ts @@ -0,0 +1,45 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { db, olms } from "@server/db"; +import { and, eq } from "drizzle-orm"; + +export async function verifyOlmAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const userId = req.user!.userId; + const olmId = req.params.olmId || req.body.olmId || req.query.olmId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + const [existingOlm] = await db + .select() + .from(olms) + .where(and(eq(olms.olmId, olmId), eq(olms.userId, userId))); + + if (!existingOlm) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this olm" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error checking if user has access to this user" + ) + ); + } +} diff --git a/server/middlewares/verifyOrgAccess.ts b/server/middlewares/verifyOrgAccess.ts index 441dd126..c5224ae5 100644 --- a/server/middlewares/verifyOrgAccess.ts +++ b/server/middlewares/verifyOrgAccess.ts @@ -47,22 +47,22 @@ export async function verifyOrgAccess( ); } - const policyCheck = await checkOrgAccessPolicy({ - orgId, - userId, - session: req.session - }); - - logger.debug("Org check policy result", { policyCheck }); - - if (!policyCheck.allowed || policyCheck.error) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") - ) - ); + if (req.orgPolicyAllowed === undefined) { + const policyCheck = await checkOrgAccessPolicy({ + orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (policyCheck.error || "Unknown error") + ) + ); + } } // User has access, attach the user's role to the request for potential future use diff --git a/server/middlewares/verifyResourceAccess.ts b/server/middlewares/verifyResourceAccess.ts index 5c88139d..9b0763ab 100644 --- a/server/middlewares/verifyResourceAccess.ts +++ b/server/middlewares/verifyResourceAccess.ts @@ -1,14 +1,10 @@ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; -import { - resources, - userOrgs, - userResources, - roleResources, -} from "@server/db"; +import { resources, userOrgs, userResources, roleResources } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifyResourceAccess( req: Request, @@ -73,6 +69,24 @@ export async function verifyResourceAccess( ); } + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (policyCheck.error || "Unknown error") + ) + ); + } + } + const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleId = userOrgRoleId; req.userOrgId = resource[0].orgId; diff --git a/server/middlewares/verifyRoleAccess.ts b/server/middlewares/verifyRoleAccess.ts index 98681644..91adf07c 100644 --- a/server/middlewares/verifyRoleAccess.ts +++ b/server/middlewares/verifyRoleAccess.ts @@ -5,6 +5,7 @@ import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifyRoleAccess( req: Request, @@ -105,6 +106,33 @@ export async function verifyRoleAccess( req.userOrgRoleId = userOrg[0].roleId; } + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (policyCheck.error || "Unknown error") + ) + ); + } + } + return next(); } catch (error) { logger.error("Error verifying role access:", error); @@ -116,4 +144,3 @@ export async function verifyRoleAccess( ); } } - diff --git a/server/middlewares/verifySession.ts b/server/middlewares/verifySession.ts index 6af34e4c..69254cc9 100644 --- a/server/middlewares/verifySession.ts +++ b/server/middlewares/verifySession.ts @@ -1,10 +1,5 @@ import { NextFunction, Response } from "express"; import ErrorResponse from "@server/types/ErrorResponse"; -import { db } from "@server/db"; -import { users } from "@server/db"; -import { eq } from "drizzle-orm"; -import createHttpError from "http-errors"; -import HttpCode from "@server/types/HttpCode"; import { verifySession } from "@server/auth/sessions/verifySession"; import { unauthorized } from "@server/auth/unauthorizedResponse"; @@ -13,24 +8,15 @@ export const verifySessionMiddleware = async ( res: Response, next: NextFunction ) => { - const { session, user } = await verifySession(req); + const { forceLogin } = req.query; + + const { session, user } = await verifySession(req, forceLogin === "true"); if (!session || !user) { return next(unauthorized()); } - const existingUser = await db - .select() - .from(users) - .where(eq(users.userId, user.userId)); - - if (!existingUser || !existingUser[0]) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "User does not exist") - ); - } - - req.user = existingUser[0]; + req.user = user; req.session = session; - next(); + return next(); }; diff --git a/server/middlewares/verifySetResourceClients.ts b/server/middlewares/verifySetResourceClients.ts new file mode 100644 index 00000000..8f9c1eca --- /dev/null +++ b/server/middlewares/verifySetResourceClients.ts @@ -0,0 +1,90 @@ +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { clients } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; + +export async function verifySetResourceClients( + req: Request, + res: Response, + next: NextFunction +) { + const userId = req.user!.userId; + const singleClientId = + req.params.clientId || req.body.clientId || req.query.clientId; + const { clientIds } = req.body; + const allClientIds = + clientIds || + (singleClientId ? [parseInt(singleClientId as string)] : []); + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (policyCheck.error || "Unknown error") + ) + ); + } + } + + if (allClientIds.length === 0) { + return next(); + } + + try { + const orgId = req.userOrg.orgId; + // get all clients for the clientIds + const clientsData = await db + .select() + .from(clients) + .where( + and( + inArray(clients.clientId, allClientIds), + eq(clients.orgId, orgId) + ) + ); + + if (clientsData.length !== allClientIds.length) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to one or more specified clients" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error checking if user has access to the specified clients" + ) + ); + } +} diff --git a/server/middlewares/verifySetResourceUsers.ts b/server/middlewares/verifySetResourceUsers.ts index be6d21fc..94600b9b 100644 --- a/server/middlewares/verifySetResourceUsers.ts +++ b/server/middlewares/verifySetResourceUsers.ts @@ -4,6 +4,7 @@ import { userOrgs } from "@server/db"; import { and, eq, inArray, or } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifySetResourceUsers( req: Request, @@ -28,6 +29,24 @@ export async function verifySetResourceUsers( ); } + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (policyCheck.error || "Unknown error") + ) + ); + } + } + if (!userIds) { return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid user IDs")); } diff --git a/server/middlewares/verifySiteAccess.ts b/server/middlewares/verifySiteAccess.ts index 6d01392f..06f06a93 100644 --- a/server/middlewares/verifySiteAccess.ts +++ b/server/middlewares/verifySiteAccess.ts @@ -1,16 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; -import { - sites, - userOrgs, - userSites, - roleSites, - roles, -} from "@server/db"; +import { sites, userOrgs, userSites, roleSites, roles } from "@server/db"; import { and, eq, or } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifySiteAccess( req: Request, @@ -82,6 +77,24 @@ export async function verifySiteAccess( ); } + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (policyCheck.error || "Unknown error") + ) + ); + } + } + const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleId = userOrgRoleId; req.userOrgId = site[0].orgId; diff --git a/server/middlewares/verifySiteResourceAccess.ts b/server/middlewares/verifySiteResourceAccess.ts index e7fefd24..ca7d37fb 100644 --- a/server/middlewares/verifySiteResourceAccess.ts +++ b/server/middlewares/verifySiteResourceAccess.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; -import { db } from "@server/db"; +import { db, roleSiteResources, userOrgs, userSiteResources } from "@server/db"; import { siteResources } from "@server/db"; import { eq, and } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifySiteResourceAccess( req: Request, @@ -12,44 +13,145 @@ export async function verifySiteResourceAccess( next: NextFunction ): Promise { try { - const siteResourceId = parseInt(req.params.siteResourceId); - const siteId = parseInt(req.params.siteId); - const orgId = req.params.orgId; + const userId = req.user!.userId; + const siteResourceId = + req.params.siteResourceId || + req.body.siteResourceId || + req.query.siteResourceId; - if (!siteResourceId || !siteId || !orgId) { + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!siteResourceId) { return next( createHttpError( HttpCode.BAD_REQUEST, - "Missing required parameters" + "Site resource ID is required" + ) + ); + } + + const siteResourceIdNum = parseInt(siteResourceId as string, 10); + if (isNaN(siteResourceIdNum)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid site resource ID" ) ); } - // Check if the site resource exists and belongs to the specified site and org const [siteResource] = await db .select() .from(siteResources) - .where(and( - eq(siteResources.siteResourceId, siteResourceId), - eq(siteResources.siteId, siteId), - eq(siteResources.orgId, orgId) - )) + .where(eq(siteResources.siteResourceId, siteResourceIdNum)) .limit(1); if (!siteResource) { return next( createHttpError( HttpCode.NOT_FOUND, - "Site resource not found" + `Site resource with ID ${siteResourceIdNum} not found` ) ); } + if (!siteResource.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Site resource with ID ${siteResourceIdNum} does not have an organization ID` + ) + ); + } + + if (!req.userOrg) { + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, siteResource.orgId) + ) + ) + .limit(1); + req.userOrg = userOrgRole[0]; + } + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (policyCheck.error || "Unknown error") + ) + ); + } + } + + const userOrgRoleId = req.userOrg.roleId; + req.userOrgRoleId = userOrgRoleId; + req.userOrgId = siteResource.orgId; + // Attach the siteResource to the request for use in the next middleware/route - // @ts-ignore - Extending Request type req.siteResource = siteResource; - next(); + const roleResourceAccess = await db + .select() + .from(roleSiteResources) + .where( + and( + eq(roleSiteResources.siteResourceId, siteResourceIdNum), + eq(roleSiteResources.roleId, userOrgRoleId) + ) + ) + .limit(1); + + if (roleResourceAccess.length > 0) { + return next(); + } + + const userResourceAccess = await db + .select() + .from(userSiteResources) + .where( + and( + eq(userSiteResources.userId, userId), + eq(userSiteResources.siteResourceId, siteResourceIdNum) + ) + ) + .limit(1); + + if (userResourceAccess.length > 0) { + return next(); + } + + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this resource" + ) + ); } catch (error) { logger.error("Error verifying site resource access:", error); return next( diff --git a/server/middlewares/verifyTargetAccess.ts b/server/middlewares/verifyTargetAccess.ts index 50563d6e..7e433fcb 100644 --- a/server/middlewares/verifyTargetAccess.ts +++ b/server/middlewares/verifyTargetAccess.ts @@ -5,6 +5,7 @@ import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { canUserAccessResource } from "../auth/canUserAccessResource"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifyTargetAccess( req: Request, @@ -102,6 +103,26 @@ export async function verifyTargetAccess( req.userOrgId = resource[0].orgId!; } + const orgId = req.userOrg.orgId; + + if (req.orgPolicyAllowed === undefined && orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (policyCheck.error || "Unknown error") + ) + ); + } + } + const resourceAllowed = await canUserAccessResource({ userId, resourceId, diff --git a/server/middlewares/verifyUser.ts b/server/middlewares/verifyUser.ts index 8fd38b24..6b491ef9 100644 --- a/server/middlewares/verifyUser.ts +++ b/server/middlewares/verifyUser.ts @@ -15,7 +15,9 @@ export const verifySessionUserMiddleware = async ( res: Response, next: NextFunction ) => { - const { session, user } = await verifySession(req); + const { forceLogin } = req.query; + + const { session, user } = await verifySession(req, forceLogin === "true"); if (!session || !user) { if (config.getRawConfig().app.log_failed_attempts) { logger.info(`User session not found. IP: ${req.ip}.`); diff --git a/server/middlewares/verifyUserAccess.ts b/server/middlewares/verifyUserAccess.ts index 3ef0f0ba..fcc4d0cb 100644 --- a/server/middlewares/verifyUserAccess.ts +++ b/server/middlewares/verifyUserAccess.ts @@ -4,6 +4,7 @@ import { userOrgs } from "@server/db"; import { and, eq, or } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifyUserAccess( req: Request, @@ -47,6 +48,24 @@ export async function verifyUserAccess( ); } + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (policyCheck.error || "Unknown error") + ) + ); + } + } + return next(); } catch (error) { return next( diff --git a/server/private/lib/config.ts b/server/private/lib/config.ts index d310d271..5337ff3f 100644 --- a/server/private/lib/config.ts +++ b/server/private/lib/config.ts @@ -45,6 +45,11 @@ export class PrivateConfig { this.rawPrivateConfig = parsedPrivateConfig; + process.env.BRANDING_HIDE_AUTH_LAYOUT_FOOTER = + this.rawPrivateConfig.branding?.hide_auth_layout_footer === true + ? "true" + : "false"; + if (this.rawPrivateConfig.branding?.colors) { process.env.BRANDING_COLORS = JSON.stringify( this.rawPrivateConfig.branding?.colors diff --git a/server/private/lib/exitNodes/exitNodes.ts b/server/private/lib/exitNodes/exitNodes.ts index 10418d5a..77149bb0 100644 --- a/server/private/lib/exitNodes/exitNodes.ts +++ b/server/private/lib/exitNodes/exitNodes.ts @@ -197,7 +197,7 @@ export async function listExitNodes(orgId: string, filterOnline = false, noCloud // // set the item in the database if it is offline // if (isActuallyOnline != node.online) { - // await db + // await trx // .update(exitNodes) // .set({ online: isActuallyOnline }) // .where(eq(exitNodes.exitNodeId, node.exitNodeId)); diff --git a/server/private/lib/lock.ts b/server/private/lib/lock.ts new file mode 100644 index 00000000..4a12063b --- /dev/null +++ b/server/private/lib/lock.ts @@ -0,0 +1,363 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { config } from "@server/lib/config"; +import logger from "@server/logger"; +import { redis } from "#private/lib/redis"; + +export class LockManager { + /** + * Acquire a distributed lock using Redis SET with NX and PX options + * @param lockKey - Unique identifier for the lock + * @param ttlMs - Time to live in milliseconds + * @returns Promise - true if lock acquired, false otherwise + */ + async acquireLock( + lockKey: string, + ttlMs: number = 30000 + ): Promise { + if (!redis || !redis.status || redis.status !== "ready") { + return true; + } + + const lockValue = `${ + config.getRawConfig().gerbil.exit_node_name + }:${Date.now()}`; + const redisKey = `lock:${lockKey}`; + + try { + // Use SET with NX (only set if not exists) and PX (expire in milliseconds) + // This is atomic and handles both setting and expiration + const result = await redis.set( + redisKey, + lockValue, + "PX", + ttlMs, + "NX" + ); + + if (result === "OK") { + logger.debug( + `Lock acquired: ${lockKey} by ${ + config.getRawConfig().gerbil.exit_node_name + }` + ); + return true; + } + + // Check if the existing lock is from this worker (reentrant behavior) + const existingValue = await redis.get(redisKey); + if ( + existingValue && + existingValue.startsWith( + `${config.getRawConfig().gerbil.exit_node_name}:` + ) + ) { + // Extend the lock TTL since it's the same worker + await redis.pexpire(redisKey, ttlMs); + logger.debug( + `Lock extended: ${lockKey} by ${ + config.getRawConfig().gerbil.exit_node_name + }` + ); + return true; + } + + return false; + } catch (error) { + logger.error(`Failed to acquire lock ${lockKey}:`, error); + return false; + } + } + + /** + * Release a lock using Lua script to ensure atomicity + * @param lockKey - Unique identifier for the lock + */ + async releaseLock(lockKey: string): Promise { + if (!redis || !redis.status || redis.status !== "ready") { + return; + } + + const redisKey = `lock:${lockKey}`; + + // Lua script to ensure we only delete the lock if it belongs to this worker + const luaScript = ` + local key = KEYS[1] + local worker_prefix = ARGV[1] + local current_value = redis.call('GET', key) + + if current_value and string.find(current_value, worker_prefix, 1, true) == 1 then + return redis.call('DEL', key) + else + return 0 + end + `; + + try { + const result = (await redis.eval( + luaScript, + 1, + redisKey, + `${config.getRawConfig().gerbil.exit_node_name}:` + )) as number; + + if (result === 1) { + logger.debug( + `Lock released: ${lockKey} by ${ + config.getRawConfig().gerbil.exit_node_name + }` + ); + } else { + logger.warn( + `Lock not released - not owned by worker: ${lockKey} by ${ + config.getRawConfig().gerbil.exit_node_name + }` + ); + } + } catch (error) { + logger.error(`Failed to release lock ${lockKey}:`, error); + } + } + + /** + * Force release a lock regardless of owner (use with caution) + * @param lockKey - Unique identifier for the lock + */ + async forceReleaseLock(lockKey: string): Promise { + if (!redis || !redis.status || redis.status !== "ready") { + return; + } + + const redisKey = `lock:${lockKey}`; + + try { + const result = await redis.del(redisKey); + if (result === 1) { + logger.debug(`Lock force released: ${lockKey}`); + } + } catch (error) { + logger.error(`Failed to force release lock ${lockKey}:`, error); + } + } + + /** + * Check if a lock exists and get its info + * @param lockKey - Unique identifier for the lock + * @returns Promise<{exists: boolean, ownedByMe: boolean, ttl: number}> + */ + async getLockInfo(lockKey: string): Promise<{ + exists: boolean; + ownedByMe: boolean; + ttl: number; + owner?: string; + }> { + if (!redis || !redis.status || redis.status !== "ready") { + return { exists: false, ownedByMe: true, ttl: 0 }; + } + + const redisKey = `lock:${lockKey}`; + + try { + const [value, ttl] = await Promise.all([ + redis.get(redisKey), + redis.pttl(redisKey) + ]); + + const exists = value !== null; + const ownedByMe = + exists && + value!.startsWith(`${config.getRawConfig().gerbil.exit_node_name}:`); + const owner = exists ? value!.split(":")[0] : undefined; + + return { + exists, + ownedByMe, + ttl: ttl > 0 ? ttl : 0, + owner + }; + } catch (error) { + logger.error(`Failed to get lock info ${lockKey}:`, error); + return { exists: false, ownedByMe: false, ttl: 0 }; + } + } + + /** + * Extend the TTL of an existing lock owned by this worker + * @param lockKey - Unique identifier for the lock + * @param ttlMs - New TTL in milliseconds + * @returns Promise - true if extended successfully + */ + async extendLock(lockKey: string, ttlMs: number): Promise { + if (!redis || !redis.status || redis.status !== "ready") { + return true; + } + + const redisKey = `lock:${lockKey}`; + + // Lua script to extend TTL only if lock is owned by this worker + const luaScript = ` + local key = KEYS[1] + local worker_prefix = ARGV[1] + local ttl = tonumber(ARGV[2]) + local current_value = redis.call('GET', key) + + if current_value and string.find(current_value, worker_prefix, 1, true) == 1 then + return redis.call('PEXPIRE', key, ttl) + else + return 0 + end + `; + + try { + const result = (await redis.eval( + luaScript, + 1, + redisKey, + `${config.getRawConfig().gerbil.exit_node_name}:`, + ttlMs.toString() + )) as number; + + if (result === 1) { + logger.debug( + `Lock extended: ${lockKey} by ${ + config.getRawConfig().gerbil.exit_node_name + } for ${ttlMs}ms` + ); + return true; + } + return false; + } catch (error) { + logger.error(`Failed to extend lock ${lockKey}:`, error); + return false; + } + } + + /** + * Attempt to acquire lock with retries and exponential backoff + * @param lockKey - Unique identifier for the lock + * @param ttlMs - Time to live in milliseconds + * @param maxRetries - Maximum number of retry attempts + * @param baseDelayMs - Base delay between retries in milliseconds + * @returns Promise - true if lock acquired + */ + async acquireLockWithRetry( + lockKey: string, + ttlMs: number = 30000, + maxRetries: number = 5, + baseDelayMs: number = 100 + ): Promise { + if (!redis || !redis.status || redis.status !== "ready") { + return true; + } + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const acquired = await this.acquireLock(lockKey, ttlMs); + + if (acquired) { + return true; + } + + if (attempt < maxRetries) { + // Exponential backoff with jitter + const delay = + baseDelayMs * Math.pow(2, attempt) + Math.random() * 100; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + logger.warn( + `Failed to acquire lock ${lockKey} after ${maxRetries + 1} attempts` + ); + return false; + } + + /** + * Execute a function while holding a lock + * @param lockKey - Unique identifier for the lock + * @param fn - Function to execute while holding the lock + * @param ttlMs - Lock TTL in milliseconds + * @returns Promise - Result of the executed function + */ + async withLock( + lockKey: string, + fn: () => Promise, + ttlMs: number = 30000 + ): Promise { + if (!redis || !redis.status || redis.status !== "ready") { + return await fn(); + } + + const acquired = await this.acquireLock(lockKey, ttlMs); + + if (!acquired) { + throw new Error(`Failed to acquire lock: ${lockKey}`); + } + + try { + return await fn(); + } finally { + await this.releaseLock(lockKey); + } + } + + /** + * Clean up expired locks - Redis handles this automatically, but this method + * can be used to get statistics about locks + * @returns Promise<{activeLocksCount: number, locksOwnedByMe: number}> + */ + async getLockStatistics(): Promise<{ + activeLocksCount: number; + locksOwnedByMe: number; + }> { + if (!redis || !redis.status || redis.status !== "ready") { + return { activeLocksCount: 0, locksOwnedByMe: 0 }; + } + + try { + const keys = await redis.keys("lock:*"); + let locksOwnedByMe = 0; + + if (keys.length > 0) { + const values = await redis.mget(...keys); + locksOwnedByMe = values.filter( + (value) => + value && + value.startsWith( + `${config.getRawConfig().gerbil.exit_node_name}:` + ) + ).length; + } + + return { + activeLocksCount: keys.length, + locksOwnedByMe + }; + } catch (error) { + logger.error("Failed to get lock statistics:", error); + return { activeLocksCount: 0, locksOwnedByMe: 0 }; + } + } + + /** + * Close the Redis connection + */ + async disconnect(): Promise { + if (!redis || !redis.status || redis.status !== "ready") { + return; + } + await redis.quit(); + } +} + +export const lockManager = new LockManager(); diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index cc12b1fb..754a5c53 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -124,6 +124,7 @@ export const privateConfigSchema = z.object({ }) ) .optional(), + hide_auth_layout_footer: z.boolean().optional().default(false), login_page: z .object({ subtitle_text: z.string().optional(), diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 5d6ea195..2d0e3ddb 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -434,9 +434,9 @@ export async function getTraefikConfig( routerMiddlewares.push(rewriteMiddlewareName); } - logger.debug( - `Created path rewrite middleware ${rewriteMiddlewareName}: ${resource.pathMatchType}(${resource.path}) -> ${resource.rewritePathType}(${resource.rewritePath})` - ); + // logger.debug( + // `Created path rewrite middleware ${rewriteMiddlewareName}: ${resource.pathMatchType}(${resource.path}) -> ${resource.rewritePathType}(${resource.rewritePath})` + // ); } catch (error) { logger.error( `Failed to create path rewrite middleware for resource ${resource.resourceId}: ${error}` diff --git a/server/private/middlewares/index.ts b/server/private/middlewares/index.ts index bb4d9c05..d6083f74 100644 --- a/server/private/middlewares/index.ts +++ b/server/private/middlewares/index.ts @@ -16,4 +16,5 @@ export * from "./verifyRemoteExitNodeAccess"; export * from "./verifyIdpAccess"; export * from "./verifyLoginPageAccess"; export * from "./logActionAudit"; -export * from "./verifySubscription"; \ No newline at end of file +export * from "./verifySubscription"; +export * from "./verifyValidLicense"; diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 5fe8d538..94c10457 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -31,7 +31,6 @@ import { verifyUserIsServerAdmin, verifySiteAccess, verifyClientAccess, - verifyClientsEnabled, } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import { @@ -437,7 +436,6 @@ authenticated.get( authenticated.post( "/re-key/:clientId/regenerate-client-secret", - verifyClientsEnabled, verifyClientAccess, verifyUserHasAction(ActionsEnum.reGenerateSecret), reKey.reGenerateClientSecret diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index f78fb592..a61f37b2 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -1043,7 +1043,7 @@ hybridRouter.get( ); } - let rules = await db + const rules = await db .select() .from(resourceRules) .where(eq(resourceRules.resourceId, resourceId)); @@ -1369,7 +1369,7 @@ const updateHolePunchSchema = z.object({ port: z.number(), timestamp: z.number(), reachableAt: z.string().optional(), - publicKey: z.string().optional() + publicKey: z.string() // this is the client public key }); hybridRouter.post( "/gerbil/update-hole-punch", @@ -1408,7 +1408,7 @@ hybridRouter.post( ); } - const { olmId, newtId, ip, port, timestamp, token, reachableAt } = + const { olmId, newtId, ip, port, timestamp, token, publicKey, reachableAt } = parsedParams.data; const destinations = await updateAndGenerateEndpointDestinations( @@ -1418,6 +1418,7 @@ hybridRouter.post( port, timestamp, token, + publicKey, exitNode, true ); @@ -1742,7 +1743,12 @@ hybridRouter.post( tls: logEntry.tls })); - await db.insert(requestAuditLog).values(logEntries); + // batch them into inserts of 100 to avoid exceeding parameter limits + const batchSize = 100; + for (let i = 0; i < logEntries.length; i += batchSize) { + const batch = logEntries.slice(i, i + batchSize); + await db.insert(requestAuditLog).values(batch); + } return response(res, { data: null, diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 21c74624..7ce378d1 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -13,13 +13,17 @@ import * as orgIdp from "#private/routers/orgIdp"; import * as org from "#private/routers/org"; +import * as logs from "#private/routers/auditLogs"; -import { Router } from "express"; import { - verifyApiKey, verifyApiKeyHasAction, verifyApiKeyIsRoot, + verifyApiKeyOrgAccess, } from "@server/middlewares"; +import { + verifyValidSubscription, + verifyValidLicense +} from "#private/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import { unauthenticated as ua, authenticated as a } from "@server/routers/integration"; @@ -42,4 +46,42 @@ authenticated.delete( verifyApiKeyHasAction(ActionsEnum.deleteIdp), logActionAudit(ActionsEnum.deleteIdp), orgIdp.deleteOrgIdp, -); \ No newline at end of file +); + +authenticated.get( + "/org/:orgId/logs/action", + verifyValidLicense, + verifyValidSubscription, + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.exportLogs), + logs.queryActionAuditLogs +); + +authenticated.get( + "/org/:orgId/logs/action/export", + verifyValidLicense, + verifyValidSubscription, + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.exportLogs), + logActionAudit(ActionsEnum.exportLogs), + logs.exportActionAuditLogs +); + +authenticated.get( + "/org/:orgId/logs/access", + verifyValidLicense, + verifyValidSubscription, + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.exportLogs), + logs.queryAccessAuditLogs +); + +authenticated.get( + "/org/:orgId/logs/access/export", + verifyValidLicense, + verifyValidSubscription, + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.exportLogs), + logActionAudit(ActionsEnum.exportLogs), + logs.exportAccessAuditLogs +); diff --git a/server/private/routers/loginPage/createLoginPage.ts b/server/private/routers/loginPage/createLoginPage.ts index 17050855..75744026 100644 --- a/server/private/routers/loginPage/createLoginPage.ts +++ b/server/private/routers/loginPage/createLoginPage.ts @@ -164,7 +164,7 @@ export async function createLoginPage( .select() .from(exitNodes) .where(and(eq(exitNodes.type, "gerbil"), eq(exitNodes.online, true))) - .limit(10); + .limit(10); } // select a random exit node diff --git a/server/private/routers/ws/ws.ts b/server/private/routers/ws/ws.ts index 0122126f..41c400cd 100644 --- a/server/private/routers/ws/ws.ts +++ b/server/private/routers/ws/ws.ts @@ -38,6 +38,7 @@ import { rateLimitService } from "#private/lib/rateLimit"; import { messageHandlers } from "@server/routers/ws/messageHandlers"; import { messageHandlers as privateMessageHandlers } from "#private/routers/ws/messageHandlers"; import { AuthenticatedWebSocket, ClientType, WSMessage, TokenPayload, WebSocketRequest, RedisMessage } from "@server/routers/ws"; +import { validateSessionToken } from "@server/auth/sessions/app"; // Merge public and private message handlers Object.assign(messageHandlers, privateMessageHandlers); @@ -370,6 +371,9 @@ const sendToClientLocal = async ( client.send(messageString); } }); + + logger.debug(`sendToClient: Message type ${message.type} sent to clientId ${clientId}`); + return true; }; @@ -478,7 +482,8 @@ const getActiveNodes = async ( // Token verification middleware const verifyToken = async ( token: string, - clientType: ClientType + clientType: ClientType, + userToken: string ): Promise => { try { if (clientType === "newt") { @@ -506,6 +511,17 @@ const verifyToken = async ( if (!existingOlm || !existingOlm[0]) { return null; } + + if (olm.userId) { // this is a user device and we need to check the user token + const { session: userSession, user } = await validateSessionToken(userToken); + if (!userSession || !user) { + return null; + } + if (user.userId !== olm.userId) { + return null; + } + } + return { client: existingOlm[0], session, clientType }; } else if (clientType === "remoteExitNode") { const { session, remoteExitNode } = @@ -652,6 +668,7 @@ const handleWSUpgrade = (server: HttpServer): void => { url.searchParams.get("token") || request.headers["sec-websocket-protocol"] || ""; + const userToken = url.searchParams.get('userToken') || ''; let clientType = url.searchParams.get( "clientType" ) as ClientType; @@ -673,7 +690,7 @@ const handleWSUpgrade = (server: HttpServer): void => { return; } - const tokenPayload = await verifyToken(token, clientType); + const tokenPayload = await verifyToken(token, clientType, userToken); if (!tokenPayload) { logger.debug( "Unauthorized connection attempt: invalid token..." @@ -792,6 +809,28 @@ if (redisManager.isRedisEnabled()) { ); } +// Disconnect a specific client and force them to reconnect +const disconnectClient = async (clientId: string): Promise => { + const mapKey = getClientMapKey(clientId); + const clients = connectedClients.get(mapKey); + + if (!clients || clients.length === 0) { + logger.debug(`No connections found for client ID: ${clientId}`); + return false; + } + + logger.info(`Disconnecting client ID: ${clientId} (${clients.length} connection(s))`); + + // Close all connections for this client + clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.close(1000, "Disconnected by server"); + } + }); + + return true; +}; + // Cleanup function for graceful shutdown const cleanup = async (): Promise => { try { @@ -829,6 +868,7 @@ export { connectedClients, hasActiveConnections, getActiveNodes, + disconnectClient, NODE_ID, cleanup }; diff --git a/server/routers/auditLogs/queryRequestAuditLog.ts b/server/routers/auditLogs/queryRequestAuditLog.ts index 6c56c186..663ad787 100644 --- a/server/routers/auditLogs/queryRequestAuditLog.ts +++ b/server/routers/auditLogs/queryRequestAuditLog.ts @@ -131,7 +131,7 @@ export function queryRequest(data: Q) { eq(requestAuditLog.resourceId, resources.resourceId) ) // TODO: Is this efficient? .where(getWhere(data)) - .orderBy(desc(requestAuditLog.timestamp), desc(requestAuditLog.id)); + .orderBy(desc(requestAuditLog.timestamp)); } export function countRequestQuery(data: Q) { diff --git a/server/routers/auth/index.ts b/server/routers/auth/index.ts index 754478fc..4600a4cc 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -13,4 +13,7 @@ export * from "./initialSetupComplete"; export * from "./validateSetupToken"; export * from "./changePassword"; export * from "./checkResourceSession"; -export * from "./securityKey"; \ No newline at end of file +export * from "./securityKey"; +export * from "./startDeviceWebAuth"; +export * from "./verifyDeviceWebAuth"; +export * from "./pollDeviceWebAuth"; \ No newline at end of file diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index 9c913054..dca8bb87 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -1,7 +1,9 @@ import { createSession, generateSessionToken, - serializeSessionCookie + invalidateSession, + serializeSessionCookie, + SESSION_COOKIE_NAME } from "@server/auth/sessions/app"; import { db, resources } from "@server/db"; import { users, securityKeys } from "@server/db"; @@ -21,11 +23,11 @@ import { UserType } from "@server/types/UserTypes"; import { logAccessAudit } from "#dynamic/lib/logAccessAudit"; export const loginBodySchema = z.strictObject({ - email: z.email().toLowerCase(), - password: z.string(), - code: z.string().optional(), - resourceGuid: z.string().optional() - }); + email: z.email().toLowerCase(), + password: z.string(), + code: z.string().optional(), + resourceGuid: z.string().optional() +}); export type LoginBody = z.infer; @@ -41,6 +43,21 @@ export async function login( res: Response, next: NextFunction ): Promise { + const { forceLogin } = req.query; + const { session: existingSession } = await verifySession( + req, + forceLogin === "true" + ); + if (existingSession) { + return response(res, { + data: null, + success: true, + error: false, + message: "Already logged in", + status: HttpCode.OK + }); + } + const parsedBody = loginBodySchema.safeParse(req.body); if (!parsedBody.success) { @@ -55,17 +72,6 @@ export async function login( const { email, password, code, resourceGuid } = parsedBody.data; try { - const { session: existingSession } = await verifySession(req); - if (existingSession) { - return response(res, { - data: null, - success: true, - error: false, - message: "Already logged in", - status: HttpCode.OK - }); - } - let resourceId: number | null = null; let orgId: string | null = null; if (resourceGuid) { @@ -225,6 +231,12 @@ export async function login( } } + // check for previous cookie value and expire it + const previousCookie = req.cookies[SESSION_COOKIE_NAME]; + if (previousCookie) { + await invalidateSession(previousCookie); + } + const token = generateSessionToken(); const sess = await createSession(token, existingUser.userId); const isSecure = req.protocol === "https"; diff --git a/server/routers/auth/pollDeviceWebAuth.ts b/server/routers/auth/pollDeviceWebAuth.ts new file mode 100644 index 00000000..9949ab42 --- /dev/null +++ b/server/routers/auth/pollDeviceWebAuth.ts @@ -0,0 +1,168 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; +import { response } from "@server/lib/response"; +import { db, deviceWebAuthCodes } from "@server/db"; +import { eq, and, gt } from "drizzle-orm"; +import { + createSession, + generateSessionToken +} from "@server/auth/sessions/app"; +import { encodeHexLowerCase } from "@oslojs/encoding"; +import { sha256 } from "@oslojs/crypto/sha2"; + +const paramsSchema = z.object({ + code: z.string().min(1, "Code is required") +}); + +export type PollDeviceWebAuthParams = z.infer; + +// Helper function to hash device code before querying database +function hashDeviceCode(code: string): string { + return encodeHexLowerCase( + sha256(new TextEncoder().encode(code)) + ); +} + +export type PollDeviceWebAuthResponse = { + verified: boolean; + token?: string; +}; + +// Helper function to extract IP from request (same as in startDeviceWebAuth) +function extractIpFromRequest(req: Request): string | undefined { + const ip = req.ip || req.socket.remoteAddress; + if (!ip) { + return undefined; + } + + // Handle IPv6 format [::1] or IPv4 format + if (ip.startsWith("[") && ip.includes("]")) { + const ipv6Match = ip.match(/\[(.*?)\]/); + if (ipv6Match) { + return ipv6Match[1]; + } + } + + // Handle IPv4 with port (split at last colon) + const lastColonIndex = ip.lastIndexOf(":"); + if (lastColonIndex !== -1) { + return ip.substring(0, lastColonIndex); + } + + return ip; +} + +export async function pollDeviceWebAuth( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedParams = paramsSchema.safeParse(req.params); + + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + try { + const { code } = parsedParams.data; + const now = Date.now(); + const requestIp = extractIpFromRequest(req); + + // Hash the code before querying + const hashedCode = hashDeviceCode(code); + + // Find the code in the database + const [deviceCode] = await db + .select() + .from(deviceWebAuthCodes) + .where(eq(deviceWebAuthCodes.code, hashedCode)) + .limit(1); + + if (!deviceCode) { + return response(res, { + data: { + verified: false + }, + success: true, + error: false, + message: "Code not found", + status: HttpCode.OK + }); + } + + // Check if code is expired + if (deviceCode.expiresAt <= now) { + return response(res, { + data: { + verified: false + }, + success: true, + error: false, + message: "Code expired", + status: HttpCode.OK + }); + } + + // Check if code is verified + if (!deviceCode.verified) { + return response(res, { + data: { + verified: false + }, + success: true, + error: false, + message: "Code not yet verified", + status: HttpCode.OK + }); + } + + // Check if userId is set (should be set when verified) + if (!deviceCode.userId) { + logger.error("Device code is verified but userId is missing", { codeId: deviceCode.codeId }); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Invalid code state" + ) + ); + } + + // Generate session token + const token = generateSessionToken(); + await createSession(token, deviceCode.userId); + + // Delete the code after successful exchange for a token + await db + .delete(deviceWebAuthCodes) + .where(eq(deviceWebAuthCodes.codeId, deviceCode.codeId)); + + return response(res, { + data: { + verified: true, + token + }, + success: true, + error: false, + message: "Code verified and session created", + status: HttpCode.OK + }); + } catch (e) { + logger.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to poll device code" + ) + ); + } +} + diff --git a/server/routers/auth/securityKey.ts b/server/routers/auth/securityKey.ts index cde2f61a..eed2328d 100644 --- a/server/routers/auth/securityKey.ts +++ b/server/routers/auth/securityKey.ts @@ -52,7 +52,7 @@ setInterval(async () => { await db .delete(webauthnChallenge) .where(lt(webauthnChallenge.expiresAt, now)); - logger.debug("Cleaned up expired security key challenges"); + // logger.debug("Cleaned up expired security key challenges"); } catch (error) { logger.error("Failed to clean up expired security key challenges", error); } diff --git a/server/routers/auth/startDeviceWebAuth.ts b/server/routers/auth/startDeviceWebAuth.ts new file mode 100644 index 00000000..925df67f --- /dev/null +++ b/server/routers/auth/startDeviceWebAuth.ts @@ -0,0 +1,156 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; +import { response } from "@server/lib/response"; +import { db, deviceWebAuthCodes } from "@server/db"; +import { alphabet, generateRandomString } from "oslo/crypto"; +import { createDate } from "oslo"; +import { TimeSpan } from "oslo"; +import { maxmindLookup } from "@server/db/maxmind"; +import { encodeHexLowerCase } from "@oslojs/encoding"; +import { sha256 } from "@oslojs/crypto/sha2"; + +const bodySchema = z.object({ + deviceName: z.string().optional(), + applicationName: z.string().min(1, "Application name is required") +}).strict(); + +export type StartDeviceWebAuthBody = z.infer; + +export type StartDeviceWebAuthResponse = { + code: string; + expiresInSeconds: number; +}; + +// Helper function to generate device code in format A1AJ-N5JD +function generateDeviceCode(): string { + const part1 = generateRandomString(4, alphabet("A-Z", "0-9")); + const part2 = generateRandomString(4, alphabet("A-Z", "0-9")); + return `${part1}-${part2}`; +} + +// Helper function to hash device code before storing in database +function hashDeviceCode(code: string): string { + return encodeHexLowerCase( + sha256(new TextEncoder().encode(code)) + ); +} + +// Helper function to extract IP from request +function extractIpFromRequest(req: Request): string | undefined { + const ip = req.ip || req.socket.remoteAddress; + if (!ip) { + return undefined; + } + + // Handle IPv6 format [::1] or IPv4 format + if (ip.startsWith("[") && ip.includes("]")) { + const ipv6Match = ip.match(/\[(.*?)\]/); + if (ipv6Match) { + return ipv6Match[1]; + } + } + + // Handle IPv4 with port (split at last colon) + const lastColonIndex = ip.lastIndexOf(":"); + if (lastColonIndex !== -1) { + return ip.substring(0, lastColonIndex); + } + + return ip; +} + +// Helper function to get city from IP (if available) +async function getCityFromIp(ip: string): Promise { + try { + if (!maxmindLookup) { + return undefined; + } + + const result = maxmindLookup.get(ip); + if (!result) { + return undefined; + } + + // MaxMind CountryResponse doesn't include city by default + // If city data is available, it would be in result.city?.names?.en + // But since we're using CountryResponse type, we'll just return undefined + // The user said "don't do this if not easy", so we'll skip city for now + return undefined; + } catch (error) { + logger.debug("Failed to get city from IP", error); + return undefined; + } +} + +export async function startDeviceWebAuth( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = bodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + try { + const { deviceName, applicationName } = parsedBody.data; + + // Generate device code + const code = generateDeviceCode(); + + // Hash the code before storing in database + const hashedCode = hashDeviceCode(code); + + // Extract IP from request + const ip = extractIpFromRequest(req); + + // Get city (optional, may return undefined) + const city = ip ? await getCityFromIp(ip) : undefined; + + // Set expiration to 5 minutes from now + const expiresAt = createDate(new TimeSpan(5, "m")).getTime(); + + // Insert into database (store hashed code) + await db.insert(deviceWebAuthCodes).values({ + code: hashedCode, + ip: ip || null, + city: city || null, + deviceName: deviceName || null, + applicationName, + expiresAt, + createdAt: Date.now() + }); + + // calculate relative expiration in seconds + const expiresInSeconds = Math.floor((expiresAt - Date.now()) / 1000); + + return response(res, { + data: { + code, + expiresInSeconds + }, + success: true, + error: false, + message: "Device web auth code generated", + status: HttpCode.OK + }); + } catch (e) { + logger.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to start device web auth" + ) + ); + } +} diff --git a/server/routers/auth/verifyDeviceWebAuth.ts b/server/routers/auth/verifyDeviceWebAuth.ts new file mode 100644 index 00000000..be0e0ff2 --- /dev/null +++ b/server/routers/auth/verifyDeviceWebAuth.ts @@ -0,0 +1,180 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; +import { response } from "@server/lib/response"; +import { db, deviceWebAuthCodes, sessions } from "@server/db"; +import { eq, and, gt } from "drizzle-orm"; +import { encodeHexLowerCase } from "@oslojs/encoding"; +import { sha256 } from "@oslojs/crypto/sha2"; +import { unauthorized } from "@server/auth/unauthorizedResponse"; + +const bodySchema = z + .object({ + code: z.string().min(1, "Code is required"), + verify: z.boolean().optional().default(false) // If false, just check and return metadata + }) + .strict(); + +// Helper function to hash device code before querying database +function hashDeviceCode(code: string): string { + return encodeHexLowerCase(sha256(new TextEncoder().encode(code))); +} + +export type VerifyDeviceWebAuthBody = z.infer; + +export type VerifyDeviceWebAuthResponse = { + success: boolean; + message: string; + metadata?: { + ip: string | null; + city: string | null; + deviceName: string | null; + applicationName: string; + createdAt: number; + }; +}; + +export async function verifyDeviceWebAuth( + req: Request, + res: Response, + next: NextFunction +): Promise { + const { user, session } = req; + if (!user || !session) { + return next(createHttpError(HttpCode.UNAUTHORIZED, "Unauthorized")); + } + + if (session.deviceAuthUsed) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Device web auth code already used for this session" + ) + ); + } + + if (!session.issuedAt) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Session issuedAt timestamp missing" + ) + ); + } + + // make sure sessions is not older than 5 minutes + const now = Date.now(); + if (now - session.issuedAt > 5 * 60 * 1000) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Session is too old to verify device web auth code" + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + try { + const { code, verify } = parsedBody.data; + const now = Date.now(); + + logger.debug("Verifying device web auth code:", { code }); + + // Hash the code before querying + const hashedCode = hashDeviceCode(code); + + // Find the code in the database that is not expired and not already verified + const [deviceCode] = await db + .select() + .from(deviceWebAuthCodes) + .where( + and( + eq(deviceWebAuthCodes.code, hashedCode), + gt(deviceWebAuthCodes.expiresAt, now), + eq(deviceWebAuthCodes.verified, false) + ) + ) + .limit(1); + + logger.debug("Device code lookup result:", deviceCode); + + if (!deviceCode) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid, expired, or already verified code" + ) + ); + } + + // If verify is false, just return metadata without verifying + if (!verify) { + return response(res, { + data: { + success: true, + message: "Code is valid", + metadata: { + ip: deviceCode.ip, + city: deviceCode.city, + deviceName: deviceCode.deviceName, + applicationName: deviceCode.applicationName, + createdAt: deviceCode.createdAt + } + }, + success: true, + error: false, + message: "Code validation successful", + status: HttpCode.OK + }); + } + + // Update the code to mark it as verified and store the user who verified it + await db + .update(deviceWebAuthCodes) + .set({ + verified: true, + userId: req.user!.userId + }) + .where(eq(deviceWebAuthCodes.codeId, deviceCode.codeId)); + + // Also update the session to mark that device auth was used + await db + .update(sessions) + .set({ + deviceAuthUsed: true + }) + .where(eq(sessions.sessionId, session.sessionId)); + + return response(res, { + data: { + success: true, + message: "Device code verified successfully" + }, + success: true, + error: false, + message: "Device code verified successfully", + status: HttpCode.OK + }); + } catch (e) { + logger.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to verify device code" + ) + ); + } +} diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index 6beb52c6..70f1fe1a 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -69,9 +69,9 @@ export async function cleanUpOldLogs(orgId: string, retentionDays: number) { ) ); - logger.debug( - `Cleaned up request audit logs older than ${retentionDays} days` - ); + // logger.debug( + // `Cleaned up request audit logs older than ${retentionDays} days` + // ); } catch (error) { logger.error("Error cleaning up old request audit logs:", error); } diff --git a/server/routers/client/createClient.ts b/server/routers/client/createClient.ts index d1346879..160006e1 100644 --- a/server/routers/client/createClient.ts +++ b/server/routers/client/createClient.ts @@ -8,8 +8,6 @@ import { roleClients, userClients, olms, - clientSites, - exitNodes, orgs, sites } from "@server/db"; @@ -21,23 +19,24 @@ 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 { isValidIP } from "@server/lib/validators"; import { isIpInCidr } from "@server/lib/ip"; -import { OpenAPITags, registry } from "@server/openApi"; import { listExitNodes } from "#dynamic/lib/exitNodes"; +import { generateId } from "@server/auth/sessions/app"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; const createClientParamsSchema = z.strictObject({ - orgId: z.string() - }); + orgId: z.string() +}); const createClientSchema = z.strictObject({ - name: z.string().min(1).max(255), - siteIds: z.array(z.int().positive()), - olmId: z.string(), - secret: z.string(), - subnet: z.string(), - type: z.enum(["olm"]) - }); + name: z.string().min(1).max(255), + olmId: z.string(), + secret: z.string(), + subnet: z.string(), + type: z.enum(["olm"]) +}); export type CreateClientBody = z.infer; @@ -46,7 +45,7 @@ export type CreateClientResponse = Client; registry.registerPath({ method: "put", path: "/org/{orgId}/client", - description: "Create a new client.", + description: "Create a new client for an organization.", tags: [OpenAPITags.Client, OpenAPITags.Org], request: { params: createClientParamsSchema, @@ -77,7 +76,7 @@ export async function createClient( ); } - const { name, type, siteIds, olmId, secret, subnet } = parsedBody.data; + const { name, type, olmId, secret, subnet } = parsedBody.data; const parsedParams = createClientParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -172,75 +171,90 @@ export async function createClient( ); } + // check if the olmId already exists + const [existingOlm] = await db + .select() + .from(olms) + .where(eq(olms.olmId, olmId)) + .limit(1); + + if (existingOlm) { + return next( + createHttpError( + HttpCode.CONFLICT, + `OLM with ID ${olmId} already exists` + ) + ); + } + + let newClient: Client | null = null; await db.transaction(async (trx) => { // TODO: more intelligent way to pick the exit node const exitNodesList = await listExitNodes(orgId); const randomExitNode = exitNodesList[Math.floor(Math.random() * exitNodesList.length)]; - const adminRole = await trx + 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(); + if (!adminRole) { return next( createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) ); } - const [newClient] = await trx + [newClient] = await trx .insert(clients) .values({ exitNodeId: randomExitNode.exitNodeId, orgId, name, subnet: updatedSubnet, - type + type, + olmId // this is to lock it to a specific olm even if the olm moves across clients }) .returning(); await trx.insert(roleClients).values({ - roleId: adminRole[0].roleId, + roleId: adminRole.roleId, clientId: newClient.clientId }); - if (req.user && req.userOrgRoleId != adminRole[0].roleId) { - // make sure the user can access the site + if (req.user && req.userOrgRoleId != adminRole.roleId) { + // make sure the user can access the client trx.insert(userClients).values({ - userId: req.user?.userId!, + 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 - })) - ); + let secretToUse = secret; + if (!secretToUse) { + secretToUse = generateId(48); } - const secretHash = await hashPassword(secret); + const secretHash = await hashPassword(secretToUse); await trx.insert(olms).values({ olmId, secretHash, + name, clientId: newClient.clientId, dateCreated: moment().toISOString() }); - return response(res, { - data: newClient, - success: true, - error: false, - message: "Site created successfully", - status: HttpCode.CREATED - }); + await rebuildClientAssociationsFromClient(newClient, trx); + }); + + return response(res, { + data: newClient, + success: true, + error: false, + message: "Site created successfully", + status: HttpCode.CREATED }); } catch (error) { logger.error(error); diff --git a/server/routers/client/createUserClient.ts b/server/routers/client/createUserClient.ts new file mode 100644 index 00000000..e5b5ea8f --- /dev/null +++ b/server/routers/client/createUserClient.ts @@ -0,0 +1,253 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { + roles, + Client, + clients, + roleClients, + userClients, + olms, + 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 { isValidIP } from "@server/lib/validators"; +import { isIpInCidr } from "@server/lib/ip"; +import { listExitNodes } from "#dynamic/lib/exitNodes"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; + +const paramsSchema = z + .object({ + orgId: z.string(), + userId: z.string() + }) + .strict(); + +const bodySchema = z + .object({ + name: z.string().min(1).max(255), + olmId: z.string(), + subnet: z.string(), + type: z.enum(["olm"]) + }) + .strict(); + +export type CreateClientAndOlmBody = z.infer; + +export type CreateClientAndOlmResponse = Client; + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/user/{userId}/client", + description: + "Create a new client for a user and associate it with an existing olm.", + tags: [OpenAPITags.Client, OpenAPITags.Org, OpenAPITags.User], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function createUserClient( + 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 { name, type, olmId, subnet } = parsedBody.data; + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, userId } = parsedParams.data; + + if (!isValidIP(subnet)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid subnet format. Please provide a valid CIDR notation." + ) + ); + } + + const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); + + if (!org) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Organization with ID ${orgId} not found` + ) + ); + } + + if (!org.subnet) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Organization with ID ${orgId} has no subnet defined` + ) + ); + } + + if (!isIpInCidr(subnet, org.subnet)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IP is not in the CIDR range of the subnet." + ) + ); + } + + const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org + + // make sure the subnet is unique + const subnetExistsClients = await db + .select() + .from(clients) + .where( + and(eq(clients.subnet, updatedSubnet), eq(clients.orgId, orgId)) + ) + .limit(1); + + if (subnetExistsClients.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Subnet ${updatedSubnet} already exists in clients` + ) + ); + } + + const subnetExistsSites = await db + .select() + .from(sites) + .where( + and(eq(sites.address, updatedSubnet), eq(sites.orgId, orgId)) + ) + .limit(1); + + if (subnetExistsSites.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Subnet ${updatedSubnet} already exists in sites` + ) + ); + } + + // check if the olmId already exists + const [existingOlm] = await db + .select() + .from(olms) + .where(eq(olms.olmId, olmId)) + .limit(1); + + if (!existingOlm) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `OLM with ID ${olmId} does not exist` + ) + ); + } + + if (existingOlm.userId !== userId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `OLM with ID ${olmId} does not belong to user with ID ${userId}` + ) + ); + } + + let newClient: Client | null = null; + await db.transaction(async (trx) => { + // TODO: more intelligent way to pick the exit node + const exitNodesList = await listExitNodes(orgId); + const randomExitNode = + exitNodesList[Math.floor(Math.random() * exitNodesList.length)]; + + const [adminRole] = await trx + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); + + if (!adminRole) { + return next( + createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) + ); + } + + [newClient] = await trx + .insert(clients) + .values({ + exitNodeId: randomExitNode.exitNodeId, + orgId, + name, + subnet: updatedSubnet, + type, + olmId, // this is to lock it to a specific olm even if the olm moves across clients + userId + }) + .returning(); + + await trx.insert(roleClients).values({ + roleId: adminRole.roleId, + clientId: newClient.clientId + }); + + trx.insert(userClients).values({ + userId, + clientId: newClient.clientId + }); + + await rebuildClientAssociationsFromClient(newClient, trx); + }); + + 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 index 1f514c65..775708ce 100644 --- a/server/routers/client/deleteClient.ts +++ b/server/routers/client/deleteClient.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; -import { clients, clientSites } from "@server/db"; +import { db, olms } from "@server/db"; +import { clients, clientSitesAssociationsCache } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -9,10 +9,12 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { sendTerminateClient } from "./terminate"; const deleteClientSchema = z.strictObject({ - clientId: z.string().transform(Number).pipe(z.int().positive()) - }); + clientId: z.string().transform(Number).pipe(z.int().positive()) +}); registry.registerPath({ method: "delete", @@ -58,16 +60,38 @@ export async function deleteClient( ); } - await db.transaction(async (trx) => { - // Delete the client-site associations first - await trx - .delete(clientSites) - .where(eq(clientSites.clientId, clientId)); + if (client.userId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Cannot delete a user client with this endpoint` + ) + ); + } + await db.transaction(async (trx) => { // Then delete the client itself - await trx + const [deletedClient] = await trx .delete(clients) - .where(eq(clients.clientId, clientId)); + .where(eq(clients.clientId, clientId)) + .returning(); + + const [olm] = await trx + .select() + .from(olms) + .where(eq(olms.clientId, clientId)) + .limit(1); + + // this is a machine client so we also delete the olm + if (!client.userId && client.olmId) { + await trx.delete(olms).where(eq(olms.olmId, client.olmId)); + } + + await rebuildClientAssociationsFromClient(deletedClient, trx); + + if (olm) { + await sendTerminateClient(deletedClient.clientId, olm.olmId); // the olmId needs to be provided because it cant look it up after deletion + } }); return response(res, { diff --git a/server/routers/client/getClient.ts b/server/routers/client/getClient.ts index a8730faf..c9d22b10 100644 --- a/server/routers/client/getClient.ts +++ b/server/routers/client/getClient.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { clients, clientSites } from "@server/db"; +import { clients, clientSitesAssociationsCache } from "@server/db"; import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -29,9 +29,9 @@ async function query(clientId: number) { // Get the siteIds associated with this client const sites = await db - .select({ siteId: clientSites.siteId }) - .from(clientSites) - .where(eq(clientSites.clientId, clientId)); + .select({ siteId: clientSitesAssociationsCache.siteId }) + .from(clientSitesAssociationsCache) + .where(eq(clientSitesAssociationsCache.clientId, clientId)); // Add the siteIds to the client object return { diff --git a/server/routers/client/index.ts b/server/routers/client/index.ts index 385c7bed..8e88c11e 100644 --- a/server/routers/client/index.ts +++ b/server/routers/client/index.ts @@ -3,4 +3,5 @@ export * from "./createClient"; export * from "./deleteClient"; export * from "./listClients"; export * from "./updateClient"; -export * from "./getClient"; \ No newline at end of file +export * from "./getClient"; +export * from "./createUserClient"; diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index dfac03a7..ff1050ef 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -1,16 +1,16 @@ -import { db, olms } from "@server/db"; +import { db, olms, users } from "@server/db"; import { clients, orgs, roleClients, sites, userClients, - clientSites + clientSitesAssociationsCache } 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 { and, count, eq, inArray, isNotNull, isNull, or, sql } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; @@ -19,7 +19,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import NodeCache from "node-cache"; import semver from "semver"; -const olmVersionCache = new NodeCache({ stdTTL: 3600 }); +const olmVersionCache = new NodeCache({ stdTTL: 3600 }); async function getLatestOlmVersion(): Promise { try { @@ -29,7 +29,7 @@ async function getLatestOlmVersion(): Promise { } const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 1500); + const timeoutId = setTimeout(() => controller.abort(), 1500); const response = await fetch( "https://api.github.com/repos/fosrl/olm/tags", @@ -94,10 +94,25 @@ const listClientsSchema = z.object({ .optional() .default("0") .transform(Number) - .pipe(z.int().nonnegative()) + .pipe(z.int().nonnegative()), + filter: z + .enum(["user", "machine"]) + .optional() }); -function queryClients(orgId: string, accessibleClientIds: number[]) { +function queryClients(orgId: string, accessibleClientIds: number[], filter?: "user" | "machine") { + const conditions = [ + inArray(clients.clientId, accessibleClientIds), + eq(clients.orgId, orgId) + ]; + + // Add filter condition based on filter type + if (filter === "user") { + conditions.push(isNotNull(clients.userId)); + } else if (filter === "machine") { + conditions.push(isNull(clients.userId)); + } + return db .select({ clientId: clients.clientId, @@ -110,17 +125,16 @@ function queryClients(orgId: string, accessibleClientIds: number[]) { orgName: orgs.name, type: clients.type, online: clients.online, - olmVersion: olms.version + olmVersion: olms.version, + userId: clients.userId, + username: users.username, + userEmail: users.email }) .from(clients) .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) .leftJoin(olms, eq(clients.clientId, olms.clientId)) - .where( - and( - inArray(clients.clientId, accessibleClientIds), - eq(clients.orgId, orgId) - ) - ); + .leftJoin(users, eq(clients.userId, users.userId)) + .where(and(...conditions)); } async function getSiteAssociations(clientIds: number[]) { @@ -128,14 +142,14 @@ async function getSiteAssociations(clientIds: number[]) { return db .select({ - clientId: clientSites.clientId, - siteId: clientSites.siteId, + clientId: clientSitesAssociationsCache.clientId, + siteId: clientSitesAssociationsCache.siteId, siteName: sites.name, siteNiceId: sites.niceId }) - .from(clientSites) - .leftJoin(sites, eq(clientSites.siteId, sites.siteId)) - .where(inArray(clientSites.clientId, clientIds)); + .from(clientSitesAssociationsCache) + .leftJoin(sites, eq(clientSitesAssociationsCache.siteId, sites.siteId)) + .where(inArray(clientSitesAssociationsCache.clientId, clientIds)); } type OlmWithUpdateAvailable = Awaited>[0] & { @@ -182,7 +196,7 @@ export async function listClients( ) ); } - const { limit, offset } = parsedQuery.data; + const { limit, offset, filter } = parsedQuery.data; const parsedParams = listClientsParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -231,18 +245,24 @@ export async function listClients( const accessibleClientIds = accessibleClients.map( (client) => client.clientId ); - const baseQuery = queryClients(orgId, accessibleClientIds); + const baseQuery = queryClients(orgId, accessibleClientIds, filter); + + // Get client count with filter + const countConditions = [ + inArray(clients.clientId, accessibleClientIds), + eq(clients.orgId, orgId) + ]; + + if (filter === "user") { + countConditions.push(isNotNull(clients.userId)); + } else if (filter === "machine") { + countConditions.push(isNull(clients.userId)); + } - // Get client count const countQuery = db .select({ count: count() }) .from(clients) - .where( - and( - inArray(clients.clientId, accessibleClientIds), - eq(clients.orgId, orgId) - ) - ); + .where(and(...countConditions)); const clientsList = await baseQuery.limit(limit).offset(offset); const totalCountResult = await countQuery; diff --git a/server/routers/client/targets.ts b/server/routers/client/targets.ts index e5c46d70..c9bb910b 100644 --- a/server/routers/client/targets.ts +++ b/server/routers/client/targets.ts @@ -1,35 +1,136 @@ import { sendToClient } from "#dynamic/routers/ws"; +import { db, olms } from "@server/db"; +import { Alias, SubnetProxyTarget } from "@server/lib/ip"; +import logger from "@server/logger"; +import { eq } from "drizzle-orm"; -export async function addTargets( - newtId: string, - destinationIp: string, - destinationPort: number, - protocol: string, - port: number -) { - const target = `${port}:${destinationIp}:${destinationPort}`; - +export async function addTargets(newtId: string, targets: SubnetProxyTarget[]) { await sendToClient(newtId, { - type: `newt/wg/${protocol}/add`, - data: { - targets: [target] // We can only use one target for WireGuard right now - } + type: `newt/wg/targets/add`, + data: targets }); } export async function removeTargets( newtId: string, - destinationIp: string, - destinationPort: number, - protocol: string, - port: number + targets: SubnetProxyTarget[] ) { - const target = `${port}:${destinationIp}:${destinationPort}`; - await sendToClient(newtId, { - type: `newt/wg/${protocol}/remove`, - data: { - targets: [target] // We can only use one target for WireGuard right now - } + type: `newt/wg/targets/remove`, + data: targets + }); +} + +export async function updateTargets( + newtId: string, + targets: { + oldTargets: SubnetProxyTarget[]; + newTargets: SubnetProxyTarget[]; + } +) { + await sendToClient(newtId, { + type: `newt/wg/targets/update`, + data: targets + }).catch((error) => { + logger.warn(`Error sending message:`, error); + }); +} + +export async function addPeerData( + clientId: number, + siteId: number, + remoteSubnets: string[], + aliases: Alias[], + olmId?: string +) { + if (!olmId) { + const [olm] = await db + .select() + .from(olms) + .where(eq(olms.clientId, clientId)) + .limit(1); + if (!olm) { + return; // ignore this because an olm might not be associated with the client anymore + } + olmId = olm.olmId; + } + + await sendToClient(olmId, { + type: `olm/wg/peer/data/add`, + data: { + siteId: siteId, + remoteSubnets: remoteSubnets, + aliases: aliases + } + }).catch((error) => { + logger.warn(`Error sending message:`, error); + }); +} + +export async function removePeerData( + clientId: number, + siteId: number, + remoteSubnets: string[], + aliases: Alias[], + olmId?: string +) { + if (!olmId) { + const [olm] = await db + .select() + .from(olms) + .where(eq(olms.clientId, clientId)) + .limit(1); + if (!olm) { + return; + } + olmId = olm.olmId; + } + + await sendToClient(olmId, { + type: `olm/wg/peer/data/remove`, + data: { + siteId: siteId, + remoteSubnets: remoteSubnets, + aliases: aliases + } + }).catch((error) => { + logger.warn(`Error sending message:`, error); + }); +} + +export async function updatePeerData( + clientId: number, + siteId: number, + remoteSubnets: { + oldRemoteSubnets: string[]; + newRemoteSubnets: string[]; + } | undefined, + aliases: { + oldAliases: Alias[]; + newAliases: Alias[]; + } | undefined, + olmId?: string +) { + if (!olmId) { + const [olm] = await db + .select() + .from(olms) + .where(eq(olms.clientId, clientId)) + .limit(1); + if (!olm) { + return; + } + olmId = olm.olmId; + } + + await sendToClient(olmId, { + type: `olm/wg/peer/data/update`, + data: { + siteId: siteId, + ...remoteSubnets, + ...aliases + } + }).catch((error) => { + logger.warn(`Error sending message:`, error); }); } diff --git a/server/routers/client/terminate.ts b/server/routers/client/terminate.ts new file mode 100644 index 00000000..dc49ef05 --- /dev/null +++ b/server/routers/client/terminate.ts @@ -0,0 +1,22 @@ +import { sendToClient } from "#dynamic/routers/ws"; +import { db, olms } from "@server/db"; +import { eq } from "drizzle-orm"; + +export async function sendTerminateClient(clientId: number, olmId?: string | null) { + if (!olmId) { + 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`); + } + olmId = olm.olmId; + } + + await sendToClient(olmId, { + type: `olm/terminate`, + data: {} + }); +} diff --git a/server/routers/client/updateClient.ts b/server/routers/client/updateClient.ts index 27f85238..e2f11a7c 100644 --- a/server/routers/client/updateClient.ts +++ b/server/routers/client/updateClient.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { Client, db, exitNodes, olms, sites } from "@server/db"; -import { clients, clientSites } from "@server/db"; +import { clients, clientSitesAssociationsCache } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -9,27 +9,14 @@ import logger from "@server/logger"; import { eq, and } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { - addPeer as newtAddPeer, - deletePeer as newtDeletePeer -} from "../newt/peers"; -import { - addPeer as olmAddPeer, - deletePeer as olmDeletePeer -} from "../olm/peers"; -import { sendToExitNode } from "#dynamic/lib/exitNodes"; -import { hashPassword } from "@server/auth/password"; const updateClientParamsSchema = z.strictObject({ - clientId: z.string().transform(Number).pipe(z.int().positive()) - }); + clientId: z.string().transform(Number).pipe(z.int().positive()) +}); const updateClientSchema = z.strictObject({ - name: z.string().min(1).max(255).optional(), - siteIds: z - .array(z.int().positive()) - .optional(), - }); + name: z.string().min(1).max(255).optional() +}); export type UpdateClientBody = z.infer; @@ -51,11 +38,6 @@ registry.registerPath({ responses: {} }); -interface PeerDestination { - destinationIP: string; - destinationPort: number; -} - export async function updateClient( req: Request, res: Response, @@ -72,7 +54,7 @@ export async function updateClient( ); } - const { name, siteIds } = parsedBody.data; + const { name } = parsedBody.data; const parsedParams = updateClientParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -86,7 +68,6 @@ export async function updateClient( const { clientId } = parsedParams.data; - // Fetch the client to make sure it exists and the user has access to it const [client] = await db .select() @@ -103,266 +84,11 @@ export async function updateClient( ); } - 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); - - const siteIdsToProcess = siteIds || []; - // Determine which sites were added and removed - sitesAdded = siteIdsToProcess.filter( - (siteId) => !existingSiteIds.includes(siteId) - ); - sitesRemoved = existingSiteIds.filter( - (siteId) => !siteIdsToProcess.includes(siteId) - ); - - let updatedClient: Client | undefined = undefined; - let sitesData: any; // TODO: define type somehow from the query below - 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 - // Remove sites that are no longer associated - for (const siteId of sitesRemoved) { - await trx - .delete(clientSites) - .where( - and( - eq(clientSites.clientId, clientId), - eq(clientSites.siteId, siteId) - ) - ); - } - - // Add new site associations - for (const siteId of sitesAdded) { - await trx.insert(clientSites).values({ - clientId, - siteId - }); - } - - // Fetch the updated client - [updatedClient] = await trx - .select() - .from(clients) - .where(eq(clients.clientId, clientId)) - .limit(1); - - // get all sites for this client and join with exit nodes with site.exitNodeId - sitesData = await trx - .select() - .from(sites) - .innerJoin(clientSites, eq(sites.siteId, clientSites.siteId)) - .leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId)) - .where(eq(clientSites.clientId, client.clientId)); - }); - - logger.info( - `Adding ${sitesAdded.length} new sites to client ${client.clientId}` - ); - for (const siteId of sitesAdded) { - if (!client.subnet || !client.pubKey) { - logger.debug("Client subnet, pubKey or endpoint is not set"); - continue; - } - - // TODO: WE NEED TO HANDLE THIS BETTER. WE ARE DEFAULTING TO RELAYING FOR NEW SITES - // BUT REALLY WE NEED TO TRACK THE USERS PREFERENCE THAT THEY CHOSE IN THE CLIENTS - // AND TRIGGER A HOLEPUNCH OR SOMETHING TO GET THE ENDPOINT AND HP TO THE NEW SITES - const isRelayed = true; - - const site = await newtAddPeer(siteId, { - publicKey: client.pubKey, - allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client - // endpoint: isRelayed ? "" : clientSite.endpoint - endpoint: isRelayed ? "" : "" // we are not HPing yet so no endpoint - }); - - if (!site) { - logger.debug("Failed to add peer to newt - missing site"); - continue; - } - - if (!site.endpoint || !site.publicKey) { - logger.debug("Site endpoint or publicKey is not set"); - continue; - } - - let endpoint; - - if (isRelayed) { - if (!site.exitNodeId) { - logger.warn( - `Site ${site.siteId} has no exit node, skipping` - ); - return null; - } - - // get the exit node for the site - const [exitNode] = await db - .select() - .from(exitNodes) - .where(eq(exitNodes.exitNodeId, site.exitNodeId)) - .limit(1); - - if (!exitNode) { - logger.warn(`Exit node not found for site ${site.siteId}`); - return null; - } - - endpoint = `${exitNode.endpoint}:21820`; - } else { - if (!site.endpoint) { - logger.warn( - `Site ${site.siteId} has no endpoint, skipping` - ); - return null; - } - endpoint = site.endpoint; - } - - await olmAddPeer(client.clientId, { - siteId: site.siteId, - endpoint: endpoint, - publicKey: site.publicKey, - serverIP: site.address, - serverPort: site.listenPort, - remoteSubnets: site.remoteSubnets - }); - } - - logger.info( - `Removing ${sitesRemoved.length} sites from client ${client.clientId}` - ); - for (const siteId of sitesRemoved) { - if (!client.pubKey) { - logger.debug("Client pubKey is not set"); - continue; - } - const site = await newtDeletePeer(siteId, client.pubKey); - if (!site) { - logger.debug("Failed to delete peer from newt - missing site"); - continue; - } - if (!site.endpoint || !site.publicKey) { - logger.debug("Site endpoint or publicKey is not set"); - continue; - } - await olmDeletePeer(client.clientId, site.siteId, site.publicKey); - } - - if (!updatedClient || !sitesData) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - `Failed to update client` - ) - ); - } - - let exitNodeDestinations: { - reachableAt: string; - exitNodeId: number; - type: string; - name: string; - sourceIp: string; - sourcePort: number; - destinations: PeerDestination[]; - }[] = []; - - for (const site of sitesData) { - if (!site.sites.subnet) { - logger.warn( - `Site ${site.sites.siteId} has no subnet, skipping` - ); - continue; - } - - if (!site.clientSites.endpoint) { - logger.warn( - `Site ${site.sites.siteId} has no endpoint, skipping` - ); - continue; - } - - // find the destinations in the array - let destinations = exitNodeDestinations.find( - (d) => d.reachableAt === site.exitNodes?.reachableAt - ); - - if (!destinations) { - destinations = { - reachableAt: site.exitNodes?.reachableAt || "", - exitNodeId: site.exitNodes?.exitNodeId || 0, - type: site.exitNodes?.type || "", - name: site.exitNodes?.name || "", - sourceIp: site.clientSites.endpoint.split(":")[0] || "", - sourcePort: - parseInt(site.clientSites.endpoint.split(":")[1]) || 0, - destinations: [ - { - destinationIP: site.sites.subnet.split("/")[0], - destinationPort: site.sites.listenPort || 0 - } - ] - }; - } else { - // add to the existing destinations - destinations.destinations.push({ - destinationIP: site.sites.subnet.split("/")[0], - destinationPort: site.sites.listenPort || 0 - }); - } - - // update it in the array - exitNodeDestinations = exitNodeDestinations.filter( - (d) => d.reachableAt !== site.exitNodes?.reachableAt - ); - exitNodeDestinations.push(destinations); - } - - for (const destination of exitNodeDestinations) { - logger.info( - `Updating destinations for exit node at ${destination.reachableAt}` - ); - const payload = { - sourceIp: destination.sourceIp, - sourcePort: destination.sourcePort, - destinations: destination.destinations - }; - logger.info( - `Payload for update-destinations: ${JSON.stringify(payload, null, 2)}` - ); - - // Create an ExitNode-like object for sendToExitNode - const exitNodeForComm = { - exitNodeId: destination.exitNodeId, - type: destination.type, - reachableAt: destination.reachableAt, - name: destination.name - } as any; // Using 'as any' since we know sendToExitNode will handle this correctly - - await sendToExitNode(exitNodeForComm, { - remoteType: "remoteExitNode/update-destinations", - localPath: "/update-destinations", - method: "POST", - data: payload - }); - } + const updatedClient = await db + .update(clients) + .set({ name }) + .where(eq(clients.clientId, clientId)) + .returning(); return response(res, { data: updatedClient, diff --git a/server/routers/external.ts b/server/routers/external.ts index 13553b3f..188654bc 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -16,6 +16,8 @@ import * as idp from "./idp"; import * as blueprints from "./blueprints"; import * as apiKeys from "./apiKeys"; import * as logs from "./auditLogs"; +import * as newt from "./newt"; +import * as olm from "./olm"; import HttpCode from "@server/types/HttpCode"; import { verifyAccessTokenAccess, @@ -27,6 +29,7 @@ import { verifyTargetAccess, verifyRoleAccess, verifySetResourceUsers, + verifySetResourceClients, verifyUserAccess, getUserOrgs, verifyUserIsServerAdmin, @@ -34,14 +37,12 @@ import { verifyClientAccess, verifyApiKeyAccess, verifyDomainAccess, - verifyClientsEnabled, verifyUserHasAction, verifyUserIsOrgOwner, - verifySiteResourceAccess + verifySiteResourceAccess, + verifyOlmAccess } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; -import { createNewt, getNewtToken } from "./newt"; -import { getOlmToken } from "./olm"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import createHttpError from "http-errors"; import { build } from "@server/build"; @@ -129,7 +130,6 @@ authenticated.get( authenticated.get( "/org/:orgId/pick-client-defaults", - verifyClientsEnabled, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createClient), client.pickClientDefaults @@ -137,7 +137,6 @@ authenticated.get( authenticated.get( "/org/:orgId/clients", - verifyClientsEnabled, verifyOrgAccess, verifyUserHasAction(ActionsEnum.listClients), client.listClients @@ -145,7 +144,6 @@ authenticated.get( authenticated.get( "/client/:clientId", - verifyClientsEnabled, verifyClientAccess, verifyUserHasAction(ActionsEnum.getClient), client.getClient @@ -153,16 +151,15 @@ authenticated.get( authenticated.put( "/org/:orgId/client", - verifyClientsEnabled, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createClient), logActionAudit(ActionsEnum.createClient), client.createClient ); +// TODO: Separate into a deleteUserClient (for user clients) and deleteClient (for machine clients) authenticated.delete( "/client/:clientId", - verifyClientsEnabled, verifyClientAccess, verifyUserHasAction(ActionsEnum.deleteClient), logActionAudit(ActionsEnum.deleteClient), @@ -171,7 +168,6 @@ authenticated.delete( 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 logActionAudit(ActionsEnum.updateClient), @@ -286,6 +282,72 @@ authenticated.delete( siteResource.deleteSiteResource ); +authenticated.get( + "/site-resource/:siteResourceId/roles", + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.listResourceRoles), + siteResource.listSiteResourceRoles +); + +authenticated.get( + "/site-resource/:siteResourceId/users", + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.listResourceUsers), + siteResource.listSiteResourceUsers +); + +authenticated.get( + "/site-resource/:siteResourceId/clients", + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.listResourceUsers), + siteResource.listSiteResourceClients +); + +authenticated.post( + "/site-resource/:siteResourceId/roles", + verifySiteResourceAccess, + verifyRoleAccess, + verifyUserHasAction(ActionsEnum.setResourceRoles), + logActionAudit(ActionsEnum.setResourceRoles), + siteResource.setSiteResourceRoles, +); + +authenticated.post( + "/site-resource/:siteResourceId/users", + verifySiteResourceAccess, + verifySetResourceUsers, + verifyUserHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + siteResource.setSiteResourceUsers, +); + +authenticated.post( + "/site-resource/:siteResourceId/clients", + verifySiteResourceAccess, + verifySetResourceClients, + verifyUserHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + siteResource.setSiteResourceClients, +); + +authenticated.post( + "/site-resource/:siteResourceId/clients/add", + verifySiteResourceAccess, + verifySetResourceClients, + verifyUserHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + siteResource.addClientToSiteResource, +); + +authenticated.post( + "/site-resource/:siteResourceId/clients/remove", + verifySiteResourceAccess, + verifySetResourceClients, + verifyUserHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + siteResource.removeClientFromSiteResource, +); + authenticated.put( "/org/:orgId/resource", verifyOrgAccess, @@ -649,9 +711,15 @@ unauthenticated.get( // ); unauthenticated.get("/user", verifySessionMiddleware, user.getUser); +unauthenticated.get("/my-device", verifySessionMiddleware, user.myDevice); authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers); authenticated.get("/user/:userId", verifyUserIsServerAdmin, user.adminGetUser); +authenticated.post( + "/user/:userId/generate-password-reset-code", + verifyUserIsServerAdmin, + user.adminGeneratePasswordResetCode +); authenticated.delete( "/user/:userId", verifyUserIsServerAdmin, @@ -734,6 +802,32 @@ authenticated.delete( // createNewt // ); +authenticated.put( + "/user/:userId/olm", + verifyIsLoggedInUser, + olm.createUserOlm +); + +authenticated.get( + "/user/:userId/olms", + verifyIsLoggedInUser, + olm.listUserOlms +); + +authenticated.delete( + "/user/:userId/olm/:olmId", + verifyIsLoggedInUser, + verifyOlmAccess, + olm.deleteUserOlm +); + +authenticated.get( + "/user/:userId/olm/:olmId", + verifyIsLoggedInUser, + verifyOlmAccess, + olm.getUserOlm +); + authenticated.put( "/idp/oidc", verifyUserIsServerAdmin, @@ -993,7 +1087,7 @@ authRouter.post( }, store: createStore() }), - getNewtToken + newt.getNewtToken ); authRouter.post( "/olm/get-token", @@ -1008,7 +1102,7 @@ authRouter.post( }, store: createStore() }), - getOlmToken + olm.getOlmToken ); authRouter.post( @@ -1253,3 +1347,51 @@ authRouter.delete( }), auth.deleteSecurityKey ); + +authRouter.post( + "/device-web-auth/start", + rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 30, // Allow 30 device auth code requests per 15 minutes per IP + keyGenerator: (req) => + `deviceWebAuthStart:${ipKeyGenerator(req.ip || "")}`, + handler: (req, res, next) => { + const message = `You can only request a device auth code ${30} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + auth.startDeviceWebAuth +); + +authRouter.get( + "/device-web-auth/poll/:code", + rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: 60, // Allow 60 polling requests per minute per IP (poll every second) + keyGenerator: (req) => + `deviceWebAuthPoll:${ipKeyGenerator(req.ip || "")}:${req.params.code}`, + handler: (req, res, next) => { + const message = `You can only poll a device auth code ${60} times per minute. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + auth.pollDeviceWebAuth +); + +authenticated.post( + "/device-web-auth/verify", + rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 50, // Allow 50 verification attempts per 15 minutes per user + keyGenerator: (req) => + `deviceWebAuthVerify:${req.user?.userId || ipKeyGenerator(req.ip || "")}`, + handler: (req, res, next) => { + const message = `You can only verify a device auth code ${50} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + auth.verifyDeviceWebAuth +); diff --git a/server/routers/gerbil/getAllRelays.ts b/server/routers/gerbil/getAllRelays.ts index 6eaf87e2..b7d33b95 100644 --- a/server/routers/gerbil/getAllRelays.ts +++ b/server/routers/gerbil/getAllRelays.ts @@ -7,7 +7,7 @@ import { olms, Site, sites, - clientSites, + clientSitesAssociationsCache, ExitNode } from "@server/db"; import { db } from "@server/db"; @@ -109,8 +109,8 @@ export async function generateRelayMappings(exitNode: ExitNode) { // Find all clients associated with this site through clientSites const clientSitesRes = await db .select() - .from(clientSites) - .where(eq(clientSites.siteId, site.siteId)); + .from(clientSitesAssociationsCache) + .where(eq(clientSitesAssociationsCache.siteId, site.siteId)); for (const clientSite of clientSitesRes) { if (!clientSite.endpoint) { diff --git a/server/routers/gerbil/updateHolePunch.ts b/server/routers/gerbil/updateHolePunch.ts index 34bc2c6b..e1fa7c4c 100644 --- a/server/routers/gerbil/updateHolePunch.ts +++ b/server/routers/gerbil/updateHolePunch.ts @@ -6,7 +6,7 @@ import { olms, Site, sites, - clientSites, + clientSitesAssociationsCache, exitNodes, ExitNode } from "@server/db"; @@ -19,6 +19,8 @@ import { fromError } from "zod-validation-error"; import { validateNewtSessionToken } from "@server/auth/sessions/newt"; import { validateOlmSessionToken } from "@server/auth/sessions/olm"; import { checkExitNodeOrg } from "#dynamic/lib/exitNodes"; +import { updatePeer as updateOlmPeer } from "../olm/peers"; +import { updatePeer as updateNewtPeer } from "../newt/peers"; // Define Zod schema for request validation const updateHolePunchSchema = z.object({ @@ -28,8 +30,9 @@ const updateHolePunchSchema = z.object({ ip: z.string(), port: z.number(), timestamp: z.number(), + publicKey: z.string(), reachableAt: z.string().optional(), - publicKey: z.string().optional() + exitNodePublicKey: z.string().optional() }); // New response type with multi-peer destination support @@ -63,23 +66,26 @@ export async function updateHolePunch( timestamp, token, reachableAt, - publicKey + publicKey, // this is the client's current public key for this session + exitNodePublicKey } = parsedParams.data; let exitNode: ExitNode | undefined; - if (publicKey) { + if (exitNodePublicKey) { // Get the exit node by public key [exitNode] = await db .select() .from(exitNodes) - .where(eq(exitNodes.publicKey, publicKey)); + .where(eq(exitNodes.publicKey, exitNodePublicKey)); } else { // FOR BACKWARDS COMPATIBILITY IF GERBIL IS STILL =<1.1.0 [exitNode] = await db.select().from(exitNodes).limit(1); } if (!exitNode) { - logger.warn(`Exit node not found for publicKey: ${publicKey}`); + logger.warn( + `Exit node not found for publicKey: ${exitNodePublicKey}` + ); return next( createHttpError(HttpCode.NOT_FOUND, "Exit node not found") ); @@ -92,12 +98,13 @@ export async function updateHolePunch( port, timestamp, token, + publicKey, exitNode ); - logger.debug( - `Returning ${destinations.length} peer destinations for olmId: ${olmId} or newtId: ${newtId}: ${JSON.stringify(destinations, null, 2)}` - ); + // logger.debug( + // `Returning ${destinations.length} peer destinations for olmId: ${olmId} or newtId: ${newtId}: ${JSON.stringify(destinations, null, 2)}` + // ); // Return the new multi-peer structure return res.status(HttpCode.OK).send({ @@ -121,6 +128,7 @@ export async function updateAndGenerateEndpointDestinations( port: number, timestamp: number, token: string, + publicKey: string, exitNode: ExitNode, checkOrg = false ) { @@ -128,9 +136,9 @@ export async function updateAndGenerateEndpointDestinations( const destinations: PeerDestination[] = []; if (olmId) { - logger.debug( - `Got hole punch with ip: ${ip}, port: ${port} for olmId: ${olmId}` - ); + // logger.debug( + // `Got hole punch with ip: ${ip}, port: ${port} for olmId: ${olmId}` + // ); const { session, olm: olmSession } = await validateOlmSessionToken(token); @@ -150,7 +158,7 @@ export async function updateAndGenerateEndpointDestinations( throw new Error("Olm not found"); } - const [client] = await db + const [updatedClient] = await db .update(clients) .set({ lastHolePunch: timestamp @@ -158,10 +166,16 @@ export async function updateAndGenerateEndpointDestinations( .where(eq(clients.clientId, olm.clientId)) .returning(); - if (await checkExitNodeOrg(exitNode.exitNodeId, client.orgId) && checkOrg) { + if ( + (await checkExitNodeOrg( + exitNode.exitNodeId, + updatedClient.orgId + )) && + checkOrg + ) { // not allowed logger.warn( - `Exit node ${exitNode.exitNodeId} is not allowed for org ${client.orgId}` + `Exit node ${exitNode.exitNodeId} is not allowed for org ${updatedClient.orgId}` ); throw new Error("Exit node not allowed"); } @@ -171,40 +185,70 @@ export async function updateAndGenerateEndpointDestinations( .select({ siteId: sites.siteId, subnet: sites.subnet, - listenPort: sites.listenPort + listenPort: sites.listenPort, + publicKey: sites.publicKey, + endpoint: clientSitesAssociationsCache.endpoint }) .from(sites) - .innerJoin(clientSites, eq(sites.siteId, clientSites.siteId)) + .innerJoin( + clientSitesAssociationsCache, + eq(sites.siteId, clientSitesAssociationsCache.siteId) + ) .where( and( eq(sites.exitNodeId, exitNode.exitNodeId), - eq(clientSites.clientId, olm.clientId) + eq(clientSitesAssociationsCache.clientId, olm.clientId) ) ); // Update clientSites for each site on this exit node for (const site of sitesOnExitNode) { - logger.debug( - `Updating site ${site.siteId} on exit node ${exitNode.exitNodeId}` - ); + // logger.debug( + // `Updating site ${site.siteId} on exit node ${exitNode.exitNodeId}` + // ); - await db - .update(clientSites) + // if the public key or endpoint has changed, update it otherwise continue + if ( + site.endpoint === `${ip}:${port}` && + site.publicKey === publicKey + ) { + continue; + } + + const [updatedClientSitesAssociationsCache] = await db + .update(clientSitesAssociationsCache) .set({ - endpoint: `${ip}:${port}` + endpoint: `${ip}:${port}`, + publicKey: publicKey }) .where( and( - eq(clientSites.clientId, olm.clientId), - eq(clientSites.siteId, site.siteId) + eq(clientSitesAssociationsCache.clientId, olm.clientId), + eq(clientSitesAssociationsCache.siteId, site.siteId) ) + ) + .returning(); + + if ( + updatedClientSitesAssociationsCache.endpoint !== + site.endpoint && // this is the endpoint from the join table not the site + updatedClient.pubKey === publicKey // only trigger if the client's public key matches the current public key which means it has registered so we dont prematurely send the update + ) { + logger.info( + `ClientSitesAssociationsCache for client ${olm.clientId} and site ${site.siteId} endpoint changed from ${site.endpoint} to ${updatedClientSitesAssociationsCache.endpoint}` ); + // Handle any additional logic for endpoint change + handleClientEndpointChange( + olm.clientId, + updatedClientSitesAssociationsCache.endpoint! + ); + } } - logger.debug( - `Updated ${sitesOnExitNode.length} sites on exit node ${exitNode.exitNodeId}` - ); - if (!client) { + // logger.debug( + // `Updated ${sitesOnExitNode.length} sites on exit node ${exitNode.exitNodeId}` + // ); + if (!updatedClient) { logger.warn(`Client not found for olm: ${olmId}`); throw new Error("Client not found"); } @@ -219,9 +263,9 @@ export async function updateAndGenerateEndpointDestinations( } } } else if (newtId) { - logger.debug( - `Got hole punch with ip: ${ip}, port: ${port} for newtId: ${newtId}` - ); + // logger.debug( + // `Got hole punch with ip: ${ip}, port: ${port} for newtId: ${newtId}` + // ); const { session, newt: newtSession } = await validateNewtSessionToken(token); @@ -253,7 +297,10 @@ export async function updateAndGenerateEndpointDestinations( .where(eq(sites.siteId, newt.siteId)) .limit(1); - if (await checkExitNodeOrg(exitNode.exitNodeId, site.orgId) && checkOrg) { + if ( + (await checkExitNodeOrg(exitNode.exitNodeId, site.orgId)) && + checkOrg + ) { // not allowed logger.warn( `Exit node ${exitNode.exitNodeId} is not allowed for org ${site.orgId}` @@ -273,6 +320,18 @@ export async function updateAndGenerateEndpointDestinations( .where(eq(sites.siteId, newt.siteId)) .returning(); + if ( + updatedSite.endpoint != site.endpoint && + updatedSite.publicKey == publicKey + ) { + // only trigger if the site's public key matches the current public key which means it has registered so we dont prematurely send the update + logger.info( + `Site ${newt.siteId} endpoint changed from ${site.endpoint} to ${updatedSite.endpoint}` + ); + // Handle any additional logic for endpoint change + handleSiteEndpointChange(newt.siteId, updatedSite.endpoint!); + } + if (!updatedSite || !updatedSite.subnet) { logger.warn(`Site not found: ${newt.siteId}`); throw new Error("Site not found"); @@ -326,3 +385,143 @@ export async function updateAndGenerateEndpointDestinations( } return destinations; } + +async function handleSiteEndpointChange(siteId: number, newEndpoint: string) { + // Alert all clients connected to this site that the endpoint has changed (only if NOT relayed) + try { + // Get site details + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!site || !site.publicKey) { + logger.warn(`Site ${siteId} not found or has no public key`); + return; + } + + // Get all non-relayed clients connected to this site + const connectedClients = await db + .select({ + clientId: clients.clientId, + olmId: olms.olmId, + isRelayed: clientSitesAssociationsCache.isRelayed + }) + .from(clientSitesAssociationsCache) + .innerJoin( + clients, + eq(clientSitesAssociationsCache.clientId, clients.clientId) + ) + .innerJoin(olms, eq(olms.clientId, clients.clientId)) + .where( + and( + eq(clientSitesAssociationsCache.siteId, siteId), + eq(clientSitesAssociationsCache.isRelayed, false) + ) + ); + + // Update each non-relayed client with the new site endpoint + for (const client of connectedClients) { + try { + await updateOlmPeer( + client.clientId, + { + siteId: siteId, + publicKey: site.publicKey, + endpoint: newEndpoint + }, + client.olmId + ); + logger.debug( + `Updated client ${client.clientId} with new site ${siteId} endpoint: ${newEndpoint}` + ); + } catch (error) { + logger.error( + `Failed to update client ${client.clientId} with new site endpoint: ${error}` + ); + } + } + } catch (error) { + logger.error( + `Error handling site endpoint change for site ${siteId}: ${error}` + ); + } +} + +async function handleClientEndpointChange( + clientId: number, + newEndpoint: string +) { + // Alert all sites connected to this client that the endpoint has changed (only if NOT relayed) + try { + // Get client details + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client || !client.pubKey) { + logger.warn(`Client ${clientId} not found or has no public key`); + return; + } + + // Get all non-relayed sites connected to this client + const connectedSites = await db + .select({ + siteId: sites.siteId, + newtId: newts.newtId, + isRelayed: clientSitesAssociationsCache.isRelayed, + subnet: clients.subnet + }) + .from(clientSitesAssociationsCache) + .innerJoin( + sites, + eq(clientSitesAssociationsCache.siteId, sites.siteId) + ) + .innerJoin(newts, eq(newts.siteId, sites.siteId)) + .innerJoin( + clients, + eq(clientSitesAssociationsCache.clientId, clients.clientId) + ) + .where( + and( + eq(clientSitesAssociationsCache.clientId, clientId), + eq(clientSitesAssociationsCache.isRelayed, false) + ) + ); + + // Update each non-relayed site with the new client endpoint + for (const siteData of connectedSites) { + try { + if (!siteData.subnet) { + logger.warn( + `Client ${clientId} has no subnet, skipping update for site ${siteData.siteId}` + ); + continue; + } + + await updateNewtPeer( + siteData.siteId, + client.pubKey, + { + endpoint: newEndpoint + }, + siteData.newtId + ); + logger.debug( + `Updated site ${siteData.siteId} with new client ${clientId} endpoint: ${newEndpoint}` + ); + } catch (error) { + logger.error( + `Failed to update site ${siteData.siteId} with new client endpoint: ${error}` + ); + } + } + } catch (error) { + logger.error( + `Error handling client endpoint change for client ${clientId}: ${error}` + ); + } +} diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index e248f844..f6b21ff6 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -33,6 +33,7 @@ import { UserType } from "@server/types/UserTypes"; import { FeatureId } from "@server/lib/billing"; import { usageService } from "@server/lib/billing/usageService"; import { build } from "@server/build"; +import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; const ensureTrailingSlash = (url: string): string => { return url; @@ -364,10 +365,18 @@ export async function validateOidcCallback( ); if (!existingUserOrgs.length) { - // delete the user - // await db - // .delete(users) - // .where(eq(users.userId, existingUser.userId)); + // delete all auto -provisioned user orgs + await db + .delete(userOrgs) + .where( + and( + eq(userOrgs.userId, existingUser.userId), + eq(userOrgs.autoProvisioned, true) + ) + ); + + await calculateUserClientsForOrgs(existingUser.userId); + return next( createHttpError( HttpCode.UNAUTHORIZED, @@ -513,6 +522,8 @@ export async function validateOidcCallback( userCount: userCount.length }); } + + await calculateUserClientsForOrgs(userId!, trx); }); for (const orgCount of orgUserCounts) { @@ -553,6 +564,24 @@ export async function validateOidcCallback( ); } + // check for existing user orgs + const existingUserOrgs = await db + .select() + .from(userOrgs) + .where(and(eq(userOrgs.userId, existingUser.userId))); + + if (!existingUserOrgs.length) { + logger.debug( + "No existing user orgs found for non-auto-provisioned IdP" + ); + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + `User with username ${userIdentifier} is unprovisioned. This user must be added to an organization before logging in.` + ) + ); + } + const token = generateSessionToken(); const sess = await createSession(token, existingUser.userId); const isSecure = req.protocol === "https"; diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 7164c1de..878d61fa 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -10,6 +10,7 @@ import * as client from "./client"; import * as accessToken from "./accessToken"; import * as apiKeys from "./apiKeys"; import * as idp from "./idp"; +import * as logs from "./auditLogs"; import * as siteResource from "./siteResource"; import { verifyApiKey, @@ -24,8 +25,8 @@ import { verifyApiKeyAccessTokenAccess, verifyApiKeyIsRoot, verifyApiKeyClientAccess, - verifyClientsEnabled, - verifyApiKeySiteResourceAccess + verifyApiKeySiteResourceAccess, + verifyApiKeySetResourceClients } from "@server/middlewares"; import HttpCode from "@server/types/HttpCode"; import { Router } from "express"; @@ -197,6 +198,108 @@ authenticated.delete( siteResource.deleteSiteResource ); +authenticated.get( + "/site-resource/:siteResourceId/roles", + verifyApiKeySiteResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listResourceRoles), + siteResource.listSiteResourceRoles +); + +authenticated.get( + "/site-resource/:siteResourceId/users", + verifyApiKeySiteResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listResourceUsers), + siteResource.listSiteResourceUsers +); + +authenticated.get( + "/site-resource/:siteResourceId/clients", + verifyApiKeySiteResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listResourceUsers), + siteResource.listSiteResourceClients +); + +authenticated.post( + "/site-resource/:siteResourceId/roles", + verifyApiKeySiteResourceAccess, + verifyApiKeyRoleAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceRoles), + logActionAudit(ActionsEnum.setResourceRoles), + siteResource.setSiteResourceRoles +); + +authenticated.post( + "/site-resource/:siteResourceId/users", + verifyApiKeySiteResourceAccess, + verifyApiKeySetResourceUsers, + verifyApiKeyHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + siteResource.setSiteResourceUsers +); + +authenticated.post( + "/site-resource/:siteResourceId/roles/add", + verifyApiKeySiteResourceAccess, + verifyApiKeyRoleAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceRoles), + logActionAudit(ActionsEnum.setResourceRoles), + siteResource.addRoleToSiteResource +); + +authenticated.post( + "/site-resource/:siteResourceId/roles/remove", + verifyApiKeySiteResourceAccess, + verifyApiKeyRoleAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceRoles), + logActionAudit(ActionsEnum.setResourceRoles), + siteResource.removeRoleFromSiteResource +); + +authenticated.post( + "/site-resource/:siteResourceId/users/add", + verifyApiKeySiteResourceAccess, + verifyApiKeySetResourceUsers, + verifyApiKeyHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + siteResource.addUserToSiteResource +); + +authenticated.post( + "/site-resource/:siteResourceId/users/remove", + verifyApiKeySiteResourceAccess, + verifyApiKeySetResourceUsers, + verifyApiKeyHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + siteResource.removeUserFromSiteResource +); + +authenticated.post( + "/site-resource/:siteResourceId/clients", + verifyApiKeySiteResourceAccess, + verifyApiKeySetResourceClients, + verifyApiKeyHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + siteResource.setSiteResourceClients +); + +authenticated.post( + "/site-resource/:siteResourceId/clients/add", + verifyApiKeySiteResourceAccess, + verifyApiKeySetResourceClients, + verifyApiKeyHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + siteResource.addClientToSiteResource +); + +authenticated.post( + "/site-resource/:siteResourceId/clients/remove", + verifyApiKeySiteResourceAccess, + verifyApiKeySetResourceClients, + verifyApiKeyHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + siteResource.removeClientFromSiteResource +); + authenticated.put( "/org/:orgId/resource", verifyApiKeyOrgAccess, @@ -412,6 +515,42 @@ authenticated.post( resource.setResourceUsers ); +authenticated.post( + "/resource/:resourceId/roles/add", + verifyApiKeyResourceAccess, + verifyApiKeyRoleAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceRoles), + logActionAudit(ActionsEnum.setResourceRoles), + resource.addRoleToResource +); + +authenticated.post( + "/resource/:resourceId/roles/remove", + verifyApiKeyResourceAccess, + verifyApiKeyRoleAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceRoles), + logActionAudit(ActionsEnum.setResourceRoles), + resource.removeRoleFromResource +); + +authenticated.post( + "/resource/:resourceId/users/add", + verifyApiKeyResourceAccess, + verifyApiKeySetResourceUsers, + verifyApiKeyHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + resource.addUserToResource +); + +authenticated.post( + "/resource/:resourceId/users/remove", + verifyApiKeyResourceAccess, + verifyApiKeySetResourceUsers, + verifyApiKeyHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + resource.removeUserFromResource +); + authenticated.post( `/resource/:resourceId/password`, verifyApiKeyResourceAccess, @@ -657,7 +796,6 @@ authenticated.get( authenticated.get( "/org/:orgId/pick-client-defaults", - verifyClientsEnabled, verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createClient), client.pickClientDefaults @@ -665,7 +803,6 @@ authenticated.get( authenticated.get( "/org/:orgId/clients", - verifyClientsEnabled, verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.listClients), client.listClients @@ -673,7 +810,6 @@ authenticated.get( authenticated.get( "/client/:clientId", - verifyClientsEnabled, verifyApiKeyClientAccess, verifyApiKeyHasAction(ActionsEnum.getClient), client.getClient @@ -681,16 +817,24 @@ authenticated.get( authenticated.put( "/org/:orgId/client", - verifyClientsEnabled, verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createClient), logActionAudit(ActionsEnum.createClient), client.createClient ); +// authenticated.put( +// "/org/:orgId/user/:userId/client", +// verifyClientsEnabled, +// verifyApiKeyOrgAccess, +// verifyApiKeyUserAccess, +// verifyApiKeyHasAction(ActionsEnum.createClient), +// logActionAudit(ActionsEnum.createClient), +// client.createUserClient +// ); + authenticated.delete( "/client/:clientId", - verifyClientsEnabled, verifyApiKeyClientAccess, verifyApiKeyHasAction(ActionsEnum.deleteClient), logActionAudit(ActionsEnum.deleteClient), @@ -699,7 +843,6 @@ authenticated.delete( authenticated.post( "/client/:clientId", - verifyClientsEnabled, verifyApiKeyClientAccess, verifyApiKeyHasAction(ActionsEnum.updateClient), logActionAudit(ActionsEnum.updateClient), @@ -713,3 +856,32 @@ authenticated.put( logActionAudit(ActionsEnum.applyBlueprint), blueprints.applyJSONBlueprint ); + +authenticated.get( + "/org/:orgId/logs/request", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.viewLogs), + logs.queryRequestAuditLogs +); + +authenticated.get( + "/org/:orgId/logs/request/export", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.exportLogs), + logActionAudit(ActionsEnum.exportLogs), + logs.exportRequestAuditLogs +); + +authenticated.get( + "/org/:orgId/logs/analytics", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.viewLogs), + logs.queryRequestAnalytics +); + +authenticated.get( + "/org/:orgId/resource-names", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listResources), + resource.listAllResourceNames +); diff --git a/server/routers/newt/handleGetConfigMessage.ts b/server/routers/newt/handleGetConfigMessage.ts index fb40c398..5f42cd82 100644 --- a/server/routers/newt/handleGetConfigMessage.ts +++ b/server/routers/newt/handleGetConfigMessage.ts @@ -6,15 +6,15 @@ import { db, ExitNode, exitNodes, - resources, siteResources, - Target, - targets + clientSiteResourcesAssociationsCache } from "@server/db"; -import { clients, clientSites, Newt, sites } from "@server/db"; -import { eq, and, inArray } from "drizzle-orm"; +import { clients, clientSitesAssociationsCache, Newt, sites } from "@server/db"; +import { eq } from "drizzle-orm"; import { updatePeer } from "../olm/peers"; import { sendToExitNode } from "#dynamic/lib/exitNodes"; +import { generateSubnetProxyTargets, SubnetProxyTarget } from "@server/lib/ip"; +import config from "@server/lib/config"; const inputSchema = z.object({ publicKey: z.string(), @@ -66,7 +66,9 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { // we need to wait for hole punch success if (!existingSite.endpoint) { - logger.debug(`In newt get config: existing site ${existingSite.siteId} has no endpoint, skipping`); + logger.debug( + `In newt get config: existing site ${existingSite.siteId} has no endpoint, skipping` + ); return; } @@ -74,12 +76,12 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { // 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; - // } + if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) { + logger.warn( + `handleGetConfigMessage: Site ${existingSite.siteId} last hole punch is too old, skipping` + ); + return; + } // update the endpoint and the public key const [site] = await db @@ -132,75 +134,95 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { const clientsRes = await db .select() .from(clients) - .innerJoin(clientSites, eq(clients.clientId, clientSites.clientId)) - .where(eq(clientSites.siteId, siteId)); + .innerJoin( + clientSitesAssociationsCache, + eq(clients.clientId, clientSitesAssociationsCache.clientId) + ) + .where(eq(clientSitesAssociationsCache.siteId, siteId)); // Prepare peers data for the response const peers = await Promise.all( clientsRes .filter((client) => { if (!client.clients.pubKey) { + logger.warn( + `Client ${client.clients.clientId} has no public key, skipping` + ); return false; } if (!client.clients.subnet) { + logger.warn( + `Client ${client.clients.clientId} has no subnet, skipping` + ); return false; } return true; }) .map(async (client) => { // Add or update this peer on the olm if it is connected - try { - if (!site.publicKey) { - logger.warn( - `Site ${site.siteId} has no public key, skipping` - ); - return null; - } - let endpoint = site.endpoint; - if (client.clientSites.isRelayed) { - if (!site.exitNodeId) { - logger.warn( - `Site ${site.siteId} has no exit node, skipping` - ); - return null; - } - - if (!exitNode) { - logger.warn( - `Exit node not found for site ${site.siteId}` - ); - return null; - } - endpoint = `${exitNode.endpoint}:21820`; - } - - if (!endpoint) { - logger.warn( - `In Newt get config: Peer site ${site.siteId} has no endpoint, skipping` - ); - return null; - } - - await updatePeer(client.clients.clientId, { - siteId: site.siteId, - endpoint: endpoint, - publicKey: site.publicKey, - serverIP: site.address, - serverPort: site.listenPort, - remoteSubnets: site.remoteSubnets - }); - } catch (error) { - logger.error( - `Failed to add/update peer ${client.clients.pubKey} to olm ${newt.newtId}: ${error}` + if (!site.publicKey) { + logger.warn( + `Site ${site.siteId} has no public key, skipping` ); + return null; } + if (!exitNode) { + logger.warn(`Exit node not found for site ${site.siteId}`); + return null; + } + + if (!site.endpoint) { + logger.warn( + `Site ${site.siteId} has no endpoint, skipping` + ); + return null; + } + + // const allSiteResources = await db // only get the site resources that this client has access to + // .select() + // .from(siteResources) + // .innerJoin( + // clientSiteResourcesAssociationsCache, + // eq( + // siteResources.siteResourceId, + // clientSiteResourcesAssociationsCache.siteResourceId + // ) + // ) + // .where( + // and( + // eq(siteResources.siteId, site.siteId), + // eq( + // clientSiteResourcesAssociationsCache.clientId, + // client.clients.clientId + // ) + // ) + // ); + await updatePeer(client.clients.clientId, { + siteId: site.siteId, + endpoint: site.endpoint, + relayEndpoint: `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`, + publicKey: site.publicKey, + serverIP: site.address, + serverPort: site.listenPort + // remoteSubnets: generateRemoteSubnets( + // allSiteResources.map( + // ({ siteResources }) => siteResources + // ) + // ), + // aliases: generateAliasConfig( + // allSiteResources.map( + // ({ siteResources }) => siteResources + // ) + // ) + }); + return { publicKey: client.clients.pubKey!, allowedIps: [`${client.clients.subnet.split("/")[0]}/32`], // we want to only allow from that client - endpoint: client.clientSites.isRelayed + endpoint: client.clientSitesAssociationsCache.isRelayed ? "" - : client.clientSites.endpoint! // if its relayed it should be localhost + : client.clientSitesAssociationsCache.endpoint! // if its relayed it should be localhost }; }) ); @@ -208,42 +230,50 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { // Filter out any null values from peers that didn't have an olm const validPeers = peers.filter((peer) => peer !== null); - // Get all enabled targets with their resource protocol information + // Get all enabled site resources for this site const allSiteResources = await db .select() .from(siteResources) .where(eq(siteResources.siteId, siteId)); - const { tcpTargets, udpTargets } = allSiteResources.reduce( - (acc, resource) => { - // Filter out invalid targets - if (!resource.proxyPort || !resource.destinationIp || !resource.destinationPort) { - return acc; - } + const targetsToSend: SubnetProxyTarget[] = []; - // Format target into string - const formattedTarget = `${resource.proxyPort}:${resource.destinationIp}:${resource.destinationPort}`; + for (const resource of allSiteResources) { + // Get clients associated with this specific resource + const resourceClients = await db + .select({ + clientId: clients.clientId, + pubKey: clients.pubKey, + subnet: clients.subnet + }) + .from(clients) + .innerJoin( + clientSiteResourcesAssociationsCache, + eq( + clients.clientId, + clientSiteResourcesAssociationsCache.clientId + ) + ) + .where( + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + resource.siteResourceId + ) + ); - // Add to the appropriate protocol array - if (resource.protocol === "tcp") { - acc.tcpTargets.push(formattedTarget); - } else { - acc.udpTargets.push(formattedTarget); - } + const resourceTargets = generateSubnetProxyTargets( + resource, + resourceClients + ); - return acc; - }, - { tcpTargets: [] as string[], udpTargets: [] as string[] } - ); + targetsToSend.push(...resourceTargets); + } // Build the configuration response const configResponse = { ipAddress: site.address, peers: validPeers, - targets: { - udp: udpTargets, - tcp: tcpTargets - } + targets: targetsToSend }; logger.debug("Sending config: ", configResponse); diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 813e3ef5..f4d963a1 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -1,8 +1,8 @@ -import { db, exitNodeOrgs, newts } from "@server/db"; +import { db, ExitNode, exitNodeOrgs, newts, Transaction } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { exitNodes, Newt, resources, sites, Target, targets } from "@server/db"; import { targetHealthCheck } from "@server/db"; -import { eq, and, sql, inArray } from "drizzle-orm"; +import { eq, and, sql, inArray, ne } from "drizzle-orm"; import { addPeer, deletePeer } from "../gerbil/peers"; import logger from "@server/logger"; import config from "@server/lib/config"; @@ -17,6 +17,7 @@ import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes"; import { fetchContainers } from "./dockerSocket"; +import { lockManager } from "#dynamic/lib/lock"; export type ExitNodePingResult = { exitNodeId: number; @@ -151,27 +152,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { return; } - const sitesQuery = await db - .select({ - subnet: sites.subnet - }) - .from(sites) - .where(eq(sites.exitNodeId, exitNodeId)); + const newSubnet = await getUniqueSubnetForSite(exitNode); - const blockSize = config.getRawConfig().gerbil.site_block_size; - const subnets = sitesQuery - .map((site) => site.subnet) - .filter( - (subnet) => - subnet && /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(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 id ${exitNodeId} and site id ${siteId}` @@ -378,3 +360,39 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { excludeSender: false // Include sender in broadcast }; }; + +async function getUniqueSubnetForSite( + exitNode: ExitNode, + trx: Transaction | typeof db = db +): Promise { + const lockKey = `subnet-allocation:${exitNode.exitNodeId}`; + + return await lockManager.withLock( + lockKey, + async () => { + const sitesQuery = await trx + .select({ + subnet: sites.subnet + }) + .from(sites) + .where(eq(sites.exitNodeId, exitNode.exitNodeId)); + + const blockSize = config.getRawConfig().gerbil.site_block_size; + const subnets = sitesQuery + .map((site) => site.subnet) + .filter( + (subnet) => + subnet && /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet) + ) + .filter((subnet) => subnet !== null); + subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`)); + const newSubnet = findNextAvailableCidr( + subnets, + blockSize, + exitNode.address + ); + return newSubnet; + }, + 5000 // 5 second lock TTL - subnet allocation should be quick + ); +} diff --git a/server/routers/newt/peers.ts b/server/routers/newt/peers.ts index 03dc3460..694f0c0f 100644 --- a/server/routers/newt/peers.ts +++ b/server/routers/newt/peers.ts @@ -1,4 +1,4 @@ -import { db } from "@server/db"; +import { db, Site } from "@server/db"; import { newts, sites } from "@server/db"; import { eq } from "drizzle-orm"; import { sendToClient } from "#dynamic/routers/ws"; @@ -10,65 +10,78 @@ export async function addPeer( publicKey: string; allowedIps: string[]; endpoint: string; - } + }, + newtId?: 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`); + let site: Site | null = null; + if (!newtId) { + [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(`Site found for site ${siteId}`); + } + newtId = newt.newtId; } - // 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, { + await sendToClient(newtId, { type: "newt/wg/peer/add", data: peer + }).catch((error) => { + logger.warn(`Error sending message:`, error); }); - logger.info(`Added peer ${peer.publicKey} to newt ${newt.newtId}`); + logger.info(`Added peer ${peer.publicKey} to 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`); +export async function deletePeer(siteId: number, publicKey: string, newtId?: string) { + let site: Site | null = null; + if (!newtId) { + [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}`); + } + newtId = newt.newtId; } - // 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, { + await sendToClient(newtId, { type: "newt/wg/peer/remove", data: { publicKey } + }).catch((error) => { + logger.warn(`Error sending message:`, error); }); - logger.info(`Deleted peer ${publicKey} from newt ${newt.newtId}`); + logger.info(`Deleted peer ${publicKey} from newt ${newtId}`); return site; } @@ -79,36 +92,43 @@ export async function updatePeer( peer: { allowedIps?: string[]; endpoint?: string; - } + }, + newtId?: 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`); + let site: Site | null = null; + if (!newtId) { + [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}`); + } + newtId = newt.newtId; } - // 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, { + await sendToClient(newtId, { type: "newt/wg/peer/update", data: { publicKey, ...peer } + }).catch((error) => { + logger.warn(`Error sending message:`, error); }); - logger.info(`Updated peer ${publicKey} on newt ${newt.newtId}`); + logger.info(`Updated peer ${publicKey} on newt ${newtId}`); return site; } diff --git a/server/routers/olm/createUserOlm.ts b/server/routers/olm/createUserOlm.ts new file mode 100644 index 00000000..0fc1e452 --- /dev/null +++ b/server/routers/olm/createUserOlm.ts @@ -0,0 +1,116 @@ +import { NextFunction, Request, Response } from "express"; +import { db, olms } from "@server/db"; +import HttpCode from "@server/types/HttpCode"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import moment from "moment"; +import { generateId } from "@server/auth/sessions/app"; +import { fromError } from "zod-validation-error"; +import { hashPassword } from "@server/auth/password"; +import { OpenAPITags, registry } from "@server/openApi"; +import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; + +const bodySchema = z + .object({ + name: z.string().min(1).max(255) + }) + .strict(); + +const paramsSchema = z.object({ + userId: z.string() +}); + +export type CreateOlmBody = z.infer; + +export type CreateOlmResponse = { + olmId: string; + secret: string; +}; + +// registry.registerPath({ +// method: "put", +// path: "/user/{userId}/olm", +// description: "Create a new olm for a user.", +// tags: [OpenAPITags.User, OpenAPITags.Client], +// request: { +// body: { +// content: { +// "application/json": { +// schema: bodySchema +// } +// } +// }, +// params: paramsSchema +// }, +// responses: {} +// }); + +export async function createUserOlm( + 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 { name } = parsedBody.data; + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { userId } = parsedParams.data; + + const olmId = generateId(15); + const secret = generateId(48); + + const secretHash = await hashPassword(secret); + + await db.transaction(async (trx) => { + await trx.insert(olms).values({ + olmId: olmId, + userId, + name, + secretHash, + dateCreated: moment().toISOString() + }); + + await calculateUserClientsForOrgs(userId, trx); + }); + + return response(res, { + data: { + olmId, + secret + }, + success: true, + error: false, + message: "Olm created successfully", + status: HttpCode.OK + }); + } catch (e) { + console.error(e); + + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create olm" + ) + ); + } +} diff --git a/server/routers/olm/deleteUserOlm.ts b/server/routers/olm/deleteUserOlm.ts new file mode 100644 index 00000000..83a3d16f --- /dev/null +++ b/server/routers/olm/deleteUserOlm.ts @@ -0,0 +1,101 @@ +import { NextFunction, Request, Response } from "express"; +import { Client, db } from "@server/db"; +import { olms, clients, clientSitesAssociationsCache } from "@server/db"; +import { eq } from "drizzle-orm"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { sendTerminateClient } from "../client/terminate"; + +const paramsSchema = z + .object({ + userId: z.string(), + olmId: z.string() + }) + .strict(); + +// registry.registerPath({ +// method: "delete", +// path: "/user/{userId}/olm/{olmId}", +// description: "Delete an olm for a user.", +// tags: [OpenAPITags.User, OpenAPITags.Client], +// request: { +// params: paramsSchema +// }, +// responses: {} +// }); + +export async function deleteUserOlm( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { olmId } = parsedParams.data; + + // Delete associated clients and the OLM in a transaction + await db.transaction(async (trx) => { + // Find all clients associated with this OLM + const associatedClients = await trx + .select({ clientId: clients.clientId }) + .from(clients) + .where(eq(clients.olmId, olmId)); + + let deletedClient: Client | null = null; + // Delete all associated clients + if (associatedClients.length > 0) { + [deletedClient] = await trx + .delete(clients) + .where(eq(clients.olmId, olmId)) + .returning(); + } + + // Finally, delete the OLM itself + const [olm] = await trx + .delete(olms) + .where(eq(olms.olmId, olmId)) + .returning(); + + if (deletedClient) { + await rebuildClientAssociationsFromClient(deletedClient, trx); + if (olm) { + await sendTerminateClient( + deletedClient.clientId, + olm.olmId + ); // the olmId needs to be provided because it cant look it up after deletion + } + } + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "Device deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to delete device" + ) + ); + } +} diff --git a/server/routers/olm/getOlmToken.ts b/server/routers/olm/getOlmToken.ts index c26f5936..8e82dc94 100644 --- a/server/routers/olm/getOlmToken.ts +++ b/server/routers/olm/getOlmToken.ts @@ -1,9 +1,16 @@ import { generateSessionToken } from "@server/auth/sessions/app"; -import { db } from "@server/db"; +import { + clients, + db, + ExitNode, + exitNodes, + sites, + clientSitesAssociationsCache +} 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 { and, eq, inArray } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; @@ -19,7 +26,8 @@ import config from "@server/lib/config"; export const olmGetTokenBodySchema = z.object({ olmId: z.string(), secret: z.string(), - token: z.string().optional() + token: z.string().optional(), + orgId: z.string().optional() }); export type OlmGetTokenBody = z.infer; @@ -40,7 +48,7 @@ export async function getOlmToken( ); } - const { olmId, secret, token } = parsedBody.data; + const { olmId, secret, token, orgId } = parsedBody.data; try { if (token) { @@ -61,11 +69,12 @@ export async function getOlmToken( } } - const existingOlmRes = await db + const [existingOlm] = await db .select() .from(olms) .where(eq(olms.olmId, olmId)); - if (!existingOlmRes || !existingOlmRes.length) { + + if (!existingOlm) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -74,12 +83,11 @@ export async function getOlmToken( ); } - const existingOlm = existingOlmRes[0]; - const validSecret = await verifyPassword( secret, existingOlm.secretHash ); + if (!validSecret) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( @@ -96,11 +104,115 @@ export async function getOlmToken( const resToken = generateSessionToken(); await createOlmSession(resToken, existingOlm.olmId); + let orgIdToUse = orgId; + let clientIdToUse; + if (!orgIdToUse) { + if (!existingOlm.clientId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Olm is not associated with a client, orgId is required" + ) + ); + } + + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, existingOlm.clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Olm's associated client not found, orgId is required" + ) + ); + } + + orgIdToUse = client.orgId; + clientIdToUse = client.clientId; + } else { + // we did provide the org + const [client] = await db + .select() + .from(clients) + .where( + and(eq(clients.orgId, orgIdToUse), eq(clients.olmId, olmId)) + ) // we want to lock on to the client with this olmId otherwise it can get assigned to a random one + .limit(1); + + if (!client) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "No client found for provided orgId" + ) + ); + } + + if (existingOlm.clientId !== client.clientId) { + // we only need to do this if the client is changing + + logger.debug( + `Switching olm client ${existingOlm.olmId} to org ${orgId} for user ${existingOlm.userId}` + ); + + await db + .update(olms) + .set({ + clientId: client.clientId + }) + .where(eq(olms.olmId, existingOlm.olmId)); + } + + clientIdToUse = client.clientId; + } + + // Get all exit nodes from sites where the client has peers + const clientSites = await db + .select() + .from(clientSitesAssociationsCache) + .innerJoin( + sites, + eq(sites.siteId, clientSitesAssociationsCache.siteId) + ) + .where(eq(clientSitesAssociationsCache.clientId, clientIdToUse!)); + + // Extract unique exit node IDs + const exitNodeIds = Array.from( + new Set( + clientSites + .map(({ sites: site }) => site.exitNodeId) + .filter((id): id is number => id !== null) + ) + ); + + let allExitNodes: ExitNode[] = []; + if (exitNodeIds.length > 0) { + allExitNodes = await db + .select() + .from(exitNodes) + .where(inArray(exitNodes.exitNodeId, exitNodeIds)); + } + + const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => { + return { + publicKey: exitNode.publicKey, + endpoint: exitNode.endpoint + }; + }); + logger.debug("Token created successfully"); - return response<{ token: string }>(res, { + return response<{ + token: string; + exitNodes: { publicKey: string; endpoint: string }[]; + }>(res, { data: { - token: resToken + token: resToken, + exitNodes: exitNodesHpData }, success: true, error: false, diff --git a/server/routers/olm/getUserOlm.ts b/server/routers/olm/getUserOlm.ts new file mode 100644 index 00000000..aa9b89af --- /dev/null +++ b/server/routers/olm/getUserOlm.ts @@ -0,0 +1,70 @@ +import { NextFunction, Request, Response } from "express"; +import { db } from "@server/db"; +import { olms } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z + .object({ + userId: z.string(), + olmId: z.string() + }) + .strict(); + +// registry.registerPath({ +// method: "get", +// path: "/user/{userId}/olm/{olmId}", +// description: "Get an olm for a user.", +// tags: [OpenAPITags.User, OpenAPITags.Client], +// request: { +// params: paramsSchema +// }, +// responses: {} +// }); + +export async function getUserOlm( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { olmId, userId } = parsedParams.data; + + const [olm] = await db + .select() + .from(olms) + .where(and(eq(olms.userId, userId), eq(olms.olmId, olmId))); + + return response(res, { + data: olm, + success: true, + error: false, + message: "Successfully retrieved olm", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to retrieve olm" + ) + ); + } +} diff --git a/server/routers/olm/handleOlmPingMessage.ts b/server/routers/olm/handleOlmPingMessage.ts index 6f00640d..632b0f3f 100644 --- a/server/routers/olm/handleOlmPingMessage.ts +++ b/server/routers/olm/handleOlmPingMessage.ts @@ -1,8 +1,12 @@ import { db } from "@server/db"; +import { disconnectClient } from "#dynamic/routers/ws"; import { MessageHandler } from "@server/routers/ws"; import { clients, Olm } from "@server/db"; import { eq, lt, isNull, and, or } from "drizzle-orm"; import logger from "@server/logger"; +import { validateSessionToken } from "@server/auth/sessions/app"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { sendTerminateClient } from "../client/terminate"; // Track if the offline checker interval is running let offlineCheckerInterval: NodeJS.Timeout | null = null; @@ -20,10 +24,14 @@ export const startOlmOfflineChecker = (): void => { offlineCheckerInterval = setInterval(async () => { try { - const twoMinutesAgo = Math.floor((Date.now() - OFFLINE_THRESHOLD_MS) / 1000); + const twoMinutesAgo = Math.floor( + (Date.now() - OFFLINE_THRESHOLD_MS) / 1000 + ); + + // TODO: WE NEED TO MAKE SURE THIS WORKS WITH DISTRIBUTED NODES ALL DOING THE SAME THING // Find clients that haven't pinged in the last 2 minutes and mark them as offline - await db + const offlineClients = await db .update(clients) .set({ online: false }) .where( @@ -34,8 +42,34 @@ export const startOlmOfflineChecker = (): void => { isNull(clients.lastPing) ) ) + ) + .returning(); + + for (const offlineClient of offlineClients) { + logger.info( + `Kicking offline olm client ${offlineClient.clientId} due to inactivity` ); + if (!offlineClient.olmId) { + logger.warn( + `Offline client ${offlineClient.clientId} has no olmId, cannot disconnect` + ); + continue; + } + + // Send a disconnect message to the client if connected + try { + await sendTerminateClient(offlineClient.clientId, offlineClient.olmId); // terminate first + // wait a moment to ensure the message is sent + await new Promise(resolve => setTimeout(resolve, 1000)); + await disconnectClient(offlineClient.olmId); + } catch (error) { + logger.error( + `Error sending disconnect to offline olm ${offlineClient.clientId}`, + { error } + ); + } + } } catch (error) { logger.error("Error in offline checker interval", { error }); } @@ -62,11 +96,57 @@ export const handleOlmPingMessage: MessageHandler = async (context) => { const { message, client: c, sendToClient } = context; const olm = c as Olm; + const { userToken } = message.data; + if (!olm) { logger.warn("Olm not found"); return; } + if (olm.userId) { + // we need to check a user token to make sure its still valid + const { session: userSession, user } = + await validateSessionToken(userToken); + if (!userSession || !user) { + logger.warn("Invalid user session for olm ping"); + return; // by returning here we just ignore the ping and the setInterval will force it to disconnect + } + if (user.userId !== olm.userId) { + logger.warn("User ID mismatch for olm ping"); + return; + } + + // get the client + const [client] = await db + .select() + .from(clients) + .where( + and( + eq(clients.olmId, olm.olmId), + eq(clients.userId, olm.userId) + ) + ) + .limit(1); + + if (!client) { + logger.warn("Client not found for olm ping"); + return; + } + + const policyCheck = await checkOrgAccessPolicy({ + orgId: client.orgId, + userId: olm.userId, + session: userToken // this is the user token passed in the message + }); + + if (!policyCheck.allowed) { + logger.warn( + `Olm user ${olm.userId} does not pass access policies for org ${client.orgId}: ${policyCheck.error}` + ); + return; + } + } + if (!olm.clientId) { logger.warn("Olm has no client ID!"); return; @@ -78,7 +158,7 @@ export const handleOlmPingMessage: MessageHandler = async (context) => { .update(clients) .set({ lastPing: Math.floor(Date.now() / 1000), - online: true, + online: true }) .where(eq(clients.clientId, olm.clientId)); } catch (error) { @@ -89,7 +169,7 @@ export const handleOlmPingMessage: MessageHandler = async (context) => { message: { type: "pong", data: { - timestamp: new Date().toISOString(), + timestamp: new Date().toISOString() } }, broadcast: false, diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 66128f0e..2b30cbcf 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -1,31 +1,115 @@ -import { db, ExitNode } from "@server/db"; +import { + Client, + clientSiteResourcesAssociationsCache, + db, + ExitNode, + Org, + orgs, + roleClients, + roles, + siteResources, + Transaction, + userClients, + userOrgs, + users +} from "@server/db"; import { MessageHandler } from "@server/routers/ws"; -import { clients, clientSites, exitNodes, Olm, olms, sites } from "@server/db"; -import { and, eq, inArray } from "drizzle-orm"; +import { + clients, + clientSitesAssociationsCache, + exitNodes, + Olm, + olms, + sites +} from "@server/db"; +import { and, eq, inArray, isNull } from "drizzle-orm"; import { addPeer, deletePeer } from "../newt/peers"; import logger from "@server/logger"; import { listExitNodes } from "#dynamic/lib/exitNodes"; +import { + generateAliasConfig, + getNextAvailableClientSubnet +} from "@server/lib/ip"; +import { generateRemoteSubnets } from "@server/lib/ip"; +import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { validateSessionToken } from "@server/auth/sessions/app"; +import config from "@server/lib/config"; 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; + const now = Math.floor(Date.now() / 1000); if (!olm) { logger.warn("Olm not found"); return; } + + const { publicKey, relay, olmVersion, olmAgent, orgId, userToken } = message.data; + if (!olm.clientId) { - logger.warn("Olm has no client ID!"); + logger.warn("Olm client ID not found"); return; } - const clientId = olm.clientId; - const { publicKey, relay, olmVersion } = message.data; + + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, olm.clientId)) + .limit(1); + + if (!client) { + logger.warn("Client ID not found"); + return; + } + + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, client.orgId)) + .limit(1); + + if (!org) { + logger.warn("Org not found"); + return; + } + + if (orgId) { + if (!olm.userId) { + logger.warn("Olm has no user ID"); + return; + } + + const { session: userSession, user } = + await validateSessionToken(userToken); + if (!userSession || !user) { + logger.warn("Invalid user session for olm register"); + return; // by returning here we just ignore the ping and the setInterval will force it to disconnect + } + if (user.userId !== olm.userId) { + logger.warn("User ID mismatch for olm register"); + return; + } + + const policyCheck = await checkOrgAccessPolicy({ + orgId: orgId, + userId: olm.userId, + session: userToken // this is the user token passed in the message + }); + + if (!policyCheck.allowed) { + logger.warn( + `Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}` + ); + return; + } + } logger.debug( - `Olm client ID: ${clientId}, Public Key: ${publicKey}, Relay: ${relay}` + `Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}` ); if (!publicKey) { @@ -33,66 +117,16 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { 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) { - // TODO: FOR NOW WE ARE JUST HOLEPUNCHING ALL EXIT NODES BUT IN THE FUTURE WE SHOULD HANDLE THIS BETTER - - // Get the exit node - const allExitNodes = await listExitNodes(client.orgId, true); // FILTER THE ONLINE ONES - - const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => { - return { - publicKey: exitNode.publicKey, - endpoint: exitNode.endpoint - }; - }); - - // Send holepunch message - await sendToClient(olm.olmId, { - type: "olm/wg/holepunch/all", - data: { - exitNodes: exitNodesHpData - } - }); - - if (!olmVersion) { - // THIS IS FOR BACKWARDS COMPATIBILITY - // THE OLDER CLIENTS DID NOT SEND THE VERSION - await sendToClient(olm.olmId, { - type: "olm/wg/holepunch", - data: { - serverPubKey: allExitNodes[0].publicKey, - endpoint: allExitNodes[0].endpoint - } - }); - } - } - - if (olmVersion) { + if ((olmVersion && olm.version !== olmVersion) || (olmAgent && olm.agent !== olmAgent)) { await db .update(olms) .set({ - version: olmVersion + version: olmVersion, + agent: olmAgent }) .where(eq(olms.olmId, olm.olmId)); } - // 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..." @@ -103,23 +137,26 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { .set({ pubKey: publicKey }) - .where(eq(clients.clientId, olm.clientId)); + .where(eq(clients.clientId, client.clientId)); // set isRelay to false for all of the client's sites to reset the connection metadata await db - .update(clientSites) + .update(clientSitesAssociationsCache) .set({ isRelayed: relay == true }) - .where(eq(clientSites.clientId, olm.clientId)); + .where(eq(clientSitesAssociationsCache.clientId, client.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)); + .innerJoin( + clientSitesAssociationsCache, + eq(sites.siteId, clientSitesAssociationsCache.siteId) + ) + .where(eq(clientSitesAssociationsCache.clientId, client.clientId)); // Prepare an array to store site configurations const siteConfigurations = []; @@ -127,15 +164,18 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { `Found ${sitesData.length} sites for client ${client.clientId}` ); - if (sitesData.length === 0) { - sendToClient(olm.olmId, { - type: "olm/register/no-sites", - data: {} - }); + // this prevents us from accepting a register from an olm that has not hole punched yet. + // the olm will pump the register so we can keep checking + // TODO: I still think there is a better way to do this rather than locking it out here but ??? + if (now - (client.lastHolePunch || 0) > 5 && sitesData.length > 0) { + logger.warn( + "Client last hole punch is too old and we have sites to send; skipping this register" + ); + return; } // Process each site - for (const { sites: site } of sitesData) { + for (const { sites: site, clientSitesAssociationsCache: association } of sitesData) { if (!site.exitNodeId) { logger.warn( `Site ${site.siteId} does not have exit node, skipping` @@ -145,7 +185,9 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { // Validate endpoint and hole punch status if (!site.endpoint) { - logger.warn(`In olm register: site ${site.siteId} has no endpoint, skipping`); + logger.warn( + `In olm register: site ${site.siteId} has no endpoint, skipping` + ); continue; } @@ -171,11 +213,11 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { const [clientSite] = await db .select() - .from(clientSites) + .from(clientSitesAssociationsCache) .where( and( - eq(clientSites.clientId, client.clientId), - eq(clientSites.siteId, site.siteId) + eq(clientSitesAssociationsCache.clientId, client.clientId), + eq(clientSitesAssociationsCache.siteId, site.siteId) ) ) .limit(1); @@ -196,7 +238,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { ); } - let endpoint = site.endpoint; + let relayEndpoint: string | undefined = undefined; if (relay) { const [exitNode] = await db .select() @@ -207,17 +249,43 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { logger.warn(`Exit node not found for site ${site.siteId}`); continue; } - endpoint = `${exitNode.endpoint}:21820`; + relayEndpoint = `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`; } + const allSiteResources = await db // only get the site resources that this client has access to + .select() + .from(siteResources) + .innerJoin( + clientSiteResourcesAssociationsCache, + eq( + siteResources.siteResourceId, + clientSiteResourcesAssociationsCache.siteResourceId + ) + ) + .where( + and( + eq(siteResources.siteId, site.siteId), + eq( + clientSiteResourcesAssociationsCache.clientId, + client.clientId + ) + ) + ); + // Add site configuration to the array siteConfigurations.push({ siteId: site.siteId, - endpoint: endpoint, + // relayEndpoint: relayEndpoint, // this can be undefined now if not relayed // lets not do this for now because it would conflict with the hole punch testing + endpoint: site.endpoint, publicKey: site.publicKey, serverIP: site.address, serverPort: site.listenPort, - remoteSubnets: site.remoteSubnets + remoteSubnets: generateRemoteSubnets( + allSiteResources.map(({ siteResources }) => siteResources) + ), + aliases: generateAliasConfig( + allSiteResources.map(({ siteResources }) => siteResources) + ) }); } @@ -233,7 +301,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { type: "olm/wg/connect", data: { sites: siteConfigurations, - tunnelIP: client.subnet + tunnelIP: client.subnet, + utilitySubnet: org.utilitySubnet } }, broadcast: false, diff --git a/server/routers/olm/handleOlmRelayMessage.ts b/server/routers/olm/handleOlmRelayMessage.ts index 9b31754c..595b35ba 100644 --- a/server/routers/olm/handleOlmRelayMessage.ts +++ b/server/routers/olm/handleOlmRelayMessage.ts @@ -1,8 +1,8 @@ import { db, exitNodes, sites } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; -import { clients, clientSites, Olm } from "@server/db"; +import { clients, clientSitesAssociationsCache, Olm } from "@server/db"; import { and, eq } from "drizzle-orm"; -import { updatePeer } from "../newt/peers"; +import { updatePeer as newtUpdatePeer } from "../newt/peers"; import logger from "@server/logger"; export const handleOlmRelayMessage: MessageHandler = async (context) => { @@ -67,30 +67,31 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => { } await db - .update(clientSites) + .update(clientSitesAssociationsCache) .set({ isRelayed: true }) .where( and( - eq(clientSites.clientId, olm.clientId), - eq(clientSites.siteId, siteId) + eq(clientSitesAssociationsCache.clientId, olm.clientId), + eq(clientSitesAssociationsCache.siteId, siteId) ) ); // update the peer on the exit node - await updatePeer(siteId, client.pubKey, { - endpoint: "" // this removes the endpoint + await newtUpdatePeer(siteId, client.pubKey, { + endpoint: "" // this removes the endpoint so the exit node knows to relay }); - sendToClient(olm.olmId, { - type: "olm/wg/peer/relay", - data: { - siteId: siteId, - endpoint: exitNode.endpoint, - publicKey: exitNode.publicKey - } - }); - - return; + return { + message: { + type: "olm/wg/peer/relay", + data: { + siteId: siteId, + relayEndpoint: exitNode.endpoint + } + }, + broadcast: false, + excludeSender: false + }; }; diff --git a/server/routers/olm/handleOlmServerPeerAddMessage.ts b/server/routers/olm/handleOlmServerPeerAddMessage.ts new file mode 100644 index 00000000..83f7ad8c --- /dev/null +++ b/server/routers/olm/handleOlmServerPeerAddMessage.ts @@ -0,0 +1,187 @@ +import { + Client, + clientSiteResourcesAssociationsCache, + db, + ExitNode, + Org, + orgs, + roleClients, + roles, + siteResources, + Transaction, + userClients, + userOrgs, + users +} from "@server/db"; +import { MessageHandler } from "@server/routers/ws"; +import { + clients, + clientSitesAssociationsCache, + exitNodes, + Olm, + olms, + sites +} from "@server/db"; +import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; +import { addPeer, deletePeer } from "../newt/peers"; +import logger from "@server/logger"; +import { listExitNodes } from "#dynamic/lib/exitNodes"; +import { + generateAliasConfig, + getNextAvailableClientSubnet +} from "@server/lib/ip"; +import { generateRemoteSubnets } from "@server/lib/ip"; +import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { validateSessionToken } from "@server/auth/sessions/app"; +import config from "@server/lib/config"; +import { + addPeer as newtAddPeer, + deletePeer as newtDeletePeer +} from "@server/routers/newt/peers"; + +export const handleOlmServerPeerAddMessage: MessageHandler = async ( + context +) => { + logger.info("Handling register olm message!"); + const { message, client: c, sendToClient } = context; + const olm = c as Olm; + + const now = Math.floor(Date.now() / 1000); + + if (!olm) { + logger.warn("Olm not found"); + return; + } + + const { siteId } = message.data; + + // get the site + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!site) { + logger.error( + `handleOlmServerPeerAddMessage: Site with ID ${siteId} not found` + ); + return; + } + + if (!site.endpoint) { + logger.error( + `handleOlmServerPeerAddMessage: Site with ID ${siteId} has no endpoint` + ); + return; + } + + // get the client + + if (!olm.clientId) { + logger.error( + `handleOlmServerPeerAddMessage: Olm with ID ${olm.olmId} has no clientId` + ); + return; + } + + const [client] = await db + .select() + .from(clients) + .where(and(eq(clients.clientId, olm.clientId))) + .limit(1); + + if (!client) { + logger.error( + `handleOlmServerPeerAddMessage: Client with ID ${olm.clientId} not found` + ); + return; + } + + if (!client.pubKey) { + logger.error( + `handleOlmServerPeerAddMessage: Client with ID ${client.clientId} has no public key` + ); + return; + } + + let endpoint: string | null = null; + + // TODO: should we pick only the one from the site its talking to instead of any good current session? + const currentSessionSiteAssociationCaches = await db + .select() + .from(clientSitesAssociationsCache) + .where( + and( + eq(clientSitesAssociationsCache.clientId, client.clientId), + isNotNull(clientSitesAssociationsCache.endpoint), + eq(clientSitesAssociationsCache.publicKey, client.pubKey) // limit it to the current session its connected with otherwise the endpoint could be stale + ) + ); + + // pick an endpoint + for (const assoc of currentSessionSiteAssociationCaches) { + if (assoc.endpoint) { + endpoint = assoc.endpoint; + break; + } + } + + if (!endpoint) { + logger.error( + `handleOlmServerPeerAddMessage: No endpoint found for client ${client.clientId}` + ); + return; + } + + // NOTE: here we are always starting direct to the peer and will relay later + + await newtAddPeer(siteId, { + publicKey: client.pubKey, + allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client + endpoint: endpoint // this is the client's endpoint with reference to the site's exit node + }); + + const allSiteResources = await db // only get the site resources that this client has access to + .select() + .from(siteResources) + .innerJoin( + clientSiteResourcesAssociationsCache, + eq( + siteResources.siteResourceId, + clientSiteResourcesAssociationsCache.siteResourceId + ) + ) + .where( + and( + eq(siteResources.siteId, site.siteId), + eq( + clientSiteResourcesAssociationsCache.clientId, + client.clientId + ) + ) + ); + + // Return connect message with all site configurations + return { + message: { + type: "olm/wg/peer/add", + data: { + siteId: site.siteId, + endpoint: site.endpoint, + publicKey: site.publicKey, + serverIP: site.address, + serverPort: site.listenPort, + remoteSubnets: generateRemoteSubnets( + allSiteResources.map(({ siteResources }) => siteResources) + ), + aliases: generateAliasConfig( + allSiteResources.map(({ siteResources }) => siteResources) + ) + } + }, + broadcast: false, + excludeSender: false + }; +}; diff --git a/server/routers/olm/handleOlmUnRelayMessage.ts b/server/routers/olm/handleOlmUnRelayMessage.ts new file mode 100644 index 00000000..5f47a095 --- /dev/null +++ b/server/routers/olm/handleOlmUnRelayMessage.ts @@ -0,0 +1,96 @@ +import { db, exitNodes, sites } from "@server/db"; +import { MessageHandler } from "@server/routers/ws"; +import { clients, clientSitesAssociationsCache, Olm } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import { updatePeer as newtUpdatePeer } from "../newt/peers"; +import logger from "@server/logger"; + +export const handleOlmUnRelayMessage: MessageHandler = async (context) => { + const { message, client: c, sendToClient } = context; + const olm = c as Olm; + + logger.info("Handling unrelay olm message!"); + + if (!olm) { + logger.warn("Olm not found"); + return; + } + + if (!olm.clientId) { + logger.warn("Olm has no site!"); // TODO: Maybe we create the site here? + return; + } + + const clientId = olm.clientId; + + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + logger.warn("Client not found"); + return; + } + + // make sure we hand endpoints for both the site and the client and the lastHolePunch is not too old + if (!client.pubKey) { + logger.warn("Client has no endpoint or listen port"); + return; + } + + const { siteId } = message.data; + + // Get the site + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!site) { + logger.warn("Site not found or has no exit node"); + return; + } + + const [clientSiteAssociation] = await db + .update(clientSitesAssociationsCache) + .set({ + isRelayed: false + }) + .where( + and( + eq(clientSitesAssociationsCache.clientId, olm.clientId), + eq(clientSitesAssociationsCache.siteId, siteId) + ) + ) + .returning(); + + if (!clientSiteAssociation) { + logger.warn("Client-Site association not found"); + return; + } + + if (!clientSiteAssociation.endpoint) { + logger.warn("Client-Site association has no endpoint, cannot unrelay"); + return; + } + + // update the peer on the exit node + await newtUpdatePeer(siteId, client.pubKey, { + endpoint: clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the exit node + }); + + return { + message: { + type: "olm/wg/peer/unrelay", + data: { + siteId: siteId, + endpoint: site.endpoint + } + }, + broadcast: false, + excludeSender: false + }; +}; diff --git a/server/routers/olm/index.ts b/server/routers/olm/index.ts index 8426612e..e671dd42 100644 --- a/server/routers/olm/index.ts +++ b/server/routers/olm/index.ts @@ -1,5 +1,11 @@ export * from "./handleOlmRegisterMessage"; export * from "./getOlmToken"; -export * from "./createOlm"; +export * from "./createUserOlm"; export * from "./handleOlmRelayMessage"; -export * from "./handleOlmPingMessage"; \ No newline at end of file +export * from "./handleOlmPingMessage"; +export * from "./deleteUserOlm"; +export * from "./listUserOlms"; +export * from "./deleteUserOlm"; +export * from "./getUserOlm"; +export * from "./handleOlmServerPeerAddMessage"; +export * from "./handleOlmUnRelayMessage"; \ No newline at end of file diff --git a/server/routers/olm/listUserOlms.ts b/server/routers/olm/listUserOlms.ts new file mode 100644 index 00000000..2756c917 --- /dev/null +++ b/server/routers/olm/listUserOlms.ts @@ -0,0 +1,139 @@ +import { NextFunction, Request, Response } from "express"; +import { db } from "@server/db"; +import { olms } from "@server/db"; +import { eq, count, desc } from "drizzle-orm"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; + +const querySchema = 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()) +}); + +const paramsSchema = z + .object({ + userId: z.string() + }) + .strict(); + +// registry.registerPath({ +// method: "delete", +// path: "/user/{userId}/olms", +// description: "List all olms for a user.", +// tags: [OpenAPITags.User, OpenAPITags.Client], +// request: { +// query: querySchema, +// params: paramsSchema +// }, +// responses: {} +// }); + +export type ListUserOlmsResponse = { + olms: Array<{ + olmId: string; + dateCreated: string; + version: string | null; + name: string | null; + clientId: number | null; + userId: string | null; + }>; + pagination: { + total: number; + limit: number; + offset: number; + }; +}; + +export async function listUserOlms( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const { limit, offset } = parsedQuery.data; + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { userId } = parsedParams.data; + + // Get total count + const [totalCountResult] = await db + .select({ count: count() }) + .from(olms) + .where(eq(olms.userId, userId)); + + const total = totalCountResult?.count || 0; + + // Get OLMs for the current user + const userOlms = await db + .select({ + olmId: olms.olmId, + dateCreated: olms.dateCreated, + version: olms.version, + name: olms.name, + clientId: olms.clientId, + userId: olms.userId + }) + .from(olms) + .where(eq(olms.userId, userId)) + .orderBy(desc(olms.dateCreated)) + .limit(limit) + .offset(offset); + + return response(res, { + data: { + olms: userOlms, + pagination: { + total, + limit, + offset + } + }, + success: true, + error: false, + message: "Olms retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to list OLMs" + ) + ); + } +} diff --git a/server/routers/olm/peers.ts b/server/routers/olm/peers.ts index 396866a1..2ef7a772 100644 --- a/server/routers/olm/peers.ts +++ b/server/routers/olm/peers.ts @@ -3,6 +3,7 @@ import { clients, olms, newts, sites } from "@server/db"; import { eq } from "drizzle-orm"; import { sendToClient } from "#dynamic/routers/ws"; import logger from "@server/logger"; +import { Alias } from "yaml"; export async function addPeer( clientId: number, @@ -10,54 +11,74 @@ export async function addPeer( siteId: number; publicKey: string; endpoint: string; + relayEndpoint: string; serverIP: string | null; serverPort: number | null; - remoteSubnets: string | null; // optional, comma-separated list of subnets that this site can access - } + remoteSubnets: string[] | null; // optional, comma-separated list of subnets that this site can access + aliases: Alias[]; + }, + olmId?: 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`); + if (!olmId) { + const [olm] = await db + .select() + .from(olms) + .where(eq(olms.clientId, clientId)) + .limit(1); + if (!olm) { + return; // ignore this because an olm might not be associated with the client anymore + } + olmId = olm.olmId; } - await sendToClient(olm.olmId, { + await sendToClient(olmId, { type: "olm/wg/peer/add", data: { siteId: peer.siteId, publicKey: peer.publicKey, endpoint: peer.endpoint, + relayEndpoint: peer.relayEndpoint, serverIP: peer.serverIP, serverPort: peer.serverPort, - remoteSubnets: peer.remoteSubnets // optional, comma-separated list of subnets that this site can access + remoteSubnets: peer.remoteSubnets, // optional, comma-separated list of subnets that this site can access + aliases: peer.aliases } + }).catch((error) => { + logger.warn(`Error sending message:`, error); }); - logger.info(`Added peer ${peer.publicKey} to olm ${olm.olmId}`); + logger.info(`Added peer ${peer.publicKey} to 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`); +export async function deletePeer( + clientId: number, + siteId: number, + publicKey: string, + olmId?: string +) { + if (!olmId) { + const [olm] = await db + .select() + .from(olms) + .where(eq(olms.clientId, clientId)) + .limit(1); + if (!olm) { + return; + } + olmId = olm.olmId; } - await sendToClient(olm.olmId, { + await sendToClient(olmId, { type: "olm/wg/peer/remove", data: { publicKey, siteId: siteId } + }).catch((error) => { + logger.warn(`Error sending message:`, error); }); - logger.info(`Deleted peer ${publicKey} from olm ${olm.olmId}`); + logger.info(`Deleted peer ${publicKey} from olm ${olmId}`); } export async function updatePeer( @@ -66,31 +87,80 @@ export async function updatePeer( siteId: number; publicKey: string; endpoint: string; - serverIP: string | null; - serverPort: number | null; - remoteSubnets?: string | null; // optional, comma-separated list of subnets that - } + relayEndpoint?: string; + serverIP?: string | null; + serverPort?: number | null; + remoteSubnets?: string[] | null; // optional, comma-separated list of subnets that + aliases?: Alias[] | null; + }, + olmId?: 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`); + if (!olmId) { + const [olm] = await db + .select() + .from(olms) + .where(eq(olms.clientId, clientId)) + .limit(1); + if (!olm) { + return + } + olmId = olm.olmId; } - await sendToClient(olm.olmId, { + await sendToClient(olmId, { type: "olm/wg/peer/update", data: { siteId: peer.siteId, publicKey: peer.publicKey, endpoint: peer.endpoint, + relayEndpoint: peer.relayEndpoint, serverIP: peer.serverIP, serverPort: peer.serverPort, - remoteSubnets: peer.remoteSubnets + remoteSubnets: peer.remoteSubnets, + aliases: peer.aliases } + }).catch((error) => { + logger.warn(`Error sending message:`, error); }); - logger.info(`Added peer ${peer.publicKey} to olm ${olm.olmId}`); + logger.info(`Updated peer ${peer.publicKey} on olm ${olmId}`); +} + +export async function initPeerAddHandshake( + clientId: number, + peer: { + siteId: number; + exitNode: { + publicKey: string; + endpoint: string; + }; + }, + olmId?: string +) { + if (!olmId) { + const [olm] = await db + .select() + .from(olms) + .where(eq(olms.clientId, clientId)) + .limit(1); + if (!olm) { + return; + } + olmId = olm.olmId; + } + + await sendToClient(olmId, { + type: "olm/wg/peer/holepunch/site/add", + data: { + siteId: peer.siteId, + exitNode: { + publicKey: peer.exitNode.publicKey, + endpoint: peer.exitNode.endpoint + } + } + }).catch((error) => { + logger.warn(`Error sending message:`, error); + }); + + logger.info(`Initiated peer add handshake for site ${peer.siteId} to olm ${olmId}`); } diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index e44bf021..e0e42754 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -26,12 +26,13 @@ import { createCustomer } from "#dynamic/lib/billing"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; import { build } from "@server/build"; +import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; const createOrgSchema = z.strictObject({ - orgId: z.string(), - name: z.string().min(1).max(255), - subnet: z.string() - }); + orgId: z.string(), + name: z.string().min(1).max(255), + subnet: z.string() +}); registry.registerPath({ method: "put", @@ -131,12 +132,16 @@ export async function createOrg( .from(domains) .where(eq(domains.configManaged, true)); + const utilitySubnet = + config.getRawConfig().orgs.utility_subnet_group; + const newOrg = await trx .insert(orgs) .values({ orgId, name, subnet, + utilitySubnet, createdAt: new Date().toISOString() }) .returning(); @@ -190,6 +195,7 @@ export async function createOrg( ); } + let ownerUserId: string | null = null; if (req.user) { await trx.insert(userOrgs).values({ userId: req.user!.userId, @@ -197,6 +203,7 @@ export async function createOrg( roleId: roleId, isOwner: true }); + ownerUserId = req.user!.userId; } else { // if org created by root api key, set the server admin as the owner const [serverAdmin] = await trx @@ -216,6 +223,7 @@ export async function createOrg( roleId: roleId, isOwner: true }); + ownerUserId = serverAdmin.userId; } const memberRole = await trx @@ -234,6 +242,8 @@ export async function createOrg( orgId })) ); + + await calculateUserClientsForOrgs(ownerUserId, trx); }); if (!org) { diff --git a/server/routers/org/deleteOrg.ts b/server/routers/org/deleteOrg.ts index 0e21a8c0..35dc7503 100644 --- a/server/routers/org/deleteOrg.ts +++ b/server/routers/org/deleteOrg.ts @@ -1,6 +1,15 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, domains, orgDomains, resources } from "@server/db"; +import { + clients, + clientSiteResourcesAssociationsCache, + clientSitesAssociationsCache, + db, + domains, + olms, + orgDomains, + resources +} from "@server/db"; import { newts, newtSessions, orgs, sites, userActions } from "@server/db"; import { eq, and, inArray, sql } from "drizzle-orm"; import response from "@server/lib/response"; @@ -14,8 +23,8 @@ import { deletePeer } from "../gerbil/peers"; import { OpenAPITags, registry } from "@server/openApi"; const deleteOrgSchema = z.strictObject({ - orgId: z.string() - }); + orgId: z.string() +}); export type DeleteOrgResponse = {}; @@ -69,41 +78,75 @@ export async function deleteOrg( .where(eq(sites.orgId, orgId)) .limit(1); + const orgClients = await db + .select() + .from(clients) + .where(eq(clients.orgId, orgId)); + const deletedNewtIds: string[] = []; + const olmsToTerminate: string[] = []; await db.transaction(async (trx) => { - if (sites) { - for (const site of orgSites) { - if (site.pubKey) { - if (site.type == "wireguard") { - await deletePeer(site.exitNodeId!, site.pubKey); - } else if (site.type == "newt") { - // get the newt on the site by querying the newt table for siteId - const [deletedNewt] = await trx - .delete(newts) - .where(eq(newts.siteId, site.siteId)) - .returning(); - if (deletedNewt) { - deletedNewtIds.push(deletedNewt.newtId); + for (const site of orgSites) { + if (site.pubKey) { + if (site.type == "wireguard") { + await deletePeer(site.exitNodeId!, site.pubKey); + } else if (site.type == "newt") { + // get the newt on the site by querying the newt table for siteId + const [deletedNewt] = await trx + .delete(newts) + .where(eq(newts.siteId, site.siteId)) + .returning(); + if (deletedNewt) { + deletedNewtIds.push(deletedNewt.newtId); - // delete all of the sessions for the newt - await trx - .delete(newtSessions) - .where( - eq( - newtSessions.newtId, - deletedNewt.newtId - ) - ); - } + // delete all of the sessions for the newt + await trx + .delete(newtSessions) + .where( + eq(newtSessions.newtId, deletedNewt.newtId) + ); } } - - logger.info(`Deleting site ${site.siteId}`); - await trx - .delete(sites) - .where(eq(sites.siteId, site.siteId)); } + + logger.info(`Deleting site ${site.siteId}`); + await trx.delete(sites).where(eq(sites.siteId, site.siteId)); + } + for (const client of orgClients) { + const [olm] = await trx + .select() + .from(olms) + .where(eq(olms.clientId, client.clientId)) + .limit(1); + + if (olm) { + olmsToTerminate.push(olm.olmId); + } + + logger.info(`Deleting client ${client.clientId}`); + await trx + .delete(clients) + .where(eq(clients.clientId, client.clientId)); + + // also delete the associations + await trx + .delete(clientSiteResourcesAssociationsCache) + .where( + eq( + clientSiteResourcesAssociationsCache.clientId, + client.clientId + ) + ); + + await trx + .delete(clientSitesAssociationsCache) + .where( + eq( + clientSitesAssociationsCache.clientId, + client.clientId + ) + ); } const allOrgDomains = await trx @@ -150,7 +193,7 @@ export async function deleteOrg( // Send termination messages outside of transaction to prevent blocking for (const newtId of deletedNewtIds) { const payload = { - type: `newt/terminate`, + type: `newt/wg/terminate`, data: {} }; // Don't await this to prevent blocking the response @@ -162,6 +205,18 @@ export async function deleteOrg( }); } + for (const olmId of olmsToTerminate) { + sendToClient(olmId, { + type: "olm/terminate", + data: {} + }).catch((error) => { + logger.error( + "Failed to send termination message to olm:", + error + ); + }); + } + return response(res, { data: null, success: true, diff --git a/server/routers/org/getOrgOverview.ts b/server/routers/org/getOrgOverview.ts index 90883fd7..dc704d6a 100644 --- a/server/routers/org/getOrgOverview.ts +++ b/server/routers/org/getOrgOverview.ts @@ -130,7 +130,7 @@ export async function getOrgOverview( numSites, numUsers, numResources, - isAdmin: role.name === "Admin", + isAdmin: role.isAdmin || false, isOwner: req.userOrg?.isOwner || false }, success: true, diff --git a/server/routers/resource/addRoleToResource.ts b/server/routers/resource/addRoleToResource.ts new file mode 100644 index 00000000..c29f2757 --- /dev/null +++ b/server/routers/resource/addRoleToResource.ts @@ -0,0 +1,161 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resources } from "@server/db"; +import { roleResources, roles } 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 { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const addRoleToResourceBodySchema = z + .object({ + roleId: z.number().int().positive() + }) + .strict(); + +const addRoleToResourceParamsSchema = z + .object({ + resourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}/roles/add", + description: "Add a single role to a resource.", + tags: [OpenAPITags.Resource, OpenAPITags.Role], + request: { + params: addRoleToResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addRoleToResourceBodySchema + } + } + } + }, + responses: {} +}); + +export async function addRoleToResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = addRoleToResourceBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { roleId } = parsedBody.data; + + const parsedParams = addRoleToResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + // get the resource + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + // verify the role exists and belongs to the same org + const [role] = await db + .select() + .from(roles) + .where( + and( + eq(roles.roleId, roleId), + eq(roles.orgId, resource.orgId) + ) + ) + .limit(1); + + if (!role) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Role not found or does not belong to the same organization" + ) + ); + } + + // Check if the role is an admin role + if (role.isAdmin) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Admin role cannot be assigned to resources" + ) + ); + } + + // Check if role already exists in resource + const existingEntry = await db + .select() + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + eq(roleResources.roleId, roleId) + ) + ); + + if (existingEntry.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Role already assigned to resource" + ) + ); + } + + await db.insert(roleResources).values({ + roleId, + resourceId + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Role added to resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/resource/addUserToResource.ts b/server/routers/resource/addUserToResource.ts new file mode 100644 index 00000000..6dbfe086 --- /dev/null +++ b/server/routers/resource/addUserToResource.ts @@ -0,0 +1,130 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resources } from "@server/db"; +import { userResources } 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 { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const addUserToResourceBodySchema = z + .object({ + userId: z.string() + }) + .strict(); + +const addUserToResourceParamsSchema = z + .object({ + resourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}/users/add", + description: "Add a single user to a resource.", + tags: [OpenAPITags.Resource, OpenAPITags.User], + request: { + params: addUserToResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addUserToResourceBodySchema + } + } + } + }, + responses: {} +}); + +export async function addUserToResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = addUserToResourceBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { userId } = parsedBody.data; + + const parsedParams = addUserToResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + // get the resource + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + // Check if user already exists in resource + const existingEntry = await db + .select() + .from(userResources) + .where( + and( + eq(userResources.resourceId, resourceId), + eq(userResources.userId, userId) + ) + ); + + if (existingEntry.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "User already assigned to resource" + ) + ); + } + + await db.insert(userResources).values({ + userId, + resourceId + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "User added to resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 687195c9..e85d30f5 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -25,4 +25,8 @@ export * from "./getUserResources"; export * from "./setResourceHeaderAuth"; export * from "./addEmailToResourceWhitelist"; export * from "./removeEmailFromResourceWhitelist"; +export * from "./addRoleToResource"; +export * from "./removeRoleFromResource"; +export * from "./addUserToResource"; +export * from "./removeUserFromResource"; export * from "./listAllResourceNames"; diff --git a/server/routers/resource/listAllResourceNames.ts b/server/routers/resource/listAllResourceNames.ts index 80b21fd4..df78e264 100644 --- a/server/routers/resource/listAllResourceNames.ts +++ b/server/routers/resource/listAllResourceNames.ts @@ -1,26 +1,14 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, resourceHeaderAuth } from "@server/db"; -import { - resources, - userResources, - roleResources, - resourcePassword, - resourcePincode, - targets, - targetHealthCheck -} from "@server/db"; +import { db } from "@server/db"; +import { resources } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { sql, eq, or, inArray, and, count } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import type { - ResourceWithTargets, - ListResourcesResponse -} from "./listResources"; const listResourcesParamsSchema = z.strictObject({ orgId: z.string() diff --git a/server/routers/resource/removeRoleFromResource.ts b/server/routers/resource/removeRoleFromResource.ts new file mode 100644 index 00000000..cb44ac4a --- /dev/null +++ b/server/routers/resource/removeRoleFromResource.ts @@ -0,0 +1,166 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resources } from "@server/db"; +import { roleResources, roles } 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 { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const removeRoleFromResourceBodySchema = z + .object({ + roleId: z.number().int().positive() + }) + .strict(); + +const removeRoleFromResourceParamsSchema = z + .object({ + resourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}/roles/remove", + description: "Remove a single role from a resource.", + tags: [OpenAPITags.Resource, OpenAPITags.Role], + request: { + params: removeRoleFromResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeRoleFromResourceBodySchema + } + } + } + }, + responses: {} +}); + +export async function removeRoleFromResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = removeRoleFromResourceBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { roleId } = parsedBody.data; + + const parsedParams = removeRoleFromResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + // get the resource + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + // Check if the role is an admin role + const [roleToCheck] = await db + .select() + .from(roles) + .where( + and( + eq(roles.roleId, roleId), + eq(roles.orgId, resource.orgId) + ) + ) + .limit(1); + + if (!roleToCheck) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Role not found or does not belong to the same organization" + ) + ); + } + + if (roleToCheck.isAdmin) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Admin role cannot be removed from resources" + ) + ); + } + + // Check if role exists in resource + const existingEntry = await db + .select() + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + eq(roleResources.roleId, roleId) + ) + ); + + if (existingEntry.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Role not found in resource" + ) + ); + } + + await db + .delete(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + eq(roleResources.roleId, roleId) + ) + ); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Role removed from resource successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/resource/removeUserFromResource.ts b/server/routers/resource/removeUserFromResource.ts new file mode 100644 index 00000000..8dce7e48 --- /dev/null +++ b/server/routers/resource/removeUserFromResource.ts @@ -0,0 +1,136 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resources } from "@server/db"; +import { userResources } 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 { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const removeUserFromResourceBodySchema = z + .object({ + userId: z.string() + }) + .strict(); + +const removeUserFromResourceParamsSchema = z + .object({ + resourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}/users/remove", + description: "Remove a single user from a resource.", + tags: [OpenAPITags.Resource, OpenAPITags.User], + request: { + params: removeUserFromResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeUserFromResourceBodySchema + } + } + } + }, + responses: {} +}); + +export async function removeUserFromResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = removeUserFromResourceBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { userId } = parsedBody.data; + + const parsedParams = removeUserFromResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + // get the resource + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + // Check if user exists in resource + const existingEntry = await db + .select() + .from(userResources) + .where( + and( + eq(userResources.resourceId, resourceId), + eq(userResources.userId, userId) + ) + ); + + if (existingEntry.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found in resource" + ) + ); + } + + await db + .delete(userResources) + .where( + and( + eq(userResources.resourceId, resourceId), + eq(userResources.userId, userId) + ) + ); + + return response(res, { + data: {}, + success: true, + error: false, + message: "User removed from resource successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/resource/setResourceRoles.ts b/server/routers/resource/setResourceRoles.ts index 19b7b601..5064c7e0 100644 --- a/server/routers/resource/setResourceRoles.ts +++ b/server/routers/resource/setResourceRoles.ts @@ -7,7 +7,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import { eq, and, ne } from "drizzle-orm"; +import { eq, and, ne, inArray } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; const setResourceRolesBodySchema = z.strictObject({ @@ -86,28 +86,20 @@ export async function setResourceRoles( ); } - // get this org's admin role - const adminRole = await db + // Check if any of the roleIds are admin roles + const rolesToCheck = await db .select() .from(roles) .where( and( - eq(roles.name, "Admin"), + inArray(roles.roleId, roleIds), eq(roles.orgId, resource.orgId) ) - ) - .limit(1); - - if (!adminRole.length) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Admin role not found" - ) ); - } - if (roleIds.includes(adminRole[0].roleId)) { + const hasAdminRole = rolesToCheck.some((role) => role.isAdmin); + + if (hasAdminRole) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -116,13 +108,31 @@ export async function setResourceRoles( ); } - await db.transaction(async (trx) => { - await trx.delete(roleResources).where( + // Get all admin role IDs for this org to exclude from deletion + const adminRoles = await db + .select() + .from(roles) + .where( and( - eq(roleResources.resourceId, resourceId), - ne(roleResources.roleId, adminRole[0].roleId) // delete all but the admin role + eq(roles.isAdmin, true), + eq(roles.orgId, resource.orgId) ) ); + const adminRoleIds = adminRoles.map((role) => role.roleId); + + await db.transaction(async (trx) => { + if (adminRoleIds.length > 0) { + await trx.delete(roleResources).where( + and( + eq(roleResources.resourceId, resourceId), + ne(roleResources.roleId, adminRoleIds[0]) // delete all but the admin role + ) + ); + } else { + await trx.delete(roleResources).where( + eq(roleResources.resourceId, resourceId) + ); + } const newRoleResources = await Promise.all( roleIds.map((roleId) => diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 1008bac9..f3792e28 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -329,9 +329,11 @@ async function updateHttpResource( } } - let headers = null; + let headers = undefined; if (updateData.headers) { headers = JSON.stringify(updateData.headers); + } else if (updateData.headers === null) { + headers = null; } const updatedResource = await db diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index 81a35451..2ec8d3dc 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -198,6 +198,62 @@ export async function createSite( } } + if (subnet && exitNodeId) { + //make sure the subnet is in the range of the exit node if provided + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, exitNodeId)); + + if (!exitNode) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Exit node not found") + ); + } + + if (!exitNode.address) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Exit node has no subnet defined" + ) + ); + } + + const subnetIp = subnet.split("/")[0]; + + if (!isIpInCidr(subnetIp, exitNode.address)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Subnet is not in the CIDR range of the exit node address." + ) + ); + } + + // lets also make sure there is no overlap with other sites on the exit node + const sitesQuery = await db + .select({ + subnet: sites.subnet + }) + .from(sites) + .where( + and( + eq(sites.exitNodeId, exitNodeId), + eq(sites.subnet, subnet) + ) + ); + + if (sitesQuery.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Subnet ${subnet} overlaps with an existing site on this exit node. Please restart site creation.` + ) + ); + } + } + const niceId = await getUniqueSiteName(orgId); let newSite: Site; diff --git a/server/routers/siteResource/addClientToSiteResource.ts b/server/routers/siteResource/addClientToSiteResource.ts new file mode 100644 index 00000000..587294e5 --- /dev/null +++ b/server/routers/siteResource/addClientToSiteResource.ts @@ -0,0 +1,156 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources, clients, clientSiteResources } 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 { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; + +const addClientToSiteResourceBodySchema = z + .object({ + clientId: z.number().int().positive() + }) + .strict(); + +const addClientToSiteResourceParamsSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/clients/add", + description: "Add a single client to a site resource. Clients with a userId cannot be added.", + tags: [OpenAPITags.Resource, OpenAPITags.Client], + request: { + params: addClientToSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addClientToSiteResourceBodySchema + } + } + } + }, + responses: {} +}); + +export async function addClientToSiteResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = addClientToSiteResourceBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { clientId } = parsedBody.data; + + const parsedParams = addClientToSiteResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + // get the site resource + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + // Check if client exists and has a userId + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Client not found") + ); + } + + if (client.userId !== null) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Cannot add clients that are associated with a user" + ) + ); + } + + // Check if client already exists in site resource + const existingEntry = await db + .select() + .from(clientSiteResources) + .where( + and( + eq(clientSiteResources.siteResourceId, siteResourceId), + eq(clientSiteResources.clientId, clientId) + ) + ); + + if (existingEntry.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Client already assigned to site resource" + ) + ); + } + + await db.transaction(async (trx) => { + await trx.insert(clientSiteResources).values({ + clientId, + siteResourceId + }); + + await rebuildClientAssociationsFromSiteResource(siteResource, trx); + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Client added to site resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/siteResource/addRoleToSiteResource.ts b/server/routers/siteResource/addRoleToSiteResource.ts new file mode 100644 index 00000000..542ca535 --- /dev/null +++ b/server/routers/siteResource/addRoleToSiteResource.ts @@ -0,0 +1,166 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources } from "@server/db"; +import { roleSiteResources, roles } 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 { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; + +const addRoleToSiteResourceBodySchema = z + .object({ + roleId: z.number().int().positive() + }) + .strict(); + +const addRoleToSiteResourceParamsSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/roles/add", + description: "Add a single role to a site resource.", + tags: [OpenAPITags.Resource, OpenAPITags.Role], + request: { + params: addRoleToSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addRoleToSiteResourceBodySchema + } + } + } + }, + responses: {} +}); + +export async function addRoleToSiteResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = addRoleToSiteResourceBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { roleId } = parsedBody.data; + + const parsedParams = addRoleToSiteResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + // get the site resource + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + // verify the role exists and belongs to the same org + const [role] = await db + .select() + .from(roles) + .where( + and( + eq(roles.roleId, roleId), + eq(roles.orgId, siteResource.orgId) + ) + ) + .limit(1); + + if (!role) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Role not found or does not belong to the same organization" + ) + ); + } + + // Check if the role is an admin role + if (role.isAdmin) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Admin role cannot be assigned to site resources" + ) + ); + } + + // Check if role already exists in site resource + const existingEntry = await db + .select() + .from(roleSiteResources) + .where( + and( + eq(roleSiteResources.siteResourceId, siteResourceId), + eq(roleSiteResources.roleId, roleId) + ) + ); + + if (existingEntry.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Role already assigned to site resource" + ) + ); + } + + await db.transaction(async (trx) => { + await trx.insert(roleSiteResources).values({ + roleId, + siteResourceId + }); + + await rebuildClientAssociationsFromSiteResource(siteResource, trx); + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Role added to site resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/siteResource/addUserToSiteResource.ts b/server/routers/siteResource/addUserToSiteResource.ts new file mode 100644 index 00000000..c9d1f30a --- /dev/null +++ b/server/routers/siteResource/addUserToSiteResource.ts @@ -0,0 +1,135 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources } from "@server/db"; +import { userSiteResources } 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 { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; + +const addUserToSiteResourceBodySchema = z + .object({ + userId: z.string() + }) + .strict(); + +const addUserToSiteResourceParamsSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/users/add", + description: "Add a single user to a site resource.", + tags: [OpenAPITags.Resource, OpenAPITags.User], + request: { + params: addUserToSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addUserToSiteResourceBodySchema + } + } + } + }, + responses: {} +}); + +export async function addUserToSiteResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = addUserToSiteResourceBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { userId } = parsedBody.data; + + const parsedParams = addUserToSiteResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + // get the site resource + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + // Check if user already exists in site resource + const existingEntry = await db + .select() + .from(userSiteResources) + .where( + and( + eq(userSiteResources.siteResourceId, siteResourceId), + eq(userSiteResources.userId, userId) + ) + ); + + if (existingEntry.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "User already assigned to site resource" + ) + ); + } + + await db.transaction(async (trx) => { + await trx.insert(userSiteResources).values({ + userId, + siteResourceId + }); + + await rebuildClientAssociationsFromSiteResource(siteResource, trx); + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "User added to site resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index b77b52e4..27280f7a 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -1,30 +1,93 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, newts } from "@server/db"; -import { siteResources, sites, orgs, SiteResource } from "@server/db"; +import { + clientSiteResources, + db, + newts, + roles, + roleSiteResources, + userSiteResources +} from "@server/db"; +import { siteResources, sites, SiteResource } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { eq, and } from "drizzle-orm"; +import { eq, and, is } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; -import { addTargets } from "../client/targets"; import { getUniqueSiteResourceName } from "@server/db/names"; +import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { getNextAvailableAliasAddress } from "@server/lib/ip"; const createSiteResourceParamsSchema = z.strictObject({ - siteId: z.string().transform(Number).pipe(z.int().positive()), - orgId: z.string() - }); + siteId: z.string().transform(Number).pipe(z.int().positive()), + orgId: z.string() +}); -const createSiteResourceSchema = z.strictObject({ +const createSiteResourceSchema = z + .strictObject({ name: z.string().min(1).max(255), - protocol: z.enum(["tcp", "udp"]), - proxyPort: z.int().positive(), - destinationPort: z.int().positive(), - destinationIp: z.string(), - enabled: z.boolean().default(true) - }); + mode: z.enum(["host", "cidr", "port"]), + // protocol: z.enum(["tcp", "udp"]).optional(), + // proxyPort: z.int().positive().optional(), + // destinationPort: z.int().positive().optional(), + destination: z.string().min(1), + enabled: z.boolean().default(true), + alias: z + .string() + .regex( + /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/, + "Alias must be a fully qualified domain name (e.g., example.com)" + ) + .optional(), + userIds: z.array(z.string()), + roleIds: z.array(z.int()), + clientIds: z.array(z.int()) + }) + .strict() + .refine( + (data) => { + if (data.mode === "host") { + // Check if it's a valid IP address using zod (v4 or v6) + const isValidIP = z + .union([z.ipv4(), z.ipv6()]) + .safeParse(data.destination).success; + + if (isValidIP) { + return true + } + + // Check if it's a valid domain (hostname pattern, TLD not required) + const domainRegex = + /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; + const isValidDomain = domainRegex.test(data.destination); + const isValidAlias = data.alias && domainRegex.test(data.alias); + + return isValidDomain && isValidAlias; // require the alias to be set in the case of domain + } + return true; + }, + { + message: + "Destination must be a valid IP address or domain name for host mode" + } + ) + .refine( + (data) => { + if (data.mode === "cidr") { + // Check if it's a valid CIDR (v4 or v6) + const isValidCIDR = z + .union([z.cidrv4(), z.cidrv6()]) + .safeParse(data.destination).success; + return isValidCIDR; + } + return true; + }, + { + message: "Destination must be a valid CIDR notation for cidr mode" + } + ); export type CreateSiteResourceBody = z.infer; export type CreateSiteResourceResponse = SiteResource; @@ -78,11 +141,16 @@ export async function createSiteResource( const { siteId, orgId } = parsedParams.data; const { name, - protocol, - proxyPort, - destinationPort, - destinationIp, - enabled + mode, + // protocol, + // proxyPort, + // destinationPort, + destination, + enabled, + alias, + userIds, + roleIds, + clientIds } = parsedBody.data; // Verify the site exists and belongs to the org @@ -96,57 +164,153 @@ export async function createSiteResource( return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); } - // check if resource with same protocol and proxy port already exists - const [existingResource] = await db - .select() - .from(siteResources) - .where( - and( - eq(siteResources.siteId, siteId), - eq(siteResources.orgId, orgId), - eq(siteResources.protocol, protocol), - eq(siteResources.proxyPort, proxyPort) + // // check if resource with same protocol and proxy port already exists (only for port mode) + // if (mode === "port" && protocol && proxyPort) { + // const [existingResource] = await db + // .select() + // .from(siteResources) + // .where( + // and( + // eq(siteResources.siteId, siteId), + // eq(siteResources.orgId, orgId), + // eq(siteResources.protocol, protocol), + // eq(siteResources.proxyPort, proxyPort) + // ) + // ) + // .limit(1); + // if (existingResource && existingResource.siteResourceId) { + // return next( + // createHttpError( + // HttpCode.CONFLICT, + // "A resource with the same protocol and proxy port already exists" + // ) + // ); + // } + // } + + // make sure the alias is unique within the org if provided + if (alias) { + const [conflict] = await db + .select() + .from(siteResources) + .where( + and( + eq(siteResources.orgId, orgId), + eq(siteResources.alias, alias.trim()) + ) ) - ) - .limit(1); - if (existingResource && existingResource.siteResourceId) { - return next( - createHttpError( - HttpCode.CONFLICT, - "A resource with the same protocol and proxy port already exists" - ) - ); + .limit(1); + + if (conflict) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Alias already in use by another site resource" + ) + ); + } } const niceId = await getUniqueSiteResourceName(orgId); - - // Create the site resource - const [newSiteResource] = await db - .insert(siteResources) - .values({ - siteId, - niceId, - orgId, - name, - protocol, - proxyPort, - destinationPort, - destinationIp, - enabled - }) - .returning(); - - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); - - if (!newt) { - return next(createHttpError(HttpCode.NOT_FOUND, "Newt not found")); + let aliasAddress: string | null = null; + if (mode == "host") { + // we can only have an alias on a host + aliasAddress = await getNextAvailableAliasAddress(orgId); } - await addTargets(newt.newtId, destinationIp, destinationPort, protocol, proxyPort); + let newSiteResource: SiteResource | undefined; + await db.transaction(async (trx) => { + // Create the site resource + [newSiteResource] = await trx + .insert(siteResources) + .values({ + siteId, + niceId, + orgId, + name, + mode, + // protocol: mode === "port" ? protocol : null, + // proxyPort: mode === "port" ? proxyPort : null, + // destinationPort: mode === "port" ? destinationPort : null, + destination, + enabled, + alias, + aliasAddress + }) + .returning(); + + const siteResourceId = newSiteResource.siteResourceId; + + //////////////////// update the associations //////////////////// + + const [adminRole] = await trx + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); + + if (!adminRole) { + return next( + createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) + ); + } + + await trx.insert(roleSiteResources).values({ + roleId: adminRole.roleId, + siteResourceId: siteResourceId + }); + + if (roleIds.length > 0) { + await trx + .insert(roleSiteResources) + .values( + roleIds.map((roleId) => ({ roleId, siteResourceId })) + ); + } + + if (userIds.length > 0) { + await trx + .insert(userSiteResources) + .values( + userIds.map((userId) => ({ userId, siteResourceId })) + ); + } + + if (clientIds.length > 0) { + await trx.insert(clientSiteResources).values( + clientIds.map((clientId) => ({ + clientId, + siteResourceId + })) + ); + } + + const [newt] = await trx + .select() + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); + + if (!newt) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Newt not found") + ); + } + + await rebuildClientAssociationsFromSiteResource( + newSiteResource, + trx + ); // we need to call this because we added to the admin role + }); + + if (!newSiteResource) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Site resource creation failed" + ) + ); + } logger.info( `Created site resource ${newSiteResource.siteResourceId} for site ${siteId}` diff --git a/server/routers/siteResource/deleteSiteResource.ts b/server/routers/siteResource/deleteSiteResource.ts index 02bc2c72..a7175608 100644 --- a/server/routers/siteResource/deleteSiteResource.ts +++ b/server/routers/siteResource/deleteSiteResource.ts @@ -9,13 +9,13 @@ import { eq, and } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; -import { removeTargets } from "../client/targets"; +import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; const deleteSiteResourceParamsSchema = z.strictObject({ - siteResourceId: z.string().transform(Number).pipe(z.int().positive()), - siteId: z.string().transform(Number).pipe(z.int().positive()), - orgId: z.string() - }); + siteResourceId: z.string().transform(Number).pipe(z.int().positive()), + siteId: z.string().transform(Number).pipe(z.int().positive()), + orgId: z.string() +}); export type DeleteSiteResourceResponse = { message: string; @@ -38,7 +38,9 @@ export async function deleteSiteResource( next: NextFunction ): Promise { try { - const parsedParams = deleteSiteResourceParamsSchema.safeParse(req.params); + const parsedParams = deleteSiteResourceParamsSchema.safeParse( + req.params + ); if (!parsedParams.success) { return next( createHttpError( @@ -64,51 +66,53 @@ export async function deleteSiteResource( const [existingSiteResource] = await db .select() .from(siteResources) - .where(and( - eq(siteResources.siteResourceId, siteResourceId), - eq(siteResources.siteId, siteId), - eq(siteResources.orgId, orgId) - )) + .where( + and( + eq(siteResources.siteResourceId, siteResourceId), + eq(siteResources.siteId, siteId), + eq(siteResources.orgId, orgId) + ) + ) .limit(1); if (!existingSiteResource) { return next( - createHttpError( - HttpCode.NOT_FOUND, - "Site resource not found" - ) + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") ); } - // Delete the site resource - await db - .delete(siteResources) - .where(and( - eq(siteResources.siteResourceId, siteResourceId), - eq(siteResources.siteId, siteId), - eq(siteResources.orgId, orgId) - )); + await db.transaction(async (trx) => { + // Delete the site resource + const [removedSiteResource] = await trx + .delete(siteResources) + .where( + and( + eq(siteResources.siteResourceId, siteResourceId), + eq(siteResources.siteId, siteId), + eq(siteResources.orgId, orgId) + ) + ) + .returning(); - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); + const [newt] = await trx + .select() + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); - if (!newt) { - return next(createHttpError(HttpCode.NOT_FOUND, "Newt not found")); - } + if (!newt) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Newt not found") + ); + } - await removeTargets( - newt.newtId, - existingSiteResource.destinationIp, - existingSiteResource.destinationPort, - existingSiteResource.protocol, - existingSiteResource.proxyPort + await rebuildClientAssociationsFromSiteResource(removedSiteResource, trx); + }); + + logger.info( + `Deleted site resource ${siteResourceId} for site ${siteId}` ); - logger.info(`Deleted site resource ${siteResourceId} for site ${siteId}`); - return response(res, { data: { message: "Site resource deleted successfully" }, success: true, @@ -118,6 +122,11 @@ export async function deleteSiteResource( }); } catch (error) { logger.error("Error deleting site resource:", error); - return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Failed to delete site resource")); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to delete site resource" + ) + ); } } diff --git a/server/routers/siteResource/index.ts b/server/routers/siteResource/index.ts index 2c3e2526..9494843b 100644 --- a/server/routers/siteResource/index.ts +++ b/server/routers/siteResource/index.ts @@ -4,3 +4,15 @@ export * from "./getSiteResource"; export * from "./updateSiteResource"; export * from "./listSiteResources"; export * from "./listAllSiteResourcesByOrg"; +export * from "./listSiteResourceRoles"; +export * from "./listSiteResourceUsers"; +export * from "./listSiteResourceClients"; +export * from "./setSiteResourceRoles"; +export * from "./setSiteResourceUsers"; +export * from "./addRoleToSiteResource"; +export * from "./removeRoleFromSiteResource"; +export * from "./addUserToSiteResource"; +export * from "./removeUserFromSiteResource"; +export * from "./setSiteResourceClients"; +export * from "./addClientToSiteResource"; +export * from "./removeClientFromSiteResource"; diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 96b9a668..5de66505 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -30,7 +30,7 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({ }); export type ListAllSiteResourcesByOrgResponse = { - siteResources: (SiteResource & { siteName: string, siteNiceId: string })[]; + siteResources: (SiteResource & { siteName: string, siteNiceId: string, siteAddress: string | null })[]; }; registry.registerPath({ @@ -80,14 +80,18 @@ export async function listAllSiteResourcesByOrg( siteResourceId: siteResources.siteResourceId, siteId: siteResources.siteId, orgId: siteResources.orgId, + niceId: siteResources.niceId, name: siteResources.name, + mode: siteResources.mode, protocol: siteResources.protocol, proxyPort: siteResources.proxyPort, destinationPort: siteResources.destinationPort, - destinationIp: siteResources.destinationIp, + destination: siteResources.destination, enabled: siteResources.enabled, + alias: siteResources.alias, siteName: sites.name, - siteNiceId: sites.niceId + siteNiceId: sites.niceId, + siteAddress: sites.address }) .from(siteResources) .innerJoin(sites, eq(siteResources.siteId, sites.siteId)) diff --git a/server/routers/siteResource/listSiteResourceClients.ts b/server/routers/siteResource/listSiteResourceClients.ts new file mode 100644 index 00000000..9b04ac32 --- /dev/null +++ b/server/routers/siteResource/listSiteResourceClients.ts @@ -0,0 +1,85 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { clientSiteResources, clients } 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 listSiteResourceClientsSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +async function queryClients(siteResourceId: number) { + return await db + .select({ + clientId: clientSiteResources.clientId, + name: clients.name, + subnet: clients.subnet + }) + .from(clientSiteResources) + .innerJoin(clients, eq(clientSiteResources.clientId, clients.clientId)) + .where(eq(clientSiteResources.siteResourceId, siteResourceId)); +} + +export type ListSiteResourceClientsResponse = { + clients: NonNullable>>; +}; + +registry.registerPath({ + method: "get", + path: "/site-resource/{siteResourceId}/clients", + description: "List all clients for a site resource.", + tags: [OpenAPITags.Resource, OpenAPITags.Client], + request: { + params: listSiteResourceClientsSchema + }, + responses: {} +}); + +export async function listSiteResourceClients( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = listSiteResourceClientsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + const siteResourceClientsList = await queryClients(siteResourceId); + + return response(res, { + data: { + clients: siteResourceClientsList + }, + success: true, + error: false, + message: "Site resource 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/siteResource/listSiteResourceRoles.ts b/server/routers/siteResource/listSiteResourceRoles.ts new file mode 100644 index 00000000..5504c003 --- /dev/null +++ b/server/routers/siteResource/listSiteResourceRoles.ts @@ -0,0 +1,86 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { roleSiteResources, roles } 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 listSiteResourceRolesSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +async function query(siteResourceId: number) { + return await db + .select({ + roleId: roles.roleId, + name: roles.name, + description: roles.description, + isAdmin: roles.isAdmin + }) + .from(roleSiteResources) + .innerJoin(roles, eq(roleSiteResources.roleId, roles.roleId)) + .where(eq(roleSiteResources.siteResourceId, siteResourceId)); +} + +export type ListSiteResourceRolesResponse = { + roles: NonNullable>>; +}; + +registry.registerPath({ + method: "get", + path: "/site-resource/{siteResourceId}/roles", + description: "List all roles for a site resource.", + tags: [OpenAPITags.Resource, OpenAPITags.Role], + request: { + params: listSiteResourceRolesSchema + }, + responses: {} +}); + +export async function listSiteResourceRoles( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = listSiteResourceRolesSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + const siteResourceRolesList = await query(siteResourceId); + + return response(res, { + data: { + roles: siteResourceRolesList + }, + success: true, + error: false, + message: "Site resource roles 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/siteResource/listSiteResourceUsers.ts b/server/routers/siteResource/listSiteResourceUsers.ts new file mode 100644 index 00000000..6cc19557 --- /dev/null +++ b/server/routers/siteResource/listSiteResourceUsers.ts @@ -0,0 +1,89 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { idp, userSiteResources, users } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const listSiteResourceUsersSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +async function queryUsers(siteResourceId: number) { + return await db + .select({ + userId: userSiteResources.userId, + username: users.username, + type: users.type, + idpName: idp.name, + idpId: users.idpId, + email: users.email + }) + .from(userSiteResources) + .innerJoin(users, eq(userSiteResources.userId, users.userId)) + .leftJoin(idp, eq(users.idpId, idp.idpId)) + .where(eq(userSiteResources.siteResourceId, siteResourceId)); +} + +export type ListSiteResourceUsersResponse = { + users: NonNullable>>; +}; + +registry.registerPath({ + method: "get", + path: "/site-resource/{siteResourceId}/users", + description: "List all users for a site resource.", + tags: [OpenAPITags.Resource, OpenAPITags.User], + request: { + params: listSiteResourceUsersSchema + }, + responses: {} +}); + +export async function listSiteResourceUsers( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = listSiteResourceUsersSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + const siteResourceUsersList = await queryUsers(siteResourceId); + + return response(res, { + data: { + users: siteResourceUsersList + }, + success: true, + error: false, + message: "Site resource users 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/siteResource/removeClientFromSiteResource.ts b/server/routers/siteResource/removeClientFromSiteResource.ts new file mode 100644 index 00000000..c6a5dfe8 --- /dev/null +++ b/server/routers/siteResource/removeClientFromSiteResource.ts @@ -0,0 +1,162 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources, clients, clientSiteResources } 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 { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; + +const removeClientFromSiteResourceBodySchema = z + .object({ + clientId: z.number().int().positive() + }) + .strict(); + +const removeClientFromSiteResourceParamsSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/clients/remove", + description: "Remove a single client from a site resource. Clients with a userId cannot be removed.", + tags: [OpenAPITags.Resource, OpenAPITags.Client], + request: { + params: removeClientFromSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeClientFromSiteResourceBodySchema + } + } + } + }, + responses: {} +}); + +export async function removeClientFromSiteResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = removeClientFromSiteResourceBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { clientId } = parsedBody.data; + + const parsedParams = removeClientFromSiteResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + // get the site resource + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + // Check if client exists and has a userId + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Client not found") + ); + } + + if (client.userId !== null) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Cannot remove clients that are associated with a user" + ) + ); + } + + // Check if client exists in site resource + const existingEntry = await db + .select() + .from(clientSiteResources) + .where( + and( + eq(clientSiteResources.siteResourceId, siteResourceId), + eq(clientSiteResources.clientId, clientId) + ) + ); + + if (existingEntry.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Client not found in site resource" + ) + ); + } + + await db.transaction(async (trx) => { + await trx + .delete(clientSiteResources) + .where( + and( + eq(clientSiteResources.siteResourceId, siteResourceId), + eq(clientSiteResources.clientId, clientId) + ) + ); + + await rebuildClientAssociationsFromSiteResource(siteResource, trx); + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Client removed from site resource successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/siteResource/removeRoleFromSiteResource.ts b/server/routers/siteResource/removeRoleFromSiteResource.ts new file mode 100644 index 00000000..0041ed83 --- /dev/null +++ b/server/routers/siteResource/removeRoleFromSiteResource.ts @@ -0,0 +1,171 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources } from "@server/db"; +import { roleSiteResources, roles } 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 { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; + +const removeRoleFromSiteResourceBodySchema = z + .object({ + roleId: z.number().int().positive() + }) + .strict(); + +const removeRoleFromSiteResourceParamsSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/roles/remove", + description: "Remove a single role from a site resource.", + tags: [OpenAPITags.Resource, OpenAPITags.Role], + request: { + params: removeRoleFromSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeRoleFromSiteResourceBodySchema + } + } + } + }, + responses: {} +}); + +export async function removeRoleFromSiteResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = removeRoleFromSiteResourceBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { roleId } = parsedBody.data; + + const parsedParams = removeRoleFromSiteResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + // get the site resource + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + // Check if the role is an admin role + const [roleToCheck] = await db + .select() + .from(roles) + .where( + and( + eq(roles.roleId, roleId), + eq(roles.orgId, siteResource.orgId) + ) + ) + .limit(1); + + if (!roleToCheck) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Role not found or does not belong to the same organization" + ) + ); + } + + if (roleToCheck.isAdmin) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Admin role cannot be removed from site resources" + ) + ); + } + + // Check if role exists in site resource + const existingEntry = await db + .select() + .from(roleSiteResources) + .where( + and( + eq(roleSiteResources.siteResourceId, siteResourceId), + eq(roleSiteResources.roleId, roleId) + ) + ); + + if (existingEntry.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Role not found in site resource" + ) + ); + } + + await db.transaction(async (trx) => { + await trx + .delete(roleSiteResources) + .where( + and( + eq(roleSiteResources.siteResourceId, siteResourceId), + eq(roleSiteResources.roleId, roleId) + ) + ); + + await rebuildClientAssociationsFromSiteResource(siteResource, trx); + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Role removed from site resource successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/siteResource/removeUserFromSiteResource.ts b/server/routers/siteResource/removeUserFromSiteResource.ts new file mode 100644 index 00000000..280a01f2 --- /dev/null +++ b/server/routers/siteResource/removeUserFromSiteResource.ts @@ -0,0 +1,141 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources } from "@server/db"; +import { userSiteResources } 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 { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; + +const removeUserFromSiteResourceBodySchema = z + .object({ + userId: z.string() + }) + .strict(); + +const removeUserFromSiteResourceParamsSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/users/remove", + description: "Remove a single user from a site resource.", + tags: [OpenAPITags.Resource, OpenAPITags.User], + request: { + params: removeUserFromSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeUserFromSiteResourceBodySchema + } + } + } + }, + responses: {} +}); + +export async function removeUserFromSiteResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = removeUserFromSiteResourceBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { userId } = parsedBody.data; + + const parsedParams = removeUserFromSiteResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + // get the site resource + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + // Check if user exists in site resource + const existingEntry = await db + .select() + .from(userSiteResources) + .where( + and( + eq(userSiteResources.siteResourceId, siteResourceId), + eq(userSiteResources.userId, userId) + ) + ); + + if (existingEntry.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found in site resource" + ) + ); + } + + await db.transaction(async (trx) => { + await trx + .delete(userSiteResources) + .where( + and( + eq(userSiteResources.siteResourceId, siteResourceId), + eq(userSiteResources.userId, userId) + ) + ); + + await rebuildClientAssociationsFromSiteResource(siteResource, trx); + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "User removed from site resource successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/siteResource/setSiteResourceClients.ts b/server/routers/siteResource/setSiteResourceClients.ts new file mode 100644 index 00000000..0a25b7e9 --- /dev/null +++ b/server/routers/siteResource/setSiteResourceClients.ts @@ -0,0 +1,144 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources, clients, clientSiteResources } 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 { eq, inArray } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; + +const setSiteResourceClientsBodySchema = z + .object({ + clientIds: z.array(z.number().int().positive()) + }) + .strict(); + +const setSiteResourceClientsParamsSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/clients", + description: + "Set clients for a site resource. This will replace all existing clients. Clients with a userId cannot be added.", + tags: [OpenAPITags.Resource, OpenAPITags.Client], + request: { + params: setSiteResourceClientsParamsSchema, + body: { + content: { + "application/json": { + schema: setSiteResourceClientsBodySchema + } + } + } + }, + responses: {} +}); + +export async function setSiteResourceClients( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = setSiteResourceClientsBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { clientIds } = parsedBody.data; + + const parsedParams = setSiteResourceClientsParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + // get the site resource + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Site resource not found" + ) + ); + } + + // Check if any clients have a userId (associated with a user) + if (clientIds.length > 0) { + const clientsWithUsers = await db + .select() + .from(clients) + .where( + inArray(clients.clientId, clientIds) + ); + + const clientsWithUserId = clientsWithUsers.filter( + (client) => client.userId !== null + ); + + if (clientsWithUserId.length > 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Cannot add clients that are associated with a user" + ) + ); + } + } + + await db.transaction(async (trx) => { + await trx + .delete(clientSiteResources) + .where(eq(clientSiteResources.siteResourceId, siteResourceId)); + + if (clientIds.length > 0) { + await trx + .insert(clientSiteResources) + .values(clientIds.map((clientId) => ({ clientId, siteResourceId }))); + } + + await rebuildClientAssociationsFromSiteResource(siteResource, trx); + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Clients set for site resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/siteResource/setSiteResourceRoles.ts b/server/routers/siteResource/setSiteResourceRoles.ts new file mode 100644 index 00000000..7aa07de1 --- /dev/null +++ b/server/routers/siteResource/setSiteResourceRoles.ts @@ -0,0 +1,166 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources } from "@server/db"; +import { roleSiteResources, roles } 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 { eq, and, ne, inArray } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; + +const setSiteResourceRolesBodySchema = z + .object({ + roleIds: z.array(z.number().int().positive()) + }) + .strict(); + +const setSiteResourceRolesParamsSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/roles", + description: + "Set roles for a site resource. This will replace all existing roles.", + tags: [OpenAPITags.Resource, OpenAPITags.Role], + request: { + params: setSiteResourceRolesParamsSchema, + body: { + content: { + "application/json": { + schema: setSiteResourceRolesBodySchema + } + } + } + }, + responses: {} +}); + +export async function setSiteResourceRoles( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = setSiteResourceRolesBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { roleIds } = parsedBody.data; + + const parsedParams = setSiteResourceRolesParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + // get the site resource + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Site resource not found" + ) + ); + } + + // Check if any of the roleIds are admin roles + const rolesToCheck = await db + .select() + .from(roles) + .where( + and( + inArray(roles.roleId, roleIds), + eq(roles.orgId, siteResource.orgId) + ) + ); + + const hasAdminRole = rolesToCheck.some((role) => role.isAdmin); + + if (hasAdminRole) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Admin role cannot be assigned to site resources" + ) + ); + } + + // Get all admin role IDs for this org to exclude from deletion + const adminRoles = await db + .select() + .from(roles) + .where( + and( + eq(roles.isAdmin, true), + eq(roles.orgId, siteResource.orgId) + ) + ); + const adminRoleIds = adminRoles.map((role) => role.roleId); + + await db.transaction(async (trx) => { + if (adminRoleIds.length > 0) { + await trx.delete(roleSiteResources).where( + and( + eq(roleSiteResources.siteResourceId, siteResourceId), + ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role + ) + ); + } else { + await trx.delete(roleSiteResources).where( + eq(roleSiteResources.siteResourceId, siteResourceId) + ); + } + + if (roleIds.length > 0) { + await trx + .insert(roleSiteResources) + .values(roleIds.map((roleId) => ({ roleId, siteResourceId }))); + } + + await rebuildClientAssociationsFromSiteResource(siteResource, trx); + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Roles set for site resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/siteResource/setSiteResourceUsers.ts b/server/routers/siteResource/setSiteResourceUsers.ts new file mode 100644 index 00000000..4dae0ada --- /dev/null +++ b/server/routers/siteResource/setSiteResourceUsers.ts @@ -0,0 +1,122 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources } from "@server/db"; +import { userSiteResources } 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 { eq } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; + +const setSiteResourceUsersBodySchema = z + .object({ + userIds: z.array(z.string()) + }) + .strict(); + +const setSiteResourceUsersParamsSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/users", + description: + "Set users for a site resource. This will replace all existing users.", + tags: [OpenAPITags.Resource, OpenAPITags.User], + request: { + params: setSiteResourceUsersParamsSchema, + body: { + content: { + "application/json": { + schema: setSiteResourceUsersBodySchema + } + } + } + }, + responses: {} +}); + +export async function setSiteResourceUsers( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = setSiteResourceUsersBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { userIds } = parsedBody.data; + + const parsedParams = setSiteResourceUsersParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + // get the site resource + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Site resource not found" + ) + ); + } + + await db.transaction(async (trx) => { + await trx + .delete(userSiteResources) + .where(eq(userSiteResources.siteResourceId, siteResourceId)); + + if (userIds.length > 0) { + await trx + .insert(userSiteResources) + .values(userIds.map((userId) => ({ userId, siteResourceId }))); + } + + await rebuildClientAssociationsFromSiteResource(siteResource, trx); + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Users set for site resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index fd316e74..63013164 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -1,33 +1,98 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, newts, sites } from "@server/db"; +import { + clientSiteResources, + db, + newts, + roles, + roleSiteResources, + sites, + userSiteResources +} from "@server/db"; import { siteResources, SiteResource } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { eq, and } from "drizzle-orm"; +import { eq, and, ne } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; -import { addTargets } from "../client/targets"; +import { updatePeerData, updateTargets } from "@server/routers/client/targets"; +import { + generateAliasConfig, + generateRemoteSubnets, + generateSubnetProxyTargets +} from "@server/lib/ip"; +import { + getClientSiteResourceAccess, + rebuildClientAssociationsFromSiteResource +} from "@server/lib/rebuildClientAssociations"; const updateSiteResourceParamsSchema = z.strictObject({ - siteResourceId: z - .string() - .transform(Number) - .pipe(z.int().positive()), - siteId: z.string().transform(Number).pipe(z.int().positive()), - orgId: z.string() - }); + siteResourceId: z.string().transform(Number).pipe(z.int().positive()), + siteId: z.string().transform(Number).pipe(z.int().positive()), + orgId: z.string() +}); -const updateSiteResourceSchema = z.strictObject({ +const updateSiteResourceSchema = z + .strictObject({ name: z.string().min(1).max(255).optional(), - protocol: z.enum(["tcp", "udp"]).optional(), - proxyPort: z.int().positive().optional(), - destinationPort: z.int().positive().optional(), - destinationIp: z.string().optional(), - enabled: z.boolean().optional() - }); + // mode: z.enum(["host", "cidr", "port"]).optional(), + mode: z.enum(["host", "cidr"]).optional(), + // protocol: z.enum(["tcp", "udp"]).nullish(), + // proxyPort: z.int().positive().nullish(), + // destinationPort: z.int().positive().nullish(), + destination: z.string().min(1).optional(), + enabled: z.boolean().optional(), + alias: z + .string() + .regex( + /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/, + "Alias must be a fully qualified domain name (e.g., example.internal)" + ) + .nullish(), + userIds: z.array(z.string()), + roleIds: z.array(z.int()), + clientIds: z.array(z.int()) + }) + .strict() + .refine( + (data) => { + if (data.mode === "host" && data.destination) { + // Check if it's a valid IP address using zod (v4 or v6) + const isValidIP = z + .union([z.ipv4(), z.ipv6()]) + .safeParse(data.destination).success; + + // Check if it's a valid domain (hostname pattern, TLD not required) + const domainRegex = + /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; + const isValidDomain = domainRegex.test(data.destination); + + return isValidIP || isValidDomain; + } + return true; + }, + { + message: + "Destination must be a valid IP address or domain name for host mode" + } + ) + .refine( + (data) => { + if (data.mode === "cidr" && data.destination) { + // Check if it's a valid CIDR (v4 or v6) + const isValidCIDR = z + .union([z.cidrv4(), z.cidrv6()]) + .safeParse(data.destination).success; + return isValidCIDR; + } + return true; + }, + { + message: "Destination must be a valid CIDR notation for cidr mode" + } + ); export type UpdateSiteResourceBody = z.infer; export type UpdateSiteResourceResponse = SiteResource; @@ -79,7 +144,16 @@ export async function updateSiteResource( } const { siteResourceId, siteId, orgId } = parsedParams.data; - const updateData = parsedBody.data; + const { + name, + mode, + destination, + alias, + enabled, + userIds, + roleIds, + clientIds + } = parsedBody.data; const [site] = await db .select() @@ -110,69 +184,190 @@ export async function updateSiteResource( ); } - const protocol = updateData.protocol || existingSiteResource.protocol; - const proxyPort = - updateData.proxyPort || existingSiteResource.proxyPort; + // make sure the alias is unique within the org if provided + if (alias) { + const [conflict] = await db + .select() + .from(siteResources) + .where( + and( + eq(siteResources.orgId, orgId), + eq(siteResources.alias, alias.trim()), + ne(siteResources.siteResourceId, siteResourceId) // exclude self + ) + ) + .limit(1); - // check if resource with same protocol and proxy port already exists - const [existingResource] = await db - .select() - .from(siteResources) - .where( - and( - eq(siteResources.siteId, siteId), - eq(siteResources.orgId, orgId), - eq(siteResources.protocol, protocol), - eq(siteResources.proxyPort, proxyPort) - ) - ) - .limit(1); - if ( - existingResource && - existingResource.siteResourceId !== siteResourceId - ) { - return next( - createHttpError( - HttpCode.CONFLICT, - "A resource with the same protocol and proxy port already exists" + if (conflict) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Alias already in use by another site resource" + ) + ); + } + } + + let updatedSiteResource: SiteResource | undefined; + await db.transaction(async (trx) => { + // Update the site resource + [updatedSiteResource] = await trx + .update(siteResources) + .set({ + name: name, + mode: mode, + destination: destination, + enabled: enabled, + alias: alias && alias.trim() ? alias : null + }) + .where( + and( + eq(siteResources.siteResourceId, siteResourceId), + eq(siteResources.siteId, siteId), + eq(siteResources.orgId, orgId) + ) ) + .returning(); + + //////////////////// update the associations //////////////////// + + await trx + .delete(clientSiteResources) + .where(eq(clientSiteResources.siteResourceId, siteResourceId)); + + if (clientIds.length > 0) { + await trx.insert(clientSiteResources).values( + clientIds.map((clientId) => ({ + clientId, + siteResourceId + })) + ); + } + + await trx + .delete(userSiteResources) + .where(eq(userSiteResources.siteResourceId, siteResourceId)); + + if (userIds.length > 0) { + await trx + .insert(userSiteResources) + .values( + userIds.map((userId) => ({ userId, siteResourceId })) + ); + } + + // Get all admin role IDs for this org to exclude from deletion + const adminRoles = await trx + .select() + .from(roles) + .where( + and( + eq(roles.isAdmin, true), + eq(roles.orgId, updatedSiteResource.orgId) + ) + ); + const adminRoleIds = adminRoles.map((role) => role.roleId); + + if (adminRoleIds.length > 0) { + await trx.delete(roleSiteResources).where( + and( + eq(roleSiteResources.siteResourceId, siteResourceId), + ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role + ) + ); + } else { + await trx + .delete(roleSiteResources) + .where( + eq(roleSiteResources.siteResourceId, siteResourceId) + ); + } + + if (roleIds.length > 0) { + await trx + .insert(roleSiteResources) + .values( + roleIds.map((roleId) => ({ roleId, siteResourceId })) + ); + } + + const { mergedAllClients } = + await rebuildClientAssociationsFromSiteResource( + existingSiteResource, // we want to rebuild based on the existing resource then we will apply the change to the destination below + trx + ); + + // after everything is rebuilt above we still need to update the targets and remote subnets if the destination changed + const destinationChanged = + existingSiteResource.destination !== + updatedSiteResource.destination; + const aliasChanged = + existingSiteResource.alias !== updatedSiteResource.alias; + + if (destinationChanged || aliasChanged) { + const [newt] = await trx + .select() + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); + + if (!newt) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Newt not found") + ); + } + + // Only update targets on newt if destination changed + if (destinationChanged) { + const oldTargets = generateSubnetProxyTargets( + existingSiteResource, + mergedAllClients + ); + const newTargets = generateSubnetProxyTargets( + updatedSiteResource, + mergedAllClients + ); + + await updateTargets(newt.newtId, { + oldTargets: oldTargets, + newTargets: newTargets + }); + } + + const olmJobs: Promise[] = []; + for (const client of mergedAllClients) { + // we also need to update the remote subnets on the olms for each client that has access to this site + olmJobs.push( + updatePeerData( + client.clientId, + updatedSiteResource.siteId, + destinationChanged ? { + oldRemoteSubnets: generateRemoteSubnets([ + existingSiteResource + ]), + newRemoteSubnets: generateRemoteSubnets([ + updatedSiteResource + ]) + } : undefined, + aliasChanged ? { + oldAliases: generateAliasConfig([ + existingSiteResource + ]), + newAliases: generateAliasConfig([ + updatedSiteResource + ]) + } : undefined + ) + ); + } + + await Promise.all(olmJobs); + } + + logger.info( + `Updated site resource ${siteResourceId} for site ${siteId}` ); - } - - // Update the site resource - const [updatedSiteResource] = await db - .update(siteResources) - .set(updateData) - .where( - and( - eq(siteResources.siteResourceId, siteResourceId), - eq(siteResources.siteId, siteId), - eq(siteResources.orgId, orgId) - ) - ) - .returning(); - - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); - - if (!newt) { - return next(createHttpError(HttpCode.NOT_FOUND, "Newt not found")); - } - - await addTargets( - newt.newtId, - updatedSiteResource.destinationIp, - updatedSiteResource.destinationPort, - updatedSiteResource.protocol, - updatedSiteResource.proxyPort - ); - - logger.info( - `Updated site resource ${siteResourceId} for site ${siteId}` - ); + }); return response(res, { data: updatedSiteResource, diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index 30f61134..3e94d96c 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -12,6 +12,7 @@ import { checkValidInvite } from "@server/auth/checkValidInvite"; import { verifySession } from "@server/auth/sessions/verifySession"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; +import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; const acceptInviteBodySchema = z.strictObject({ token: z.string(), @@ -129,6 +130,8 @@ export async function acceptInvite( .select() .from(userOrgs) .where(eq(userOrgs.orgId, existingInvite.orgId)); + + await calculateUserClientsForOrgs(existingUser[0].userId, trx); }); if (totalUsers) { diff --git a/server/routers/user/addUserRole.ts b/server/routers/user/addUserRole.ts index 915ea64a..32eaa19d 100644 --- a/server/routers/user/addUserRole.ts +++ b/server/routers/user/addUserRole.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { clients, db, UserOrg } from "@server/db"; import { userOrgs, roles } from "@server/db"; import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; @@ -10,11 +10,12 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import stoi from "@server/lib/stoi"; import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; const addUserRoleParamsSchema = z.strictObject({ - userId: z.string(), - roleId: z.string().transform(stoi).pipe(z.number()) - }); + userId: z.string(), + roleId: z.string().transform(stoi).pipe(z.number()) +}); export type AddUserRoleResponse = z.infer; @@ -72,7 +73,9 @@ export async function addUserRole( const existingUser = await db .select() .from(userOrgs) - .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId))) + .where( + and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId)) + ) .limit(1); if (existingUser.length === 0) { @@ -108,14 +111,39 @@ export async function addUserRole( ); } - const newUserRole = await db - .update(userOrgs) - .set({ roleId }) - .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId))) - .returning(); + let newUserRole: UserOrg | null = null; + await db.transaction(async (trx) => { + [newUserRole] = await trx + .update(userOrgs) + .set({ roleId }) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, role.orgId) + ) + ) + .returning(); + + // get the client associated with this user in this org + const orgClients = await trx + .select() + .from(clients) + .where( + and( + eq(clients.userId, userId), + eq(clients.orgId, role.orgId) + ) + ) + .limit(1); + + for (const orgClient of orgClients) { + // we just changed the user's role, so we need to rebuild client associations and what they have access to + await rebuildClientAssociationsFromClient(orgClient, trx); + } + }); return response(res, { - data: newUserRole[0], + data: newUserRole, success: true, error: false, message: "Role added to user successfully", diff --git a/server/routers/user/adminGeneratePasswordResetCode.ts b/server/routers/user/adminGeneratePasswordResetCode.ts new file mode 100644 index 00000000..5d283c5c --- /dev/null +++ b/server/routers/user/adminGeneratePasswordResetCode.ts @@ -0,0 +1,125 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import HttpCode from "@server/types/HttpCode"; +import { response } from "@server/lib/response"; +import { db } from "@server/db"; +import { passwordResetTokens, users } from "@server/db"; +import { eq } from "drizzle-orm"; +import { alphabet, generateRandomString } from "oslo/crypto"; +import { createDate } from "oslo"; +import logger from "@server/logger"; +import { TimeSpan } from "oslo"; +import { hashPassword } from "@server/auth/password"; +import { UserType } from "@server/types/UserTypes"; +import config from "@server/lib/config"; + +const adminGeneratePasswordResetCodeSchema = z.strictObject({ + userId: z.string().min(1) +}); + +export type AdminGeneratePasswordResetCodeBody = z.infer; + +export type AdminGeneratePasswordResetCodeResponse = { + token: string; + email: string; + url: string; +}; + +export async function adminGeneratePasswordResetCode( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedParams = adminGeneratePasswordResetCodeSchema.safeParse(req.params); + + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { userId } = parsedParams.data; + + try { + const existingUser = await db + .select() + .from(users) + .where(eq(users.userId, userId)); + + if (!existingUser || !existingUser.length) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found" + ) + ); + } + + if (existingUser[0].type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Password reset codes can only be generated for internal users" + ) + ); + } + + if (!existingUser[0].email) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User does not have an email address" + ) + ); + } + + const token = generateRandomString(8, alphabet("0-9", "A-Z", "a-z")); + + await db.transaction(async (trx) => { + await trx + .delete(passwordResetTokens) + .where(eq(passwordResetTokens.userId, existingUser[0].userId)); + + const tokenHash = await hashPassword(token); + + await trx.insert(passwordResetTokens).values({ + userId: existingUser[0].userId, + email: existingUser[0].email!, + tokenHash, + expiresAt: createDate(new TimeSpan(2, "h")).getTime() + }); + }); + + const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${existingUser[0].email}&token=${token}`; + + logger.info( + `Admin generated password reset code for user ${existingUser[0].email} (${userId})` + ); + + return response(res, { + data: { + token, + email: existingUser[0].email!, + url + }, + success: true, + error: false, + message: "Password reset code generated successfully", + status: HttpCode.OK + }); + } catch (e) { + logger.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to generate password reset code" + ) + ); + } +} + diff --git a/server/routers/user/adminRemoveUser.ts b/server/routers/user/adminRemoveUser.ts index 02ad56d6..ae7f9f47 100644 --- a/server/routers/user/adminRemoveUser.ts +++ b/server/routers/user/adminRemoveUser.ts @@ -8,10 +8,11 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; const removeUserSchema = z.strictObject({ - userId: z.string() - }); + userId: z.string() +}); export async function adminRemoveUser( req: Request, @@ -50,7 +51,11 @@ export async function adminRemoveUser( ); } - await db.delete(users).where(eq(users.userId, userId)); + await db.transaction(async (trx) => { + await trx.delete(users).where(eq(users.userId, userId)); + + await calculateUserClientsForOrgs(userId, trx); + }); return response(res, { data: null, diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index dccd0d65..99a2258c 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -15,6 +15,7 @@ import { FeatureId } from "@server/lib/billing"; import { build } from "@server/build"; import { getOrgTierData } from "#dynamic/lib/billing"; import { TierId } from "@server/lib/billing/tiers"; +import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -84,14 +85,7 @@ export async function createOrgUser( } const { orgId } = parsedParams.data; - const { - username, - email, - name, - type, - idpId, - roleId - } = parsedBody.data; + const { username, email, name, type, idpId, roleId } = parsedBody.data; if (build == "saas") { const usage = await usageService.getUsage(orgId, FeatureId.USERS); @@ -197,7 +191,9 @@ export async function createOrgUser( ) ); + let userId: string | undefined; if (existingUser) { + userId = existingUser.userId; const [existingOrgUser] = await trx .select() .from(userOrgs) @@ -227,7 +223,7 @@ export async function createOrgUser( }) .returning(); } else { - const userId = generateId(15); + userId = generateId(15); const [newUser] = await trx .insert(users) @@ -239,7 +235,7 @@ export async function createOrgUser( type: "oidc", idpId, dateCreated: new Date().toISOString(), - emailVerified: true, + emailVerified: true }) .returning(); @@ -259,6 +255,8 @@ export async function createOrgUser( .select() .from(userOrgs) .where(eq(userOrgs.orgId, orgId)); + + await calculateUserClientsForOrgs(userId, trx); }); if (orgUsers) { diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 7148eb87..35c5c4a7 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -8,9 +8,11 @@ export * from "./getOrgUser"; export * from "./adminListUsers"; export * from "./adminRemoveUser"; export * from "./adminGetUser"; +export * from "./adminGeneratePasswordResetCode"; export * from "./listInvitations"; export * from "./removeInvitation"; export * from "./createOrgUser"; export * from "./adminUpdateUser2FA"; export * from "./adminGetUser"; export * from "./updateOrgUser"; +export * from "./myDevice"; diff --git a/server/routers/user/myDevice.ts b/server/routers/user/myDevice.ts new file mode 100644 index 00000000..144108e1 --- /dev/null +++ b/server/routers/user/myDevice.ts @@ -0,0 +1,114 @@ +import { Request, Response, NextFunction } from "express"; +import { db, Olm, olms, orgs, userOrgs } from "@server/db"; +import { idp, users } from "@server/db"; +import { and, 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 { GetUserResponse } from "./getUser"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const querySchema = z.object({ + olmId: z.string() +}); + +type ResponseOrg = { + orgId: string; + orgName: string; + roleId: number; +}; + +export type MyDeviceResponse = { + user: GetUserResponse; + orgs: ResponseOrg[]; + olm: Olm | null; +}; + +export async function myDevice( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const { olmId } = parsedQuery.data; + + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not found") + ); + } + + const [user] = await db + .select({ + userId: users.userId, + email: users.email, + username: users.username, + name: users.name, + type: users.type, + twoFactorEnabled: users.twoFactorEnabled, + emailVerified: users.emailVerified, + serverAdmin: users.serverAdmin, + idpName: idp.name, + idpId: users.idpId + }) + .from(users) + .leftJoin(idp, eq(users.idpId, idp.idpId)) + .where(eq(users.userId, userId)) + .limit(1); + + if (!user) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `User with ID ${userId} not found` + ) + ); + } + + const [olm] = await db + .select() + .from(olms) + .where(and(eq(olms.userId, userId), eq(olms.olmId, olmId))); + + const userOrganizations = await db + .select({ + orgId: userOrgs.orgId, + orgName: orgs.name, + roleId: userOrgs.roleId + }) + .from(userOrgs) + .where(eq(userOrgs.userId, userId)) + .innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId)); + + return response(res, { + data: { + user, + orgs: userOrganizations, + olm + }, + success: true, + error: false, + message: "My device retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/user/removeUserOrg.ts b/server/routers/user/removeUserOrg.ts index 83ff6802..cbbb4495 100644 --- a/server/routers/user/removeUserOrg.ts +++ b/server/routers/user/removeUserOrg.ts @@ -13,6 +13,7 @@ import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; import { build } from "@server/build"; import { UserType } from "@server/types/UserTypes"; +import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; const removeUserSchema = z.strictObject({ userId: z.string(), @@ -118,22 +119,24 @@ export async function removeUserOrg( .from(userOrgs) .where(eq(userOrgs.orgId, orgId)); - if (build === "saas") { - const [rootUser] = await trx - .select() - .from(users) - .where(eq(users.userId, userId)); + // if (build === "saas") { + // const [rootUser] = await trx + // .select() + // .from(users) + // .where(eq(users.userId, userId)); + // + // const [leftInOrgs] = await trx + // .select({ count: count() }) + // .from(userOrgs) + // .where(eq(userOrgs.userId, userId)); + // + // // if the user is not an internal user and does not belong to any org, delete the entire user + // if (rootUser?.type !== UserType.Internal && !leftInOrgs.count) { + // await trx.delete(users).where(eq(users.userId, userId)); + // } + // } - const [leftInOrgs] = await trx - .select({ count: count() }) - .from(userOrgs) - .where(eq(userOrgs.userId, userId)); - - // if the user is not an internal user and does not belong to any org, delete the entire user - if (rootUser?.type !== UserType.Internal && !leftInOrgs.count) { - await trx.delete(users).where(eq(users.userId, userId)); - } - } + await calculateUserClientsForOrgs(userId, trx); }); if (userCount) { diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts index cbb023b3..acd1aef0 100644 --- a/server/routers/ws/messageHandlers.ts +++ b/server/routers/ws/messageHandlers.ts @@ -11,23 +11,27 @@ import { handleOlmRegisterMessage, handleOlmRelayMessage, handleOlmPingMessage, - startOlmOfflineChecker + startOlmOfflineChecker, + handleOlmServerPeerAddMessage, + handleOlmUnRelayMessage } from "../olm"; import { handleHealthcheckStatusMessage } from "../target"; import { MessageHandler } from "./types"; export const messageHandlers: Record = { - "newt/wg/register": handleNewtRegisterMessage, + "olm/wg/server/peer/add": handleOlmServerPeerAddMessage, "olm/wg/register": handleOlmRegisterMessage, + "olm/wg/relay": handleOlmRelayMessage, + "olm/wg/unrelay": handleOlmUnRelayMessage, + "olm/ping": handleOlmPingMessage, + "newt/wg/register": handleNewtRegisterMessage, "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, "newt/blueprint/apply": handleApplyBlueprintMessage, - "newt/healthcheck/status": handleHealthcheckStatusMessage, + "newt/healthcheck/status": handleHealthcheckStatusMessage }; -startOlmOfflineChecker(); // this is to handle the offline check for olms \ No newline at end of file +startOlmOfflineChecker(); // this is to handle the offline check for olms diff --git a/server/routers/ws/ws.ts b/server/routers/ws/ws.ts index 9bba41dc..abbec880 100644 --- a/server/routers/ws/ws.ts +++ b/server/routers/ws/ws.ts @@ -11,6 +11,7 @@ import { messageHandlers } from "./messageHandlers"; import logger from "@server/logger"; import { v4 as uuidv4 } from "uuid"; import { ClientType, TokenPayload, WebSocketRequest, WSMessage, AuthenticatedWebSocket } from "./types"; +import { validateSessionToken } from "@server/auth/sessions/app"; // Subset of TokenPayload for public ws.ts (newt and olm only) interface PublicTokenPayload { @@ -94,6 +95,8 @@ const sendToClient = async (clientId: string, message: WSMessage): Promise => { +const verifyToken = async (token: string, clientType: ClientType, userToken: string): Promise => { try { if (clientType === 'newt') { @@ -145,6 +148,17 @@ try { if (!existingOlm || !existingOlm[0]) { return null; } + + if (olm.userId) { // this is a user device and we need to check the user token + const { session: userSession, user } = await validateSessionToken(userToken); + if (!userSession || !user) { + return null; + } + if (user.userId !== olm.userId) { + return null; + } + } + return { client: existingOlm[0], session, clientType }; } @@ -239,6 +253,7 @@ const handleWSUpgrade = (server: HttpServer): void => { try { const url = new URL(request.url || '', `http://${request.headers.host}`); const token = url.searchParams.get('token') || request.headers["sec-websocket-protocol"] || ''; + const userToken = url.searchParams.get('userToken') || ''; let clientType = url.searchParams.get('clientType') as ClientType; if (!clientType) { @@ -252,7 +267,7 @@ const handleWSUpgrade = (server: HttpServer): void => { return; } - const tokenPayload = await verifyToken(token, clientType); + const tokenPayload = await verifyToken(token, clientType, userToken); if (!tokenPayload) { logger.warn("Unauthorized connection attempt: invalid token..."); socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); @@ -271,6 +286,28 @@ const handleWSUpgrade = (server: HttpServer): void => { }); }; +// Disconnect a specific client and force them to reconnect +const disconnectClient = async (clientId: string): Promise => { + const mapKey = getClientMapKey(clientId); + const clients = connectedClients.get(mapKey); + + if (!clients || clients.length === 0) { + logger.debug(`No connections found for client ID: ${clientId}`); + return false; + } + + logger.info(`Disconnecting client ID: ${clientId} (${clients.length} connection(s))`); + + // Close all connections for this client + clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.close(1000, "Disconnected by server"); + } + }); + + return true; +}; + // Cleanup function for graceful shutdown const cleanup = async (): Promise => { try { @@ -297,6 +334,7 @@ export { connectedClients, hasActiveConnections, getActiveNodes, + disconnectClient, NODE_ID, cleanup }; diff --git a/src/actions/server.ts b/src/actions/server.ts index b75c3ed7..2759e621 100644 --- a/src/actions/server.ts +++ b/src/actions/server.ts @@ -237,10 +237,11 @@ export type SecurityKeyVerifyResponse = { }; export async function loginProxy( - request: LoginRequest + request: LoginRequest, + forceLogin?: boolean ): Promise> { const serverPort = process.env.SERVER_EXTERNAL_PORT; - const url = `http://localhost:${serverPort}/api/v1/auth/login`; + const url = `http://localhost:${serverPort}/api/v1/auth/login${forceLogin ? "?forceLogin=true" : ""}`; console.log("Making login request to:", url); @@ -248,10 +249,11 @@ export async function loginProxy( } export async function securityKeyStartProxy( - request: SecurityKeyStartRequest + request: SecurityKeyStartRequest, + forceLogin?: boolean ): Promise> { const serverPort = process.env.SERVER_EXTERNAL_PORT; - const url = `http://localhost:${serverPort}/api/v1/auth/security-key/authenticate/start`; + const url = `http://localhost:${serverPort}/api/v1/auth/security-key/authenticate/start${forceLogin ? "?forceLogin=true" : ""}`; console.log("Making security key start request to:", url); @@ -260,10 +262,11 @@ export async function securityKeyStartProxy( export async function securityKeyVerifyProxy( request: SecurityKeyVerifyRequest, - tempSessionId: string + tempSessionId: string, + forceLogin?: boolean ): Promise> { const serverPort = process.env.SERVER_EXTERNAL_PORT; - const url = `http://localhost:${serverPort}/api/v1/auth/security-key/authenticate/verify`; + const url = `http://localhost:${serverPort}/api/v1/auth/security-key/authenticate/verify${forceLogin ? "?forceLogin=true" : ""}`; console.log("Making security key verify request to:", url); @@ -407,10 +410,19 @@ export async function validateOidcUrlCallbackProxy( export async function generateOidcUrlProxy( idpId: number, redirect: string, - orgId?: string + orgId?: string, + forceLogin?: boolean ): Promise> { const serverPort = process.env.SERVER_EXTERNAL_PORT; - const url = `http://localhost:${serverPort}/api/v1/auth/idp/${idpId}/oidc/generate-url${orgId ? `?orgId=${orgId}` : ""}`; + const queryParams = new URLSearchParams(); + if (orgId) { + queryParams.append("orgId", orgId); + } + if (forceLogin) { + queryParams.append("forceLogin", "true"); + } + const queryString = queryParams.toString(); + const url = `http://localhost:${serverPort}/api/v1/auth/idp/${idpId}/oidc/generate-url${queryString ? `?${queryString}` : ""}`; console.log("Making OIDC URL generation request to:", url); diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index 25b3de1f..fc806acc 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -1,17 +1,15 @@ -import { verifySession } from "@app/lib/auth/verifySession"; -import UserProvider from "@app/providers/UserProvider"; -import { cache } from "react"; -import MemberResourcesPortal from "../../components/MemberResourcesPortal"; -import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; -import { internal } from "@app/lib/api"; -import { AxiosResponse } from "axios"; -import { authCookieHeader } from "@app/lib/api/cookies"; -import { redirect } from "next/navigation"; import { Layout } from "@app/components/Layout"; -import { ListUserOrgsResponse } from "@server/routers/org"; +import MemberResourcesPortal from "@app/components/MemberResourcesPortal"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { verifySession } from "@app/lib/auth/verifySession"; import { pullEnv } from "@app/lib/pullEnv"; -import EnvProvider from "@app/providers/EnvProvider"; -import { orgLangingNavItems } from "@app/app/navigation"; +import UserProvider from "@app/providers/UserProvider"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import { cache } from "react"; type OrgPageProps = { params: Promise<{ orgId: string }>; @@ -22,6 +20,10 @@ export default async function OrgPage(props: OrgPageProps) { const orgId = params.orgId; const env = pullEnv(); + if (!orgId) { + redirect(`/`); + } + const getUser = cache(verifySession); const user = await getUser(); diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx index 0834608d..62cada8d 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx @@ -1,6 +1,7 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { ExitNodesDataTable } from "./ExitNodesDataTable"; import { DropdownMenu, @@ -99,9 +100,10 @@ export default function ExitNodesTable({ }); }; - const columns: ColumnDef[] = [ + const columns: ExtendedColumnDef[] = [ { accessorKey: "name", + friendlyName: t("name"), header: ({ column }) => { return ( @@ -121,4 +129,4 @@ export default function CredentialsPage() { /> ); -} \ No newline at end of file +} diff --git a/src/app/[orgId]/settings/clients/[clientId]/general/page.tsx b/src/app/[orgId]/settings/clients/machine/[clientId]/general/page.tsx similarity index 57% rename from src/app/[orgId]/settings/clients/[clientId]/general/page.tsx rename to src/app/[orgId]/settings/clients/machine/[clientId]/general/page.tsx index c7171c8d..63d88593 100644 --- a/src/app/[orgId]/settings/clients/[clientId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/[clientId]/general/page.tsx @@ -1,49 +1,40 @@ "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, + SettingsSectionDescription, + SettingsSectionFooter, SettingsSectionForm, - SettingsSectionFooter + SettingsSectionHeader, + SettingsSectionTitle } from "@app/components/Settings"; -import { formatAxiosError } from "@app/lib/api"; -import { createApiClient } from "@app/lib/api"; +import { useClientContext } from "@app/hooks/useClientContext"; 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 { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { zodResolver } from "@hookform/resolvers/zod"; import { ListSitesResponse } from "@server/routers/site"; +import { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; const GeneralFormSchema = z.object({ - name: z.string().nonempty("Name is required"), - siteIds: z.array( - z.object({ - id: z.string(), - text: z.string() - }) - ) + name: z.string().nonempty("Name is required") }); type GeneralFormValues = z.infer; @@ -54,15 +45,11 @@ export default function GeneralPage() { 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: [] + name: client?.name }, mode: "onChange" }); @@ -75,23 +62,6 @@ export default function GeneralPage() { 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", @@ -114,8 +84,7 @@ export default function GeneralPage() { try { await api.post(`/client/${client?.clientId}`, { - name: data.name, - siteIds: data.siteIds.map(site => parseInt(site.id)) + name: data.name }); updateClient({ name: data.name }); @@ -130,10 +99,7 @@ export default function GeneralPage() { toast({ variant: "destructive", title: t("clientUpdateFailed"), - description: formatAxiosError( - e, - t("clientUpdateError") - ) + description: formatAxiosError(e, t("clientUpdateError")) }); } finally { setLoading(false); @@ -173,39 +139,6 @@ export default function GeneralPage() { )} /> - - ( - - {t("sites")} - { - form.setValue( - "siteIds", - newTags as [Tag, ...Tag[]] - ); - }} - enableAutocomplete={true} - autocompleteOptions={sites} - allowDuplicates={false} - restrictTagsToAutocompleteOptions={true} - sortTags={true} - /> - - {t("sitesDescription")} - - - - )} - /> @@ -224,4 +157,4 @@ export default function GeneralPage() { ); -} \ No newline at end of file +} diff --git a/src/app/[orgId]/settings/clients/[clientId]/layout.tsx b/src/app/[orgId]/settings/clients/machine/[clientId]/layout.tsx similarity index 76% rename from src/app/[orgId]/settings/clients/[clientId]/layout.tsx rename to src/app/[orgId]/settings/clients/machine/[clientId]/layout.tsx index 257cb20f..a51a003d 100644 --- a/src/app/[orgId]/settings/clients/[clientId]/layout.tsx +++ b/src/app/[orgId]/settings/clients/machine/[clientId]/layout.tsx @@ -1,19 +1,19 @@ -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 "../../../../../components/ClientInfoCard"; -import ClientProvider from "@app/providers/ClientProvider"; -import { redirect } from "next/navigation"; +import ClientInfoCard from "@app/components/ClientInfoCard"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; -import { getTranslations } from "next-intl/server"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import ClientProvider from "@app/providers/ClientProvider"; import { build } from "@server/build"; +import { GetClientResponse } from "@server/routers/client"; +import { AxiosResponse } from "axios"; +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; type SettingsLayoutProps = { children: React.ReactNode; params: Promise<{ clientId: number | string; orgId: string }>; -} +}; export default async function SettingsLayout(props: SettingsLayoutProps) { const params = await props.params; @@ -36,16 +36,17 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { const navItems = [ { - title: t('general'), - href: `/{orgId}/settings/clients/{clientId}/general` + title: t("general"), + href: `/{orgId}/settings/clients/machine/{clientId}/general` }, - ...(build === 'enterprise' - ? [{ - title: t('credentials'), - href: `/{orgId}/settings/clients/{clientId}/credentials` - }, - ] - : []), + ...(build === "enterprise" + ? [ + { + title: t("credentials"), + href: `/{orgId}/settings/clients/machine/{clientId}/credentials` + } + ] + : []) ]; return ( @@ -58,9 +59,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
- - {children} - + {children}
diff --git a/src/app/[orgId]/settings/clients/[clientId]/page.tsx b/src/app/[orgId]/settings/clients/machine/[clientId]/page.tsx similarity index 67% rename from src/app/[orgId]/settings/clients/[clientId]/page.tsx rename to src/app/[orgId]/settings/clients/machine/[clientId]/page.tsx index cbe9fb97..c59f6920 100644 --- a/src/app/[orgId]/settings/clients/[clientId]/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/[clientId]/page.tsx @@ -4,5 +4,7 @@ export default async function ClientPage(props: { params: Promise<{ orgId: string; clientId: number | string }>; }) { const params = await props.params; - redirect(`/${params.orgId}/settings/clients/${params.clientId}/general`); + redirect( + `/${params.orgId}/settings/clients/machine/${params.clientId}/general` + ); } diff --git a/src/app/[orgId]/settings/clients/create/page.tsx b/src/app/[orgId]/settings/clients/machine/create/page.tsx similarity index 74% rename from src/app/[orgId]/settings/clients/create/page.tsx rename to src/app/[orgId]/settings/clients/machine/create/page.tsx index aaee4d31..a0489f30 100644 --- a/src/app/[orgId]/settings/clients/create/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/create/page.tsx @@ -42,10 +42,7 @@ import { FaFreebsd, FaWindows } from "react-icons/fa"; -import { - SiNixos, - SiKubernetes -} from "react-icons/si"; +import { SiNixos, SiKubernetes } from "react-icons/si"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; @@ -71,9 +68,11 @@ interface TunnelTypeOption { disabled?: boolean; } +type CommandItem = string | { title: string; command: string }; + type Commands = { - unix: Record; - windows: Record; + unix: Record; + windows: Record; }; const platforms = ["unix", "windows"] as const; @@ -93,18 +92,7 @@ export default function Page() { .min(2, { message: t("nameMin", { len: 2 }) }) .max(30, { message: t("nameMax", { len: 30 }) }), method: z.enum(["olm"]), - siteIds: z - .array( - z.object({ - id: z.string(), - text: z.string() - }) - ) - .refine((val) => val.length > 0, { - message: t("siteRequired") - }), - subnet: z.union([z.ipv4(), z.ipv6()]) - .refine((val) => val.length > 0, { + subnet: z.union([z.ipv4(), z.ipv6()]).refine((val) => val.length > 0, { message: t("subnetRequired") }) }); @@ -123,10 +111,6 @@ export default function Page() { ]); const [loadingPage, setLoadingPage] = useState(true); - const [sites, setSites] = useState([]); - const [activeSitesTagIndex, setActiveSitesTagIndex] = useState< - number | null - >(null); const [platform, setPlatform] = useState("unix"); const [architecture, setArchitecture] = useState("All"); @@ -150,14 +134,26 @@ export default function Page() { const commands = { unix: { All: [ - `curl -fsSL https://pangolin.net/get-olm.sh | bash`, - `sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + { + title: t("install"), + command: `curl -fsSL https://pangolin.net/get-olm.sh | bash` + }, + { + title: t("run"), + command: `sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + } ] }, windows: { x64: [ - `curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`, - `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}` + { + title: t("install"), + command: `curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"` + }, + { + title: t("run"), + command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}` + } ] } }; @@ -188,8 +184,8 @@ export default function Page() { } }; - const getCommand = () => { - const placeholder = [t("unknownCommand")]; + const getCommand = (): CommandItem[] => { + const placeholder: CommandItem[] = [t("unknownCommand")]; if (!commands) { return placeholder; } @@ -236,12 +232,11 @@ export default function Page() { } }; - const form = useForm({ + const form = useForm({ resolver: zodResolver(createClientFormSchema), defaultValues: { name: "", method: "olm", - siteIds: [], subnet: "" } }); @@ -262,7 +257,6 @@ export default function Page() { const payload: CreateClientBody = { name: data.name, type: data.method as "olm", - siteIds: data.siteIds.map((site) => parseInt(site.id)), olmId: clientDefaults.olmId, secret: clientDefaults.olmSecret, subnet: data.subnet @@ -282,7 +276,7 @@ export default function Page() { if (res && res.status === 201) { const data = res.data.data; - router.push(`/${orgId}/settings/clients/${data.clientId}`); + router.push(`/${orgId}/settings/clients/machine/${data.clientId}`); } setCreateLoading(false); @@ -294,18 +288,18 @@ export default function Page() { // Fetch available sites - const res = await api.get>( - `/org/${orgId}/sites/` - ); - const sites = res.data.data.sites.filter( - (s) => s.type === "newt" && s.subnet - ); - setSites( - sites.map((site) => ({ - id: site.siteId.toString(), - text: site.name - })) - ); + // const res = await api.get>( + // `/org/${orgId}/sites/` + // ); + // const sites = res.data.data.sites.filter( + // (s) => s.type === "newt" && s.subnet + // ); + // setSites( + // sites.map((site) => ({ + // id: site.siteId.toString(), + // text: site.name + // })) + // ); let olmVersion = "latest"; @@ -331,7 +325,7 @@ export default function Page() { const latestVersion = data.tag_name; olmVersion = latestVersion; } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { + if (error instanceof Error && error.name === "AbortError") { console.error(t("olmErrorFetchTimeout")); } else { console.error( @@ -416,116 +410,68 @@ export default function Page() { - -
- { - if (e.key === "Enter") { - e.preventDefault(); // block default enter refresh - } - }} - className="space-y-4" - id="create-client-form" - > - ( - - - {t("name")} - - - - - - - )} - /> - - ( - - - {t("address")} - - - - - - - {t("addressDescription")} - - - )} - /> - - ( - - - {t("sites")} - - + { + if (e.key === "Enter") { + e.preventDefault(); // block default enter refresh + } + }} + className="space-y-4 grid gap-4 grid-cols-1 md:grid-cols-2 items-start" + id="create-client-form" + > + ( + + + {t("name")} + + + { - form.setValue( - "siteIds", - olmags as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - sites - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} /> - - {t("sitesDescription")} - - - - )} - /> - - -
+ + + + {t( + "clientNameDescription" + )} + + + )} + /> + + ( + + + {t("clientAddress")} + + + + + + + {t( + "addressDescription" + )} + + + )} + /> + +
@@ -537,7 +483,9 @@ export default function Page() { {t("clientOlmCredentials")} - {t("clientOlmCredentialsDescription")} + {t( + "clientOlmCredentialsDescription" + )} @@ -659,13 +607,43 @@ export default function Page() {

{t("commands")}

-
- +
+ {getCommand().map( + (item, index) => { + const commandText = + typeof item === + "string" + ? item + : item.command; + const title = + typeof item === + "string" + ? undefined + : item.title; + + return ( +
+ {title && ( +

+ { + title + } +

+ )} + +
+ ); + } + )}
diff --git a/src/app/[orgId]/settings/clients/machine/page.tsx b/src/app/[orgId]/settings/clients/machine/page.tsx new file mode 100644 index 00000000..b450b09f --- /dev/null +++ b/src/app/[orgId]/settings/clients/machine/page.tsx @@ -0,0 +1,78 @@ +import type { ClientRow } from "@app/components/MachineClientsTable"; +import MachineClientsTable from "@app/components/MachineClientsTable"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { ListClientsResponse } from "@server/routers/client"; +import { AxiosResponse } from "axios"; +import { getTranslations } from "next-intl/server"; + +type ClientsPageProps = { + params: Promise<{ orgId: string }>; + searchParams: Promise<{ view?: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function ClientsPage(props: ClientsPageProps) { + const t = await getTranslations(); + + const params = await props.params; + + let machineClients: ListClientsResponse["clients"] = []; + + try { + const machineRes = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/clients?filter=machine`, + await authCookieHeader() + ); + machineClients = machineRes.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 mapClientToRow = ( + client: ListClientsResponse["clients"][0] + ): ClientRow => { + 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, + olmVersion: client.olmVersion || undefined, + olmUpdateAvailable: client.olmUpdateAvailable || false, + userId: client.userId, + username: client.username, + userEmail: client.userEmail + }; + }; + + const machineClientRows: ClientRow[] = machineClients.map(mapClientToRow); + + return ( + <> + + + + + ); +} diff --git a/src/app/[orgId]/settings/clients/page.tsx b/src/app/[orgId]/settings/clients/page.tsx index fd73b736..aeea1c83 100644 --- a/src/app/[orgId]/settings/clients/page.tsx +++ b/src/app/[orgId]/settings/clients/page.tsx @@ -1,63 +1,13 @@ -import { internal } from "@app/lib/api"; -import { authCookieHeader } from "@app/lib/api/cookies"; -import { AxiosResponse } from "axios"; -import { ClientRow } from "../../../../components/ClientsTable"; -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { ListClientsResponse } from "@server/routers/client"; -import ClientsTable from "../../../../components/ClientsTable"; -import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; type ClientsPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise<{ view?: string }>; }; export const dynamic = "force-dynamic"; export default async function ClientsPage(props: ClientsPageProps) { - const t = await getTranslations(); - 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, - olmVersion: client.olmVersion || undefined, - olmUpdateAvailable: client.olmUpdateAvailable || false, - }; - }); - - return ( - <> - - - - - ); + redirect(`/${params.orgId}/settings/clients/user`); } diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx new file mode 100644 index 00000000..399588fc --- /dev/null +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -0,0 +1,75 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { ListClientsResponse } from "@server/routers/client"; +import { getTranslations } from "next-intl/server"; +import type { ClientRow } from "@app/components/MachineClientsTable"; +import UserDevicesTable from "@app/components/UserDevicesTable"; + +type ClientsPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function ClientsPage(props: ClientsPageProps) { + const t = await getTranslations(); + + const params = await props.params; + + let userClients: ListClientsResponse["clients"] = []; + + try { + const userRes = await internal.get>( + `/org/${params.orgId}/clients?filter=user`, + await authCookieHeader() + ); + userClients = userRes.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 mapClientToRow = ( + client: ListClientsResponse["clients"][0] + ): ClientRow => { + 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, + olmVersion: client.olmVersion || undefined, + olmUpdateAvailable: client.olmUpdateAvailable || false, + userId: client.userId, + username: client.username, + userEmail: client.userEmail + }; + }; + + const userClientRows: ClientRow[] = userClients.map(mapClientToRow); + + return ( + <> + + + + + ); +} diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index 54d22188..daec5b30 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -323,29 +323,25 @@ export default function GeneralPage() { )} /> - {env.flags.enableClients && ( - ( - - - {t("subnet")} - - - - - - - {t("subnetDescription")} - - - )} - /> - )} + ( + + {t("subnet")} + + + + + + {t("subnetDescription")} + + + )} + />
diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index 5b12ea3a..8f44c23c 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -82,7 +82,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { {children} diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index 56071976..1e7f2476 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -466,7 +466,7 @@ export default function GeneralPage() { cell: ({ row }) => { return ( ); @@ -238,13 +244,9 @@ export default function UsersTable({ users }: Props) { }} dialog={
-

- {t("userQuestionRemove")} -

+

{t("userQuestionRemove")}

-

- {t("userMessageRemove")} -

+

{t("userMessageRemove")}

} buttonText={t("userDeleteConfirm")} diff --git a/src/app/auth/(private)/org/page.tsx b/src/app/auth/(private)/org/page.tsx index 06910f6a..ecc2293a 100644 --- a/src/app/auth/(private)/org/page.tsx +++ b/src/app/auth/(private)/org/page.tsx @@ -154,7 +154,7 @@ export default async function OrgAuthPage(props: { - + {branding?.logoUrl && (
diff --git a/src/app/auth/layout.tsx b/src/app/auth/layout.tsx index 88b0f07d..70439824 100644 --- a/src/app/auth/layout.tsx +++ b/src/app/auth/layout.tsx @@ -1,5 +1,13 @@ import ThemeSwitcher from "@app/components/ThemeSwitcher"; +import { Separator } from "@app/components/ui/separator"; +import { priv } from "@app/lib/api"; +import { pullEnv } from "@app/lib/pullEnv"; +import { build } from "@server/build"; +import { GetLicenseStatusResponse } from "@server/routers/license/types"; +import { AxiosResponse } from "axios"; import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { cache } from "react"; export const metadata: Metadata = { title: `Auth - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, @@ -11,15 +19,117 @@ type AuthLayoutProps = { }; export default async function AuthLayout({ children }: AuthLayoutProps) { + const env = pullEnv(); + const t = await getTranslations(); + let hideFooter = false; + + if (build == "enterprise") { + const licenseStatusRes = await cache( + async () => + await priv.get>( + "/license/status" + ) + )(); + if ( + env.branding.hideAuthLayoutFooter && + licenseStatusRes.data.data.isHostLicensed && + licenseStatusRes.data.data.isLicenseValid + ) { + hideFooter = true; + } + } + return (
-
+
{children}
+ + {!hideFooter && ( + + )}
); } diff --git a/src/app/auth/login/device/page.tsx b/src/app/auth/login/device/page.tsx new file mode 100644 index 00000000..07c804fb --- /dev/null +++ b/src/app/auth/login/device/page.tsx @@ -0,0 +1,34 @@ +import { verifySession } from "@app/lib/auth/verifySession"; +import { redirect } from "next/navigation"; +import DeviceLoginForm from "@/components/DeviceLoginForm"; +import { cache } from "react"; + +export const dynamic = "force-dynamic"; + +type Props = { + searchParams: Promise<{ code?: string }>; +}; + +export default async function DeviceLoginPage({ searchParams }: Props) { + const user = await verifySession({ forceLogin: true }); + + const params = await searchParams; + const code = params.code || ""; + + if (!user) { + const redirectDestination = code + ? `/auth/login/device?code=${encodeURIComponent(code)}` + : "/auth/login/device"; + redirect(`/auth/login?forceLogin=true&redirect=${encodeURIComponent(redirectDestination)}`); + } + + const userName = user?.name || user?.username || ""; + + return ( + + ); +} diff --git a/src/app/auth/login/device/success/page.tsx b/src/app/auth/login/device/success/page.tsx new file mode 100644 index 00000000..1b79c600 --- /dev/null +++ b/src/app/auth/login/device/success/page.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import BrandingLogo from "@app/components/BrandingLogo"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { CheckCircle2 } from "lucide-react"; +import { useTranslations } from "next-intl"; + +export default function DeviceAuthSuccessPage() { + const { env } = useEnvContext(); + const { isUnlocked } = useLicenseStatusContext(); + const t = useTranslations(); + + const logoWidth = isUnlocked() + ? env.branding.logo?.authPage?.width || 175 + : 175; + const logoHeight = isUnlocked() + ? env.branding.logo?.authPage?.height || 58 + : 58; + + return ( + + +
+ +
+
+

{t("deviceActivation")}

+
+
+ +
+ +
+

+ {t("deviceConnected")} +

+

+ {t("deviceAuthorizedMessage")} +

+
+
+
+
+ ); +} diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 11543345..615f33cf 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -25,12 +25,14 @@ export default async function Page(props: { const user = await getUser({ skipCheckVerifyEmail: true }); const isInvite = searchParams?.redirect?.includes("/invite"); + const forceLoginParam = searchParams?.forceLogin; + const forceLogin = forceLoginParam === "true"; const env = pullEnv(); const signUpDisabled = env.flags.disableSignupWithoutInvite; - if (user) { + if (user && !forceLogin) { redirect("/"); } @@ -53,7 +55,7 @@ export default async function Page(props: { if (loginPageDomain) { const redirectUrl = searchParams.redirect as string | undefined; - + let url = `https://${loginPageDomain}/auth/org`; if (redirectUrl) { url += `?redirect=${redirectUrl}`; @@ -96,7 +98,7 @@ export default async function Page(props: {
)} - + {(!signUpDisabled || isInvite) && (

diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 31650809..022a4d7b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -20,7 +20,7 @@ import { Toaster } from "@app/components/ui/toaster"; import { build } from "@server/build"; import { TopLoader } from "@app/components/Toploader"; import Script from "next/script"; -import { ReactQueryProvider } from "@app/components/react-query-provider"; +import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider"; import { TailwindIndicator } from "@app/components/TailwindIndicator"; export const metadata: Metadata = { @@ -96,16 +96,16 @@ export default async function RootLayout({ strategy="afterInteractive" /> )} - - - - - + + + + + @@ -125,11 +125,11 @@ export default async function RootLayout({ - - - - - + + + + + {process.env.NODE_ENV === "development" && ( diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index c3380415..fbccc042 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -18,6 +18,9 @@ import { Logs, SquareMousePointer, ScanEye, + GlobeLock, + Smartphone, + Laptop, ChartLine } from "lucide-react"; @@ -32,42 +35,58 @@ export const orgLangingNavItems: SidebarNavItem[] = [ { title: "sidebarAccount", href: "/{orgId}", - icon: + icon: } ]; -export const orgNavSections = ( - enableClients: boolean = true -): SidebarNavSection[] => [ +export const orgNavSections = (): SidebarNavSection[] => [ { heading: "sidebarGeneral", items: [ { title: "sidebarSites", href: "/{orgId}/settings/sites", - icon: + icon: }, { title: "sidebarResources", - href: "/{orgId}/settings/resources", - icon: + icon: , + items: [ + { + title: "sidebarProxyResources", + href: "/{orgId}/settings/resources/proxy", + icon: + }, + { + title: "sidebarClientResources", + href: "/{orgId}/settings/resources/client", + icon: + } + ] }, - ...(enableClients - ? [ - { - title: "sidebarClients", - href: "/{orgId}/settings/clients", - icon: , - isBeta: true - } - ] - : []), - ...(build === "saas" + { + title: "sidebarClients", + icon: , + isBeta: true, + items: [ + { + href: "/{orgId}/settings/clients/user", + title: "sidebarUserDevices", + icon: + }, + { + href: "/{orgId}/settings/clients/machine", + title: "sidebarMachineClients", + icon: + } + ] + }, + ...(build == "saas" ? [ { title: "sidebarRemoteExitNodes", href: "/{orgId}/settings/remote-exit-nodes", - icon: , + icon: , showEE: true } ] @@ -75,39 +94,45 @@ export const orgNavSections = ( { title: "sidebarDomains", href: "/{orgId}/settings/domains", - icon: + icon: }, { title: "sidebarBluePrints", href: "/{orgId}/settings/blueprints", - icon: + icon: } ] }, { - heading: "sidebarAccessControl", + heading: "accessControls", items: [ { title: "sidebarUsers", - href: "/{orgId}/settings/access/users", - icon: + icon: , + items: [ + { + title: "sidebarUsers", + href: "/{orgId}/settings/access/users", + icon: + }, + { + title: "sidebarInvitations", + href: "/{orgId}/settings/access/invitations", + icon: + } + ] }, { title: "sidebarRoles", href: "/{orgId}/settings/access/roles", - icon: - }, - { - title: "sidebarInvitations", - href: "/{orgId}/settings/access/invitations", - icon: + icon: }, ...(build == "saas" ? [ { title: "sidebarIdentityProviders", href: "/{orgId}/settings/idp", - icon: , + icon: , showEE: true } ] @@ -115,38 +140,56 @@ export const orgNavSections = ( { title: "sidebarShareableLinks", href: "/{orgId}/settings/share-links", - icon: + icon: } ] }, { - heading: "sidebarLogAndAnalytics", - items: [ - { - title: "sidebarLogsRequest", - href: "/{orgId}/settings/logs/request", - icon: - }, - { + heading: "sidebarLogsAndAnalytics", + items: (() => { + const logItems: SidebarNavItem[] = [ + { + title: "sidebarLogsRequest", + href: "/{orgId}/settings/logs/request", + icon: + }, + ...(build != "oss" + ? [ + { + title: "sidebarLogsAccess", + href: "/{orgId}/settings/logs/access", + icon: + }, + { + title: "sidebarLogsAction", + href: "/{orgId}/settings/logs/action", + icon: + } + ] + : []) + ]; + + const analytics = { title: "sidebarLogsAnalytics", href: "/{orgId}/settings/logs/analytics", icon: - }, - ...(build != "oss" - ? [ - { - title: "sidebarLogsAccess", - href: "/{orgId}/settings/logs/access", - icon: - }, - { - title: "sidebarLogsAction", - href: "/{orgId}/settings/logs/action", - icon: - } - ] - : []) - ] + }; + + // If only one log item, return it directly without grouping + if (logItems.length === 1) { + return [analytics, ...logItems]; + } + + // If multiple log items, create a group + return [ + analytics, + { + title: "sidebarLogs", + icon: , + items: logItems + } + ]; + })() }, { heading: "sidebarOrganization", @@ -154,14 +197,14 @@ export const orgNavSections = ( { title: "sidebarApiKeys", href: "/{orgId}/settings/api-keys", - icon: + icon: }, ...(build == "saas" ? [ { title: "sidebarBilling", href: "/{orgId}/settings/billing", - icon: + icon: } ] : []), @@ -170,14 +213,14 @@ export const orgNavSections = ( { title: "sidebarEnterpriseLicenses", href: "/{orgId}/settings/license", - icon: + icon: } ] : []), { title: "sidebarSettings", href: "/{orgId}/settings/general", - icon: + icon: } ] } @@ -185,29 +228,29 @@ export const orgNavSections = ( export const adminNavSections: SidebarNavSection[] = [ { - heading: "Admin", + heading: "sidebarAdmin", items: [ { title: "sidebarAllUsers", href: "/admin/users", - icon: + icon: }, { title: "sidebarApiKeys", href: "/admin/api-keys", - icon: + icon: }, { title: "sidebarIdentityProviders", href: "/admin/idp", - icon: + icon: }, ...(build == "enterprise" ? [ { title: "sidebarLicense", href: "/admin/license", - icon: + icon: } ] : []) diff --git a/src/app/page.tsx b/src/app/page.tsx index 2db1b6b1..f89aa866 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -80,7 +80,7 @@ export default async function Page(props: { const lastOrgCookie = allCookies.get("pangolin-last-org")?.value; const lastOrgExists = orgs.some((org) => org.orgId === lastOrgCookie); - if (lastOrgExists) { + if (lastOrgExists && lastOrgCookie) { redirect(`/${lastOrgCookie}`); } else { let ownedOrg = orgs.find((org) => org.isOwner); diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx index 65ffc786..53072554 100644 --- a/src/app/setup/page.tsx +++ b/src/app/setup/page.tsx @@ -76,8 +76,8 @@ export default function StepperForm() { } catch (e) { console.error("Failed to fetch default subnet:", e); toast({ - title: "Error", - description: "Failed to fetch default subnet", + title: t("error"), + description: t("setupFailedToFetchSubnet"), variant: "destructive" }); } @@ -296,31 +296,27 @@ export default function StepperForm() { )} /> - {env.flags.enableClients && ( - ( - - - Subnet - - - - - - - Network subnet for this - organization. A default - value has been provided. - - - )} - /> - )} + ( + + + {t("setupSubnetAdvanced")} + + + + + + + {t("setupSubnetDescription")} + + + )} + /> {orgIdTaken && !orgCreated ? ( diff --git a/src/components/AdminIdpDataTable.tsx b/src/components/AdminIdpDataTable.tsx index 63a0b4bb..26ec6b31 100644 --- a/src/components/AdminIdpDataTable.tsx +++ b/src/components/AdminIdpDataTable.tsx @@ -35,6 +35,9 @@ export function IdpDataTable({ }} onRefresh={onRefresh} isRefreshing={isRefreshing} + enableColumnVisibility={true} + stickyLeftColumn="name" + stickyRightColumn="actions" /> ); } diff --git a/src/components/AdminIdpTable.tsx b/src/components/AdminIdpTable.tsx index cea32da3..b0782fcb 100644 --- a/src/components/AdminIdpTable.tsx +++ b/src/components/AdminIdpTable.tsx @@ -1,6 +1,7 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { IdpDataTable } from "@app/components/AdminIdpDataTable"; import { Button } from "@app/components/ui/button"; import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; @@ -77,9 +78,10 @@ export default function IdpTable({ idps }: Props) { } }; - const columns: ColumnDef[] = [ + const columns: ExtendedColumnDef[] = [ { accessorKey: "idpId", + friendlyName: "ID", header: ({ column }) => { return ( - - +

+ + + + + + {r.type !== "internal" && ( { - setSelected(r); - setIsDeleteModalOpen(true); + generatePasswordResetCode(r.id); }} > - {t("delete")} + {t("generatePasswordResetCode")} - - - -
- + )} + { + setSelected(r); + setIsDeleteModalOpen(true); + }} + > + {t("delete")} + + + + +
); } } @@ -288,6 +345,58 @@ export default function UsersTable({ users }: Props) { onRefresh={refreshData} isRefreshing={isRefreshing} /> + + + + + + {t("passwordResetCodeGenerated")} + + + {t("passwordResetCodeGeneratedDescription")} + + + + {passwordResetCodeData && ( +
+
+ + +
+
+ + +
+
+ + +
+
+ )} +
+ + + + + +
+
); } diff --git a/src/components/ApiKeysDataTable.tsx b/src/components/ApiKeysDataTable.tsx index 58ab9252..c4f93136 100644 --- a/src/components/ApiKeysDataTable.tsx +++ b/src/components/ApiKeysDataTable.tsx @@ -59,6 +59,9 @@ export function ApiKeysDataTable({ addButtonText={t('apiKeysAdd')} onRefresh={onRefresh} isRefreshing={isRefreshing} + enableColumnVisibility={true} + stickyLeftColumn="name" + stickyRightColumn="actions" /> ); } diff --git a/src/components/ApiKeysTable.tsx b/src/components/ApiKeysTable.tsx index 1fcae24d..863d2bc6 100644 --- a/src/components/ApiKeysTable.tsx +++ b/src/components/ApiKeysTable.tsx @@ -1,6 +1,7 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, @@ -85,9 +86,11 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { }); }; - const columns: ColumnDef[] = [ + const columns: ExtendedColumnDef[] = [ { accessorKey: "name", + enableHiding: false, + friendlyName: t("name"), header: ({ column }) => { return ( - - + + + ); } @@ -178,13 +185,9 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { }} dialog={
-

- {t("apiKeysQuestionRemove")} -

+

{t("apiKeysQuestionRemove")}

-

- {t("apiKeysMessageRemove")} -

+

{t("apiKeysMessageRemove")}

} buttonText={t("apiKeysDeleteConfirm")} diff --git a/src/components/BlueprintsTable.tsx b/src/components/BlueprintsTable.tsx index 3ccff996..0fa05d75 100644 --- a/src/components/BlueprintsTable.tsx +++ b/src/components/BlueprintsTable.tsx @@ -1,6 +1,7 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { Button } from "@app/components/ui/button"; import { ArrowRight, @@ -30,9 +31,10 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) { const [isRefreshing, startTransition] = useTransition(); const router = useRouter(); - const columns: ColumnDef[] = [ + const columns: ExtendedColumnDef[] = [ { accessorKey: "createdAt", + friendlyName: t("appliedAt"), header: ({ column }) => { return ( + ); + } + }, + { + accessorKey: "siteName", + friendlyName: t("site"), + header: () => {t("site")}, + cell: ({ row }) => { + const resourceRow = row.original; + return ( + + + + ); + } + }, + { + accessorKey: "mode", + friendlyName: t("editInternalResourceDialogMode"), + header: () => ( + + {t("editInternalResourceDialogMode")} + + ), + cell: ({ row }) => { + const resourceRow = row.original; + const modeLabels: Record<"host" | "cidr" | "port", string> = { + host: t("editInternalResourceDialogModeHost"), + cidr: t("editInternalResourceDialogModeCidr"), + port: t("editInternalResourceDialogModePort") + }; + return {modeLabels[resourceRow.mode]}; + } + }, + { + accessorKey: "destination", + friendlyName: t("resourcesTableDestination"), + header: () => ( + {t("resourcesTableDestination")} + ), + cell: ({ row }) => { + const resourceRow = row.original; + return ( + + ); + } + }, + { + accessorKey: "alias", + friendlyName: t("resourcesTableAlias"), + header: () => ( + {t("resourcesTableAlias")} + ), + cell: ({ row }) => { + const resourceRow = row.original; + return resourceRow.mode === "host" && resourceRow.alias ? ( + + ) : ( + - + ); + } + }, + { + id: "actions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const resourceRow = row.original; + return ( +
+ + + + + + { + setSelectedInternalResource( + resourceRow + ); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + +
+ ); + } + } + ]; + + return ( + <> + {selectedInternalResource && ( + { + setIsDeleteModalOpen(val); + setSelectedInternalResource(null); + }} + dialog={ +
+

{t("resourceQuestionRemove")}

+

{t("resourceMessageRemove")}

+
+ } + buttonText={t("resourceDeleteConfirm")} + onConfirm={async () => + deleteInternalResource( + selectedInternalResource!.id, + selectedInternalResource!.siteId + ) + } + string={selectedInternalResource.name} + title={t("resourceDelete")} + /> + )} + + setIsCreateDialogOpen(true)} + addButtonText={t("resourceAdd")} + onRefresh={refreshData} + isRefreshing={isRefreshing} + defaultSort={defaultSort} + enableColumnVisibility={true} + persistColumnVisibility="internal-resources" + stickyLeftColumn="name" + stickyRightColumn="actions" + /> + + {editingResource && ( + { + router.refresh(); + setEditingResource(null); + }} + /> + )} + + { + router.refresh(); + }} + /> + + ); +} diff --git a/src/components/ClientsDataTable.tsx b/src/components/ClientsDataTable.tsx index 619f1fad..cf42e7bc 100644 --- a/src/components/ClientsDataTable.tsx +++ b/src/components/ClientsDataTable.tsx @@ -5,12 +5,23 @@ import { } from "@tanstack/react-table"; import { DataTable } from "@app/components/ui/data-table"; +type TabFilter = { + id: string; + label: string; + filterFn: (row: any) => boolean; +}; + interface DataTableProps { columns: ColumnDef[]; data: TData[]; onRefresh?: () => void; isRefreshing?: boolean; addClient?: () => void; + columnVisibility?: Record; + enableColumnVisibility?: boolean; + hideHeader?: boolean; + tabs?: TabFilter[]; + defaultTab?: string; } export function ClientsDataTable({ @@ -18,20 +29,29 @@ export function ClientsDataTable({ data, addClient, onRefresh, - isRefreshing + isRefreshing, + columnVisibility, + enableColumnVisibility, + hideHeader = false, + tabs, + defaultTab }: DataTableProps) { return ( ); } diff --git a/src/components/ClientsTable.tsx b/src/components/ClientsTable.tsx deleted file mode 100644 index e5f6a006..00000000 --- a/src/components/ClientsTable.tsx +++ /dev/null @@ -1,350 +0,0 @@ -"use client"; - -import { ColumnDef } from "@tanstack/react-table"; -import { ClientsDataTable } from "@app/components/ClientsDataTable"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from "@app/components/ui/dropdown-menu"; -import { Button } from "@app/components/ui/button"; -import { - ArrowRight, - ArrowUpDown, - ArrowUpRight, - Check, - MoreHorizontal, - X -} from "lucide-react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useState } 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 { useTranslations } from "next-intl"; -import { Badge } from "./ui/badge"; -import { InfoPopup } from "./ui/info-popup"; - -export type ClientRow = { - id: number; - name: string; - subnet: string; - // siteIds: string; - mbIn: string; - mbOut: string; - orgId: string; - online: boolean; - olmVersion?: string; - olmUpdateAvailable: boolean; -}; - -type ClientTableProps = { - clients: ClientRow[]; - orgId: string; -}; - -export default function ClientsTable({ clients, orgId }: ClientTableProps) { - const router = useRouter(); - - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [selectedClient, setSelectedClient] = useState( - null - ); - const [rows, setRows] = useState(clients); - - const api = createApiClient(useEnvContext()); - const [isRefreshing, setIsRefreshing] = useState(false); - const t = useTranslations(); - - 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 deleteClient = (clientId: number) => { - api.delete(`/client/${clientId}`) - .catch((e) => { - console.error("Error deleting client", e); - toast({ - variant: "destructive", - title: "Error deleting client", - description: formatAxiosError(e, "Error deleting client") - }); - }) - .then(() => { - router.refresh(); - setIsDeleteModalOpen(false); - - const newRows = rows.filter((row) => row.id !== clientId); - - setRows(newRows); - }); - }; - - const columns: ColumnDef[] = [ - { - accessorKey: "name", - header: ({ column }) => { - return ( - - ); - } - }, - // { - // accessorKey: "siteName", - // header: ({ column }) => { - // return ( - // - // ); - // }, - // cell: ({ row }) => { - // const r = row.original; - // return ( - // - // - // - // ); - // } - // }, - { - accessorKey: "online", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const originalRow = row.original; - if (originalRow.online) { - return ( - -
- Connected -
- ); - } else { - return ( - -
- Disconnected -
- ); - } - } - }, - { - accessorKey: "mbIn", - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "mbOut", - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "client", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const originalRow = row.original; - - return ( -
- -
- Olm - {originalRow.olmVersion && ( - - v{originalRow.olmVersion} - - )} -
-
- {originalRow.olmUpdateAvailable && ( - - )} -
- ); - } - }, - { - accessorKey: "subnet", - header: ({ column }) => { - return ( - - ); - } - }, - { - id: "actions", - cell: ({ row }) => { - const clientRow = row.original; - return ( -
- - - - - - - - - View settings - - - { - setSelectedClient(clientRow); - setIsDeleteModalOpen(true); - }} - > - Delete - - - - - - -
- ); - } - } - ]; - - return ( - <> - {selectedClient && ( - { - setIsDeleteModalOpen(val); - setSelectedClient(null); - }} - dialog={ -
-

- {t("deleteClientQuestion")} -

-

- {t("clientMessageRemove")} -

-
- } - buttonText="Confirm Delete Client" - onConfirm={async () => deleteClient(selectedClient!.id)} - string={selectedClient.name} - title="Delete Client" - /> - )} - - { - router.push(`/${orgId}/settings/clients/create`); - }} - onRefresh={refreshData} - isRefreshing={isRefreshing} - /> - - ); -} diff --git a/src/components/ContainersSelector.tsx b/src/components/ContainersSelector.tsx index b97e0eeb..7a5cc74b 100644 --- a/src/components/ContainersSelector.tsx +++ b/src/components/ContainersSelector.tsx @@ -7,6 +7,7 @@ import { getFilteredRowModel, VisibilityState } from "@tanstack/react-table"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { Button } from "@/components/ui/button"; import { Credenza, @@ -181,17 +182,19 @@ const DockerContainersTable: FC<{ [getExposedPorts] ); - const columns: ColumnDef[] = [ + const columns: ExtendedColumnDef[] = [ { accessorKey: "name", - header: t("containerName"), + friendlyName: t("containerName"), + header: () => ({t("containerName")}), cell: ({ row }) => (
{row.original.name}
) }, { accessorKey: "image", - header: t("containerImage"), + friendlyName: t("containerImage"), + header: () => ({t("containerImage")}), cell: ({ row }) => (
{row.original.image} @@ -200,7 +203,8 @@ const DockerContainersTable: FC<{ }, { accessorKey: "state", - header: t("containerState"), + friendlyName: t("containerState"), + header: () => ({t("containerState")}), cell: ({ row }) => ( ({t("containerNetworks")}), cell: ({ row }) => { const networks = Object.keys(row.original.networks); return ( @@ -233,7 +238,8 @@ const DockerContainersTable: FC<{ }, { accessorKey: "hostname", - header: t("containerHostnameIp"), + friendlyName: t("containerHostnameIp"), + header: () => ({t("containerHostnameIp")}), enableHiding: false, cell: ({ row }) => (
@@ -243,7 +249,8 @@ const DockerContainersTable: FC<{ }, { accessorKey: "labels", - header: t("containerLabels"), + friendlyName: t("containerLabels"), + header: () => ({t("containerLabels")}), cell: ({ row }) => { const labels = row.original.labels || {}; const labelEntries = Object.entries(labels); @@ -295,7 +302,7 @@ const DockerContainersTable: FC<{ }, { accessorKey: "ports", - header: t("containerPorts"), + header: () => ({t("containerPorts")}), enableHiding: false, cell: ({ row }) => { const ports = getExposedPorts(row.original); @@ -353,7 +360,7 @@ const DockerContainersTable: FC<{ }, { id: "actions", - header: t("containerActions"), + header: () => ({t("containerActions")}), cell: ({ row }) => { const ports = getExposedPorts(row.original); return ( diff --git a/src/components/CopyToClipboard.tsx b/src/components/CopyToClipboard.tsx index b187e6c6..5cdeed55 100644 --- a/src/components/CopyToClipboard.tsx +++ b/src/components/CopyToClipboard.tsx @@ -26,24 +26,21 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) => const t = useTranslations(); return ( -
+
{isLink ? ( {displayValue} ) : ( )} + @@ -180,9 +355,13 @@ export default function CreateInternalResourceDialog({ - {t("createInternalResourceDialogCreateClientResource")} + + {t("createInternalResourceDialogCreateClientResource")} + - {t("createInternalResourceDialogCreateClientResourceDescription")} + {t( + "createInternalResourceDialogCreateClientResourceDescription" + )} @@ -195,7 +374,9 @@ export default function CreateInternalResourceDialog({ {/* Resource Properties Form */}

- {t("createInternalResourceDialogResourceProperties")} + {t( + "createInternalResourceDialogResourceProperties" + )}

( - {t("createInternalResourceDialogName")} + + {t( + "createInternalResourceDialogName" + )} + @@ -212,157 +397,238 @@ export default function CreateInternalResourceDialog({ )} /> -
- ( - - {t("createInternalResourceDialogSite")} - - - - + + + + + + + + {t( + "createInternalResourceDialogNoSitesFound" )} - > - {field.value - ? availableSites.find( - (site) => site.siteId === field.value - )?.name - : t("createInternalResourceDialogSelectSite")} - - - - - - - - - {t("createInternalResourceDialogNoSitesFound")} - - {availableSites.map((site) => ( + + + {availableSites.map( + ( + site + ) => ( { - field.onChange(site.siteId); + field.onChange( + site.siteId + ); }} > - {site.name} + { + site.name + } - ))} - - - - - - - - )} - /> - - ( - - - {t("createInternalResourceDialogProtocol")} - - - - - )} - /> -
- - ( - - {t("createInternalResourceDialogSitePort")} - - - field.onChange( - e.target.value === "" ? undefined : parseInt(e.target.value) - ) - } - /> - - - {t("createInternalResourceDialogSitePortDescription")} - + ) + )} + + + + + )} /> + + ( + + + {t( + "createInternalResourceDialogMode" + )} + + + + + )} + /> + {/* + {mode === "port" && ( + <> +
+ ( + + + {t("createInternalResourceDialogProtocol")} + + + + + )} + /> + + ( + + {t("createInternalResourceDialogSitePort")} + + + field.onChange( + e.target.value === "" ? undefined : parseInt(e.target.value) + ) + } + /> + + + + )} + /> +
+ + )} */}
{/* Target Configuration Form */}

- {t("createInternalResourceDialogTargetConfiguration")} + {t( + "createInternalResourceDialogTargetConfiguration" + )}

-
- ( - - - {t("targetAddr")} - - - - - - {t("createInternalResourceDialogDestinationIPDescription")} - - - - )} - /> + ( + + + {t( + "createInternalResourceDialogDestination" + )} + + + + + + {mode === "host" && + t( + "createInternalResourceDialogDestinationHostDescription" + )} + {mode === "cidr" && + t( + "createInternalResourceDialogDestinationCidrDescription" + )} + {/* {mode === "port" && t("createInternalResourceDialogDestinationIPDescription")} */} + + + + )} + /> + {/* {mode === "port" && ( )} /> -
+ )} */} +
+
+ + {/* Alias */} + {mode !== "cidr" && ( +
+ ( + + + {t( + "createInternalResourceDialogAlias" + )} + + + + + + {t( + "createInternalResourceDialogAliasDescription" + )} + + + + )} + /> +
+ )} + + {/* Access Control Section */} +
+

+ {t("resourceUsersRoles")} +

+
+ ( + + + {t("roles")} + + + { + form.setValue( + "roles", + newRoles as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + allRoles + } + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + {t( + "resourceRoleDescription" + )} + + + )} + /> + ( + + + {t("users")} + + + { + form.setValue( + "users", + newUsers as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + allUsers + } + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + )} + /> + {hasMachineClients && ( + ( + + + {t("machineClients")} + + + { + form.setValue( + "clients", + newClients as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + allClients + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + )} + /> + )}
- + + + + + + + + ); +} diff --git a/src/components/DeviceLoginForm.tsx b/src/components/DeviceLoginForm.tsx new file mode 100644 index 00000000..1eeeb5ae --- /dev/null +++ b/src/components/DeviceLoginForm.tsx @@ -0,0 +1,303 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useRouter } from "next/navigation"; +import { + InputOTP, + InputOTPGroup, + InputOTPSeparator, + InputOTPSlot +} from "@/components/ui/input-otp"; +import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; +import { AlertTriangle } from "lucide-react"; +import { DeviceAuthConfirmation } from "@/components/DeviceAuthConfirmation"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import BrandingLogo from "./BrandingLogo"; +import { useTranslations } from "next-intl"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; + +const createFormSchema = (t: (key: string) => string) => + z.object({ + code: z.string().length(8, t("deviceCodeInvalidFormat")) + }); + +type DeviceAuthMetadata = { + ip: string | null; + city: string | null; + deviceName: string | null; + applicationName: string; + createdAt: number; +}; + +type DeviceLoginFormProps = { + userEmail: string; + userName?: string; + initialCode?: string; +}; + +export default function DeviceLoginForm({ + userEmail, + userName, + initialCode = "" +}: DeviceLoginFormProps) { + const router = useRouter(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [metadata, setMetadata] = useState(null); + const [code, setCode] = useState(""); + const { isUnlocked } = useLicenseStatusContext(); + const t = useTranslations(); + + const formSchema = createFormSchema(t); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + code: initialCode.replace(/-/g, "").toUpperCase() + } + }); + + async function onSubmit(data: z.infer) { + setError(null); + setLoading(true); + + try { + // split code and add dash if missing + if (!data.code.includes("-") && data.code.length === 8) { + data.code = data.code.slice(0, 4) + "-" + data.code.slice(4); + } + + await new Promise((resolve) => setTimeout(resolve, 300)); + + // First check - get metadata + const res = await api.post( + "/device-web-auth/verify?forceLogin=true", + { + code: data.code.toUpperCase(), + verify: false + } + ); + + if (res.data.success && res.data.data.metadata) { + setMetadata(res.data.data.metadata); + setCode(data.code.toUpperCase()); + } else { + setError(t("deviceCodeInvalidOrExpired")); + } + } catch (e: any) { + const errorMessage = formatAxiosError(e); + setError(errorMessage || t("deviceCodeInvalidOrExpired")); + } finally { + setLoading(false); + } + } + + async function onConfirm() { + if (!code || !metadata) return; + + setError(null); + setLoading(true); + + try { + await new Promise((resolve) => setTimeout(resolve, 300)); + + // Final verify + await api.post("/device-web-auth/verify", { + code: code, + verify: true + }); + + // Redirect to success page + router.push("/auth/login/device/success"); + } catch (e: any) { + const errorMessage = formatAxiosError(e); + setError(errorMessage || t("deviceCodeVerifyFailed")); + setMetadata(null); + setCode(""); + form.reset(); + } finally { + setLoading(false); + } + } + + const logoWidth = isUnlocked() + ? env.branding.logo?.authPage?.width || 175 + : 175; + const logoHeight = isUnlocked() + ? env.branding.logo?.authPage?.height || 58 + : 58; + + function onCancel() { + setMetadata(null); + setCode(""); + form.reset(); + setError(null); + } + + const profileLabel = (userName || userEmail || "").trim(); + const profileInitial = profileLabel + ? profileLabel.charAt(0).toUpperCase() + : "?"; + + async function handleUseDifferentAccount() { + try { + await api.post("/auth/logout"); + } catch (logoutError) { + console.error( + "Failed to logout before switching account", + logoutError + ); + } finally { + const currentSearch = + typeof window !== "undefined" ? window.location.search : ""; + const redirectTarget = `/auth/login/device${currentSearch || ""}`; + router.push( + `/auth/login?forceLogin=true&redirect=${encodeURIComponent(redirectTarget)}` + ); + router.refresh(); + } + } + + if (metadata) { + return ( + + ); + } + + return ( + + +
+ +
+
+

+ {t("deviceActivation")} +

+
+
+ +
+ + {profileInitial} + +
+
+

+ {profileLabel || userEmail} +

+

+ {t( + "deviceLoginDeviceRequestingAccessToAccount" + )} +

+
+ +
+
+ +
+ +
+

+ {t("deviceCodeEnterPrompt")} +

+
+ + ( + + +
+ { + // Strip hyphens and convert to uppercase + const cleanedValue = value + .replace(/-/g, "") + .toUpperCase(); + field.onChange( + cleanedValue + ); + }} + > + + + + + + + + + + + + + + +
+
+ +
+ )} + /> + + {error && ( + + {error} + + )} + + + + +
+
+ ); +} diff --git a/src/components/DomainsDataTable.tsx b/src/components/DomainsDataTable.tsx index 4059b7d3..5e8b06a5 100644 --- a/src/components/DomainsDataTable.tsx +++ b/src/components/DomainsDataTable.tsx @@ -33,6 +33,9 @@ export function DomainsDataTable({ onAdd={onAdd} onRefresh={onRefresh} isRefreshing={isRefreshing} + enableColumnVisibility={true} + stickyLeftColumn="baseDomain" + stickyRightColumn="actions" /> ); } diff --git a/src/components/DomainsTable.tsx b/src/components/DomainsTable.tsx index a099a8b8..331425c3 100644 --- a/src/components/DomainsTable.tsx +++ b/src/components/DomainsTable.tsx @@ -1,6 +1,7 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DomainsDataTable } from "@app/components/DomainsDataTable"; import { Button } from "@app/components/ui/button"; import { @@ -135,8 +136,31 @@ export default function DomainsTable({ domains, orgId }: Props) { } }; - const statusColumn: ColumnDef = { + const typeColumn: ExtendedColumnDef = { + accessorKey: "type", + friendlyName: t("type"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const type = row.original.type; + return {getTypeDisplay(type)}; + } + }; + + const statusColumn: ExtendedColumnDef = { accessorKey: "verified", + friendlyName: t("status"), header: ({ column }) => { return ( - ); - }, - cell: ({ row }) => { - const type = row.original.type; - return ( - {getTypeDisplay(type)} - ); - } - }, + ...(env.env.flags.usePangolinDns ? [typeColumn] : []), ...(env.env.flags.usePangolinDns ? [statusColumn] : []), { id: "actions", + enableHiding: false, + header: () => , cell: ({ row }) => { const domain = row.original; const isRestarting = restartingDomains.has(domain.domainId); return ( -
+
+ + + + + + + + {t("viewSettings")} + + + { + setSelectedDomain(domain); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + {domain.failed && ( )} -
- - - - - - - - {t("viewSettings")} - - - { - setSelectedDomain(domain); - setIsDeleteModalOpen(true); - }} - > - - {t("delete")} - - - - - - - - -
+ + + {/* + + + diff --git a/src/components/GenerateLicenseKeysTable.tsx b/src/components/GenerateLicenseKeysTable.tsx index 835bb70d..2be17e89 100644 --- a/src/components/GenerateLicenseKeysTable.tsx +++ b/src/components/GenerateLicenseKeysTable.tsx @@ -2,6 +2,7 @@ import { useTranslations } from "next-intl"; import { ColumnDef } from "@tanstack/react-table"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { Button } from "./ui/button"; import { ArrowUpDown } from "lucide-react"; import CopyToClipboard from "./CopyToClipboard"; @@ -63,9 +64,10 @@ export default function GenerateLicenseKeysTable({ } }; - const columns: ColumnDef[] = [ + const columns: ExtendedColumnDef[] = [ { accessorKey: "licenseKey", + friendlyName: t("licenseKey"), header: ({ column }) => { return ( )} {state === "request" && ( - )} - {quickstart - ? t('accountSetupSubmit') - : t('passwordResetSubmit') - } - + +
)} {state === "mfa" && ( @@ -507,7 +568,7 @@ export default function ResetPasswordForm({ mfaForm.reset(); }} > - {t('passwordBack')} + {t("passwordBack")} )} @@ -521,7 +582,7 @@ export default function ResetPasswordForm({ form.reset(); }} > - {t('backToEmail')} + {t("backToEmail")} )}
diff --git a/src/components/ResourcesTable.tsx b/src/components/ResourcesTable.tsx deleted file mode 100644 index 1cea3452..00000000 --- a/src/components/ResourcesTable.tsx +++ /dev/null @@ -1,1204 +0,0 @@ -"use client"; - -import { - ColumnDef, - flexRender, - getCoreRowModel, - useReactTable, - getPaginationRowModel, - SortingState, - getSortedRowModel, - ColumnFiltersState, - getFilteredRowModel, - VisibilityState -} from "@tanstack/react-table"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - DropdownMenuCheckboxItem -} from "@app/components/ui/dropdown-menu"; -import { Button } from "@app/components/ui/button"; -import { - ArrowRight, - ArrowUpDown, - MoreHorizontal, - ArrowUpRight, - ShieldOff, - ShieldCheck, - RefreshCw, - Settings2, - Plus, - Search, - ChevronDown, - Clock, - Wifi, - WifiOff, - CheckCircle2, - XCircle -} from "lucide-react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useState, useEffect } from "react"; -import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { formatAxiosError } from "@app/lib/api"; -import { toast } from "@app/hooks/useToast"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import CopyToClipboard from "@app/components/CopyToClipboard"; -import { Switch } from "@app/components/ui/switch"; -import { AxiosResponse } from "axios"; -import { UpdateResourceResponse } from "@server/routers/resource"; -import { ListSitesResponse } from "@server/routers/site"; -import { useTranslations } from "next-intl"; -import { InfoPopup } from "@app/components/ui/info-popup"; -import { Input } from "@app/components/ui/input"; -import { DataTablePagination } from "@app/components/DataTablePagination"; -import { Card, CardContent, CardHeader } from "@app/components/ui/card"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow -} from "@app/components/ui/table"; -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger -} from "@app/components/ui/tabs"; -import { useSearchParams } from "next/navigation"; -import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; -import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog"; -import { Alert, AlertDescription } from "@app/components/ui/alert"; - -export type TargetHealth = { - targetId: number; - ip: string; - port: number; - enabled: boolean; - healthStatus?: "healthy" | "unhealthy" | "unknown"; -}; - -export type ResourceRow = { - id: number; - nice: string | null; - name: string; - orgId: string; - domain: string; - authState: string; - http: boolean; - protocol: string; - proxyPort: number | null; - enabled: boolean; - domainId?: string; - ssl: boolean; - targetHost?: string; - targetPort?: number; - targets?: TargetHealth[]; -}; - -function getOverallHealthStatus( - targets?: TargetHealth[] -): "online" | "degraded" | "offline" | "unknown" { - if (!targets || targets.length === 0) { - return "unknown"; - } - - const monitoredTargets = targets.filter( - (t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown" - ); - - if (monitoredTargets.length === 0) { - return "unknown"; - } - - const healthyCount = monitoredTargets.filter( - (t) => t.healthStatus === "healthy" - ).length; - const unhealthyCount = monitoredTargets.filter( - (t) => t.healthStatus === "unhealthy" - ).length; - - if (healthyCount === monitoredTargets.length) { - return "online"; - } else if (unhealthyCount === monitoredTargets.length) { - return "offline"; - } else { - return "degraded"; - } -} - -function StatusIcon({ - status, - className = "" -}: { - status: "online" | "degraded" | "offline" | "unknown"; - className?: string; -}) { - const iconClass = `h-4 w-4 ${className}`; - - switch (status) { - case "online": - return ; - case "degraded": - return ; - case "offline": - return ; - case "unknown": - return ; - default: - return null; - } -} -export type InternalResourceRow = { - id: number; - name: string; - orgId: string; - siteName: string; - protocol: string; - proxyPort: number | null; - siteId: number; - siteNiceId: string; - destinationIp: string; - destinationPort: number; -}; - -type Site = ListSitesResponse["sites"][0]; - -type ResourcesTableProps = { - resources: ResourceRow[]; - internalResources: InternalResourceRow[]; - orgId: string; - defaultView?: "proxy" | "internal"; - defaultSort?: { - id: string; - desc: boolean; - }; -}; - -const STORAGE_KEYS = { - PAGE_SIZE: "datatable-page-size", - getTablePageSize: (tableId?: string) => - tableId ? `datatable-${tableId}-page-size` : STORAGE_KEYS.PAGE_SIZE -}; - -const getStoredPageSize = (tableId?: string, defaultSize = 20): number => { - if (typeof window === "undefined") return defaultSize; - - try { - const key = STORAGE_KEYS.getTablePageSize(tableId); - const stored = localStorage.getItem(key); - if (stored) { - const parsed = parseInt(stored, 10); - if (parsed > 0 && parsed <= 1000) { - return parsed; - } - } - } catch (error) { - console.warn("Failed to read page size from localStorage:", error); - } - return defaultSize; -}; - -const setStoredPageSize = (pageSize: number, tableId?: string): void => { - if (typeof window === "undefined") return; - - try { - const key = STORAGE_KEYS.getTablePageSize(tableId); - localStorage.setItem(key, pageSize.toString()); - } catch (error) { - console.warn("Failed to save page size to localStorage:", error); - } -}; - -export default function ResourcesTable({ - resources, - internalResources, - orgId, - defaultView = "proxy", - defaultSort -}: ResourcesTableProps) { - const router = useRouter(); - const searchParams = useSearchParams(); - const t = useTranslations(); - - const { env } = useEnvContext(); - - const api = createApiClient({ env }); - - const [proxyPageSize, setProxyPageSize] = useState(() => - getStoredPageSize("proxy-resources", 20) - ); - const [internalPageSize, setInternalPageSize] = useState(() => - getStoredPageSize("internal-resources", 20) - ); - - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [selectedResource, setSelectedResource] = - useState(); - const [selectedInternalResource, setSelectedInternalResource] = - useState(); - const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); - const [editingResource, setEditingResource] = - useState(); - const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); - const [sites, setSites] = useState([]); - - const [proxySorting, setProxySorting] = useState( - defaultSort ? [defaultSort] : [] - ); - - const [proxyColumnVisibility, setProxyColumnVisibility] = - useState({}); - const [internalColumnVisibility, setInternalColumnVisibility] = - useState({}); - - const [proxyColumnFilters, setProxyColumnFilters] = - useState([]); - const [proxyGlobalFilter, setProxyGlobalFilter] = useState([]); - - const [internalSorting, setInternalSorting] = useState( - defaultSort ? [defaultSort] : [] - ); - const [internalColumnFilters, setInternalColumnFilters] = - useState([]); - const [internalGlobalFilter, setInternalGlobalFilter] = useState([]); - const [isRefreshing, setIsRefreshing] = useState(false); - - const currentView = searchParams.get("view") || defaultView; - - 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); - } - }; - - useEffect(() => { - const fetchSites = async () => { - try { - const res = await api.get>( - `/org/${orgId}/sites` - ); - setSites(res.data.data.sites); - } catch (error) { - console.error("Failed to fetch sites:", error); - } - }; - - if (orgId) { - fetchSites(); - } - }, [orgId]); - - const handleTabChange = (value: string) => { - const params = new URLSearchParams(searchParams); - if (value === "internal") { - params.set("view", "internal"); - } else { - params.delete("view"); - } - - const newUrl = `${window.location.pathname}${params.toString() ? "?" + params.toString() : ""}`; - router.replace(newUrl, { scroll: false }); - }; - - const getSearchInput = () => { - if (currentView === "internal") { - return ( -
- - internalTable.setGlobalFilter( - String(e.target.value) - ) - } - className="w-full pl-8" - /> - -
- ); - } - return ( -
- - proxyTable.setGlobalFilter(String(e.target.value)) - } - className="w-full pl-8" - /> - -
- ); - }; - - const getActionButton = () => { - if (currentView === "internal") { - return ( - - ); - } - return ( - - ); - }; - - const deleteResource = (resourceId: number) => { - api.delete(`/resource/${resourceId}`) - .catch((e) => { - console.error(t("resourceErrorDelte"), e); - toast({ - variant: "destructive", - title: t("resourceErrorDelte"), - description: formatAxiosError(e, t("resourceErrorDelte")) - }); - }) - .then(() => { - router.refresh(); - setIsDeleteModalOpen(false); - }); - }; - - const deleteInternalResource = async ( - resourceId: number, - siteId: number - ) => { - try { - await api.delete( - `/org/${orgId}/site/${siteId}/resource/${resourceId}` - ); - router.refresh(); - setIsDeleteModalOpen(false); - } catch (e) { - console.error(t("resourceErrorDelete"), e); - toast({ - variant: "destructive", - title: t("resourceErrorDelte"), - description: formatAxiosError(e, t("v")) - }); - } - }; - - async function toggleResourceEnabled(val: boolean, resourceId: number) { - const res = await api - .post>( - `resource/${resourceId}`, - { - enabled: val - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("resourcesErrorUpdate"), - description: formatAxiosError( - e, - t("resourcesErrorUpdateDescription") - ) - }); - }); - } - - function TargetStatusCell({ targets }: { targets?: TargetHealth[] }) { - const overallStatus = getOverallHealthStatus(targets); - - if (!targets || targets.length === 0) { - return ( -
- - - {t("resourcesTableNoTargets")} - -
- ); - } - - const monitoredTargets = targets.filter( - (t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown" - ); - const unknownTargets = targets.filter( - (t) => !t.enabled || !t.healthStatus || t.healthStatus === "unknown" - ); - - return ( - - - - - - {monitoredTargets.length > 0 && ( - <> - {monitoredTargets.map((target) => ( - -
- - {`${target.ip}:${target.port}`} -
- - {target.healthStatus} - -
- ))} - - )} - {unknownTargets.length > 0 && ( - <> - {unknownTargets.map((target) => ( - -
- - {`${target.ip}:${target.port}`} -
- - {!target.enabled - ? t("disabled") - : t("resourcesTableNotMonitored")} - -
- ))} - - )} -
-
- ); - } - - const proxyColumns: ColumnDef[] = [ - { - accessorKey: "name", - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "protocol", - header: t("protocol"), - cell: ({ row }) => { - const resourceRow = row.original; - return ( - - {resourceRow.http - ? resourceRow.ssl - ? "HTTPS" - : "HTTP" - : resourceRow.protocol.toUpperCase()} - - ); - } - }, - { - id: "status", - accessorKey: "status", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const resourceRow = row.original; - return ; - }, - sortingFn: (rowA, rowB) => { - const statusA = getOverallHealthStatus(rowA.original.targets); - const statusB = getOverallHealthStatus(rowB.original.targets); - const statusOrder = { - online: 3, - degraded: 2, - offline: 1, - unknown: 0 - }; - return statusOrder[statusA] - statusOrder[statusB]; - } - }, - { - accessorKey: "domain", - header: t("access"), - cell: ({ row }) => { - const resourceRow = row.original; - return ( -
- {!resourceRow.http ? ( - - ) : !resourceRow.domainId ? ( - - ) : ( - - )} -
- ); - } - }, - { - accessorKey: "authState", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const resourceRow = row.original; - return ( -
- {resourceRow.authState === "protected" ? ( - - - {t("protected")} - - ) : resourceRow.authState === "not_protected" ? ( - - - {t("notProtected")} - - ) : ( - - - )} -
- ); - } - }, - { - accessorKey: "enabled", - header: t("enabled"), - cell: ({ row }) => ( - - toggleResourceEnabled(val, row.original.id) - } - /> - ) - }, - { - id: "actions", - cell: ({ row }) => { - const resourceRow = row.original; - return ( -
- - - - - - - - {t("viewSettings")} - - - { - setSelectedResource(resourceRow); - setIsDeleteModalOpen(true); - }} - > - - {t("delete")} - - - - - - - -
- ); - } - } - ]; - - const internalColumns: ColumnDef[] = [ - { - accessorKey: "name", - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "siteName", - header: t("siteName"), - cell: ({ row }) => { - const resourceRow = row.original; - return ( - - - - ); - } - }, - { - accessorKey: "protocol", - header: t("protocol"), - cell: ({ row }) => { - const resourceRow = row.original; - return {resourceRow.protocol.toUpperCase()}; - } - }, - { - accessorKey: "proxyPort", - header: t("proxyPort"), - cell: ({ row }) => { - const resourceRow = row.original; - return ( - - ); - } - }, - { - accessorKey: "destination", - header: t("resourcesTableDestination"), - cell: ({ row }) => { - const resourceRow = row.original; - const destination = `${resourceRow.destinationIp}:${resourceRow.destinationPort}`; - return ; - } - }, - - { - id: "actions", - cell: ({ row }) => { - const resourceRow = row.original; - return ( -
- - - - - - { - setSelectedInternalResource( - resourceRow - ); - setIsDeleteModalOpen(true); - }} - > - - {t("delete")} - - - - - -
- ); - } - } - ]; - - const proxyTable = useReactTable({ - data: resources, - columns: proxyColumns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - onSortingChange: setProxySorting, - getSortedRowModel: getSortedRowModel(), - onColumnFiltersChange: setProxyColumnFilters, - getFilteredRowModel: getFilteredRowModel(), - onGlobalFilterChange: setProxyGlobalFilter, - onColumnVisibilityChange: setProxyColumnVisibility, - initialState: { - pagination: { - pageSize: proxyPageSize, - pageIndex: 0 - } - }, - state: { - sorting: proxySorting, - columnFilters: proxyColumnFilters, - globalFilter: proxyGlobalFilter, - columnVisibility: proxyColumnVisibility - } - }); - - const internalTable = useReactTable({ - data: internalResources, - columns: internalColumns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - onSortingChange: setInternalSorting, - getSortedRowModel: getSortedRowModel(), - onColumnFiltersChange: setInternalColumnFilters, - getFilteredRowModel: getFilteredRowModel(), - onGlobalFilterChange: setInternalGlobalFilter, - onColumnVisibilityChange: setInternalColumnVisibility, - initialState: { - pagination: { - pageSize: internalPageSize, - pageIndex: 0 - } - }, - state: { - sorting: internalSorting, - columnFilters: internalColumnFilters, - globalFilter: internalGlobalFilter, - columnVisibility: internalColumnVisibility - } - }); - - const handleProxyPageSizeChange = (newPageSize: number) => { - setProxyPageSize(newPageSize); - setStoredPageSize(newPageSize, "proxy-resources"); - }; - - const handleInternalPageSizeChange = (newPageSize: number) => { - setInternalPageSize(newPageSize); - setStoredPageSize(newPageSize, "internal-resources"); - }; - - return ( - <> - {selectedResource && ( - { - setIsDeleteModalOpen(val); - setSelectedResource(null); - }} - dialog={ -
-

{t("resourceQuestionRemove")}

-

{t("resourceMessageRemove")}

-
- } - buttonText={t("resourceDeleteConfirm")} - onConfirm={async () => deleteResource(selectedResource!.id)} - string={selectedResource.name} - title={t("resourceDelete")} - /> - )} - - {selectedInternalResource && ( - { - setIsDeleteModalOpen(val); - setSelectedInternalResource(null); - }} - dialog={ -
-

{t("resourceQuestionRemove")}

-

{t("resourceMessageRemove")}

-
- } - buttonText={t("resourceDeleteConfirm")} - onConfirm={async () => - deleteInternalResource( - selectedInternalResource!.id, - selectedInternalResource!.siteId - ) - } - string={selectedInternalResource.name} - title={t("resourceDelete")} - /> - )} - -
- - - -
- {getSearchInput()} - - {env.flags.enableClients && ( - - - {t("resourcesTableProxyResources")} - - - {t("resourcesTableClientResources")} - - - )} -
-
-
- -
-
{getActionButton()}
-
-
- - - - - {proxyTable - .getHeaderGroups() - .map((headerGroup) => ( - - {headerGroup.headers.map( - (header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} - - ) - )} - - ))} - - - {proxyTable.getRowModel().rows - ?.length ? ( - proxyTable - .getRowModel() - .rows.map((row) => ( - - {row - .getVisibleCells() - .map((cell) => ( - - {flexRender( - cell - .column - .columnDef - .cell, - cell.getContext() - )} - - ))} - - )) - ) : ( - - - {t( - "resourcesTableNoProxyResourcesFound" - )} - - - )} - -
-
- -
-
- -
- - - {t( - "resourcesTableTheseResourcesForUseWith" - )}{" "} - - {t("resourcesTableClients")} - - {" "} - {t( - "resourcesTableAndOnlyAccessibleInternally" - )} - - -
- - - {internalTable - .getHeaderGroups() - .map((headerGroup) => ( - - {headerGroup.headers.map( - (header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} - - ) - )} - - ))} - - - {internalTable.getRowModel().rows - ?.length ? ( - internalTable - .getRowModel() - .rows.map((row) => ( - - {row - .getVisibleCells() - .map((cell) => ( - - {flexRender( - cell - .column - .columnDef - .cell, - cell.getContext() - )} - - ))} - - )) - ) : ( - - - {t( - "resourcesTableNoInternalResourcesFound" - )} - - - )} - -
-
- -
-
-
-
-
-
- - {editingResource && ( - { - router.refresh(); - setEditingResource(null); - }} - /> - )} - - { - router.refresh(); - }} - /> - - ); -} diff --git a/src/components/RolesDataTable.tsx b/src/components/RolesDataTable.tsx index 8043fc23..c01f635d 100644 --- a/src/components/RolesDataTable.tsx +++ b/src/components/RolesDataTable.tsx @@ -36,6 +36,9 @@ export function RolesDataTable({ onRefresh={onRefresh} isRefreshing={isRefreshing} addButtonText={t('accessRolesAdd')} + enableColumnVisibility={true} + stickyLeftColumn="name" + stickyRightColumn="actions" /> ); } diff --git a/src/components/RolesTable.tsx b/src/components/RolesTable.tsx index 292384a8..43956ab2 100644 --- a/src/components/RolesTable.tsx +++ b/src/components/RolesTable.tsx @@ -1,6 +1,7 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, @@ -61,9 +62,11 @@ export default function UsersTable({ roles: r }: RolesTableProps) { } }; - const columns: ColumnDef[] = [ + const columns: ExtendedColumnDef[] = [ { accessorKey: "name", + enableHiding: false, + friendlyName: t("name"), header: ({ column }) => { return ( @@ -132,6 +135,7 @@ export default function ShareLinksTable({ }, { accessorKey: "title", + friendlyName: t("title"), header: ({ column }) => { return ( + + +
+ {item.items!.map((childItem) => + renderNavItem(childItem, level + 1) + )} +
+
+ + ); +} + export function SidebarNav({ className, sections, @@ -56,7 +166,8 @@ export function SidebarNav({ const { user } = useUserContext(); const t = useTranslations(); - function hydrateHref(val: string): string { + function hydrateHref(val?: string): string | undefined { + if (!val) return undefined; return val .replace("{orgId}", orgId) .replace("{niceId}", niceId) @@ -66,18 +177,56 @@ export function SidebarNav({ .replace("{clientId}", clientId); } + function isItemOrChildActive(item: SidebarNavItem): boolean { + const hydratedHref = hydrateHref(item.href); + if (hydratedHref && pathname.startsWith(hydratedHref)) { + return true; + } + if (item.items) { + return item.items.some((child) => isItemOrChildActive(child)); + } + return false; + } + const renderNavItem = ( item: SidebarNavItem, - hydratedHref: string, - isActive: boolean, - isDisabled: boolean - ) => { + level: number = 0 + ): React.ReactNode => { + const hydratedHref = hydrateHref(item.href); + const hasNestedItems = item.items && item.items.length > 0; + const isActive = hydratedHref + ? pathname.startsWith(hydratedHref) + : false; + const isChildActive = hasNestedItems + ? isItemOrChildActive(item) + : false; + const isEE = build === "enterprise" && item.showEE && !isUnlocked(); + const isDisabled = disabled || isEE; const tooltipText = item.showEE && !isUnlocked() ? `${t(item.title)} (${t("licenseBadge")})` : t(item.title); - const itemContent = ( + // If item has nested items, render as collapsible + if (hasNestedItems && !isCollapsed) { + return ( + + ); + } + + // Regular item without nested items + const itemContent = hydratedHref ? ( - {t(item.title)} - {item.isBeta && ( - - {t("beta")} - - )} +
+ {t(item.title)} + {item.isBeta && ( + + {t("beta")} + + )} +
{build === "enterprise" && item.showEE && !isUnlocked() && ( - + {t("licenseBadge")} )} )} + ) : ( +
+ {item.icon && ( + {item.icon} + )} +
+ {t(item.title)} + {item.isBeta && ( + + {t("beta")} + + )} +
+ {build === "enterprise" && item.showEE && !isUnlocked() && ( + {t("licenseBadge")} + )} +
); if (isCollapsed) { + // If item has nested items, show both tooltip and popover + if (hasNestedItems) { + return ( + + + + + + + + + +

{tooltipText}

+
+ +
+ {item.items!.map((childItem) => { + const childHydratedHref = + hydrateHref(childItem.href); + const childIsActive = + childHydratedHref + ? pathname.startsWith( + childHydratedHref + ) + : false; + const childIsEE = + build === "enterprise" && + childItem.showEE && + !isUnlocked(); + const childIsDisabled = + disabled || childIsEE; + + if (!childHydratedHref) { + return null; + } + + return ( + { + if (childIsDisabled) { + e.preventDefault(); + } else if ( + onItemClick + ) { + onItemClick(); + } + }} + > + {childItem.icon && ( + + {childItem.icon} + + )} +
+ + {t(childItem.title)} + + {childItem.isBeta && ( + + {t("beta")} + + )} +
+ {build === "enterprise" && + childItem.showEE && + !isUnlocked() && ( + + {t( + "licenseBadge" + )} + + )} + + ); + })} +
+
+
+
+
+ ); + } + + // Regular item without nested items - show tooltip return ( - + {itemContent} @@ -144,9 +439,7 @@ export function SidebarNav({ ); } - return ( - {itemContent} - ); + return {itemContent}; }; return ( @@ -161,26 +454,12 @@ export function SidebarNav({ {sections.map((section) => (
{!isCollapsed && ( -
- {t(section.heading)} +
+ {t(`${section.heading}`)}
)}
- {section.items.map((item) => { - const hydratedHref = hydrateHref(item.href); - const isActive = pathname.startsWith(hydratedHref); - const isEE = - build === "enterprise" && - item.showEE && - !isUnlocked(); - const isDisabled = disabled || isEE; - return renderNavItem( - item, - hydratedHref, - isActive, - isDisabled || false - ); - })} + {section.items.map((item) => renderNavItem(item, 0))}
))} diff --git a/src/components/SignupForm.tsx b/src/components/SignupForm.tsx index 76d2dfce..2eb1eeaf 100644 --- a/src/components/SignupForm.tsx +++ b/src/components/SignupForm.tsx @@ -202,7 +202,7 @@ export default function SignupForm({ : 58; return ( - +
diff --git a/src/components/SiteInfoCard.tsx b/src/components/SiteInfoCard.tsx index 6d35e145..26327349 100644 --- a/src/components/SiteInfoCard.tsx +++ b/src/components/SiteInfoCard.tsx @@ -35,7 +35,7 @@ export default function SiteInfoCard({ }: SiteInfoCardProps) { return ( - + {t("identifier")} @@ -75,7 +75,7 @@ export default function SiteInfoCard({ }: SiteInfoCardProps) { - {env.flags.enableClients && site.type == "newt" && ( + {site.type == "newt" && ( Address diff --git a/src/components/SitesDataTable.tsx b/src/components/SitesDataTable.tsx index 5dc39077..148c474f 100644 --- a/src/components/SitesDataTable.tsx +++ b/src/components/SitesDataTable.tsx @@ -10,6 +10,8 @@ interface DataTableProps { createSite?: () => void; onRefresh?: () => void; isRefreshing?: boolean; + columnVisibility?: Record; + enableColumnVisibility?: boolean; } export function SitesDataTable({ @@ -17,7 +19,9 @@ export function SitesDataTable({ data, createSite, onRefresh, - isRefreshing + isRefreshing, + columnVisibility, + enableColumnVisibility }: DataTableProps) { const t = useTranslations(); @@ -38,6 +42,10 @@ export function SitesDataTable({ id: "name", desc: false }} + columnVisibility={columnVisibility} + enableColumnVisibility={enableColumnVisibility} + stickyLeftColumn="name" + stickyRightColumn="actions" /> ); } diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 0d8b161c..5943a7f7 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -1,6 +1,7 @@ "use client"; import { Column, ColumnDef } from "@tanstack/react-table"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { SitesDataTable } from "@app/components/SitesDataTable"; import { DropdownMenu, @@ -106,9 +107,10 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { }); }; - const columns: ColumnDef[] = [ + const columns: ExtendedColumnDef[] = [ { accessorKey: "name", + enableHiding: false, header: ({ column }) => { return ( + ); + }, + cell: ({ row }) => { + return {row.original.nice || "-"}; + } + }, { accessorKey: "online", + friendlyName: t("online"), header: ({ column }) => { return ( - ); - }, - cell: ({ row }: { row: any }) => { - const originalRow = row.original; - return originalRow.address ? ( -
- {originalRow.address} -
- ) : ( - "-" - ); - } - } - ] - : []), + { + accessorKey: "address", + header: ({ column }: { column: Column }) => { + return ( + + ); + }, + cell: ({ row }: { row: any }) => { + const originalRow = row.original; + return originalRow.address ? ( +
+ {originalRow.address} +
+ ) : ( + "-" + ); + } + }, { id: "actions", + enableHiding: false, + header: () => , cell: ({ row }) => { const siteRow = row.original; return ( -
+
@@ -395,9 +415,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { }} dialog={
-

- {t("siteQuestionRemove")} -

+

{t("siteQuestionRemove")}

{t("siteMessageRemove")}

} @@ -416,6 +434,13 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { } onRefresh={refreshData} isRefreshing={isRefreshing} + columnVisibility={{ + niceId: false, + nice: false, + exitNode: false, + address: false + }} + enableColumnVisibility={true} /> ); diff --git a/src/components/react-query-provider.tsx b/src/components/TanstackQueryProvider.tsx similarity index 59% rename from src/components/react-query-provider.tsx rename to src/components/TanstackQueryProvider.tsx index 0f65ba62..9a6e7dd9 100644 --- a/src/components/react-query-provider.tsx +++ b/src/components/TanstackQueryProvider.tsx @@ -3,19 +3,29 @@ import * as React from "react"; import { QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { QueryClient } from "@tanstack/react-query"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient } from "@app/lib/api"; +import { durationToMs } from "@app/lib/durationToMs"; export type ReactQueryProviderProps = { children: React.ReactNode; }; -export function ReactQueryProvider({ children }: ReactQueryProviderProps) { +export function TanstackQueryProvider({ children }: ReactQueryProviderProps) { + const api = createApiClient(useEnvContext()); const [queryClient] = React.useState( () => new QueryClient({ defaultOptions: { queries: { retry: 2, // retry twice by default - staleTime: 5 * 60 * 1_000 // 5 minutes + staleTime: 0, + meta: { + api + } + }, + mutations: { + meta: { api } } } }) diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx new file mode 100644 index 00000000..cd203b51 --- /dev/null +++ b/src/components/UserDevicesTable.tsx @@ -0,0 +1,421 @@ +"use client"; + +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { DataTable } from "@app/components/ui/data-table"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; +import { Button } from "@app/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { + ArrowRight, + ArrowUpDown, + ArrowUpRight, + MoreHorizontal +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMemo, useState, useTransition } from "react"; +import { Badge } from "./ui/badge"; +import { InfoPopup } from "./ui/info-popup"; + +export type ClientRow = { + id: number; + name: string; + subnet: string; + // siteIds: string; + mbIn: string; + mbOut: string; + orgId: string; + online: boolean; + olmVersion?: string; + olmUpdateAvailable: boolean; + userId: string | null; + username: string | null; + userEmail: string | null; +}; + +type ClientTableProps = { + userClients: ClientRow[]; + orgId: string; +}; + +export default function UserDevicesTable({ userClients }: ClientTableProps) { + const router = useRouter(); + const t = useTranslations(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedClient, setSelectedClient] = useState( + null + ); + + const api = createApiClient(useEnvContext()); + const [isRefreshing, startTransition] = useTransition(); + + const defaultUserColumnVisibility = { + client: false, + subnet: false + }; + + const refreshData = () => { + startTransition(() => { + try { + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); + }; + + const deleteClient = (clientId: number) => { + api.delete(`/client/${clientId}`) + .catch((e) => { + console.error("Error deleting client", e); + toast({ + variant: "destructive", + title: "Error deleting client", + description: formatAxiosError(e, "Error deleting client") + }); + }) + .then(() => { + startTransition(() => { + router.refresh(); + setIsDeleteModalOpen(false); + }); + }); + }; + + // Check if there are any rows without userIds in the current view's data + const hasRowsWithoutUserId = useMemo(() => { + return userClients.some((client) => !client.userId); + }, [userClients]); + + const columns: ExtendedColumnDef[] = useMemo(() => { + const baseColumns: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + friendlyName: "Name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "userEmail", + friendlyName: "User", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const r = row.original; + return r.userId ? ( + + + + ) : ( + "-" + ); + } + }, + // { + // accessorKey: "siteName", + // header: ({ column }) => { + // return ( + // + // ); + // }, + // cell: ({ row }) => { + // const r = row.original; + // return ( + // + // + // + // ); + // } + // }, + { + accessorKey: "online", + friendlyName: "Connectivity", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const originalRow = row.original; + if (originalRow.online) { + return ( + +
+ Connected +
+ ); + } else { + return ( + +
+ Disconnected +
+ ); + } + } + }, + { + accessorKey: "mbIn", + friendlyName: "Data In", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "mbOut", + friendlyName: "Data Out", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "client", + friendlyName: t("client"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const originalRow = row.original; + + return ( +
+ +
+ Olm + {originalRow.olmVersion && ( + + v{originalRow.olmVersion} + + )} +
+
+ {originalRow.olmUpdateAvailable && ( + + )} +
+ ); + } + }, + { + accessorKey: "subnet", + friendlyName: "Address", + header: ({ column }) => { + return ( + + ); + } + } + ]; + + // Only include actions column if there are rows without userIds + if (hasRowsWithoutUserId) { + baseColumns.push({ + id: "actions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const clientRow = row.original; + return !clientRow.userId ? ( +
+ + + + + + {/* */} + {/* */} + {/* View settings */} + {/* */} + {/* */} + { + setSelectedClient(clientRow); + setIsDeleteModalOpen(true); + }} + > + + Delete + + + + + + + +
+ ) : null; + } + }); + } + + return baseColumns; + }, [hasRowsWithoutUserId, t]); + + return ( + <> + {selectedClient && ( + { + setIsDeleteModalOpen(val); + setSelectedClient(null); + }} + dialog={ +
+

{t("deleteClientQuestion")}

+

{t("clientMessageRemove")}

+
+ } + buttonText="Confirm Delete Client" + onConfirm={async () => deleteClient(selectedClient!.id)} + string={selectedClient.name} + title="Delete Client" + /> + )} + + + + ); +} diff --git a/src/components/UsersDataTable.tsx b/src/components/UsersDataTable.tsx index db12b697..7324b8b4 100644 --- a/src/components/UsersDataTable.tsx +++ b/src/components/UsersDataTable.tsx @@ -36,6 +36,9 @@ export function UsersDataTable({ onRefresh={onRefresh} isRefreshing={isRefreshing} addButtonText={t('accessUserCreate')} + enableColumnVisibility={true} + stickyLeftColumn="displayUsername" + stickyRightColumn="actions" /> ); } diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx index b6827dff..55ee9505 100644 --- a/src/components/UsersTable.tsx +++ b/src/components/UsersTable.tsx @@ -1,6 +1,7 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, @@ -70,9 +71,11 @@ export default function UsersTable({ users: u }: UsersTableProps) { } }; - const columns: ColumnDef[] = [ + const columns: ExtendedColumnDef[] = [ { accessorKey: "displayUsername", + enableHiding: false, + friendlyName: t("username"), header: ({ column }) => { return (
); @@ -143,82 +145,65 @@ export default function UsersTable({ users: u }: UsersTableProps) { }, { id: "actions", + enableHiding: false, + header: () => , cell: ({ row }) => { const userRow = row.original; return (
- <> -
- {userRow.isOwner && ( - - )} - {!userRow.isOwner && ( - <> - - - + + + + + {t("accessUsersManage")} + + + {`${userRow.username}-${userRow.idpId}` !== + `${user?.username}-${user?.idpId}` && ( + { + setIsDeleteModalOpen( + true + ); + setSelectedUser( + userRow + ); + }} > - - {t("openMenu")} + + {t("accessUserRemove")} - - - - - - - {t("accessUsersManage")} - - - {`${userRow.username}-${userRow.idpId}` !== - `${user?.username}-${user?.idpId}` && ( - { - setIsDeleteModalOpen( - true - ); - setSelectedUser( - userRow - ); - }} - > - - {t( - "accessUserRemove" - )} - - - )} - - - - )} -
- - {userRow.isOwner && ( - - )} + + )} + + + + )} +
{!userRow.isOwner && ( + + + + + + {selectedDevice && ( + { + setIsDeleteModalOpen(val); + if (!val) { + setSelectedDevice(null); + } + }} + dialog={ +
+

+ {t("deviceQuestionRemove") || + "Are you sure you want to delete this device?"} +

+

+ {t("deviceMessageRemove") || + "This action cannot be undone."} +

+
+ } + buttonText={t("deviceDeleteConfirm") || "Delete Device"} + onConfirm={async () => deleteDevice(selectedDevice.olmId)} + string={selectedDevice.name || selectedDevice.olmId} + title={t("deleteDevice") || "Delete Device"} + /> + )} + + ); +} diff --git a/src/components/private/OrgIdpTable.tsx b/src/components/private/OrgIdpTable.tsx index 059d175f..0a23ae90 100644 --- a/src/components/private/OrgIdpTable.tsx +++ b/src/components/private/OrgIdpTable.tsx @@ -1,6 +1,7 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { IdpDataTable } from "@app/components/private/OrgIdpDataTable"; import { Button } from "@app/components/ui/button"; import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; @@ -59,9 +60,10 @@ export default function IdpTable({ idps, orgId }: Props) { }; - const columns: ColumnDef[] = [ + const columns: ExtendedColumnDef[] = [ { accessorKey: "idpId", + friendlyName: "ID", header: ({ column }) => { return ( +
+ +
)} {onAdd && addButtonText && ( - +
+ +
)}
- - - {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() - )} - - ))} +
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const columnId = header.column.id; + const accessorKey = (header.column.columnDef as any).accessorKey as string | undefined; + const stickyClasses = getStickyClasses(columnId, accessorKey); + const isRightSticky = isStickyColumn(columnId, accessorKey, "right"); + const hasHideableColumns = enableColumnVisibility && + table.getAllColumns().some((col) => col.getCanHide()); + + return ( + + {header.isPlaceholder ? null : ( + isRightSticky && hasHideableColumns ? ( +
+ + + + + + + {t("toggleColumns") || "Toggle columns"} + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + const columnDef = column.columnDef as any; + const friendlyName = columnDef.friendlyName; + const displayName = friendlyName || + (typeof columnDef.header === "string" + ? columnDef.header + : column.id); + return ( + + column.toggleVisibility(!!value) + } + onSelect={(e) => e.preventDefault()} + > + {displayName} + + ); + })} + + +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+
+ ) : ( + flexRender( + header.column.columnDef.header, + header.getContext() + ) + ) + )} +
+ ); + })}
- )) - ) : ( - - - No results found. - - - )} - -
+ ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const columnId = cell.column.id; + const accessorKey = (cell.column.columnDef as any).accessorKey as string | undefined; + const stickyClasses = getStickyClasses(columnId, accessorKey); + const isRightSticky = isStickyColumn(columnId, accessorKey, "right"); + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ); + })} + + )) + ) : ( + + + No results found. + + + )} + + +
-
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index da9e6504..fa81756a 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -38,7 +38,7 @@ const DialogContent = React.forwardRef< ( type={showPassword ? "text" : "password"} data-slot="input" className={cn( - "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm shadow-2xs", - "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "focus-visible:border-ring focus-visible:ring-ring/50", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className )} @@ -43,8 +43,8 @@ const Input = React.forwardRef( type={type} data-slot="input" className={cn( - "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm shadow-2xs", - "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "focus-visible:border-ring focus-visible:ring-ring/50", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className )} diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx index df9f0dff..400cdbb4 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -29,7 +29,7 @@ function PopoverContent({ align={align} sideOffset={sideOffset} className={cn( - "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden", + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-sm outline-hidden", className )} {...props} diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx index 2c2df285..f36ccacb 100644 --- a/src/components/ui/scroll-area.tsx +++ b/src/components/ui/scroll-area.tsx @@ -18,7 +18,7 @@ function ScrollArea({ > {children} diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 03dd3d26..2e6575db 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -36,7 +36,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-2xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 w-full", + "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 w-full", className )} {...props} @@ -60,7 +60,7 @@ function SelectContent({ ( return (