mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-05 02:06:41 +00:00
Merge pull request #1449 from fosrl/declare
Add declareivie config for resources, path matching, custom headers
This commit is contained in:
@@ -41,7 +41,7 @@ _Pangolin tunnels your services to the internet so you can access anything from
|
|||||||
[](https://hub.docker.com/r/fosrl/pangolin)
|
[](https://hub.docker.com/r/fosrl/pangolin)
|
||||||

|

|
||||||
[](https://discord.gg/HCJR8Xhme4)
|
[](https://discord.gg/HCJR8Xhme4)
|
||||||
[](https://www.youtube.com/@fossorial-app)
|
[](https://www.youtube.com/@fossorial-app)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
72
blueprint.py
Normal file
72
blueprint.py
Normal file
@@ -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)
|
||||||
|
|
||||||
69
blueprint.yaml
Normal file
69
blueprint.yaml
Normal file
@@ -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
|
||||||
@@ -13,7 +13,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- ENVIRONMENT=dev
|
- ENVIRONMENT=dev
|
||||||
- DB_TYPE=pg
|
|
||||||
volumes:
|
volumes:
|
||||||
# Mount source code for hot reload
|
# Mount source code for hot reload
|
||||||
- ./src:/app/src
|
- ./src:/app/src
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ esbuild
|
|||||||
bundle: true,
|
bundle: true,
|
||||||
outfile: argv.out,
|
outfile: argv.out,
|
||||||
format: "esm",
|
format: "esm",
|
||||||
minify: true,
|
minify: false,
|
||||||
banner: {
|
banner: {
|
||||||
js: banner,
|
js: banner,
|
||||||
},
|
},
|
||||||
@@ -63,7 +63,7 @@ esbuild
|
|||||||
packagePath: getPackagePaths(),
|
packagePath: getPackagePaths(),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
sourcemap: "external",
|
sourcemap: "inline",
|
||||||
target: "node22",
|
target: "node22",
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -15,7 +17,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
"net"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD
|
// 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.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
|
config.InstallGerbil = true
|
||||||
} else {
|
} else {
|
||||||
config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
|
config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
|
||||||
@@ -584,6 +590,32 @@ func generateRandomSecretKey() string {
|
|||||||
return string(b)
|
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.
|
// Run external commands with stdio/stderr attached.
|
||||||
func run(name string, args ...string) error {
|
func run(name string, args ...string) error {
|
||||||
cmd := exec.Command(name, args...)
|
cmd := exec.Command(name, args...)
|
||||||
|
|||||||
@@ -454,6 +454,8 @@
|
|||||||
"accessRoleErrorAddDescription": "An error occurred while adding user to the role.",
|
"accessRoleErrorAddDescription": "An error occurred while adding user to the role.",
|
||||||
"userSaved": "User saved",
|
"userSaved": "User saved",
|
||||||
"userSavedDescription": "The user has been updated.",
|
"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",
|
"accessControlsDescription": "Manage what this user can access and do in the organization",
|
||||||
"accessControlsSubmit": "Save Access Controls",
|
"accessControlsSubmit": "Save Access Controls",
|
||||||
"roles": "Roles",
|
"roles": "Roles",
|
||||||
@@ -911,6 +913,8 @@
|
|||||||
"idpConnectingToFinished": "Connected",
|
"idpConnectingToFinished": "Connected",
|
||||||
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
|
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
|
||||||
"idpErrorNotFound": "IdP not found",
|
"idpErrorNotFound": "IdP not found",
|
||||||
|
"idpGoogleAlt": "Google",
|
||||||
|
"idpAzureAlt": "Azure",
|
||||||
"inviteInvalid": "Invalid Invite",
|
"inviteInvalid": "Invalid Invite",
|
||||||
"inviteInvalidDescription": "The invite link is invalid.",
|
"inviteInvalidDescription": "The invite link is invalid.",
|
||||||
"inviteErrorWrongUser": "Invite is not for this user",
|
"inviteErrorWrongUser": "Invite is not for this user",
|
||||||
@@ -982,6 +986,8 @@
|
|||||||
"licenseTierProfessionalRequired": "Professional Edition Required",
|
"licenseTierProfessionalRequired": "Professional Edition Required",
|
||||||
"licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.",
|
"licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.",
|
||||||
"actionGetOrg": "Get Organization",
|
"actionGetOrg": "Get Organization",
|
||||||
|
"updateOrgUser": "Update Org User",
|
||||||
|
"createOrgUser": "Create Org User",
|
||||||
"actionUpdateOrg": "Update Organization",
|
"actionUpdateOrg": "Update Organization",
|
||||||
"actionUpdateUser": "Update User",
|
"actionUpdateUser": "Update User",
|
||||||
"actionGetUser": "Get User",
|
"actionGetUser": "Get User",
|
||||||
@@ -1234,7 +1240,7 @@
|
|||||||
"newtUpdateAvailable": "Update Available",
|
"newtUpdateAvailable": "Update Available",
|
||||||
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
||||||
"domainPickerEnterDomain": "Domain",
|
"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.",
|
"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",
|
"domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
|
||||||
"domainPickerTabAll": "All",
|
"domainPickerTabAll": "All",
|
||||||
@@ -1496,5 +1502,22 @@
|
|||||||
"convertButton": "Convert This Node to Managed Self-Hosted"
|
"convertButton": "Convert This Node to Managed Self-Hosted"
|
||||||
},
|
},
|
||||||
"internationaldomaindetected": "International Domain Detected",
|
"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"
|
||||||
|
}
|
||||||
@@ -454,6 +454,8 @@
|
|||||||
"accessRoleErrorAddDescription": "An error occurred while adding user to the role.",
|
"accessRoleErrorAddDescription": "An error occurred while adding user to the role.",
|
||||||
"userSaved": "User saved",
|
"userSaved": "User saved",
|
||||||
"userSavedDescription": "The user has been updated.",
|
"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",
|
"accessControlsDescription": "Manage what this user can access and do in the organization",
|
||||||
"accessControlsSubmit": "Save Access Controls",
|
"accessControlsSubmit": "Save Access Controls",
|
||||||
"roles": "Roles",
|
"roles": "Roles",
|
||||||
@@ -911,6 +913,8 @@
|
|||||||
"idpConnectingToFinished": "Connected",
|
"idpConnectingToFinished": "Connected",
|
||||||
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
|
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
|
||||||
"idpErrorNotFound": "IdP not found",
|
"idpErrorNotFound": "IdP not found",
|
||||||
|
"idpGoogleAlt": "Google",
|
||||||
|
"idpAzureAlt": "Azure",
|
||||||
"inviteInvalid": "Invalid Invite",
|
"inviteInvalid": "Invalid Invite",
|
||||||
"inviteInvalidDescription": "The invite link is invalid.",
|
"inviteInvalidDescription": "The invite link is invalid.",
|
||||||
"inviteErrorWrongUser": "Invite is not for this user",
|
"inviteErrorWrongUser": "Invite is not for this user",
|
||||||
@@ -982,6 +986,8 @@
|
|||||||
"licenseTierProfessionalRequired": "Professional Edition Required",
|
"licenseTierProfessionalRequired": "Professional Edition Required",
|
||||||
"licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.",
|
"licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.",
|
||||||
"actionGetOrg": "Get Organization",
|
"actionGetOrg": "Get Organization",
|
||||||
|
"updateOrgUser": "Update Org User",
|
||||||
|
"createOrgUser": "Create Org User",
|
||||||
"actionUpdateOrg": "Update Organization",
|
"actionUpdateOrg": "Update Organization",
|
||||||
"actionUpdateUser": "Update User",
|
"actionUpdateUser": "Update User",
|
||||||
"actionGetUser": "Get User",
|
"actionGetUser": "Get User",
|
||||||
@@ -1234,7 +1240,7 @@
|
|||||||
"newtUpdateAvailable": "Update Available",
|
"newtUpdateAvailable": "Update Available",
|
||||||
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
||||||
"domainPickerEnterDomain": "Domain",
|
"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.",
|
"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",
|
"domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
|
||||||
"domainPickerTabAll": "All",
|
"domainPickerTabAll": "All",
|
||||||
@@ -1496,5 +1502,22 @@
|
|||||||
"convertButton": "Convert This Node to Managed Self-Hosted"
|
"convertButton": "Convert This Node to Managed Self-Hosted"
|
||||||
},
|
},
|
||||||
"internationaldomaindetected": "International Domain Detected",
|
"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"
|
||||||
|
}
|
||||||
@@ -454,6 +454,8 @@
|
|||||||
"accessRoleErrorAddDescription": "Beim Hinzufügen des Benutzers zur Rolle ist ein Fehler aufgetreten.",
|
"accessRoleErrorAddDescription": "Beim Hinzufügen des Benutzers zur Rolle ist ein Fehler aufgetreten.",
|
||||||
"userSaved": "Benutzer gespeichert",
|
"userSaved": "Benutzer gespeichert",
|
||||||
"userSavedDescription": "Der Benutzer wurde aktualisiert.",
|
"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",
|
"accessControlsDescription": "Verwalten Sie, worauf dieser Benutzer in der Organisation zugreifen und was er tun kann",
|
||||||
"accessControlsSubmit": "Zugriffskontrollen speichern",
|
"accessControlsSubmit": "Zugriffskontrollen speichern",
|
||||||
"roles": "Rollen",
|
"roles": "Rollen",
|
||||||
@@ -911,6 +913,8 @@
|
|||||||
"idpConnectingToFinished": "Verbunden",
|
"idpConnectingToFinished": "Verbunden",
|
||||||
"idpErrorConnectingTo": "Es gab ein Problem bei der Verbindung zu {name}. Bitte kontaktieren Sie Ihren Administrator.",
|
"idpErrorConnectingTo": "Es gab ein Problem bei der Verbindung zu {name}. Bitte kontaktieren Sie Ihren Administrator.",
|
||||||
"idpErrorNotFound": "IdP nicht gefunden",
|
"idpErrorNotFound": "IdP nicht gefunden",
|
||||||
|
"idpGoogleAlt": "Google",
|
||||||
|
"idpAzureAlt": "Azure",
|
||||||
"inviteInvalid": "Ungültige Einladung",
|
"inviteInvalid": "Ungültige Einladung",
|
||||||
"inviteInvalidDescription": "Der Einladungslink ist ungültig.",
|
"inviteInvalidDescription": "Der Einladungslink ist ungültig.",
|
||||||
"inviteErrorWrongUser": "Einladung ist nicht für diesen Benutzer",
|
"inviteErrorWrongUser": "Einladung ist nicht für diesen Benutzer",
|
||||||
@@ -982,6 +986,8 @@
|
|||||||
"licenseTierProfessionalRequired": "Professional Edition erforderlich",
|
"licenseTierProfessionalRequired": "Professional Edition erforderlich",
|
||||||
"licenseTierProfessionalRequiredDescription": "Diese Funktion ist nur in der Professional Edition verfügbar.",
|
"licenseTierProfessionalRequiredDescription": "Diese Funktion ist nur in der Professional Edition verfügbar.",
|
||||||
"actionGetOrg": "Organisation abrufen",
|
"actionGetOrg": "Organisation abrufen",
|
||||||
|
"updateOrgUser": "Org Benutzer aktualisieren",
|
||||||
|
"createOrgUser": "Org Benutzer erstellen",
|
||||||
"actionUpdateOrg": "Organisation aktualisieren",
|
"actionUpdateOrg": "Organisation aktualisieren",
|
||||||
"actionUpdateUser": "Benutzer aktualisieren",
|
"actionUpdateUser": "Benutzer aktualisieren",
|
||||||
"actionGetUser": "Benutzer abrufen",
|
"actionGetUser": "Benutzer abrufen",
|
||||||
@@ -1234,7 +1240,7 @@
|
|||||||
"newtUpdateAvailable": "Update verfügbar",
|
"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.",
|
"newtUpdateAvailableInfo": "Eine neue Version von Newt ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.",
|
||||||
"domainPickerEnterDomain": "Domain",
|
"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.",
|
"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",
|
"domainPickerDescriptionSaas": "Geben Sie eine vollständige Domäne, Subdomäne oder einfach einen Namen ein, um verfügbare Optionen zu sehen",
|
||||||
"domainPickerTabAll": "Alle",
|
"domainPickerTabAll": "Alle",
|
||||||
@@ -1496,5 +1502,22 @@
|
|||||||
"convertButton": "Diesen Knoten in Managed Self-Hosted umwandeln"
|
"convertButton": "Diesen Knoten in Managed Self-Hosted umwandeln"
|
||||||
},
|
},
|
||||||
"internationaldomaindetected": "Internationale Domain erkannt",
|
"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"
|
||||||
|
}
|
||||||
@@ -454,6 +454,8 @@
|
|||||||
"accessRoleErrorAddDescription": "An error occurred while adding user to the role.",
|
"accessRoleErrorAddDescription": "An error occurred while adding user to the role.",
|
||||||
"userSaved": "User saved",
|
"userSaved": "User saved",
|
||||||
"userSavedDescription": "The user has been updated.",
|
"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",
|
"accessControlsDescription": "Manage what this user can access and do in the organization",
|
||||||
"accessControlsSubmit": "Save Access Controls",
|
"accessControlsSubmit": "Save Access Controls",
|
||||||
"roles": "Roles",
|
"roles": "Roles",
|
||||||
@@ -511,6 +513,7 @@
|
|||||||
"ipAddressErrorInvalidFormat": "Invalid IP address format",
|
"ipAddressErrorInvalidFormat": "Invalid IP address format",
|
||||||
"ipAddressErrorInvalidOctet": "Invalid IP address octet",
|
"ipAddressErrorInvalidOctet": "Invalid IP address octet",
|
||||||
"path": "Path",
|
"path": "Path",
|
||||||
|
"matchPath": "Match Path",
|
||||||
"ipAddressRange": "IP Range",
|
"ipAddressRange": "IP Range",
|
||||||
"rulesErrorFetch": "Failed to fetch rules",
|
"rulesErrorFetch": "Failed to fetch rules",
|
||||||
"rulesErrorFetchDescription": "An error occurred while fetching rules",
|
"rulesErrorFetchDescription": "An error occurred while fetching rules",
|
||||||
@@ -911,6 +914,8 @@
|
|||||||
"idpConnectingToFinished": "Connected",
|
"idpConnectingToFinished": "Connected",
|
||||||
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
|
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
|
||||||
"idpErrorNotFound": "IdP not found",
|
"idpErrorNotFound": "IdP not found",
|
||||||
|
"idpGoogleAlt": "Google",
|
||||||
|
"idpAzureAlt": "Azure",
|
||||||
"inviteInvalid": "Invalid Invite",
|
"inviteInvalid": "Invalid Invite",
|
||||||
"inviteInvalidDescription": "The invite link is invalid.",
|
"inviteInvalidDescription": "The invite link is invalid.",
|
||||||
"inviteErrorWrongUser": "Invite is not for this user",
|
"inviteErrorWrongUser": "Invite is not for this user",
|
||||||
@@ -982,6 +987,8 @@
|
|||||||
"licenseTierProfessionalRequired": "Professional Edition Required",
|
"licenseTierProfessionalRequired": "Professional Edition Required",
|
||||||
"licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.",
|
"licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.",
|
||||||
"actionGetOrg": "Get Organization",
|
"actionGetOrg": "Get Organization",
|
||||||
|
"updateOrgUser": "Update Org User",
|
||||||
|
"createOrgUser": "Create Org User",
|
||||||
"actionUpdateOrg": "Update Organization",
|
"actionUpdateOrg": "Update Organization",
|
||||||
"actionUpdateUser": "Update User",
|
"actionUpdateUser": "Update User",
|
||||||
"actionGetUser": "Get User",
|
"actionGetUser": "Get User",
|
||||||
@@ -991,6 +998,7 @@
|
|||||||
"actionDeleteSite": "Delete Site",
|
"actionDeleteSite": "Delete Site",
|
||||||
"actionGetSite": "Get Site",
|
"actionGetSite": "Get Site",
|
||||||
"actionListSites": "List Sites",
|
"actionListSites": "List Sites",
|
||||||
|
"actionApplyBlueprint": "Apply Blueprint",
|
||||||
"setupToken": "Setup Token",
|
"setupToken": "Setup Token",
|
||||||
"setupTokenDescription": "Enter the setup token from the server console.",
|
"setupTokenDescription": "Enter the setup token from the server console.",
|
||||||
"setupTokenRequired": "Setup token is required",
|
"setupTokenRequired": "Setup token is required",
|
||||||
@@ -1133,8 +1141,8 @@
|
|||||||
"sidebarLicense": "License",
|
"sidebarLicense": "License",
|
||||||
"sidebarClients": "Clients (Beta)",
|
"sidebarClients": "Clients (Beta)",
|
||||||
"sidebarDomains": "Domains",
|
"sidebarDomains": "Domains",
|
||||||
"enableDockerSocket": "Enable Docker Socket",
|
"enableDockerSocket": "Enable Docker Blueprint",
|
||||||
"enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.",
|
"enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt.",
|
||||||
"enableDockerSocketLink": "Learn More",
|
"enableDockerSocketLink": "Learn More",
|
||||||
"viewDockerContainers": "View Docker Containers",
|
"viewDockerContainers": "View Docker Containers",
|
||||||
"containersIn": "Containers in {siteName}",
|
"containersIn": "Containers in {siteName}",
|
||||||
@@ -1234,7 +1242,7 @@
|
|||||||
"newtUpdateAvailable": "Update Available",
|
"newtUpdateAvailable": "Update Available",
|
||||||
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
||||||
"domainPickerEnterDomain": "Domain",
|
"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.",
|
"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",
|
"domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
|
||||||
"domainPickerTabAll": "All",
|
"domainPickerTabAll": "All",
|
||||||
@@ -1392,8 +1400,6 @@
|
|||||||
"editInternalResourceDialogProtocol": "Protocol",
|
"editInternalResourceDialogProtocol": "Protocol",
|
||||||
"editInternalResourceDialogSitePort": "Site Port",
|
"editInternalResourceDialogSitePort": "Site Port",
|
||||||
"editInternalResourceDialogTargetConfiguration": "Target Configuration",
|
"editInternalResourceDialogTargetConfiguration": "Target Configuration",
|
||||||
"editInternalResourceDialogDestinationIP": "Destination IP",
|
|
||||||
"editInternalResourceDialogDestinationPort": "Destination Port",
|
|
||||||
"editInternalResourceDialogCancel": "Cancel",
|
"editInternalResourceDialogCancel": "Cancel",
|
||||||
"editInternalResourceDialogSaveResource": "Save Resource",
|
"editInternalResourceDialogSaveResource": "Save Resource",
|
||||||
"editInternalResourceDialogSuccess": "Success",
|
"editInternalResourceDialogSuccess": "Success",
|
||||||
@@ -1424,9 +1430,7 @@
|
|||||||
"createInternalResourceDialogSitePort": "Site Port",
|
"createInternalResourceDialogSitePort": "Site Port",
|
||||||
"createInternalResourceDialogSitePortDescription": "Use this port to access the resource on the site when connected with a client.",
|
"createInternalResourceDialogSitePortDescription": "Use this port to access the resource on the site when connected with a client.",
|
||||||
"createInternalResourceDialogTargetConfiguration": "Target Configuration",
|
"createInternalResourceDialogTargetConfiguration": "Target Configuration",
|
||||||
"createInternalResourceDialogDestinationIP": "Destination IP",
|
"createInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.",
|
||||||
"createInternalResourceDialogDestinationIPDescription": "The IP address of the resource on the site's network.",
|
|
||||||
"createInternalResourceDialogDestinationPort": "Destination Port",
|
|
||||||
"createInternalResourceDialogDestinationPortDescription": "The port on the destination IP where the resource is accessible.",
|
"createInternalResourceDialogDestinationPortDescription": "The port on the destination IP where the resource is accessible.",
|
||||||
"createInternalResourceDialogCancel": "Cancel",
|
"createInternalResourceDialogCancel": "Cancel",
|
||||||
"createInternalResourceDialogCreateResource": "Create Resource",
|
"createInternalResourceDialogCreateResource": "Create Resource",
|
||||||
@@ -1496,5 +1500,25 @@
|
|||||||
"convertButton": "Convert This Node to Managed Self-Hosted"
|
"convertButton": "Convert This Node to Managed Self-Hosted"
|
||||||
},
|
},
|
||||||
"internationaldomaindetected": "International Domain Detected",
|
"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"
|
||||||
|
}
|
||||||
@@ -454,6 +454,8 @@
|
|||||||
"accessRoleErrorAddDescription": "Ocurrió un error mientras se añadía el usuario al rol.",
|
"accessRoleErrorAddDescription": "Ocurrió un error mientras se añadía el usuario al rol.",
|
||||||
"userSaved": "Usuario guardado",
|
"userSaved": "Usuario guardado",
|
||||||
"userSavedDescription": "El usuario ha sido actualizado.",
|
"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",
|
"accessControlsDescription": "Administrar lo que este usuario puede acceder y hacer en la organización",
|
||||||
"accessControlsSubmit": "Guardar controles de acceso",
|
"accessControlsSubmit": "Guardar controles de acceso",
|
||||||
"roles": "Roles",
|
"roles": "Roles",
|
||||||
@@ -911,6 +913,8 @@
|
|||||||
"idpConnectingToFinished": "Conectado",
|
"idpConnectingToFinished": "Conectado",
|
||||||
"idpErrorConnectingTo": "Hubo un problema al conectar con {name}. Por favor, póngase en contacto con su administrador.",
|
"idpErrorConnectingTo": "Hubo un problema al conectar con {name}. Por favor, póngase en contacto con su administrador.",
|
||||||
"idpErrorNotFound": "IdP no encontrado",
|
"idpErrorNotFound": "IdP no encontrado",
|
||||||
|
"idpGoogleAlt": "Google",
|
||||||
|
"idpAzureAlt": "Azure",
|
||||||
"inviteInvalid": "Invitación inválida",
|
"inviteInvalid": "Invitación inválida",
|
||||||
"inviteInvalidDescription": "El enlace de invitación no es válido.",
|
"inviteInvalidDescription": "El enlace de invitación no es válido.",
|
||||||
"inviteErrorWrongUser": "La invitación no es para este usuario",
|
"inviteErrorWrongUser": "La invitación no es para este usuario",
|
||||||
@@ -982,6 +986,8 @@
|
|||||||
"licenseTierProfessionalRequired": "Edición Profesional requerida",
|
"licenseTierProfessionalRequired": "Edición Profesional requerida",
|
||||||
"licenseTierProfessionalRequiredDescription": "Esta característica sólo está disponible en la Edición Profesional.",
|
"licenseTierProfessionalRequiredDescription": "Esta característica sólo está disponible en la Edición Profesional.",
|
||||||
"actionGetOrg": "Obtener organización",
|
"actionGetOrg": "Obtener organización",
|
||||||
|
"updateOrgUser": "Actualizar usuario Org",
|
||||||
|
"createOrgUser": "Crear usuario Org",
|
||||||
"actionUpdateOrg": "Actualizar organización",
|
"actionUpdateOrg": "Actualizar organización",
|
||||||
"actionUpdateUser": "Actualizar usuario",
|
"actionUpdateUser": "Actualizar usuario",
|
||||||
"actionGetUser": "Obtener usuario",
|
"actionGetUser": "Obtener usuario",
|
||||||
@@ -1234,7 +1240,7 @@
|
|||||||
"newtUpdateAvailable": "Nueva actualización disponible",
|
"newtUpdateAvailable": "Nueva actualización disponible",
|
||||||
"newtUpdateAvailableInfo": "Hay una nueva versión de Newt disponible. Actualice a la última versión para la mejor experiencia.",
|
"newtUpdateAvailableInfo": "Hay una nueva versión de Newt disponible. Actualice a la última versión para la mejor experiencia.",
|
||||||
"domainPickerEnterDomain": "Dominio",
|
"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.",
|
"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",
|
"domainPickerDescriptionSaas": "Ingresa un dominio completo, subdominio o simplemente un nombre para ver las opciones disponibles",
|
||||||
"domainPickerTabAll": "Todo",
|
"domainPickerTabAll": "Todo",
|
||||||
@@ -1496,5 +1502,22 @@
|
|||||||
"convertButton": "Convierte este nodo a autoalojado administrado"
|
"convertButton": "Convierte este nodo a autoalojado administrado"
|
||||||
},
|
},
|
||||||
"internationaldomaindetected": "Dominio Internacional detectado",
|
"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"
|
||||||
|
}
|
||||||
@@ -454,6 +454,8 @@
|
|||||||
"accessRoleErrorAddDescription": "Une erreur s'est produite lors de l'ajout de l'utilisateur au rôle.",
|
"accessRoleErrorAddDescription": "Une erreur s'est produite lors de l'ajout de l'utilisateur au rôle.",
|
||||||
"userSaved": "Utilisateur enregistré",
|
"userSaved": "Utilisateur enregistré",
|
||||||
"userSavedDescription": "L'utilisateur a été mis à jour.",
|
"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",
|
"accessControlsDescription": "Gérer ce que cet utilisateur peut accéder et faire dans l'organisation",
|
||||||
"accessControlsSubmit": "Enregistrer les contrôles d'accès",
|
"accessControlsSubmit": "Enregistrer les contrôles d'accès",
|
||||||
"roles": "Rôles",
|
"roles": "Rôles",
|
||||||
@@ -911,6 +913,8 @@
|
|||||||
"idpConnectingToFinished": "Connecté",
|
"idpConnectingToFinished": "Connecté",
|
||||||
"idpErrorConnectingTo": "Un problème est survenu lors de la connexion à {name}. Veuillez contacter votre administrateur.",
|
"idpErrorConnectingTo": "Un problème est survenu lors de la connexion à {name}. Veuillez contacter votre administrateur.",
|
||||||
"idpErrorNotFound": "IdP introuvable",
|
"idpErrorNotFound": "IdP introuvable",
|
||||||
|
"idpGoogleAlt": "Google",
|
||||||
|
"idpAzureAlt": "Azure",
|
||||||
"inviteInvalid": "Invitation invalide",
|
"inviteInvalid": "Invitation invalide",
|
||||||
"inviteInvalidDescription": "Le lien d'invitation n'est pas valide.",
|
"inviteInvalidDescription": "Le lien d'invitation n'est pas valide.",
|
||||||
"inviteErrorWrongUser": "L'invitation n'est pas pour cet utilisateur",
|
"inviteErrorWrongUser": "L'invitation n'est pas pour cet utilisateur",
|
||||||
@@ -982,6 +986,8 @@
|
|||||||
"licenseTierProfessionalRequired": "Édition Professionnelle Requise",
|
"licenseTierProfessionalRequired": "Édition Professionnelle Requise",
|
||||||
"licenseTierProfessionalRequiredDescription": "Cette fonctionnalité n'est disponible que dans l'Édition Professionnelle.",
|
"licenseTierProfessionalRequiredDescription": "Cette fonctionnalité n'est disponible que dans l'Édition Professionnelle.",
|
||||||
"actionGetOrg": "Obtenir l'organisation",
|
"actionGetOrg": "Obtenir l'organisation",
|
||||||
|
"updateOrgUser": "Mise à jour de l'utilisateur Org",
|
||||||
|
"createOrgUser": "Créer un utilisateur Org",
|
||||||
"actionUpdateOrg": "Mettre à jour l'organisation",
|
"actionUpdateOrg": "Mettre à jour l'organisation",
|
||||||
"actionUpdateUser": "Mettre à jour l'utilisateur",
|
"actionUpdateUser": "Mettre à jour l'utilisateur",
|
||||||
"actionGetUser": "Obtenir l'utilisateur",
|
"actionGetUser": "Obtenir l'utilisateur",
|
||||||
@@ -1234,7 +1240,7 @@
|
|||||||
"newtUpdateAvailable": "Mise à jour disponible",
|
"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.",
|
"newtUpdateAvailableInfo": "Une nouvelle version de Newt est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.",
|
||||||
"domainPickerEnterDomain": "Domaine",
|
"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.",
|
"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",
|
"domainPickerDescriptionSaas": "Entrez un domaine complet, un sous-domaine ou juste un nom pour voir les options disponibles",
|
||||||
"domainPickerTabAll": "Tous",
|
"domainPickerTabAll": "Tous",
|
||||||
@@ -1496,5 +1502,22 @@
|
|||||||
"convertButton": "Convertir ce noeud en auto-hébergé géré"
|
"convertButton": "Convertir ce noeud en auto-hébergé géré"
|
||||||
},
|
},
|
||||||
"internationaldomaindetected": "Domaine international détecté",
|
"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"
|
||||||
|
}
|
||||||
@@ -454,6 +454,8 @@
|
|||||||
"accessRoleErrorAddDescription": "Si è verificato un errore durante l'aggiunta dell'utente al ruolo.",
|
"accessRoleErrorAddDescription": "Si è verificato un errore durante l'aggiunta dell'utente al ruolo.",
|
||||||
"userSaved": "Utente salvato",
|
"userSaved": "Utente salvato",
|
||||||
"userSavedDescription": "L'utente è stato aggiornato.",
|
"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",
|
"accessControlsDescription": "Gestisci cosa questo utente può accedere e fare nell'organizzazione",
|
||||||
"accessControlsSubmit": "Salva Controlli di Accesso",
|
"accessControlsSubmit": "Salva Controlli di Accesso",
|
||||||
"roles": "Ruoli",
|
"roles": "Ruoli",
|
||||||
@@ -911,6 +913,8 @@
|
|||||||
"idpConnectingToFinished": "Connesso",
|
"idpConnectingToFinished": "Connesso",
|
||||||
"idpErrorConnectingTo": "Si è verificato un problema durante la connessione a {name}. Contatta il tuo amministratore.",
|
"idpErrorConnectingTo": "Si è verificato un problema durante la connessione a {name}. Contatta il tuo amministratore.",
|
||||||
"idpErrorNotFound": "IdP non trovato",
|
"idpErrorNotFound": "IdP non trovato",
|
||||||
|
"idpGoogleAlt": "Google",
|
||||||
|
"idpAzureAlt": "Azure",
|
||||||
"inviteInvalid": "Invito Non Valido",
|
"inviteInvalid": "Invito Non Valido",
|
||||||
"inviteInvalidDescription": "Il link di invito non è valido.",
|
"inviteInvalidDescription": "Il link di invito non è valido.",
|
||||||
"inviteErrorWrongUser": "L'invito non è per questo utente",
|
"inviteErrorWrongUser": "L'invito non è per questo utente",
|
||||||
@@ -982,6 +986,8 @@
|
|||||||
"licenseTierProfessionalRequired": "Edizione Professional Richiesta",
|
"licenseTierProfessionalRequired": "Edizione Professional Richiesta",
|
||||||
"licenseTierProfessionalRequiredDescription": "Questa funzionalità è disponibile solo nell'Edizione Professional.",
|
"licenseTierProfessionalRequiredDescription": "Questa funzionalità è disponibile solo nell'Edizione Professional.",
|
||||||
"actionGetOrg": "Ottieni Organizzazione",
|
"actionGetOrg": "Ottieni Organizzazione",
|
||||||
|
"updateOrgUser": "Aggiorna Utente Org",
|
||||||
|
"createOrgUser": "Crea Utente Org",
|
||||||
"actionUpdateOrg": "Aggiorna Organizzazione",
|
"actionUpdateOrg": "Aggiorna Organizzazione",
|
||||||
"actionUpdateUser": "Aggiorna Utente",
|
"actionUpdateUser": "Aggiorna Utente",
|
||||||
"actionGetUser": "Ottieni Utente",
|
"actionGetUser": "Ottieni Utente",
|
||||||
@@ -1234,7 +1240,7 @@
|
|||||||
"newtUpdateAvailable": "Aggiornamento Disponibile",
|
"newtUpdateAvailable": "Aggiornamento Disponibile",
|
||||||
"newtUpdateAvailableInfo": "È disponibile una nuova versione di Newt. Si prega di aggiornare all'ultima versione per la migliore esperienza.",
|
"newtUpdateAvailableInfo": "È disponibile una nuova versione di Newt. Si prega di aggiornare all'ultima versione per la migliore esperienza.",
|
||||||
"domainPickerEnterDomain": "Dominio",
|
"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.",
|
"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",
|
"domainPickerDescriptionSaas": "Inserisci un dominio completo, un sottodominio o semplicemente un nome per vedere le opzioni disponibili",
|
||||||
"domainPickerTabAll": "Tutti",
|
"domainPickerTabAll": "Tutti",
|
||||||
@@ -1496,5 +1502,22 @@
|
|||||||
"convertButton": "Converti questo nodo in auto-ospitato gestito"
|
"convertButton": "Converti questo nodo in auto-ospitato gestito"
|
||||||
},
|
},
|
||||||
"internationaldomaindetected": "Dominio Internazionale Rilevato",
|
"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"
|
||||||
|
}
|
||||||
@@ -454,6 +454,8 @@
|
|||||||
"accessRoleErrorAddDescription": "사용자를 역할에 추가하는 동안 오류가 발생했습니다.",
|
"accessRoleErrorAddDescription": "사용자를 역할에 추가하는 동안 오류가 발생했습니다.",
|
||||||
"userSaved": "사용자 저장됨",
|
"userSaved": "사용자 저장됨",
|
||||||
"userSavedDescription": "사용자가 업데이트되었습니다.",
|
"userSavedDescription": "사용자가 업데이트되었습니다.",
|
||||||
|
"autoProvisioned": "자동 프로비저닝됨",
|
||||||
|
"autoProvisionedDescription": "이 사용자가 ID 공급자에 의해 자동으로 관리될 수 있도록 허용합니다",
|
||||||
"accessControlsDescription": "이 사용자가 조직에서 접근하고 수행할 수 있는 작업을 관리하세요",
|
"accessControlsDescription": "이 사용자가 조직에서 접근하고 수행할 수 있는 작업을 관리하세요",
|
||||||
"accessControlsSubmit": "접근 제어 저장",
|
"accessControlsSubmit": "접근 제어 저장",
|
||||||
"roles": "역할",
|
"roles": "역할",
|
||||||
@@ -911,6 +913,8 @@
|
|||||||
"idpConnectingToFinished": "연결됨",
|
"idpConnectingToFinished": "연결됨",
|
||||||
"idpErrorConnectingTo": "{name}에 연결하는 데 문제가 발생했습니다. 관리자에게 문의하십시오.",
|
"idpErrorConnectingTo": "{name}에 연결하는 데 문제가 발생했습니다. 관리자에게 문의하십시오.",
|
||||||
"idpErrorNotFound": "IdP를 찾을 수 없습니다.",
|
"idpErrorNotFound": "IdP를 찾을 수 없습니다.",
|
||||||
|
"idpGoogleAlt": "구글",
|
||||||
|
"idpAzureAlt": "애저",
|
||||||
"inviteInvalid": "유효하지 않은 초대",
|
"inviteInvalid": "유효하지 않은 초대",
|
||||||
"inviteInvalidDescription": "초대 링크가 유효하지 않습니다.",
|
"inviteInvalidDescription": "초대 링크가 유효하지 않습니다.",
|
||||||
"inviteErrorWrongUser": "이 초대는 이 사용자에게 해당되지 않습니다",
|
"inviteErrorWrongUser": "이 초대는 이 사용자에게 해당되지 않습니다",
|
||||||
@@ -982,6 +986,8 @@
|
|||||||
"licenseTierProfessionalRequired": "전문 에디션이 필요합니다.",
|
"licenseTierProfessionalRequired": "전문 에디션이 필요합니다.",
|
||||||
"licenseTierProfessionalRequiredDescription": "이 기능은 Professional Edition에서만 사용할 수 있습니다.",
|
"licenseTierProfessionalRequiredDescription": "이 기능은 Professional Edition에서만 사용할 수 있습니다.",
|
||||||
"actionGetOrg": "조직 가져오기",
|
"actionGetOrg": "조직 가져오기",
|
||||||
|
"updateOrgUser": "조직 사용자 업데이트",
|
||||||
|
"createOrgUser": "조직 사용자 생성",
|
||||||
"actionUpdateOrg": "조직 업데이트",
|
"actionUpdateOrg": "조직 업데이트",
|
||||||
"actionUpdateUser": "사용자 업데이트",
|
"actionUpdateUser": "사용자 업데이트",
|
||||||
"actionGetUser": "사용자 조회",
|
"actionGetUser": "사용자 조회",
|
||||||
@@ -1234,7 +1240,7 @@
|
|||||||
"newtUpdateAvailable": "업데이트 가능",
|
"newtUpdateAvailable": "업데이트 가능",
|
||||||
"newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
|
"newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
|
||||||
"domainPickerEnterDomain": "도메인",
|
"domainPickerEnterDomain": "도메인",
|
||||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, 또는 그냥 myapp",
|
"domainPickerPlaceholder": "myapp.example.com",
|
||||||
"domainPickerDescription": "리소스의 전체 도메인을 입력하여 사용 가능한 옵션을 확인하십시오.",
|
"domainPickerDescription": "리소스의 전체 도메인을 입력하여 사용 가능한 옵션을 확인하십시오.",
|
||||||
"domainPickerDescriptionSaas": "전체 도메인, 서브도메인 또는 이름을 입력하여 사용 가능한 옵션을 확인하십시오.",
|
"domainPickerDescriptionSaas": "전체 도메인, 서브도메인 또는 이름을 입력하여 사용 가능한 옵션을 확인하십시오.",
|
||||||
"domainPickerTabAll": "모두",
|
"domainPickerTabAll": "모두",
|
||||||
@@ -1496,5 +1502,22 @@
|
|||||||
"convertButton": "이 노드를 관리 자체 호스팅으로 변환"
|
"convertButton": "이 노드를 관리 자체 호스팅으로 변환"
|
||||||
},
|
},
|
||||||
"internationaldomaindetected": "국제 도메인 감지됨",
|
"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"
|
||||||
|
}
|
||||||
@@ -454,6 +454,8 @@
|
|||||||
"accessRoleErrorAddDescription": "Det oppstod en feil under tilordning av brukeren til rollen.",
|
"accessRoleErrorAddDescription": "Det oppstod en feil under tilordning av brukeren til rollen.",
|
||||||
"userSaved": "Bruker lagret",
|
"userSaved": "Bruker lagret",
|
||||||
"userSavedDescription": "Brukeren har blitt oppdatert.",
|
"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",
|
"accessControlsDescription": "Administrer hva denne brukeren kan få tilgang til og gjøre i organisasjonen",
|
||||||
"accessControlsSubmit": "Lagre tilgangskontroller",
|
"accessControlsSubmit": "Lagre tilgangskontroller",
|
||||||
"roles": "Roller",
|
"roles": "Roller",
|
||||||
@@ -911,6 +913,8 @@
|
|||||||
"idpConnectingToFinished": "Tilkoblet",
|
"idpConnectingToFinished": "Tilkoblet",
|
||||||
"idpErrorConnectingTo": "Det oppstod et problem med å koble til {name}. Vennligst kontakt din administrator.",
|
"idpErrorConnectingTo": "Det oppstod et problem med å koble til {name}. Vennligst kontakt din administrator.",
|
||||||
"idpErrorNotFound": "IdP ikke funnet",
|
"idpErrorNotFound": "IdP ikke funnet",
|
||||||
|
"idpGoogleAlt": "Google",
|
||||||
|
"idpAzureAlt": "Azure",
|
||||||
"inviteInvalid": "Ugyldig invitasjon",
|
"inviteInvalid": "Ugyldig invitasjon",
|
||||||
"inviteInvalidDescription": "Invitasjonslenken er ugyldig.",
|
"inviteInvalidDescription": "Invitasjonslenken er ugyldig.",
|
||||||
"inviteErrorWrongUser": "Invitasjonen er ikke for denne brukeren",
|
"inviteErrorWrongUser": "Invitasjonen er ikke for denne brukeren",
|
||||||
@@ -982,6 +986,8 @@
|
|||||||
"licenseTierProfessionalRequired": "Profesjonell utgave påkrevd",
|
"licenseTierProfessionalRequired": "Profesjonell utgave påkrevd",
|
||||||
"licenseTierProfessionalRequiredDescription": "Denne funksjonen er kun tilgjengelig i den profesjonelle utgaven.",
|
"licenseTierProfessionalRequiredDescription": "Denne funksjonen er kun tilgjengelig i den profesjonelle utgaven.",
|
||||||
"actionGetOrg": "Hent organisasjon",
|
"actionGetOrg": "Hent organisasjon",
|
||||||
|
"updateOrgUser": "Oppdater org.bruker",
|
||||||
|
"createOrgUser": "Opprett Org bruker",
|
||||||
"actionUpdateOrg": "Oppdater organisasjon",
|
"actionUpdateOrg": "Oppdater organisasjon",
|
||||||
"actionUpdateUser": "Oppdater bruker",
|
"actionUpdateUser": "Oppdater bruker",
|
||||||
"actionGetUser": "Hent bruker",
|
"actionGetUser": "Hent bruker",
|
||||||
@@ -1234,7 +1240,7 @@
|
|||||||
"newtUpdateAvailable": "Oppdatering tilgjengelig",
|
"newtUpdateAvailable": "Oppdatering tilgjengelig",
|
||||||
"newtUpdateAvailableInfo": "En ny versjon av Newt er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.",
|
"newtUpdateAvailableInfo": "En ny versjon av Newt er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.",
|
||||||
"domainPickerEnterDomain": "Domene",
|
"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.",
|
"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",
|
"domainPickerDescriptionSaas": "Skriv inn et fullt domene, underdomene eller bare et navn for å se tilgjengelige alternativer",
|
||||||
"domainPickerTabAll": "Alle",
|
"domainPickerTabAll": "Alle",
|
||||||
@@ -1496,5 +1502,22 @@
|
|||||||
"convertButton": "Konverter denne noden til manuelt bruk"
|
"convertButton": "Konverter denne noden til manuelt bruk"
|
||||||
},
|
},
|
||||||
"internationaldomaindetected": "Internasjonalt domene oppdaget",
|
"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"
|
||||||
|
}
|
||||||
@@ -454,6 +454,8 @@
|
|||||||
"accessRoleErrorAddDescription": "Er is een fout opgetreden tijdens het toevoegen van de rol.",
|
"accessRoleErrorAddDescription": "Er is een fout opgetreden tijdens het toevoegen van de rol.",
|
||||||
"userSaved": "Gebruiker opgeslagen",
|
"userSaved": "Gebruiker opgeslagen",
|
||||||
"userSavedDescription": "De gebruiker is bijgewerkt.",
|
"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",
|
"accessControlsDescription": "Beheer wat deze gebruiker toegang heeft tot en doet in de organisatie",
|
||||||
"accessControlsSubmit": "Bewaar Toegangsbesturing",
|
"accessControlsSubmit": "Bewaar Toegangsbesturing",
|
||||||
"roles": "Rollen",
|
"roles": "Rollen",
|
||||||
@@ -911,6 +913,8 @@
|
|||||||
"idpConnectingToFinished": "Verbonden",
|
"idpConnectingToFinished": "Verbonden",
|
||||||
"idpErrorConnectingTo": "Er was een probleem bij het verbinden met {name}. Neem contact op met uw beheerder.",
|
"idpErrorConnectingTo": "Er was een probleem bij het verbinden met {name}. Neem contact op met uw beheerder.",
|
||||||
"idpErrorNotFound": "IdP niet gevonden",
|
"idpErrorNotFound": "IdP niet gevonden",
|
||||||
|
"idpGoogleAlt": "Google",
|
||||||
|
"idpAzureAlt": "Azure",
|
||||||
"inviteInvalid": "Ongeldige uitnodiging",
|
"inviteInvalid": "Ongeldige uitnodiging",
|
||||||
"inviteInvalidDescription": "Uitnodigingslink is ongeldig.",
|
"inviteInvalidDescription": "Uitnodigingslink is ongeldig.",
|
||||||
"inviteErrorWrongUser": "Uitnodiging is niet voor deze gebruiker",
|
"inviteErrorWrongUser": "Uitnodiging is niet voor deze gebruiker",
|
||||||
@@ -982,6 +986,8 @@
|
|||||||
"licenseTierProfessionalRequired": "Professionele editie vereist",
|
"licenseTierProfessionalRequired": "Professionele editie vereist",
|
||||||
"licenseTierProfessionalRequiredDescription": "Deze functie is alleen beschikbaar in de Professional Edition.",
|
"licenseTierProfessionalRequiredDescription": "Deze functie is alleen beschikbaar in de Professional Edition.",
|
||||||
"actionGetOrg": "Krijg Organisatie",
|
"actionGetOrg": "Krijg Organisatie",
|
||||||
|
"updateOrgUser": "Org gebruiker bijwerken",
|
||||||
|
"createOrgUser": "Org gebruiker aanmaken",
|
||||||
"actionUpdateOrg": "Organisatie bijwerken",
|
"actionUpdateOrg": "Organisatie bijwerken",
|
||||||
"actionUpdateUser": "Gebruiker bijwerken",
|
"actionUpdateUser": "Gebruiker bijwerken",
|
||||||
"actionGetUser": "Gebruiker ophalen",
|
"actionGetUser": "Gebruiker ophalen",
|
||||||
@@ -1234,7 +1240,7 @@
|
|||||||
"newtUpdateAvailable": "Update beschikbaar",
|
"newtUpdateAvailable": "Update beschikbaar",
|
||||||
"newtUpdateAvailableInfo": "Er is een nieuwe versie van Newt beschikbaar. Update naar de nieuwste versie voor de beste ervaring.",
|
"newtUpdateAvailableInfo": "Er is een nieuwe versie van Newt beschikbaar. Update naar de nieuwste versie voor de beste ervaring.",
|
||||||
"domainPickerEnterDomain": "Domein",
|
"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.",
|
"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",
|
"domainPickerDescriptionSaas": "Voer een volledig domein, subdomein of gewoon een naam in om beschikbare opties te zien",
|
||||||
"domainPickerTabAll": "Alles",
|
"domainPickerTabAll": "Alles",
|
||||||
@@ -1496,5 +1502,22 @@
|
|||||||
"convertButton": "Converteer deze node naar Beheerde Zelf-Hosted"
|
"convertButton": "Converteer deze node naar Beheerde Zelf-Hosted"
|
||||||
},
|
},
|
||||||
"internationaldomaindetected": "Internationaal Domein Gedetecteerd",
|
"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"
|
||||||
|
}
|
||||||
@@ -454,6 +454,8 @@
|
|||||||
"accessRoleErrorAddDescription": "Wystąpił błąd podczas dodawania użytkownika do roli.",
|
"accessRoleErrorAddDescription": "Wystąpił błąd podczas dodawania użytkownika do roli.",
|
||||||
"userSaved": "Użytkownik zapisany",
|
"userSaved": "Użytkownik zapisany",
|
||||||
"userSavedDescription": "Użytkownik został zaktualizowany.",
|
"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",
|
"accessControlsDescription": "Zarządzaj tym, do czego użytkownik ma dostęp i co może robić w organizacji",
|
||||||
"accessControlsSubmit": "Zapisz kontrole dostępu",
|
"accessControlsSubmit": "Zapisz kontrole dostępu",
|
||||||
"roles": "Role",
|
"roles": "Role",
|
||||||
@@ -911,6 +913,8 @@
|
|||||||
"idpConnectingToFinished": "Połączono",
|
"idpConnectingToFinished": "Połączono",
|
||||||
"idpErrorConnectingTo": "Wystąpił problem z połączeniem z {name}. Skontaktuj się z administratorem.",
|
"idpErrorConnectingTo": "Wystąpił problem z połączeniem z {name}. Skontaktuj się z administratorem.",
|
||||||
"idpErrorNotFound": "Nie znaleziono IdP",
|
"idpErrorNotFound": "Nie znaleziono IdP",
|
||||||
|
"idpGoogleAlt": "Google",
|
||||||
|
"idpAzureAlt": "Azure",
|
||||||
"inviteInvalid": "Nieprawidłowe zaproszenie",
|
"inviteInvalid": "Nieprawidłowe zaproszenie",
|
||||||
"inviteInvalidDescription": "Link zapraszający jest nieprawidłowy.",
|
"inviteInvalidDescription": "Link zapraszający jest nieprawidłowy.",
|
||||||
"inviteErrorWrongUser": "Zaproszenie nie jest dla tego użytkownika",
|
"inviteErrorWrongUser": "Zaproszenie nie jest dla tego użytkownika",
|
||||||
@@ -982,6 +986,8 @@
|
|||||||
"licenseTierProfessionalRequired": "Wymagana edycja Professional",
|
"licenseTierProfessionalRequired": "Wymagana edycja Professional",
|
||||||
"licenseTierProfessionalRequiredDescription": "Ta funkcja jest dostępna tylko w edycji Professional.",
|
"licenseTierProfessionalRequiredDescription": "Ta funkcja jest dostępna tylko w edycji Professional.",
|
||||||
"actionGetOrg": "Pobierz organizację",
|
"actionGetOrg": "Pobierz organizację",
|
||||||
|
"updateOrgUser": "Aktualizuj użytkownika Org",
|
||||||
|
"createOrgUser": "Utwórz użytkownika Org",
|
||||||
"actionUpdateOrg": "Aktualizuj organizację",
|
"actionUpdateOrg": "Aktualizuj organizację",
|
||||||
"actionUpdateUser": "Zaktualizuj użytkownika",
|
"actionUpdateUser": "Zaktualizuj użytkownika",
|
||||||
"actionGetUser": "Pobierz użytkownika",
|
"actionGetUser": "Pobierz użytkownika",
|
||||||
@@ -1234,7 +1240,7 @@
|
|||||||
"newtUpdateAvailable": "Dostępna aktualizacja",
|
"newtUpdateAvailable": "Dostępna aktualizacja",
|
||||||
"newtUpdateAvailableInfo": "Nowa wersja Newt jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.",
|
"newtUpdateAvailableInfo": "Nowa wersja Newt jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.",
|
||||||
"domainPickerEnterDomain": "Domena",
|
"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.",
|
"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",
|
"domainPickerDescriptionSaas": "Wprowadź pełną domenę, subdomenę lub po prostu nazwę, aby zobaczyć dostępne opcje",
|
||||||
"domainPickerTabAll": "Wszystko",
|
"domainPickerTabAll": "Wszystko",
|
||||||
@@ -1496,5 +1502,22 @@
|
|||||||
"convertButton": "Konwertuj ten węzeł do zarządzanego samodzielnie"
|
"convertButton": "Konwertuj ten węzeł do zarządzanego samodzielnie"
|
||||||
},
|
},
|
||||||
"internationaldomaindetected": "Wykryto międzynarodową domenę",
|
"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"
|
||||||
|
}
|
||||||
@@ -454,6 +454,8 @@
|
|||||||
"accessRoleErrorAddDescription": "Ocorreu um erro ao adicionar usuário à função.",
|
"accessRoleErrorAddDescription": "Ocorreu um erro ao adicionar usuário à função.",
|
||||||
"userSaved": "Usuário salvo",
|
"userSaved": "Usuário salvo",
|
||||||
"userSavedDescription": "O usuário foi atualizado.",
|
"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",
|
"accessControlsDescription": "Gerencie o que este usuário pode acessar e fazer na organização",
|
||||||
"accessControlsSubmit": "Salvar Controles de Acesso",
|
"accessControlsSubmit": "Salvar Controles de Acesso",
|
||||||
"roles": "Funções",
|
"roles": "Funções",
|
||||||
@@ -911,6 +913,8 @@
|
|||||||
"idpConnectingToFinished": "Conectado",
|
"idpConnectingToFinished": "Conectado",
|
||||||
"idpErrorConnectingTo": "Ocorreu um problema ao ligar a {name}. Por favor, contacte o seu administrador.",
|
"idpErrorConnectingTo": "Ocorreu um problema ao ligar a {name}. Por favor, contacte o seu administrador.",
|
||||||
"idpErrorNotFound": "IdP não encontrado",
|
"idpErrorNotFound": "IdP não encontrado",
|
||||||
|
"idpGoogleAlt": "Google",
|
||||||
|
"idpAzureAlt": "Azure",
|
||||||
"inviteInvalid": "Convite Inválido",
|
"inviteInvalid": "Convite Inválido",
|
||||||
"inviteInvalidDescription": "O link do convite é inválido.",
|
"inviteInvalidDescription": "O link do convite é inválido.",
|
||||||
"inviteErrorWrongUser": "O convite não é para este usuário",
|
"inviteErrorWrongUser": "O convite não é para este usuário",
|
||||||
@@ -982,6 +986,8 @@
|
|||||||
"licenseTierProfessionalRequired": "Edição Profissional Necessária",
|
"licenseTierProfessionalRequired": "Edição Profissional Necessária",
|
||||||
"licenseTierProfessionalRequiredDescription": "Esta funcionalidade só está disponível na Edição Profissional.",
|
"licenseTierProfessionalRequiredDescription": "Esta funcionalidade só está disponível na Edição Profissional.",
|
||||||
"actionGetOrg": "Obter Organização",
|
"actionGetOrg": "Obter Organização",
|
||||||
|
"updateOrgUser": "Atualizar usuário Org",
|
||||||
|
"createOrgUser": "Criar usuário Org",
|
||||||
"actionUpdateOrg": "Atualizar Organização",
|
"actionUpdateOrg": "Atualizar Organização",
|
||||||
"actionUpdateUser": "Atualizar Usuário",
|
"actionUpdateUser": "Atualizar Usuário",
|
||||||
"actionGetUser": "Obter Usuário",
|
"actionGetUser": "Obter Usuário",
|
||||||
@@ -1234,7 +1240,7 @@
|
|||||||
"newtUpdateAvailable": "Nova Atualização Disponível",
|
"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.",
|
"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",
|
"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.",
|
"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",
|
"domainPickerDescriptionSaas": "Insira um domínio completo, subdomínio ou apenas um nome para ver as opções disponíveis",
|
||||||
"domainPickerTabAll": "Todos",
|
"domainPickerTabAll": "Todos",
|
||||||
@@ -1496,5 +1502,22 @@
|
|||||||
"convertButton": "Converter este nó para Auto-Hospedado Gerenciado"
|
"convertButton": "Converter este nó para Auto-Hospedado Gerenciado"
|
||||||
},
|
},
|
||||||
"internationaldomaindetected": "Domínio Internacional Detectado",
|
"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"
|
||||||
|
}
|
||||||
@@ -454,6 +454,8 @@
|
|||||||
"accessRoleErrorAddDescription": "Произошла ошибка при добавлении пользователя в роль.",
|
"accessRoleErrorAddDescription": "Произошла ошибка при добавлении пользователя в роль.",
|
||||||
"userSaved": "Пользователь сохранён",
|
"userSaved": "Пользователь сохранён",
|
||||||
"userSavedDescription": "Пользователь был обновлён.",
|
"userSavedDescription": "Пользователь был обновлён.",
|
||||||
|
"autoProvisioned": "Автоподбор",
|
||||||
|
"autoProvisionedDescription": "Разрешить автоматическое управление этим пользователем",
|
||||||
"accessControlsDescription": "Управляйте тем, к чему этот пользователь может получить доступ и что делать в организации",
|
"accessControlsDescription": "Управляйте тем, к чему этот пользователь может получить доступ и что делать в организации",
|
||||||
"accessControlsSubmit": "Сохранить контроль доступа",
|
"accessControlsSubmit": "Сохранить контроль доступа",
|
||||||
"roles": "Роли",
|
"roles": "Роли",
|
||||||
@@ -911,6 +913,8 @@
|
|||||||
"idpConnectingToFinished": "Подключено",
|
"idpConnectingToFinished": "Подключено",
|
||||||
"idpErrorConnectingTo": "Возникла проблема при подключении к {name}. Пожалуйста, свяжитесь с вашим администратором.",
|
"idpErrorConnectingTo": "Возникла проблема при подключении к {name}. Пожалуйста, свяжитесь с вашим администратором.",
|
||||||
"idpErrorNotFound": "IdP не найден",
|
"idpErrorNotFound": "IdP не найден",
|
||||||
|
"idpGoogleAlt": "Google",
|
||||||
|
"idpAzureAlt": "Azure",
|
||||||
"inviteInvalid": "Недействительное приглашение",
|
"inviteInvalid": "Недействительное приглашение",
|
||||||
"inviteInvalidDescription": "Ссылка на приглашение недействительна.",
|
"inviteInvalidDescription": "Ссылка на приглашение недействительна.",
|
||||||
"inviteErrorWrongUser": "Приглашение не для этого пользователя",
|
"inviteErrorWrongUser": "Приглашение не для этого пользователя",
|
||||||
@@ -982,6 +986,8 @@
|
|||||||
"licenseTierProfessionalRequired": "Требуется профессиональная версия",
|
"licenseTierProfessionalRequired": "Требуется профессиональная версия",
|
||||||
"licenseTierProfessionalRequiredDescription": "Эта функция доступна только в профессиональной версии.",
|
"licenseTierProfessionalRequiredDescription": "Эта функция доступна только в профессиональной версии.",
|
||||||
"actionGetOrg": "Получить организацию",
|
"actionGetOrg": "Получить организацию",
|
||||||
|
"updateOrgUser": "Обновить пользователя Org",
|
||||||
|
"createOrgUser": "Создать пользователя Org",
|
||||||
"actionUpdateOrg": "Обновить организацию",
|
"actionUpdateOrg": "Обновить организацию",
|
||||||
"actionUpdateUser": "Обновить пользователя",
|
"actionUpdateUser": "Обновить пользователя",
|
||||||
"actionGetUser": "Получить пользователя",
|
"actionGetUser": "Получить пользователя",
|
||||||
@@ -1234,7 +1240,7 @@
|
|||||||
"newtUpdateAvailable": "Доступно обновление",
|
"newtUpdateAvailable": "Доступно обновление",
|
||||||
"newtUpdateAvailableInfo": "Доступна новая версия Newt. Пожалуйста, обновитесь до последней версии для лучшего опыта.",
|
"newtUpdateAvailableInfo": "Доступна новая версия Newt. Пожалуйста, обновитесь до последней версии для лучшего опыта.",
|
||||||
"domainPickerEnterDomain": "Домен",
|
"domainPickerEnterDomain": "Домен",
|
||||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, или просто myapp",
|
"domainPickerPlaceholder": "myapp.example.com",
|
||||||
"domainPickerDescription": "Введите полный домен ресурса, чтобы увидеть доступные опции.",
|
"domainPickerDescription": "Введите полный домен ресурса, чтобы увидеть доступные опции.",
|
||||||
"domainPickerDescriptionSaas": "Введите полный домен, поддомен или просто имя, чтобы увидеть доступные опции",
|
"domainPickerDescriptionSaas": "Введите полный домен, поддомен или просто имя, чтобы увидеть доступные опции",
|
||||||
"domainPickerTabAll": "Все",
|
"domainPickerTabAll": "Все",
|
||||||
@@ -1496,5 +1502,22 @@
|
|||||||
"convertButton": "Конвертировать этот узел в управляемый себе-хост"
|
"convertButton": "Конвертировать этот узел в управляемый себе-хост"
|
||||||
},
|
},
|
||||||
"internationaldomaindetected": "Обнаружен международный домен",
|
"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"
|
||||||
|
}
|
||||||
@@ -454,6 +454,8 @@
|
|||||||
"accessRoleErrorAddDescription": "Kullanıcı role eklenirken bir hata oluştu.",
|
"accessRoleErrorAddDescription": "Kullanıcı role eklenirken bir hata oluştu.",
|
||||||
"userSaved": "Kullanıcı kaydedildi",
|
"userSaved": "Kullanıcı kaydedildi",
|
||||||
"userSavedDescription": "Kullanıcı güncellenmiştir.",
|
"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",
|
"accessControlsDescription": "Bu kullanıcının organizasyonda neleri erişebileceğini ve yapabileceğini yönetin",
|
||||||
"accessControlsSubmit": "Erişim Kontrollerini Kaydet",
|
"accessControlsSubmit": "Erişim Kontrollerini Kaydet",
|
||||||
"roles": "Roller",
|
"roles": "Roller",
|
||||||
@@ -911,6 +913,8 @@
|
|||||||
"idpConnectingToFinished": "Bağlandı",
|
"idpConnectingToFinished": "Bağlandı",
|
||||||
"idpErrorConnectingTo": "{name} ile bağlantı kurarken bir sorun meydana geldi. Lütfen yöneticiye danışın.",
|
"idpErrorConnectingTo": "{name} ile bağlantı kurarken bir sorun meydana geldi. Lütfen yöneticiye danışın.",
|
||||||
"idpErrorNotFound": "IdP bulunamadı",
|
"idpErrorNotFound": "IdP bulunamadı",
|
||||||
|
"idpGoogleAlt": "Google",
|
||||||
|
"idpAzureAlt": "Azure",
|
||||||
"inviteInvalid": "Geçersiz Davet",
|
"inviteInvalid": "Geçersiz Davet",
|
||||||
"inviteInvalidDescription": "Davet bağlantısı geçersiz.",
|
"inviteInvalidDescription": "Davet bağlantısı geçersiz.",
|
||||||
"inviteErrorWrongUser": "Davet bu kullanıcı için değil",
|
"inviteErrorWrongUser": "Davet bu kullanıcı için değil",
|
||||||
@@ -982,6 +986,8 @@
|
|||||||
"licenseTierProfessionalRequired": "Profesyonel Sürüme Gereklidir",
|
"licenseTierProfessionalRequired": "Profesyonel Sürüme Gereklidir",
|
||||||
"licenseTierProfessionalRequiredDescription": "Bu özellik yalnızca Professional Edition'da kullanılabilir.",
|
"licenseTierProfessionalRequiredDescription": "Bu özellik yalnızca Professional Edition'da kullanılabilir.",
|
||||||
"actionGetOrg": "Kuruluşu Al",
|
"actionGetOrg": "Kuruluşu Al",
|
||||||
|
"updateOrgUser": "Organizasyon Kullanıcısını Güncelle",
|
||||||
|
"createOrgUser": "Organizasyon Kullanıcısı Oluştur",
|
||||||
"actionUpdateOrg": "Kuruluşu Güncelle",
|
"actionUpdateOrg": "Kuruluşu Güncelle",
|
||||||
"actionUpdateUser": "Kullanıcıyı Güncelle",
|
"actionUpdateUser": "Kullanıcıyı Güncelle",
|
||||||
"actionGetUser": "Kullanıcıyı Getir",
|
"actionGetUser": "Kullanıcıyı Getir",
|
||||||
@@ -1234,7 +1240,7 @@
|
|||||||
"newtUpdateAvailable": "Güncelleme Mevcut",
|
"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.",
|
"newtUpdateAvailableInfo": "Newt'in yeni bir versiyonu mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.",
|
||||||
"domainPickerEnterDomain": "Domain",
|
"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.",
|
"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",
|
"domainPickerDescriptionSaas": "Mevcut seçenekleri görmek için tam etki alanı, alt etki alanı veya sadece bir isim girin",
|
||||||
"domainPickerTabAll": "Tümü",
|
"domainPickerTabAll": "Tümü",
|
||||||
@@ -1496,5 +1502,22 @@
|
|||||||
"convertButton": "Bu Düğümü Yönetilen Kendi Kendine Barındırma Dönüştürün"
|
"convertButton": "Bu Düğümü Yönetilen Kendi Kendine Barındırma Dönüştürün"
|
||||||
},
|
},
|
||||||
"internationaldomaindetected": "Uluslararası Alan Adı Tespit Edildi",
|
"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"
|
||||||
|
}
|
||||||
@@ -454,6 +454,8 @@
|
|||||||
"accessRoleErrorAddDescription": "添加用户到角色时出错。",
|
"accessRoleErrorAddDescription": "添加用户到角色时出错。",
|
||||||
"userSaved": "用户已保存",
|
"userSaved": "用户已保存",
|
||||||
"userSavedDescription": "用户已更新。",
|
"userSavedDescription": "用户已更新。",
|
||||||
|
"autoProvisioned": "自动设置",
|
||||||
|
"autoProvisionedDescription": "允许此用户由身份提供商自动管理",
|
||||||
"accessControlsDescription": "管理此用户在组织中可以访问和做什么",
|
"accessControlsDescription": "管理此用户在组织中可以访问和做什么",
|
||||||
"accessControlsSubmit": "保存访问控制",
|
"accessControlsSubmit": "保存访问控制",
|
||||||
"roles": "角色",
|
"roles": "角色",
|
||||||
@@ -911,6 +913,8 @@
|
|||||||
"idpConnectingToFinished": "已连接",
|
"idpConnectingToFinished": "已连接",
|
||||||
"idpErrorConnectingTo": "无法连接到 {name},请联系管理员协助处理。",
|
"idpErrorConnectingTo": "无法连接到 {name},请联系管理员协助处理。",
|
||||||
"idpErrorNotFound": "找不到 IdP",
|
"idpErrorNotFound": "找不到 IdP",
|
||||||
|
"idpGoogleAlt": "Google",
|
||||||
|
"idpAzureAlt": "Azure",
|
||||||
"inviteInvalid": "无效邀请",
|
"inviteInvalid": "无效邀请",
|
||||||
"inviteInvalidDescription": "邀请链接无效。",
|
"inviteInvalidDescription": "邀请链接无效。",
|
||||||
"inviteErrorWrongUser": "邀请不是该用户的",
|
"inviteErrorWrongUser": "邀请不是该用户的",
|
||||||
@@ -982,6 +986,8 @@
|
|||||||
"licenseTierProfessionalRequired": "需要专业版",
|
"licenseTierProfessionalRequired": "需要专业版",
|
||||||
"licenseTierProfessionalRequiredDescription": "此功能仅在专业版可用。",
|
"licenseTierProfessionalRequiredDescription": "此功能仅在专业版可用。",
|
||||||
"actionGetOrg": "获取组织",
|
"actionGetOrg": "获取组织",
|
||||||
|
"updateOrgUser": "更新组织用户",
|
||||||
|
"createOrgUser": "创建组织用户",
|
||||||
"actionUpdateOrg": "更新组织",
|
"actionUpdateOrg": "更新组织",
|
||||||
"actionUpdateUser": "更新用户",
|
"actionUpdateUser": "更新用户",
|
||||||
"actionGetUser": "获取用户",
|
"actionGetUser": "获取用户",
|
||||||
@@ -1234,7 +1240,7 @@
|
|||||||
"newtUpdateAvailable": "更新可用",
|
"newtUpdateAvailable": "更新可用",
|
||||||
"newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。",
|
"newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。",
|
||||||
"domainPickerEnterDomain": "域名",
|
"domainPickerEnterDomain": "域名",
|
||||||
"domainPickerPlaceholder": "myapp.example.com、api.v1.mydomain.com 或仅 myapp",
|
"domainPickerPlaceholder": "example.com",
|
||||||
"domainPickerDescription": "输入资源的完整域名以查看可用选项。",
|
"domainPickerDescription": "输入资源的完整域名以查看可用选项。",
|
||||||
"domainPickerDescriptionSaas": "输入完整域名、子域或名称以查看可用选项。",
|
"domainPickerDescriptionSaas": "输入完整域名、子域或名称以查看可用选项。",
|
||||||
"domainPickerTabAll": "所有",
|
"domainPickerTabAll": "所有",
|
||||||
@@ -1496,5 +1502,22 @@
|
|||||||
"convertButton": "将此节点转换为管理自托管的"
|
"convertButton": "将此节点转换为管理自托管的"
|
||||||
},
|
},
|
||||||
"internationaldomaindetected": "检测到国际域",
|
"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"
|
||||||
|
}
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -84,7 +84,6 @@
|
|||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"rebuild": "0.1.2",
|
"rebuild": "0.1.2",
|
||||||
"semver": "^7.7.2",
|
"semver": "^7.7.2",
|
||||||
"source-map-support": "0.5.21",
|
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"tailwind-merge": "3.3.1",
|
"tailwind-merge": "3.3.1",
|
||||||
"tw-animate-css": "^1.3.8",
|
"tw-animate-css": "^1.3.8",
|
||||||
@@ -7577,6 +7576,7 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
@@ -16971,6 +16971,7 @@
|
|||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||||
|
"dev": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -16989,6 +16990,7 @@
|
|||||||
"version": "0.5.21",
|
"version": "0.5.21",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||||
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"buffer-from": "^1.0.0",
|
"buffer-from": "^1.0.0",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"db:clear-migrations": "rm -rf server/migrations",
|
"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: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",
|
"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",
|
"email": "email dev --dir server/emails/templates --port 3005",
|
||||||
"build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs"
|
"build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs"
|
||||||
},
|
},
|
||||||
@@ -101,7 +101,6 @@
|
|||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"rebuild": "0.1.2",
|
"rebuild": "0.1.2",
|
||||||
"semver": "^7.7.2",
|
"semver": "^7.7.2",
|
||||||
"source-map-support": "0.5.21",
|
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"tailwind-merge": "3.3.1",
|
"tailwind-merge": "3.3.1",
|
||||||
"tw-animate-css": "^1.3.8",
|
"tw-animate-css": "^1.3.8",
|
||||||
|
|||||||
BIN
public/idp/azure.png
Normal file
BIN
public/idp/azure.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
BIN
public/idp/google.png
Normal file
BIN
public/idp/google.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -100,7 +100,9 @@ export enum ActionsEnum {
|
|||||||
getApiKey = "getApiKey",
|
getApiKey = "getApiKey",
|
||||||
createOrgDomain = "createOrgDomain",
|
createOrgDomain = "createOrgDomain",
|
||||||
deleteOrgDomain = "deleteOrgDomain",
|
deleteOrgDomain = "deleteOrgDomain",
|
||||||
restartOrgDomain = "restartOrgDomain"
|
restartOrgDomain = "restartOrgDomain",
|
||||||
|
updateOrgUser = "updateOrgUser",
|
||||||
|
applyBlueprint = "applyBlueprint"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { db } from "@server/db";
|
import { db, resources, siteResources } from "@server/db";
|
||||||
import { exitNodes, sites } from "@server/db";
|
import { exitNodes, sites } from "@server/db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { __DIRNAME } from "@server/lib/consts";
|
import { __DIRNAME } from "@server/lib/consts";
|
||||||
@@ -34,6 +34,44 @@ export async function getUniqueSiteName(orgId: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUniqueResourceName(orgId: string): Promise<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
export async function getUniqueExitNodeEndpointName(): Promise<string> {
|
||||||
let loops = 0;
|
let loops = 0;
|
||||||
const count = await db
|
const count = await db
|
||||||
|
|||||||
@@ -50,3 +50,4 @@ function createDb() {
|
|||||||
|
|
||||||
export const db = createDb();
|
export const db = createDb();
|
||||||
export default db;
|
export default db;
|
||||||
|
export type Transaction = Parameters<Parameters<typeof db["transaction"]>[0]>[0];
|
||||||
@@ -71,6 +71,7 @@ export const resources = pgTable("resources", {
|
|||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
})
|
})
|
||||||
.notNull(),
|
.notNull(),
|
||||||
|
niceId: text("niceId").notNull(),
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
subdomain: varchar("subdomain"),
|
subdomain: varchar("subdomain"),
|
||||||
fullDomain: varchar("fullDomain"),
|
fullDomain: varchar("fullDomain"),
|
||||||
@@ -95,6 +96,7 @@ export const resources = pgTable("resources", {
|
|||||||
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
|
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
|
||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
}),
|
}),
|
||||||
|
headers: text("headers"), // comma-separated list of headers to add to the request
|
||||||
});
|
});
|
||||||
|
|
||||||
export const targets = pgTable("targets", {
|
export const targets = pgTable("targets", {
|
||||||
@@ -113,7 +115,9 @@ export const targets = pgTable("targets", {
|
|||||||
method: varchar("method"),
|
method: varchar("method"),
|
||||||
port: integer("port").notNull(),
|
port: integer("port").notNull(),
|
||||||
internalPort: integer("internalPort"),
|
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", {
|
export const exitNodes = pgTable("exitNodes", {
|
||||||
@@ -127,7 +131,8 @@ export const exitNodes = pgTable("exitNodes", {
|
|||||||
maxConnections: integer("maxConnections"),
|
maxConnections: integer("maxConnections"),
|
||||||
online: boolean("online").notNull().default(false),
|
online: boolean("online").notNull().default(false),
|
||||||
lastPing: integer("lastPing"),
|
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
|
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")
|
orgId: varchar("orgId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
|
niceId: varchar("niceId").notNull(),
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
protocol: varchar("protocol").notNull(),
|
protocol: varchar("protocol").notNull(),
|
||||||
proxyPort: integer("proxyPort").notNull(),
|
proxyPort: integer("proxyPort").notNull(),
|
||||||
@@ -212,7 +218,8 @@ export const userOrgs = pgTable("userOrgs", {
|
|||||||
roleId: integer("roleId")
|
roleId: integer("roleId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => roles.roleId),
|
.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", {
|
export const emailVerificationCodes = pgTable("emailVerificationCodes", {
|
||||||
@@ -458,6 +465,7 @@ export const idpOidcConfig = pgTable("idpOidcConfig", {
|
|||||||
idpId: integer("idpId")
|
idpId: integer("idpId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => idp.idpId, { onDelete: "cascade" }),
|
.references(() => idp.idpId, { onDelete: "cascade" }),
|
||||||
|
variant: varchar("variant").notNull().default("oidc"),
|
||||||
clientId: varchar("clientId").notNull(),
|
clientId: varchar("clientId").notNull(),
|
||||||
clientSecret: varchar("clientSecret").notNull(),
|
clientSecret: varchar("clientSecret").notNull(),
|
||||||
authUrl: varchar("authUrl").notNull(),
|
authUrl: varchar("authUrl").notNull(),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ function createDb() {
|
|||||||
|
|
||||||
export const db = createDb();
|
export const db = createDb();
|
||||||
export default db;
|
export default db;
|
||||||
|
export type Transaction = Parameters<Parameters<typeof db["transaction"]>[0]>[0];
|
||||||
|
|
||||||
function checkFileExists(filePath: string): boolean {
|
function checkFileExists(filePath: string): boolean {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export const resources = sqliteTable("resources", {
|
|||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
})
|
})
|
||||||
.notNull(),
|
.notNull(),
|
||||||
|
niceId: text("niceId").notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
subdomain: text("subdomain"),
|
subdomain: text("subdomain"),
|
||||||
fullDomain: text("fullDomain"),
|
fullDomain: text("fullDomain"),
|
||||||
@@ -107,6 +108,7 @@ export const resources = sqliteTable("resources", {
|
|||||||
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
|
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
|
||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
}),
|
}),
|
||||||
|
headers: text("headers"), // comma-separated list of headers to add to the request
|
||||||
});
|
});
|
||||||
|
|
||||||
export const targets = sqliteTable("targets", {
|
export const targets = sqliteTable("targets", {
|
||||||
@@ -125,7 +127,9 @@ export const targets = sqliteTable("targets", {
|
|||||||
method: text("method"),
|
method: text("method"),
|
||||||
port: integer("port").notNull(),
|
port: integer("port").notNull(),
|
||||||
internalPort: integer("internalPort"),
|
internalPort: integer("internalPort"),
|
||||||
enabled: integer("enabled", { mode: "boolean" }).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", {
|
export const exitNodes = sqliteTable("exitNodes", {
|
||||||
@@ -139,23 +143,28 @@ export const exitNodes = sqliteTable("exitNodes", {
|
|||||||
maxConnections: integer("maxConnections"),
|
maxConnections: integer("maxConnections"),
|
||||||
online: integer("online", { mode: "boolean" }).notNull().default(false),
|
online: integer("online", { mode: "boolean" }).notNull().default(false),
|
||||||
lastPing: integer("lastPing"),
|
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
|
export const siteResources = sqliteTable("siteResources", {
|
||||||
siteResourceId: integer("siteResourceId").primaryKey({ autoIncrement: true }),
|
// this is for the clients
|
||||||
|
siteResourceId: integer("siteResourceId").primaryKey({
|
||||||
|
autoIncrement: true
|
||||||
|
}),
|
||||||
siteId: integer("siteId")
|
siteId: integer("siteId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sites.siteId, { onDelete: "cascade" }),
|
.references(() => sites.siteId, { onDelete: "cascade" }),
|
||||||
orgId: text("orgId")
|
orgId: text("orgId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
|
niceId: text("niceId").notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
protocol: text("protocol").notNull(),
|
protocol: text("protocol").notNull(),
|
||||||
proxyPort: integer("proxyPort").notNull(),
|
proxyPort: integer("proxyPort").notNull(),
|
||||||
destinationPort: integer("destinationPort").notNull(),
|
destinationPort: integer("destinationPort").notNull(),
|
||||||
destinationIp: text("destinationIp").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", {
|
export const users = sqliteTable("user", {
|
||||||
@@ -259,7 +268,9 @@ export const clientSites = sqliteTable("clientSites", {
|
|||||||
siteId: integer("siteId")
|
siteId: integer("siteId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sites.siteId, { onDelete: "cascade" }),
|
.references(() => sites.siteId, { onDelete: "cascade" }),
|
||||||
isRelayed: integer("isRelayed", { mode: "boolean" }).notNull().default(false),
|
isRelayed: integer("isRelayed", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
endpoint: text("endpoint")
|
endpoint: text("endpoint")
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -317,7 +328,10 @@ export const userOrgs = sqliteTable("userOrgs", {
|
|||||||
roleId: integer("roleId")
|
roleId: integer("roleId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => roles.roleId),
|
.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", {
|
export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
|
||||||
@@ -603,6 +617,7 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", {
|
|||||||
idpOauthConfigId: integer("idpOauthConfigId").primaryKey({
|
idpOauthConfigId: integer("idpOauthConfigId").primaryKey({
|
||||||
autoIncrement: true
|
autoIncrement: true
|
||||||
}),
|
}),
|
||||||
|
variant: text("variant").notNull().default("oidc"),
|
||||||
idpId: integer("idpId")
|
idpId: integer("idpId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => idp.idpId, { onDelete: "cascade" }),
|
.references(() => idp.idpId, { onDelete: "cascade" }),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
#! /usr/bin/env node
|
#! /usr/bin/env node
|
||||||
import "./extendZod.ts";
|
import "./extendZod.ts";
|
||||||
import 'source-map-support/register.js';
|
|
||||||
|
|
||||||
import { runSetupFunctions } from "./setup";
|
import { runSetupFunctions } from "./setup";
|
||||||
import { createApiServer } from "./apiServer";
|
import { createApiServer } from "./apiServer";
|
||||||
|
|||||||
170
server/lib/blueprints/applyBlueprint.ts
Normal file
170
server/lib/blueprints/applyBlueprint.ts
Normal file
@@ -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<void> {
|
||||||
|
// 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,
|
||||||
|
// }
|
||||||
|
// ]
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// });
|
||||||
53
server/lib/blueprints/applyNewtDockerBlueprint.ts
Normal file
53
server/lib/blueprints/applyNewtDockerBlueprint.ts
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
117
server/lib/blueprints/clientResources.ts
Normal file
117
server/lib/blueprints/clientResources.ts
Normal file
@@ -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<ClientResourcesResults> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
301
server/lib/blueprints/parseDockerContainers.ts
Normal file
301
server/lib/blueprints/parseDockerContainers.ts
Normal file
@@ -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));
|
||||||
109
server/lib/blueprints/parseDotNotation.ts
Normal file
109
server/lib/blueprints/parseDotNotation.ts
Normal file
@@ -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));
|
||||||
885
server/lib/blueprints/proxyResources.ts
Normal file
885
server/lib/blueprints/proxyResources.ts
Normal file
@@ -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<ProxyResourcesResults> {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
366
server/lib/blueprints/types.ts
Normal file
366
server/lib/blueprints/types.ts
Normal file
@@ -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<typeof TargetSchema>;
|
||||||
|
|
||||||
|
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<string, string[]>();
|
||||||
|
|
||||||
|
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<string, string[]>();
|
||||||
|
|
||||||
|
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<number, string[]>();
|
||||||
|
|
||||||
|
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<number, string[]>();
|
||||||
|
|
||||||
|
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<number, string[]>();
|
||||||
|
|
||||||
|
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<number, string[]>();
|
||||||
|
|
||||||
|
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<typeof SiteSchema>;
|
||||||
|
export type Target = z.infer<typeof TargetSchema>;
|
||||||
|
export type Resource = z.infer<typeof ResourceSchema>;
|
||||||
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
@@ -2,7 +2,7 @@ import path from "path";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
// This is a placeholder value replaced by the build process
|
// 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 __FILENAME = fileURLToPath(import.meta.url);
|
||||||
export const __DIRNAME = path.dirname(__FILENAME);
|
export const __DIRNAME = path.dirname(__FILENAME);
|
||||||
|
|||||||
112
server/lib/domainUtils.ts
Normal file
112
server/lib/domainUtils.ts
Normal file
@@ -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<DomainValidationResult> {
|
||||||
|
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'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import logger from "@server/logger";
|
|||||||
import { ExitNode } from "@server/db";
|
import { ExitNode } from "@server/db";
|
||||||
|
|
||||||
interface ExitNodeRequest {
|
interface ExitNodeRequest {
|
||||||
remoteType: string;
|
remoteType?: string;
|
||||||
localPath: string;
|
localPath: string;
|
||||||
method?: "POST" | "DELETE" | "GET" | "PUT";
|
method?: "POST" | "DELETE" | "GET" | "PUT";
|
||||||
data?: any;
|
data?: any;
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ export async function listExitNodes(orgId: string, filterOnline = false) {
|
|||||||
maxConnections: exitNodes.maxConnections,
|
maxConnections: exitNodes.maxConnections,
|
||||||
online: exitNodes.online,
|
online: exitNodes.online,
|
||||||
lastPing: exitNodes.lastPing,
|
lastPing: exitNodes.lastPing,
|
||||||
type: exitNodes.type
|
type: exitNodes.type,
|
||||||
|
region: exitNodes.region
|
||||||
})
|
})
|
||||||
.from(exitNodes);
|
.from(exitNodes);
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class TelemetryClient {
|
|||||||
logger.error("Failed to collect analytics:", err);
|
logger.error("Failed to collect analytics:", err);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
6 * 60 * 60 * 1000
|
48 * 60 * 60 * 1000
|
||||||
);
|
);
|
||||||
|
|
||||||
this.collectAndSendAnalytics().catch((err) => {
|
this.collectAndSendAnalytics().catch((err) => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
getValidCertificatesForDomains,
|
getValidCertificatesForDomains,
|
||||||
getValidCertificatesForDomainsHybrid
|
getValidCertificatesForDomainsHybrid
|
||||||
} from "./remoteCertificates";
|
} from "./remoteCertificates";
|
||||||
|
import { sendToExitNode } from "./exitNodeComms";
|
||||||
|
|
||||||
export class TraefikConfigManager {
|
export class TraefikConfigManager {
|
||||||
private intervalId: NodeJS.Timeout | null = null;
|
private intervalId: NodeJS.Timeout | null = null;
|
||||||
@@ -403,27 +404,11 @@ export class TraefikConfigManager {
|
|||||||
[exitNode] = await db.select().from(exitNodes).limit(1);
|
[exitNode] = await db.select().from(exitNodes).limit(1);
|
||||||
}
|
}
|
||||||
if (exitNode) {
|
if (exitNode) {
|
||||||
try {
|
await sendToExitNode(exitNode, {
|
||||||
await axios.post(
|
localPath: "/update-local-snis",
|
||||||
`${exitNode.reachableAt}/update-local-snis`,
|
method: "POST",
|
||||||
{ fullDomains: Array.from(domains) },
|
data: { 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
logger.error(
|
logger.error(
|
||||||
"No exit node found. Has gerbil registered yet?"
|
"No exit node found. Has gerbil registered yet?"
|
||||||
|
|||||||
@@ -129,6 +129,40 @@ export function isValidDomain(domain: string): boolean {
|
|||||||
return true;
|
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 = [
|
const validTlds = [
|
||||||
"AAA",
|
"AAA",
|
||||||
"AARP",
|
"AARP",
|
||||||
|
|||||||
@@ -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) {
|
if (!req.apiKeyOrg) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -32,11 +37,6 @@ export async function verifyApiKeySetResourceUsers(
|
|||||||
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid user IDs"));
|
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) {
|
if (userIds.length === 0) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -345,6 +345,12 @@ authenticated.get(
|
|||||||
verifyUserHasAction(ActionsEnum.getResource),
|
verifyUserHasAction(ActionsEnum.getResource),
|
||||||
resource.getResource
|
resource.getResource
|
||||||
);
|
);
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/resource/:niceId",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.getResource),
|
||||||
|
resource.getResource
|
||||||
|
);
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/resource/:resourceId",
|
"/resource/:resourceId",
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
@@ -582,6 +588,14 @@ authenticated.put(
|
|||||||
user.createOrgUser
|
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.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
@@ -956,7 +970,7 @@ authRouter.post(
|
|||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
max: 15,
|
max: 15,
|
||||||
keyGenerator: (req) =>
|
keyGenerator: (req) =>
|
||||||
`requestEmailVerificationCode:${req.body.email || ipKeyGenerator(req.ip || "")}`,
|
`requestEmailVerificationCode:${req.user?.email || ipKeyGenerator(req.ip || "")}`,
|
||||||
handler: (req, res, next) => {
|
handler: (req, res, next) => {
|
||||||
const message = `You can only request an email verification code ${15} times every ${15} minutes. Please try again later.`;
|
const message = `You can only request an email verification code ${15} times every ${15} minutes. Please try again later.`;
|
||||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
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 { domains, idp, orgDomains, users, idpOrg } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
@@ -33,23 +33,21 @@ async function query(limit: number, offset: number) {
|
|||||||
idpId: idp.idpId,
|
idpId: idp.idpId,
|
||||||
name: idp.name,
|
name: idp.name,
|
||||||
type: idp.type,
|
type: idp.type,
|
||||||
orgCount: sql<number>`count(${idpOrg.orgId})`
|
variant: idpOidcConfig.variant,
|
||||||
|
orgCount: sql<number>`count(${idpOrg.orgId})`,
|
||||||
|
autoProvision: idp.autoProvision
|
||||||
})
|
})
|
||||||
.from(idp)
|
.from(idp)
|
||||||
.leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`)
|
.leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`)
|
||||||
.groupBy(idp.idpId)
|
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
|
||||||
|
.groupBy(idp.idpId, idpOidcConfig.variant)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ListIdpsResponse = {
|
export type ListIdpsResponse = {
|
||||||
idps: Array<{
|
idps: Awaited<ReturnType<typeof query>>;
|
||||||
idpId: number;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
orgCount: number;
|
|
||||||
}>;
|
|
||||||
pagination: {
|
pagination: {
|
||||||
total: number;
|
total: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
|
|||||||
@@ -354,8 +354,13 @@ export async function validateOidcCallback(
|
|||||||
.from(userOrgs)
|
.from(userOrgs)
|
||||||
.where(eq(userOrgs.userId, userId!));
|
.where(eq(userOrgs.userId, userId!));
|
||||||
|
|
||||||
// Delete orgs that are no longer valid
|
// Filter to only auto-provisioned orgs for CRUD operations
|
||||||
const orgsToDelete = currentUserOrgs.filter(
|
const autoProvisionedOrgs = currentUserOrgs.filter(
|
||||||
|
(org) => org.autoProvisioned === true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete auto-provisioned orgs that are no longer valid
|
||||||
|
const orgsToDelete = autoProvisionedOrgs.filter(
|
||||||
(currentOrg) =>
|
(currentOrg) =>
|
||||||
!userOrgInfo.some(
|
!userOrgInfo.some(
|
||||||
(newOrg) => newOrg.orgId === currentOrg.orgId
|
(newOrg) => newOrg.orgId === currentOrg.orgId
|
||||||
@@ -374,8 +379,8 @@ export async function validateOidcCallback(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update roles for existing orgs where the role has changed
|
// Update roles for existing auto-provisioned orgs where the role has changed
|
||||||
const orgsToUpdate = currentUserOrgs.filter((currentOrg) => {
|
const orgsToUpdate = autoProvisionedOrgs.filter((currentOrg) => {
|
||||||
const newOrg = userOrgInfo.find(
|
const newOrg = userOrgInfo.find(
|
||||||
(newOrg) => newOrg.orgId === currentOrg.orgId
|
(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(
|
const orgsToAdd = userOrgInfo.filter(
|
||||||
(newOrg) =>
|
(newOrg) =>
|
||||||
!currentUserOrgs.some(
|
!currentUserOrgs.some(
|
||||||
@@ -415,12 +420,14 @@ export async function validateOidcCallback(
|
|||||||
userId: userId!,
|
userId: userId!,
|
||||||
orgId: org.orgId,
|
orgId: org.orgId,
|
||||||
roleId: org.roleId,
|
roleId: org.roleId,
|
||||||
|
autoProvisioned: true,
|
||||||
dateCreated: new Date().toISOString()
|
dateCreated: new Date().toISOString()
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loop through all the orgs and get the total number of users from the userOrgs table
|
// 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) {
|
for (const org of currentUserOrgs) {
|
||||||
const userCount = await trx
|
const userCount = await trx
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ import {
|
|||||||
verifyApiKeyIsRoot,
|
verifyApiKeyIsRoot,
|
||||||
verifyApiKeyClientAccess,
|
verifyApiKeyClientAccess,
|
||||||
verifyClientsEnabled,
|
verifyClientsEnabled,
|
||||||
verifyApiKeySiteResourceAccess
|
verifyApiKeySiteResourceAccess,
|
||||||
|
verifyOrgAccess
|
||||||
} from "@server/middlewares";
|
} from "@server/middlewares";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
@@ -469,6 +470,21 @@ authenticated.get(
|
|||||||
user.listUsers
|
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(
|
authenticated.delete(
|
||||||
"/org/:orgId/user/:userId",
|
"/org/:orgId/user/:userId",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
@@ -628,3 +644,10 @@ authenticated.post(
|
|||||||
verifyApiKeyHasAction(ActionsEnum.updateClient),
|
verifyApiKeyHasAction(ActionsEnum.updateClient),
|
||||||
client.updateClient
|
client.updateClient
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/org/:orgId/blueprint",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.applyBlueprint),
|
||||||
|
org.applyBlueprint
|
||||||
|
);
|
||||||
73
server/routers/newt/handleApplyBlueprintMessage.ts
Normal file
73
server/routers/newt/handleApplyBlueprintMessage.ts
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getNextAvailableClientSubnet
|
getNextAvailableClientSubnet
|
||||||
} from "@server/lib/ip";
|
} from "@server/lib/ip";
|
||||||
import { selectBestExitNode, verifyExitNodeOrgAccess } from "@server/lib/exitNodes";
|
import { selectBestExitNode, verifyExitNodeOrgAccess } from "@server/lib/exitNodes";
|
||||||
|
import { fetchContainers } from "./dockerSocket";
|
||||||
|
|
||||||
export type ExitNodePingResult = {
|
export type ExitNodePingResult = {
|
||||||
exitNodeId: number;
|
exitNodeId: number;
|
||||||
@@ -76,6 +77,15 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
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 siteSubnet = oldSite.subnet;
|
||||||
let exitNodeIdToQuery = oldSite.exitNodeId;
|
let exitNodeIdToQuery = oldSite.exitNodeId;
|
||||||
if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) {
|
if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { MessageHandler } from "../ws";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { dockerSocketCache } from "./dockerSocket";
|
import { dockerSocketCache } from "./dockerSocket";
|
||||||
import { Newt } from "@server/db";
|
import { Newt } from "@server/db";
|
||||||
|
import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint";
|
||||||
|
|
||||||
export const handleDockerStatusMessage: MessageHandler = async (context) => {
|
export const handleDockerStatusMessage: MessageHandler = async (context) => {
|
||||||
const { message, client, sendToClient } = context;
|
const { message, client, sendToClient } = context;
|
||||||
@@ -57,4 +58,15 @@ export const handleDockerContainersMessage: MessageHandler = async (
|
|||||||
} else {
|
} else {
|
||||||
logger.warn(`Newt ${newt.newtId} does not have Docker containers`);
|
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
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export * from "./handleNewtRegisterMessage";
|
|||||||
export * from "./handleReceiveBandwidthMessage";
|
export * from "./handleReceiveBandwidthMessage";
|
||||||
export * from "./handleGetConfigMessage";
|
export * from "./handleGetConfigMessage";
|
||||||
export * from "./handleSocketMessages";
|
export * from "./handleSocketMessages";
|
||||||
export * from "./handleNewtPingRequestMessage";
|
export * from "./handleNewtPingRequestMessage";
|
||||||
|
export * from "./handleApplyBlueprintMessage";
|
||||||
127
server/routers/org/applyBlueprint.ts
Normal file
127
server/routers/org/applyBlueprint.ts
Normal file
@@ -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<any> {
|
||||||
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,3 +7,4 @@ export * from "./checkId";
|
|||||||
export * from "./getOrgOverview";
|
export * from "./getOrgOverview";
|
||||||
export * from "./listOrgs";
|
export * from "./listOrgs";
|
||||||
export * from "./pickOrgDefaults";
|
export * from "./pickOrgDefaults";
|
||||||
|
export * from "./applyBlueprint";
|
||||||
@@ -21,6 +21,8 @@ import { subdomainSchema } from "@server/lib/schemas";
|
|||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { getUniqueResourceName } from "@server/db/names";
|
||||||
|
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||||
|
|
||||||
const createResourceParamsSchema = z
|
const createResourceParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -193,76 +195,21 @@ async function createHttpResource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { name, domainId } = parsedBody.data;
|
const { name, domainId } = parsedBody.data;
|
||||||
let subdomain = parsedBody.data.subdomain;
|
const subdomain = parsedBody.data.subdomain;
|
||||||
|
|
||||||
const [domainRes] = await db
|
// Validate domain and construct full domain
|
||||||
.select()
|
const domainResult = await validateAndConstructDomain(domainId, orgId, subdomain);
|
||||||
.from(domains)
|
|
||||||
.where(eq(domains.domainId, domainId))
|
if (!domainResult.success) {
|
||||||
.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) {
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
`Domain with ID ${domainRes.domains.domainId} is not verified`
|
domainResult.error
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let fullDomain = "";
|
const { fullDomain, subdomain: finalSubdomain } = domainResult;
|
||||||
if (domainRes.domains.type == "ns") {
|
|
||||||
if (subdomain) {
|
|
||||||
fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
|
|
||||||
} else {
|
|
||||||
fullDomain = domainRes.domains.baseDomain;
|
|
||||||
}
|
|
||||||
} else if (domainRes.domains.type == "cname") {
|
|
||||||
fullDomain = domainRes.domains.baseDomain;
|
|
||||||
} else if (domainRes.domains.type == "wildcard") {
|
|
||||||
if (subdomain) {
|
|
||||||
// the subdomain cant have a dot in it
|
|
||||||
const parsedSubdomain = subdomainSchema.safeParse(subdomain);
|
|
||||||
if (!parsedSubdomain.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedSubdomain.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
|
|
||||||
} else {
|
|
||||||
fullDomain = domainRes.domains.baseDomain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fullDomain === domainRes.domains.baseDomain) {
|
|
||||||
subdomain = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
fullDomain = fullDomain.toLowerCase();
|
|
||||||
|
|
||||||
logger.debug(`Full domain: ${fullDomain}`);
|
logger.debug(`Full domain: ${fullDomain}`);
|
||||||
|
|
||||||
@@ -283,15 +230,18 @@ async function createHttpResource(
|
|||||||
|
|
||||||
let resource: Resource | undefined;
|
let resource: Resource | undefined;
|
||||||
|
|
||||||
|
const niceId = await getUniqueResourceName(orgId);
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
const newResource = await trx
|
const newResource = await trx
|
||||||
.insert(resources)
|
.insert(resources)
|
||||||
.values({
|
.values({
|
||||||
|
niceId,
|
||||||
fullDomain,
|
fullDomain,
|
||||||
domainId,
|
domainId,
|
||||||
orgId,
|
orgId,
|
||||||
name,
|
name,
|
||||||
subdomain,
|
subdomain: finalSubdomain,
|
||||||
http: true,
|
http: true,
|
||||||
protocol: "tcp",
|
protocol: "tcp",
|
||||||
ssl: true
|
ssl: true
|
||||||
@@ -391,10 +341,13 @@ async function createRawResource(
|
|||||||
|
|
||||||
let resource: Resource | undefined;
|
let resource: Resource | undefined;
|
||||||
|
|
||||||
|
const niceId = await getUniqueResourceName(orgId);
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
const newResource = await trx
|
const newResource = await trx
|
||||||
.insert(resources)
|
.insert(resources)
|
||||||
.values({
|
.values({
|
||||||
|
niceId,
|
||||||
orgId,
|
orgId,
|
||||||
name,
|
name,
|
||||||
http,
|
http,
|
||||||
|
|||||||
@@ -2,32 +2,72 @@ import { Request, Response, NextFunction } from "express";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { Resource, resources, sites } 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 response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import stoi from "@server/lib/stoi";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
const getResourceSchema = z
|
const getResourceSchema = z
|
||||||
.object({
|
.object({
|
||||||
resourceId: z
|
resourceId: z
|
||||||
.string()
|
.string()
|
||||||
.transform(Number)
|
.optional()
|
||||||
.pipe(z.number().int().positive())
|
.transform(stoi)
|
||||||
|
.pipe(z.number().int().positive().optional())
|
||||||
|
.optional(),
|
||||||
|
niceId: z.string().optional(),
|
||||||
|
orgId: z.string().optional()
|
||||||
})
|
})
|
||||||
.strict();
|
.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<Awaited<ReturnType<typeof query>>>;
|
||||||
|
|
||||||
|
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({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/resource/{resourceId}",
|
path: "/resource/{resourceId}",
|
||||||
description: "Get a resource.",
|
description: "Get a resource by resourceId.",
|
||||||
tags: [OpenAPITags.Resource],
|
tags: [OpenAPITags.Resource],
|
||||||
request: {
|
request: {
|
||||||
params: getResourceSchema
|
params: z.object({
|
||||||
|
resourceId: z.number()
|
||||||
|
})
|
||||||
},
|
},
|
||||||
responses: {}
|
responses: {}
|
||||||
});
|
});
|
||||||
@@ -48,29 +88,18 @@ export async function getResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { resourceId } = parsedParams.data;
|
const { resourceId, niceId, orgId } = parsedParams.data;
|
||||||
|
|
||||||
const [resp] = await db
|
const resource = await query(resourceId, niceId, orgId);
|
||||||
.select()
|
|
||||||
.from(resources)
|
|
||||||
.where(eq(resources.resourceId, resourceId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const resource = resp;
|
|
||||||
|
|
||||||
if (!resource) {
|
if (!resource) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Resource with ID ${resourceId} not found`
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response<GetResourceResponse>(res, {
|
||||||
data: {
|
data: resource,
|
||||||
...resource
|
|
||||||
},
|
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Resource retrieved successfully",
|
message: "Resource retrieved successfully",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export type GetResourceAuthInfoResponse = {
|
|||||||
url: string;
|
url: string;
|
||||||
whitelist: boolean;
|
whitelist: boolean;
|
||||||
skipToIdpId: number | null;
|
skipToIdpId: number | null;
|
||||||
|
orgId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getResourceAuthInfo(
|
export async function getResourceAuthInfo(
|
||||||
@@ -88,7 +89,8 @@ export async function getResourceAuthInfo(
|
|||||||
blockAccess: resource.blockAccess,
|
blockAccess: resource.blockAccess,
|
||||||
url,
|
url,
|
||||||
whitelist: resource.emailWhitelistEnabled,
|
whitelist: resource.emailWhitelistEnabled,
|
||||||
skipToIdpId: resource.skipToIdpId
|
skipToIdpId: resource.skipToIdpId,
|
||||||
|
orgId: resource.orgId
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import logger from "@server/logger";
|
|||||||
import stoi from "@server/lib/stoi";
|
import stoi from "@server/lib/stoi";
|
||||||
import { fromZodError } from "zod-validation-error";
|
import { fromZodError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { warn } from "console";
|
||||||
|
|
||||||
const listResourcesParamsSchema = z
|
const listResourcesParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -54,7 +55,8 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
|
|||||||
protocol: resources.protocol,
|
protocol: resources.protocol,
|
||||||
proxyPort: resources.proxyPort,
|
proxyPort: resources.proxyPort,
|
||||||
enabled: resources.enabled,
|
enabled: resources.enabled,
|
||||||
domainId: resources.domainId
|
domainId: resources.domainId,
|
||||||
|
niceId: resources.niceId
|
||||||
})
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { tlsNameSchema } from "@server/lib/schemas";
|
|||||||
import { subdomainSchema } from "@server/lib/schemas";
|
import { subdomainSchema } from "@server/lib/schemas";
|
||||||
import { registry } from "@server/openApi";
|
import { registry } from "@server/openApi";
|
||||||
import { OpenAPITags } from "@server/openApi";
|
import { OpenAPITags } from "@server/openApi";
|
||||||
|
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||||
|
import { validateHeaders } from "@server/lib/validators";
|
||||||
|
|
||||||
const updateResourceParamsSchema = z
|
const updateResourceParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -44,7 +46,8 @@ const updateHttpResourceBodySchema = z
|
|||||||
stickySession: z.boolean().optional(),
|
stickySession: z.boolean().optional(),
|
||||||
tlsServerName: z.string().nullable().optional(),
|
tlsServerName: z.string().nullable().optional(),
|
||||||
setHostHeader: 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()
|
.strict()
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
@@ -82,6 +85,18 @@ const updateHttpResourceBodySchema = z
|
|||||||
message:
|
message:
|
||||||
"Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header."
|
"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;
|
export type UpdateResourceResponse = Resource;
|
||||||
@@ -230,78 +245,19 @@ async function updateHttpResource(
|
|||||||
if (updateData.domainId) {
|
if (updateData.domainId) {
|
||||||
const domainId = updateData.domainId;
|
const domainId = updateData.domainId;
|
||||||
|
|
||||||
const [domainRes] = await db
|
// Validate domain and construct full domain
|
||||||
.select()
|
const domainResult = await validateAndConstructDomain(domainId, resource.orgId, updateData.subdomain);
|
||||||
.from(domains)
|
|
||||||
.where(eq(domains.domainId, domainId))
|
if (!domainResult.success) {
|
||||||
.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) {
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
`Domain with ID ${updateData.domainId} is not verified`
|
domainResult.error
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let fullDomain = "";
|
const { fullDomain, subdomain: finalSubdomain } = domainResult;
|
||||||
if (domainRes.domains.type == "ns") {
|
|
||||||
if (updateData.subdomain) {
|
|
||||||
fullDomain = `${updateData.subdomain}.${domainRes.domains.baseDomain}`;
|
|
||||||
} else {
|
|
||||||
fullDomain = domainRes.domains.baseDomain;
|
|
||||||
}
|
|
||||||
} else if (domainRes.domains.type == "cname") {
|
|
||||||
fullDomain = domainRes.domains.baseDomain;
|
|
||||||
} else if (domainRes.domains.type == "wildcard") {
|
|
||||||
if (updateData.subdomain !== undefined) {
|
|
||||||
// the subdomain cant have a dot in it
|
|
||||||
const parsedSubdomain = subdomainSchema.safeParse(
|
|
||||||
updateData.subdomain
|
|
||||||
);
|
|
||||||
if (!parsedSubdomain.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedSubdomain.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
fullDomain = `${updateData.subdomain}.${domainRes.domains.baseDomain}`;
|
|
||||||
} else {
|
|
||||||
fullDomain = domainRes.domains.baseDomain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fullDomain = fullDomain.toLowerCase();
|
|
||||||
|
|
||||||
logger.debug(`Full domain: ${fullDomain}`);
|
logger.debug(`Full domain: ${fullDomain}`);
|
||||||
|
|
||||||
@@ -332,9 +288,8 @@ async function updateHttpResource(
|
|||||||
.where(eq(resources.resourceId, resource.resourceId));
|
.where(eq(resources.resourceId, resource.resourceId));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fullDomain === domainRes.domains.baseDomain) {
|
// Update the subdomain in the update data
|
||||||
updateData.subdomain = null;
|
updateData.subdomain = finalSubdomain;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedResource = await db
|
const updatedResource = await db
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export async function pickSiteDefaults(
|
|||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Organization retrieved successfully",
|
message: "Site defaults chosen successfully",
|
||||||
status: HttpCode.OK
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { addTargets } from "../client/targets";
|
import { addTargets } from "../client/targets";
|
||||||
|
import { getUniqueSiteResourceName } from "@server/db/names";
|
||||||
|
|
||||||
const createSiteResourceParamsSchema = z
|
const createSiteResourceParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -121,11 +122,14 @@ export async function createSiteResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const niceId = await getUniqueSiteResourceName(orgId);
|
||||||
|
|
||||||
// Create the site resource
|
// Create the site resource
|
||||||
const [newSiteResource] = await db
|
const [newSiteResource] = await db
|
||||||
.insert(siteResources)
|
.insert(siteResources)
|
||||||
.values({
|
.values({
|
||||||
siteId,
|
siteId,
|
||||||
|
niceId,
|
||||||
orgId,
|
orgId,
|
||||||
name,
|
name,
|
||||||
protocol,
|
protocol,
|
||||||
|
|||||||
@@ -12,21 +12,72 @@ import { OpenAPITags, registry } from "@server/openApi";
|
|||||||
|
|
||||||
const getSiteResourceParamsSchema = z
|
const getSiteResourceParamsSchema = z
|
||||||
.object({
|
.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()),
|
siteId: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||||
|
niceId: z.string().optional(),
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
})
|
})
|
||||||
.strict();
|
.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<Awaited<ReturnType<typeof query>>>;
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}",
|
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],
|
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
||||||
request: {
|
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: {}
|
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
|
// Get the site resource
|
||||||
const [siteResource] = await db
|
const siteResource = await query(siteResourceId, siteId, niceId, orgId);
|
||||||
.select()
|
|
||||||
.from(siteResources)
|
|
||||||
.where(and(
|
|
||||||
eq(siteResources.siteResourceId, siteResourceId),
|
|
||||||
eq(siteResources.siteId, siteId),
|
|
||||||
eq(siteResources.orgId, orgId)
|
|
||||||
))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!siteResource) {
|
if (!siteResource) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const updateSiteResourceSchema = z
|
|||||||
protocol: z.enum(["tcp", "udp"]).optional(),
|
protocol: z.enum(["tcp", "udp"]).optional(),
|
||||||
proxyPort: z.number().int().positive().optional(),
|
proxyPort: z.number().int().positive().optional(),
|
||||||
destinationPort: z.number().int().positive().optional(),
|
destinationPort: z.number().int().positive().optional(),
|
||||||
destinationIp: z.string().ip().optional(),
|
destinationIp: z.string().optional(),
|
||||||
enabled: z.boolean().optional()
|
enabled: z.boolean().optional()
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ const createTargetSchema = z
|
|||||||
ip: z.string().refine(isTargetValid),
|
ip: z.string().refine(isTargetValid),
|
||||||
method: z.string().optional().nullable(),
|
method: z.string().optional().nullable(),
|
||||||
port: z.number().int().min(1).max(65535),
|
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();
|
.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) {
|
if (!internalPort) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { db } from "@server/db";
|
import { db, Transaction } from "@server/db";
|
||||||
import { resources, targets } from "@server/db";
|
import { resources, targets } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
const currentBannedPorts: number[] = [];
|
const currentBannedPorts: number[] = [];
|
||||||
|
|
||||||
export async function pickPort(siteId: number): Promise<{
|
export async function pickPort(siteId: number, trx: Transaction | typeof db): Promise<{
|
||||||
internalPort: number;
|
internalPort: number;
|
||||||
targetIps: string[];
|
targetIps: string[];
|
||||||
}> {
|
}> {
|
||||||
@@ -12,7 +12,7 @@ export async function pickPort(siteId: number): Promise<{
|
|||||||
const targetIps: string[] = [];
|
const targetIps: string[] = [];
|
||||||
const targetInternalPorts: number[] = [];
|
const targetInternalPorts: number[] = [];
|
||||||
|
|
||||||
const targetsRes = await db
|
const targetsRes = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(targets)
|
.from(targets)
|
||||||
.where(eq(targets.siteId, siteId));
|
.where(eq(targets.siteId, siteId));
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ function queryTargets(resourceId: number) {
|
|||||||
enabled: targets.enabled,
|
enabled: targets.enabled,
|
||||||
resourceId: targets.resourceId,
|
resourceId: targets.resourceId,
|
||||||
siteId: targets.siteId,
|
siteId: targets.siteId,
|
||||||
siteType: sites.type
|
siteType: sites.type,
|
||||||
|
path: targets.path,
|
||||||
|
pathMatchType: targets.pathMatchType
|
||||||
})
|
})
|
||||||
.from(targets)
|
.from(targets)
|
||||||
.leftJoin(sites, eq(sites.siteId, targets.siteId))
|
.leftJoin(sites, eq(sites.siteId, targets.siteId))
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ const updateTargetBodySchema = z
|
|||||||
ip: z.string().refine(isTargetValid),
|
ip: z.string().refine(isTargetValid),
|
||||||
method: z.string().min(1).max(10).optional().nullable(),
|
method: z.string().min(1).max(10).optional().nullable(),
|
||||||
port: z.number().int().min(1).max(65535).optional(),
|
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()
|
.strict()
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.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) {
|
if (!internalPort) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ export async function traefikConfigProvider(
|
|||||||
config.getRawConfig().traefik.site_types
|
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] = {
|
traefikConfig.http.middlewares[badgerMiddlewareName] = {
|
||||||
plugin: {
|
plugin: {
|
||||||
[badgerMiddlewareName]: {
|
[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
|
// 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
|
// Start from sites on this exit node, then join to targets and resources
|
||||||
const resourcesWithTargetsAndSites = await tx
|
const resourcesWithTargetsAndSites = await db
|
||||||
.select({
|
.select({
|
||||||
// Resource fields
|
// Resource fields
|
||||||
resourceId: resources.resourceId,
|
resourceId: resources.resourceId,
|
||||||
@@ -124,6 +123,7 @@ export async function getTraefikConfig(
|
|||||||
tlsServerName: resources.tlsServerName,
|
tlsServerName: resources.tlsServerName,
|
||||||
setHostHeader: resources.setHostHeader,
|
setHostHeader: resources.setHostHeader,
|
||||||
enableProxy: resources.enableProxy,
|
enableProxy: resources.enableProxy,
|
||||||
|
headers: resources.headers,
|
||||||
// Target fields
|
// Target fields
|
||||||
targetId: targets.targetId,
|
targetId: targets.targetId,
|
||||||
targetEnabled: targets.enabled,
|
targetEnabled: targets.enabled,
|
||||||
@@ -131,6 +131,9 @@ export async function getTraefikConfig(
|
|||||||
method: targets.method,
|
method: targets.method,
|
||||||
port: targets.port,
|
port: targets.port,
|
||||||
internalPort: targets.internalPort,
|
internalPort: targets.internalPort,
|
||||||
|
path: targets.path,
|
||||||
|
pathMatchType: targets.pathMatchType,
|
||||||
|
|
||||||
// Site fields
|
// Site fields
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
siteType: sites.type,
|
siteType: sites.type,
|
||||||
@@ -152,7 +155,7 @@ export async function getTraefikConfig(
|
|||||||
inArray(sites.type, siteTypes),
|
inArray(sites.type, siteTypes),
|
||||||
config.getRawConfig().traefik.allow_raw_resources
|
config.getRawConfig().traefik.allow_raw_resources
|
||||||
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
|
? 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) => {
|
resourcesWithTargetsAndSites.forEach((row) => {
|
||||||
const resourceId = row.resourceId;
|
const resourceId = row.resourceId;
|
||||||
|
const targetPath = sanitizePath(row.path) || ""; // Handle null/undefined paths
|
||||||
|
const pathMatchType = row.pathMatchType || "";
|
||||||
|
|
||||||
if (!resourcesMap.has(resourceId)) {
|
// Create a unique key combining resourceId and path+pathMatchType
|
||||||
resourcesMap.set(resourceId, {
|
const pathKey = [targetPath, pathMatchType].filter(Boolean).join("-");
|
||||||
|
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||||
|
|
||||||
|
if (!resourcesMap.has(mapKey)) {
|
||||||
|
resourcesMap.set(mapKey, {
|
||||||
resourceId: row.resourceId,
|
resourceId: row.resourceId,
|
||||||
fullDomain: row.fullDomain,
|
fullDomain: row.fullDomain,
|
||||||
ssl: row.ssl,
|
ssl: row.ssl,
|
||||||
@@ -177,12 +186,15 @@ export async function getTraefikConfig(
|
|||||||
tlsServerName: row.tlsServerName,
|
tlsServerName: row.tlsServerName,
|
||||||
setHostHeader: row.setHostHeader,
|
setHostHeader: row.setHostHeader,
|
||||||
enableProxy: row.enableProxy,
|
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
|
// Add target with its associated site data
|
||||||
resourcesMap.get(resourceId).targets.push({
|
resourcesMap.get(mapKey).targets.push({
|
||||||
resourceId: row.resourceId,
|
resourceId: row.resourceId,
|
||||||
targetId: row.targetId,
|
targetId: row.targetId,
|
||||||
ip: row.ip,
|
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 {};
|
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 targets = resource.targets;
|
||||||
|
|
||||||
const routerName = `${resource.resourceId}-router`;
|
const routerName = `${key}-router`;
|
||||||
const serviceName = `${resource.resourceId}-service`;
|
const serviceName = `${key}-service`;
|
||||||
const fullDomain = `${resource.fullDomain}`;
|
const fullDomain = `${resource.fullDomain}`;
|
||||||
const transportName = `${resource.resourceId}-transport`;
|
const transportName = `${key}-transport`;
|
||||||
const hostHeaderMiddlewareName = `${resource.resourceId}-host-header-middleware`;
|
const headersMiddlewareName = `${key}-headers-middleware`;
|
||||||
|
|
||||||
if (!resource.enabled) {
|
if (!resource.enabled) {
|
||||||
continue;
|
continue;
|
||||||
@@ -238,9 +254,6 @@ export async function getTraefikConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!resource.fullDomain) {
|
if (!resource.fullDomain) {
|
||||||
logger.error(
|
|
||||||
`Resource ${resource.resourceId} has no fullDomain`
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,16 +309,68 @@ export async function getTraefikConfig(
|
|||||||
const additionalMiddlewares =
|
const additionalMiddlewares =
|
||||||
config.getRawConfig().traefik.additional_middlewares || [];
|
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] = {
|
config_output.http.routers![routerName] = {
|
||||||
entryPoints: [
|
entryPoints: [
|
||||||
resource.ssl
|
resource.ssl
|
||||||
? config.getRawConfig().traefik.https_entrypoint
|
? config.getRawConfig().traefik.https_entrypoint
|
||||||
: config.getRawConfig().traefik.http_entrypoint
|
: config.getRawConfig().traefik.http_entrypoint
|
||||||
],
|
],
|
||||||
middlewares: [badgerMiddlewareName, ...additionalMiddlewares],
|
middlewares: routerMiddlewares,
|
||||||
service: serviceName,
|
service: serviceName,
|
||||||
rule: `Host(\`${fullDomain}\`)`,
|
rule: rule,
|
||||||
priority: 100,
|
priority: priority,
|
||||||
...(resource.ssl ? { tls } : {})
|
...(resource.ssl ? { tls } : {})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -316,8 +381,8 @@ export async function getTraefikConfig(
|
|||||||
],
|
],
|
||||||
middlewares: [redirectHttpsMiddlewareName],
|
middlewares: [redirectHttpsMiddlewareName],
|
||||||
service: serviceName,
|
service: serviceName,
|
||||||
rule: `Host(\`${fullDomain}\`)`,
|
rule: rule,
|
||||||
priority: 100
|
priority: priority
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,27 +478,6 @@ export async function getTraefikConfig(
|
|||||||
serviceName
|
serviceName
|
||||||
].loadBalancer.serversTransport = transportName;
|
].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 {
|
} else {
|
||||||
// Non-HTTP (TCP/UDP) configuration
|
// Non-HTTP (TCP/UDP) configuration
|
||||||
if (!resource.enableProxy) {
|
if (!resource.enableProxy) {
|
||||||
@@ -529,3 +573,13 @@ export async function getTraefikConfig(
|
|||||||
}
|
}
|
||||||
return config_output;
|
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, "");
|
||||||
|
}
|
||||||
@@ -84,7 +84,14 @@ export async function createOrgUser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
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
|
const [role] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -141,7 +148,12 @@ export async function createOrgUser(
|
|||||||
const [existingUser] = await trx
|
const [existingUser] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.username, username));
|
.where(
|
||||||
|
and(
|
||||||
|
eq(users.username, username),
|
||||||
|
eq(users.idpId, idpId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
const [existingOrgUser] = await trx
|
const [existingOrgUser] = await trx
|
||||||
@@ -168,7 +180,8 @@ export async function createOrgUser(
|
|||||||
.values({
|
.values({
|
||||||
orgId,
|
orgId,
|
||||||
userId: existingUser.userId,
|
userId: existingUser.userId,
|
||||||
roleId: role.roleId
|
roleId: role.roleId,
|
||||||
|
autoProvisioned: false
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
} else {
|
} else {
|
||||||
@@ -184,7 +197,7 @@ export async function createOrgUser(
|
|||||||
type: "oidc",
|
type: "oidc",
|
||||||
idpId,
|
idpId,
|
||||||
dateCreated: new Date().toISOString(),
|
dateCreated: new Date().toISOString(),
|
||||||
emailVerified: true
|
emailVerified: true,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -193,7 +206,8 @@ export async function createOrgUser(
|
|||||||
.values({
|
.values({
|
||||||
orgId,
|
orgId,
|
||||||
userId: newUser.userId,
|
userId: newUser.userId,
|
||||||
roleId: role.roleId
|
roleId: role.roleId,
|
||||||
|
autoProvisioned: false
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
}
|
}
|
||||||
@@ -204,7 +218,6 @@ export async function createOrgUser(
|
|||||||
.from(userOrgs)
|
.from(userOrgs)
|
||||||
.where(eq(userOrgs.orgId, orgId));
|
.where(eq(userOrgs.orgId, orgId));
|
||||||
});
|
});
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "User type is required")
|
createHttpError(HttpCode.BAD_REQUEST, "User type is required")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db, idp, idpOidcConfig } from "@server/db";
|
||||||
import { roles, userOrgs, users } from "@server/db";
|
import { roles, userOrgs, users } from "@server/db";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -25,10 +25,18 @@ async function queryUser(orgId: string, userId: string) {
|
|||||||
isOwner: userOrgs.isOwner,
|
isOwner: userOrgs.isOwner,
|
||||||
isAdmin: roles.isAdmin,
|
isAdmin: roles.isAdmin,
|
||||||
twoFactorEnabled: users.twoFactorEnabled,
|
twoFactorEnabled: users.twoFactorEnabled,
|
||||||
|
autoProvisioned: userOrgs.autoProvisioned,
|
||||||
|
idpId: users.idpId,
|
||||||
|
idpName: idp.name,
|
||||||
|
idpType: idp.type,
|
||||||
|
idpVariant: idpOidcConfig.variant,
|
||||||
|
idpAutoProvision: idp.autoProvision
|
||||||
})
|
})
|
||||||
.from(userOrgs)
|
.from(userOrgs)
|
||||||
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
||||||
.leftJoin(users, eq(userOrgs.userId, users.userId))
|
.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)))
|
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
return user;
|
return user;
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ export * from "./removeInvitation";
|
|||||||
export * from "./createOrgUser";
|
export * from "./createOrgUser";
|
||||||
export * from "./adminUpdateUser2FA";
|
export * from "./adminUpdateUser2FA";
|
||||||
export * from "./adminGetUser";
|
export * from "./adminGetUser";
|
||||||
|
export * from "./updateOrgUser";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db, idpOidcConfig } from "@server/db";
|
||||||
import { idp, roles, userOrgs, users } from "@server/db";
|
import { idp, roles, userOrgs, users } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -50,12 +50,15 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
|
|||||||
isOwner: userOrgs.isOwner,
|
isOwner: userOrgs.isOwner,
|
||||||
idpName: idp.name,
|
idpName: idp.name,
|
||||||
idpId: users.idpId,
|
idpId: users.idpId,
|
||||||
|
idpType: idp.type,
|
||||||
|
idpVariant: idpOidcConfig.variant,
|
||||||
twoFactorEnabled: users.twoFactorEnabled,
|
twoFactorEnabled: users.twoFactorEnabled,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||||
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
||||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||||
|
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
|
||||||
.where(eq(userOrgs.orgId, orgId))
|
.where(eq(userOrgs.orgId, orgId))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
|
|||||||
112
server/routers/user/updateOrgUser.ts
Normal file
112
server/routers/user/updateOrgUser.ts
Normal file
@@ -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<any> {
|
||||||
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
handleGetConfigMessage,
|
handleGetConfigMessage,
|
||||||
handleDockerStatusMessage,
|
handleDockerStatusMessage,
|
||||||
handleDockerContainersMessage,
|
handleDockerContainersMessage,
|
||||||
handleNewtPingRequestMessage
|
handleNewtPingRequestMessage,
|
||||||
|
handleApplyBlueprintMessage
|
||||||
} from "../newt";
|
} from "../newt";
|
||||||
import {
|
import {
|
||||||
handleOlmRegisterMessage,
|
handleOlmRegisterMessage,
|
||||||
@@ -23,7 +24,8 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
|||||||
"olm/ping": handleOlmPingMessage,
|
"olm/ping": handleOlmPingMessage,
|
||||||
"newt/socket/status": handleDockerStatusMessage,
|
"newt/socket/status": handleDockerStatusMessage,
|
||||||
"newt/socket/containers": handleDockerContainersMessage,
|
"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
|
startOlmOfflineChecker(); // this is to handle the offline check for olms
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import m1 from "./scriptsPg/1.6.0";
|
|||||||
import m2 from "./scriptsPg/1.7.0";
|
import m2 from "./scriptsPg/1.7.0";
|
||||||
import m3 from "./scriptsPg/1.8.0";
|
import m3 from "./scriptsPg/1.8.0";
|
||||||
import m4 from "./scriptsPg/1.9.0";
|
import m4 from "./scriptsPg/1.9.0";
|
||||||
|
import m5 from "./scriptsPg/1.10.0";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
@@ -18,7 +19,8 @@ const migrations = [
|
|||||||
{ version: "1.6.0", run: m1 },
|
{ version: "1.6.0", run: m1 },
|
||||||
{ version: "1.7.0", run: m2 },
|
{ version: "1.7.0", run: m2 },
|
||||||
{ version: "1.8.0", run: m3 },
|
{ 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
|
// Add new migrations here as they are created
|
||||||
] as {
|
] as {
|
||||||
version: string;
|
version: string;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import m21 from "./scriptsSqlite/1.6.0";
|
|||||||
import m22 from "./scriptsSqlite/1.7.0";
|
import m22 from "./scriptsSqlite/1.7.0";
|
||||||
import m23 from "./scriptsSqlite/1.8.0";
|
import m23 from "./scriptsSqlite/1.8.0";
|
||||||
import m24 from "./scriptsSqlite/1.9.0";
|
import m24 from "./scriptsSqlite/1.9.0";
|
||||||
|
import m25 from "./scriptsSqlite/1.10.0";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
@@ -51,6 +52,7 @@ const migrations = [
|
|||||||
{ version: "1.7.0", run: m22 },
|
{ version: "1.7.0", run: m22 },
|
||||||
{ version: "1.8.0", run: m23 },
|
{ version: "1.8.0", run: m23 },
|
||||||
{ version: "1.9.0", run: m24 },
|
{ version: "1.9.0", run: m24 },
|
||||||
|
{ version: "1.10.0", run: m25 },
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
128
server/setup/scriptsPg/1.10.0.ts
Normal file
128
server/setup/scriptsPg/1.10.0.ts
Normal file
@@ -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, "");
|
||||||
|
}
|
||||||
116
server/setup/scriptsSqlite/1.10.0.ts
Normal file
116
server/setup/scriptsSqlite/1.10.0.ts
Normal file
@@ -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<number, number>();
|
||||||
|
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, "");
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import UserProvider from "@app/providers/UserProvider";
|
import UserProvider from "@app/providers/UserProvider";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import OrganizationLandingCard from "./OrganizationLandingCard";
|
import OrganizationLandingCard from "../../components/OrganizationLandingCard";
|
||||||
import MemberResourcesPortal from "./MemberResourcesPortal";
|
import MemberResourcesPortal from "../../components/MemberResourcesPortal";
|
||||||
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
|
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
|
||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import InvitationsTable, { InvitationRow } from "./InvitationsTable";
|
import InvitationsTable, { InvitationRow } from "../../../../../components/InvitationsTable";
|
||||||
import { GetOrgResponse } from "@server/routers/org";
|
import { GetOrgResponse } from "@server/routers/org";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import UserProvider from "@app/providers/UserProvider";
|
import UserProvider from "@app/providers/UserProvider";
|
||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav";
|
import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { GetOrgResponse } from "@server/routers/org";
|
|||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import { ListRolesResponse } from "@server/routers/role";
|
import { ListRolesResponse } from "@server/routers/role";
|
||||||
import RolesTable, { RoleRow } from "./RolesTable";
|
import RolesTable, { RoleRow } from "../../../../../components/RolesTable";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "@app/components/ui/select";
|
} from "@app/components/ui/select";
|
||||||
|
import { Checkbox } from "@app/components/ui/checkbox";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { InviteUserResponse } from "@server/routers/user";
|
import { InviteUserResponse } from "@server/routers/user";
|
||||||
@@ -41,6 +42,8 @@ import { formatAxiosError } from "@app/lib/api";
|
|||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import IdpTypeBadge from "@app/components/IdpTypeBadge";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
|
||||||
export default function AccessControlsPage() {
|
export default function AccessControlsPage() {
|
||||||
const { orgUser: user } = userOrgUserContext();
|
const { orgUser: user } = userOrgUserContext();
|
||||||
@@ -56,14 +59,16 @@ export default function AccessControlsPage() {
|
|||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
username: z.string(),
|
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<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
username: user.username!,
|
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);
|
console.error(e);
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t('accessRoleErrorFetch'),
|
title: t("accessRoleErrorFetch"),
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(
|
||||||
e,
|
e,
|
||||||
t('accessRoleErrorFetchDescription')
|
t("accessRoleErrorFetchDescription")
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -91,31 +96,38 @@ export default function AccessControlsPage() {
|
|||||||
fetchRoles();
|
fetchRoles();
|
||||||
|
|
||||||
form.setValue("roleId", user.roleId.toString());
|
form.setValue("roleId", user.roleId.toString());
|
||||||
|
form.setValue("autoProvisioned", user.autoProvisioned || false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const res = await api
|
try {
|
||||||
.post<
|
// Execute both API calls simultaneously
|
||||||
AxiosResponse<InviteUserResponse>
|
const [roleRes, userRes] = await Promise.all([
|
||||||
>(`/role/${values.roleId}/add/${user.userId}`)
|
api.post<AxiosResponse<InviteUserResponse>>(
|
||||||
.catch((e) => {
|
`/role/${values.roleId}/add/${user.userId}`
|
||||||
toast({
|
),
|
||||||
variant: "destructive",
|
api.post(`/org/${orgId}/user/${user.userId}`, {
|
||||||
title: t('accessRoleErrorAdd'),
|
autoProvisioned: values.autoProvisioned
|
||||||
description: formatAxiosError(
|
})
|
||||||
e,
|
]);
|
||||||
t('accessRoleErrorAddDescription')
|
|
||||||
)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res && res.status === 200) {
|
if (roleRes.status === 200 && userRes.status === 200) {
|
||||||
|
toast({
|
||||||
|
variant: "default",
|
||||||
|
title: t("userSaved"),
|
||||||
|
description: t("userSavedDescription")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
toast({
|
toast({
|
||||||
variant: "default",
|
variant: "destructive",
|
||||||
title: t('userSaved'),
|
title: t("accessRoleErrorAdd"),
|
||||||
description: t('userSavedDescription')
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
t("accessRoleErrorAddDescription")
|
||||||
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,9 +138,11 @@ export default function AccessControlsPage() {
|
|||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>{t('accessControls')}</SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
|
{t("accessControls")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
{t('accessControlsDescription')}
|
{t("accessControlsDescription")}
|
||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
@@ -140,19 +154,49 @@ export default function AccessControlsPage() {
|
|||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="access-controls-form"
|
id="access-controls-form"
|
||||||
>
|
>
|
||||||
|
{/* IDP Type Display */}
|
||||||
|
{user.type !== UserType.Internal &&
|
||||||
|
user.idpType && (
|
||||||
|
<div className="flex items-center space-x-2 mb-4">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("idp")}:
|
||||||
|
</span>
|
||||||
|
<IdpTypeBadge
|
||||||
|
type={user.idpType}
|
||||||
|
variant={
|
||||||
|
user.idpVariant || undefined
|
||||||
|
}
|
||||||
|
name={user.idpName || undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="roleId"
|
name="roleId"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('role')}</FormLabel>
|
<FormLabel>{t("role")}</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
// If auto provision is enabled, set it to false when role changes
|
||||||
|
if (user.idpAutoProvision) {
|
||||||
|
form.setValue(
|
||||||
|
"autoProvisioned",
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={t('accessRoleSelect')} />
|
<SelectValue
|
||||||
|
placeholder={t(
|
||||||
|
"accessRoleSelect"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -170,6 +214,35 @@ export default function AccessControlsPage() {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{user.idpAutoProvision && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="autoProvisioned"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-start space-x-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="space-y-1 leading-none">
|
||||||
|
<FormLabel>
|
||||||
|
{t("autoProvisioned")}
|
||||||
|
</FormLabel>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"autoProvisionedDescription"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</SettingsSectionForm>
|
</SettingsSectionForm>
|
||||||
@@ -182,7 +255,7 @@ export default function AccessControlsPage() {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
form="access-controls-form"
|
form="access-controls-form"
|
||||||
>
|
>
|
||||||
{t('accessControlsSubmit')}
|
{t("accessControlsSubmit")}
|
||||||
</Button>
|
</Button>
|
||||||
</SettingsSectionFooter>
|
</SettingsSectionFooter>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import { Checkbox } from "@app/components/ui/checkbox";
|
|||||||
import { ListIdpsResponse } from "@server/routers/idp";
|
import { ListIdpsResponse } from "@server/routers/idp";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
type UserType = "internal" | "oidc";
|
type UserType = "internal" | "oidc";
|
||||||
|
|
||||||
@@ -53,6 +54,17 @@ interface IdpOption {
|
|||||||
idpId: number;
|
idpId: number;
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
variant: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserOption {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
disabled: boolean;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
idpId?: number;
|
||||||
|
variant?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
@@ -62,14 +74,14 @@ export default function Page() {
|
|||||||
const api = createApiClient({ env });
|
const api = createApiClient({ env });
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const [userType, setUserType] = useState<UserType | null>("internal");
|
const [selectedOption, setSelectedOption] = useState<string | null>("internal");
|
||||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [expiresInDays, setExpiresInDays] = useState(1);
|
const [expiresInDays, setExpiresInDays] = useState(1);
|
||||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
||||||
const [idps, setIdps] = useState<IdpOption[]>([]);
|
const [idps, setIdps] = useState<IdpOption[]>([]);
|
||||||
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
|
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
|
||||||
const [selectedIdp, setSelectedIdp] = useState<IdpOption | null>(null);
|
const [userOptions, setUserOptions] = useState<UserOption[]>([]);
|
||||||
const [dataLoaded, setDataLoaded] = useState(false);
|
const [dataLoaded, setDataLoaded] = useState(false);
|
||||||
|
|
||||||
const internalFormSchema = z.object({
|
const internalFormSchema = z.object({
|
||||||
@@ -80,7 +92,13 @@ export default function Page() {
|
|||||||
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
|
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
|
||||||
});
|
});
|
||||||
|
|
||||||
const externalFormSchema = z.object({
|
const googleAzureFormSchema = z.object({
|
||||||
|
email: z.string().email({ message: t("emailInvalid") }),
|
||||||
|
name: z.string().optional(),
|
||||||
|
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
|
||||||
|
});
|
||||||
|
|
||||||
|
const genericOidcFormSchema = z.object({
|
||||||
username: z.string().min(1, { message: t("usernameRequired") }),
|
username: z.string().min(1, { message: t("usernameRequired") }),
|
||||||
email: z
|
email: z
|
||||||
.string()
|
.string()
|
||||||
@@ -96,11 +114,44 @@ export default function Page() {
|
|||||||
switch (type.toLowerCase()) {
|
switch (type.toLowerCase()) {
|
||||||
case "oidc":
|
case "oidc":
|
||||||
return t("idpGenericOidc");
|
return t("idpGenericOidc");
|
||||||
|
case "google":
|
||||||
|
return t("idpGoogleDescription");
|
||||||
|
case "azure":
|
||||||
|
return t("idpAzureDescription");
|
||||||
default:
|
default:
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getIdpIcon = (variant: string | null) => {
|
||||||
|
if (!variant) return null;
|
||||||
|
|
||||||
|
switch (variant.toLowerCase()) {
|
||||||
|
case "google":
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src="/idp/google.png"
|
||||||
|
alt={t("idpGoogleAlt")}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "azure":
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src="/idp/azure.png"
|
||||||
|
alt={t("idpAzureAlt")}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const validFor = [
|
const validFor = [
|
||||||
{ hours: 24, name: t("day", { count: 1 }) },
|
{ hours: 24, name: t("day", { count: 1 }) },
|
||||||
{ hours: 48, name: t("day", { count: 2 }) },
|
{ hours: 48, name: t("day", { count: 2 }) },
|
||||||
@@ -120,8 +171,17 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const externalForm = useForm<z.infer<typeof externalFormSchema>>({
|
const googleAzureForm = useForm<z.infer<typeof googleAzureFormSchema>>({
|
||||||
resolver: zodResolver(externalFormSchema),
|
resolver: zodResolver(googleAzureFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: "",
|
||||||
|
name: "",
|
||||||
|
roleId: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const genericOidcForm = useForm<z.infer<typeof genericOidcFormSchema>>({
|
||||||
|
resolver: zodResolver(genericOidcFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
username: "",
|
username: "",
|
||||||
email: "",
|
email: "",
|
||||||
@@ -132,33 +192,19 @@ export default function Page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userType === "internal") {
|
if (selectedOption === "internal") {
|
||||||
setSendEmail(env.email.emailEnabled);
|
setSendEmail(env.email.emailEnabled);
|
||||||
internalForm.reset();
|
internalForm.reset();
|
||||||
setInviteLink(null);
|
setInviteLink(null);
|
||||||
setExpiresInDays(1);
|
setExpiresInDays(1);
|
||||||
} else if (userType === "oidc") {
|
} else if (selectedOption && selectedOption !== "internal") {
|
||||||
externalForm.reset();
|
googleAzureForm.reset();
|
||||||
|
genericOidcForm.reset();
|
||||||
}
|
}
|
||||||
}, [userType, env.email.emailEnabled, internalForm, externalForm]);
|
}, [selectedOption, env.email.emailEnabled, internalForm, googleAzureForm, genericOidcForm]);
|
||||||
|
|
||||||
const [userTypes, setUserTypes] = useState<StrategyOption<string>[]>([
|
|
||||||
{
|
|
||||||
id: "internal",
|
|
||||||
title: t("userTypeInternal"),
|
|
||||||
description: t("userTypeInternalDescription"),
|
|
||||||
disabled: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "oidc",
|
|
||||||
title: t("userTypeExternal"),
|
|
||||||
description: t("userTypeExternalDescription"),
|
|
||||||
disabled: true
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userType) {
|
if (!selectedOption) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,20 +245,6 @@ export default function Page() {
|
|||||||
|
|
||||||
if (res?.status === 200) {
|
if (res?.status === 200) {
|
||||||
setIdps(res.data.data.idps);
|
setIdps(res.data.data.idps);
|
||||||
|
|
||||||
if (res.data.data.idps.length) {
|
|
||||||
setUserTypes((prev) =>
|
|
||||||
prev.map((type) => {
|
|
||||||
if (type.id === "oidc") {
|
|
||||||
return {
|
|
||||||
...type,
|
|
||||||
disabled: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return type;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +258,33 @@ export default function Page() {
|
|||||||
fetchInitialData();
|
fetchInitialData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Build user options when IDPs are loaded
|
||||||
|
useEffect(() => {
|
||||||
|
const options: UserOption[] = [
|
||||||
|
{
|
||||||
|
id: "internal",
|
||||||
|
title: t("userTypeInternal"),
|
||||||
|
description: t("userTypeInternalDescription"),
|
||||||
|
disabled: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add IDP options
|
||||||
|
idps.forEach((idp) => {
|
||||||
|
options.push({
|
||||||
|
id: `idp-${idp.idpId}`,
|
||||||
|
title: idp.name,
|
||||||
|
description: formatIdpType(idp.variant || idp.type),
|
||||||
|
disabled: false,
|
||||||
|
icon: getIdpIcon(idp.variant),
|
||||||
|
idpId: idp.idpId,
|
||||||
|
variant: idp.variant
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setUserOptions(options);
|
||||||
|
}, [idps, t]);
|
||||||
|
|
||||||
async function onSubmitInternal(
|
async function onSubmitInternal(
|
||||||
values: z.infer<typeof internalFormSchema>
|
values: z.infer<typeof internalFormSchema>
|
||||||
) {
|
) {
|
||||||
@@ -274,9 +333,52 @@ export default function Page() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmitExternal(
|
async function onSubmitGoogleAzure(
|
||||||
values: z.infer<typeof externalFormSchema>
|
values: z.infer<typeof googleAzureFormSchema>
|
||||||
) {
|
) {
|
||||||
|
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
|
||||||
|
if (!selectedUserOption?.idpId) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const res = await api
|
||||||
|
.put(`/org/${orgId}/user`, {
|
||||||
|
username: values.email, // Use email as username for Google/Azure
|
||||||
|
email: values.email,
|
||||||
|
name: values.name,
|
||||||
|
type: "oidc",
|
||||||
|
idpId: selectedUserOption.idpId,
|
||||||
|
roleId: parseInt(values.roleId)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("userErrorCreate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
t("userErrorCreateDescription")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.status === 201) {
|
||||||
|
toast({
|
||||||
|
variant: "default",
|
||||||
|
title: t("userCreated"),
|
||||||
|
description: t("userCreatedDescription")
|
||||||
|
});
|
||||||
|
router.push(`/${orgId}/settings/access/users`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmitGenericOidc(
|
||||||
|
values: z.infer<typeof genericOidcFormSchema>
|
||||||
|
) {
|
||||||
|
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
|
||||||
|
if (!selectedUserOption?.idpId) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const res = await api
|
const res = await api
|
||||||
@@ -285,7 +387,7 @@ export default function Page() {
|
|||||||
email: values.email,
|
email: values.email,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
type: "oidc",
|
type: "oidc",
|
||||||
idpId: parseInt(values.idpId),
|
idpId: selectedUserOption.idpId,
|
||||||
roleId: parseInt(values.roleId)
|
roleId: parseInt(values.roleId)
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -330,7 +432,7 @@ export default function Page() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
{!inviteLink && build !== "saas" ? (
|
{!inviteLink && build !== "saas" && dataLoaded ? (
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
@@ -342,15 +444,15 @@ export default function Page() {
|
|||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<StrategySelect
|
<StrategySelect
|
||||||
options={userTypes}
|
options={userOptions}
|
||||||
defaultValue={userType || undefined}
|
defaultValue={selectedOption || undefined}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setUserType(value as UserType);
|
setSelectedOption(value);
|
||||||
if (value === "internal") {
|
if (value === "internal") {
|
||||||
internalForm.reset();
|
internalForm.reset();
|
||||||
} else if (value === "oidc") {
|
} else {
|
||||||
externalForm.reset();
|
googleAzureForm.reset();
|
||||||
setSelectedIdp(null);
|
genericOidcForm.reset();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
cols={2}
|
cols={2}
|
||||||
@@ -359,7 +461,7 @@ export default function Page() {
|
|||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{userType === "internal" && dataLoaded && (
|
{selectedOption === "internal" && dataLoaded && (
|
||||||
<>
|
<>
|
||||||
{!inviteLink ? (
|
{!inviteLink ? (
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
@@ -564,71 +666,7 @@ export default function Page() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{userType !== "internal" && dataLoaded && (
|
{selectedOption && selectedOption !== "internal" && dataLoaded && (
|
||||||
<>
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>
|
|
||||||
{t("idpTitle")}
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("idpSelect")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
{idps.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{t("idpNotConfigured")}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<Form {...externalForm}>
|
|
||||||
<FormField
|
|
||||||
control={externalForm.control}
|
|
||||||
name="idpId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<StrategySelect
|
|
||||||
options={idps.map(
|
|
||||||
(idp) => ({
|
|
||||||
id: idp.idpId.toString(),
|
|
||||||
title: idp.name,
|
|
||||||
description:
|
|
||||||
formatIdpType(
|
|
||||||
idp.type
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
defaultValue={
|
|
||||||
field.value
|
|
||||||
}
|
|
||||||
onChange={(
|
|
||||||
value
|
|
||||||
) => {
|
|
||||||
field.onChange(
|
|
||||||
value
|
|
||||||
);
|
|
||||||
const idp =
|
|
||||||
idps.find(
|
|
||||||
(idp) =>
|
|
||||||
idp.idpId.toString() ===
|
|
||||||
value
|
|
||||||
);
|
|
||||||
setSelectedIdp(
|
|
||||||
idp || null
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
cols={2}
|
|
||||||
/>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</SettingsSectionBody>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
{idps.length > 0 && (
|
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
@@ -640,144 +678,206 @@ export default function Page() {
|
|||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SettingsSectionForm>
|
<SettingsSectionForm>
|
||||||
<Form {...externalForm}>
|
{/* Google/Azure Form */}
|
||||||
<form
|
{(() => {
|
||||||
onSubmit={externalForm.handleSubmit(
|
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
|
||||||
onSubmitExternal
|
return selectedUserOption?.variant === "google" || selectedUserOption?.variant === "azure";
|
||||||
)}
|
})() && (
|
||||||
className="space-y-4"
|
<Form {...googleAzureForm}>
|
||||||
id="create-user-form"
|
<form
|
||||||
>
|
onSubmit={googleAzureForm.handleSubmit(
|
||||||
<FormField
|
onSubmitGoogleAzure
|
||||||
control={
|
|
||||||
externalForm.control
|
|
||||||
}
|
|
||||||
name="username"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t(
|
|
||||||
"username"
|
|
||||||
)}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
{t(
|
|
||||||
"usernameUniq"
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
)}
|
||||||
/>
|
className="space-y-4"
|
||||||
|
id="create-user-form"
|
||||||
<FormField
|
>
|
||||||
control={
|
<FormField
|
||||||
externalForm.control
|
control={googleAzureForm.control}
|
||||||
}
|
name="email"
|
||||||
name="email"
|
render={({ field }) => (
|
||||||
render={({ field }) => (
|
<FormItem>
|
||||||
<FormItem>
|
<FormLabel>
|
||||||
<FormLabel>
|
{t("email")}
|
||||||
{t(
|
</FormLabel>
|
||||||
"emailOptional"
|
|
||||||
)}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={
|
|
||||||
externalForm.control
|
|
||||||
}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t(
|
|
||||||
"nameOptional"
|
|
||||||
)}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={
|
|
||||||
externalForm.control
|
|
||||||
}
|
|
||||||
name="roleId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t("role")}
|
|
||||||
</FormLabel>
|
|
||||||
<Select
|
|
||||||
onValueChange={
|
|
||||||
field.onChange
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger className="w-full">
|
<Input
|
||||||
<SelectValue
|
{...field}
|
||||||
placeholder={t(
|
/>
|
||||||
"accessRoleSelect"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<FormMessage />
|
||||||
{roles.map(
|
</FormItem>
|
||||||
(
|
)}
|
||||||
role
|
/>
|
||||||
) => (
|
|
||||||
|
<FormField
|
||||||
|
control={googleAzureForm.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("nameOptional")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={googleAzureForm.control}
|
||||||
|
name="roleId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("role")}
|
||||||
|
</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t("accessRoleSelect")}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{roles.map((role) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={
|
key={role.roleId}
|
||||||
role.roleId
|
|
||||||
}
|
|
||||||
value={role.roleId.toString()}
|
value={role.roleId.toString()}
|
||||||
>
|
>
|
||||||
{
|
{role.name}
|
||||||
role.name
|
|
||||||
}
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)
|
))}
|
||||||
)}
|
</SelectContent>
|
||||||
</SelectContent>
|
</Select>
|
||||||
</Select>
|
<FormMessage />
|
||||||
<FormMessage />
|
</FormItem>
|
||||||
</FormItem>
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Generic OIDC Form */}
|
||||||
|
{(() => {
|
||||||
|
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
|
||||||
|
return selectedUserOption?.variant !== "google" && selectedUserOption?.variant !== "azure";
|
||||||
|
})() && (
|
||||||
|
<Form {...genericOidcForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={genericOidcForm.handleSubmit(
|
||||||
|
onSubmitGenericOidc
|
||||||
)}
|
)}
|
||||||
/>
|
className="space-y-4"
|
||||||
</form>
|
id="create-user-form"
|
||||||
</Form>
|
>
|
||||||
|
<FormField
|
||||||
|
control={genericOidcForm.control}
|
||||||
|
name="username"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("username")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{t("usernameUniq")}
|
||||||
|
</p>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={genericOidcForm.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("emailOptional")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={genericOidcForm.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("nameOptional")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={genericOidcForm.control}
|
||||||
|
name="roleId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("role")}
|
||||||
|
</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t("accessRoleSelect")}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<SelectItem
|
||||||
|
key={role.roleId}
|
||||||
|
value={role.roleId.toString()}
|
||||||
|
>
|
||||||
|
{role.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
</SettingsSectionForm>
|
</SettingsSectionForm>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
|
|
||||||
<div className="flex justify-end space-x-2 mt-8">
|
<div className="flex justify-end space-x-2 mt-8">
|
||||||
{userType && dataLoaded && (
|
{selectedOption && dataLoaded && (
|
||||||
<Button
|
<Button
|
||||||
type={inviteLink ? "button" : "submit"}
|
type={inviteLink ? "button" : "submit"}
|
||||||
form={inviteLink ? undefined : "create-user-form"}
|
form={inviteLink ? undefined : "create-user-form"}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { internal } from "@app/lib/api";
|
|||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { ListUsersResponse } from "@server/routers/user";
|
import { ListUsersResponse } from "@server/routers/user";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import UsersTable, { UserRow } from "./UsersTable";
|
import UsersTable, { UserRow } from "../../../../../components/UsersTable";
|
||||||
import { GetOrgResponse } from "@server/routers/org";
|
import { GetOrgResponse } from "@server/routers/org";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import UserProvider from "@app/providers/UserProvider";
|
import UserProvider from "@app/providers/UserProvider";
|
||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav";
|
import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
@@ -77,6 +77,7 @@ export default async function UsersPage(props: UsersPageProps) {
|
|||||||
name: user.name,
|
name: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
type: user.type,
|
type: user.type,
|
||||||
|
idpVariant: user.idpVariant,
|
||||||
idpId: user.idpId,
|
idpId: user.idpId,
|
||||||
idpName: user.idpName || t('idpNameInternal'),
|
idpName: user.idpName || t('idpNameInternal'),
|
||||||
status: t('userConfirmed'),
|
status: t('userConfirmed'),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { internal } from "@app/lib/api";
|
|||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import OrgApiKeysTable, { OrgApiKeyRow } from "./OrgApiKeysTable";
|
import OrgApiKeysTable, { OrgApiKeyRow } from "../../../../components/OrgApiKeysTable";
|
||||||
import { ListOrgApiKeysResponse } from "@server/routers/apiKeys";
|
import { ListOrgApiKeysResponse } from "@server/routers/apiKeys";
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ export const dynamic = "force-dynamic";
|
|||||||
export default async function ApiKeysPage(props: ApiKeyPageProps) {
|
export default async function ApiKeysPage(props: ApiKeyPageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
|
||||||
let apiKeys: ListOrgApiKeysResponse["apiKeys"] = [];
|
let apiKeys: ListOrgApiKeysResponse["apiKeys"] = [];
|
||||||
try {
|
try {
|
||||||
const res = await internal.get<AxiosResponse<ListOrgApiKeysResponse>>(
|
const res = await internal.get<AxiosResponse<ListOrgApiKeysResponse>>(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { AxiosResponse } from "axios";
|
|||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { GetClientResponse } from "@server/routers/client";
|
import { GetClientResponse } from "@server/routers/client";
|
||||||
import ClientInfoCard from "./ClientInfoCard";
|
import ClientInfoCard from "../../../../../components/ClientInfoCard";
|
||||||
import ClientProvider from "@app/providers/ClientProvider";
|
import ClientProvider from "@app/providers/ClientProvider";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { ClientRow } from "./ClientsTable";
|
import { ClientRow } from "../../../../components/ClientsTable";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { ListClientsResponse } from "@server/routers/client";
|
import { ListClientsResponse } from "@server/routers/client";
|
||||||
import ClientsTable from "./ClientsTable";
|
import ClientsTable from "../../../../components/ClientsTable";
|
||||||
|
|
||||||
type ClientsPageProps = {
|
type ClientsPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { internal } from "@app/lib/api";
|
|||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import DomainsTable, { DomainRow } from "./DomainsTable";
|
import DomainsTable, { DomainRow } from "../../../../components/DomainsTable";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import { GetOrgResponse } from "@server/routers/org";
|
import { GetOrgResponse } from "@server/routers/org";
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ import {
|
|||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { ListUsersResponse } from "@server/routers/user";
|
import { ListUsersResponse } from "@server/routers/user";
|
||||||
import { Binary, Key } from "lucide-react";
|
import { Binary, Key } from "lucide-react";
|
||||||
import SetResourcePasswordForm from "./SetResourcePasswordForm";
|
import SetResourcePasswordForm from "../../../../../../components/SetResourcePasswordForm";
|
||||||
import SetResourcePincodeForm from "./SetResourcePincodeForm";
|
import SetResourcePincodeForm from "../../../../../../components/SetResourcePincodeForm";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import {
|
import {
|
||||||
@@ -54,7 +54,7 @@ import DomainPicker from "@app/components/DomainPicker";
|
|||||||
import { Globe } from "lucide-react";
|
import { Globe } from "lucide-react";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
||||||
import { DomainRow } from "../../../domains/DomainsTable";
|
import { DomainRow } from "../../../../../../components/DomainsTable";
|
||||||
import { toASCII, toUnicode } from "punycode";
|
import { toASCII, toUnicode } from "punycode";
|
||||||
|
|
||||||
export default function GeneralForm() {
|
export default function GeneralForm() {
|
||||||
@@ -160,7 +160,7 @@ export default function GeneralForm() {
|
|||||||
const rawDomains = res.data.data.domains as DomainRow[];
|
const rawDomains = res.data.data.domains as DomainRow[];
|
||||||
const domains = rawDomains.map((domain) => ({
|
const domains = rawDomains.map((domain) => ({
|
||||||
...domain,
|
...domain,
|
||||||
baseDomain: toUnicode(domain.baseDomain),
|
baseDomain: toUnicode(domain.baseDomain),
|
||||||
}));
|
}));
|
||||||
setBaseDomains(domains);
|
setBaseDomains(domains);
|
||||||
setFormKey((key) => key + 1);
|
setFormKey((key) => key + 1);
|
||||||
@@ -471,15 +471,11 @@ export default function GeneralForm() {
|
|||||||
? `${sanitizedSubdomain}.${selectedDomain.baseDomain}`
|
? `${sanitizedSubdomain}.${selectedDomain.baseDomain}`
|
||||||
: selectedDomain.baseDomain;
|
: selectedDomain.baseDomain;
|
||||||
|
|
||||||
setResourceFullDomain(sanitizedFullDomain);
|
setResourceFullDomain(`${resource.ssl ? "https" : "http"}://${sanitizedFullDomain}`);
|
||||||
form.setValue("domainId", selectedDomain.domainId);
|
form.setValue("domainId", selectedDomain.domainId);
|
||||||
form.setValue("subdomain", sanitizedSubdomain);
|
form.setValue("subdomain", sanitizedSubdomain);
|
||||||
|
|
||||||
setEditDomainOpen(false);
|
setEditDomainOpen(false);
|
||||||
|
|
||||||
toast({
|
|
||||||
description: `Final domain: ${sanitizedFullDomain}`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -12,13 +12,13 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
|||||||
import { GetOrgResponse } from "@server/routers/org";
|
import { GetOrgResponse } from "@server/routers/org";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import ResourceInfoBox from "./ResourceInfoBox";
|
import ResourceInfoBox from "../../../../../components/ResourceInfoBox";
|
||||||
import { GetSiteResponse } from "@server/routers/site";
|
import { GetSiteResponse } from "@server/routers/site";
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
interface ResourceLayoutProps {
|
interface ResourceLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: Promise<{ resourceId: number | string; orgId: string }>;
|
params: Promise<{ niceId: string; orgId: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ResourceLayout(props: ResourceLayoutProps) {
|
export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||||
@@ -31,7 +31,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
|||||||
let resource = null;
|
let resource = null;
|
||||||
try {
|
try {
|
||||||
const res = await internal.get<AxiosResponse<GetResourceResponse>>(
|
const res = await internal.get<AxiosResponse<GetResourceResponse>>(
|
||||||
`/resource/${params.resourceId}`,
|
`/org/${params.orgId}/resource/${params.niceId}`,
|
||||||
await authCookieHeader()
|
await authCookieHeader()
|
||||||
);
|
);
|
||||||
resource = res.data.data;
|
resource = res.data.data;
|
||||||
@@ -77,22 +77,22 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{
|
{
|
||||||
title: t('general'),
|
title: t('general'),
|
||||||
href: `/{orgId}/settings/resources/{resourceId}/general`
|
href: `/{orgId}/settings/resources/{niceId}/general`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('proxy'),
|
title: t('proxy'),
|
||||||
href: `/{orgId}/settings/resources/{resourceId}/proxy`
|
href: `/{orgId}/settings/resources/{niceId}/proxy`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
if (resource.http) {
|
if (resource.http) {
|
||||||
navItems.push({
|
navItems.push({
|
||||||
title: t('authentication'),
|
title: t('authentication'),
|
||||||
href: `/{orgId}/settings/resources/{resourceId}/authentication`
|
href: `/{orgId}/settings/resources/{niceId}/authentication`
|
||||||
});
|
});
|
||||||
navItems.push({
|
navItems.push({
|
||||||
title: t('rules'),
|
title: t('rules'),
|
||||||
href: `/{orgId}/settings/resources/{resourceId}/rules`
|
href: `/{orgId}/settings/resources/{niceId}/rules`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default async function ResourcePage(props: {
|
export default async function ResourcePage(props: {
|
||||||
params: Promise<{ resourceId: number | string; orgId: string }>;
|
params: Promise<{ niceId: string; orgId: string }>;
|
||||||
}) {
|
}) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
redirect(
|
redirect(
|
||||||
`/${params.orgId}/settings/resources/${params.resourceId}/proxy`
|
`/${params.orgId}/settings/resources/${params.niceId}/proxy`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,9 @@ import {
|
|||||||
Heart,
|
Heart,
|
||||||
Check,
|
Check,
|
||||||
CircleCheck,
|
CircleCheck,
|
||||||
CircleX
|
CircleX,
|
||||||
|
ArrowRight,
|
||||||
|
MoveRight
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { ContainersSelector } from "@app/components/ContainersSelector";
|
import { ContainersSelector } from "@app/components/ContainersSelector";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -95,13 +97,48 @@ import {
|
|||||||
} from "@app/components/ui/command";
|
} from "@app/components/ui/command";
|
||||||
import { Badge } from "@app/components/ui/badge";
|
import { Badge } from "@app/components/ui/badge";
|
||||||
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
||||||
|
import { HeadersInput } from "@app/components/HeadersInput";
|
||||||
|
|
||||||
const addTargetSchema = z.object({
|
const addTargetSchema = z.object({
|
||||||
ip: z.string().refine(isTargetValid),
|
ip: z.string().refine(isTargetValid),
|
||||||
method: z.string().nullable(),
|
method: z.string().nullable(),
|
||||||
port: z.coerce.number().int().positive(),
|
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"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const targetsSettingsSchema = z.object({
|
const targetsSettingsSchema = z.object({
|
||||||
stickySession: z.boolean()
|
stickySession: z.boolean()
|
||||||
@@ -129,7 +166,9 @@ export default function ReverseProxyTargets(props: {
|
|||||||
const [targets, setTargets] = useState<LocalTarget[]>([]);
|
const [targets, setTargets] = useState<LocalTarget[]>([]);
|
||||||
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
|
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
|
||||||
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
||||||
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(new Map());
|
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
|
||||||
const initializeDockerForSite = async (siteId: number) => {
|
const initializeDockerForSite = async (siteId: number) => {
|
||||||
if (dockerStates.has(siteId)) {
|
if (dockerStates.has(siteId)) {
|
||||||
@@ -139,14 +178,14 @@ export default function ReverseProxyTargets(props: {
|
|||||||
const dockerManager = new DockerManager(api, siteId);
|
const dockerManager = new DockerManager(api, siteId);
|
||||||
const dockerState = await dockerManager.initializeDocker();
|
const dockerState = await dockerManager.initializeDocker();
|
||||||
|
|
||||||
setDockerStates(prev => new Map(prev.set(siteId, dockerState)));
|
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshContainersForSite = async (siteId: number) => {
|
const refreshContainersForSite = async (siteId: number) => {
|
||||||
const dockerManager = new DockerManager(api, siteId);
|
const dockerManager = new DockerManager(api, siteId);
|
||||||
const containers = await dockerManager.fetchContainers();
|
const containers = await dockerManager.fetchContainers();
|
||||||
|
|
||||||
setDockerStates(prev => {
|
setDockerStates((prev) => {
|
||||||
const newMap = new Map(prev);
|
const newMap = new Map(prev);
|
||||||
const existingState = newMap.get(siteId);
|
const existingState = newMap.get(siteId);
|
||||||
if (existingState) {
|
if (existingState) {
|
||||||
@@ -157,11 +196,13 @@ export default function ReverseProxyTargets(props: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getDockerStateForSite = (siteId: number): DockerState => {
|
const getDockerStateForSite = (siteId: number): DockerState => {
|
||||||
return dockerStates.get(siteId) || {
|
return (
|
||||||
isEnabled: false,
|
dockerStates.get(siteId) || {
|
||||||
isAvailable: false,
|
isEnabled: false,
|
||||||
containers: []
|
isAvailable: false,
|
||||||
};
|
containers: []
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [httpsTlsLoading, setHttpsTlsLoading] = useState(false);
|
const [httpsTlsLoading, setHttpsTlsLoading] = useState(false);
|
||||||
@@ -185,7 +226,8 @@ export default function ReverseProxyTargets(props: {
|
|||||||
{
|
{
|
||||||
message: t("proxyErrorInvalidHeader")
|
message: t("proxyErrorInvalidHeader")
|
||||||
}
|
}
|
||||||
)
|
),
|
||||||
|
headers: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
const tlsSettingsSchema = z.object({
|
const tlsSettingsSchema = z.object({
|
||||||
@@ -215,7 +257,9 @@ export default function ReverseProxyTargets(props: {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
ip: "",
|
ip: "",
|
||||||
method: resource.http ? "http" : null,
|
method: resource.http ? "http" : null,
|
||||||
port: "" as any as number
|
port: "" as any as number,
|
||||||
|
path: null,
|
||||||
|
pathMatchType: null
|
||||||
} as z.infer<typeof addTargetSchema>
|
} as z.infer<typeof addTargetSchema>
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -241,7 +285,8 @@ export default function ReverseProxyTargets(props: {
|
|||||||
const proxySettingsForm = useForm<ProxySettingsValues>({
|
const proxySettingsForm = useForm<ProxySettingsValues>({
|
||||||
resolver: zodResolver(proxySettingsSchema),
|
resolver: zodResolver(proxySettingsSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
setHostHeader: resource.setHostHeader || ""
|
setHostHeader: resource.setHostHeader || "",
|
||||||
|
headers: resource.headers || ""
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -256,7 +301,7 @@ export default function ReverseProxyTargets(props: {
|
|||||||
const fetchTargets = async () => {
|
const fetchTargets = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get<AxiosResponse<ListTargetsResponse>>(
|
const res = await api.get<AxiosResponse<ListTargetsResponse>>(
|
||||||
`/resource/${params.resourceId}/targets`
|
`/resource/${resource.resourceId}/targets`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
@@ -298,7 +343,9 @@ export default function ReverseProxyTargets(props: {
|
|||||||
setSites(res.data.data.sites);
|
setSites(res.data.data.sites);
|
||||||
|
|
||||||
// Initialize Docker for newt sites
|
// Initialize Docker for newt sites
|
||||||
const newtSites = res.data.data.sites.filter(site => site.type === "newt");
|
const newtSites = res.data.data.sites.filter(
|
||||||
|
(site) => site.type === "newt"
|
||||||
|
);
|
||||||
for (const site of newtSites) {
|
for (const site of newtSites) {
|
||||||
initializeDockerForSite(site.siteId);
|
initializeDockerForSite(site.siteId);
|
||||||
}
|
}
|
||||||
@@ -387,6 +434,8 @@ export default function ReverseProxyTargets(props: {
|
|||||||
|
|
||||||
const newTarget: LocalTarget = {
|
const newTarget: LocalTarget = {
|
||||||
...data,
|
...data,
|
||||||
|
path: data.path || null,
|
||||||
|
pathMatchType: data.pathMatchType || null,
|
||||||
siteType: site?.type || null,
|
siteType: site?.type || null,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
targetId: new Date().getTime(),
|
targetId: new Date().getTime(),
|
||||||
@@ -398,7 +447,9 @@ export default function ReverseProxyTargets(props: {
|
|||||||
addTargetForm.reset({
|
addTargetForm.reset({
|
||||||
ip: "",
|
ip: "",
|
||||||
method: resource.http ? "http" : null,
|
method: resource.http ? "http" : null,
|
||||||
port: "" as any as number
|
port: "" as any as number,
|
||||||
|
path: null,
|
||||||
|
pathMatchType: null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,11 +469,11 @@ export default function ReverseProxyTargets(props: {
|
|||||||
targets.map((target) =>
|
targets.map((target) =>
|
||||||
target.targetId === targetId
|
target.targetId === targetId
|
||||||
? {
|
? {
|
||||||
...target,
|
...target,
|
||||||
...data,
|
...data,
|
||||||
updated: true,
|
updated: true,
|
||||||
siteType: site?.type || null
|
siteType: site?.type || null
|
||||||
}
|
}
|
||||||
: target
|
: target
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -441,13 +492,15 @@ export default function ReverseProxyTargets(props: {
|
|||||||
port: target.port,
|
port: target.port,
|
||||||
method: target.method,
|
method: target.method,
|
||||||
enabled: target.enabled,
|
enabled: target.enabled,
|
||||||
siteId: target.siteId
|
siteId: target.siteId,
|
||||||
|
path: target.path,
|
||||||
|
pathMatchType: target.pathMatchType
|
||||||
};
|
};
|
||||||
|
|
||||||
if (target.new) {
|
if (target.new) {
|
||||||
const res = await api.put<
|
const res = await api.put<
|
||||||
AxiosResponse<CreateTargetResponse>
|
AxiosResponse<CreateTargetResponse>
|
||||||
>(`/resource/${params.resourceId}/target`, data);
|
>(`/resource/${resource.resourceId}/target`, data);
|
||||||
target.targetId = res.data.data.targetId;
|
target.targetId = res.data.data.targetId;
|
||||||
target.new = false;
|
target.new = false;
|
||||||
} else if (target.updated) {
|
} else if (target.updated) {
|
||||||
@@ -471,11 +524,12 @@ export default function ReverseProxyTargets(props: {
|
|||||||
stickySession: stickySessionData.stickySession,
|
stickySession: stickySessionData.stickySession,
|
||||||
ssl: tlsData.ssl,
|
ssl: tlsData.ssl,
|
||||||
tlsServerName: tlsData.tlsServerName || null,
|
tlsServerName: tlsData.tlsServerName || null,
|
||||||
setHostHeader: proxyData.setHostHeader || null
|
setHostHeader: proxyData.setHostHeader || null,
|
||||||
|
headers: proxyData.headers || null
|
||||||
};
|
};
|
||||||
|
|
||||||
// Single API call to update all settings
|
// Single API call to update all settings
|
||||||
await api.post(`/resource/${params.resourceId}`, payload);
|
await api.post(`/resource/${resource.resourceId}`, payload);
|
||||||
|
|
||||||
// Update local resource context
|
// Update local resource context
|
||||||
updateResource({
|
updateResource({
|
||||||
@@ -483,7 +537,8 @@ export default function ReverseProxyTargets(props: {
|
|||||||
stickySession: stickySessionData.stickySession,
|
stickySession: stickySessionData.stickySession,
|
||||||
ssl: tlsData.ssl,
|
ssl: tlsData.ssl,
|
||||||
tlsServerName: tlsData.tlsServerName || null,
|
tlsServerName: tlsData.tlsServerName || null,
|
||||||
setHostHeader: proxyData.setHostHeader || null
|
setHostHeader: proxyData.setHostHeader || null,
|
||||||
|
headers: proxyData.headers || null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,6 +567,89 @@ export default function ReverseProxyTargets(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnDef<LocalTarget>[] = [
|
const columns: ColumnDef<LocalTarget>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "path",
|
||||||
|
header: t("matchPath"),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const [showPathInput, setShowPathInput] = useState(
|
||||||
|
!!(row.original.path || row.original.pathMatchType)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!showPathInput) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowPathInput(true)}
|
||||||
|
>
|
||||||
|
+ {t("matchPath")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 min-w-[200px] items-center">
|
||||||
|
<Select
|
||||||
|
defaultValue={row.original.pathMatchType || "exact"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateTarget(row.original.targetId, {
|
||||||
|
...row.original,
|
||||||
|
pathMatchType: value as "exact" | "prefix" | "regex"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-25">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="exact">Exact</SelectItem>
|
||||||
|
<SelectItem value="prefix">Prefix</SelectItem>
|
||||||
|
<SelectItem value="regex">Regex</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
placeholder={
|
||||||
|
row.original.pathMatchType === "regex"
|
||||||
|
? "^/api/.*"
|
||||||
|
: "/path"
|
||||||
|
}
|
||||||
|
defaultValue={row.original.path || ""}
|
||||||
|
className="flex-1 min-w-[150px]"
|
||||||
|
onBlur={(e) => {
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setShowPathInput(false);
|
||||||
|
updateTarget(row.original.targetId, {
|
||||||
|
...row.original,
|
||||||
|
path: null,
|
||||||
|
pathMatchType: null
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<MoveRight className="ml-4 h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "siteId",
|
accessorKey: "siteId",
|
||||||
header: t("site"),
|
header: t("site"),
|
||||||
@@ -546,7 +684,7 @@ export default function ReverseProxyTargets(props: {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"justify-between flex-1",
|
"justify-between flex-1",
|
||||||
!row.original.siteId &&
|
!row.original.siteId &&
|
||||||
"text-muted-foreground"
|
"text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{row.original.siteId
|
{row.original.siteId
|
||||||
@@ -597,49 +735,59 @@ export default function ReverseProxyTargets(props: {
|
|||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
{selectedSite && selectedSite.type === "newt" && (() => {
|
{selectedSite &&
|
||||||
const dockerState = getDockerStateForSite(selectedSite.siteId);
|
selectedSite.type === "newt" &&
|
||||||
return (
|
(() => {
|
||||||
<ContainersSelector
|
const dockerState = getDockerStateForSite(
|
||||||
site={selectedSite}
|
selectedSite.siteId
|
||||||
containers={dockerState.containers}
|
);
|
||||||
isAvailable={dockerState.isAvailable}
|
return (
|
||||||
onContainerSelect={handleContainerSelectForTarget}
|
<ContainersSelector
|
||||||
onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
|
site={selectedSite}
|
||||||
/>
|
containers={dockerState.containers}
|
||||||
);
|
isAvailable={dockerState.isAvailable}
|
||||||
})()}
|
onContainerSelect={
|
||||||
|
handleContainerSelectForTarget
|
||||||
|
}
|
||||||
|
onRefresh={() =>
|
||||||
|
refreshContainersForSite(
|
||||||
|
selectedSite.siteId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
...(resource.http
|
...(resource.http
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
accessorKey: "method",
|
accessorKey: "method",
|
||||||
header: t("method"),
|
header: t("method"),
|
||||||
cell: ({ row }: { row: Row<LocalTarget> }) => (
|
cell: ({ row }: { row: Row<LocalTarget> }) => (
|
||||||
<Select
|
<Select
|
||||||
defaultValue={row.original.method ?? ""}
|
defaultValue={row.original.method ?? ""}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateTarget(row.original.targetId, {
|
updateTarget(row.original.targetId, {
|
||||||
...row.original,
|
...row.original,
|
||||||
method: value
|
method: value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
{row.original.method}
|
{row.original.method}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="http">http</SelectItem>
|
<SelectItem value="http">http</SelectItem>
|
||||||
<SelectItem value="https">https</SelectItem>
|
<SelectItem value="https">https</SelectItem>
|
||||||
<SelectItem value="h2c">h2c</SelectItem>
|
<SelectItem value="h2c">h2c</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
accessorKey: "ip",
|
accessorKey: "ip",
|
||||||
@@ -658,9 +806,13 @@ export default function ReverseProxyTargets(props: {
|
|||||||
if (parsed) {
|
if (parsed) {
|
||||||
updateTarget(row.original.targetId, {
|
updateTarget(row.original.targetId, {
|
||||||
...row.original,
|
...row.original,
|
||||||
method: hasProtocol ? parsed.protocol : row.original.method,
|
method: hasProtocol
|
||||||
|
? parsed.protocol
|
||||||
|
: row.original.method,
|
||||||
ip: parsed.host,
|
ip: parsed.host,
|
||||||
port: hasPort ? parsed.port : row.original.port
|
port: hasPort
|
||||||
|
? parsed.port
|
||||||
|
: row.original.port
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
updateTarget(row.original.targetId, {
|
updateTarget(row.original.targetId, {
|
||||||
@@ -807,21 +959,21 @@ export default function ReverseProxyTargets(props: {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"justify-between flex-1",
|
"justify-between flex-1",
|
||||||
!field.value &&
|
!field.value &&
|
||||||
"text-muted-foreground"
|
"text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{field.value
|
{field.value
|
||||||
? sites.find(
|
? sites.find(
|
||||||
(
|
(
|
||||||
site
|
site
|
||||||
) =>
|
) =>
|
||||||
site.siteId ===
|
site.siteId ===
|
||||||
field.value
|
field.value
|
||||||
)
|
)
|
||||||
?.name
|
?.name
|
||||||
: t(
|
: t(
|
||||||
"siteSelect"
|
"siteSelect"
|
||||||
)}
|
)}
|
||||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -887,18 +1039,35 @@ export default function ReverseProxyTargets(props: {
|
|||||||
);
|
);
|
||||||
return selectedSite &&
|
return selectedSite &&
|
||||||
selectedSite.type ===
|
selectedSite.type ===
|
||||||
"newt" ? (() => {
|
"newt"
|
||||||
const dockerState = getDockerStateForSite(selectedSite.siteId);
|
? (() => {
|
||||||
return (
|
const dockerState =
|
||||||
<ContainersSelector
|
getDockerStateForSite(
|
||||||
site={selectedSite}
|
selectedSite.siteId
|
||||||
containers={dockerState.containers}
|
);
|
||||||
isAvailable={dockerState.isAvailable}
|
return (
|
||||||
onContainerSelect={handleContainerSelect}
|
<ContainersSelector
|
||||||
onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
|
site={
|
||||||
/>
|
selectedSite
|
||||||
);
|
}
|
||||||
})() : null;
|
containers={
|
||||||
|
dockerState.containers
|
||||||
|
}
|
||||||
|
isAvailable={
|
||||||
|
dockerState.isAvailable
|
||||||
|
}
|
||||||
|
onContainerSelect={
|
||||||
|
handleContainerSelect
|
||||||
|
}
|
||||||
|
onRefresh={() =>
|
||||||
|
refreshContainersForSite(
|
||||||
|
selectedSite.siteId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -964,25 +1133,59 @@ export default function ReverseProxyTargets(props: {
|
|||||||
name="ip"
|
name="ip"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="relative">
|
<FormItem className="relative">
|
||||||
<FormLabel>{t("targetAddr")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("targetAddr")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
id="ip"
|
id="ip"
|
||||||
{...field}
|
{...field}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const input = e.target.value.trim();
|
const input =
|
||||||
const hasProtocol = /^(https?|h2c):\/\//.test(input);
|
e.target.value.trim();
|
||||||
const hasPort = /:\d+(?:\/|$)/.test(input);
|
const hasProtocol =
|
||||||
|
/^(https?|h2c):\/\//.test(
|
||||||
|
input
|
||||||
|
);
|
||||||
|
const hasPort =
|
||||||
|
/:\d+(?:\/|$)/.test(
|
||||||
|
input
|
||||||
|
);
|
||||||
|
|
||||||
if (hasProtocol || hasPort) {
|
if (
|
||||||
const parsed = parseHostTarget(input);
|
hasProtocol ||
|
||||||
|
hasPort
|
||||||
|
) {
|
||||||
|
const parsed =
|
||||||
|
parseHostTarget(
|
||||||
|
input
|
||||||
|
);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
if (hasProtocol || !addTargetForm.getValues("method")) {
|
if (
|
||||||
addTargetForm.setValue("method", parsed.protocol);
|
hasProtocol ||
|
||||||
|
!addTargetForm.getValues(
|
||||||
|
"method"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
addTargetForm.setValue(
|
||||||
|
"method",
|
||||||
|
parsed.protocol
|
||||||
|
);
|
||||||
}
|
}
|
||||||
addTargetForm.setValue("ip", parsed.host);
|
addTargetForm.setValue(
|
||||||
if (hasPort || !addTargetForm.getValues("port")) {
|
"ip",
|
||||||
addTargetForm.setValue("port", parsed.port);
|
parsed.host
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
hasPort ||
|
||||||
|
!addTargetForm.getValues(
|
||||||
|
"port"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
addTargetForm.setValue(
|
||||||
|
"port",
|
||||||
|
parsed.port
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1091,12 +1294,12 @@ export default function ReverseProxyTargets(props: {
|
|||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header
|
header
|
||||||
.column
|
.column
|
||||||
.columnDef
|
.columnDef
|
||||||
.header,
|
.header,
|
||||||
header.getContext()
|
header.getContext()
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -1256,6 +1459,36 @@ export default function ReverseProxyTargets(props: {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={proxySettingsForm.control}
|
||||||
|
name="headers"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-base font-semibold">
|
||||||
|
{t("customHeaders")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<HeadersInput
|
||||||
|
value={
|
||||||
|
field.value || ""
|
||||||
|
}
|
||||||
|
onChange={(value) => {
|
||||||
|
field.onChange(
|
||||||
|
value
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"customHeadersDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</SettingsSectionForm>
|
</SettingsSectionForm>
|
||||||
@@ -128,7 +128,7 @@ export default function ResourceRules(props: {
|
|||||||
try {
|
try {
|
||||||
const res = await api.get<
|
const res = await api.get<
|
||||||
AxiosResponse<ListResourceRulesResponse>
|
AxiosResponse<ListResourceRulesResponse>
|
||||||
>(`/resource/${params.resourceId}/rules`);
|
>(`/resource/${resource.resourceId}/rules`);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
setRules(res.data.data.rules);
|
setRules(res.data.data.rules);
|
||||||
}
|
}
|
||||||
@@ -251,7 +251,7 @@ export default function ResourceRules(props: {
|
|||||||
|
|
||||||
// Save rules enabled state
|
// Save rules enabled state
|
||||||
const res = await api
|
const res = await api
|
||||||
.post(`/resource/${params.resourceId}`, {
|
.post(`/resource/${resource.resourceId}`, {
|
||||||
applyRules: rulesEnabled
|
applyRules: rulesEnabled
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -336,13 +336,13 @@ export default function ResourceRules(props: {
|
|||||||
|
|
||||||
if (rule.new) {
|
if (rule.new) {
|
||||||
const res = await api.put(
|
const res = await api.put(
|
||||||
`/resource/${params.resourceId}/rule`,
|
`/resource/${resource.resourceId}/rule`,
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
rule.ruleId = res.data.data.ruleId;
|
rule.ruleId = res.data.data.ruleId;
|
||||||
} else if (rule.updated) {
|
} else if (rule.updated) {
|
||||||
await api.post(
|
await api.post(
|
||||||
`/resource/${params.resourceId}/rule/${rule.ruleId}`,
|
`/resource/${resource.resourceId}/rule/${rule.ruleId}`,
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -361,7 +361,7 @@ export default function ResourceRules(props: {
|
|||||||
|
|
||||||
for (const ruleId of rulesToRemove) {
|
for (const ruleId of rulesToRemove) {
|
||||||
await api.delete(
|
await api.delete(
|
||||||
`/resource/${params.resourceId}/rule/${ruleId}`
|
`/resource/${resource.resourceId}/rule/${ruleId}`
|
||||||
);
|
);
|
||||||
setRules(rules.filter((r) => r.ruleId !== ruleId));
|
setRules(rules.filter((r) => r.ruleId !== ruleId));
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,7 @@ import {
|
|||||||
} from "@app/components/ui/popover";
|
} from "@app/components/ui/popover";
|
||||||
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
||||||
import { cn } from "@app/lib/cn";
|
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 CopyTextBox from "@app/components/CopyTextBox";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -90,7 +90,7 @@ import { ListTargetsResponse } from "@server/routers/target";
|
|||||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
import { DockerManager, DockerState } from "@app/lib/docker";
|
||||||
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
||||||
import { toASCII, toUnicode } from 'punycode';
|
import { toASCII, toUnicode } from 'punycode';
|
||||||
import { DomainRow } from "../../domains/DomainsTable";
|
import { DomainRow } from "../../../../../components/DomainsTable";
|
||||||
|
|
||||||
const baseResourceFormSchema = z.object({
|
const baseResourceFormSchema = z.object({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
@@ -112,8 +112,42 @@ const addTargetSchema = z.object({
|
|||||||
ip: z.string().refine(isTargetValid),
|
ip: z.string().refine(isTargetValid),
|
||||||
method: z.string().nullable(),
|
method: z.string().nullable(),
|
||||||
port: z.coerce.number().int().positive(),
|
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<typeof baseResourceFormSchema>;
|
type BaseResourceFormValues = z.infer<typeof baseResourceFormSchema>;
|
||||||
type HttpResourceFormValues = z.infer<typeof httpResourceFormSchema>;
|
type HttpResourceFormValues = z.infer<typeof httpResourceFormSchema>;
|
||||||
@@ -202,7 +236,9 @@ export default function Page() {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
ip: "",
|
ip: "",
|
||||||
method: baseForm.watch("http") ? "http" : null,
|
method: baseForm.watch("http") ? "http" : null,
|
||||||
port: "" as any as number
|
port: "" as any as number,
|
||||||
|
path: null,
|
||||||
|
pathMatchType: null
|
||||||
} as z.infer<typeof addTargetSchema>
|
} as z.infer<typeof addTargetSchema>
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -273,6 +309,8 @@ export default function Page() {
|
|||||||
|
|
||||||
const newTarget: LocalTarget = {
|
const newTarget: LocalTarget = {
|
||||||
...data,
|
...data,
|
||||||
|
path: data.path || null,
|
||||||
|
pathMatchType: data.pathMatchType || null,
|
||||||
siteType: site?.type || null,
|
siteType: site?.type || null,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
targetId: new Date().getTime(),
|
targetId: new Date().getTime(),
|
||||||
@@ -284,7 +322,9 @@ export default function Page() {
|
|||||||
addTargetForm.reset({
|
addTargetForm.reset({
|
||||||
ip: "",
|
ip: "",
|
||||||
method: baseForm.watch("http") ? "http" : null,
|
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() {
|
async function onSubmit() {
|
||||||
setShowSnippets(true);
|
|
||||||
router.refresh();
|
|
||||||
setCreateLoading(true);
|
setCreateLoading(true);
|
||||||
|
|
||||||
const baseData = baseForm.getValues();
|
const baseData = baseForm.getValues();
|
||||||
@@ -372,7 +410,9 @@ export default function Page() {
|
|||||||
port: target.port,
|
port: target.port,
|
||||||
method: target.method,
|
method: target.method,
|
||||||
enabled: target.enabled,
|
enabled: target.enabled,
|
||||||
siteId: target.siteId
|
siteId: target.siteId,
|
||||||
|
path: target.path,
|
||||||
|
pathMatchType: target.pathMatchType
|
||||||
};
|
};
|
||||||
|
|
||||||
await api.put(`/resource/${id}/target`, data);
|
await api.put(`/resource/${id}/target`, data);
|
||||||
@@ -495,6 +535,89 @@ export default function Page() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const columns: ColumnDef<LocalTarget>[] = [
|
const columns: ColumnDef<LocalTarget>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "path",
|
||||||
|
header: t("matchPath"),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const [showPathInput, setShowPathInput] = useState(
|
||||||
|
!!(row.original.path || row.original.pathMatchType)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!showPathInput) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowPathInput(true)}
|
||||||
|
>
|
||||||
|
+ {t("matchPath")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 min-w-[200px] items-center">
|
||||||
|
<Select
|
||||||
|
defaultValue={row.original.pathMatchType || "exact"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateTarget(row.original.targetId, {
|
||||||
|
...row.original,
|
||||||
|
pathMatchType: value as "exact" | "prefix" | "regex"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-25">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="exact">Exact</SelectItem>
|
||||||
|
<SelectItem value="prefix">Prefix</SelectItem>
|
||||||
|
<SelectItem value="regex">Regex</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
placeholder={
|
||||||
|
row.original.pathMatchType === "regex"
|
||||||
|
? "^/api/.*"
|
||||||
|
: "/path"
|
||||||
|
}
|
||||||
|
defaultValue={row.original.path || ""}
|
||||||
|
className="flex-1 min-w-[150px]"
|
||||||
|
onBlur={(e) => {
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setShowPathInput(false);
|
||||||
|
updateTarget(row.original.targetId, {
|
||||||
|
...row.original,
|
||||||
|
path: null,
|
||||||
|
pathMatchType: null
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<MoveRight className="ml-4 h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "siteId",
|
accessorKey: "siteId",
|
||||||
header: t("site"),
|
header: t("site"),
|
||||||
@@ -1423,7 +1546,7 @@ export default function Page() {
|
|||||||
{t("resourceAddEntrypoints")}
|
{t("resourceAddEntrypoints")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
(Edit file: config/traefik/traefik_config.yml)
|
{t("resourceAddEntrypointsEditFile")}
|
||||||
</p>
|
</p>
|
||||||
<CopyTextBox
|
<CopyTextBox
|
||||||
text={`entryPoints:
|
text={`entryPoints:
|
||||||
@@ -1438,7 +1561,7 @@ export default function Page() {
|
|||||||
{t("resourceExposePorts")}
|
{t("resourceExposePorts")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
(Edit file: docker-compose.yml)
|
{t("resourceExposePortsEditFile")}
|
||||||
</p>
|
</p>
|
||||||
<CopyTextBox
|
<CopyTextBox
|
||||||
text={`ports:
|
text={`ports:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
|||||||
import ResourcesTable, {
|
import ResourcesTable, {
|
||||||
ResourceRow,
|
ResourceRow,
|
||||||
InternalResourceRow
|
InternalResourceRow
|
||||||
} from "./ResourcesTable";
|
} from "../../../../components/ResourcesTable";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { ListResourcesResponse } from "@server/routers/resource";
|
import { ListResourcesResponse } from "@server/routers/resource";
|
||||||
import { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
import { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
||||||
@@ -76,8 +76,7 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
|||||||
id: resource.resourceId,
|
id: resource.resourceId,
|
||||||
name: resource.name,
|
name: resource.name,
|
||||||
orgId: params.orgId,
|
orgId: params.orgId,
|
||||||
|
nice: resource.niceId,
|
||||||
|
|
||||||
domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`,
|
domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`,
|
||||||
protocol: resource.protocol,
|
protocol: resource.protocol,
|
||||||
proxyPort: resource.proxyPort,
|
proxyPort: resource.proxyPort,
|
||||||
@@ -91,7 +90,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
|||||||
? "protected"
|
? "protected"
|
||||||
: "not_protected",
|
: "not_protected",
|
||||||
enabled: resource.enabled,
|
enabled: resource.enabled,
|
||||||
domainId: resource.domainId || undefined
|
domainId: resource.domainId || undefined,
|
||||||
|
ssl: resource.ssl
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import { cache } from "react";
|
|||||||
import { GetOrgResponse } from "@server/routers/org";
|
import { GetOrgResponse } from "@server/routers/org";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import { ListAccessTokensResponse } from "@server/routers/accessToken";
|
import { ListAccessTokensResponse } from "@server/routers/accessToken";
|
||||||
import ShareLinksTable, { ShareLinkRow } from "./ShareLinksTable";
|
import ShareLinksTable, { ShareLinkRow } from "../../../../components/ShareLinksTable";
|
||||||
import ShareableLinksSplash from "./ShareLinksSplash";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
type ShareLinksPageProps = {
|
type ShareLinksPageProps = {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user