diff --git a/README.md b/README.md index cf78470f..8e55cf12 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ _Pangolin tunnels your services to the internet so you can access anything from [![Docker](https://img.shields.io/docker/pulls/fosrl/pangolin?style=flat-square)](https://hub.docker.com/r/fosrl/pangolin) ![Stars](https://img.shields.io/github/stars/fosrl/pangolin?style=flat-square) [![Discord](https://img.shields.io/discord/1325658630518865980?logo=discord&style=flat-square)](https://discord.gg/HCJR8Xhme4) -[![Youtube](https://img.shields.io/badge/YouTube-red?logo=youtube&logoColor=white&style=flat-square)](https://www.youtube.com/@fossorial-app) +[![YouTube](https://img.shields.io/badge/YouTube-red?logo=youtube&logoColor=white&style=flat-square)](https://www.youtube.com/@fossorial-app) diff --git a/blueprint.py b/blueprint.py new file mode 100644 index 00000000..b4f1a99c --- /dev/null +++ b/blueprint.py @@ -0,0 +1,72 @@ +import requests +import yaml +import json +import base64 + +# The file path for the YAML file to be read +# You can change this to the path of your YAML file +YAML_FILE_PATH = 'blueprint.yaml' + +# The API endpoint and headers from the curl request +API_URL = 'http://localhost:3004/v1/org/test/blueprint' +HEADERS = { + 'accept': '*/*', + 'Authorization': 'Bearer v7ix7xha1bmq2on.tzsden374mtmkeczm3tx44uzxsljnrst7nmg7ccr', + 'Content-Type': 'application/json' +} + +def convert_and_send(file_path, url, headers): + """ + Reads a YAML file, converts its content to a JSON payload, + and sends it via a PUT request to a specified URL. + """ + try: + # Read the YAML file content + with open(file_path, 'r') as file: + yaml_content = file.read() + + # Parse the YAML string to a Python dictionary + # This will be used to ensure the YAML is valid before sending + parsed_yaml = yaml.safe_load(yaml_content) + + # convert the parsed YAML to a JSON string + json_payload = json.dumps(parsed_yaml) + print("Converted JSON payload:") + print(json_payload) + + # Encode the JSON string to Base64 + encoded_json = base64.b64encode(json_payload.encode('utf-8')).decode('utf-8') + + # Create the final payload with the base64 encoded data + final_payload = { + "blueprint": encoded_json + } + + print("Sending the following Base64 encoded JSON payload:") + print(final_payload) + print("-" * 20) + + # Make the PUT request with the base64 encoded payload + response = requests.put(url, headers=headers, json=final_payload) + + # Print the API response for debugging + print(f"API Response Status Code: {response.status_code}") + print("API Response Content:") + print(response.text) + + # Raise an exception for bad status codes (4xx or 5xx) + response.raise_for_status() + + except FileNotFoundError: + print(f"Error: The file '{file_path}' was not found.") + except yaml.YAMLError as e: + print(f"Error parsing YAML file: {e}") + except requests.exceptions.RequestException as e: + print(f"An error occurred during the API request: {e}") + except Exception as e: + print(f"An unexpected error occurred: {e}") + +# Run the function +if __name__ == "__main__": + convert_and_send(YAML_FILE_PATH, API_URL, HEADERS) + diff --git a/blueprint.yaml b/blueprint.yaml new file mode 100644 index 00000000..03c51521 --- /dev/null +++ b/blueprint.yaml @@ -0,0 +1,69 @@ +client-resources: + client-resource-nice-id-uno: + name: this is my resource + protocol: tcp + proxy-port: 3001 + hostname: localhost + internal-port: 3000 + site: lively-yosemite-toad + client-resource-nice-id-duce: + name: this is my resource + protocol: udp + proxy-port: 3000 + hostname: localhost + internal-port: 3000 + site: lively-yosemite-toad + +proxy-resources: + resource-nice-id-uno: + name: this is my resource + protocol: http + full-domain: duce.test.example.com + host-header: example.com + tls-server-name: example.com + # auth: + # pincode: 123456 + # password: sadfasdfadsf + # sso-enabled: true + # sso-roles: + # - Member + # sso-users: + # - owen@fossorial.io + # whitelist-users: + # - owen@fossorial.io + headers: + - name: X-Example-Header + value: example-value + - name: X-Another-Header + value: another-value + rules: + - action: allow + match: ip + value: 1.1.1.1 + - action: deny + match: cidr + value: 2.2.2.2/32 + - action: pass + match: path + value: /admin + targets: + - site: lively-yosemite-toad + path: /path + pathMatchType: prefix + hostname: localhost + method: http + port: 8000 + - site: slim-alpine-chipmunk + hostname: localhost + path: /yoman + pathMatchType: exact + method: http + port: 8001 + resource-nice-id-duce: + name: this is other resource + protocol: tcp + proxy-port: 3000 + targets: + - site: lively-yosemite-toad + hostname: localhost + port: 3000 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 09b150d7..469f9b4c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,6 @@ services: environment: - NODE_ENV=development - ENVIRONMENT=dev - - DB_TYPE=pg volumes: # Mount source code for hot reload - ./src:/app/src diff --git a/esbuild.mjs b/esbuild.mjs index d76c0753..8086a77e 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -52,7 +52,7 @@ esbuild bundle: true, outfile: argv.out, format: "esm", - minify: true, + minify: false, banner: { js: banner, }, @@ -63,7 +63,7 @@ esbuild packagePath: getPackagePaths(), }), ], - sourcemap: "external", + sourcemap: "inline", target: "node22", }) .then(() => { diff --git a/install/main.go b/install/main.go index 1d684b51..33606250 100644 --- a/install/main.go +++ b/install/main.go @@ -8,6 +8,8 @@ import ( "io" "io/fs" "math/rand" + "net" + "net/http" "os" "os/exec" "path/filepath" @@ -15,7 +17,6 @@ import ( "strings" "text/template" "time" - "net" ) // DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD @@ -328,7 +329,12 @@ func collectUserInput(reader *bufio.Reader) Config { config.HybridSecret = readString(reader, "Enter your secret", "") } - config.DashboardDomain = readString(reader, "The public addressable IP address for this node or a domain pointing to it", "") + // Try to get public IP as default + publicIP := getPublicIP() + if publicIP != "" { + fmt.Printf("Detected public IP: %s\n", publicIP) + } + config.DashboardDomain = readString(reader, "The public addressable IP address for this node or a domain pointing to it", publicIP) config.InstallGerbil = true } else { config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "") @@ -584,6 +590,32 @@ func generateRandomSecretKey() string { return string(b) } +func getPublicIP() string { + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Get("https://ifconfig.io/ip") + if err != nil { + return "" + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "" + } + + ip := strings.TrimSpace(string(body)) + + // Validate that it's a valid IP address + if net.ParseIP(ip) != nil { + return ip + } + + return "" +} + // Run external commands with stdio/stderr attached. func run(name string, args ...string) error { cmd := exec.Command(name, args...) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index d17c99f3..e22d4e48 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -454,6 +454,8 @@ "accessRoleErrorAddDescription": "An error occurred while adding user to the role.", "userSaved": "User saved", "userSavedDescription": "The user has been updated.", + "autoProvisioned": "Auto Provisioned", + "autoProvisionedDescription": "Allow this user to be automatically managed by identity provider", "accessControlsDescription": "Manage what this user can access and do in the organization", "accessControlsSubmit": "Save Access Controls", "roles": "Roles", @@ -911,6 +913,8 @@ "idpConnectingToFinished": "Connected", "idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.", "idpErrorNotFound": "IdP not found", + "idpGoogleAlt": "Google", + "idpAzureAlt": "Azure", "inviteInvalid": "Invalid Invite", "inviteInvalidDescription": "The invite link is invalid.", "inviteErrorWrongUser": "Invite is not for this user", @@ -982,6 +986,8 @@ "licenseTierProfessionalRequired": "Professional Edition Required", "licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.", "actionGetOrg": "Get Organization", + "updateOrgUser": "Update Org User", + "createOrgUser": "Create Org User", "actionUpdateOrg": "Update Organization", "actionUpdateUser": "Update User", "actionGetUser": "Get User", @@ -1234,7 +1240,7 @@ "newtUpdateAvailable": "Update Available", "newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", "domainPickerEnterDomain": "Domain", - "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp", + "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Enter the full domain of the resource to see available options.", "domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options", "domainPickerTabAll": "All", @@ -1496,5 +1502,22 @@ "convertButton": "Convert This Node to Managed Self-Hosted" }, "internationaldomaindetected": "International Domain Detected", - "willbestoredas": "Will be stored as:" -} + "willbestoredas": "Will be stored as:", + "idpGoogleDescription": "Google OAuth2/OIDC provider", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", + "domainPickerProvidedDomain": "Provided Domain", + "domainPickerFreeProvidedDomain": "Free Provided Domain", + "domainPickerVerified": "Verified", + "domainPickerUnverified": "Unverified", + "domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.", + "domainPickerError": "Error", + "domainPickerErrorLoadDomains": "Failed to load organization domains", + "domainPickerErrorCheckAvailability": "Failed to check domain availability", + "domainPickerInvalidSubdomain": "Invalid subdomain", + "domainPickerInvalidSubdomainRemoved": "The input \"{sub}\" was removed because it's not valid.", + "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" could not be made valid for {domain}.", + "domainPickerSubdomainSanitized": "Subdomain sanitized", + "domainPickerSubdomainCorrected": "\"{sub}\" was corrected to \"{sanitized}\"", + "resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml", + "resourceExposePortsEditFile": "Edit file: docker-compose.yml" +} \ No newline at end of file diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 727d9a5e..92606f5e 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -454,6 +454,8 @@ "accessRoleErrorAddDescription": "An error occurred while adding user to the role.", "userSaved": "User saved", "userSavedDescription": "The user has been updated.", + "autoProvisioned": "Auto Provisioned", + "autoProvisionedDescription": "Allow this user to be automatically managed by identity provider", "accessControlsDescription": "Manage what this user can access and do in the organization", "accessControlsSubmit": "Save Access Controls", "roles": "Roles", @@ -911,6 +913,8 @@ "idpConnectingToFinished": "Connected", "idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.", "idpErrorNotFound": "IdP not found", + "idpGoogleAlt": "Google", + "idpAzureAlt": "Azure", "inviteInvalid": "Invalid Invite", "inviteInvalidDescription": "The invite link is invalid.", "inviteErrorWrongUser": "Invite is not for this user", @@ -982,6 +986,8 @@ "licenseTierProfessionalRequired": "Professional Edition Required", "licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.", "actionGetOrg": "Get Organization", + "updateOrgUser": "Update Org User", + "createOrgUser": "Create Org User", "actionUpdateOrg": "Update Organization", "actionUpdateUser": "Update User", "actionGetUser": "Get User", @@ -1234,7 +1240,7 @@ "newtUpdateAvailable": "Update Available", "newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", "domainPickerEnterDomain": "Domain", - "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp", + "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Enter the full domain of the resource to see available options.", "domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options", "domainPickerTabAll": "All", @@ -1496,5 +1502,22 @@ "convertButton": "Convert This Node to Managed Self-Hosted" }, "internationaldomaindetected": "International Domain Detected", - "willbestoredas": "Will be stored as:" -} + "willbestoredas": "Will be stored as:", + "idpGoogleDescription": "Google OAuth2/OIDC provider", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", + "domainPickerProvidedDomain": "Provided Domain", + "domainPickerFreeProvidedDomain": "Free Provided Domain", + "domainPickerVerified": "Verified", + "domainPickerUnverified": "Unverified", + "domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.", + "domainPickerError": "Error", + "domainPickerErrorLoadDomains": "Failed to load organization domains", + "domainPickerErrorCheckAvailability": "Failed to check domain availability", + "domainPickerInvalidSubdomain": "Invalid subdomain", + "domainPickerInvalidSubdomainRemoved": "The input \"{sub}\" was removed because it's not valid.", + "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" could not be made valid for {domain}.", + "domainPickerSubdomainSanitized": "Subdomain sanitized", + "domainPickerSubdomainCorrected": "\"{sub}\" was corrected to \"{sanitized}\"", + "resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml", + "resourceExposePortsEditFile": "Edit file: docker-compose.yml" +} \ No newline at end of file diff --git a/messages/de-DE.json b/messages/de-DE.json index 9d00f23c..a09bfbd1 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -454,6 +454,8 @@ "accessRoleErrorAddDescription": "Beim Hinzufügen des Benutzers zur Rolle ist ein Fehler aufgetreten.", "userSaved": "Benutzer gespeichert", "userSavedDescription": "Der Benutzer wurde aktualisiert.", + "autoProvisioned": "Automatisch vorgesehen", + "autoProvisionedDescription": "Erlaube diesem Benutzer die automatische Verwaltung durch Identitätsanbieter", "accessControlsDescription": "Verwalten Sie, worauf dieser Benutzer in der Organisation zugreifen und was er tun kann", "accessControlsSubmit": "Zugriffskontrollen speichern", "roles": "Rollen", @@ -911,6 +913,8 @@ "idpConnectingToFinished": "Verbunden", "idpErrorConnectingTo": "Es gab ein Problem bei der Verbindung zu {name}. Bitte kontaktieren Sie Ihren Administrator.", "idpErrorNotFound": "IdP nicht gefunden", + "idpGoogleAlt": "Google", + "idpAzureAlt": "Azure", "inviteInvalid": "Ungültige Einladung", "inviteInvalidDescription": "Der Einladungslink ist ungültig.", "inviteErrorWrongUser": "Einladung ist nicht für diesen Benutzer", @@ -982,6 +986,8 @@ "licenseTierProfessionalRequired": "Professional Edition erforderlich", "licenseTierProfessionalRequiredDescription": "Diese Funktion ist nur in der Professional Edition verfügbar.", "actionGetOrg": "Organisation abrufen", + "updateOrgUser": "Org Benutzer aktualisieren", + "createOrgUser": "Org Benutzer erstellen", "actionUpdateOrg": "Organisation aktualisieren", "actionUpdateUser": "Benutzer aktualisieren", "actionGetUser": "Benutzer abrufen", @@ -1234,7 +1240,7 @@ "newtUpdateAvailable": "Update verfügbar", "newtUpdateAvailableInfo": "Eine neue Version von Newt ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.", "domainPickerEnterDomain": "Domain", - "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, oder einfach myapp", + "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Geben Sie die vollständige Domäne der Ressource ein, um verfügbare Optionen zu sehen.", "domainPickerDescriptionSaas": "Geben Sie eine vollständige Domäne, Subdomäne oder einfach einen Namen ein, um verfügbare Optionen zu sehen", "domainPickerTabAll": "Alle", @@ -1496,5 +1502,22 @@ "convertButton": "Diesen Knoten in Managed Self-Hosted umwandeln" }, "internationaldomaindetected": "Internationale Domain erkannt", - "willbestoredas": "Wird gespeichert als:" -} + "willbestoredas": "Wird gespeichert als:", + "idpGoogleDescription": "Google OAuth2/OIDC Provider", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", + "domainPickerProvidedDomain": "Angegebene Domain", + "domainPickerFreeProvidedDomain": "Kostenlose Domain", + "domainPickerVerified": "Verifiziert", + "domainPickerUnverified": "Nicht verifiziert", + "domainPickerInvalidSubdomainStructure": "Diese Subdomain enthält ungültige Zeichen oder Struktur. Sie wird beim Speichern automatisch bereinigt.", + "domainPickerError": "Fehler", + "domainPickerErrorLoadDomains": "Fehler beim Laden der Organisations-Domänen", + "domainPickerErrorCheckAvailability": "Fehler beim Prüfen der Domain-Verfügbarkeit", + "domainPickerInvalidSubdomain": "Ungültige Subdomain", + "domainPickerInvalidSubdomainRemoved": "Die Eingabe \"{sub}\" wurde entfernt, weil sie nicht gültig ist.", + "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" konnte nicht für {domain} gültig gemacht werden.", + "domainPickerSubdomainSanitized": "Subdomain bereinigt", + "domainPickerSubdomainCorrected": "\"{sub}\" wurde korrigiert zu \"{sanitized}\"", + "resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml", + "resourceExposePortsEditFile": "Edit file: docker-compose.yml" +} \ No newline at end of file diff --git a/messages/en-US.json b/messages/en-US.json index dbfa817e..95ac83b4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -454,6 +454,8 @@ "accessRoleErrorAddDescription": "An error occurred while adding user to the role.", "userSaved": "User saved", "userSavedDescription": "The user has been updated.", + "autoProvisioned": "Auto Provisioned", + "autoProvisionedDescription": "Allow this user to be automatically managed by identity provider", "accessControlsDescription": "Manage what this user can access and do in the organization", "accessControlsSubmit": "Save Access Controls", "roles": "Roles", @@ -511,6 +513,7 @@ "ipAddressErrorInvalidFormat": "Invalid IP address format", "ipAddressErrorInvalidOctet": "Invalid IP address octet", "path": "Path", + "matchPath": "Match Path", "ipAddressRange": "IP Range", "rulesErrorFetch": "Failed to fetch rules", "rulesErrorFetchDescription": "An error occurred while fetching rules", @@ -911,6 +914,8 @@ "idpConnectingToFinished": "Connected", "idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.", "idpErrorNotFound": "IdP not found", + "idpGoogleAlt": "Google", + "idpAzureAlt": "Azure", "inviteInvalid": "Invalid Invite", "inviteInvalidDescription": "The invite link is invalid.", "inviteErrorWrongUser": "Invite is not for this user", @@ -982,6 +987,8 @@ "licenseTierProfessionalRequired": "Professional Edition Required", "licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.", "actionGetOrg": "Get Organization", + "updateOrgUser": "Update Org User", + "createOrgUser": "Create Org User", "actionUpdateOrg": "Update Organization", "actionUpdateUser": "Update User", "actionGetUser": "Get User", @@ -991,6 +998,7 @@ "actionDeleteSite": "Delete Site", "actionGetSite": "Get Site", "actionListSites": "List Sites", + "actionApplyBlueprint": "Apply Blueprint", "setupToken": "Setup Token", "setupTokenDescription": "Enter the setup token from the server console.", "setupTokenRequired": "Setup token is required", @@ -1133,8 +1141,8 @@ "sidebarLicense": "License", "sidebarClients": "Clients (Beta)", "sidebarDomains": "Domains", - "enableDockerSocket": "Enable Docker Socket", - "enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.", + "enableDockerSocket": "Enable Docker Blueprint", + "enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt.", "enableDockerSocketLink": "Learn More", "viewDockerContainers": "View Docker Containers", "containersIn": "Containers in {siteName}", @@ -1234,7 +1242,7 @@ "newtUpdateAvailable": "Update Available", "newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", "domainPickerEnterDomain": "Domain", - "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp", + "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Enter the full domain of the resource to see available options.", "domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options", "domainPickerTabAll": "All", @@ -1392,8 +1400,6 @@ "editInternalResourceDialogProtocol": "Protocol", "editInternalResourceDialogSitePort": "Site Port", "editInternalResourceDialogTargetConfiguration": "Target Configuration", - "editInternalResourceDialogDestinationIP": "Destination IP", - "editInternalResourceDialogDestinationPort": "Destination Port", "editInternalResourceDialogCancel": "Cancel", "editInternalResourceDialogSaveResource": "Save Resource", "editInternalResourceDialogSuccess": "Success", @@ -1424,9 +1430,7 @@ "createInternalResourceDialogSitePort": "Site Port", "createInternalResourceDialogSitePortDescription": "Use this port to access the resource on the site when connected with a client.", "createInternalResourceDialogTargetConfiguration": "Target Configuration", - "createInternalResourceDialogDestinationIP": "Destination IP", - "createInternalResourceDialogDestinationIPDescription": "The IP address of the resource on the site's network.", - "createInternalResourceDialogDestinationPort": "Destination Port", + "createInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.", "createInternalResourceDialogDestinationPortDescription": "The port on the destination IP where the resource is accessible.", "createInternalResourceDialogCancel": "Cancel", "createInternalResourceDialogCreateResource": "Create Resource", @@ -1496,5 +1500,25 @@ "convertButton": "Convert This Node to Managed Self-Hosted" }, "internationaldomaindetected": "International Domain Detected", - "willbestoredas": "Will be stored as:" -} + "willbestoredas": "Will be stored as:", + "idpGoogleDescription": "Google OAuth2/OIDC provider", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", + "customHeaders": "Custom Headers", + "customHeadersDescription": "Headers new line separated: Header-Name: value.", + "headersValidationError": "Headers must be in the format: Header-Name: value.", + "domainPickerProvidedDomain": "Provided Domain", + "domainPickerFreeProvidedDomain": "Free Provided Domain", + "domainPickerVerified": "Verified", + "domainPickerUnverified": "Unverified", + "domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.", + "domainPickerError": "Error", + "domainPickerErrorLoadDomains": "Failed to load organization domains", + "domainPickerErrorCheckAvailability": "Failed to check domain availability", + "domainPickerInvalidSubdomain": "Invalid subdomain", + "domainPickerInvalidSubdomainRemoved": "The input \"{sub}\" was removed because it's not valid.", + "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" could not be made valid for {domain}.", + "domainPickerSubdomainSanitized": "Subdomain sanitized", + "domainPickerSubdomainCorrected": "\"{sub}\" was corrected to \"{sanitized}\"", + "resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml", + "resourceExposePortsEditFile": "Edit file: docker-compose.yml" +} \ No newline at end of file diff --git a/messages/es-ES.json b/messages/es-ES.json index fe8c52d1..156ae5f3 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -454,6 +454,8 @@ "accessRoleErrorAddDescription": "Ocurrió un error mientras se añadía el usuario al rol.", "userSaved": "Usuario guardado", "userSavedDescription": "El usuario ha sido actualizado.", + "autoProvisioned": "Auto asegurado", + "autoProvisionedDescription": "Permitir a este usuario ser administrado automáticamente por el proveedor de identidad", "accessControlsDescription": "Administrar lo que este usuario puede acceder y hacer en la organización", "accessControlsSubmit": "Guardar controles de acceso", "roles": "Roles", @@ -911,6 +913,8 @@ "idpConnectingToFinished": "Conectado", "idpErrorConnectingTo": "Hubo un problema al conectar con {name}. Por favor, póngase en contacto con su administrador.", "idpErrorNotFound": "IdP no encontrado", + "idpGoogleAlt": "Google", + "idpAzureAlt": "Azure", "inviteInvalid": "Invitación inválida", "inviteInvalidDescription": "El enlace de invitación no es válido.", "inviteErrorWrongUser": "La invitación no es para este usuario", @@ -982,6 +986,8 @@ "licenseTierProfessionalRequired": "Edición Profesional requerida", "licenseTierProfessionalRequiredDescription": "Esta característica sólo está disponible en la Edición Profesional.", "actionGetOrg": "Obtener organización", + "updateOrgUser": "Actualizar usuario Org", + "createOrgUser": "Crear usuario Org", "actionUpdateOrg": "Actualizar organización", "actionUpdateUser": "Actualizar usuario", "actionGetUser": "Obtener usuario", @@ -1234,7 +1240,7 @@ "newtUpdateAvailable": "Nueva actualización disponible", "newtUpdateAvailableInfo": "Hay una nueva versión de Newt disponible. Actualice a la última versión para la mejor experiencia.", "domainPickerEnterDomain": "Dominio", - "domainPickerPlaceholder": "myapp.example.com, api.v1.miDominio.com, o solo myapp", + "domainPickerPlaceholder": "miapp.ejemplo.com", "domainPickerDescription": "Ingresa el dominio completo del recurso para ver las opciones disponibles.", "domainPickerDescriptionSaas": "Ingresa un dominio completo, subdominio o simplemente un nombre para ver las opciones disponibles", "domainPickerTabAll": "Todo", @@ -1496,5 +1502,22 @@ "convertButton": "Convierte este nodo a autoalojado administrado" }, "internationaldomaindetected": "Dominio Internacional detectado", - "willbestoredas": "Se almacenará como:" -} + "willbestoredas": "Se almacenará como:", + "idpGoogleDescription": "Proveedor OAuth2/OIDC de Google", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", + "domainPickerProvidedDomain": "Dominio proporcionado", + "domainPickerFreeProvidedDomain": "Dominio proporcionado gratis", + "domainPickerVerified": "Verificado", + "domainPickerUnverified": "Sin verificar", + "domainPickerInvalidSubdomainStructure": "Este subdominio contiene caracteres o estructura no válidos. Se limpiará automáticamente al guardar.", + "domainPickerError": "Error", + "domainPickerErrorLoadDomains": "Error al cargar los dominios de la organización", + "domainPickerErrorCheckAvailability": "No se pudo comprobar la disponibilidad del dominio", + "domainPickerInvalidSubdomain": "Subdominio inválido", + "domainPickerInvalidSubdomainRemoved": "La entrada \"{sub}\" fue eliminada porque no es válida.", + "domainPickerInvalidSubdomainCannotMakeValid": "No se ha podido hacer válido \"{sub}\" para {domain}.", + "domainPickerSubdomainSanitized": "Subdominio saneado", + "domainPickerSubdomainCorrected": "\"{sub}\" fue corregido a \"{sanitized}\"", + "resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml", + "resourceExposePortsEditFile": "Edit file: docker-compose.yml" +} \ No newline at end of file diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 7f51a9c8..8b0529c6 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -454,6 +454,8 @@ "accessRoleErrorAddDescription": "Une erreur s'est produite lors de l'ajout de l'utilisateur au rôle.", "userSaved": "Utilisateur enregistré", "userSavedDescription": "L'utilisateur a été mis à jour.", + "autoProvisioned": "Auto-provisionné", + "autoProvisionedDescription": "Permettre à cet utilisateur d'être géré automatiquement par le fournisseur d'identité", "accessControlsDescription": "Gérer ce que cet utilisateur peut accéder et faire dans l'organisation", "accessControlsSubmit": "Enregistrer les contrôles d'accès", "roles": "Rôles", @@ -911,6 +913,8 @@ "idpConnectingToFinished": "Connecté", "idpErrorConnectingTo": "Un problème est survenu lors de la connexion à {name}. Veuillez contacter votre administrateur.", "idpErrorNotFound": "IdP introuvable", + "idpGoogleAlt": "Google", + "idpAzureAlt": "Azure", "inviteInvalid": "Invitation invalide", "inviteInvalidDescription": "Le lien d'invitation n'est pas valide.", "inviteErrorWrongUser": "L'invitation n'est pas pour cet utilisateur", @@ -982,6 +986,8 @@ "licenseTierProfessionalRequired": "Édition Professionnelle Requise", "licenseTierProfessionalRequiredDescription": "Cette fonctionnalité n'est disponible que dans l'Édition Professionnelle.", "actionGetOrg": "Obtenir l'organisation", + "updateOrgUser": "Mise à jour de l'utilisateur Org", + "createOrgUser": "Créer un utilisateur Org", "actionUpdateOrg": "Mettre à jour l'organisation", "actionUpdateUser": "Mettre à jour l'utilisateur", "actionGetUser": "Obtenir l'utilisateur", @@ -1234,7 +1240,7 @@ "newtUpdateAvailable": "Mise à jour disponible", "newtUpdateAvailableInfo": "Une nouvelle version de Newt est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.", "domainPickerEnterDomain": "Domaine", - "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, ou simplement myapp", + "domainPickerPlaceholder": "monapp.exemple.com", "domainPickerDescription": "Entrez le domaine complet de la ressource pour voir les options disponibles.", "domainPickerDescriptionSaas": "Entrez un domaine complet, un sous-domaine ou juste un nom pour voir les options disponibles", "domainPickerTabAll": "Tous", @@ -1496,5 +1502,22 @@ "convertButton": "Convertir ce noeud en auto-hébergé géré" }, "internationaldomaindetected": "Domaine international détecté", - "willbestoredas": "Sera stocké comme :" -} + "willbestoredas": "Sera stocké comme :", + "idpGoogleDescription": "Fournisseur Google OAuth2/OIDC", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", + "domainPickerProvidedDomain": "Domaine fourni", + "domainPickerFreeProvidedDomain": "Domaine fourni gratuitement", + "domainPickerVerified": "Vérifié", + "domainPickerUnverified": "Non vérifié", + "domainPickerInvalidSubdomainStructure": "Ce sous-domaine contient des caractères ou une structure non valide. Il sera automatiquement nettoyé lorsque vous enregistrez.", + "domainPickerError": "Erreur", + "domainPickerErrorLoadDomains": "Impossible de charger les domaines de l'organisation", + "domainPickerErrorCheckAvailability": "Impossible de vérifier la disponibilité du domaine", + "domainPickerInvalidSubdomain": "Sous-domaine invalide", + "domainPickerInvalidSubdomainRemoved": "L'entrée \"{sub}\" a été supprimée car elle n'est pas valide.", + "domainPickerInvalidSubdomainCannotMakeValid": "La «{sub}» n'a pas pu être validée pour {domain}.", + "domainPickerSubdomainSanitized": "Sous-domaine nettoyé", + "domainPickerSubdomainCorrected": "\"{sub}\" a été corrigé à \"{sanitized}\"", + "resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml", + "resourceExposePortsEditFile": "Edit file: docker-compose.yml" +} \ No newline at end of file diff --git a/messages/it-IT.json b/messages/it-IT.json index 9b935609..a3ae83b1 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -454,6 +454,8 @@ "accessRoleErrorAddDescription": "Si è verificato un errore durante l'aggiunta dell'utente al ruolo.", "userSaved": "Utente salvato", "userSavedDescription": "L'utente è stato aggiornato.", + "autoProvisioned": "Auto Provisioned", + "autoProvisionedDescription": "Permetti a questo utente di essere gestito automaticamente dal provider di identità", "accessControlsDescription": "Gestisci cosa questo utente può accedere e fare nell'organizzazione", "accessControlsSubmit": "Salva Controlli di Accesso", "roles": "Ruoli", @@ -911,6 +913,8 @@ "idpConnectingToFinished": "Connesso", "idpErrorConnectingTo": "Si è verificato un problema durante la connessione a {name}. Contatta il tuo amministratore.", "idpErrorNotFound": "IdP non trovato", + "idpGoogleAlt": "Google", + "idpAzureAlt": "Azure", "inviteInvalid": "Invito Non Valido", "inviteInvalidDescription": "Il link di invito non è valido.", "inviteErrorWrongUser": "L'invito non è per questo utente", @@ -982,6 +986,8 @@ "licenseTierProfessionalRequired": "Edizione Professional Richiesta", "licenseTierProfessionalRequiredDescription": "Questa funzionalità è disponibile solo nell'Edizione Professional.", "actionGetOrg": "Ottieni Organizzazione", + "updateOrgUser": "Aggiorna Utente Org", + "createOrgUser": "Crea Utente Org", "actionUpdateOrg": "Aggiorna Organizzazione", "actionUpdateUser": "Aggiorna Utente", "actionGetUser": "Ottieni Utente", @@ -1234,7 +1240,7 @@ "newtUpdateAvailable": "Aggiornamento Disponibile", "newtUpdateAvailableInfo": "È disponibile una nuova versione di Newt. Si prega di aggiornare all'ultima versione per la migliore esperienza.", "domainPickerEnterDomain": "Dominio", - "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, o semplicemente myapp", + "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Inserisci il dominio completo della risorsa per vedere le opzioni disponibili.", "domainPickerDescriptionSaas": "Inserisci un dominio completo, un sottodominio o semplicemente un nome per vedere le opzioni disponibili", "domainPickerTabAll": "Tutti", @@ -1496,5 +1502,22 @@ "convertButton": "Converti questo nodo in auto-ospitato gestito" }, "internationaldomaindetected": "Dominio Internazionale Rilevato", - "willbestoredas": "Verrà conservato come:" -} + "willbestoredas": "Verrà conservato come:", + "idpGoogleDescription": "Google OAuth2/OIDC provider", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", + "domainPickerProvidedDomain": "Dominio Fornito", + "domainPickerFreeProvidedDomain": "Dominio Fornito Gratuito", + "domainPickerVerified": "Verificato", + "domainPickerUnverified": "Non Verificato", + "domainPickerInvalidSubdomainStructure": "Questo sottodominio contiene caratteri o struttura non validi. Sarà sanificato automaticamente quando si salva.", + "domainPickerError": "Errore", + "domainPickerErrorLoadDomains": "Impossibile caricare i domini dell'organizzazione", + "domainPickerErrorCheckAvailability": "Impossibile verificare la disponibilità del dominio", + "domainPickerInvalidSubdomain": "Sottodominio non valido", + "domainPickerInvalidSubdomainRemoved": "L'input \"{sub}\" è stato rimosso perché non è valido.", + "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" non può essere reso valido per {domain}.", + "domainPickerSubdomainSanitized": "Sottodominio igienizzato", + "domainPickerSubdomainCorrected": "\"{sub}\" è stato corretto in \"{sanitized}\"", + "resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml", + "resourceExposePortsEditFile": "Edit file: docker-compose.yml" +} \ No newline at end of file diff --git a/messages/ko-KR.json b/messages/ko-KR.json index 2b9e7b1c..eaf31661 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -454,6 +454,8 @@ "accessRoleErrorAddDescription": "사용자를 역할에 추가하는 동안 오류가 발생했습니다.", "userSaved": "사용자 저장됨", "userSavedDescription": "사용자가 업데이트되었습니다.", + "autoProvisioned": "자동 프로비저닝됨", + "autoProvisionedDescription": "이 사용자가 ID 공급자에 의해 자동으로 관리될 수 있도록 허용합니다", "accessControlsDescription": "이 사용자가 조직에서 접근하고 수행할 수 있는 작업을 관리하세요", "accessControlsSubmit": "접근 제어 저장", "roles": "역할", @@ -911,6 +913,8 @@ "idpConnectingToFinished": "연결됨", "idpErrorConnectingTo": "{name}에 연결하는 데 문제가 발생했습니다. 관리자에게 문의하십시오.", "idpErrorNotFound": "IdP를 찾을 수 없습니다.", + "idpGoogleAlt": "구글", + "idpAzureAlt": "애저", "inviteInvalid": "유효하지 않은 초대", "inviteInvalidDescription": "초대 링크가 유효하지 않습니다.", "inviteErrorWrongUser": "이 초대는 이 사용자에게 해당되지 않습니다", @@ -982,6 +986,8 @@ "licenseTierProfessionalRequired": "전문 에디션이 필요합니다.", "licenseTierProfessionalRequiredDescription": "이 기능은 Professional Edition에서만 사용할 수 있습니다.", "actionGetOrg": "조직 가져오기", + "updateOrgUser": "조직 사용자 업데이트", + "createOrgUser": "조직 사용자 생성", "actionUpdateOrg": "조직 업데이트", "actionUpdateUser": "사용자 업데이트", "actionGetUser": "사용자 조회", @@ -1234,7 +1240,7 @@ "newtUpdateAvailable": "업데이트 가능", "newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", "domainPickerEnterDomain": "도메인", - "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, 또는 그냥 myapp", + "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "리소스의 전체 도메인을 입력하여 사용 가능한 옵션을 확인하십시오.", "domainPickerDescriptionSaas": "전체 도메인, 서브도메인 또는 이름을 입력하여 사용 가능한 옵션을 확인하십시오.", "domainPickerTabAll": "모두", @@ -1496,5 +1502,22 @@ "convertButton": "이 노드를 관리 자체 호스팅으로 변환" }, "internationaldomaindetected": "국제 도메인 감지됨", - "willbestoredas": "다음으로 저장됩니다:" -} + "willbestoredas": "다음으로 저장됩니다:", + "idpGoogleDescription": "Google OAuth2/OIDC 공급자", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC 공급자", + "domainPickerProvidedDomain": "제공된 도메인", + "domainPickerFreeProvidedDomain": "무료 제공된 도메인", + "domainPickerVerified": "검증됨", + "domainPickerUnverified": "검증되지 않음", + "domainPickerInvalidSubdomainStructure": "이 하위 도메인은 잘못된 문자 또는 구조를 포함하고 있습니다. 저장 시 자동으로 정리됩니다.", + "domainPickerError": "오류", + "domainPickerErrorLoadDomains": "조직 도메인 로드 실패", + "domainPickerErrorCheckAvailability": "도메인 가용성 확인 실패", + "domainPickerInvalidSubdomain": "잘못된 하위 도메인", + "domainPickerInvalidSubdomainRemoved": "입력 \"{sub}\"이(가) 유효하지 않으므로 제거되었습니다.", + "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\"을(를) {domain}에 대해 유효하게 만들 수 없습니다.", + "domainPickerSubdomainSanitized": "하위 도메인 정리됨", + "domainPickerSubdomainCorrected": "\"{sub}\"이(가) \"{sanitized}\"로 수정되었습니다", + "resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml", + "resourceExposePortsEditFile": "Edit file: docker-compose.yml" +} \ No newline at end of file diff --git a/messages/nb-NO.json b/messages/nb-NO.json index 6d1ae86a..e391cda9 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -454,6 +454,8 @@ "accessRoleErrorAddDescription": "Det oppstod en feil under tilordning av brukeren til rollen.", "userSaved": "Bruker lagret", "userSavedDescription": "Brukeren har blitt oppdatert.", + "autoProvisioned": "Auto avlyst", + "autoProvisionedDescription": "Tillat denne brukeren å bli automatisk administrert av en identitetsleverandør", "accessControlsDescription": "Administrer hva denne brukeren kan få tilgang til og gjøre i organisasjonen", "accessControlsSubmit": "Lagre tilgangskontroller", "roles": "Roller", @@ -911,6 +913,8 @@ "idpConnectingToFinished": "Tilkoblet", "idpErrorConnectingTo": "Det oppstod et problem med å koble til {name}. Vennligst kontakt din administrator.", "idpErrorNotFound": "IdP ikke funnet", + "idpGoogleAlt": "Google", + "idpAzureAlt": "Azure", "inviteInvalid": "Ugyldig invitasjon", "inviteInvalidDescription": "Invitasjonslenken er ugyldig.", "inviteErrorWrongUser": "Invitasjonen er ikke for denne brukeren", @@ -982,6 +986,8 @@ "licenseTierProfessionalRequired": "Profesjonell utgave påkrevd", "licenseTierProfessionalRequiredDescription": "Denne funksjonen er kun tilgjengelig i den profesjonelle utgaven.", "actionGetOrg": "Hent organisasjon", + "updateOrgUser": "Oppdater org.bruker", + "createOrgUser": "Opprett Org bruker", "actionUpdateOrg": "Oppdater organisasjon", "actionUpdateUser": "Oppdater bruker", "actionGetUser": "Hent bruker", @@ -1234,7 +1240,7 @@ "newtUpdateAvailable": "Oppdatering tilgjengelig", "newtUpdateAvailableInfo": "En ny versjon av Newt er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.", "domainPickerEnterDomain": "Domene", - "domainPickerPlaceholder": "minapp.eksempel.com, api.v1.mittdomene.com, eller bare minapp", + "domainPickerPlaceholder": "minapp.eksempel.no", "domainPickerDescription": "Skriv inn hele domenet til ressursen for å se tilgjengelige alternativer.", "domainPickerDescriptionSaas": "Skriv inn et fullt domene, underdomene eller bare et navn for å se tilgjengelige alternativer", "domainPickerTabAll": "Alle", @@ -1496,5 +1502,22 @@ "convertButton": "Konverter denne noden til manuelt bruk" }, "internationaldomaindetected": "Internasjonalt domene oppdaget", - "willbestoredas": "Vil bli lagret som:" -} + "willbestoredas": "Vil bli lagret som:", + "idpGoogleDescription": "Google OAuth2/OIDC leverandør", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", + "domainPickerProvidedDomain": "Gitt domene", + "domainPickerFreeProvidedDomain": "Gratis oppgitt domene", + "domainPickerVerified": "Bekreftet", + "domainPickerUnverified": "Uverifisert", + "domainPickerInvalidSubdomainStructure": "Dette underdomenet inneholder ugyldige tegn eller struktur. Det vil automatisk bli utsatt når du lagrer.", + "domainPickerError": "Feil", + "domainPickerErrorLoadDomains": "Kan ikke laste organisasjonens domener", + "domainPickerErrorCheckAvailability": "Kunne ikke kontrollere domenetilgjengelighet", + "domainPickerInvalidSubdomain": "Ugyldig underdomene", + "domainPickerInvalidSubdomainRemoved": "Inndata \"{sub}\" ble fjernet fordi det ikke er gyldig.", + "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" kunne ikke gjøres gyldig for {domain}.", + "domainPickerSubdomainSanitized": "Underdomenet som ble sanivert", + "domainPickerSubdomainCorrected": "\"{sub}\" var korrigert til \"{sanitized}\"", + "resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml", + "resourceExposePortsEditFile": "Edit file: docker-compose.yml" +} \ No newline at end of file diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 6252d752..2a74c53c 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -454,6 +454,8 @@ "accessRoleErrorAddDescription": "Er is een fout opgetreden tijdens het toevoegen van de rol.", "userSaved": "Gebruiker opgeslagen", "userSavedDescription": "De gebruiker is bijgewerkt.", + "autoProvisioned": "Automatisch bevestigen", + "autoProvisionedDescription": "Toestaan dat deze gebruiker automatisch wordt beheerd door een identiteitsprovider", "accessControlsDescription": "Beheer wat deze gebruiker toegang heeft tot en doet in de organisatie", "accessControlsSubmit": "Bewaar Toegangsbesturing", "roles": "Rollen", @@ -911,6 +913,8 @@ "idpConnectingToFinished": "Verbonden", "idpErrorConnectingTo": "Er was een probleem bij het verbinden met {name}. Neem contact op met uw beheerder.", "idpErrorNotFound": "IdP niet gevonden", + "idpGoogleAlt": "Google", + "idpAzureAlt": "Azure", "inviteInvalid": "Ongeldige uitnodiging", "inviteInvalidDescription": "Uitnodigingslink is ongeldig.", "inviteErrorWrongUser": "Uitnodiging is niet voor deze gebruiker", @@ -982,6 +986,8 @@ "licenseTierProfessionalRequired": "Professionele editie vereist", "licenseTierProfessionalRequiredDescription": "Deze functie is alleen beschikbaar in de Professional Edition.", "actionGetOrg": "Krijg Organisatie", + "updateOrgUser": "Org gebruiker bijwerken", + "createOrgUser": "Org gebruiker aanmaken", "actionUpdateOrg": "Organisatie bijwerken", "actionUpdateUser": "Gebruiker bijwerken", "actionGetUser": "Gebruiker ophalen", @@ -1234,7 +1240,7 @@ "newtUpdateAvailable": "Update beschikbaar", "newtUpdateAvailableInfo": "Er is een nieuwe versie van Newt beschikbaar. Update naar de nieuwste versie voor de beste ervaring.", "domainPickerEnterDomain": "Domein", - "domainPickerPlaceholder": "mijnapp.voorbeeld.com, api.v1.mijndomein.com, of gewoon mijnapp", + "domainPickerPlaceholder": "mijnapp.voorbeeld.nl", "domainPickerDescription": "Voer de volledige domein van de bron in om beschikbare opties te zien.", "domainPickerDescriptionSaas": "Voer een volledig domein, subdomein of gewoon een naam in om beschikbare opties te zien", "domainPickerTabAll": "Alles", @@ -1496,5 +1502,22 @@ "convertButton": "Converteer deze node naar Beheerde Zelf-Hosted" }, "internationaldomaindetected": "Internationaal Domein Gedetecteerd", - "willbestoredas": "Zal worden opgeslagen als:" -} + "willbestoredas": "Zal worden opgeslagen als:", + "idpGoogleDescription": "Google OAuth2/OIDC provider", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", + "domainPickerProvidedDomain": "Opgegeven domein", + "domainPickerFreeProvidedDomain": "Gratis verstrekt domein", + "domainPickerVerified": "Geverifieerd", + "domainPickerUnverified": "Ongeverifieerd", + "domainPickerInvalidSubdomainStructure": "Dit subdomein bevat ongeldige tekens of structuur. Het zal automatisch worden gesaneerd wanneer u opslaat.", + "domainPickerError": "Foutmelding", + "domainPickerErrorLoadDomains": "Fout bij het laden van organisatiedomeinen", + "domainPickerErrorCheckAvailability": "Kan domein beschikbaarheid niet controleren", + "domainPickerInvalidSubdomain": "Ongeldig subdomein", + "domainPickerInvalidSubdomainRemoved": "De invoer \"{sub}\" is verwijderd omdat het niet geldig is.", + "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" kon niet geldig worden gemaakt voor {domain}.", + "domainPickerSubdomainSanitized": "Subdomein gesaniseerd", + "domainPickerSubdomainCorrected": "\"{sub}\" was gecorrigeerd op \"{sanitized}\"", + "resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml", + "resourceExposePortsEditFile": "Edit file: docker-compose.yml" +} \ No newline at end of file diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 1aee50f2..ca62d325 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -454,6 +454,8 @@ "accessRoleErrorAddDescription": "Wystąpił błąd podczas dodawania użytkownika do roli.", "userSaved": "Użytkownik zapisany", "userSavedDescription": "Użytkownik został zaktualizowany.", + "autoProvisioned": "Przesłane automatycznie", + "autoProvisionedDescription": "Pozwól temu użytkownikowi na automatyczne zarządzanie przez dostawcę tożsamości", "accessControlsDescription": "Zarządzaj tym, do czego użytkownik ma dostęp i co może robić w organizacji", "accessControlsSubmit": "Zapisz kontrole dostępu", "roles": "Role", @@ -911,6 +913,8 @@ "idpConnectingToFinished": "Połączono", "idpErrorConnectingTo": "Wystąpił problem z połączeniem z {name}. Skontaktuj się z administratorem.", "idpErrorNotFound": "Nie znaleziono IdP", + "idpGoogleAlt": "Google", + "idpAzureAlt": "Azure", "inviteInvalid": "Nieprawidłowe zaproszenie", "inviteInvalidDescription": "Link zapraszający jest nieprawidłowy.", "inviteErrorWrongUser": "Zaproszenie nie jest dla tego użytkownika", @@ -982,6 +986,8 @@ "licenseTierProfessionalRequired": "Wymagana edycja Professional", "licenseTierProfessionalRequiredDescription": "Ta funkcja jest dostępna tylko w edycji Professional.", "actionGetOrg": "Pobierz organizację", + "updateOrgUser": "Aktualizuj użytkownika Org", + "createOrgUser": "Utwórz użytkownika Org", "actionUpdateOrg": "Aktualizuj organizację", "actionUpdateUser": "Zaktualizuj użytkownika", "actionGetUser": "Pobierz użytkownika", @@ -1234,7 +1240,7 @@ "newtUpdateAvailable": "Dostępna aktualizacja", "newtUpdateAvailableInfo": "Nowa wersja Newt jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.", "domainPickerEnterDomain": "Domena", - "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com lub po prostu myapp", + "domainPickerPlaceholder": "mojapp.example.com", "domainPickerDescription": "Wpisz pełną domenę zasobu, aby zobaczyć dostępne opcje.", "domainPickerDescriptionSaas": "Wprowadź pełną domenę, subdomenę lub po prostu nazwę, aby zobaczyć dostępne opcje", "domainPickerTabAll": "Wszystko", @@ -1496,5 +1502,22 @@ "convertButton": "Konwertuj ten węzeł do zarządzanego samodzielnie" }, "internationaldomaindetected": "Wykryto międzynarodową domenę", - "willbestoredas": "Będą przechowywane jako:" -} + "willbestoredas": "Będą przechowywane jako:", + "idpGoogleDescription": "Dostawca Google OAuth2/OIDC", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", + "domainPickerProvidedDomain": "Dostarczona domena", + "domainPickerFreeProvidedDomain": "Darmowa oferowana domena", + "domainPickerVerified": "Zweryfikowano", + "domainPickerUnverified": "Niezweryfikowane", + "domainPickerInvalidSubdomainStructure": "Ta subdomena zawiera nieprawidłowe znaki lub strukturę. Zostanie ona automatycznie oczyszczona po zapisaniu.", + "domainPickerError": "Błąd", + "domainPickerErrorLoadDomains": "Nie udało się załadować domen organizacji", + "domainPickerErrorCheckAvailability": "Nie udało się sprawdzić dostępności domeny", + "domainPickerInvalidSubdomain": "Nieprawidłowa subdomena", + "domainPickerInvalidSubdomainRemoved": "Wejście \"{sub}\" zostało usunięte, ponieważ jest nieprawidłowe.", + "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" nie może być poprawne dla {domain}.", + "domainPickerSubdomainSanitized": "Poddomena oczyszczona", + "domainPickerSubdomainCorrected": "\"{sub}\" został skorygowany do \"{sanitized}\"", + "resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml", + "resourceExposePortsEditFile": "Edit file: docker-compose.yml" +} \ No newline at end of file diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 84afb6aa..4c15bb04 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -454,6 +454,8 @@ "accessRoleErrorAddDescription": "Ocorreu um erro ao adicionar usuário à função.", "userSaved": "Usuário salvo", "userSavedDescription": "O usuário foi atualizado.", + "autoProvisioned": "Auto provisionado", + "autoProvisionedDescription": "Permitir que este usuário seja gerenciado automaticamente pelo provedor de identidade", "accessControlsDescription": "Gerencie o que este usuário pode acessar e fazer na organização", "accessControlsSubmit": "Salvar Controles de Acesso", "roles": "Funções", @@ -911,6 +913,8 @@ "idpConnectingToFinished": "Conectado", "idpErrorConnectingTo": "Ocorreu um problema ao ligar a {name}. Por favor, contacte o seu administrador.", "idpErrorNotFound": "IdP não encontrado", + "idpGoogleAlt": "Google", + "idpAzureAlt": "Azure", "inviteInvalid": "Convite Inválido", "inviteInvalidDescription": "O link do convite é inválido.", "inviteErrorWrongUser": "O convite não é para este usuário", @@ -982,6 +986,8 @@ "licenseTierProfessionalRequired": "Edição Profissional Necessária", "licenseTierProfessionalRequiredDescription": "Esta funcionalidade só está disponível na Edição Profissional.", "actionGetOrg": "Obter Organização", + "updateOrgUser": "Atualizar usuário Org", + "createOrgUser": "Criar usuário Org", "actionUpdateOrg": "Atualizar Organização", "actionUpdateUser": "Atualizar Usuário", "actionGetUser": "Obter Usuário", @@ -1234,7 +1240,7 @@ "newtUpdateAvailable": "Nova Atualização Disponível", "newtUpdateAvailableInfo": "Uma nova versão do Newt está disponível. Atualize para a versão mais recente para uma melhor experiência.", "domainPickerEnterDomain": "Domínio", - "domainPickerPlaceholder": "meuapp.exemplo.com, api.v1.meudominio.com, ou apenas meuapp", + "domainPickerPlaceholder": "myapp.exemplo.com", "domainPickerDescription": "Insira o domínio completo do recurso para ver as opções disponíveis.", "domainPickerDescriptionSaas": "Insira um domínio completo, subdomínio ou apenas um nome para ver as opções disponíveis", "domainPickerTabAll": "Todos", @@ -1496,5 +1502,22 @@ "convertButton": "Converter este nó para Auto-Hospedado Gerenciado" }, "internationaldomaindetected": "Domínio Internacional Detectado", - "willbestoredas": "Será armazenado como:" -} + "willbestoredas": "Será armazenado como:", + "idpGoogleDescription": "Provedor Google OAuth2/OIDC", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", + "domainPickerProvidedDomain": "Domínio fornecido", + "domainPickerFreeProvidedDomain": "Domínio fornecido grátis", + "domainPickerVerified": "Verificada", + "domainPickerUnverified": "Não verificado", + "domainPickerInvalidSubdomainStructure": "Este subdomínio contém caracteres ou estrutura inválidos. Ele será eliminado automaticamente quando você salvar.", + "domainPickerError": "ERRO", + "domainPickerErrorLoadDomains": "Falha ao carregar domínios da organização", + "domainPickerErrorCheckAvailability": "Não foi possível verificar a disponibilidade do domínio", + "domainPickerInvalidSubdomain": "Subdomínio inválido", + "domainPickerInvalidSubdomainRemoved": "A entrada \"{sub}\" foi removida porque ela não é válida.", + "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" não pôde ser válido para {domain}.", + "domainPickerSubdomainSanitized": "Subdomínio banalizado", + "domainPickerSubdomainCorrected": "\"{sub}\" foi corrigido para \"{sanitized}\"", + "resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml", + "resourceExposePortsEditFile": "Edit file: docker-compose.yml" +} \ No newline at end of file diff --git a/messages/ru-RU.json b/messages/ru-RU.json index ffcbe8dc..1a7cddfe 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -454,6 +454,8 @@ "accessRoleErrorAddDescription": "Произошла ошибка при добавлении пользователя в роль.", "userSaved": "Пользователь сохранён", "userSavedDescription": "Пользователь был обновлён.", + "autoProvisioned": "Автоподбор", + "autoProvisionedDescription": "Разрешить автоматическое управление этим пользователем", "accessControlsDescription": "Управляйте тем, к чему этот пользователь может получить доступ и что делать в организации", "accessControlsSubmit": "Сохранить контроль доступа", "roles": "Роли", @@ -911,6 +913,8 @@ "idpConnectingToFinished": "Подключено", "idpErrorConnectingTo": "Возникла проблема при подключении к {name}. Пожалуйста, свяжитесь с вашим администратором.", "idpErrorNotFound": "IdP не найден", + "idpGoogleAlt": "Google", + "idpAzureAlt": "Azure", "inviteInvalid": "Недействительное приглашение", "inviteInvalidDescription": "Ссылка на приглашение недействительна.", "inviteErrorWrongUser": "Приглашение не для этого пользователя", @@ -982,6 +986,8 @@ "licenseTierProfessionalRequired": "Требуется профессиональная версия", "licenseTierProfessionalRequiredDescription": "Эта функция доступна только в профессиональной версии.", "actionGetOrg": "Получить организацию", + "updateOrgUser": "Обновить пользователя Org", + "createOrgUser": "Создать пользователя Org", "actionUpdateOrg": "Обновить организацию", "actionUpdateUser": "Обновить пользователя", "actionGetUser": "Получить пользователя", @@ -1234,7 +1240,7 @@ "newtUpdateAvailable": "Доступно обновление", "newtUpdateAvailableInfo": "Доступна новая версия Newt. Пожалуйста, обновитесь до последней версии для лучшего опыта.", "domainPickerEnterDomain": "Домен", - "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, или просто myapp", + "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Введите полный домен ресурса, чтобы увидеть доступные опции.", "domainPickerDescriptionSaas": "Введите полный домен, поддомен или просто имя, чтобы увидеть доступные опции", "domainPickerTabAll": "Все", @@ -1496,5 +1502,22 @@ "convertButton": "Конвертировать этот узел в управляемый себе-хост" }, "internationaldomaindetected": "Обнаружен международный домен", - "willbestoredas": "Будет храниться как:" -} + "willbestoredas": "Будет храниться как:", + "idpGoogleDescription": "Google OAuth2/OIDC провайдер", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", + "domainPickerProvidedDomain": "Домен предоставлен", + "domainPickerFreeProvidedDomain": "Бесплатный домен", + "domainPickerVerified": "Подтверждено", + "domainPickerUnverified": "Не подтверждено", + "domainPickerInvalidSubdomainStructure": "Этот поддомен содержит недопустимые символы или структуру. Он будет очищен автоматически при сохранении.", + "domainPickerError": "Ошибка", + "domainPickerErrorLoadDomains": "Не удалось загрузить домены организации", + "domainPickerErrorCheckAvailability": "Не удалось проверить доступность домена", + "domainPickerInvalidSubdomain": "Неверный поддомен", + "domainPickerInvalidSubdomainRemoved": "Ввод \"{sub}\" был удален, потому что он недействителен.", + "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" не может быть действительным для {domain}.", + "domainPickerSubdomainSanitized": "Субдомен очищен", + "domainPickerSubdomainCorrected": "\"{sub}\" был исправлен на \"{sanitized}\"", + "resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml", + "resourceExposePortsEditFile": "Edit file: docker-compose.yml" +} \ No newline at end of file diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 2253dab2..aed7ada0 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -454,6 +454,8 @@ "accessRoleErrorAddDescription": "Kullanıcı role eklenirken bir hata oluştu.", "userSaved": "Kullanıcı kaydedildi", "userSavedDescription": "Kullanıcı güncellenmiştir.", + "autoProvisioned": "Otomatik Sağlandı", + "autoProvisionedDescription": "Bu kullanıcının kimlik sağlayıcısı tarafından otomatik olarak yönetilmesine izin ver", "accessControlsDescription": "Bu kullanıcının organizasyonda neleri erişebileceğini ve yapabileceğini yönetin", "accessControlsSubmit": "Erişim Kontrollerini Kaydet", "roles": "Roller", @@ -911,6 +913,8 @@ "idpConnectingToFinished": "Bağlandı", "idpErrorConnectingTo": "{name} ile bağlantı kurarken bir sorun meydana geldi. Lütfen yöneticiye danışın.", "idpErrorNotFound": "IdP bulunamadı", + "idpGoogleAlt": "Google", + "idpAzureAlt": "Azure", "inviteInvalid": "Geçersiz Davet", "inviteInvalidDescription": "Davet bağlantısı geçersiz.", "inviteErrorWrongUser": "Davet bu kullanıcı için değil", @@ -982,6 +986,8 @@ "licenseTierProfessionalRequired": "Profesyonel Sürüme Gereklidir", "licenseTierProfessionalRequiredDescription": "Bu özellik yalnızca Professional Edition'da kullanılabilir.", "actionGetOrg": "Kuruluşu Al", + "updateOrgUser": "Organizasyon Kullanıcısını Güncelle", + "createOrgUser": "Organizasyon Kullanıcısı Oluştur", "actionUpdateOrg": "Kuruluşu Güncelle", "actionUpdateUser": "Kullanıcıyı Güncelle", "actionGetUser": "Kullanıcıyı Getir", @@ -1234,7 +1240,7 @@ "newtUpdateAvailable": "Güncelleme Mevcut", "newtUpdateAvailableInfo": "Newt'in yeni bir versiyonu mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.", "domainPickerEnterDomain": "Domain", - "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com veya sadece myapp", + "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Mevcut seçenekleri görmek için kaynağın tam etki alanını girin.", "domainPickerDescriptionSaas": "Mevcut seçenekleri görmek için tam etki alanı, alt etki alanı veya sadece bir isim girin", "domainPickerTabAll": "Tümü", @@ -1496,5 +1502,22 @@ "convertButton": "Bu Düğümü Yönetilen Kendi Kendine Barındırma Dönüştürün" }, "internationaldomaindetected": "Uluslararası Alan Adı Tespit Edildi", - "willbestoredas": "Şu şekilde depolanacak:" -} + "willbestoredas": "Şu şekilde depolanacak:", + "idpGoogleDescription": "Google OAuth2/OIDC sağlayıcısı", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC sağlayıcısı", + "domainPickerProvidedDomain": "Sağlanan Alan Adı", + "domainPickerFreeProvidedDomain": "Ücretsiz Sağlanan Alan Adı", + "domainPickerVerified": "Doğrulandı", + "domainPickerUnverified": "Doğrulanmadı", + "domainPickerInvalidSubdomainStructure": "Bu alt alan adı geçersiz karakterler veya yapı içeriyor. Kaydettiğinizde otomatik olarak temizlenecektir.", + "domainPickerError": "Hata", + "domainPickerErrorLoadDomains": "Organizasyon alan adları yüklenemedi", + "domainPickerErrorCheckAvailability": "Alan adı kullanılabilirliği kontrol edilemedi", + "domainPickerInvalidSubdomain": "Geçersiz alt alan adı", + "domainPickerInvalidSubdomainRemoved": "Girdi \"{sub}\" geçersiz olduğu için kaldırıldı.", + "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" {domain} için geçerli yapılamadı.", + "domainPickerSubdomainSanitized": "Alt alan adı temizlendi", + "domainPickerSubdomainCorrected": "\"{sub}\" \"{sanitized}\" olarak düzeltildi", + "resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml", + "resourceExposePortsEditFile": "Edit file: docker-compose.yml" +} \ No newline at end of file diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 1eaa2263..1aacfbe9 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -454,6 +454,8 @@ "accessRoleErrorAddDescription": "添加用户到角色时出错。", "userSaved": "用户已保存", "userSavedDescription": "用户已更新。", + "autoProvisioned": "自动设置", + "autoProvisionedDescription": "允许此用户由身份提供商自动管理", "accessControlsDescription": "管理此用户在组织中可以访问和做什么", "accessControlsSubmit": "保存访问控制", "roles": "角色", @@ -911,6 +913,8 @@ "idpConnectingToFinished": "已连接", "idpErrorConnectingTo": "无法连接到 {name},请联系管理员协助处理。", "idpErrorNotFound": "找不到 IdP", + "idpGoogleAlt": "Google", + "idpAzureAlt": "Azure", "inviteInvalid": "无效邀请", "inviteInvalidDescription": "邀请链接无效。", "inviteErrorWrongUser": "邀请不是该用户的", @@ -982,6 +986,8 @@ "licenseTierProfessionalRequired": "需要专业版", "licenseTierProfessionalRequiredDescription": "此功能仅在专业版可用。", "actionGetOrg": "获取组织", + "updateOrgUser": "更新组织用户", + "createOrgUser": "创建组织用户", "actionUpdateOrg": "更新组织", "actionUpdateUser": "更新用户", "actionGetUser": "获取用户", @@ -1234,7 +1240,7 @@ "newtUpdateAvailable": "更新可用", "newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。", "domainPickerEnterDomain": "域名", - "domainPickerPlaceholder": "myapp.example.com、api.v1.mydomain.com 或仅 myapp", + "domainPickerPlaceholder": "example.com", "domainPickerDescription": "输入资源的完整域名以查看可用选项。", "domainPickerDescriptionSaas": "输入完整域名、子域或名称以查看可用选项。", "domainPickerTabAll": "所有", @@ -1496,5 +1502,22 @@ "convertButton": "将此节点转换为管理自托管的" }, "internationaldomaindetected": "检测到国际域", - "willbestoredas": "储存为:" -} + "willbestoredas": "储存为:", + "idpGoogleDescription": "Google OAuth2/OIDC 提供商", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", + "domainPickerProvidedDomain": "提供的域", + "domainPickerFreeProvidedDomain": "免费提供的域", + "domainPickerVerified": "已验证", + "domainPickerUnverified": "未验证", + "domainPickerInvalidSubdomainStructure": "此子域包含无效的字符或结构。当您保存时,它将被自动清除。", + "domainPickerError": "错误", + "domainPickerErrorLoadDomains": "加载组织域名失败", + "domainPickerErrorCheckAvailability": "检查域可用性失败", + "domainPickerInvalidSubdomain": "无效的子域", + "domainPickerInvalidSubdomainRemoved": "输入 \"{sub}\" 已被移除,因为其无效。", + "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" 无法为 {domain} 变为有效。", + "domainPickerSubdomainSanitized": "子域已净化", + "domainPickerSubdomainCorrected": "\"{sub}\" 已被更正为 \"{sanitized}\"", + "resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml", + "resourceExposePortsEditFile": "Edit file: docker-compose.yml" +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e7e1c323..acda09ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,7 +84,6 @@ "react-icons": "^5.5.0", "rebuild": "0.1.2", "semver": "^7.7.2", - "source-map-support": "0.5.21", "swagger-ui-express": "^5.0.1", "tailwind-merge": "3.3.1", "tw-animate-css": "^1.3.8", @@ -7577,6 +7576,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, "license": "MIT" }, "node_modules/bytes": { @@ -16971,6 +16971,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -16989,6 +16990,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", diff --git a/package.json b/package.json index 2a4a0ffb..6aa75049 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "db:clear-migrations": "rm -rf server/migrations", "build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs", "build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs", - "start": "DB_TYPE=sqlite NODE_OPTIONS=--enable-source-maps NODE_ENV=development ENVIRONMENT=prod sh -c 'node dist/migrations.mjs && node dist/server.mjs'", + "start": "ENVIRONMENT=prod node dist/migrations.mjs && ENVIRONMENT=prod NODE_ENV=development node --enable-source-maps dist/server.mjs", "email": "email dev --dir server/emails/templates --port 3005", "build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs" }, @@ -101,7 +101,6 @@ "react-icons": "^5.5.0", "rebuild": "0.1.2", "semver": "^7.7.2", - "source-map-support": "0.5.21", "swagger-ui-express": "^5.0.1", "tailwind-merge": "3.3.1", "tw-animate-css": "^1.3.8", diff --git a/public/idp/azure.png b/public/idp/azure.png new file mode 100644 index 00000000..d6ec5baf Binary files /dev/null and b/public/idp/azure.png differ diff --git a/public/idp/google.png b/public/idp/google.png new file mode 100644 index 00000000..da097687 Binary files /dev/null and b/public/idp/google.png differ diff --git a/server/auth/actions.ts b/server/auth/actions.ts index a3ad60ab..6b6c9bf4 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -100,7 +100,9 @@ export enum ActionsEnum { getApiKey = "getApiKey", createOrgDomain = "createOrgDomain", deleteOrgDomain = "deleteOrgDomain", - restartOrgDomain = "restartOrgDomain" + restartOrgDomain = "restartOrgDomain", + updateOrgUser = "updateOrgUser", + applyBlueprint = "applyBlueprint" } export async function checkUserActionPermission( diff --git a/server/db/names.ts b/server/db/names.ts index 41f4c170..2da38f10 100644 --- a/server/db/names.ts +++ b/server/db/names.ts @@ -1,6 +1,6 @@ import { join } from "path"; import { readFileSync } from "fs"; -import { db } from "@server/db"; +import { db, resources, siteResources } from "@server/db"; import { exitNodes, sites } from "@server/db"; import { eq, and } from "drizzle-orm"; import { __DIRNAME } from "@server/lib/consts"; @@ -34,6 +34,44 @@ export async function getUniqueSiteName(orgId: string): Promise { } } +export async function getUniqueResourceName(orgId: string): Promise { + let loops = 0; + while (true) { + if (loops > 100) { + throw new Error("Could not generate a unique name"); + } + + 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) { + return name; + } + loops++; + } +} + +export async function getUniqueSiteResourceName(orgId: string): Promise { + let loops = 0; + while (true) { + if (loops > 100) { + throw new Error("Could not generate a unique name"); + } + + 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) { + return name; + } + loops++; + } +} + export async function getUniqueExitNodeEndpointName(): Promise { let loops = 0; const count = await db diff --git a/server/db/pg/driver.ts b/server/db/pg/driver.ts index 9625867d..c7c292f0 100644 --- a/server/db/pg/driver.ts +++ b/server/db/pg/driver.ts @@ -50,3 +50,4 @@ function createDb() { export const db = createDb(); export default db; +export type Transaction = Parameters[0]>[0]; \ No newline at end of file diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index 8e725ab1..3cb5486b 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -71,6 +71,7 @@ export const resources = pgTable("resources", { onDelete: "cascade" }) .notNull(), + niceId: text("niceId").notNull(), name: varchar("name").notNull(), subdomain: varchar("subdomain"), fullDomain: varchar("fullDomain"), @@ -95,6 +96,7 @@ export const resources = pgTable("resources", { skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { onDelete: "cascade" }), + headers: text("headers"), // comma-separated list of headers to add to the request }); export const targets = pgTable("targets", { @@ -113,7 +115,9 @@ export const targets = pgTable("targets", { method: varchar("method"), port: integer("port").notNull(), internalPort: integer("internalPort"), - enabled: boolean("enabled").notNull().default(true) + enabled: boolean("enabled").notNull().default(true), + path: text("path"), + pathMatchType: text("pathMatchType"), // exact, prefix, regex }); export const exitNodes = pgTable("exitNodes", { @@ -127,7 +131,8 @@ export const exitNodes = pgTable("exitNodes", { maxConnections: integer("maxConnections"), online: boolean("online").notNull().default(false), lastPing: integer("lastPing"), - type: text("type").default("gerbil") // gerbil, remoteExitNode + type: text("type").default("gerbil"), // gerbil, remoteExitNode + region: varchar("region") }); export const siteResources = pgTable("siteResources", { // this is for the clients @@ -138,6 +143,7 @@ export const siteResources = pgTable("siteResources", { // this is for the clien orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), + niceId: varchar("niceId").notNull(), name: varchar("name").notNull(), protocol: varchar("protocol").notNull(), proxyPort: integer("proxyPort").notNull(), @@ -212,7 +218,8 @@ export const userOrgs = pgTable("userOrgs", { roleId: integer("roleId") .notNull() .references(() => roles.roleId), - isOwner: boolean("isOwner").notNull().default(false) + isOwner: boolean("isOwner").notNull().default(false), + autoProvisioned: boolean("autoProvisioned").default(false) }); export const emailVerificationCodes = pgTable("emailVerificationCodes", { @@ -458,6 +465,7 @@ export const idpOidcConfig = pgTable("idpOidcConfig", { idpId: integer("idpId") .notNull() .references(() => idp.idpId, { onDelete: "cascade" }), + variant: varchar("variant").notNull().default("oidc"), clientId: varchar("clientId").notNull(), clientSecret: varchar("clientSecret").notNull(), authUrl: varchar("authUrl").notNull(), diff --git a/server/db/sqlite/driver.ts b/server/db/sqlite/driver.ts index 124bd885..6369c268 100644 --- a/server/db/sqlite/driver.ts +++ b/server/db/sqlite/driver.ts @@ -18,6 +18,7 @@ function createDb() { export const db = createDb(); export default db; +export type Transaction = Parameters[0]>[0]; function checkFileExists(filePath: string): boolean { try { diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index c3e79291..7362f28a 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -77,6 +77,7 @@ export const resources = sqliteTable("resources", { onDelete: "cascade" }) .notNull(), + niceId: text("niceId").notNull(), name: text("name").notNull(), subdomain: text("subdomain"), fullDomain: text("fullDomain"), @@ -107,6 +108,7 @@ export const resources = sqliteTable("resources", { skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { onDelete: "cascade" }), + headers: text("headers"), // comma-separated list of headers to add to the request }); export const targets = sqliteTable("targets", { @@ -125,7 +127,9 @@ export const targets = sqliteTable("targets", { method: text("method"), port: integer("port").notNull(), internalPort: integer("internalPort"), - enabled: integer("enabled", { mode: "boolean" }).notNull().default(true) + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + path: text("path"), + pathMatchType: text("pathMatchType"), // exact, prefix, regex }); export const exitNodes = sqliteTable("exitNodes", { @@ -139,23 +143,28 @@ export const exitNodes = sqliteTable("exitNodes", { maxConnections: integer("maxConnections"), online: integer("online", { mode: "boolean" }).notNull().default(false), lastPing: integer("lastPing"), - type: text("type").default("gerbil") // gerbil, remoteExitNode + type: text("type").default("gerbil"), // gerbil, remoteExitNode + region: text("region") }); -export const siteResources = sqliteTable("siteResources", { // this is for the clients - siteResourceId: integer("siteResourceId").primaryKey({ autoIncrement: true }), +export const siteResources = sqliteTable("siteResources", { + // this is for the clients + siteResourceId: integer("siteResourceId").primaryKey({ + autoIncrement: true + }), siteId: integer("siteId") .notNull() .references(() => sites.siteId, { onDelete: "cascade" }), orgId: text("orgId") .notNull() .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), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true) }); export const users = sqliteTable("user", { @@ -259,7 +268,9 @@ export const clientSites = sqliteTable("clientSites", { siteId: integer("siteId") .notNull() .references(() => sites.siteId, { onDelete: "cascade" }), - isRelayed: integer("isRelayed", { mode: "boolean" }).notNull().default(false), + isRelayed: integer("isRelayed", { mode: "boolean" }) + .notNull() + .default(false), endpoint: text("endpoint") }); @@ -317,7 +328,10 @@ export const userOrgs = sqliteTable("userOrgs", { roleId: integer("roleId") .notNull() .references(() => roles.roleId), - isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false) + isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false), + autoProvisioned: integer("autoProvisioned", { + mode: "boolean" + }).default(false) }); export const emailVerificationCodes = sqliteTable("emailVerificationCodes", { @@ -603,6 +617,7 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", { idpOauthConfigId: integer("idpOauthConfigId").primaryKey({ autoIncrement: true }), + variant: text("variant").notNull().default("oidc"), idpId: integer("idpId") .notNull() .references(() => idp.idpId, { onDelete: "cascade" }), diff --git a/server/index.ts b/server/index.ts index dc8acc8a..746de7b9 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,6 +1,5 @@ #! /usr/bin/env node import "./extendZod.ts"; -import 'source-map-support/register.js'; import { runSetupFunctions } from "./setup"; import { createApiServer } from "./apiServer"; diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts new file mode 100644 index 00000000..47193420 --- /dev/null +++ b/server/lib/blueprints/applyBlueprint.ts @@ -0,0 +1,170 @@ +import { db, newts, Target } from "@server/db"; +import { Config, ConfigSchema } from "./types"; +import { ProxyResourcesResults, updateProxyResources } from "./proxyResources"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { resources, targets, sites } from "@server/db"; +import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm"; +import { addTargets as addProxyTargets } from "@server/routers/newt/targets"; +import { addTargets as addClientTargets } from "@server/routers/client/targets"; +import { + ClientResourcesResults, + updateClientResources +} from "./clientResources"; + +export async function applyBlueprint( + orgId: string, + configData: unknown, + siteId?: number +): Promise { + // Validate the input data + const validationResult = ConfigSchema.safeParse(configData); + if (!validationResult.success) { + throw new Error(fromError(validationResult.error).toString()); + } + + const config: Config = validationResult.data; + + try { + let proxyResourcesResults: ProxyResourcesResults = []; + let clientResourcesResults: ClientResourcesResults = []; + await db.transaction(async (trx) => { + proxyResourcesResults = await updateProxyResources( + orgId, + config, + trx, + siteId + ); + clientResourcesResults = await updateClientResources( + orgId, + config, + trx, + siteId + ); + }); + + logger.debug( + `Successfully updated proxy resources for org ${orgId}: ${JSON.stringify(proxyResourcesResults)}` + ); + + // We need to update the targets on the newts from the successfully updated information + for (const result of proxyResourcesResults) { + for (const target of result.targetsToUpdate) { + const [site] = await db + .select() + .from(sites) + .innerJoin(newts, eq(sites.siteId, newts.siteId)) + .where( + and( + eq(sites.siteId, target.siteId), + eq(sites.orgId, orgId), + eq(sites.type, "newt"), + isNotNull(sites.pubKey) + ) + ) + .limit(1); + + if (site) { + logger.debug( + `Updating target ${target.targetId} on site ${site.sites.siteId}` + ); + + await addProxyTargets( + site.newt.newtId, + [target], + result.proxyResource.protocol, + result.proxyResource.proxyPort + ); + } + } + } + + logger.debug( + `Successfully updated client resources for org ${orgId}: ${JSON.stringify(clientResourcesResults)}` + ); + + // We need to update the targets on the newts from the successfully updated information + for (const result of clientResourcesResults) { + const [site] = await db + .select() + .from(sites) + .innerJoin(newts, eq(sites.siteId, newts.siteId)) + .where( + and( + eq(sites.siteId, result.resource.siteId), + eq(sites.orgId, orgId), + eq(sites.type, "newt"), + isNotNull(sites.pubKey) + ) + ) + .limit(1); + + if (site) { + 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 + ); + } + } + } catch (error) { + logger.error(`Failed to update database from config: ${error}`); + throw error; + } +} + +// await updateDatabaseFromConfig("org_i21aifypnlyxur2", { +// resources: { +// "resource-nice-id": { +// name: "this is my resource", +// protocol: "http", +// "full-domain": "level1.test.example.com", +// "host-header": "example.com", +// "tls-server-name": "example.com", +// auth: { +// pincode: 123456, +// password: "sadfasdfadsf", +// "sso-enabled": true, +// "sso-roles": ["Member"], +// "sso-users": ["owen@fossorial.io"], +// "whitelist-users": ["owen@fossorial.io"] +// }, +// targets: [ +// { +// site: "glossy-plains-viscacha-rat", +// hostname: "localhost", +// method: "http", +// port: 8000, +// healthcheck: { +// port: 8000, +// hostname: "localhost" +// } +// }, +// { +// site: "glossy-plains-viscacha-rat", +// hostname: "localhost", +// method: "http", +// port: 8001 +// } +// ] +// }, +// "resource-nice-id2": { +// name: "http server", +// protocol: "tcp", +// "proxy-port": 3000, +// targets: [ +// { +// site: "glossy-plains-viscacha-rat", +// hostname: "localhost", +// port: 3000, +// } +// ] +// } +// } +// }); diff --git a/server/lib/blueprints/applyNewtDockerBlueprint.ts b/server/lib/blueprints/applyNewtDockerBlueprint.ts new file mode 100644 index 00000000..f69e4854 --- /dev/null +++ b/server/lib/blueprints/applyNewtDockerBlueprint.ts @@ -0,0 +1,53 @@ +import { sendToClient } from "@server/routers/ws"; +import { processContainerLabels } from "./parseDockerContainers"; +import { applyBlueprint } from "./applyBlueprint"; +import { db, sites } from "@server/db"; +import { eq } from "drizzle-orm"; +import logger from "@server/logger"; + +export async function applyNewtDockerBlueprint( + siteId: number, + newtId: string, + containers: any +) { + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!site) { + logger.warn("Site not found in applyNewtDockerBlueprint"); + return; + } + + // logger.debug(`Applying Docker blueprint to site: ${siteId}`); + // logger.debug(`Containers: ${JSON.stringify(containers, null, 2)}`); + + try { + const blueprint = processContainerLabels(containers); + + logger.debug(`Received Docker blueprint: ${JSON.stringify(blueprint)}`); + + // Update the blueprint in the database + await applyBlueprint(site.orgId, blueprint, site.siteId); + } catch (error) { + logger.error(`Failed to update database from config: ${error}`); + await sendToClient(newtId, { + type: "newt/blueprint/results", + data: { + success: false, + message: `Failed to update database from config: ${error}` + } + }); + return; + } + + await sendToClient(newtId, { + type: "newt/blueprint/results", + data: { + success: true, + message: "Config updated successfully" + } + }); +} diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts new file mode 100644 index 00000000..59bbc346 --- /dev/null +++ b/server/lib/blueprints/clientResources.ts @@ -0,0 +1,117 @@ +import { + SiteResource, + siteResources, + Transaction, +} from "@server/db"; +import { sites } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import { + Config, +} from "./types"; +import logger from "@server/logger"; + +export type ClientResourcesResults = { + resource: SiteResource; +}[]; + +export async function updateClientResources( + orgId: string, + config: Config, + trx: Transaction, + siteId?: number +): Promise { + const results: ClientResourcesResults = []; + + for (const [resourceNiceId, resourceData] of Object.entries( + config["client-resources"] + )) { + const [existingResource] = await trx + .select() + .from(siteResources) + .where( + and( + eq(siteResources.orgId, orgId), + eq(siteResources.niceId, resourceNiceId) + ) + ) + .limit(1); + + const resourceSiteId = resourceData.site; + let site; + + if (resourceSiteId) { + // Look up site by niceId + [site] = await trx + .select({ siteId: sites.siteId }) + .from(sites) + .where( + and( + eq(sites.niceId, resourceSiteId), + eq(sites.orgId, orgId) + ) + ) + .limit(1); + } else if (siteId) { + // Use the provided siteId directly, but verify it belongs to the org + [site] = await trx + .select({ siteId: sites.siteId }) + .from(sites) + .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) + .limit(1); + } else { + throw new Error(`Target site is required`); + } + + if (!site) { + throw new Error( + `Site not found: ${resourceSiteId} in org ${orgId}` + ); + } + + if (existingResource) { + // Update existing resource + const [updatedResource] = await trx + .update(siteResources) + .set({ + name: resourceData.name || resourceNiceId, + siteId: site.siteId, + proxyPort: resourceData["proxy-port"]!, + destinationIp: resourceData.hostname, + destinationPort: resourceData["internal-port"], + protocol: resourceData.protocol + }) + .where( + eq( + siteResources.siteResourceId, + existingResource.siteResourceId + ) + ) + .returning(); + + results.push({ resource: updatedResource }); + } else { + // Create new resource + const [newResource] = await trx + .insert(siteResources) + .values({ + orgId: orgId, + siteId: site.siteId, + niceId: resourceNiceId, + name: resourceData.name || resourceNiceId, + proxyPort: resourceData["proxy-port"]!, + destinationIp: resourceData.hostname, + destinationPort: resourceData["internal-port"], + protocol: resourceData.protocol + }) + .returning(); + + logger.info( + `Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}` + ); + + results.push({ resource: newResource }); + } + } + + return results; +} diff --git a/server/lib/blueprints/parseDockerContainers.ts b/server/lib/blueprints/parseDockerContainers.ts new file mode 100644 index 00000000..1510e6e1 --- /dev/null +++ b/server/lib/blueprints/parseDockerContainers.ts @@ -0,0 +1,301 @@ +import logger from "@server/logger"; +import { setNestedProperty } from "./parseDotNotation"; + +export type DockerLabels = { + [key: string]: string; +}; + +export type ParsedObject = { + [key: string]: any; +}; + +type ContainerPort = { + privatePort: number; + publicPort: number; + type: string; + ip: string; +}; + +type Container = { + id: string; + name: string; + image: string; + state: string; + status: string; + ports: ContainerPort[] | null; + labels: DockerLabels; + created: number; + networks: { [key: string]: any }; + hostname: string; +}; + +type Target = { + hostname?: string; + port?: number; + method?: string; + enabled?: boolean; + [key: string]: any; +}; + +type ResourceConfig = { + [key: string]: any; + targets?: (Target | null)[]; +}; + +function getContainerPort(container: Container): number | null { + if (!container.ports || container.ports.length === 0) { + return null; + } + // Return the first port's privatePort + return container.ports[0].privatePort; + // return container.ports[0].publicPort; +} + +export function processContainerLabels(containers: Container[]): { + "proxy-resources": { [key: string]: ResourceConfig }; + "client-resources": { [key: string]: ResourceConfig }; +} { + const result = { + "proxy-resources": {} as { [key: string]: ResourceConfig }, + "client-resources": {} as { [key: string]: ResourceConfig } + }; + + // Process each container + containers.forEach((container) => { + if (container.state !== "running") { + return; + } + + const proxyResourceLabels: DockerLabels = {}; + const clientResourceLabels: DockerLabels = {}; + + // Filter and separate proxy-resources and client-resources labels + Object.entries(container.labels).forEach(([key, value]) => { + if (key.startsWith("pangolin.proxy-resources.")) { + // remove the pangolin.proxy- prefix to get "resources.xxx" + const strippedKey = key.replace("pangolin.proxy-", ""); + proxyResourceLabels[strippedKey] = value; + } else if (key.startsWith("pangolin.client-resources.")) { + // remove the pangolin.client- prefix to get "resources.xxx" + const strippedKey = key.replace("pangolin.client-", ""); + clientResourceLabels[strippedKey] = value; + } + }); + + // Process proxy resources + if (Object.keys(proxyResourceLabels).length > 0) { + processResourceLabels(proxyResourceLabels, container, result["proxy-resources"]); + } + + // Process client resources + if (Object.keys(clientResourceLabels).length > 0) { + processResourceLabels(clientResourceLabels, container, result["client-resources"]); + } + }); + + return result; +} + +function processResourceLabels( + resourceLabels: DockerLabels, + container: Container, + targetResult: { [key: string]: ResourceConfig } +) { + // Parse the labels using the existing parseDockerLabels logic + const tempResult: ParsedObject = {}; + Object.entries(resourceLabels).forEach(([key, value]) => { + setNestedProperty(tempResult, key, value); + }); + + // Merge into target result + if (tempResult.resources) { + Object.entries(tempResult.resources).forEach( + ([resourceKey, resourceConfig]: [string, any]) => { + // Initialize resource if it doesn't exist + if (!targetResult[resourceKey]) { + targetResult[resourceKey] = {}; + } + + // Merge all properties except targets + Object.entries(resourceConfig).forEach( + ([propKey, propValue]) => { + if (propKey !== "targets") { + targetResult[resourceKey][propKey] = propValue; + } + } + ); + + // Handle targets specially + if ( + resourceConfig.targets && + Array.isArray(resourceConfig.targets) + ) { + const resource = targetResult[resourceKey]; + if (resource) { + if (!resource.targets) { + resource.targets = []; + } + + resourceConfig.targets.forEach( + (target: any, targetIndex: number) => { + // check if the target is an empty object + if ( + typeof target === "object" && + Object.keys(target).length === 0 + ) { + logger.debug( + `Skipping null target at index ${targetIndex} for resource ${resourceKey}` + ); + resource.targets!.push(null); + return; + } + + // Ensure targets array is long enough + while ( + resource.targets!.length <= targetIndex + ) { + resource.targets!.push({}); + } + + // Set default hostname and port if not provided + const finalTarget = { ...target }; + if (!finalTarget.hostname) { + finalTarget.hostname = + container.name || + container.hostname; + } + if (!finalTarget.port) { + const containerPort = + getContainerPort(container); + if (containerPort !== null) { + finalTarget.port = containerPort; + } + } + + // Merge with existing target data + resource.targets![targetIndex] = { + ...resource.targets![targetIndex], + ...finalTarget + }; + } + ); + } + } + } + ); + } +} + +// // Test example +// const testContainers: Container[] = [ +// { +// id: "57e056cb0e3a", +// name: "nginx1", +// image: "nginxdemos/hello", +// state: "running", +// status: "Up 4 days", +// ports: [ +// { +// privatePort: 80, +// publicPort: 8000, +// type: "tcp", +// ip: "0.0.0.0" +// } +// ], +// labels: { +// "resources.nginx.name": "nginx", +// "resources.nginx.full-domain": "nginx.example.com", +// "resources.nginx.protocol": "http", +// "resources.nginx.targets[0].enabled": "true" +// }, +// created: 1756942725, +// networks: { +// owen_default: { +// networkId: +// "cb131c0f1d5d8ef7158660e77fc370508f5a563e1f9829b53a1945ae3725b58c" +// } +// }, +// hostname: "57e056cb0e3a" +// }, +// { +// id: "58e056cb0e3b", +// name: "nginx2", +// image: "nginxdemos/hello", +// state: "running", +// status: "Up 4 days", +// ports: [ +// { +// privatePort: 80, +// publicPort: 8001, +// type: "tcp", +// ip: "0.0.0.0" +// } +// ], +// labels: { +// "resources.nginx.name": "nginx", +// "resources.nginx.full-domain": "nginx.example.com", +// "resources.nginx.protocol": "http", +// "resources.nginx.targets[1].enabled": "true" +// }, +// created: 1756942726, +// networks: { +// owen_default: { +// networkId: +// "cb131c0f1d5d8ef7158660e77fc370508f5a563e1f9829b53a1945ae3725b58c" +// } +// }, +// hostname: "58e056cb0e3b" +// }, +// { +// id: "59e056cb0e3c", +// name: "api-server", +// image: "my-api:latest", +// state: "running", +// status: "Up 2 days", +// ports: [ +// { +// privatePort: 3000, +// publicPort: 3000, +// type: "tcp", +// ip: "0.0.0.0" +// } +// ], +// labels: { +// "resources.api.name": "API Server", +// "resources.api.protocol": "http", +// "resources.api.targets[0].enabled": "true", +// "resources.api.targets[0].hostname": "custom-host", +// "resources.api.targets[0].port": "3001" +// }, +// created: 1756942727, +// networks: { +// owen_default: { +// networkId: +// "cb131c0f1d5d8ef7158660e77fc370508f5a563e1f9829b53a1945ae3725b58c" +// } +// }, +// hostname: "59e056cb0e3c" +// }, +// { +// id: "d0e29b08361c", +// name: "beautiful_wilson", +// image: "bolkedebruin/rdpgw:latest", +// state: "exited", +// status: "Exited (0) 4 hours ago", +// ports: null, +// labels: {}, +// created: 1757359039, +// networks: { +// bridge: { +// networkId: +// "ea7f56dfc9cc476b8a3560b5b570d0fe8a6a2bc5e8343ab1ed37822086e89687" +// } +// }, +// hostname: "d0e29b08361c" +// } +// ]; + +// // Test the function +// const result = processContainerLabels(testContainers); +// console.log("Processed result:"); +// console.log(JSON.stringify(result, null, 2)); diff --git a/server/lib/blueprints/parseDotNotation.ts b/server/lib/blueprints/parseDotNotation.ts new file mode 100644 index 00000000..87509d39 --- /dev/null +++ b/server/lib/blueprints/parseDotNotation.ts @@ -0,0 +1,109 @@ +export function setNestedProperty(obj: any, path: string, value: string): void { + const keys = path.split("."); + let current = obj; + + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + + // Handle array notation like "targets[0]" + const arrayMatch = key.match(/^(.+)\[(\d+)\]$/); + + if (arrayMatch) { + const [, arrayKey, indexStr] = arrayMatch; + const index = parseInt(indexStr, 10); + + // Initialize array if it doesn't exist + if (!current[arrayKey]) { + current[arrayKey] = []; + } + + // Ensure array is long enough + while (current[arrayKey].length <= index) { + current[arrayKey].push({}); + } + + current = current[arrayKey][index]; + } else { + // Regular object property + if (!current[key]) { + current[key] = {}; + } + current = current[key]; + } + } + + // Set the final value + const finalKey = keys[keys.length - 1]; + const arrayMatch = finalKey.match(/^(.+)\[(\d+)\]$/); + + if (arrayMatch) { + const [, arrayKey, indexStr] = arrayMatch; + const index = parseInt(indexStr, 10); + + if (!current[arrayKey]) { + current[arrayKey] = []; + } + + // Ensure array is long enough + while (current[arrayKey].length <= index) { + current[arrayKey].push(null); + } + + current[arrayKey][index] = convertValue(value); + } else { + current[finalKey] = convertValue(value); + } +} + +// Helper function to convert string values to appropriate types +export function convertValue(value: string): any { + // Convert boolean strings + if (value === "true") return true; + if (value === "false") return false; + + // Convert numeric strings + if (/^\d+$/.test(value)) { + const num = parseInt(value, 10); + return num; + } + + if (/^\d*\.\d+$/.test(value)) { + const num = parseFloat(value); + return num; + } + + // Return as string + return value; +} + +// // Example usage: +// const dockerLabels: DockerLabels = { +// "resources.resource-nice-id.name": "this is my resource", +// "resources.resource-nice-id.protocol": "http", +// "resources.resource-nice-id.full-domain": "level1.test3.example.com", +// "resources.resource-nice-id.host-header": "example.com", +// "resources.resource-nice-id.tls-server-name": "example.com", +// "resources.resource-nice-id.auth.pincode": "123456", +// "resources.resource-nice-id.auth.password": "sadfasdfadsf", +// "resources.resource-nice-id.auth.sso-enabled": "true", +// "resources.resource-nice-id.auth.sso-roles[0]": "Member", +// "resources.resource-nice-id.auth.sso-users[0]": "owen@fossorial.io", +// "resources.resource-nice-id.auth.whitelist-users[0]": "owen@fossorial.io", +// "resources.resource-nice-id.targets[0].hostname": "localhost", +// "resources.resource-nice-id.targets[0].method": "http", +// "resources.resource-nice-id.targets[0].port": "8000", +// "resources.resource-nice-id.targets[0].healthcheck.port": "8000", +// "resources.resource-nice-id.targets[0].healthcheck.hostname": "localhost", +// "resources.resource-nice-id.targets[1].hostname": "localhost", +// "resources.resource-nice-id.targets[1].method": "http", +// "resources.resource-nice-id.targets[1].port": "8001", +// "resources.resource-nice-id2.name": "this is other resource", +// "resources.resource-nice-id2.protocol": "tcp", +// "resources.resource-nice-id2.proxy-port": "3000", +// "resources.resource-nice-id2.targets[0].hostname": "localhost", +// "resources.resource-nice-id2.targets[0].port": "3000" +// }; + +// // Parse the labels +// const parsed = parseDockerLabels(dockerLabels); +// console.log(JSON.stringify(parsed, null, 2)); diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts new file mode 100644 index 00000000..6244fefa --- /dev/null +++ b/server/lib/blueprints/proxyResources.ts @@ -0,0 +1,885 @@ +import { + domains, + orgDomains, + Resource, + resourcePincode, + resourceRules, + resourceWhitelist, + roleResources, + roles, + Target, + Transaction, + userOrgs, + userResources, + users +} from "@server/db"; +import { resources, targets, sites } from "@server/db"; +import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm"; +import { + Config, + ConfigSchema, + isTargetsOnlyResource, + TargetData +} from "./types"; +import logger from "@server/logger"; +import { pickPort } from "@server/routers/target/helpers"; +import { resourcePassword } from "@server/db"; +import { hashPassword } from "@server/auth/password"; +import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; + +export type ProxyResourcesResults = { + proxyResource: Resource; + targetsToUpdate: Target[]; +}[]; + +export async function updateProxyResources( + orgId: string, + config: Config, + trx: Transaction, + siteId?: number +): Promise { + const results: ProxyResourcesResults = []; + + for (const [resourceNiceId, resourceData] of Object.entries( + config["proxy-resources"] + )) { + const targetsToUpdate: Target[] = []; + let resource: Resource; + + async function createTarget( // reusable function to create a target + resourceId: number, + targetData: TargetData + ) { + const targetSiteId = targetData.site; + let site; + + if (targetSiteId) { + // Look up site by niceId + [site] = await trx + .select({ siteId: sites.siteId }) + .from(sites) + .where( + and( + eq(sites.niceId, targetSiteId), + eq(sites.orgId, orgId) + ) + ) + .limit(1); + } else if (siteId) { + // Use the provided siteId directly, but verify it belongs to the org + [site] = await trx + .select({ siteId: sites.siteId }) + .from(sites) + .where( + and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)) + ) + .limit(1); + } else { + throw new Error(`Target site is required`); + } + + if (!site) { + throw new Error( + `Site not found: ${targetSiteId} in org ${orgId}` + ); + } + + let internalPortToCreate; + if (!targetData["internal-port"]) { + const { internalPort, targetIps } = await pickPort( + site.siteId!, + trx + ); + internalPortToCreate = internalPort; + } else { + internalPortToCreate = targetData["internal-port"]; + } + + // Create target + const [newTarget] = await trx + .insert(targets) + .values({ + resourceId: resourceId, + siteId: site.siteId, + ip: targetData.hostname, + method: targetData.method, + port: targetData.port, + enabled: targetData.enabled, + internalPort: internalPortToCreate, + path: targetData.path, + pathMatchType: targetData["path-match"] + }) + .returning(); + + targetsToUpdate.push(newTarget); + } + + // Find existing resource by niceId and orgId + const [existingResource] = await trx + .select() + .from(resources) + .where( + and( + eq(resources.niceId, resourceNiceId), + eq(resources.orgId, orgId) + ) + ) + .limit(1); + + const http = resourceData.protocol == "http"; + const protocol = + resourceData.protocol == "http" ? "tcp" : resourceData.protocol; + const resourceEnabled = + resourceData.enabled == undefined || resourceData.enabled == null + ? true + : resourceData.enabled; + const resourceSsl = + resourceData.ssl == undefined || resourceData.ssl == null + ? true + : resourceData.ssl; + let headers = ""; + for (const header of resourceData.headers || []) { + headers += `${header.name}: ${header.value},`; + } + // if there are headers, remove the trailing comma + if (headers.endsWith(",")) { + headers = headers.slice(0, -1); + } + + if (existingResource) { + let domain; + if (http) { + domain = await getDomain( + existingResource.resourceId, + resourceData["full-domain"]!, + orgId, + trx + ); + } + + // check if the only key in the resource is targets, if so, skip the update + if (isTargetsOnlyResource(resourceData)) { + logger.debug( + `Skipping update for resource ${existingResource.resourceId} as only targets are provided` + ); + resource = existingResource; + } else { + // Update existing resource + [resource] = await trx + .update(resources) + .set({ + name: resourceData.name || "Unnamed Resource", + protocol: protocol || "http", + http: http, + proxyPort: http ? null : resourceData["proxy-port"], + fullDomain: http ? resourceData["full-domain"] : null, + subdomain: domain ? domain.subdomain : null, + domainId: domain ? domain.domainId : null, + enabled: resourceEnabled, + sso: resourceData.auth?.["sso-enabled"] || false, + ssl: resourceSsl, + setHostHeader: resourceData["host-header"] || null, + tlsServerName: resourceData["tls-server-name"] || null, + emailWhitelistEnabled: resourceData.auth?.[ + "whitelist-users" + ] + ? resourceData.auth["whitelist-users"].length > 0 + : false, + headers: headers || null, + applyRules: + resourceData.rules && resourceData.rules.length > 0 + }) + .where( + eq(resources.resourceId, existingResource.resourceId) + ) + .returning(); + + await trx + .delete(resourcePassword) + .where( + eq( + resourcePassword.resourceId, + existingResource.resourceId + ) + ); + if (resourceData.auth?.password) { + const passwordHash = await hashPassword( + resourceData.auth.password + ); + + await trx.insert(resourcePassword).values({ + resourceId: existingResource.resourceId, + passwordHash + }); + } + + await trx + .delete(resourcePincode) + .where( + eq( + resourcePincode.resourceId, + existingResource.resourceId + ) + ); + if (resourceData.auth?.pincode) { + const pincodeHash = await hashPassword( + resourceData.auth.pincode.toString() + ); + + await trx.insert(resourcePincode).values({ + resourceId: existingResource.resourceId, + pincodeHash, + digitLength: 6 + }); + } + + if (resourceData.auth?.["sso-roles"]) { + const ssoRoles = resourceData.auth?.["sso-roles"]; + await syncRoleResources( + existingResource.resourceId, + ssoRoles, + orgId, + trx + ); + } + + if (resourceData.auth?.["sso-users"]) { + const ssoUsers = resourceData.auth?.["sso-users"]; + await syncUserResources( + existingResource.resourceId, + ssoUsers, + orgId, + trx + ); + } + + if (resourceData.auth?.["whitelist-users"]) { + const whitelistUsers = + resourceData.auth?.["whitelist-users"]; + await syncWhitelistUsers( + existingResource.resourceId, + whitelistUsers, + orgId, + trx + ); + } + } + + const existingResourceTargets = await trx + .select() + .from(targets) + .where(eq(targets.resourceId, existingResource.resourceId)) + .orderBy(asc(targets.targetId)); + + // Create new targets + for (const [index, targetData] of resourceData.targets.entries()) { + if ( + !targetData || + (typeof targetData === "object" && + Object.keys(targetData).length === 0) + ) { + // If targetData is null or an empty object, we can skip it + continue; + } + const existingTarget = existingResourceTargets[index]; + + if (existingTarget) { + const targetSiteId = targetData.site; + let site; + + if (targetSiteId) { + // Look up site by niceId + [site] = await trx + .select({ siteId: sites.siteId }) + .from(sites) + .where( + and( + eq(sites.niceId, targetSiteId), + eq(sites.orgId, orgId) + ) + ) + .limit(1); + } else if (siteId) { + // Use the provided siteId directly, but verify it belongs to the org + [site] = await trx + .select({ siteId: sites.siteId }) + .from(sites) + .where( + and( + eq(sites.siteId, siteId), + eq(sites.orgId, orgId) + ) + ) + .limit(1); + } else { + throw new Error(`Target site is required`); + } + + if (!site) { + throw new Error( + `Site not found: ${targetSiteId} in org ${orgId}` + ); + } + + // update this target + const [updatedTarget] = await trx + .update(targets) + .set({ + siteId: site.siteId, + ip: targetData.hostname, + method: http ? targetData.method : null, + port: targetData.port, + enabled: targetData.enabled, + path: targetData.path, + pathMatchType: targetData["path-match"] + }) + .where(eq(targets.targetId, existingTarget.targetId)) + .returning(); + + if (checkIfTargetChanged(existingTarget, updatedTarget)) { + let internalPortToUpdate; + if (!targetData["internal-port"]) { + const { internalPort, targetIps } = await pickPort( + site.siteId!, + trx + ); + internalPortToUpdate = internalPort; + } else { + internalPortToUpdate = targetData["internal-port"]; + } + + const [finalUpdatedTarget] = await trx // this double is so we can check the whole target before and after + .update(targets) + .set({ + internalPort: internalPortToUpdate + }) + .where( + eq(targets.targetId, existingTarget.targetId) + ) + .returning(); + + targetsToUpdate.push(finalUpdatedTarget); + } + } else { + await createTarget(existingResource.resourceId, targetData); + } + } + + if (existingResourceTargets.length > resourceData.targets.length) { + const targetsToDelete = existingResourceTargets.slice( + resourceData.targets.length + ); + logger.debug( + `Targets to delete: ${JSON.stringify(targetsToDelete)}` + ); + for (const target of targetsToDelete) { + if (!target) { + continue; + } + if (siteId && target.siteId !== siteId) { + logger.debug( + `Skipping target ${target.targetId} for deletion. Site ID does not match filter.` + ); + continue; // only delete targets for the specified siteId + } + logger.debug(`Deleting target ${target.targetId}`); + await trx + .delete(targets) + .where(eq(targets.targetId, target.targetId)); + } + } + + const existingRules = await trx + .select() + .from(resourceRules) + .where( + eq(resourceRules.resourceId, existingResource.resourceId) + ) + .orderBy(resourceRules.priority); + + // Sync rules + for (const [index, rule] of resourceData.rules?.entries() || []) { + const existingRule = existingRules[index]; + if (existingRule) { + if ( + existingRule.action !== getRuleAction(rule.action) || + existingRule.match !== rule.match.toUpperCase() || + existingRule.value !== rule.value + ) { + validateRule(rule); + await trx + .update(resourceRules) + .set({ + action: getRuleAction(rule.action), + match: rule.match.toUpperCase(), + value: rule.value + }) + .where( + eq(resourceRules.ruleId, existingRule.ruleId) + ); + } + } else { + validateRule(rule); + await trx.insert(resourceRules).values({ + resourceId: existingResource.resourceId, + action: getRuleAction(rule.action), + match: rule.match.toUpperCase(), + value: rule.value, + priority: index + 1 // start priorities at 1 + }); + } + } + + if (existingRules.length > (resourceData.rules?.length || 0)) { + const rulesToDelete = existingRules.slice( + resourceData.rules?.length || 0 + ); + for (const rule of rulesToDelete) { + await trx + .delete(resourceRules) + .where(eq(resourceRules.ruleId, rule.ruleId)); + } + } + + logger.debug(`Updated resource ${existingResource.resourceId}`); + } else { + // create a brand new resource + let domain; + if (http) { + domain = await getDomain( + undefined, + resourceData["full-domain"]!, + orgId, + trx + ); + } + + // Create new resource + const [newResource] = await trx + .insert(resources) + .values({ + orgId, + niceId: resourceNiceId, + name: resourceData.name || "Unnamed Resource", + protocol: resourceData.protocol || "http", + http: http, + proxyPort: http ? null : resourceData["proxy-port"], + fullDomain: http ? resourceData["full-domain"] : null, + subdomain: domain ? domain.subdomain : null, + domainId: domain ? domain.domainId : null, + enabled: resourceEnabled, + sso: resourceData.auth?.["sso-enabled"] || false, + setHostHeader: resourceData["host-header"] || null, + tlsServerName: resourceData["tls-server-name"] || null, + ssl: resourceSsl, + headers: headers || null, + applyRules: + resourceData.rules && resourceData.rules.length > 0 + }) + .returning(); + + if (resourceData.auth?.password) { + const passwordHash = await hashPassword( + resourceData.auth.password + ); + + await trx.insert(resourcePassword).values({ + resourceId: newResource.resourceId, + passwordHash + }); + } + + if (resourceData.auth?.pincode) { + const pincodeHash = await hashPassword( + resourceData.auth.pincode.toString() + ); + + await trx.insert(resourcePincode).values({ + resourceId: newResource.resourceId, + pincodeHash, + digitLength: 6 + }); + } + + resource = newResource; + + const [adminRole] = await trx + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); + + if (!adminRole) { + throw new Error(`Admin role not found`); + } + + await trx.insert(roleResources).values({ + roleId: adminRole.roleId, + resourceId: newResource.resourceId + }); + + if (resourceData.auth?.["sso-roles"]) { + const ssoRoles = resourceData.auth?.["sso-roles"]; + await syncRoleResources( + newResource.resourceId, + ssoRoles, + orgId, + trx + ); + } + + if (resourceData.auth?.["sso-users"]) { + const ssoUsers = resourceData.auth?.["sso-users"]; + await syncUserResources( + newResource.resourceId, + ssoUsers, + orgId, + trx + ); + } + + if (resourceData.auth?.["whitelist-users"]) { + const whitelistUsers = resourceData.auth?.["whitelist-users"]; + await syncWhitelistUsers( + newResource.resourceId, + whitelistUsers, + orgId, + trx + ); + } + + // Create new targets + for (const targetData of resourceData.targets) { + if (!targetData) { + // If targetData is null or an empty object, we can skip it + continue; + } + await createTarget(newResource.resourceId, targetData); + } + + for (const [index, rule] of resourceData.rules?.entries() || []) { + validateRule(rule); + await trx.insert(resourceRules).values({ + resourceId: newResource.resourceId, + action: getRuleAction(rule.action), + match: rule.match.toUpperCase(), + value: rule.value, + priority: index + 1 // start priorities at 1 + }); + } + + logger.debug(`Created resource ${newResource.resourceId}`); + } + + results.push({ + proxyResource: resource, + targetsToUpdate + }); + } + + return results; +} + +function getRuleAction(input: string) { + let action = "DROP"; + if (input == "allow") { + action = "ACCEPT"; + } else if (input == "deny") { + action = "DROP"; + } else if (input == "pass") { + action = "PASS"; + } + return action; +} + +function validateRule(rule: any) { + if (rule.match === "cidr") { + if (!isValidCIDR(rule.value)) { + throw new Error(`Invalid CIDR provided: ${rule.value}`); + } + } else if (rule.match === "ip") { + if (!isValidIP(rule.value)) { + throw new Error(`Invalid IP provided: ${rule.value}`); + } + } else if (rule.match === "path") { + if (!isValidUrlGlobPattern(rule.value)) { + throw new Error(`Invalid URL glob pattern: ${rule.value}`); + } + } +} + +async function syncRoleResources( + resourceId: number, + ssoRoles: string[], + orgId: string, + trx: Transaction +) { + const existingRoleResources = await trx + .select() + .from(roleResources) + .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) + .where(and(eq(roles.name, roleName), eq(roles.orgId, orgId))) + .limit(1); + + if (!role) { + throw new Error(`Role not found: ${roleName} in org ${orgId}`); + } + + const existingRoleResource = existingRoleResources.find( + (rr) => rr.roleId === role.roleId + ); + + if (!existingRoleResource) { + await trx.insert(roleResources).values({ + roleId: role.roleId, + resourceId: resourceId + }); + } + } + + for (const existingRoleResource of existingRoleResources) { + const [role] = await trx + .select() + .from(roles) + .where(eq(roles.roleId, existingRoleResource.roleId)) + .limit(1); + + if (role.isAdmin) { + continue; // never remove admin access + } + + if (role && !ssoRoles.includes(role.name)) { + await trx + .delete(roleResources) + .where( + and( + eq(roleResources.roleId, existingRoleResource.roleId), + eq(roleResources.resourceId, resourceId) + ) + ); + } + } +} + +async function syncUserResources( + resourceId: number, + ssoUsers: string[], + orgId: string, + trx: Transaction +) { + const existingUserResources = await trx + .select() + .from(userResources) + .where(eq(userResources.resourceId, resourceId)); + + for (const email of ssoUsers) { + const [user] = await trx + .select() + .from(users) + .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) + .where(and(eq(users.email, email), eq(userOrgs.orgId, orgId))) + .limit(1); + + if (!user) { + throw new Error(`User not found: ${email} in org ${orgId}`); + } + + const existingUserResource = existingUserResources.find( + (rr) => rr.userId === user.user.userId + ); + + if (!existingUserResource) { + await trx.insert(userResources).values({ + userId: user.user.userId, + resourceId: resourceId + }); + } + } + + for (const existingUserResource of existingUserResources) { + const [user] = await trx + .select() + .from(users) + .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) + .where( + and( + eq(users.userId, existingUserResource.userId), + eq(userOrgs.orgId, orgId) + ) + ) + .limit(1); + + if (user && user.user.email && !ssoUsers.includes(user.user.email)) { + await trx + .delete(userResources) + .where( + and( + eq(userResources.userId, existingUserResource.userId), + eq(userResources.resourceId, resourceId) + ) + ); + } + } +} + +async function syncWhitelistUsers( + resourceId: number, + whitelistUsers: string[], + orgId: string, + trx: Transaction +) { + const existingWhitelist = await trx + .select() + .from(resourceWhitelist) + .where(eq(resourceWhitelist.resourceId, resourceId)); + + for (const email of whitelistUsers) { + const [user] = await trx + .select() + .from(users) + .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) + .where(and(eq(users.email, email), eq(userOrgs.orgId, orgId))) + .limit(1); + + if (!user) { + throw new Error(`User not found: ${email} in org ${orgId}`); + } + + const existingWhitelistEntry = existingWhitelist.find( + (w) => w.email === email + ); + + if (!existingWhitelistEntry) { + await trx.insert(resourceWhitelist).values({ + email, + resourceId: resourceId + }); + } + } + + for (const existingWhitelistEntry of existingWhitelist) { + if (!whitelistUsers.includes(existingWhitelistEntry.email)) { + await trx + .delete(resourceWhitelist) + .where( + and( + eq(resourceWhitelist.resourceId, resourceId), + eq( + resourceWhitelist.email, + existingWhitelistEntry.email + ) + ) + ); + } + } +} + +function checkIfTargetChanged( + existing: Target | undefined, + incoming: Target | undefined +): boolean { + if (!existing && incoming) return true; + if (existing && !incoming) return true; + if (!existing || !incoming) return false; + + if (existing.ip !== incoming.ip) return true; + if (existing.port !== incoming.port) return true; + if (existing.siteId !== incoming.siteId) return true; + + return false; +} + +async function getDomain( + resourceId: number | undefined, + fullDomain: string, + orgId: string, + trx: Transaction +) { + const [fullDomainExists] = await trx + .select({ resourceId: resources.resourceId }) + .from(resources) + .where( + and( + eq(resources.fullDomain, fullDomain), + eq(resources.orgId, orgId), + resourceId + ? ne(resources.resourceId, resourceId) + : isNotNull(resources.resourceId) + ) + ) + .limit(1); + + if (fullDomainExists) { + throw new Error( + `Resource already exists: ${fullDomain} in org ${orgId}` + ); + } + + const domain = await getDomainId(orgId, fullDomain, trx); + + if (!domain) { + throw new Error( + `Domain not found for full-domain: ${fullDomain} in org ${orgId}` + ); + } + + return domain; +} + +async function getDomainId( + orgId: string, + fullDomain: string, + trx: Transaction +): Promise<{ subdomain: string | null; domainId: string } | null> { + const possibleDomains = await trx + .select() + .from(domains) + .innerJoin(orgDomains, eq(domains.domainId, orgDomains.domainId)) + .where(and(eq(orgDomains.orgId, orgId), eq(domains.verified, true))) + .execute(); + + if (possibleDomains.length === 0) { + return null; + } + + const validDomains = possibleDomains.filter((domain) => { + if (domain.domains.type == "ns" || domain.domains.type == "wildcard") { + return ( + fullDomain === domain.domains.baseDomain || + fullDomain.endsWith(`.${domain.domains.baseDomain}`) + ); + } else if (domain.domains.type == "cname") { + return fullDomain === domain.domains.baseDomain; + } + }); + + if (validDomains.length === 0) { + return null; + } + + const domainSelection = validDomains[0].domains; + const baseDomain = domainSelection.baseDomain; + + // remove the base domain of the domain + let subdomain = null; + if (domainSelection.type == "ns") { + if (fullDomain != baseDomain) { + subdomain = fullDomain.replace(`.${baseDomain}`, ""); + } + } + + // Return the first valid domain + return { + subdomain: subdomain, + domainId: domainSelection.domainId + }; +} diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts new file mode 100644 index 00000000..f11ffe1f --- /dev/null +++ b/server/lib/blueprints/types.ts @@ -0,0 +1,366 @@ +import { z } from "zod"; + +export const SiteSchema = z.object({ + name: z.string().min(1).max(100), + "docker-socket-enabled": z.boolean().optional().default(true) +}); + +// Schema for individual target within a resource +export const TargetSchema = z.object({ + site: z.string().optional(), + method: z.enum(["http", "https", "h2c"]).optional(), + hostname: z.string(), + port: z.number().int().min(1).max(65535), + enabled: z.boolean().optional().default(true), + "internal-port": z.number().int().min(1).max(65535).optional(), + path: z.string().optional(), + "path-match": z.enum(["exact", "prefix", "regex"]).optional().nullable() +}); +export type TargetData = z.infer; + +export const AuthSchema = z.object({ + // pincode has to have 6 digits + pincode: z.number().min(100000).max(999999).optional(), + password: z.string().min(1).optional(), + "sso-enabled": z.boolean().optional().default(false), + "sso-roles": z + .array(z.string()) + .optional() + .default([]) + .refine((roles) => !roles.includes("Admin"), { + message: "Admin role cannot be included in sso-roles" + }), + "sso-users": z.array(z.string().email()).optional().default([]), + "whitelist-users": z.array(z.string().email()).optional().default([]), +}); + +export const RuleSchema = z.object({ + action: z.enum(["allow", "deny", "pass"]), + match: z.enum(["cidr", "path", "ip", "country"]), + value: z.string() +}); + +export const HeaderSchema = z.object({ + name: z.string().min(1), + value: z.string().min(1) +}); + +// Schema for individual resource +export const ResourceSchema = z + .object({ + name: z.string().optional(), + protocol: z.enum(["http", "tcp", "udp"]).optional(), + ssl: z.boolean().optional(), + "full-domain": z.string().optional(), + "proxy-port": z.number().int().min(1).max(65535).optional(), + enabled: z.boolean().optional(), + targets: z.array(TargetSchema.nullable()).optional().default([]), + auth: AuthSchema.optional(), + "host-header": z.string().optional(), + "tls-server-name": z.string().optional(), + headers: z.array(HeaderSchema).optional().default([]), + rules: z.array(RuleSchema).optional().default([]), + }) + .refine( + (resource) => { + if (isTargetsOnlyResource(resource)) { + return true; + } + + // Otherwise, require name and protocol for full resource definition + return ( + resource.name !== undefined && resource.protocol !== undefined + ); + }, + { + message: + "Resource must either be targets-only (only 'targets' field) or have both 'name' and 'protocol' fields at a minimum", + path: ["name", "protocol"] + } + ) + .refine( + (resource) => { + if (isTargetsOnlyResource(resource)) { + return true; + } + + // If protocol is http, all targets must have method field + if (resource.protocol === "http") { + return resource.targets.every( + (target) => target == null || target.method !== undefined + ); + } + // If protocol is tcp or udp, no target should have method field + if (resource.protocol === "tcp" || resource.protocol === "udp") { + return resource.targets.every( + (target) => target == null || target.method === undefined + ); + } + return true; + }, + (resource) => { + if (resource.protocol === "http") { + return { + message: + "When protocol is 'http', all targets must have a 'method' field", + path: ["targets"] + }; + } + return { + message: + "When protocol is 'tcp' or 'udp', targets must not have a 'method' field", + path: ["targets"] + }; + } + ) + .refine( + (resource) => { + if (isTargetsOnlyResource(resource)) { + return true; + } + + // If protocol is http, it must have a full-domain + if (resource.protocol === "http") { + return ( + resource["full-domain"] !== undefined && + resource["full-domain"].length > 0 + ); + } + return true; + }, + { + message: + "When protocol is 'http', a 'full-domain' must be provided", + path: ["full-domain"] + } + ) + .refine( + (resource) => { + if (isTargetsOnlyResource(resource)) { + return true; + } + + // If protocol is tcp or udp, it must have both proxy-port + if (resource.protocol === "tcp" || resource.protocol === "udp") { + return resource["proxy-port"] !== undefined; + } + return true; + }, + { + message: + "When protocol is 'tcp' or 'udp', 'proxy-port' must be provided", + path: ["proxy-port", "exit-node"] + } + ) + .refine( + (resource) => { + // Skip validation for targets-only resources + if (isTargetsOnlyResource(resource)) { + return true; + } + + // If protocol is tcp or udp, it must not have auth + if (resource.protocol === "tcp" || resource.protocol === "udp") { + return resource.auth === undefined; + } + return true; + }, + { + message: + "When protocol is 'tcp' or 'udp', 'auth' must not be provided", + path: ["auth"] + } + ); + +export function isTargetsOnlyResource(resource: any): boolean { + return Object.keys(resource).length === 1 && resource.targets; +} + +export const ClientResourceSchema = z.object({ + name: z.string().min(2).max(100), + site: z.string().min(2).max(100).optional(), + protocol: z.enum(["tcp", "udp"]), + "proxy-port": z.number().min(1).max(65535), + "hostname": z.string().min(1).max(255), + "internal-port": z.number().min(1).max(65535), + enabled: z.boolean().optional().default(true) +}); + +// Schema for the entire configuration object +export const ConfigSchema = z + .object({ + "proxy-resources": z.record(z.string(), ResourceSchema).optional().default({}), + "client-resources": z.record(z.string(), ClientResourceSchema).optional().default({}), + sites: z.record(z.string(), SiteSchema).optional().default({}) + }) + .refine( + // Enforce the full-domain uniqueness across resources in the same stack + (config) => { + // Extract all full-domain values with their resource keys + const fullDomainMap = new Map(); + + Object.entries(config["proxy-resources"]).forEach( + ([resourceKey, resource]) => { + const fullDomain = resource["full-domain"]; + if (fullDomain) { + // Only process if full-domain is defined + if (!fullDomainMap.has(fullDomain)) { + fullDomainMap.set(fullDomain, []); + } + fullDomainMap.get(fullDomain)!.push(resourceKey); + } + } + ); + + // Find duplicates + const duplicates = Array.from(fullDomainMap.entries()).filter( + ([_, resourceKeys]) => resourceKeys.length > 1 + ); + + return duplicates.length === 0; + }, + (config) => { + // Extract duplicates for error message + const fullDomainMap = new Map(); + + Object.entries(config["proxy-resources"]).forEach( + ([resourceKey, resource]) => { + const fullDomain = resource["full-domain"]; + if (fullDomain) { + // Only process if full-domain is defined + if (!fullDomainMap.has(fullDomain)) { + fullDomainMap.set(fullDomain, []); + } + fullDomainMap.get(fullDomain)!.push(resourceKey); + } + } + ); + + const duplicates = Array.from(fullDomainMap.entries()) + .filter(([_, resourceKeys]) => resourceKeys.length > 1) + .map( + ([fullDomain, resourceKeys]) => + `'${fullDomain}' used by resources: ${resourceKeys.join(", ")}` + ) + .join("; "); + + return { + message: `Duplicate 'full-domain' values found: ${duplicates}`, + path: ["resources"] + }; + } + ) + .refine( + // Enforce proxy-port uniqueness within proxy-resources + (config) => { + const proxyPortMap = new Map(); + + Object.entries(config["proxy-resources"]).forEach( + ([resourceKey, resource]) => { + const proxyPort = resource["proxy-port"]; + if (proxyPort !== undefined) { + if (!proxyPortMap.has(proxyPort)) { + proxyPortMap.set(proxyPort, []); + } + proxyPortMap.get(proxyPort)!.push(resourceKey); + } + } + ); + + // Find duplicates + const duplicates = Array.from(proxyPortMap.entries()).filter( + ([_, resourceKeys]) => resourceKeys.length > 1 + ); + + return duplicates.length === 0; + }, + (config) => { + // Extract duplicates for error message + const proxyPortMap = new Map(); + + Object.entries(config["proxy-resources"]).forEach( + ([resourceKey, resource]) => { + const proxyPort = resource["proxy-port"]; + if (proxyPort !== undefined) { + if (!proxyPortMap.has(proxyPort)) { + proxyPortMap.set(proxyPort, []); + } + proxyPortMap.get(proxyPort)!.push(resourceKey); + } + } + ); + + const duplicates = Array.from(proxyPortMap.entries()) + .filter(([_, resourceKeys]) => resourceKeys.length > 1) + .map( + ([proxyPort, resourceKeys]) => + `port ${proxyPort} used by proxy-resources: ${resourceKeys.join(", ")}` + ) + .join("; "); + + return { + message: `Duplicate 'proxy-port' values found in proxy-resources: ${duplicates}`, + path: ["proxy-resources"] + }; + } + ) + .refine( + // Enforce proxy-port uniqueness within client-resources + (config) => { + const proxyPortMap = new Map(); + + Object.entries(config["client-resources"]).forEach( + ([resourceKey, resource]) => { + const proxyPort = resource["proxy-port"]; + if (proxyPort !== undefined) { + if (!proxyPortMap.has(proxyPort)) { + proxyPortMap.set(proxyPort, []); + } + proxyPortMap.get(proxyPort)!.push(resourceKey); + } + } + ); + + // Find duplicates + const duplicates = Array.from(proxyPortMap.entries()).filter( + ([_, resourceKeys]) => resourceKeys.length > 1 + ); + + return duplicates.length === 0; + }, + (config) => { + // Extract duplicates for error message + const proxyPortMap = new Map(); + + Object.entries(config["client-resources"]).forEach( + ([resourceKey, resource]) => { + const proxyPort = resource["proxy-port"]; + if (proxyPort !== undefined) { + if (!proxyPortMap.has(proxyPort)) { + proxyPortMap.set(proxyPort, []); + } + proxyPortMap.get(proxyPort)!.push(resourceKey); + } + } + ); + + const duplicates = Array.from(proxyPortMap.entries()) + .filter(([_, resourceKeys]) => resourceKeys.length > 1) + .map( + ([proxyPort, resourceKeys]) => + `port ${proxyPort} used by client-resources: ${resourceKeys.join(", ")}` + ) + .join("; "); + + return { + message: `Duplicate 'proxy-port' values found in client-resources: ${duplicates}`, + path: ["client-resources"] + }; + } + ); + +// Type inference from the schema +export type Site = z.infer; +export type Target = z.infer; +export type Resource = z.infer; +export type Config = z.infer; diff --git a/server/lib/consts.ts b/server/lib/consts.ts index b9afa792..30efc07e 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.9.0"; +export const APP_VERSION = "1.10.0"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/lib/domainUtils.ts b/server/lib/domainUtils.ts new file mode 100644 index 00000000..d043ca51 --- /dev/null +++ b/server/lib/domainUtils.ts @@ -0,0 +1,112 @@ +import { db } from "@server/db"; +import { domains, orgDomains } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import { subdomainSchema } from "@server/lib/schemas"; +import { fromError } from "zod-validation-error"; + +export type DomainValidationResult = { + success: true; + fullDomain: string; + subdomain: string | null; +} | { + success: false; + error: string; +}; + +/** + * Validates a domain and constructs the full domain based on domain type and subdomain. + * + * @param domainId - The ID of the domain to validate + * @param orgId - The organization ID to check domain access + * @param subdomain - Optional subdomain to append (for ns and wildcard domains) + * @returns DomainValidationResult with success status and either fullDomain/subdomain or error message + */ +export async function validateAndConstructDomain( + domainId: string, + orgId: string, + subdomain?: string | null +): Promise { + try { + // Query domain with organization access check + const [domainRes] = await db + .select() + .from(domains) + .where(eq(domains.domainId, domainId)) + .leftJoin( + orgDomains, + and(eq(orgDomains.orgId, orgId), eq(orgDomains.domainId, domainId)) + ); + + // Check if domain exists + if (!domainRes || !domainRes.domains) { + return { + success: false, + error: `Domain with ID ${domainId} not found` + }; + } + + // Check if organization has access to domain + if (domainRes.orgDomains && domainRes.orgDomains.orgId !== orgId) { + return { + success: false, + error: `Organization does not have access to domain with ID ${domainId}` + }; + } + + // Check if domain is verified + if (!domainRes.domains.verified) { + return { + success: false, + error: `Domain with ID ${domainId} is not verified` + }; + } + + // Construct full domain based on domain type + let fullDomain = ""; + let finalSubdomain = subdomain; + + if (domainRes.domains.type === "ns") { + if (subdomain) { + fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`; + } else { + fullDomain = domainRes.domains.baseDomain; + } + } else if (domainRes.domains.type === "cname") { + fullDomain = domainRes.domains.baseDomain; + finalSubdomain = null; // CNAME domains don't use subdomains + } else if (domainRes.domains.type === "wildcard") { + if (subdomain !== undefined && subdomain !== null) { + // Validate subdomain format for wildcard domains + const parsedSubdomain = subdomainSchema.safeParse(subdomain); + if (!parsedSubdomain.success) { + return { + success: false, + error: fromError(parsedSubdomain.error).toString() + }; + } + fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`; + } else { + fullDomain = domainRes.domains.baseDomain; + } + } + + // If the full domain equals the base domain, set subdomain to null + if (fullDomain === domainRes.domains.baseDomain) { + finalSubdomain = null; + } + + // Convert to lowercase + fullDomain = fullDomain.toLowerCase(); + + return { + success: true, + fullDomain, + subdomain: finalSubdomain ?? null + }; + } catch (error) { + return { + success: false, + error: `An error occurred while validating domain: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } +} diff --git a/server/lib/exitNodeComms.ts b/server/lib/exitNodeComms.ts index f79b718f..bcfbec3e 100644 --- a/server/lib/exitNodeComms.ts +++ b/server/lib/exitNodeComms.ts @@ -3,7 +3,7 @@ import logger from "@server/logger"; import { ExitNode } from "@server/db"; interface ExitNodeRequest { - remoteType: string; + remoteType?: string; localPath: string; method?: "POST" | "DELETE" | "GET" | "PUT"; data?: any; diff --git a/server/lib/exitNodes/exitNodes.ts b/server/lib/exitNodes/exitNodes.ts index 06539bb0..7b571682 100644 --- a/server/lib/exitNodes/exitNodes.ts +++ b/server/lib/exitNodes/exitNodes.ts @@ -30,7 +30,8 @@ export async function listExitNodes(orgId: string, filterOnline = false) { maxConnections: exitNodes.maxConnections, online: exitNodes.online, lastPing: exitNodes.lastPing, - type: exitNodes.type + type: exitNodes.type, + region: exitNodes.region }) .from(exitNodes); diff --git a/server/lib/telemetry.ts b/server/lib/telemetry.ts index cd000767..827f1c7e 100644 --- a/server/lib/telemetry.ts +++ b/server/lib/telemetry.ts @@ -63,7 +63,7 @@ class TelemetryClient { logger.error("Failed to collect analytics:", err); }); }, - 6 * 60 * 60 * 1000 + 48 * 60 * 60 * 1000 ); this.collectAndSendAnalytics().catch((err) => { diff --git a/server/lib/traefikConfig.ts b/server/lib/traefikConfig.ts index e16b93d2..8b133419 100644 --- a/server/lib/traefikConfig.ts +++ b/server/lib/traefikConfig.ts @@ -15,6 +15,7 @@ import { getValidCertificatesForDomains, getValidCertificatesForDomainsHybrid } from "./remoteCertificates"; +import { sendToExitNode } from "./exitNodeComms"; export class TraefikConfigManager { private intervalId: NodeJS.Timeout | null = null; @@ -403,27 +404,11 @@ export class TraefikConfigManager { [exitNode] = await db.select().from(exitNodes).limit(1); } if (exitNode) { - try { - await axios.post( - `${exitNode.reachableAt}/update-local-snis`, - { fullDomains: Array.from(domains) }, - { headers: { "Content-Type": "application/json" } } - ); - } catch (error) { - // pull data out of the axios error to log - if (axios.isAxiosError(error)) { - logger.error("Error updating local SNI:", { - message: error.message, - code: error.code, - status: error.response?.status, - statusText: error.response?.statusText, - url: error.config?.url, - method: error.config?.method - }); - } else { - logger.error("Error updating local SNI:", error); - } - } + await sendToExitNode(exitNode, { + localPath: "/update-local-snis", + method: "POST", + data: { fullDomains: Array.from(domains) } + }); } else { logger.error( "No exit node found. Has gerbil registered yet?" diff --git a/server/lib/validators.ts b/server/lib/validators.ts index 6c581e47..522e5018 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -129,6 +129,40 @@ export function isValidDomain(domain: string): boolean { return true; } +export function validateHeaders(headers: string): boolean { + // Validate comma-separated headers in format "Header-Name: value" + const headerPairs = headers.split(",").map((pair) => pair.trim()); + return headerPairs.every((pair) => { + // Check if the pair contains exactly one colon + const colonCount = (pair.match(/:/g) || []).length; + if (colonCount !== 1) { + return false; + } + + const colonIndex = pair.indexOf(":"); + if (colonIndex === 0 || colonIndex === pair.length - 1) { + return false; + } + + const headerName = pair.substring(0, colonIndex).trim(); + const headerValue = pair.substring(colonIndex + 1).trim(); + + // Header name should not be empty and should contain valid characters + // Header names are case-insensitive and can contain alphanumeric, hyphens + const headerNameRegex = /^[a-zA-Z0-9\-_]+$/; + if (!headerName || !headerNameRegex.test(headerName)) { + return false; + } + + // Header value should not be empty and should not contain colons + if (!headerValue || headerValue.includes(":")) { + return false; + } + + return true; + }); +} + const validTlds = [ "AAA", "AARP", diff --git a/server/middlewares/integration/verifyApiKeySetResourceUsers.ts b/server/middlewares/integration/verifyApiKeySetResourceUsers.ts index 9c96e6ec..51a8f3fc 100644 --- a/server/middlewares/integration/verifyApiKeySetResourceUsers.ts +++ b/server/middlewares/integration/verifyApiKeySetResourceUsers.ts @@ -19,6 +19,11 @@ export async function verifyApiKeySetResourceUsers( ); } + if (apiKey.isRoot) { + // Root keys can access any key in any org + return next(); + } + if (!req.apiKeyOrg) { return next( createHttpError( @@ -32,11 +37,6 @@ export async function verifyApiKeySetResourceUsers( return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid user IDs")); } - if (apiKey.isRoot) { - // Root keys can access any key in any org - return next(); - } - if (userIds.length === 0) { return next(); } diff --git a/server/routers/external.ts b/server/routers/external.ts index e421a3e2..b851eda8 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -345,6 +345,12 @@ authenticated.get( verifyUserHasAction(ActionsEnum.getResource), resource.getResource ); +authenticated.get( + "/org/:orgId/resource/:niceId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getResource), + resource.getResource +); authenticated.post( "/resource/:resourceId", verifyResourceAccess, @@ -582,6 +588,14 @@ authenticated.put( user.createOrgUser ); +authenticated.post( + "/org/:orgId/user/:userId", + verifyOrgAccess, + verifyUserAccess, + verifyUserHasAction(ActionsEnum.updateOrgUser), + user.updateOrgUser +); + authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); authenticated.post( @@ -956,7 +970,7 @@ authRouter.post( windowMs: 15 * 60 * 1000, max: 15, keyGenerator: (req) => - `requestEmailVerificationCode:${req.body.email || ipKeyGenerator(req.ip || "")}`, + `requestEmailVerificationCode:${req.user?.email || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only request an email verification code ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); diff --git a/server/routers/idp/listIdps.ts b/server/routers/idp/listIdps.ts index 2a0e5809..150b9f88 100644 --- a/server/routers/idp/listIdps.ts +++ b/server/routers/idp/listIdps.ts @@ -1,11 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, idpOidcConfig } from "@server/db"; import { domains, idp, orgDomains, users, idpOrg } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { sql } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; @@ -33,23 +33,21 @@ async function query(limit: number, offset: number) { idpId: idp.idpId, name: idp.name, type: idp.type, - orgCount: sql`count(${idpOrg.orgId})` + variant: idpOidcConfig.variant, + orgCount: sql`count(${idpOrg.orgId})`, + autoProvision: idp.autoProvision }) .from(idp) .leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`) - .groupBy(idp.idpId) + .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) + .groupBy(idp.idpId, idpOidcConfig.variant) .limit(limit) .offset(offset); return res; } export type ListIdpsResponse = { - idps: Array<{ - idpId: number; - name: string; - type: string; - orgCount: number; - }>; + idps: Awaited>; pagination: { total: number; limit: number; diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 67e2baad..46baa517 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -354,8 +354,13 @@ export async function validateOidcCallback( .from(userOrgs) .where(eq(userOrgs.userId, userId!)); - // Delete orgs that are no longer valid - const orgsToDelete = currentUserOrgs.filter( + // Filter to only auto-provisioned orgs for CRUD operations + const autoProvisionedOrgs = currentUserOrgs.filter( + (org) => org.autoProvisioned === true + ); + + // Delete auto-provisioned orgs that are no longer valid + const orgsToDelete = autoProvisionedOrgs.filter( (currentOrg) => !userOrgInfo.some( (newOrg) => newOrg.orgId === currentOrg.orgId @@ -374,8 +379,8 @@ export async function validateOidcCallback( ); } - // Update roles for existing orgs where the role has changed - const orgsToUpdate = currentUserOrgs.filter((currentOrg) => { + // Update roles for existing auto-provisioned orgs where the role has changed + const orgsToUpdate = autoProvisionedOrgs.filter((currentOrg) => { const newOrg = userOrgInfo.find( (newOrg) => newOrg.orgId === currentOrg.orgId ); @@ -401,7 +406,7 @@ export async function validateOidcCallback( } } - // Add new orgs that don't exist yet + // Add new orgs that don't exist yet (these will be auto-provisioned) const orgsToAdd = userOrgInfo.filter( (newOrg) => !currentUserOrgs.some( @@ -415,12 +420,14 @@ export async function validateOidcCallback( userId: userId!, orgId: org.orgId, roleId: org.roleId, + autoProvisioned: true, dateCreated: new Date().toISOString() })) ); } // Loop through all the orgs and get the total number of users from the userOrgs table + // Use all current user orgs (both auto-provisioned and manually added) for counting for (const org of currentUserOrgs) { const userCount = await trx .select() diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 79453732..6a43aaa7 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -24,7 +24,8 @@ import { verifyApiKeyIsRoot, verifyApiKeyClientAccess, verifyClientsEnabled, - verifyApiKeySiteResourceAccess + verifyApiKeySiteResourceAccess, + verifyOrgAccess } from "@server/middlewares"; import HttpCode from "@server/types/HttpCode"; import { Router } from "express"; @@ -469,6 +470,21 @@ authenticated.get( user.listUsers ); +authenticated.put( + "/org/:orgId/user", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createOrgUser), + user.createOrgUser +); + +authenticated.post( + "/org/:orgId/user/:userId", + verifyApiKeyOrgAccess, + verifyApiKeyUserAccess, + verifyApiKeyHasAction(ActionsEnum.updateOrgUser), + user.updateOrgUser +); + authenticated.delete( "/org/:orgId/user/:userId", verifyApiKeyOrgAccess, @@ -628,3 +644,10 @@ authenticated.post( verifyApiKeyHasAction(ActionsEnum.updateClient), client.updateClient ); + +authenticated.put( + "/org/:orgId/blueprint", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.applyBlueprint), + org.applyBlueprint +); \ No newline at end of file diff --git a/server/routers/newt/handleApplyBlueprintMessage.ts b/server/routers/newt/handleApplyBlueprintMessage.ts new file mode 100644 index 00000000..68158799 --- /dev/null +++ b/server/routers/newt/handleApplyBlueprintMessage.ts @@ -0,0 +1,73 @@ +import { db, newts } from "@server/db"; +import { MessageHandler } from "../ws"; +import { exitNodes, Newt, resources, sites, Target, targets } from "@server/db"; +import { eq, and, sql, inArray } from "drizzle-orm"; +import logger from "@server/logger"; +import { applyBlueprint } from "@server/lib/blueprints/applyBlueprint"; + +export const handleApplyBlueprintMessage: MessageHandler = async (context) => { + const { message, client, sendToClient } = context; + const newt = client as Newt; + + logger.debug("Handling apply blueprint message!"); + + if (!newt) { + logger.warn("Newt not found"); + return; + } + + if (!newt.siteId) { + logger.warn("Newt has no site!"); // TODO: Maybe we create the site here? + return; + } + + // get the site + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, newt.siteId)); + + if (!site) { + logger.warn("Site not found for newt"); + return; + } + + const { blueprint } = message.data; + if (!blueprint) { + logger.warn("No blueprint provided"); + return; + } + + logger.debug(`Received blueprint: ${blueprint}`); + + try { + const blueprintParsed = JSON.parse(blueprint); + // Update the blueprint in the database + await applyBlueprint(site.orgId, blueprintParsed, site.siteId); + } catch (error) { + logger.error(`Failed to update database from config: ${error}`); + return { + message: { + type: "newt/blueprint/results", + data: { + success: false, + message: `Failed to update database from config: ${error}` + } + }, + broadcast: false, // Send to all clients + excludeSender: false // Include sender in broadcast + }; + } + + return { + message: { + type: "newt/blueprint/results", + data: { + success: true, + message: "Config updated successfully" + } + }, + broadcast: false, // Send to all clients + excludeSender: false // Include sender in broadcast + }; +}; diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 3c7ecaff..eef78765 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -10,6 +10,7 @@ import { getNextAvailableClientSubnet } from "@server/lib/ip"; import { selectBestExitNode, verifyExitNodeOrgAccess } from "@server/lib/exitNodes"; +import { fetchContainers } from "./dockerSocket"; export type ExitNodePingResult = { exitNodeId: number; @@ -76,6 +77,15 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { return; } + logger.debug(`Docker socket enabled: ${oldSite.dockerSocketEnabled}`); + + if (oldSite.dockerSocketEnabled) { + logger.debug( + "Site has docker socket enabled - requesting docker containers" + ); + fetchContainers(newt.newtId); + } + let siteSubnet = oldSite.subnet; let exitNodeIdToQuery = oldSite.exitNodeId; if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) { diff --git a/server/routers/newt/handleSocketMessages.ts b/server/routers/newt/handleSocketMessages.ts index 01b7be60..aceca37d 100644 --- a/server/routers/newt/handleSocketMessages.ts +++ b/server/routers/newt/handleSocketMessages.ts @@ -2,6 +2,7 @@ import { MessageHandler } from "../ws"; import logger from "@server/logger"; import { dockerSocketCache } from "./dockerSocket"; import { Newt } from "@server/db"; +import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint"; export const handleDockerStatusMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; @@ -57,4 +58,15 @@ export const handleDockerContainersMessage: MessageHandler = async ( } else { logger.warn(`Newt ${newt.newtId} does not have Docker containers`); } + + if (!newt.siteId) { + logger.warn("Newt has no site!"); + return; + } + + await applyNewtDockerBlueprint( + newt.siteId, + newt.newtId, + containers + ); }; diff --git a/server/routers/newt/index.ts b/server/routers/newt/index.ts index 08f047e3..9642a637 100644 --- a/server/routers/newt/index.ts +++ b/server/routers/newt/index.ts @@ -4,4 +4,5 @@ export * from "./handleNewtRegisterMessage"; export * from "./handleReceiveBandwidthMessage"; export * from "./handleGetConfigMessage"; export * from "./handleSocketMessages"; -export * from "./handleNewtPingRequestMessage"; \ No newline at end of file +export * from "./handleNewtPingRequestMessage"; +export * from "./handleApplyBlueprintMessage"; \ No newline at end of file diff --git a/server/routers/org/applyBlueprint.ts b/server/routers/org/applyBlueprint.ts new file mode 100644 index 00000000..982258ee --- /dev/null +++ b/server/routers/org/applyBlueprint.ts @@ -0,0 +1,127 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { eq } from "drizzle-orm"; +import { + apiKeyOrg, + apiKeys, + domains, + Org, + orgDomains, + orgs, + roleActions, + roles, + userOrgs, + users, + actions +} 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 config from "@server/lib/config"; +import { fromError } from "zod-validation-error"; +import { defaultRoleAllowedActions } from "../role"; +import { OpenAPITags, registry } from "@server/openApi"; +import { isValidCIDR } from "@server/lib/validators"; +import { applyBlueprint as applyBlueprintFunc } from "@server/lib/blueprints/applyBlueprint"; + +const applyBlueprintSchema = z + .object({ + blueprint: z.string() + }) + .strict(); + +const applyBlueprintParamsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/blueprint", + description: "Apply a base64 encoded blueprint to an organization", + tags: [OpenAPITags.Org], + request: { + params: applyBlueprintParamsSchema, + body: { + content: { + "application/json": { + schema: applyBlueprintSchema + } + } + } + }, + responses: {} +}); + +export async function applyBlueprint( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = applyBlueprintParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const parsedBody = applyBlueprintSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { blueprint } = parsedBody.data; + + if (!blueprint) { + logger.warn("No blueprint provided"); + return; + } + + logger.debug(`Received blueprint: ${blueprint}`); + + try { + // first base64 decode the blueprint + const decoded = Buffer.from(blueprint, "base64").toString("utf-8"); + // then parse the json + const blueprintParsed = JSON.parse(decoded); + + // Update the blueprint in the database + await applyBlueprintFunc(orgId, blueprintParsed); + } catch (error) { + logger.error(`Failed to update database from config: ${error}`); + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Failed to update database from config: ${error}` + ) + ); + } + + return response(res, { + data: null, + success: true, + error: false, + message: "Blueprint applied successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/org/index.ts b/server/routers/org/index.ts index c9a44d8d..754def66 100644 --- a/server/routers/org/index.ts +++ b/server/routers/org/index.ts @@ -7,3 +7,4 @@ export * from "./checkId"; export * from "./getOrgOverview"; export * from "./listOrgs"; export * from "./pickOrgDefaults"; +export * from "./applyBlueprint"; \ No newline at end of file diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index e3e431ec..c5f30f83 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -21,6 +21,8 @@ import { subdomainSchema } from "@server/lib/schemas"; import config from "@server/lib/config"; import { OpenAPITags, registry } from "@server/openApi"; import { build } from "@server/build"; +import { getUniqueResourceName } from "@server/db/names"; +import { validateAndConstructDomain } from "@server/lib/domainUtils"; const createResourceParamsSchema = z .object({ @@ -193,76 +195,21 @@ async function createHttpResource( } const { name, domainId } = parsedBody.data; - let subdomain = parsedBody.data.subdomain; + const subdomain = parsedBody.data.subdomain; - const [domainRes] = await db - .select() - .from(domains) - .where(eq(domains.domainId, domainId)) - .leftJoin( - orgDomains, - and(eq(orgDomains.orgId, orgId), eq(orgDomains.domainId, domainId)) - ); - - if (!domainRes || !domainRes.domains) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Domain with ID ${domainId} not found` - ) - ); - } - - if (domainRes.orgDomains && domainRes.orgDomains.orgId !== orgId) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - `Organization does not have access to domain with ID ${domainId}` - ) - ); - } - - if (!domainRes.domains.verified) { + // Validate domain and construct full domain + const domainResult = await validateAndConstructDomain(domainId, orgId, subdomain); + + if (!domainResult.success) { return next( createHttpError( HttpCode.BAD_REQUEST, - `Domain with ID ${domainRes.domains.domainId} is not verified` + domainResult.error ) ); } - let fullDomain = ""; - if (domainRes.domains.type == "ns") { - if (subdomain) { - fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`; - } else { - fullDomain = domainRes.domains.baseDomain; - } - } else if (domainRes.domains.type == "cname") { - fullDomain = domainRes.domains.baseDomain; - } else if (domainRes.domains.type == "wildcard") { - if (subdomain) { - // the subdomain cant have a dot in it - const parsedSubdomain = subdomainSchema.safeParse(subdomain); - if (!parsedSubdomain.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedSubdomain.error).toString() - ) - ); - } - fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`; - } else { - fullDomain = domainRes.domains.baseDomain; - } - } - - if (fullDomain === domainRes.domains.baseDomain) { - subdomain = null; - } - - fullDomain = fullDomain.toLowerCase(); + const { fullDomain, subdomain: finalSubdomain } = domainResult; logger.debug(`Full domain: ${fullDomain}`); @@ -283,15 +230,18 @@ async function createHttpResource( let resource: Resource | undefined; + const niceId = await getUniqueResourceName(orgId); + await db.transaction(async (trx) => { const newResource = await trx .insert(resources) .values({ + niceId, fullDomain, domainId, orgId, name, - subdomain, + subdomain: finalSubdomain, http: true, protocol: "tcp", ssl: true @@ -391,10 +341,13 @@ async function createRawResource( let resource: Resource | undefined; + const niceId = await getUniqueResourceName(orgId); + await db.transaction(async (trx) => { const newResource = await trx .insert(resources) .values({ + niceId, orgId, name, http, diff --git a/server/routers/resource/getResource.ts b/server/routers/resource/getResource.ts index a2c1c0d1..d2aebedd 100644 --- a/server/routers/resource/getResource.ts +++ b/server/routers/resource/getResource.ts @@ -2,32 +2,72 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { Resource, resources, sites } from "@server/db"; -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; +import stoi from "@server/lib/stoi"; import { OpenAPITags, registry } from "@server/openApi"; const getResourceSchema = z .object({ resourceId: z .string() - .transform(Number) - .pipe(z.number().int().positive()) + .optional() + .transform(stoi) + .pipe(z.number().int().positive().optional()) + .optional(), + niceId: z.string().optional(), + orgId: z.string().optional() }) .strict(); -export type GetResourceResponse = Resource; +async function query(resourceId?: number, niceId?: string, orgId?: string) { + if (resourceId) { + const [res] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + return res; + } else if (niceId && orgId) { + const [res] = await db + .select() + .from(resources) + .where(and(eq(resources.niceId, niceId), eq(resources.orgId, orgId))) + .limit(1); + return res; + } +} + +export type GetResourceResponse = NonNullable>>; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/resource/{niceId}", + description: + "Get a resource by orgId and niceId. NiceId is a readable ID for the resource and unique on a per org basis.", + tags: [OpenAPITags.Org, OpenAPITags.Resource], + request: { + params: z.object({ + orgId: z.string(), + niceId: z.string() + }) + }, + responses: {} +}); registry.registerPath({ method: "get", path: "/resource/{resourceId}", - description: "Get a resource.", + description: "Get a resource by resourceId.", tags: [OpenAPITags.Resource], request: { - params: getResourceSchema + params: z.object({ + resourceId: z.number() + }) }, responses: {} }); @@ -48,29 +88,18 @@ export async function getResource( ); } - const { resourceId } = parsedParams.data; + const { resourceId, niceId, orgId } = parsedParams.data; - const [resp] = await db - .select() - .from(resources) - .where(eq(resources.resourceId, resourceId)) - .limit(1); - - const resource = resp; + const resource = await query(resourceId, niceId, orgId); if (!resource) { return next( - createHttpError( - HttpCode.NOT_FOUND, - `Resource with ID ${resourceId} not found` - ) + createHttpError(HttpCode.NOT_FOUND, "Resource not found") ); } - return response(res, { - data: { - ...resource - }, + return response(res, { + data: resource, success: true, error: false, message: "Resource retrieved successfully", diff --git a/server/routers/resource/getResourceAuthInfo.ts b/server/routers/resource/getResourceAuthInfo.ts index 191221f1..c775564b 100644 --- a/server/routers/resource/getResourceAuthInfo.ts +++ b/server/routers/resource/getResourceAuthInfo.ts @@ -32,6 +32,7 @@ export type GetResourceAuthInfoResponse = { url: string; whitelist: boolean; skipToIdpId: number | null; + orgId: string; }; export async function getResourceAuthInfo( @@ -88,7 +89,8 @@ export async function getResourceAuthInfo( blockAccess: resource.blockAccess, url, whitelist: resource.emailWhitelistEnabled, - skipToIdpId: resource.skipToIdpId + skipToIdpId: resource.skipToIdpId, + orgId: resource.orgId }, success: true, error: false, diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 43757b27..27605be6 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -16,6 +16,7 @@ import logger from "@server/logger"; import stoi from "@server/lib/stoi"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { warn } from "console"; const listResourcesParamsSchema = z .object({ @@ -54,7 +55,8 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { protocol: resources.protocol, proxyPort: resources.proxyPort, enabled: resources.enabled, - domainId: resources.domainId + domainId: resources.domainId, + niceId: resources.niceId }) .from(resources) .leftJoin( diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 30acc0c1..7c0f9c63 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -20,6 +20,8 @@ import { tlsNameSchema } from "@server/lib/schemas"; import { subdomainSchema } from "@server/lib/schemas"; import { registry } from "@server/openApi"; import { OpenAPITags } from "@server/openApi"; +import { validateAndConstructDomain } from "@server/lib/domainUtils"; +import { validateHeaders } from "@server/lib/validators"; const updateResourceParamsSchema = z .object({ @@ -44,7 +46,8 @@ const updateHttpResourceBodySchema = z stickySession: z.boolean().optional(), tlsServerName: z.string().nullable().optional(), setHostHeader: z.string().nullable().optional(), - skipToIdpId: z.number().int().positive().nullable().optional() + skipToIdpId: z.number().int().positive().nullable().optional(), + headers: z.string().nullable().optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -82,6 +85,18 @@ const updateHttpResourceBodySchema = z message: "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header." } + ) + .refine( + (data) => { + if (data.headers) { + return validateHeaders(data.headers); + } + return true; + }, + { + message: + "Invalid headers format. Use comma-separated format: 'Header-Name: value, Another-Header: another-value'. Header values cannot contain colons." + } ); export type UpdateResourceResponse = Resource; @@ -230,78 +245,19 @@ async function updateHttpResource( if (updateData.domainId) { const domainId = updateData.domainId; - const [domainRes] = await db - .select() - .from(domains) - .where(eq(domains.domainId, domainId)) - .leftJoin( - orgDomains, - and( - eq(orgDomains.orgId, resource.orgId), - eq(orgDomains.domainId, domainId) - ) - ); - - if (!domainRes || !domainRes.domains) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Domain with ID ${updateData.domainId} not found` - ) - ); - } - - if ( - domainRes.orgDomains && - domainRes.orgDomains.orgId !== resource.orgId - ) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - `You do not have permission to use domain with ID ${updateData.domainId}` - ) - ); - } - - if (!domainRes.domains.verified) { + // Validate domain and construct full domain + const domainResult = await validateAndConstructDomain(domainId, resource.orgId, updateData.subdomain); + + if (!domainResult.success) { return next( createHttpError( HttpCode.BAD_REQUEST, - `Domain with ID ${updateData.domainId} is not verified` + domainResult.error ) ); } - let fullDomain = ""; - if (domainRes.domains.type == "ns") { - if (updateData.subdomain) { - fullDomain = `${updateData.subdomain}.${domainRes.domains.baseDomain}`; - } else { - fullDomain = domainRes.domains.baseDomain; - } - } else if (domainRes.domains.type == "cname") { - fullDomain = domainRes.domains.baseDomain; - } else if (domainRes.domains.type == "wildcard") { - if (updateData.subdomain !== undefined) { - // the subdomain cant have a dot in it - const parsedSubdomain = subdomainSchema.safeParse( - updateData.subdomain - ); - if (!parsedSubdomain.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedSubdomain.error).toString() - ) - ); - } - fullDomain = `${updateData.subdomain}.${domainRes.domains.baseDomain}`; - } else { - fullDomain = domainRes.domains.baseDomain; - } - } - - fullDomain = fullDomain.toLowerCase(); + const { fullDomain, subdomain: finalSubdomain } = domainResult; logger.debug(`Full domain: ${fullDomain}`); @@ -332,9 +288,8 @@ async function updateHttpResource( .where(eq(resources.resourceId, resource.resourceId)); } - if (fullDomain === domainRes.domains.baseDomain) { - updateData.subdomain = null; - } + // Update the subdomain in the update data + updateData.subdomain = finalSubdomain; } const updatedResource = await db diff --git a/server/routers/site/pickSiteDefaults.ts b/server/routers/site/pickSiteDefaults.ts index 2e705c56..58d44744 100644 --- a/server/routers/site/pickSiteDefaults.ts +++ b/server/routers/site/pickSiteDefaults.ts @@ -139,7 +139,7 @@ export async function pickSiteDefaults( }, success: true, error: false, - message: "Organization retrieved successfully", + message: "Site defaults chosen successfully", status: HttpCode.OK }); } catch (error) { diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index da41c19c..ca223b04 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -10,6 +10,7 @@ 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"; const createSiteResourceParamsSchema = z .object({ @@ -121,11 +122,14 @@ export async function createSiteResource( ); } + const niceId = await getUniqueSiteResourceName(orgId); + // Create the site resource const [newSiteResource] = await db .insert(siteResources) .values({ siteId, + niceId, orgId, name, protocol, diff --git a/server/routers/siteResource/getSiteResource.ts b/server/routers/siteResource/getSiteResource.ts index 914706cd..09c01eb0 100644 --- a/server/routers/siteResource/getSiteResource.ts +++ b/server/routers/siteResource/getSiteResource.ts @@ -12,21 +12,72 @@ import { OpenAPITags, registry } from "@server/openApi"; const getSiteResourceParamsSchema = z .object({ - siteResourceId: z.string().transform(Number).pipe(z.number().int().positive()), + siteResourceId: z + .string() + .optional() + .transform((val) => val ? Number(val) : undefined) + .pipe(z.number().int().positive().optional()) + .optional(), siteId: z.string().transform(Number).pipe(z.number().int().positive()), + niceId: z.string().optional(), orgId: z.string() }) .strict(); -export type GetSiteResourceResponse = SiteResource; +async function query(siteResourceId?: number, siteId?: number, niceId?: string, orgId?: string) { + if (siteResourceId && siteId && orgId) { + const [siteResource] = await db + .select() + .from(siteResources) + .where(and( + eq(siteResources.siteResourceId, siteResourceId), + eq(siteResources.siteId, siteId), + eq(siteResources.orgId, orgId) + )) + .limit(1); + return siteResource; + } else if (niceId && siteId && orgId) { + const [siteResource] = await db + .select() + .from(siteResources) + .where(and( + eq(siteResources.niceId, niceId), + eq(siteResources.siteId, siteId), + eq(siteResources.orgId, orgId) + )) + .limit(1); + return siteResource; + } +} + +export type GetSiteResourceResponse = NonNullable>>; registry.registerPath({ method: "get", path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}", - description: "Get a specific site resource.", + description: "Get a specific site resource by siteResourceId.", tags: [OpenAPITags.Client, OpenAPITags.Org], request: { - params: getSiteResourceParamsSchema + params: z.object({ + siteResourceId: z.number(), + siteId: z.number(), + orgId: z.string() + }) + }, + responses: {} +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/site/{siteId}/resource/nice/{niceId}", + description: "Get a specific site resource by niceId.", + tags: [OpenAPITags.Client, OpenAPITags.Org], + request: { + params: z.object({ + niceId: z.string(), + siteId: z.number(), + orgId: z.string() + }) }, responses: {} }); @@ -47,18 +98,10 @@ export async function getSiteResource( ); } - const { siteResourceId, siteId, orgId } = parsedParams.data; + const { siteResourceId, siteId, niceId, orgId } = parsedParams.data; // Get the site resource - const [siteResource] = await db - .select() - .from(siteResources) - .where(and( - eq(siteResources.siteResourceId, siteResourceId), - eq(siteResources.siteId, siteId), - eq(siteResources.orgId, orgId) - )) - .limit(1); + const siteResource = await query(siteResourceId, siteId, niceId, orgId); if (!siteResource) { return next( diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 82e2fe68..f6f71124 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -28,7 +28,7 @@ const updateSiteResourceSchema = z protocol: z.enum(["tcp", "udp"]).optional(), proxyPort: z.number().int().positive().optional(), destinationPort: z.number().int().positive().optional(), - destinationIp: z.string().ip().optional(), + destinationIp: z.string().optional(), enabled: z.boolean().optional() }) .strict(); diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 7a3acd55..fb85f566 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -30,7 +30,9 @@ const createTargetSchema = z ip: z.string().refine(isTargetValid), method: z.string().optional().nullable(), port: z.number().int().min(1).max(65535), - enabled: z.boolean().default(true) + enabled: z.boolean().default(true), + path: z.string().optional().nullable(), + pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable() }) .strict(); @@ -161,7 +163,7 @@ export async function createTarget( ); } - const { internalPort, targetIps } = await pickPort(site.siteId!); + const { internalPort, targetIps } = await pickPort(site.siteId!, db); if (!internalPort) { return next( diff --git a/server/routers/target/helpers.ts b/server/routers/target/helpers.ts index 4935d28a..13b2ee46 100644 --- a/server/routers/target/helpers.ts +++ b/server/routers/target/helpers.ts @@ -1,10 +1,10 @@ -import { db } from "@server/db"; +import { db, Transaction } from "@server/db"; import { resources, targets } from "@server/db"; import { eq } from "drizzle-orm"; const currentBannedPorts: number[] = []; -export async function pickPort(siteId: number): Promise<{ +export async function pickPort(siteId: number, trx: Transaction | typeof db): Promise<{ internalPort: number; targetIps: string[]; }> { @@ -12,7 +12,7 @@ export async function pickPort(siteId: number): Promise<{ const targetIps: string[] = []; const targetInternalPorts: number[] = []; - const targetsRes = await db + const targetsRes = await trx .select() .from(targets) .where(eq(targets.siteId, siteId)); diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index eab8f1c8..ca1159d2 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -44,7 +44,9 @@ function queryTargets(resourceId: number) { enabled: targets.enabled, resourceId: targets.resourceId, siteId: targets.siteId, - siteType: sites.type + siteType: sites.type, + path: targets.path, + pathMatchType: targets.pathMatchType }) .from(targets) .leftJoin(sites, eq(sites.siteId, targets.siteId)) diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 67d9a8df..928a1a55 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -26,7 +26,9 @@ const updateTargetBodySchema = z ip: z.string().refine(isTargetValid), method: z.string().min(1).max(10).optional().nullable(), port: z.number().int().min(1).max(65535).optional(), - enabled: z.boolean().optional() + enabled: z.boolean().optional(), + path: z.string().optional().nullable(), + pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -153,7 +155,7 @@ export async function updateTarget( ); } - const { internalPort, targetIps } = await pickPort(site.siteId!); + const { internalPort, targetIps } = await pickPort(site.siteId!, db); if (!internalPort) { return next( diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 1a55f2bd..ac24ba7f 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -54,7 +54,8 @@ export async function traefikConfigProvider( config.getRawConfig().traefik.site_types ); - if (traefikConfig?.http?.middlewares) { // BECAUSE SOMETIMES THE CONFIG CAN BE EMPTY IF THERE IS NOTHING + if (traefikConfig?.http?.middlewares) { + // BECAUSE SOMETIMES THE CONFIG CAN BE EMPTY IF THERE IS NOTHING traefikConfig.http.middlewares[badgerMiddlewareName] = { plugin: { [badgerMiddlewareName]: { @@ -104,11 +105,9 @@ export async function getTraefikConfig( }; }; - // Get all resources with related data - const allResources = await db.transaction(async (tx) => { // Get resources with their targets and sites in a single optimized query // Start from sites on this exit node, then join to targets and resources - const resourcesWithTargetsAndSites = await tx + const resourcesWithTargetsAndSites = await db .select({ // Resource fields resourceId: resources.resourceId, @@ -124,6 +123,7 @@ export async function getTraefikConfig( tlsServerName: resources.tlsServerName, setHostHeader: resources.setHostHeader, enableProxy: resources.enableProxy, + headers: resources.headers, // Target fields targetId: targets.targetId, targetEnabled: targets.enabled, @@ -131,6 +131,9 @@ export async function getTraefikConfig( method: targets.method, port: targets.port, internalPort: targets.internalPort, + path: targets.path, + pathMatchType: targets.pathMatchType, + // Site fields siteId: sites.siteId, siteType: sites.type, @@ -152,7 +155,7 @@ export async function getTraefikConfig( inArray(sites.type, siteTypes), config.getRawConfig().traefik.allow_raw_resources ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true - : eq(resources.http, true), + : eq(resources.http, true) ) ); @@ -161,9 +164,15 @@ export async function getTraefikConfig( resourcesWithTargetsAndSites.forEach((row) => { const resourceId = row.resourceId; + const targetPath = sanitizePath(row.path) || ""; // Handle null/undefined paths + const pathMatchType = row.pathMatchType || ""; - if (!resourcesMap.has(resourceId)) { - resourcesMap.set(resourceId, { + // Create a unique key combining resourceId and path+pathMatchType + const pathKey = [targetPath, pathMatchType].filter(Boolean).join("-"); + const mapKey = [resourceId, pathKey].filter(Boolean).join("-"); + + if (!resourcesMap.has(mapKey)) { + resourcesMap.set(mapKey, { resourceId: row.resourceId, fullDomain: row.fullDomain, ssl: row.ssl, @@ -177,12 +186,15 @@ export async function getTraefikConfig( tlsServerName: row.tlsServerName, setHostHeader: row.setHostHeader, enableProxy: row.enableProxy, - targets: [] + targets: [], + headers: row.headers, + path: row.path, // the targets will all have the same path + pathMatchType: row.pathMatchType // the targets will all have the same pathMatchType }); } // Add target with its associated site data - resourcesMap.get(resourceId).targets.push({ + resourcesMap.get(mapKey).targets.push({ resourceId: row.resourceId, targetId: row.targetId, ip: row.ip, @@ -200,10 +212,13 @@ export async function getTraefikConfig( }); }); - return Array.from(resourcesMap.values()); - }); + + // convert the map to an object for printing - if (!allResources.length) { + logger.debug(`Resources: ${JSON.stringify(Object.fromEntries(resourcesMap), null, 2)}`); + + // make sure we have at least one resource + if (resourcesMap.size === 0) { return {}; } @@ -219,14 +234,15 @@ export async function getTraefikConfig( } }; - for (const resource of allResources) { + // get the key and the resource + for (const [key, resource] of resourcesMap.entries()) { const targets = resource.targets; - const routerName = `${resource.resourceId}-router`; - const serviceName = `${resource.resourceId}-service`; + const routerName = `${key}-router`; + const serviceName = `${key}-service`; const fullDomain = `${resource.fullDomain}`; - const transportName = `${resource.resourceId}-transport`; - const hostHeaderMiddlewareName = `${resource.resourceId}-host-header-middleware`; + const transportName = `${key}-transport`; + const headersMiddlewareName = `${key}-headers-middleware`; if (!resource.enabled) { continue; @@ -238,9 +254,6 @@ export async function getTraefikConfig( } if (!resource.fullDomain) { - logger.error( - `Resource ${resource.resourceId} has no fullDomain` - ); continue; } @@ -296,16 +309,68 @@ export async function getTraefikConfig( const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; + const routerMiddlewares = [ + badgerMiddlewareName, + ...additionalMiddlewares + ]; + + if (resource.headers && resource.headers.length > 0) { + // if there are headers, parse them into an object + const headersObj: { [key: string]: string } = {}; + const headersArr = resource.headers.split(","); + for (const header of headersArr) { + const [key, value] = header + .split(":") + .map((s: string) => s.trim()); + if (key && value) { + headersObj[key] = value; + } + } + + if (resource.setHostHeader) { + headersObj["Host"] = resource.setHostHeader; + } + + // check if the object is not empty + if (Object.keys(headersObj).length > 0) { + // Add the headers middleware + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; + } + config_output.http.middlewares[headersMiddlewareName] = { + headers: { + customRequestHeaders: headersObj + } + }; + + routerMiddlewares.push(headersMiddlewareName); + } + } + + let rule = `Host(\`${fullDomain}\`)`; + let priority = 100; + if (resource.path && resource.pathMatchType) { + priority += 1; + // add path to rule based on match type + if (resource.pathMatchType === "exact") { + rule += ` && Path(\`${resource.path}\`)`; + } else if (resource.pathMatchType === "prefix") { + rule += ` && PathPrefix(\`${resource.path}\`)`; + } else if (resource.pathMatchType === "regex") { + rule += ` && PathRegexp(\`${resource.path}\`)`; + } + } + config_output.http.routers![routerName] = { entryPoints: [ resource.ssl ? config.getRawConfig().traefik.https_entrypoint : config.getRawConfig().traefik.http_entrypoint ], - middlewares: [badgerMiddlewareName, ...additionalMiddlewares], + middlewares: routerMiddlewares, service: serviceName, - rule: `Host(\`${fullDomain}\`)`, - priority: 100, + rule: rule, + priority: priority, ...(resource.ssl ? { tls } : {}) }; @@ -316,8 +381,8 @@ export async function getTraefikConfig( ], middlewares: [redirectHttpsMiddlewareName], service: serviceName, - rule: `Host(\`${fullDomain}\`)`, - priority: 100 + rule: rule, + priority: priority }; } @@ -413,27 +478,6 @@ export async function getTraefikConfig( serviceName ].loadBalancer.serversTransport = transportName; } - - // Add the host header middleware - if (resource.setHostHeader) { - if (!config_output.http.middlewares) { - config_output.http.middlewares = {}; - } - config_output.http.middlewares[hostHeaderMiddlewareName] = { - headers: { - customRequestHeaders: { - Host: resource.setHostHeader - } - } - }; - if (!config_output.http.routers![routerName].middlewares) { - config_output.http.routers![routerName].middlewares = []; - } - config_output.http.routers![routerName].middlewares = [ - ...config_output.http.routers![routerName].middlewares, - hostHeaderMiddlewareName - ]; - } } else { // Non-HTTP (TCP/UDP) configuration if (!resource.enableProxy) { @@ -529,3 +573,13 @@ export async function getTraefikConfig( } return config_output; } + +function sanitizePath(path: string | null | undefined): string | undefined { + if (!path) return undefined; + // clean any non alphanumeric characters from the path and replace with dashes + // the path cant be too long either, so limit to 50 characters + if (path.length > 50) { + path = path.substring(0, 50); + } + return path.replace(/[^a-zA-Z0-9]/g, ""); +} \ No newline at end of file diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index 4419772a..5b11c923 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -84,7 +84,14 @@ 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; const [role] = await db .select() @@ -141,7 +148,12 @@ export async function createOrgUser( const [existingUser] = await trx .select() .from(users) - .where(eq(users.username, username)); + .where( + and( + eq(users.username, username), + eq(users.idpId, idpId) + ) + ); if (existingUser) { const [existingOrgUser] = await trx @@ -168,7 +180,8 @@ export async function createOrgUser( .values({ orgId, userId: existingUser.userId, - roleId: role.roleId + roleId: role.roleId, + autoProvisioned: false }) .returning(); } else { @@ -184,7 +197,7 @@ export async function createOrgUser( type: "oidc", idpId, dateCreated: new Date().toISOString(), - emailVerified: true + emailVerified: true, }) .returning(); @@ -193,7 +206,8 @@ export async function createOrgUser( .values({ orgId, userId: newUser.userId, - roleId: role.roleId + roleId: role.roleId, + autoProvisioned: false }) .returning(); } @@ -204,7 +218,6 @@ export async function createOrgUser( .from(userOrgs) .where(eq(userOrgs.orgId, orgId)); }); - } else { return next( createHttpError(HttpCode.BAD_REQUEST, "User type is required") diff --git a/server/routers/user/getOrgUser.ts b/server/routers/user/getOrgUser.ts index 05e231c9..02ffd92c 100644 --- a/server/routers/user/getOrgUser.ts +++ b/server/routers/user/getOrgUser.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, idp, idpOidcConfig } from "@server/db"; import { roles, userOrgs, users } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; @@ -25,10 +25,18 @@ async function queryUser(orgId: string, userId: string) { isOwner: userOrgs.isOwner, isAdmin: roles.isAdmin, twoFactorEnabled: users.twoFactorEnabled, + autoProvisioned: userOrgs.autoProvisioned, + idpId: users.idpId, + idpName: idp.name, + idpType: idp.type, + idpVariant: idpOidcConfig.variant, + idpAutoProvision: idp.autoProvision }) .from(userOrgs) .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(users, eq(userOrgs.userId, users.userId)) + .leftJoin(idp, eq(users.idpId, idp.idpId)) + .leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .limit(1); return user; diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 6d342ad3..7148eb87 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -13,3 +13,4 @@ export * from "./removeInvitation"; export * from "./createOrgUser"; export * from "./adminUpdateUser2FA"; export * from "./adminGetUser"; +export * from "./updateOrgUser"; diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts index 83c1e492..a35da862 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, idpOidcConfig } from "@server/db"; import { idp, roles, userOrgs, users } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -50,12 +50,15 @@ async function queryUsers(orgId: string, limit: number, offset: number) { isOwner: userOrgs.isOwner, idpName: idp.name, idpId: users.idpId, + idpType: idp.type, + idpVariant: idpOidcConfig.variant, twoFactorEnabled: users.twoFactorEnabled, }) .from(users) .leftJoin(userOrgs, eq(users.userId, userOrgs.userId)) .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) + .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) .where(eq(userOrgs.orgId, orgId)) .limit(limit) .offset(offset); diff --git a/server/routers/user/updateOrgUser.ts b/server/routers/user/updateOrgUser.ts new file mode 100644 index 00000000..fb00b59f --- /dev/null +++ b/server/routers/user/updateOrgUser.ts @@ -0,0 +1,112 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, userOrgs } 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 { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z + .object({ + userId: z.string(), + orgId: z.string() + }) + .strict(); + +const bodySchema = z + .object({ + autoProvisioned: z.boolean().optional() + }) + .strict() + .refine((data) => Object.keys(data).length > 0, { + message: "At least one field must be provided for update" + }); + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/user/{userId}", + description: "Update a user in an org.", + tags: [OpenAPITags.Org, OpenAPITags.User], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function updateOrgUser( + 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 parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { userId, orgId } = parsedParams.data; + + const [existingUser] = await db + .select() + .from(userOrgs) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .limit(1); + + if (!existingUser) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found in this organization" + ) + ); + } + + const updateData = parsedBody.data; + + const [updatedUser] = await db + .update(userOrgs) + .set({ + ...updateData + }) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .returning(); + + return response(res, { + data: updatedUser, + success: true, + error: false, + message: "Org user updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts index a30daf43..8ca33b8a 100644 --- a/server/routers/ws/messageHandlers.ts +++ b/server/routers/ws/messageHandlers.ts @@ -4,7 +4,8 @@ import { handleGetConfigMessage, handleDockerStatusMessage, handleDockerContainersMessage, - handleNewtPingRequestMessage + handleNewtPingRequestMessage, + handleApplyBlueprintMessage } from "../newt"; import { handleOlmRegisterMessage, @@ -23,7 +24,8 @@ export const messageHandlers: Record = { "olm/ping": handleOlmPingMessage, "newt/socket/status": handleDockerStatusMessage, "newt/socket/containers": handleDockerContainersMessage, - "newt/ping/request": handleNewtPingRequestMessage + "newt/ping/request": handleNewtPingRequestMessage, + "newt/blueprint/apply": handleApplyBlueprintMessage, }; startOlmOfflineChecker(); // this is to handle the offline check for olms diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index 6b3f20b9..c5950e1d 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -9,6 +9,7 @@ import m1 from "./scriptsPg/1.6.0"; import m2 from "./scriptsPg/1.7.0"; import m3 from "./scriptsPg/1.8.0"; import m4 from "./scriptsPg/1.9.0"; +import m5 from "./scriptsPg/1.10.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -18,7 +19,8 @@ const migrations = [ { version: "1.6.0", run: m1 }, { version: "1.7.0", run: m2 }, { version: "1.8.0", run: m3 }, - { version: "1.9.0", run: m4 } + { version: "1.9.0", run: m4 }, + { version: "1.10.0", run: m5 }, // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 5b0850c8..79a7d0ab 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -26,6 +26,7 @@ import m21 from "./scriptsSqlite/1.6.0"; import m22 from "./scriptsSqlite/1.7.0"; import m23 from "./scriptsSqlite/1.8.0"; import m24 from "./scriptsSqlite/1.9.0"; +import m25 from "./scriptsSqlite/1.10.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -51,6 +52,7 @@ const migrations = [ { version: "1.7.0", run: m22 }, { version: "1.8.0", run: m23 }, { version: "1.9.0", run: m24 }, + { version: "1.10.0", run: m25 }, // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.10.0.ts b/server/setup/scriptsPg/1.10.0.ts new file mode 100644 index 00000000..d566cccb --- /dev/null +++ b/server/setup/scriptsPg/1.10.0.ts @@ -0,0 +1,128 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; +import { __DIRNAME, APP_PATH } from "@server/lib/consts"; +import { readFileSync } from "fs"; +import path, { join } from "path"; + +const version = "1.10.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + const resources = await db.execute(sql` + SELECT "resourceId" FROM "resources" + `); + + const siteResources = await db.execute(sql` + SELECT "siteResourceId" FROM "siteResources" + `); + + await db.execute(sql`BEGIN`); + + await db.execute( + sql`ALTER TABLE "exitNodes" ADD COLUMN "region" text;` + ); + + await db.execute( + sql`ALTER TABLE "idpOidcConfig" ADD COLUMN "variant" text DEFAULT 'oidc' NOT NULL;` + ); + + await db.execute( + sql`ALTER TABLE "resources" ADD COLUMN "niceId" text DEFAULT '' NOT NULL;` + ); + + await db.execute( + sql`ALTER TABLE "siteResources" ADD COLUMN "niceId" text DEFAULT '' NOT NULL;` + ); + + await db.execute( + sql`ALTER TABLE "userOrgs" ADD COLUMN "autoProvisioned" boolean DEFAULT false;` + ); + + await db.execute( + sql`ALTER TABLE "targets" ADD COLUMN "pathMatchType" text;` + ); + + await db.execute(sql`ALTER TABLE "targets" ADD COLUMN "path" text;`); + + await db.execute( + sql`ALTER TABLE "resources" ADD COLUMN "headers" text;` + ); + + const usedNiceIds: string[] = []; + + for (const resource of resources.rows) { + // Generate a unique name and ensure it's unique + let niceId = ""; + let loops = 0; + while (true) { + if (loops > 100) { + throw new Error("Could not generate a unique name"); + } + + niceId = generateName(); + if (!usedNiceIds.includes(niceId)) { + usedNiceIds.push(niceId); + break; + } + loops++; + } + await db.execute(sql` + UPDATE "resources" SET "niceId" = ${niceId} WHERE "resourceId" = ${resource.resourceId} + `); + } + + for (const resource of siteResources.rows) { + // Generate a unique name and ensure it's unique + let niceId = ""; + let loops = 0; + while (true) { + if (loops > 100) { + throw new Error("Could not generate a unique name"); + } + + niceId = generateName(); + if (!usedNiceIds.includes(niceId)) { + usedNiceIds.push(niceId); + break; + } + loops++; + } + await db.execute(sql` + UPDATE "siteResources" SET "niceId" = ${niceId} WHERE "siteResourceId" = ${resource.siteResourceId} + `); + } + + await db.execute(sql`COMMIT`); + console.log(`Migrated database`); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Failed to migrate db:", e); + throw e; + } +} + +const dev = process.env.ENVIRONMENT !== "prod"; +let file; +if (!dev) { + file = join(__DIRNAME, "names.json"); +} else { + file = join("server/db/names.json"); +} +export const names = JSON.parse(readFileSync(file, "utf-8")); + +export function generateName(): string { + const name = ( + names.descriptors[ + Math.floor(Math.random() * names.descriptors.length) + ] + + "-" + + names.animals[Math.floor(Math.random() * names.animals.length)] + ) + .toLowerCase() + .replace(/\s/g, "-"); + + // clean out any non-alphanumeric characters except for dashes + return name.replace(/[^a-z0-9-]/g, ""); +} diff --git a/server/setup/scriptsSqlite/1.10.0.ts b/server/setup/scriptsSqlite/1.10.0.ts new file mode 100644 index 00000000..41833ac6 --- /dev/null +++ b/server/setup/scriptsSqlite/1.10.0.ts @@ -0,0 +1,116 @@ +import { __DIRNAME, APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import { readFileSync } from "fs"; +import path, { join } from "path"; + +const version = "1.10.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + const resourceSiteMap = new Map(); + const firstSiteId: number = 1; + + try { + const resources = db + .prepare( + "SELECT resourceId FROM resources" + ) + .all() as Array<{ resourceId: number }>; + + const siteResources = db + .prepare( + "SELECT siteResourceId FROM siteResources" + ) + .all() as Array<{ siteResourceId: number }>; + + db.transaction(() => { + db.exec(` + ALTER TABLE 'exitNodes' ADD 'region' text; + ALTER TABLE 'idpOidcConfig' ADD 'variant' text DEFAULT 'oidc' NOT NULL; + ALTER TABLE 'resources' ADD 'niceId' text DEFAULT '' NOT NULL; + ALTER TABLE 'userOrgs' ADD 'autoProvisioned' integer DEFAULT false; + ALTER TABLE 'targets' ADD 'pathMatchType' text; + ALTER TABLE 'targets' ADD 'path' text; + ALTER TABLE 'resources' ADD 'headers' text; + ALTER TABLE 'siteResources' ADD 'niceId' text NOT NULL; + `); // this diverges from the schema a bit because the schema does not have a default on niceId but was required for the migration and I dont think it will effect much down the line... + + const usedNiceIds: string[] = []; + + for (const resourceId of resources) { + // Generate a unique name and ensure it's unique + let niceId = ""; + let loops = 0; + while (true) { + if (loops > 100) { + throw new Error("Could not generate a unique name"); + } + + niceId = generateName(); + if (!usedNiceIds.includes(niceId)) { + usedNiceIds.push(niceId); + break; + } + loops++; + } + db.prepare( + `UPDATE resources SET niceId = ? WHERE resourceId = ?` + ).run(niceId, resourceId.resourceId); + } + + for (const resourceId of siteResources) { + // Generate a unique name and ensure it's unique + let niceId = ""; + let loops = 0; + while (true) { + if (loops > 100) { + throw new Error("Could not generate a unique name"); + } + + niceId = generateName(); + if (!usedNiceIds.includes(niceId)) { + usedNiceIds.push(niceId); + break; + } + loops++; + } + db.prepare( + `UPDATE siteResources SET niceId = ? WHERE siteResourceId = ?` + ).run(niceId, resourceId.siteResourceId); + } + })(); + + console.log(`Migrated database`); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } +} + +const dev = process.env.ENVIRONMENT !== "prod"; +let file; +if (!dev) { + file = join(__DIRNAME, "names.json"); +} else { + file = join("server/db/names.json"); +} +export const names = JSON.parse(readFileSync(file, "utf-8")); + +export function generateName(): string { + const name = ( + names.descriptors[ + Math.floor(Math.random() * names.descriptors.length) + ] + + "-" + + names.animals[Math.floor(Math.random() * names.animals.length)] + ) + .toLowerCase() + .replace(/\s/g, "-"); + + // clean out any non-alphanumeric characters except for dashes + return name.replace(/[^a-z0-9-]/g, ""); +} diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index 4740198b..4c3ac07b 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -1,8 +1,8 @@ import { verifySession } from "@app/lib/auth/verifySession"; import UserProvider from "@app/providers/UserProvider"; import { cache } from "react"; -import OrganizationLandingCard from "./OrganizationLandingCard"; -import MemberResourcesPortal from "./MemberResourcesPortal"; +import OrganizationLandingCard from "../../components/OrganizationLandingCard"; +import MemberResourcesPortal from "../../components/MemberResourcesPortal"; import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; import { internal } from "@app/lib/api"; import { AxiosResponse } from "axios"; diff --git a/src/app/[orgId]/settings/access/invitations/page.tsx b/src/app/[orgId]/settings/access/invitations/page.tsx index 665b9a43..d7fee322 100644 --- a/src/app/[orgId]/settings/access/invitations/page.tsx +++ b/src/app/[orgId]/settings/access/invitations/page.tsx @@ -1,13 +1,13 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { AxiosResponse } from "axios"; -import InvitationsTable, { InvitationRow } from "./InvitationsTable"; +import InvitationsTable, { InvitationRow } from "../../../../../components/InvitationsTable"; import { GetOrgResponse } from "@server/routers/org"; import { cache } from "react"; import OrgProvider from "@app/providers/OrgProvider"; import UserProvider from "@app/providers/UserProvider"; import { verifySession } from "@app/lib/auth/verifySession"; -import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav"; +import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from 'next-intl/server'; diff --git a/src/app/[orgId]/settings/access/roles/page.tsx b/src/app/[orgId]/settings/access/roles/page.tsx index 8faedbf8..cffe4ed9 100644 --- a/src/app/[orgId]/settings/access/roles/page.tsx +++ b/src/app/[orgId]/settings/access/roles/page.tsx @@ -5,7 +5,7 @@ import { GetOrgResponse } from "@server/routers/org"; import { cache } from "react"; import OrgProvider from "@app/providers/OrgProvider"; import { ListRolesResponse } from "@server/routers/role"; -import RolesTable, { RoleRow } from "./RolesTable"; +import RolesTable, { RoleRow } from "../../../../../components/RolesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from 'next-intl/server'; diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index 82999ad2..4c82ec64 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -16,6 +16,7 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; +import { Checkbox } from "@app/components/ui/checkbox"; import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; import { InviteUserResponse } from "@server/routers/user"; @@ -41,6 +42,8 @@ import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; +import IdpTypeBadge from "@app/components/IdpTypeBadge"; +import { UserType } from "@server/types/UserTypes"; export default function AccessControlsPage() { const { orgUser: user } = userOrgUserContext(); @@ -56,14 +59,16 @@ export default function AccessControlsPage() { const formSchema = z.object({ username: z.string(), - roleId: z.string().min(1, { message: t('accessRoleSelectPlease') }) + roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }), + autoProvisioned: z.boolean() }); const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { username: user.username!, - roleId: user.roleId?.toString() + roleId: user.roleId?.toString(), + autoProvisioned: user.autoProvisioned || false } }); @@ -75,10 +80,10 @@ export default function AccessControlsPage() { console.error(e); toast({ variant: "destructive", - title: t('accessRoleErrorFetch'), + title: t("accessRoleErrorFetch"), description: formatAxiosError( e, - t('accessRoleErrorFetchDescription') + t("accessRoleErrorFetchDescription") ) }); }); @@ -91,31 +96,38 @@ export default function AccessControlsPage() { fetchRoles(); form.setValue("roleId", user.roleId.toString()); + form.setValue("autoProvisioned", user.autoProvisioned || false); }, []); async function onSubmit(values: z.infer) { setLoading(true); - const res = await api - .post< - AxiosResponse - >(`/role/${values.roleId}/add/${user.userId}`) - .catch((e) => { - toast({ - variant: "destructive", - title: t('accessRoleErrorAdd'), - description: formatAxiosError( - e, - t('accessRoleErrorAddDescription') - ) - }); - }); + try { + // Execute both API calls simultaneously + const [roleRes, userRes] = await Promise.all([ + api.post>( + `/role/${values.roleId}/add/${user.userId}` + ), + api.post(`/org/${orgId}/user/${user.userId}`, { + autoProvisioned: values.autoProvisioned + }) + ]); - if (res && res.status === 200) { + if (roleRes.status === 200 && userRes.status === 200) { + toast({ + variant: "default", + title: t("userSaved"), + description: t("userSavedDescription") + }); + } + } catch (e) { toast({ - variant: "default", - title: t('userSaved'), - description: t('userSavedDescription') + variant: "destructive", + title: t("accessRoleErrorAdd"), + description: formatAxiosError( + e, + t("accessRoleErrorAddDescription") + ) }); } @@ -126,9 +138,11 @@ export default function AccessControlsPage() { - {t('accessControls')} + + {t("accessControls")} + - {t('accessControlsDescription')} + {t("accessControlsDescription")} @@ -140,19 +154,49 @@ export default function AccessControlsPage() { className="space-y-4" id="access-controls-form" > + {/* IDP Type Display */} + {user.type !== UserType.Internal && + user.idpType && ( +
+ + {t("idp")}: + + +
+ )} + ( - {t('role')} + {t("role")} - -

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

- -
+ {/* Google/Azure Form */} + {(() => { + const selectedUserOption = userOptions.find(opt => opt.id === selectedOption); + return selectedUserOption?.variant === "google" || selectedUserOption?.variant === "azure"; + })() && ( +
+ - - ( - - - {t( - "emailOptional" - )} - - - - - - - )} - /> - - ( - - - {t( - "nameOptional" - )} - - - - - - - )} - /> - - ( - - - {t("role")} - - - - {roles.map( - ( - role - ) => ( + + + )} + /> + + ( + + + {t("nameOptional")} + + + + + + + )} + /> + + ( + + + {t("role")} + + - - + ))} + + + + + )} + /> + + + )} + + {/* Generic OIDC Form */} + {(() => { + const selectedUserOption = userOptions.find(opt => opt.id === selectedOption); + return selectedUserOption?.variant !== "google" && selectedUserOption?.variant !== "azure"; + })() && ( +
+ -
- + className="space-y-4" + id="create-user-form" + > + ( + + + {t("username")} + + + + +

+ {t("usernameUniq")} +

+ +
+ )} + /> + + ( + + + {t("emailOptional")} + + + + + + + )} + /> + + ( + + + {t("nameOptional")} + + + + + + + )} + /> + + ( + + + {t("role")} + + + + + )} + /> + + + )}
- )} - )}
- {userType && dataLoaded && ( + {selectedOption && dataLoaded && ( + ); + } + + return ( +
+ + { + const value = e.target.value.trim(); + if (!value) { + setShowPathInput(false); + updateTarget(row.original.targetId, { + ...row.original, + path: null, + pathMatchType: null + }); + } else { + updateTarget(row.original.targetId, { + ...row.original, + path: value + }); + } + }} + /> + + + +
+ ); + } + }, { accessorKey: "siteId", header: t("site"), @@ -546,7 +684,7 @@ export default function ReverseProxyTargets(props: { className={cn( "justify-between flex-1", !row.original.siteId && - "text-muted-foreground" + "text-muted-foreground" )} > {row.original.siteId @@ -597,49 +735,59 @@ export default function ReverseProxyTargets(props: { - {selectedSite && selectedSite.type === "newt" && (() => { - const dockerState = getDockerStateForSite(selectedSite.siteId); - return ( - refreshContainersForSite(selectedSite.siteId)} - /> - ); - })()} + {selectedSite && + selectedSite.type === "newt" && + (() => { + const dockerState = getDockerStateForSite( + selectedSite.siteId + ); + return ( + + refreshContainersForSite( + selectedSite.siteId + ) + } + /> + ); + })()}
); } }, ...(resource.http ? [ - { - accessorKey: "method", - header: t("method"), - cell: ({ row }: { row: Row }) => ( - - ) - } - ] + { + accessorKey: "method", + header: t("method"), + cell: ({ row }: { row: Row }) => ( + + ) + } + ] : []), { accessorKey: "ip", @@ -658,9 +806,13 @@ export default function ReverseProxyTargets(props: { if (parsed) { updateTarget(row.original.targetId, { ...row.original, - method: hasProtocol ? parsed.protocol : row.original.method, + method: hasProtocol + ? parsed.protocol + : row.original.method, ip: parsed.host, - port: hasPort ? parsed.port : row.original.port + port: hasPort + ? parsed.port + : row.original.port }); } else { updateTarget(row.original.targetId, { @@ -807,21 +959,21 @@ export default function ReverseProxyTargets(props: { className={cn( "justify-between flex-1", !field.value && - "text-muted-foreground" + "text-muted-foreground" )} > {field.value ? sites.find( - ( - site - ) => - site.siteId === - field.value - ) - ?.name + ( + site + ) => + site.siteId === + field.value + ) + ?.name : t( - "siteSelect" - )} + "siteSelect" + )} @@ -887,18 +1039,35 @@ export default function ReverseProxyTargets(props: { ); return selectedSite && selectedSite.type === - "newt" ? (() => { - const dockerState = getDockerStateForSite(selectedSite.siteId); - return ( - refreshContainersForSite(selectedSite.siteId)} - /> - ); - })() : null; + "newt" + ? (() => { + const dockerState = + getDockerStateForSite( + selectedSite.siteId + ); + return ( + + refreshContainersForSite( + selectedSite.siteId + ) + } + /> + ); + })() + : null; })()} @@ -964,25 +1133,59 @@ export default function ReverseProxyTargets(props: { name="ip" render={({ field }) => ( - {t("targetAddr")} + + {t("targetAddr")} + { - const input = e.target.value.trim(); - const hasProtocol = /^(https?|h2c):\/\//.test(input); - const hasPort = /:\d+(?:\/|$)/.test(input); + const input = + e.target.value.trim(); + const hasProtocol = + /^(https?|h2c):\/\//.test( + input + ); + const hasPort = + /:\d+(?:\/|$)/.test( + input + ); - if (hasProtocol || hasPort) { - const parsed = parseHostTarget(input); + if ( + hasProtocol || + hasPort + ) { + const parsed = + parseHostTarget( + input + ); if (parsed) { - if (hasProtocol || !addTargetForm.getValues("method")) { - addTargetForm.setValue("method", parsed.protocol); + if ( + hasProtocol || + !addTargetForm.getValues( + "method" + ) + ) { + addTargetForm.setValue( + "method", + parsed.protocol + ); } - addTargetForm.setValue("ip", parsed.host); - if (hasPort || !addTargetForm.getValues("port")) { - addTargetForm.setValue("port", parsed.port); + addTargetForm.setValue( + "ip", + parsed.host + ); + if ( + hasPort || + !addTargetForm.getValues( + "port" + ) + ) { + addTargetForm.setValue( + "port", + parsed.port + ); } } } else { @@ -1091,12 +1294,12 @@ export default function ReverseProxyTargets(props: { {header.isPlaceholder ? null : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} + header + .column + .columnDef + .header, + header.getContext() + )} ) )} @@ -1256,6 +1459,36 @@ export default function ReverseProxyTargets(props: { )} /> + ( + + + {t("customHeaders")} + + + { + field.onChange( + value + ); + }} + rows={4} + /> + + + {t( + "customHeadersDescription" + )} + + + + )} + /> diff --git a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/rules/page.tsx similarity index 98% rename from src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx rename to src/app/[orgId]/settings/resources/[niceId]/rules/page.tsx index 424d7973..8b5e4709 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/rules/page.tsx @@ -128,7 +128,7 @@ export default function ResourceRules(props: { try { const res = await api.get< AxiosResponse - >(`/resource/${params.resourceId}/rules`); + >(`/resource/${resource.resourceId}/rules`); if (res.status === 200) { setRules(res.data.data.rules); } @@ -251,7 +251,7 @@ export default function ResourceRules(props: { // Save rules enabled state const res = await api - .post(`/resource/${params.resourceId}`, { + .post(`/resource/${resource.resourceId}`, { applyRules: rulesEnabled }) .catch((err) => { @@ -336,13 +336,13 @@ export default function ResourceRules(props: { if (rule.new) { const res = await api.put( - `/resource/${params.resourceId}/rule`, + `/resource/${resource.resourceId}/rule`, data ); rule.ruleId = res.data.data.ruleId; } else if (rule.updated) { await api.post( - `/resource/${params.resourceId}/rule/${rule.ruleId}`, + `/resource/${resource.resourceId}/rule/${rule.ruleId}`, data ); } @@ -361,7 +361,7 @@ export default function ResourceRules(props: { for (const ruleId of rulesToRemove) { await api.delete( - `/resource/${params.resourceId}/rule/${ruleId}` + `/resource/${resource.resourceId}/rule/${ruleId}` ); setRules(rules.filter((r) => r.ruleId !== ruleId)); } diff --git a/src/app/[orgId]/settings/resources/create/page.tsx b/src/app/[orgId]/settings/resources/create/page.tsx index f7a19c08..83d94cd4 100644 --- a/src/app/[orgId]/settings/resources/create/page.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -58,7 +58,7 @@ import { } from "@app/components/ui/popover"; import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; import { cn } from "@app/lib/cn"; -import { SquareArrowOutUpRight } from "lucide-react"; +import { ArrowRight, MoveRight, SquareArrowOutUpRight } from "lucide-react"; import CopyTextBox from "@app/components/CopyTextBox"; import Link from "next/link"; import { useTranslations } from "next-intl"; @@ -90,7 +90,7 @@ import { ListTargetsResponse } from "@server/routers/target"; import { DockerManager, DockerState } from "@app/lib/docker"; import { parseHostTarget } from "@app/lib/parseHostTarget"; import { toASCII, toUnicode } from 'punycode'; -import { DomainRow } from "../../domains/DomainsTable"; +import { DomainRow } from "../../../../../components/DomainsTable"; const baseResourceFormSchema = z.object({ name: z.string().min(1).max(255), @@ -112,8 +112,42 @@ const addTargetSchema = z.object({ ip: z.string().refine(isTargetValid), method: z.string().nullable(), port: z.coerce.number().int().positive(), - siteId: z.number().int().positive() -}); + siteId: z.number().int().positive(), + path: z.string().optional().nullable(), + pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable() +}).refine( + (data) => { + // If path is provided, pathMatchType must be provided + if (data.path && !data.pathMatchType) { + return false; + } + // If pathMatchType is provided, path must be provided + if (data.pathMatchType && !data.path) { + return false; + } + // Validate path based on pathMatchType + if (data.path && data.pathMatchType) { + switch (data.pathMatchType) { + case "exact": + case "prefix": + // Path should start with / + return data.path.startsWith("/"); + case "regex": + // Validate regex + try { + new RegExp(data.path); + return true; + } catch { + return false; + } + } + } + return true; + }, + { + message: "Invalid path configuration" + } +); type BaseResourceFormValues = z.infer; type HttpResourceFormValues = z.infer; @@ -202,7 +236,9 @@ export default function Page() { defaultValues: { ip: "", method: baseForm.watch("http") ? "http" : null, - port: "" as any as number + port: "" as any as number, + path: null, + pathMatchType: null } as z.infer }); @@ -273,6 +309,8 @@ export default function Page() { const newTarget: LocalTarget = { ...data, + path: data.path || null, + pathMatchType: data.pathMatchType || null, siteType: site?.type || null, enabled: true, targetId: new Date().getTime(), @@ -284,7 +322,9 @@ export default function Page() { addTargetForm.reset({ ip: "", method: baseForm.watch("http") ? "http" : null, - port: "" as any as number + port: "" as any as number, + path: null, + pathMatchType: null }); } @@ -315,8 +355,6 @@ export default function Page() { } async function onSubmit() { - setShowSnippets(true); - router.refresh(); setCreateLoading(true); const baseData = baseForm.getValues(); @@ -372,7 +410,9 @@ export default function Page() { port: target.port, method: target.method, enabled: target.enabled, - siteId: target.siteId + siteId: target.siteId, + path: target.path, + pathMatchType: target.pathMatchType }; await api.put(`/resource/${id}/target`, data); @@ -495,6 +535,89 @@ export default function Page() { }, []); const columns: ColumnDef[] = [ + { + accessorKey: "path", + header: t("matchPath"), + cell: ({ row }) => { + const [showPathInput, setShowPathInput] = useState( + !!(row.original.path || row.original.pathMatchType) + ); + + if (!showPathInput) { + return ( + + ); + } + + return ( +
+ + { + const value = e.target.value.trim(); + if (!value) { + setShowPathInput(false); + updateTarget(row.original.targetId, { + ...row.original, + path: null, + pathMatchType: null + }); + } else { + updateTarget(row.original.targetId, { + ...row.original, + path: value + }); + } + }} + /> + + + +
+ ); + } + }, { accessorKey: "siteId", header: t("site"), @@ -1423,7 +1546,7 @@ export default function Page() { {t("resourceAddEntrypoints")}

- (Edit file: config/traefik/traefik_config.yml) + {t("resourceAddEntrypointsEditFile")}

- (Edit file: docker-compose.yml) + {t("resourceExposePortsEditFile")}

({ idpId: idp.idpId, - name: idp.name + name: idp.name, + variant: idp.variant })) as LoginFormIDP[]; const t = await getTranslations(); diff --git a/src/app/auth/reset-password/ResetPasswordForm.tsx b/src/app/auth/reset-password/ResetPasswordForm.tsx index 596afb99..3d456bd9 100644 --- a/src/app/auth/reset-password/ResetPasswordForm.tsx +++ b/src/app/auth/reset-password/ResetPasswordForm.tsx @@ -35,7 +35,7 @@ import { ResetPasswordResponse } from "@server/routers/auth"; import { Loader2 } from "lucide-react"; -import { Alert, AlertDescription } from "../../../components/ui/alert"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; import { toast } from "@app/hooks/useToast"; import { useRouter } from "next/navigation"; import { formatAxiosError } from "@app/lib/api"; @@ -210,7 +210,7 @@ export default function ResetPasswordForm({ } catch (verificationError) { console.error("Failed to send verification code:", verificationError); } - + if (redirect) { router.push(`/auth/verify-email?redirect=${redirect}`); } else { @@ -254,8 +254,8 @@ export default function ResetPasswordForm({ {quickstart ? t('completeAccountSetup') : t('passwordReset')} - {quickstart - ? t('completeAccountSetupDescription') + {quickstart + ? t('completeAccountSetupDescription') : t('passwordResetDescription') } @@ -282,8 +282,8 @@ export default function ResetPasswordForm({ - {quickstart - ? t('accountSetupSent') + {quickstart + ? t('accountSetupSent') : t('passwordResetSent') } @@ -325,8 +325,8 @@ export default function ResetPasswordForm({ render={({ field }) => ( - {quickstart - ? t('accountSetupCode') + {quickstart + ? t('accountSetupCode') : t('passwordResetCode') } @@ -338,8 +338,8 @@ export default function ResetPasswordForm({ - {quickstart - ? t('accountSetupCodeDescription') + {quickstart + ? t('accountSetupCodeDescription') : t('passwordResetCodeDescription') } @@ -354,8 +354,8 @@ export default function ResetPasswordForm({ render={({ field }) => ( - {quickstart - ? t('passwordCreate') + {quickstart + ? t('passwordCreate') : t('passwordNew') } @@ -375,8 +375,8 @@ export default function ResetPasswordForm({ render={({ field }) => ( - {quickstart - ? t('passwordCreateConfirm') + {quickstart + ? t('passwordCreateConfirm') : t('passwordNewConfirm') } @@ -490,8 +490,8 @@ export default function ResetPasswordForm({ {isSubmitting && ( )} - {quickstart - ? t('accountSetupSubmit') + {quickstart + ? t('accountSetupSubmit') : t('passwordResetSubmit') } diff --git a/src/app/auth/reset-password/page.tsx b/src/app/auth/reset-password/page.tsx index f06c7c4c..490f89f7 100644 --- a/src/app/auth/reset-password/page.tsx +++ b/src/app/auth/reset-password/page.tsx @@ -1,7 +1,7 @@ import { verifySession } from "@app/lib/auth/verifySession"; import { redirect } from "next/navigation"; import { cache } from "react"; -import ResetPasswordForm from "./ResetPasswordForm"; +import ResetPasswordForm from "@app/components/ResetPasswordForm"; import Link from "next/link"; import { cleanRedirect } from "@app/lib/cleanRedirect"; import { getTranslations } from "next-intl/server"; diff --git a/src/app/auth/resource/[resourceId]/page.tsx b/src/app/auth/resource/[resourceId]/page.tsx index 347d3586..25580ee7 100644 --- a/src/app/auth/resource/[resourceId]/page.tsx +++ b/src/app/auth/resource/[resourceId]/page.tsx @@ -2,20 +2,20 @@ import { GetResourceAuthInfoResponse, GetExchangeTokenResponse } from "@server/routers/resource"; -import ResourceAuthPortal from "./ResourceAuthPortal"; +import ResourceAuthPortal from "@app/components/ResourceAuthPortal"; import { internal, priv } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { authCookieHeader } from "@app/lib/api/cookies"; import { cache } from "react"; import { verifySession } from "@app/lib/auth/verifySession"; import { redirect } from "next/navigation"; -import ResourceNotFound from "./ResourceNotFound"; -import ResourceAccessDenied from "./ResourceAccessDenied"; -import AccessToken from "./AccessToken"; +import ResourceNotFound from "@app/components/ResourceNotFound"; +import ResourceAccessDenied from "@app/components/ResourceAccessDenied"; +import AccessToken from "@app/components/AccessToken"; import { pullEnv } from "@app/lib/pullEnv"; import { LoginFormIDP } from "@app/components/LoginForm"; import { ListIdpsResponse } from "@server/routers/idp"; -import AutoLoginHandler from "./AutoLoginHandler"; +import AutoLoginHandler from "@app/components/AutoLoginHandler"; export const dynamic = "force-dynamic"; diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index 673e69bf..b4f4fddd 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -1,4 +1,4 @@ -import SignupForm from "@app/app/auth/signup/SignupForm"; +import SignupForm from "@app/components/SignupForm"; import { verifySession } from "@app/lib/auth/verifySession"; import { cleanRedirect } from "@app/lib/cleanRedirect"; import { pullEnv } from "@app/lib/pullEnv"; @@ -11,7 +11,7 @@ import { getTranslations } from "next-intl/server"; export const dynamic = "force-dynamic"; export default async function Page(props: { - searchParams: Promise<{ + searchParams: Promise<{ redirect: string | undefined; email: string | undefined; }>; diff --git a/src/app/auth/verify-email/page.tsx b/src/app/auth/verify-email/page.tsx index 10ad809f..c549abf0 100644 --- a/src/app/auth/verify-email/page.tsx +++ b/src/app/auth/verify-email/page.tsx @@ -1,4 +1,4 @@ -import VerifyEmailForm from "@app/app/auth/verify-email/VerifyEmailForm"; +import VerifyEmailForm from "@app/components/VerifyEmailForm"; import { verifySession } from "@app/lib/auth/verifySession"; import { cleanRedirect } from "@app/lib/cleanRedirect"; import { pullEnv } from "@app/lib/pullEnv"; diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index 2e0c11e2..49c5a2c5 100644 --- a/src/app/invite/page.tsx +++ b/src/app/invite/page.tsx @@ -4,7 +4,7 @@ import { verifySession } from "@app/lib/auth/verifySession"; import { AcceptInviteResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; -import InviteStatusCard from "./InviteStatusCard"; +import InviteStatusCard from "../../components/InviteStatusCard"; import { formatAxiosError } from "@app/lib/api"; import { getTranslations } from "next-intl/server"; @@ -72,14 +72,14 @@ export default async function InvitePage(props: { const type = cardType(); if (!user && type === "user_does_not_exist") { - const redirectUrl = emailParam + const redirectUrl = emailParam ? `/auth/signup?redirect=/invite?token=${params.token}&email=${encodeURIComponent(emailParam)}` : `/auth/signup?redirect=/invite?token=${params.token}`; redirect(redirectUrl); } if (!user && type === "not_logged_in") { - const redirectUrl = emailParam + const redirectUrl = emailParam ? `/auth/login?redirect=/invite?token=${params.token}&email=${encodeURIComponent(emailParam)}` : `/auth/login?redirect=/invite?token=${params.token}`; redirect(redirectUrl); diff --git a/src/app/s/[accessToken]/page.tsx b/src/app/s/[accessToken]/page.tsx index d28ff7be..61299366 100644 --- a/src/app/s/[accessToken]/page.tsx +++ b/src/app/s/[accessToken]/page.tsx @@ -1,4 +1,4 @@ -import AccessToken from "@app/app/auth/resource/[resourceId]/AccessToken"; +import AccessToken from "@app/components/AccessToken"; export default async function ResourceAuthPage(props: { params: Promise<{ accessToken: string }>; diff --git a/src/app/[orgId]/settings/access/AccessPageHeaderAndNav.tsx b/src/components/AccessPageHeaderAndNav.tsx similarity index 100% rename from src/app/[orgId]/settings/access/AccessPageHeaderAndNav.tsx rename to src/components/AccessPageHeaderAndNav.tsx diff --git a/src/app/auth/resource/[resourceId]/AccessToken.tsx b/src/components/AccessToken.tsx similarity index 100% rename from src/app/auth/resource/[resourceId]/AccessToken.tsx rename to src/components/AccessToken.tsx diff --git a/src/app/[orgId]/settings/share-links/AccessTokenUsage.tsx b/src/components/AccessTokenUsage.tsx similarity index 100% rename from src/app/[orgId]/settings/share-links/AccessTokenUsage.tsx rename to src/components/AccessTokenUsage.tsx diff --git a/src/app/admin/idp/AdminIdpDataTable.tsx b/src/components/AdminIdpDataTable.tsx similarity index 100% rename from src/app/admin/idp/AdminIdpDataTable.tsx rename to src/components/AdminIdpDataTable.tsx diff --git a/src/app/admin/idp/AdminIdpTable.tsx b/src/components/AdminIdpTable.tsx similarity index 94% rename from src/app/admin/idp/AdminIdpTable.tsx rename to src/components/AdminIdpTable.tsx index fa7de6da..8849ba25 100644 --- a/src/app/admin/idp/AdminIdpTable.tsx +++ b/src/components/AdminIdpTable.tsx @@ -1,7 +1,7 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { IdpDataTable } from "./AdminIdpDataTable"; +import { IdpDataTable } from "@app/components/AdminIdpDataTable"; import { Button } from "@app/components/ui/button"; import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; import { useState } from "react"; @@ -20,12 +20,14 @@ import { } from "@app/components/ui/dropdown-menu"; import Link from "next/link"; import { useTranslations } from "next-intl"; +import IdpTypeBadge from "./IdpTypeBadge"; export type IdpRow = { idpId: number; name: string; type: string; orgCount: number; + variant?: string; }; type Props = { @@ -57,15 +59,6 @@ export default function IdpTable({ idps }: Props) { } }; - const getTypeDisplay = (type: string) => { - switch (type) { - case "oidc": - return "OAuth2/OIDC"; - default: - return type; - } - }; - const columns: ColumnDef[] = [ { accessorKey: "idpId", @@ -116,9 +109,8 @@ export default function IdpTable({ idps }: Props) { }, cell: ({ row }) => { const type = row.original.type; - return ( - {getTypeDisplay(type)} - ); + const variant = row.original.variant; + return ; } }, { diff --git a/src/app/admin/users/AdminUsersDataTable.tsx b/src/components/AdminUsersDataTable.tsx similarity index 100% rename from src/app/admin/users/AdminUsersDataTable.tsx rename to src/components/AdminUsersDataTable.tsx diff --git a/src/components/AdminUsersTable.tsx b/src/components/AdminUsersTable.tsx new file mode 100644 index 00000000..8e75ff24 --- /dev/null +++ b/src/components/AdminUsersTable.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { UsersDataTable } from "@app/components/AdminUsersDataTable"; +import { Button } from "@app/components/ui/button"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; +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 { + DropdownMenu, + DropdownMenuItem, + DropdownMenuContent, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; + +export type GlobalUserRow = { + id: string; + name: string | null; + username: string; + email: string | null; + type: string; + idpId: number | null; + idpName: string; + dateCreated: string; + twoFactorEnabled: boolean | null; + twoFactorSetupRequested: boolean | null; +}; + +type Props = { + users: GlobalUserRow[]; +}; + +export default function UsersTable({ users }: Props) { + const router = useRouter(); + const t = useTranslations(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [rows, setRows] = useState(users); + + const api = createApiClient(useEnvContext()); + + const deleteUser = (id: string) => { + api.delete(`/user/${id}`) + .catch((e) => { + console.error(t("userErrorDelete"), e); + toast({ + variant: "destructive", + title: t("userErrorDelete"), + description: formatAxiosError(e, t("userErrorDelete")) + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + + const newRows = rows.filter((row) => row.id !== id); + + setRows(newRows); + }); + }; + + const columns: ColumnDef[] = [ + { + accessorKey: "id", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "username", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "email", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "idpName", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "twoFactorEnabled", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const userRow = row.original; + + return ( +
+ + {userRow.twoFactorEnabled || + userRow.twoFactorSetupRequested ? ( + + {t("enabled")} + + ) : ( + {t("disabled")} + )} + +
+ ); + } + }, + { + id: "actions", + cell: ({ row }) => { + const r = row.original; + return ( + <> +
+ + + + + + { + setSelected(r); + setIsDeleteModalOpen(true); + }} + > + {t("delete")} + + + + +
+ + ); + } + } + ]; + + return ( + <> + {selected && ( + { + setIsDeleteModalOpen(val); + setSelected(null); + }} + dialog={ +
+

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

+ +

+ {t("userMessageRemove")} +

+ +

{t("userMessageConfirm")}

+
+ } + buttonText={t("userDeleteConfirm")} + onConfirm={async () => deleteUser(selected!.id)} + string={ + selected.email || selected.name || selected.username + } + title={t("userDeleteServer")} + /> + )} + + + + ); +} diff --git a/src/app/admin/api-keys/ApiKeysDataTable.tsx b/src/components/ApiKeysDataTable.tsx similarity index 100% rename from src/app/admin/api-keys/ApiKeysDataTable.tsx rename to src/components/ApiKeysDataTable.tsx diff --git a/src/app/admin/api-keys/ApiKeysTable.tsx b/src/components/ApiKeysTable.tsx similarity index 98% rename from src/app/admin/api-keys/ApiKeysTable.tsx rename to src/components/ApiKeysTable.tsx index 02aead9e..99094651 100644 --- a/src/app/admin/api-keys/ApiKeysTable.tsx +++ b/src/components/ApiKeysTable.tsx @@ -18,7 +18,7 @@ import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import moment from "moment"; -import { ApiKeysDataTable } from "./ApiKeysDataTable"; +import { ApiKeysDataTable } from "@app/components/ApiKeysDataTable"; import { useTranslations } from "next-intl"; export type ApiKeyRow = { diff --git a/src/app/auth/resource/[resourceId]/AutoLoginHandler.tsx b/src/components/AutoLoginHandler.tsx similarity index 100% rename from src/app/auth/resource/[resourceId]/AutoLoginHandler.tsx rename to src/components/AutoLoginHandler.tsx diff --git a/src/app/[orgId]/settings/clients/[clientId]/ClientInfoCard.tsx b/src/components/ClientInfoCard.tsx similarity index 100% rename from src/app/[orgId]/settings/clients/[clientId]/ClientInfoCard.tsx rename to src/components/ClientInfoCard.tsx diff --git a/src/app/[orgId]/settings/clients/ClientsDataTable.tsx b/src/components/ClientsDataTable.tsx similarity index 100% rename from src/app/[orgId]/settings/clients/ClientsDataTable.tsx rename to src/components/ClientsDataTable.tsx diff --git a/src/app/[orgId]/settings/clients/ClientsTable.tsx b/src/components/ClientsTable.tsx similarity index 99% rename from src/app/[orgId]/settings/clients/ClientsTable.tsx rename to src/components/ClientsTable.tsx index 7fa81622..fc7c7c84 100644 --- a/src/app/[orgId]/settings/clients/ClientsTable.tsx +++ b/src/components/ClientsTable.tsx @@ -1,7 +1,7 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { ClientsDataTable } from "./ClientsDataTable"; +import { ClientsDataTable } from "@app/components/ClientsDataTable"; import { DropdownMenu, DropdownMenuContent, diff --git a/src/app/[orgId]/settings/domains/CreateDomainForm.tsx b/src/components/CreateDomainForm.tsx similarity index 99% rename from src/app/[orgId]/settings/domains/CreateDomainForm.tsx rename to src/components/CreateDomainForm.tsx index e609a8ac..77fdea9c 100644 --- a/src/app/[orgId]/settings/domains/CreateDomainForm.tsx +++ b/src/components/CreateDomainForm.tsx @@ -238,7 +238,7 @@ export default function CreateDomainForm({ {t("domain")} @@ -604,4 +604,4 @@ export default function CreateDomainForm({ ); -} \ No newline at end of file +} diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index ccfddcd8..63dfc11d 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -352,7 +352,7 @@ export default function CreateInternalResourceDialog({ render={({ field }) => ( - {t("createInternalResourceDialogDestinationIP")} + {t("targetAddr")} ( - {t("createInternalResourceDialogDestinationPort")} + {t("targetPort")} void; cols?: number; + hideFreeDomain?: boolean; } export default function DomainPicker2({ orgId, onDomainChange, - cols = 2 + cols = 2, + hideFreeDomain = false }: DomainPicker2Props) { const { env } = useEnvContext(); const api = createApiClient({ env }); @@ -153,12 +155,12 @@ export default function DomainPicker2({ fullDomain: firstOrgDomain.baseDomain, baseDomain: firstOrgDomain.baseDomain }); - } else if (build === "saas" || build === "enterprise") { + } else if ((build === "saas" || build === "enterprise") && !hideFreeDomain) { // If no organization domains, select the provided domain option const domainOptionText = build === "enterprise" - ? "Provided Domain" - : "Free Provided Domain"; + ? t("domainPickerProvidedDomain") + : t("domainPickerFreeProvidedDomain"); const freeDomainOption: DomainOption = { id: "provided-search", domain: domainOptionText, @@ -171,8 +173,8 @@ export default function DomainPicker2({ console.error("Failed to load organization domains:", error); toast({ variant: "destructive", - title: "Error", - description: "Failed to load organization domains" + title: t("domainPickerError"), + description: t("domainPickerErrorLoadDomains") }); } finally { setLoadingDomains(false); @@ -180,7 +182,7 @@ export default function DomainPicker2({ }; loadOrganizationDomains(); - }, [orgId, api]); + }, [orgId, api, hideFreeDomain]); const checkAvailability = useCallback( async (input: string) => { @@ -202,8 +204,8 @@ export default function DomainPicker2({ setAvailableOptions([]); toast({ variant: "destructive", - title: "Error", - description: "Failed to check domain availability" + title: t("domainPickerError"), + description: t("domainPickerErrorCheckAvailability") }); } finally { setIsChecking(false); @@ -246,11 +248,11 @@ export default function DomainPicker2({ }); }); - if (build === "saas" || build === "enterprise") { + if ((build === "saas" || build === "enterprise") && !hideFreeDomain) { const domainOptionText = build === "enterprise" - ? "Provided Domain" - : "Free Provided Domain"; + ? t("domainPickerProvidedDomain") + : t("domainPickerFreeProvidedDomain"); options.push({ id: "provided-search", domain: domainOptionText, @@ -269,8 +271,8 @@ export default function DomainPicker2({ if (!sanitized) { toast({ variant: "destructive", - title: "Invalid subdomain", - description: `The input "${sub}" was removed because it's not valid.`, + title: t("domainPickerInvalidSubdomain"), + description: t("domainPickerInvalidSubdomainRemoved", { sub }), }); return ""; } @@ -283,16 +285,16 @@ export default function DomainPicker2({ if (!ok) { toast({ variant: "destructive", - title: "Invalid subdomain", - description: `"${sub}" could not be made valid for ${base.domain}.`, + title: t("domainPickerInvalidSubdomain"), + description: t("domainPickerInvalidSubdomainCannotMakeValid", { sub, domain: base.domain }), }); return ""; } if (sub !== sanitized) { toast({ - title: "Subdomain sanitized", - description: `"${sub}" was corrected to "${sanitized}"`, + title: t("domainPickerSubdomainSanitized"), + description: t("domainPickerSubdomainCorrected", { sub, sanitized }), }); } @@ -453,7 +455,7 @@ export default function DomainPicker2({ /> {showSubdomainInput && subdomainInput && !isValidSubdomainStructure(subdomainInput) && (

- This subdomain contains invalid characters or structure. It will be sanitized automatically when you save. + {t("domainPickerInvalidSubdomainStructure")}

)} {showSubdomainInput && !subdomainInput && ( @@ -555,8 +557,8 @@ export default function DomainPicker2({ {orgDomain.type.toUpperCase()}{" "} •{" "} {orgDomain.verified - ? "Verified" - : "Unverified"} + ? t("domainPickerVerified") + : t("domainPickerUnverified")} {(build === "saas" || - build === "enterprise") && ( + build === "enterprise") && !hideFreeDomain && ( )} )} {(build === "saas" || - build === "enterprise") && ( + build === "enterprise") && !hideFreeDomain && ( {build === "enterprise" - ? "Provided Domain" - : "Free Provided Domain"} + ? t("domainPickerProvidedDomain") + : t("domainPickerFreeProvidedDomain")} {t( @@ -771,4 +773,4 @@ function debounce any>( func(...args); }, wait); }; -} \ No newline at end of file +} diff --git a/src/app/[orgId]/settings/domains/DomainsDataTable.tsx b/src/components/DomainsDataTable.tsx similarity index 100% rename from src/app/[orgId]/settings/domains/DomainsDataTable.tsx rename to src/components/DomainsDataTable.tsx diff --git a/src/app/[orgId]/settings/domains/DomainsTable.tsx b/src/components/DomainsTable.tsx similarity index 98% rename from src/app/[orgId]/settings/domains/DomainsTable.tsx rename to src/components/DomainsTable.tsx index 84bc8bc6..5bafe935 100644 --- a/src/app/[orgId]/settings/domains/DomainsTable.tsx +++ b/src/components/DomainsTable.tsx @@ -1,7 +1,7 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { DomainsDataTable } from "./DomainsDataTable"; +import { DomainsDataTable } from "@app/components/DomainsDataTable"; import { Button } from "@app/components/ui/button"; import { ArrowUpDown } from "lucide-react"; import { useState } from "react"; @@ -12,7 +12,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { Badge } from "@app/components/ui/badge"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import CreateDomainForm from "./CreateDomainForm"; +import CreateDomainForm from "@app/components/CreateDomainForm"; import { useToast } from "@app/hooks/useToast"; import { useOrgContext } from "@app/hooks/useOrgContext"; diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index adfed1b7..d09f0b6c 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -221,7 +221,7 @@ export default function EditInternalResourceDialog({ name="destinationIp" render={({ field }) => ( - {t("editInternalResourceDialogDestinationIP")} + {t("targetAddr")} @@ -235,7 +235,7 @@ export default function EditInternalResourceDialog({ name="destinationPort" render={({ field }) => ( - {t("editInternalResourceDialogDestinationPort")} + {t("targetPort")} void; + placeholder?: string; + rows?: number; + className?: string; +} + +export function HeadersInput({ + value = "", + onChange, + placeholder = `X-Example-Header: example-value +X-Another-Header: another-value`, + rows = 4, + className +}: HeadersInputProps) { + const [internalValue, setInternalValue] = useState(""); + + // Convert comma-separated to newline-separated for display + const convertToNewlineSeparated = (commaSeparated: string): string => { + if (!commaSeparated || commaSeparated.trim() === "") return ""; + + return commaSeparated + .split(',') + .map(header => header.trim()) + .filter(header => header.length > 0) + .join('\n'); + }; + + // Convert newline-separated to comma-separated for output + const convertToCommaSeparated = (newlineSeparated: string): string => { + if (!newlineSeparated || newlineSeparated.trim() === "") return ""; + + return newlineSeparated + .split('\n') + .map(header => header.trim()) + .filter(header => header.length > 0) + .join(', '); + }; + + // Update internal value when external value changes + useEffect(() => { + setInternalValue(convertToNewlineSeparated(value)); + }, [value]); + + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setInternalValue(newValue); + + // Convert back to comma-separated format for the parent + const commaSeparatedValue = convertToCommaSeparated(newValue); + onChange(commaSeparatedValue); + }; + + return ( +