Compare commits
166 commits
Author | SHA1 | Date | |
---|---|---|---|
|
cbe1e4decb |
||
|
17e3568b0d |
||
|
1f827d8ecf |
||
|
ee5bfb96c7 |
||
|
2c3f44891e |
||
|
35ae064ce9 |
||
|
91e0accc2a |
||
|
63010c7889 |
||
|
0ecfde982d |
||
|
c9fd050673 |
||
|
0ea0b241c1 |
||
|
bf4de08896 |
||
|
3e7712d7fd |
||
|
c206bd120d |
||
|
4e55368d83 |
||
|
38db3f4180 |
||
|
a450521714 |
||
|
aba894eedf |
||
|
bcf4523689 |
||
|
b1255ddb1e |
||
|
5f43268b88 |
||
|
7a17518054 |
||
|
8992e99c07 |
||
|
bf95b0a659 |
||
|
4b824f653f |
||
|
edf1cea738 |
||
|
5d87e7371a |
||
|
7223761c43 |
||
|
0568e74821 |
||
|
87c0bd7717 |
||
|
d916dd802e |
||
|
94a7aae289 |
||
|
311df4cb0f |
||
|
52bdfd2506 |
||
|
a60038b79b |
||
|
8aa92176cb |
||
|
5ba9bcd7ba |
||
|
e55bc27992 |
||
|
fbd1534ed3 |
||
|
1f0de303cf |
||
|
32f7d9f527 |
||
|
98cb271d26 |
||
|
4a35189c18 |
||
|
476cd915ab |
||
|
f7293a3933 |
||
|
47b1d52f67 |
||
|
cb80c6eca4 |
||
|
8d91fdb987 |
||
|
2e037d2c0f |
||
|
ea01e019c7 |
||
|
c74cc48b83 |
||
|
f33a0d4fc8 |
||
|
911faf9843 |
||
|
fa115b455d |
||
|
30b6d42047 |
||
|
2a04f58f0d |
||
|
ef0bfc8844 |
||
|
aac4a93550 |
||
|
451aa5ecfd |
||
|
4496610714 |
||
|
d46fa115a8 |
||
|
dec208cdea |
||
|
5984079995 |
||
|
aa43d1936f |
||
|
d7d1af59b5 |
||
|
adca1348b6 |
||
|
915fa3e8e1 |
||
|
cc009a14d6 |
||
|
48ab3f9a80 |
||
|
52e56744c0 |
||
|
3b74f84665 |
||
|
901c0483c1 |
||
|
1bc5b6b472 |
||
|
009f7b23d9 |
||
|
f9ea02065e |
||
|
fa36aeca1c |
||
|
a0b7f14b9e |
||
|
7f6ee7c88d |
||
|
8f29c42ab4 |
||
|
7f4d410cbd |
||
|
76e19508f0 |
||
|
6d210b128d |
||
|
ef5f713bfb |
||
|
fc24331988 |
||
|
75de3118ab |
||
|
597c15b83d |
||
|
3027a0dc13 |
||
|
d27ed4d523 |
||
|
0522b93369 |
||
|
47eb1262e0 |
||
|
b503d73e46 |
||
|
2bacfa8002 |
||
|
0d684c1d01 |
||
|
e93a2a6f4d |
||
|
5223de6b92 |
||
|
824e8a2818 |
||
|
96e4a28a2a |
||
|
c2ac48400b |
||
|
f63668dc1a |
||
|
52c659feb9 |
||
|
4220f518d0 |
||
|
bb57f7537e |
||
|
1ea1e2806e |
||
|
599228274f |
||
|
471a15f3a0 |
||
|
bd425f5cfe |
||
|
50e94d2d1f |
||
|
997cbca98e |
||
|
3a1eddea43 |
||
|
764778ecc2 |
||
|
22f3dc9dac |
||
|
65b4b53064 |
||
|
f404b3bf35 |
||
|
16c16b81bc |
||
|
d868545d0d |
||
|
383f70f92f |
||
|
22b9f926dc |
||
|
f46e190f4a |
||
|
cf11ac8853 |
||
|
d040ca4208 |
||
|
34d9641aab |
||
|
4a70868d82 |
||
|
fb0fee244f |
||
|
60a4e31ff5 |
||
|
01fef8ab09 |
||
|
f7afdca5c0 |
||
|
567e74046d |
||
|
452338e67b |
||
|
c9dfff290b |
||
|
dac9608bef |
||
|
38c8f576e0 |
||
|
553cc30836 |
||
|
3538bb793e |
||
|
58ebd1275b |
||
|
791bcb2df1 |
||
|
227c898583 |
||
|
f889e54052 |
||
|
2814d2410b |
||
|
1b9ed8aab4 |
||
|
1cdecf8480 |
||
|
d1890a2756 |
||
|
a6df41cb43 |
||
|
068d8484fb |
||
|
cbee67e475 |
||
|
60d392742d |
||
|
bc151df638 |
||
|
685be473bc |
||
|
26cc0f8184 |
||
|
5efd1d5405 |
||
|
5fd411a54e |
||
|
35facd1d4c |
||
|
956db6ba2e |
||
|
49d10eed33 |
||
|
d55e4ef498 |
||
|
ce320ca2cf |
||
|
dba4918edf |
||
|
adc57375f2 |
||
|
be2c91415a |
||
|
c1ba993ac8 |
||
|
16cbe9bb69 |
||
|
52b465b289 |
||
|
eba7f55a62 |
||
|
e8585f2a66 |
||
|
459853ca99 |
||
|
a21de9f1cb |
||
|
f9f47f3869 |
191 changed files with 8630 additions and 1655 deletions
2
.github/workflows/cicd.yml
vendored
2
.github/workflows/cicd.yml
vendored
|
@ -28,7 +28,7 @@ jobs:
|
|||
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: 1.24
|
||||
|
||||
|
|
2
.github/workflows/linting.yml
vendored
2
.github/workflows/linting.yml
vendored
|
@ -21,7 +21,7 @@ jobs:
|
|||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
|
|
2
.github/workflows/stale-bot.yml
vendored
2
.github/workflows/stale-bot.yml
vendored
|
@ -14,7 +14,7 @@ jobs:
|
|||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
days-before-stale: 14
|
||||
days-before-close: 14
|
||||
|
|
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
|
@ -13,7 +13,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
|
|
15
README.md
15
README.md
|
@ -20,15 +20,24 @@ _Pangolin tunnels your services to the internet so you can access anything from
|
|||
Website
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://docs.digpangolin.com/self-host/quick-install">
|
||||
Install Guide
|
||||
<a href="https://docs.digpangolin.com/self-host/quick-install-managed">
|
||||
Quick Install Guide
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="mailto:numbat@fossorial.io">
|
||||
<a href="mailto:contact@fossorial.io">
|
||||
Contact Us
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://digpangolin.com/slack">
|
||||
Slack
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://discord.gg/HCJR8Xhme4">
|
||||
Discord
|
||||
</a>
|
||||
</h5>
|
||||
|
||||
[](https://digpangolin.com/slack)
|
||||
[](https://hub.docker.com/r/fosrl/pangolin)
|
||||

|
||||
[](https://discord.gg/HCJR8Xhme4)
|
||||
|
|
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
|
|
@ -16,8 +16,9 @@ http:
|
|||
|
||||
# Next.js router (handles everything except API and WebSocket paths)
|
||||
next-router:
|
||||
rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)"
|
||||
rule: "Host(`{{.DashboardDomain}}`)"
|
||||
service: next-service
|
||||
priority: 10
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
|
@ -27,15 +28,7 @@ http:
|
|||
api-router:
|
||||
rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)"
|
||||
service: api-service
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
# WebSocket router
|
||||
ws-router:
|
||||
rule: "Host(`{{.DashboardDomain}}`)"
|
||||
service: api-service
|
||||
priority: 100
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
|
|
|
@ -13,7 +13,6 @@ services:
|
|||
environment:
|
||||
- NODE_ENV=development
|
||||
- ENVIRONMENT=dev
|
||||
- DB_TYPE=pg
|
||||
volumes:
|
||||
# Mount source code for hot reload
|
||||
- ./src:/app/src
|
||||
|
|
|
@ -52,7 +52,7 @@ esbuild
|
|||
bundle: true,
|
||||
outfile: argv.out,
|
||||
format: "esm",
|
||||
minify: true,
|
||||
minify: false,
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
|
@ -63,7 +63,7 @@ esbuild
|
|||
packagePath: getPackagePaths(),
|
||||
}),
|
||||
],
|
||||
sourcemap: "external",
|
||||
sourcemap: "inline",
|
||||
target: "node22",
|
||||
})
|
||||
.then(() => {
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
module installer
|
||||
|
||||
go 1.24
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
golang.org/x/term v0.34.0
|
||||
golang.org/x/term v0.35.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.35.0 // indirect
|
||||
require golang.org/x/sys v0.36.0 // indirect
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
|
|
@ -8,6 +8,8 @@ import (
|
|||
"io"
|
||||
"io/fs"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
@ -15,14 +17,13 @@ import (
|
|||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
"net"
|
||||
)
|
||||
|
||||
// DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD
|
||||
func loadVersions(config *Config) {
|
||||
config.PangolinVersion = "replaceme"
|
||||
config.GerbilVersion = "replaceme"
|
||||
config.BadgerVersion = "replaceme"
|
||||
config.PangolinVersion = "1.9.4"
|
||||
config.GerbilVersion = "1.2.1"
|
||||
config.BadgerVersion = "1.2.0"
|
||||
}
|
||||
|
||||
//go:embed config/*
|
||||
|
@ -74,7 +75,7 @@ func main() {
|
|||
if err := checkPortsAvailable(p); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
|
||||
fmt.Printf("Please close any services on ports 80/443 in order to run the installation smoothly")
|
||||
fmt.Printf("Please close any services on ports 80/443 in order to run the installation smoothly. If you already have the Pangolin stack running, shut them down before proceeding.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
@ -204,8 +205,17 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
config.InstallationContainerType = podmanOrDocker(reader)
|
||||
|
||||
config.DoCrowdsecInstall = true
|
||||
installCrowdsec(config)
|
||||
err := installCrowdsec(config)
|
||||
if (err != nil) {
|
||||
fmt.Printf("Error installing CrowdSec: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("CrowdSec installed successfully!")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -328,7 +338,12 @@ func collectUserInput(reader *bufio.Reader) Config {
|
|||
config.HybridSecret = readString(reader, "Enter your secret", "")
|
||||
}
|
||||
|
||||
config.DashboardDomain = readString(reader, "The public addressable IP address for this node or a domain pointing to it", "")
|
||||
// Try to get public IP as default
|
||||
publicIP := getPublicIP()
|
||||
if publicIP != "" {
|
||||
fmt.Printf("Detected public IP: %s\n", publicIP)
|
||||
}
|
||||
config.DashboardDomain = readString(reader, "The public addressable IP address for this node or a domain pointing to it", publicIP)
|
||||
config.InstallGerbil = true
|
||||
} else {
|
||||
config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
|
||||
|
@ -584,6 +599,32 @@ func generateRandomSecretKey() string {
|
|||
return string(b)
|
||||
}
|
||||
|
||||
func getPublicIP() string {
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Get("https://ifconfig.io/ip")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
ip := strings.TrimSpace(string(body))
|
||||
|
||||
// Validate that it's a valid IP address
|
||||
if net.ParseIP(ip) != nil {
|
||||
return ip
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// Run external commands with stdio/stderr attached.
|
||||
func run(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
|
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "An error occurred while adding user to the role.",
|
||||
"userSaved": "User saved",
|
||||
"userSavedDescription": "The user has been updated.",
|
||||
"autoProvisioned": "Auto Provisioned",
|
||||
"autoProvisionedDescription": "Allow this user to be automatically managed by identity provider",
|
||||
"accessControlsDescription": "Manage what this user can access and do in the organization",
|
||||
"accessControlsSubmit": "Save Access Controls",
|
||||
"roles": "Roles",
|
||||
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Invalid IP address format",
|
||||
"ipAddressErrorInvalidOctet": "Invalid IP address octet",
|
||||
"path": "Path",
|
||||
"matchPath": "Match Path",
|
||||
"ipAddressRange": "IP Range",
|
||||
"rulesErrorFetch": "Failed to fetch rules",
|
||||
"rulesErrorFetchDescription": "An error occurred while fetching rules",
|
||||
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Connected",
|
||||
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
|
||||
"idpErrorNotFound": "IdP not found",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Invalid Invite",
|
||||
"inviteInvalidDescription": "The invite link is invalid.",
|
||||
"inviteErrorWrongUser": "Invite is not for this user",
|
||||
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Professional Edition Required",
|
||||
"licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.",
|
||||
"actionGetOrg": "Get Organization",
|
||||
"updateOrgUser": "Update Org User",
|
||||
"createOrgUser": "Create Org User",
|
||||
"actionUpdateOrg": "Update Organization",
|
||||
"actionUpdateUser": "Update User",
|
||||
"actionGetUser": "Get User",
|
||||
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Delete Site",
|
||||
"actionGetSite": "Get Site",
|
||||
"actionListSites": "List Sites",
|
||||
"actionApplyBlueprint": "Apply Blueprint",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenDescription": "Enter the setup token from the server console.",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
|
@ -1133,8 +1141,8 @@
|
|||
"sidebarLicense": "License",
|
||||
"sidebarClients": "Clients (Beta)",
|
||||
"sidebarDomains": "Domains",
|
||||
"enableDockerSocket": "Enable Docker Socket",
|
||||
"enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.",
|
||||
"enableDockerSocket": "Enable Docker Blueprint",
|
||||
"enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt.",
|
||||
"enableDockerSocketLink": "Learn More",
|
||||
"viewDockerContainers": "View Docker Containers",
|
||||
"containersIn": "Containers in {siteName}",
|
||||
|
@ -1234,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Update Available",
|
||||
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
||||
"domainPickerEnterDomain": "Domain",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp",
|
||||
"domainPickerPlaceholder": "myapp.example.com",
|
||||
"domainPickerDescription": "Enter the full domain of the resource to see available options.",
|
||||
"domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
|
||||
"domainPickerTabAll": "All",
|
||||
|
@ -1392,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protocol",
|
||||
"editInternalResourceDialogSitePort": "Site Port",
|
||||
"editInternalResourceDialogTargetConfiguration": "Target Configuration",
|
||||
"editInternalResourceDialogDestinationIP": "Destination IP",
|
||||
"editInternalResourceDialogDestinationPort": "Destination Port",
|
||||
"editInternalResourceDialogCancel": "Cancel",
|
||||
"editInternalResourceDialogSaveResource": "Save Resource",
|
||||
"editInternalResourceDialogSuccess": "Success",
|
||||
|
@ -1424,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Site Port",
|
||||
"createInternalResourceDialogSitePortDescription": "Use this port to access the resource on the site when connected with a client.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Target Configuration",
|
||||
"createInternalResourceDialogDestinationIP": "Destination IP",
|
||||
"createInternalResourceDialogDestinationIPDescription": "The IP address of the resource on the site's network.",
|
||||
"createInternalResourceDialogDestinationPort": "Destination Port",
|
||||
"createInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "The port on the destination IP where the resource is accessible.",
|
||||
"createInternalResourceDialogCancel": "Cancel",
|
||||
"createInternalResourceDialogCreateResource": "Create Resource",
|
||||
|
@ -1496,5 +1500,24 @@
|
|||
"convertButton": "Convert This Node to Managed Self-Hosted"
|
||||
},
|
||||
"internationaldomaindetected": "International Domain Detected",
|
||||
"willbestoredas": "Will be stored as:"
|
||||
"willbestoredas": "Will be stored as:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC provider",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Custom Headers",
|
||||
"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": "An error occurred while adding user to the role.",
|
||||
"userSaved": "User saved",
|
||||
"userSavedDescription": "The user has been updated.",
|
||||
"autoProvisioned": "Auto Provisioned",
|
||||
"autoProvisionedDescription": "Allow this user to be automatically managed by identity provider",
|
||||
"accessControlsDescription": "Manage what this user can access and do in the organization",
|
||||
"accessControlsSubmit": "Save Access Controls",
|
||||
"roles": "Roles",
|
||||
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Invalid IP address format",
|
||||
"ipAddressErrorInvalidOctet": "Invalid IP address octet",
|
||||
"path": "Path",
|
||||
"matchPath": "Match Path",
|
||||
"ipAddressRange": "IP Range",
|
||||
"rulesErrorFetch": "Failed to fetch rules",
|
||||
"rulesErrorFetchDescription": "An error occurred while fetching rules",
|
||||
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Connected",
|
||||
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
|
||||
"idpErrorNotFound": "IdP not found",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Invalid Invite",
|
||||
"inviteInvalidDescription": "The invite link is invalid.",
|
||||
"inviteErrorWrongUser": "Invite is not for this user",
|
||||
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Professional Edition Required",
|
||||
"licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.",
|
||||
"actionGetOrg": "Get Organization",
|
||||
"updateOrgUser": "Update Org User",
|
||||
"createOrgUser": "Create Org User",
|
||||
"actionUpdateOrg": "Update Organization",
|
||||
"actionUpdateUser": "Update User",
|
||||
"actionGetUser": "Get User",
|
||||
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Delete Site",
|
||||
"actionGetSite": "Get Site",
|
||||
"actionListSites": "List Sites",
|
||||
"actionApplyBlueprint": "Apply Blueprint",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenDescription": "Enter the setup token from the server console.",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
|
@ -1133,8 +1141,8 @@
|
|||
"sidebarLicense": "License",
|
||||
"sidebarClients": "Clients (Beta)",
|
||||
"sidebarDomains": "Domains",
|
||||
"enableDockerSocket": "Enable Docker Socket",
|
||||
"enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.",
|
||||
"enableDockerSocket": "Enable Docker Blueprint",
|
||||
"enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt.",
|
||||
"enableDockerSocketLink": "Learn More",
|
||||
"viewDockerContainers": "View Docker Containers",
|
||||
"containersIn": "Containers in {siteName}",
|
||||
|
@ -1234,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Update Available",
|
||||
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
||||
"domainPickerEnterDomain": "Domain",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp",
|
||||
"domainPickerPlaceholder": "myapp.example.com",
|
||||
"domainPickerDescription": "Enter the full domain of the resource to see available options.",
|
||||
"domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
|
||||
"domainPickerTabAll": "All",
|
||||
|
@ -1392,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protocol",
|
||||
"editInternalResourceDialogSitePort": "Site Port",
|
||||
"editInternalResourceDialogTargetConfiguration": "Target Configuration",
|
||||
"editInternalResourceDialogDestinationIP": "Destination IP",
|
||||
"editInternalResourceDialogDestinationPort": "Destination Port",
|
||||
"editInternalResourceDialogCancel": "Cancel",
|
||||
"editInternalResourceDialogSaveResource": "Save Resource",
|
||||
"editInternalResourceDialogSuccess": "Success",
|
||||
|
@ -1424,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Site Port",
|
||||
"createInternalResourceDialogSitePortDescription": "Use this port to access the resource on the site when connected with a client.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Target Configuration",
|
||||
"createInternalResourceDialogDestinationIP": "Destination IP",
|
||||
"createInternalResourceDialogDestinationIPDescription": "The IP address of the resource on the site's network.",
|
||||
"createInternalResourceDialogDestinationPort": "Destination Port",
|
||||
"createInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "The port on the destination IP where the resource is accessible.",
|
||||
"createInternalResourceDialogCancel": "Cancel",
|
||||
"createInternalResourceDialogCreateResource": "Create Resource",
|
||||
|
@ -1496,5 +1500,24 @@
|
|||
"convertButton": "Convert This Node to Managed Self-Hosted"
|
||||
},
|
||||
"internationaldomaindetected": "International Domain Detected",
|
||||
"willbestoredas": "Will be stored as:"
|
||||
"willbestoredas": "Will be stored as:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC provider",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Custom Headers",
|
||||
"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": "Beim Hinzufügen des Benutzers zur Rolle ist ein Fehler aufgetreten.",
|
||||
"userSaved": "Benutzer gespeichert",
|
||||
"userSavedDescription": "Der Benutzer wurde aktualisiert.",
|
||||
"autoProvisioned": "Automatisch vorgesehen",
|
||||
"autoProvisionedDescription": "Erlaube diesem Benutzer die automatische Verwaltung durch Identitätsanbieter",
|
||||
"accessControlsDescription": "Verwalten Sie, worauf dieser Benutzer in der Organisation zugreifen und was er tun kann",
|
||||
"accessControlsSubmit": "Zugriffskontrollen speichern",
|
||||
"roles": "Rollen",
|
||||
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Ungültiges IP-Adressformat",
|
||||
"ipAddressErrorInvalidOctet": "Ungültiges IP-Adress-Oktett",
|
||||
"path": "Pfad",
|
||||
"matchPath": "Unterverzeichnis",
|
||||
"ipAddressRange": "IP-Bereich",
|
||||
"rulesErrorFetch": "Fehler beim Abrufen der Regeln",
|
||||
"rulesErrorFetchDescription": "Beim Abrufen der Regeln ist ein Fehler aufgetreten",
|
||||
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Verbunden",
|
||||
"idpErrorConnectingTo": "Es gab ein Problem bei der Verbindung zu {name}. Bitte kontaktieren Sie Ihren Administrator.",
|
||||
"idpErrorNotFound": "IdP nicht gefunden",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Ungültige Einladung",
|
||||
"inviteInvalidDescription": "Der Einladungslink ist ungültig.",
|
||||
"inviteErrorWrongUser": "Einladung ist nicht für diesen Benutzer",
|
||||
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Professional Edition erforderlich",
|
||||
"licenseTierProfessionalRequiredDescription": "Diese Funktion ist nur in der Professional Edition verfügbar.",
|
||||
"actionGetOrg": "Organisation abrufen",
|
||||
"updateOrgUser": "Org Benutzer aktualisieren",
|
||||
"createOrgUser": "Org Benutzer erstellen",
|
||||
"actionUpdateOrg": "Organisation aktualisieren",
|
||||
"actionUpdateUser": "Benutzer aktualisieren",
|
||||
"actionGetUser": "Benutzer abrufen",
|
||||
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Standort löschen",
|
||||
"actionGetSite": "Standort abrufen",
|
||||
"actionListSites": "Standorte auflisten",
|
||||
"actionApplyBlueprint": "Blaupause anwenden",
|
||||
"setupToken": "Setup-Token",
|
||||
"setupTokenDescription": "Geben Sie das Setup-Token von der Serverkonsole ein.",
|
||||
"setupTokenRequired": "Setup-Token ist erforderlich",
|
||||
|
@ -1133,8 +1141,8 @@
|
|||
"sidebarLicense": "Lizenz",
|
||||
"sidebarClients": "Clients (Beta)",
|
||||
"sidebarDomains": "Domains",
|
||||
"enableDockerSocket": "Docker Socket aktivieren",
|
||||
"enableDockerSocketDescription": "Docker Socket-Erkennung aktivieren, um Container-Informationen zu befüllen. Socket-Pfad muss Newt bereitgestellt werden.",
|
||||
"enableDockerSocket": "Docker Blaupause aktivieren",
|
||||
"enableDockerSocketDescription": "Aktiviere Docker-Socket-Label-Scraping für Blaupausenbeschriftungen. Der Socket-Pfad muss neu angegeben werden.",
|
||||
"enableDockerSocketLink": "Mehr erfahren",
|
||||
"viewDockerContainers": "Docker Container anzeigen",
|
||||
"containersIn": "Container in {siteName}",
|
||||
|
@ -1234,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Update verfügbar",
|
||||
"newtUpdateAvailableInfo": "Eine neue Version von Newt ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.",
|
||||
"domainPickerEnterDomain": "Domain",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, oder einfach myapp",
|
||||
"domainPickerPlaceholder": "myapp.example.com",
|
||||
"domainPickerDescription": "Geben Sie die vollständige Domäne der Ressource ein, um verfügbare Optionen zu sehen.",
|
||||
"domainPickerDescriptionSaas": "Geben Sie eine vollständige Domäne, Subdomäne oder einfach einen Namen ein, um verfügbare Optionen zu sehen",
|
||||
"domainPickerTabAll": "Alle",
|
||||
|
@ -1392,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protokoll",
|
||||
"editInternalResourceDialogSitePort": "Site-Port",
|
||||
"editInternalResourceDialogTargetConfiguration": "Zielkonfiguration",
|
||||
"editInternalResourceDialogDestinationIP": "Ziel-IP",
|
||||
"editInternalResourceDialogDestinationPort": "Ziel-Port",
|
||||
"editInternalResourceDialogCancel": "Abbrechen",
|
||||
"editInternalResourceDialogSaveResource": "Ressource speichern",
|
||||
"editInternalResourceDialogSuccess": "Erfolg",
|
||||
|
@ -1424,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Site-Port",
|
||||
"createInternalResourceDialogSitePortDescription": "Verwenden Sie diesen Port, um bei Verbindung mit einem Client auf die Ressource an der Site zuzugreifen.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Zielkonfiguration",
|
||||
"createInternalResourceDialogDestinationIP": "Ziel-IP",
|
||||
"createInternalResourceDialogDestinationIPDescription": "Die IP-Adresse der Ressource im Netzwerkstandort der Site.",
|
||||
"createInternalResourceDialogDestinationPort": "Ziel-Port",
|
||||
"createInternalResourceDialogDestinationIPDescription": "Die IP-Adresse oder Hostname Adresse der Ressource im Netzwerk der Website.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "Der Port auf der Ziel-IP, unter dem die Ressource zugänglich ist.",
|
||||
"createInternalResourceDialogCancel": "Abbrechen",
|
||||
"createInternalResourceDialogCreateResource": "Ressource erstellen",
|
||||
|
@ -1496,5 +1500,24 @@
|
|||
"convertButton": "Diesen Knoten in Managed Self-Hosted umwandeln"
|
||||
},
|
||||
"internationaldomaindetected": "Internationale Domain erkannt",
|
||||
"willbestoredas": "Wird gespeichert als:"
|
||||
"willbestoredas": "Wird gespeichert als:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC Provider",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Eigene Kopfzeilen",
|
||||
"headersValidationError": "Header müssen im Format Header-Name: Wert sein.",
|
||||
"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": "Datei bearbeiten: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Datei bearbeiten: docker-compose.yml"
|
||||
}
|
||||
|
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "An error occurred while adding user to the role.",
|
||||
"userSaved": "User saved",
|
||||
"userSavedDescription": "The user has been updated.",
|
||||
"autoProvisioned": "Auto Provisioned",
|
||||
"autoProvisionedDescription": "Allow this user to be automatically managed by identity provider",
|
||||
"accessControlsDescription": "Manage what this user can access and do in the organization",
|
||||
"accessControlsSubmit": "Save Access Controls",
|
||||
"roles": "Roles",
|
||||
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Invalid IP address format",
|
||||
"ipAddressErrorInvalidOctet": "Invalid IP address octet",
|
||||
"path": "Path",
|
||||
"matchPath": "Match Path",
|
||||
"ipAddressRange": "IP Range",
|
||||
"rulesErrorFetch": "Failed to fetch rules",
|
||||
"rulesErrorFetchDescription": "An error occurred while fetching rules",
|
||||
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Connected",
|
||||
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
|
||||
"idpErrorNotFound": "IdP not found",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Invalid Invite",
|
||||
"inviteInvalidDescription": "The invite link is invalid.",
|
||||
"inviteErrorWrongUser": "Invite is not for this user",
|
||||
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Professional Edition Required",
|
||||
"licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.",
|
||||
"actionGetOrg": "Get Organization",
|
||||
"updateOrgUser": "Update Org User",
|
||||
"createOrgUser": "Create Org User",
|
||||
"actionUpdateOrg": "Update Organization",
|
||||
"actionUpdateUser": "Update User",
|
||||
"actionGetUser": "Get User",
|
||||
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Delete Site",
|
||||
"actionGetSite": "Get Site",
|
||||
"actionListSites": "List Sites",
|
||||
"actionApplyBlueprint": "Apply Blueprint",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenDescription": "Enter the setup token from the server console.",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
|
@ -1133,8 +1141,8 @@
|
|||
"sidebarLicense": "License",
|
||||
"sidebarClients": "Clients (Beta)",
|
||||
"sidebarDomains": "Domains",
|
||||
"enableDockerSocket": "Enable Docker Socket",
|
||||
"enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.",
|
||||
"enableDockerSocket": "Enable Docker Blueprint",
|
||||
"enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt.",
|
||||
"enableDockerSocketLink": "Learn More",
|
||||
"viewDockerContainers": "View Docker Containers",
|
||||
"containersIn": "Containers in {siteName}",
|
||||
|
@ -1234,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Update Available",
|
||||
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
||||
"domainPickerEnterDomain": "Domain",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp",
|
||||
"domainPickerPlaceholder": "myapp.example.com",
|
||||
"domainPickerDescription": "Enter the full domain of the resource to see available options.",
|
||||
"domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
|
||||
"domainPickerTabAll": "All",
|
||||
|
@ -1392,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protocol",
|
||||
"editInternalResourceDialogSitePort": "Site Port",
|
||||
"editInternalResourceDialogTargetConfiguration": "Target Configuration",
|
||||
"editInternalResourceDialogDestinationIP": "Destination IP",
|
||||
"editInternalResourceDialogDestinationPort": "Destination Port",
|
||||
"editInternalResourceDialogCancel": "Cancel",
|
||||
"editInternalResourceDialogSaveResource": "Save Resource",
|
||||
"editInternalResourceDialogSuccess": "Success",
|
||||
|
@ -1424,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Site Port",
|
||||
"createInternalResourceDialogSitePortDescription": "Use this port to access the resource on the site when connected with a client.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Target Configuration",
|
||||
"createInternalResourceDialogDestinationIP": "Destination IP",
|
||||
"createInternalResourceDialogDestinationIPDescription": "The IP address of the resource on the site's network.",
|
||||
"createInternalResourceDialogDestinationPort": "Destination Port",
|
||||
"createInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "The port on the destination IP where the resource is accessible.",
|
||||
"createInternalResourceDialogCancel": "Cancel",
|
||||
"createInternalResourceDialogCreateResource": "Create Resource",
|
||||
|
@ -1496,5 +1500,24 @@
|
|||
"convertButton": "Convert This Node to Managed Self-Hosted"
|
||||
},
|
||||
"internationaldomaindetected": "International Domain Detected",
|
||||
"willbestoredas": "Will be stored as:"
|
||||
"willbestoredas": "Will be stored as:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC provider",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Custom Headers",
|
||||
"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.",
|
||||
"userSaved": "Usuario guardado",
|
||||
"userSavedDescription": "El usuario ha sido actualizado.",
|
||||
"autoProvisioned": "Auto asegurado",
|
||||
"autoProvisionedDescription": "Permitir a este usuario ser administrado automáticamente por el proveedor de identidad",
|
||||
"accessControlsDescription": "Administrar lo que este usuario puede acceder y hacer en la organización",
|
||||
"accessControlsSubmit": "Guardar controles de acceso",
|
||||
"roles": "Roles",
|
||||
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Formato de dirección IP inválido",
|
||||
"ipAddressErrorInvalidOctet": "Octet de dirección IP no válido",
|
||||
"path": "Ruta",
|
||||
"matchPath": "Coincidir ruta",
|
||||
"ipAddressRange": "Rango IP",
|
||||
"rulesErrorFetch": "Error al obtener las reglas",
|
||||
"rulesErrorFetchDescription": "Se ha producido un error al recuperar las reglas",
|
||||
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Conectado",
|
||||
"idpErrorConnectingTo": "Hubo un problema al conectar con {name}. Por favor, póngase en contacto con su administrador.",
|
||||
"idpErrorNotFound": "IdP no encontrado",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Invitación inválida",
|
||||
"inviteInvalidDescription": "El enlace de invitación no es válido.",
|
||||
"inviteErrorWrongUser": "La invitación no es para este usuario",
|
||||
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Edición Profesional requerida",
|
||||
"licenseTierProfessionalRequiredDescription": "Esta característica sólo está disponible en la Edición Profesional.",
|
||||
"actionGetOrg": "Obtener organización",
|
||||
"updateOrgUser": "Actualizar usuario Org",
|
||||
"createOrgUser": "Crear usuario Org",
|
||||
"actionUpdateOrg": "Actualizar organización",
|
||||
"actionUpdateUser": "Actualizar usuario",
|
||||
"actionGetUser": "Obtener usuario",
|
||||
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Eliminar sitio",
|
||||
"actionGetSite": "Obtener sitio",
|
||||
"actionListSites": "Listar sitios",
|
||||
"actionApplyBlueprint": "Aplicar plano",
|
||||
"setupToken": "Configuración de token",
|
||||
"setupTokenDescription": "Ingrese el token de configuración desde la consola del servidor.",
|
||||
"setupTokenRequired": "Se requiere el token de configuración",
|
||||
|
@ -1133,8 +1141,8 @@
|
|||
"sidebarLicense": "Licencia",
|
||||
"sidebarClients": "Clientes (Beta)",
|
||||
"sidebarDomains": "Dominios",
|
||||
"enableDockerSocket": "Habilitar conector Docker",
|
||||
"enableDockerSocketDescription": "Habilitar el descubrimiento de Docker Socket para completar la información del contenedor. La ruta del socket debe proporcionarse a Newt.",
|
||||
"enableDockerSocket": "Habilitar Plano Docker",
|
||||
"enableDockerSocketDescription": "Activar el raspado de etiquetas de Socket Docker para etiquetas de planos. La ruta del Socket debe proporcionarse a Newt.",
|
||||
"enableDockerSocketLink": "Saber más",
|
||||
"viewDockerContainers": "Ver contenedores Docker",
|
||||
"containersIn": "Contenedores en {siteName}",
|
||||
|
@ -1234,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Nueva actualización disponible",
|
||||
"newtUpdateAvailableInfo": "Hay una nueva versión de Newt disponible. Actualice a la última versión para la mejor experiencia.",
|
||||
"domainPickerEnterDomain": "Dominio",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.miDominio.com, o solo myapp",
|
||||
"domainPickerPlaceholder": "miapp.ejemplo.com",
|
||||
"domainPickerDescription": "Ingresa el dominio completo del recurso para ver las opciones disponibles.",
|
||||
"domainPickerDescriptionSaas": "Ingresa un dominio completo, subdominio o simplemente un nombre para ver las opciones disponibles",
|
||||
"domainPickerTabAll": "Todo",
|
||||
|
@ -1392,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protocolo",
|
||||
"editInternalResourceDialogSitePort": "Puerto del sitio",
|
||||
"editInternalResourceDialogTargetConfiguration": "Configuración de objetivos",
|
||||
"editInternalResourceDialogDestinationIP": "IP de destino",
|
||||
"editInternalResourceDialogDestinationPort": "Puerto de destino",
|
||||
"editInternalResourceDialogCancel": "Cancelar",
|
||||
"editInternalResourceDialogSaveResource": "Guardar recurso",
|
||||
"editInternalResourceDialogSuccess": "Éxito",
|
||||
|
@ -1424,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Puerto del sitio",
|
||||
"createInternalResourceDialogSitePortDescription": "Use este puerto para acceder al recurso en el sitio cuando se conecta con un cliente.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Configuración de objetivos",
|
||||
"createInternalResourceDialogDestinationIP": "IP de destino",
|
||||
"createInternalResourceDialogDestinationIPDescription": "La dirección IP del recurso en la red del sitio.",
|
||||
"createInternalResourceDialogDestinationPort": "Puerto de destino",
|
||||
"createInternalResourceDialogDestinationIPDescription": "La dirección IP o nombre de host del recurso en la red del sitio.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "El puerto en la IP de destino donde el recurso es accesible.",
|
||||
"createInternalResourceDialogCancel": "Cancelar",
|
||||
"createInternalResourceDialogCreateResource": "Crear recurso",
|
||||
|
@ -1496,5 +1500,24 @@
|
|||
"convertButton": "Convierte este nodo a autoalojado administrado"
|
||||
},
|
||||
"internationaldomaindetected": "Dominio Internacional detectado",
|
||||
"willbestoredas": "Se almacenará como:"
|
||||
"willbestoredas": "Se almacenará como:",
|
||||
"idpGoogleDescription": "Proveedor OAuth2/OIDC de Google",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Cabeceras personalizadas",
|
||||
"headersValidationError": "Los encabezados deben estar en el formato: Nombre de cabecera: valor.",
|
||||
"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": "Editar archivo: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Editar archivo: docker-compose.yml"
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
"setupErrorIdentifier": "L'ID de l'organisation est déjà pris. Veuillez en choisir un autre.",
|
||||
"componentsErrorNoMemberCreate": "Vous n'êtes actuellement membre d'aucune organisation. Créez une organisation pour commencer.",
|
||||
"componentsErrorNoMember": "Vous n'êtes actuellement membre d'aucune organisation.",
|
||||
"welcome": "Bienvenue à Pangolin",
|
||||
"welcome": "Bienvenue sur Pangolin",
|
||||
"welcomeTo": "Bienvenue chez",
|
||||
"componentsCreateOrg": "Créer une organisation",
|
||||
"componentsMember": "Vous êtes membre de {count, plural, =0 {aucune organisation} one {une organisation} other {# organisations}}.",
|
||||
|
@ -34,13 +34,13 @@
|
|||
"confirmPassword": "Confirmer le mot de passe",
|
||||
"createAccount": "Créer un compte",
|
||||
"viewSettings": "Afficher les paramètres",
|
||||
"delete": "Supprimez",
|
||||
"delete": "Supprimer",
|
||||
"name": "Nom",
|
||||
"online": "En ligne",
|
||||
"offline": "Hors ligne",
|
||||
"site": "Site",
|
||||
"dataIn": "Données dans",
|
||||
"dataOut": "Données épuisées",
|
||||
"dataIn": "Données reçues",
|
||||
"dataOut": "Données envoyées",
|
||||
"connectionType": "Type de connexion",
|
||||
"tunnelType": "Type de tunnel",
|
||||
"local": "Locale",
|
||||
|
@ -175,7 +175,7 @@
|
|||
"resourceHTTPSSettingsDescription": "Configurer comment votre ressource sera accédée via HTTPS",
|
||||
"domainType": "Type de domaine",
|
||||
"subdomain": "Sous-domaine",
|
||||
"baseDomain": "Domaine de base",
|
||||
"baseDomain": "Domaine racine",
|
||||
"subdomnainDescription": "Le sous-domaine où votre ressource sera accessible.",
|
||||
"resourceRawSettings": "Paramètres TCP/UDP",
|
||||
"resourceRawSettingsDescription": "Configurer comment votre ressource sera accédée via TCP/UDP",
|
||||
|
@ -309,7 +309,7 @@
|
|||
"numberOfSites": "Nombre de sites",
|
||||
"licenseKeySearch": "Rechercher des clés de licence...",
|
||||
"licenseKeyAdd": "Ajouter une clé de licence",
|
||||
"type": "Type de texte",
|
||||
"type": "Type",
|
||||
"licenseKeyRequired": "La clé de licence est requise",
|
||||
"licenseTermsAgree": "Vous devez accepter les conditions de licence",
|
||||
"licenseErrorKeyLoad": "Impossible de charger les clés de licence",
|
||||
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "Une erreur s'est produite lors de l'ajout de l'utilisateur au rôle.",
|
||||
"userSaved": "Utilisateur enregistré",
|
||||
"userSavedDescription": "L'utilisateur a été mis à jour.",
|
||||
"autoProvisioned": "Auto-provisionné",
|
||||
"autoProvisionedDescription": "Permettre à cet utilisateur d'être géré automatiquement par le fournisseur d'identité",
|
||||
"accessControlsDescription": "Gérer ce que cet utilisateur peut accéder et faire dans l'organisation",
|
||||
"accessControlsSubmit": "Enregistrer les contrôles d'accès",
|
||||
"roles": "Rôles",
|
||||
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Format d'adresse IP invalide",
|
||||
"ipAddressErrorInvalidOctet": "Octet d'adresse IP invalide",
|
||||
"path": "Chemin",
|
||||
"matchPath": "Chemin de correspondance",
|
||||
"ipAddressRange": "Plage IP",
|
||||
"rulesErrorFetch": "Échec de la récupération des règles",
|
||||
"rulesErrorFetchDescription": "Une erreur s'est produite lors de la récupération des règles",
|
||||
|
@ -595,7 +598,7 @@
|
|||
"newtId": "ID Newt",
|
||||
"newtSecretKey": "Clé secrète Newt",
|
||||
"architecture": "Architecture",
|
||||
"sites": "Espaces",
|
||||
"sites": "Sites",
|
||||
"siteWgAnyClients": "Utilisez n'importe quel client WireGuard pour vous connecter. Vous devrez adresser vos ressources internes en utilisant l'IP du pair.",
|
||||
"siteWgCompatibleAllClients": "Compatible avec tous les clients WireGuard",
|
||||
"siteWgManualConfigurationRequired": "Configuration manuelle requise",
|
||||
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Connecté",
|
||||
"idpErrorConnectingTo": "Un problème est survenu lors de la connexion à {name}. Veuillez contacter votre administrateur.",
|
||||
"idpErrorNotFound": "IdP introuvable",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Invitation invalide",
|
||||
"inviteInvalidDescription": "Le lien d'invitation n'est pas valide.",
|
||||
"inviteErrorWrongUser": "L'invitation n'est pas pour cet utilisateur",
|
||||
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Édition Professionnelle Requise",
|
||||
"licenseTierProfessionalRequiredDescription": "Cette fonctionnalité n'est disponible que dans l'Édition Professionnelle.",
|
||||
"actionGetOrg": "Obtenir l'organisation",
|
||||
"updateOrgUser": "Mise à jour de l'utilisateur Org",
|
||||
"createOrgUser": "Créer un utilisateur Org",
|
||||
"actionUpdateOrg": "Mettre à jour l'organisation",
|
||||
"actionUpdateUser": "Mettre à jour l'utilisateur",
|
||||
"actionGetUser": "Obtenir l'utilisateur",
|
||||
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Supprimer un site",
|
||||
"actionGetSite": "Obtenir un site",
|
||||
"actionListSites": "Lister les sites",
|
||||
"actionApplyBlueprint": "Appliquer le Plan",
|
||||
"setupToken": "Jeton de configuration",
|
||||
"setupTokenDescription": "Entrez le jeton de configuration depuis la console du serveur.",
|
||||
"setupTokenRequired": "Le jeton de configuration est requis.",
|
||||
|
@ -1120,7 +1128,7 @@
|
|||
"sidebarOverview": "Aperçu",
|
||||
"sidebarHome": "Domicile",
|
||||
"sidebarSites": "Espaces",
|
||||
"sidebarResources": "Ressource",
|
||||
"sidebarResources": "Ressources",
|
||||
"sidebarAccessControl": "Contrôle d'accès",
|
||||
"sidebarUsers": "Utilisateurs",
|
||||
"sidebarInvitations": "Invitations",
|
||||
|
@ -1133,8 +1141,8 @@
|
|||
"sidebarLicense": "Licence",
|
||||
"sidebarClients": "Clients (Bêta)",
|
||||
"sidebarDomains": "Domaines",
|
||||
"enableDockerSocket": "Activer Docker Socket",
|
||||
"enableDockerSocketDescription": "Activer la découverte Docker Socket pour remplir les informations du conteneur. Le chemin du socket doit être fourni à Newt.",
|
||||
"enableDockerSocket": "Activer le Plan Docker",
|
||||
"enableDockerSocketDescription": "Activer le ramassage d'étiquettes de socket Docker pour les étiquettes de plan. Le chemin de socket doit être fourni à Newt.",
|
||||
"enableDockerSocketLink": "En savoir plus",
|
||||
"viewDockerContainers": "Voir les conteneurs Docker",
|
||||
"containersIn": "Conteneurs en {siteName}",
|
||||
|
@ -1234,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Mise à jour disponible",
|
||||
"newtUpdateAvailableInfo": "Une nouvelle version de Newt est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.",
|
||||
"domainPickerEnterDomain": "Domaine",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, ou simplement myapp",
|
||||
"domainPickerPlaceholder": "monapp.exemple.com",
|
||||
"domainPickerDescription": "Entrez le domaine complet de la ressource pour voir les options disponibles.",
|
||||
"domainPickerDescriptionSaas": "Entrez un domaine complet, un sous-domaine ou juste un nom pour voir les options disponibles",
|
||||
"domainPickerTabAll": "Tous",
|
||||
|
@ -1392,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protocole",
|
||||
"editInternalResourceDialogSitePort": "Port du site",
|
||||
"editInternalResourceDialogTargetConfiguration": "Configuration de la cible",
|
||||
"editInternalResourceDialogDestinationIP": "IP de destination",
|
||||
"editInternalResourceDialogDestinationPort": "Port de destination",
|
||||
"editInternalResourceDialogCancel": "Abandonner",
|
||||
"editInternalResourceDialogSaveResource": "Enregistrer la ressource",
|
||||
"editInternalResourceDialogSuccess": "Succès",
|
||||
|
@ -1424,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Port du site",
|
||||
"createInternalResourceDialogSitePortDescription": "Utilisez ce port pour accéder à la ressource sur le site lors de la connexion avec un client.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Configuration de la cible",
|
||||
"createInternalResourceDialogDestinationIP": "IP de destination",
|
||||
"createInternalResourceDialogDestinationIPDescription": "L'adresse IP de la ressource sur le réseau du site.",
|
||||
"createInternalResourceDialogDestinationPort": "Port de destination",
|
||||
"createInternalResourceDialogDestinationIPDescription": "L'adresse IP ou le nom d'hôte de la ressource sur le réseau du site.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "Le port sur l'IP de destination où la ressource est accessible.",
|
||||
"createInternalResourceDialogCancel": "Abandonner",
|
||||
"createInternalResourceDialogCreateResource": "Créer une ressource",
|
||||
|
@ -1496,5 +1500,24 @@
|
|||
"convertButton": "Convertir ce noeud en auto-hébergé géré"
|
||||
},
|
||||
"internationaldomaindetected": "Domaine international détecté",
|
||||
"willbestoredas": "Sera stocké comme :"
|
||||
"willbestoredas": "Sera stocké comme :",
|
||||
"idpGoogleDescription": "Fournisseur Google OAuth2/OIDC",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "En-têtes personnalisés",
|
||||
"headersValidationError": "Les entêtes doivent être au format : Header-Name: valeur.",
|
||||
"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": "Modifier le fichier : config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Modifier le fichier : docker-compose.yml"
|
||||
}
|
||||
|
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "Si è verificato un errore durante l'aggiunta dell'utente al ruolo.",
|
||||
"userSaved": "Utente salvato",
|
||||
"userSavedDescription": "L'utente è stato aggiornato.",
|
||||
"autoProvisioned": "Auto Provisioned",
|
||||
"autoProvisionedDescription": "Permetti a questo utente di essere gestito automaticamente dal provider di identità",
|
||||
"accessControlsDescription": "Gestisci cosa questo utente può accedere e fare nell'organizzazione",
|
||||
"accessControlsSubmit": "Salva Controlli di Accesso",
|
||||
"roles": "Ruoli",
|
||||
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Formato indirizzo IP non valido",
|
||||
"ipAddressErrorInvalidOctet": "Ottetto indirizzo IP non valido",
|
||||
"path": "Percorso",
|
||||
"matchPath": "Corrispondenza Tracciato",
|
||||
"ipAddressRange": "Intervallo IP",
|
||||
"rulesErrorFetch": "Impossibile recuperare le regole",
|
||||
"rulesErrorFetchDescription": "Si è verificato un errore durante il recupero delle regole",
|
||||
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Connesso",
|
||||
"idpErrorConnectingTo": "Si è verificato un problema durante la connessione a {name}. Contatta il tuo amministratore.",
|
||||
"idpErrorNotFound": "IdP non trovato",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Invito Non Valido",
|
||||
"inviteInvalidDescription": "Il link di invito non è valido.",
|
||||
"inviteErrorWrongUser": "L'invito non è per questo utente",
|
||||
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Edizione Professional Richiesta",
|
||||
"licenseTierProfessionalRequiredDescription": "Questa funzionalità è disponibile solo nell'Edizione Professional.",
|
||||
"actionGetOrg": "Ottieni Organizzazione",
|
||||
"updateOrgUser": "Aggiorna Utente Org",
|
||||
"createOrgUser": "Crea Utente Org",
|
||||
"actionUpdateOrg": "Aggiorna Organizzazione",
|
||||
"actionUpdateUser": "Aggiorna Utente",
|
||||
"actionGetUser": "Ottieni Utente",
|
||||
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Elimina Sito",
|
||||
"actionGetSite": "Ottieni Sito",
|
||||
"actionListSites": "Elenca Siti",
|
||||
"actionApplyBlueprint": "Applica Progetto",
|
||||
"setupToken": "Configura Token",
|
||||
"setupTokenDescription": "Inserisci il token di configurazione dalla console del server.",
|
||||
"setupTokenRequired": "Il token di configurazione è richiesto",
|
||||
|
@ -1133,8 +1141,8 @@
|
|||
"sidebarLicense": "Licenza",
|
||||
"sidebarClients": "Clienti (Beta)",
|
||||
"sidebarDomains": "Domini",
|
||||
"enableDockerSocket": "Abilita Docker Socket",
|
||||
"enableDockerSocketDescription": "Abilita il rilevamento Docker Socket per popolare le informazioni del contenitore. Il percorso del socket deve essere fornito a Newt.",
|
||||
"enableDockerSocket": "Abilita Progetto Docker",
|
||||
"enableDockerSocketDescription": "Abilita la raschiatura dell'etichetta Docker Socket per le etichette dei progetti. Il percorso del socket deve essere fornito a Newt.",
|
||||
"enableDockerSocketLink": "Scopri di più",
|
||||
"viewDockerContainers": "Visualizza Contenitori Docker",
|
||||
"containersIn": "Contenitori in {siteName}",
|
||||
|
@ -1234,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Aggiornamento Disponibile",
|
||||
"newtUpdateAvailableInfo": "È disponibile una nuova versione di Newt. Si prega di aggiornare all'ultima versione per la migliore esperienza.",
|
||||
"domainPickerEnterDomain": "Dominio",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, o semplicemente myapp",
|
||||
"domainPickerPlaceholder": "myapp.example.com",
|
||||
"domainPickerDescription": "Inserisci il dominio completo della risorsa per vedere le opzioni disponibili.",
|
||||
"domainPickerDescriptionSaas": "Inserisci un dominio completo, un sottodominio o semplicemente un nome per vedere le opzioni disponibili",
|
||||
"domainPickerTabAll": "Tutti",
|
||||
|
@ -1392,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protocollo",
|
||||
"editInternalResourceDialogSitePort": "Porta del Sito",
|
||||
"editInternalResourceDialogTargetConfiguration": "Configurazione Target",
|
||||
"editInternalResourceDialogDestinationIP": "IP di Destinazione",
|
||||
"editInternalResourceDialogDestinationPort": "Porta di Destinazione",
|
||||
"editInternalResourceDialogCancel": "Annulla",
|
||||
"editInternalResourceDialogSaveResource": "Salva Risorsa",
|
||||
"editInternalResourceDialogSuccess": "Successo",
|
||||
|
@ -1424,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Porta del Sito",
|
||||
"createInternalResourceDialogSitePortDescription": "Usa questa porta per accedere alla risorsa nel sito quando sei connesso con un client.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Configurazione Target",
|
||||
"createInternalResourceDialogDestinationIP": "IP di Destinazione",
|
||||
"createInternalResourceDialogDestinationIPDescription": "L'indirizzo IP della risorsa sulla rete del sito.",
|
||||
"createInternalResourceDialogDestinationPort": "Porta di Destinazione",
|
||||
"createInternalResourceDialogDestinationIPDescription": "L'indirizzo IP o hostname della risorsa nella rete del sito.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "La porta sull'IP di destinazione dove la risorsa è accessibile.",
|
||||
"createInternalResourceDialogCancel": "Annulla",
|
||||
"createInternalResourceDialogCreateResource": "Crea Risorsa",
|
||||
|
@ -1496,5 +1500,24 @@
|
|||
"convertButton": "Converti questo nodo in auto-ospitato gestito"
|
||||
},
|
||||
"internationaldomaindetected": "Dominio Internazionale Rilevato",
|
||||
"willbestoredas": "Verrà conservato come:"
|
||||
"willbestoredas": "Verrà conservato come:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC provider",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Intestazioni Personalizzate",
|
||||
"headersValidationError": "Le intestazioni devono essere nel formato: Intestazione-Nome: valore.",
|
||||
"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": "Modifica file: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Modifica file: docker-compose.yml"
|
||||
}
|
||||
|
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "사용자를 역할에 추가하는 동안 오류가 발생했습니다.",
|
||||
"userSaved": "사용자 저장됨",
|
||||
"userSavedDescription": "사용자가 업데이트되었습니다.",
|
||||
"autoProvisioned": "자동 프로비저닝됨",
|
||||
"autoProvisionedDescription": "이 사용자가 ID 공급자에 의해 자동으로 관리될 수 있도록 허용합니다",
|
||||
"accessControlsDescription": "이 사용자가 조직에서 접근하고 수행할 수 있는 작업을 관리하세요",
|
||||
"accessControlsSubmit": "접근 제어 저장",
|
||||
"roles": "역할",
|
||||
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "잘못된 IP 주소 형식",
|
||||
"ipAddressErrorInvalidOctet": "유효하지 않은 IP 주소 옥텟",
|
||||
"path": "경로",
|
||||
"matchPath": "경로 맞춤",
|
||||
"ipAddressRange": "IP 범위",
|
||||
"rulesErrorFetch": "규칙을 가져오는 데 실패했습니다.",
|
||||
"rulesErrorFetchDescription": "규칙을 가져오는 중 오류가 발생했습니다",
|
||||
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "연결됨",
|
||||
"idpErrorConnectingTo": "{name}에 연결하는 데 문제가 발생했습니다. 관리자에게 문의하십시오.",
|
||||
"idpErrorNotFound": "IdP를 찾을 수 없습니다.",
|
||||
"idpGoogleAlt": "구글",
|
||||
"idpAzureAlt": "애저",
|
||||
"inviteInvalid": "유효하지 않은 초대",
|
||||
"inviteInvalidDescription": "초대 링크가 유효하지 않습니다.",
|
||||
"inviteErrorWrongUser": "이 초대는 이 사용자에게 해당되지 않습니다",
|
||||
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "전문 에디션이 필요합니다.",
|
||||
"licenseTierProfessionalRequiredDescription": "이 기능은 Professional Edition에서만 사용할 수 있습니다.",
|
||||
"actionGetOrg": "조직 가져오기",
|
||||
"updateOrgUser": "조직 사용자 업데이트",
|
||||
"createOrgUser": "조직 사용자 생성",
|
||||
"actionUpdateOrg": "조직 업데이트",
|
||||
"actionUpdateUser": "사용자 업데이트",
|
||||
"actionGetUser": "사용자 조회",
|
||||
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "사이트 삭제",
|
||||
"actionGetSite": "사이트 가져오기",
|
||||
"actionListSites": "사이트 목록",
|
||||
"actionApplyBlueprint": "청사진 적용",
|
||||
"setupToken": "설정 토큰",
|
||||
"setupTokenDescription": "서버 콘솔에서 설정 토큰 입력.",
|
||||
"setupTokenRequired": "설정 토큰이 필요합니다",
|
||||
|
@ -1133,8 +1141,8 @@
|
|||
"sidebarLicense": "라이선스",
|
||||
"sidebarClients": "클라이언트 (Beta)",
|
||||
"sidebarDomains": "도메인",
|
||||
"enableDockerSocket": "Docker 소켓 활성화",
|
||||
"enableDockerSocketDescription": "컨테이너 정보를 채우기 위해 Docker 소켓 검색을 활성화합니다. 소켓 경로는 Newt에 제공되어야 합니다.",
|
||||
"enableDockerSocket": "Docker 청사진 활성화",
|
||||
"enableDockerSocketDescription": "블루프린트 레이블을 위한 Docker 소켓 레이블 수집을 활성화합니다. 소켓 경로는 Newt에 제공되어야 합니다.",
|
||||
"enableDockerSocketLink": "자세히 알아보기",
|
||||
"viewDockerContainers": "도커 컨테이너 보기",
|
||||
"containersIn": "{siteName}의 컨테이너",
|
||||
|
@ -1234,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "업데이트 가능",
|
||||
"newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
|
||||
"domainPickerEnterDomain": "도메인",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, 또는 그냥 myapp",
|
||||
"domainPickerPlaceholder": "myapp.example.com",
|
||||
"domainPickerDescription": "리소스의 전체 도메인을 입력하여 사용 가능한 옵션을 확인하십시오.",
|
||||
"domainPickerDescriptionSaas": "전체 도메인, 서브도메인 또는 이름을 입력하여 사용 가능한 옵션을 확인하십시오.",
|
||||
"domainPickerTabAll": "모두",
|
||||
|
@ -1392,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "프로토콜",
|
||||
"editInternalResourceDialogSitePort": "사이트 포트",
|
||||
"editInternalResourceDialogTargetConfiguration": "대상 구성",
|
||||
"editInternalResourceDialogDestinationIP": "대상 IP",
|
||||
"editInternalResourceDialogDestinationPort": "대상 IP의 포트",
|
||||
"editInternalResourceDialogCancel": "취소",
|
||||
"editInternalResourceDialogSaveResource": "리소스 저장",
|
||||
"editInternalResourceDialogSuccess": "성공",
|
||||
|
@ -1424,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "사이트 포트",
|
||||
"createInternalResourceDialogSitePortDescription": "사이트에 연결되었을 때 리소스에 접근하기 위해 이 포트를 사용합니다.",
|
||||
"createInternalResourceDialogTargetConfiguration": "대상 설정",
|
||||
"createInternalResourceDialogDestinationIP": "대상 IP",
|
||||
"createInternalResourceDialogDestinationIPDescription": "사이트 네트워크의 자원 IP 주소입니다.",
|
||||
"createInternalResourceDialogDestinationPort": "대상 포트",
|
||||
"createInternalResourceDialogDestinationIPDescription": "사이트 네트워크의 자원 IP 또는 호스트 네임 주소입니다.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "대상 IP에서 리소스에 접근할 수 있는 포트입니다.",
|
||||
"createInternalResourceDialogCancel": "취소",
|
||||
"createInternalResourceDialogCreateResource": "리소스 생성",
|
||||
|
@ -1496,5 +1500,24 @@
|
|||
"convertButton": "이 노드를 관리 자체 호스팅으로 변환"
|
||||
},
|
||||
"internationaldomaindetected": "국제 도메인 감지됨",
|
||||
"willbestoredas": "다음으로 저장됩니다:"
|
||||
"willbestoredas": "다음으로 저장됩니다:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC 공급자",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC 공급자",
|
||||
"customHeaders": "사용자 정의 헤더",
|
||||
"headersValidationError": "헤더는 형식이어야 합니다: 헤더명: 값.",
|
||||
"domainPickerProvidedDomain": "제공된 도메인",
|
||||
"domainPickerFreeProvidedDomain": "무료 제공된 도메인",
|
||||
"domainPickerVerified": "검증됨",
|
||||
"domainPickerUnverified": "검증되지 않음",
|
||||
"domainPickerInvalidSubdomainStructure": "이 하위 도메인은 잘못된 문자 또는 구조를 포함하고 있습니다. 저장 시 자동으로 정리됩니다.",
|
||||
"domainPickerError": "오류",
|
||||
"domainPickerErrorLoadDomains": "조직 도메인 로드 실패",
|
||||
"domainPickerErrorCheckAvailability": "도메인 가용성 확인 실패",
|
||||
"domainPickerInvalidSubdomain": "잘못된 하위 도메인",
|
||||
"domainPickerInvalidSubdomainRemoved": "입력 \"{sub}\"이(가) 유효하지 않으므로 제거되었습니다.",
|
||||
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\"을(를) {domain}에 대해 유효하게 만들 수 없습니다.",
|
||||
"domainPickerSubdomainSanitized": "하위 도메인 정리됨",
|
||||
"domainPickerSubdomainCorrected": "\"{sub}\"이(가) \"{sanitized}\"로 수정되었습니다",
|
||||
"resourceAddEntrypointsEditFile": "파일 편집: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "파일 편집: docker-compose.yml"
|
||||
}
|
||||
|
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "Det oppstod en feil under tilordning av brukeren til rollen.",
|
||||
"userSaved": "Bruker lagret",
|
||||
"userSavedDescription": "Brukeren har blitt oppdatert.",
|
||||
"autoProvisioned": "Auto avlyst",
|
||||
"autoProvisionedDescription": "Tillat denne brukeren å bli automatisk administrert av en identitetsleverandør",
|
||||
"accessControlsDescription": "Administrer hva denne brukeren kan få tilgang til og gjøre i organisasjonen",
|
||||
"accessControlsSubmit": "Lagre tilgangskontroller",
|
||||
"roles": "Roller",
|
||||
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Ugyldig IP-adresseformat",
|
||||
"ipAddressErrorInvalidOctet": "Ugyldig IP-adresse-oktet",
|
||||
"path": "Sti",
|
||||
"matchPath": "Match sti",
|
||||
"ipAddressRange": "IP-område",
|
||||
"rulesErrorFetch": "Klarte ikke å hente regler",
|
||||
"rulesErrorFetchDescription": "Det oppsto en feil under henting av regler",
|
||||
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Tilkoblet",
|
||||
"idpErrorConnectingTo": "Det oppstod et problem med å koble til {name}. Vennligst kontakt din administrator.",
|
||||
"idpErrorNotFound": "IdP ikke funnet",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Ugyldig invitasjon",
|
||||
"inviteInvalidDescription": "Invitasjonslenken er ugyldig.",
|
||||
"inviteErrorWrongUser": "Invitasjonen er ikke for denne brukeren",
|
||||
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Profesjonell utgave påkrevd",
|
||||
"licenseTierProfessionalRequiredDescription": "Denne funksjonen er kun tilgjengelig i den profesjonelle utgaven.",
|
||||
"actionGetOrg": "Hent organisasjon",
|
||||
"updateOrgUser": "Oppdater org.bruker",
|
||||
"createOrgUser": "Opprett Org bruker",
|
||||
"actionUpdateOrg": "Oppdater organisasjon",
|
||||
"actionUpdateUser": "Oppdater bruker",
|
||||
"actionGetUser": "Hent bruker",
|
||||
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Slett område",
|
||||
"actionGetSite": "Hent område",
|
||||
"actionListSites": "List opp områder",
|
||||
"actionApplyBlueprint": "Bruk blåkopi",
|
||||
"setupToken": "Oppsetttoken",
|
||||
"setupTokenDescription": "Skriv inn oppsetttoken fra serverkonsollen.",
|
||||
"setupTokenRequired": "Oppsetttoken er nødvendig",
|
||||
|
@ -1133,8 +1141,8 @@
|
|||
"sidebarLicense": "Lisens",
|
||||
"sidebarClients": "Klienter (Beta)",
|
||||
"sidebarDomains": "Domener",
|
||||
"enableDockerSocket": "Aktiver Docker Socket",
|
||||
"enableDockerSocketDescription": "Aktiver Docker Socket-oppdagelse for å fylle ut containerinformasjon. Socket-stien må oppgis til Newt.",
|
||||
"enableDockerSocket": "Aktiver Docker blåkopi",
|
||||
"enableDockerSocketDescription": "Aktiver skraping av Docker Socket for blueprint Etiketter. Socket bane må brukes for nye.",
|
||||
"enableDockerSocketLink": "Lær mer",
|
||||
"viewDockerContainers": "Vis Docker-containere",
|
||||
"containersIn": "Containere i {siteName}",
|
||||
|
@ -1234,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Oppdatering tilgjengelig",
|
||||
"newtUpdateAvailableInfo": "En ny versjon av Newt er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.",
|
||||
"domainPickerEnterDomain": "Domene",
|
||||
"domainPickerPlaceholder": "minapp.eksempel.com, api.v1.mittdomene.com, eller bare minapp",
|
||||
"domainPickerPlaceholder": "minapp.eksempel.no",
|
||||
"domainPickerDescription": "Skriv inn hele domenet til ressursen for å se tilgjengelige alternativer.",
|
||||
"domainPickerDescriptionSaas": "Skriv inn et fullt domene, underdomene eller bare et navn for å se tilgjengelige alternativer",
|
||||
"domainPickerTabAll": "Alle",
|
||||
|
@ -1392,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protokoll",
|
||||
"editInternalResourceDialogSitePort": "Områdeport",
|
||||
"editInternalResourceDialogTargetConfiguration": "Målkonfigurasjon",
|
||||
"editInternalResourceDialogDestinationIP": "Destinasjons-IP",
|
||||
"editInternalResourceDialogDestinationPort": "Destinasjonsport",
|
||||
"editInternalResourceDialogCancel": "Avbryt",
|
||||
"editInternalResourceDialogSaveResource": "Lagre ressurs",
|
||||
"editInternalResourceDialogSuccess": "Suksess",
|
||||
|
@ -1424,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Områdeport",
|
||||
"createInternalResourceDialogSitePortDescription": "Bruk denne porten for å få tilgang til ressursen på området når du er tilkoblet med en klient.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Målkonfigurasjon",
|
||||
"createInternalResourceDialogDestinationIP": "Destinasjons-IP",
|
||||
"createInternalResourceDialogDestinationIPDescription": "IP-adressen til ressursen på områdets nettverk.",
|
||||
"createInternalResourceDialogDestinationPort": "Destinasjonsport",
|
||||
"createInternalResourceDialogDestinationIPDescription": "IP eller vertsnavn til ressursen på nettstedets nettverk.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "Porten på destinasjons-IP-en der ressursen kan nås.",
|
||||
"createInternalResourceDialogCancel": "Avbryt",
|
||||
"createInternalResourceDialogCreateResource": "Opprett ressurs",
|
||||
|
@ -1496,5 +1500,24 @@
|
|||
"convertButton": "Konverter denne noden til manuelt bruk"
|
||||
},
|
||||
"internationaldomaindetected": "Internasjonalt domene oppdaget",
|
||||
"willbestoredas": "Vil bli lagret som:"
|
||||
"willbestoredas": "Vil bli lagret som:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC leverandør",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Egendefinerte topptekster",
|
||||
"headersValidationError": "Topptekst må være i formatet: header-navn: verdi.",
|
||||
"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": "Rediger fil: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Rediger fil: docker-compose.yml"
|
||||
}
|
||||
|
|
|
@ -38,12 +38,12 @@
|
|||
"name": "naam",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"site": "Website",
|
||||
"dataIn": "Gegevens in",
|
||||
"dataOut": "Data Uit",
|
||||
"site": "Referentie",
|
||||
"dataIn": "Dataverbruik inkomend",
|
||||
"dataOut": "Dataverbruik uitgaand",
|
||||
"connectionType": "Type verbinding",
|
||||
"tunnelType": "Tunnel type",
|
||||
"local": "lokaal",
|
||||
"local": "Lokaal",
|
||||
"edit": "Bewerken",
|
||||
"siteConfirmDelete": "Verwijderen van site bevestigen",
|
||||
"siteDelete": "Site verwijderen",
|
||||
|
@ -55,7 +55,7 @@
|
|||
"siteCreate": "Site maken",
|
||||
"siteCreateDescription2": "Volg de onderstaande stappen om een nieuwe site aan te maken en te verbinden",
|
||||
"siteCreateDescription": "Maak een nieuwe site aan om verbinding te maken met uw bronnen",
|
||||
"close": "Afsluiten",
|
||||
"close": "Sluiten",
|
||||
"siteErrorCreate": "Fout bij maken site",
|
||||
"siteErrorCreateKeyPair": "Key pair of site standaard niet gevonden",
|
||||
"siteErrorCreateDefaults": "Standaardinstellingen niet gevonden",
|
||||
|
@ -90,7 +90,7 @@
|
|||
"siteGeneralDescription": "Algemene instellingen voor deze site configureren",
|
||||
"siteSettingDescription": "Configureer de instellingen op uw site",
|
||||
"siteSetting": "{siteName} instellingen",
|
||||
"siteNewtTunnel": "Nieuwstunnel (Aanbevolen)",
|
||||
"siteNewtTunnel": "Newttunnel (Aanbevolen)",
|
||||
"siteNewtTunnelDescription": "Gemakkelijkste manier om een ingangspunt in uw netwerk te maken. Geen extra opzet.",
|
||||
"siteWg": "Basis WireGuard",
|
||||
"siteWgDescription": "Gebruik een WireGuard client om een tunnel te bouwen. Handmatige NAT installatie vereist.",
|
||||
|
@ -104,7 +104,7 @@
|
|||
"siteCredentialsSave": "Uw referenties opslaan",
|
||||
"siteCredentialsSaveDescription": "Je kunt dit slechts één keer zien. Kopieer het naar een beveiligde plek.",
|
||||
"siteInfo": "Site informatie",
|
||||
"status": "status",
|
||||
"status": "Status",
|
||||
"shareTitle": "Beheer deellinks",
|
||||
"shareDescription": "Maak deelbare links aan om tijdelijke of permanente toegang tot uw bronnen te verlenen",
|
||||
"shareSearch": "Zoek share links...",
|
||||
|
@ -146,19 +146,19 @@
|
|||
"never": "Nooit",
|
||||
"shareErrorSelectResource": "Selecteer een bron",
|
||||
"resourceTitle": "Bronnen beheren",
|
||||
"resourceDescription": "Veilige proxy's voor uw privé applicaties maken",
|
||||
"resourceDescription": "Veilige proxy's voor uw privé applicaties aanmaken",
|
||||
"resourcesSearch": "Zoek bronnen...",
|
||||
"resourceAdd": "Bron toevoegen",
|
||||
"resourceErrorDelte": "Fout bij verwijderen document",
|
||||
"authentication": "Authenticatie",
|
||||
"protected": "Beschermd",
|
||||
"notProtected": "Niet beschermd",
|
||||
"protected": "Beveiligd",
|
||||
"notProtected": "Niet beveiligd",
|
||||
"resourceMessageRemove": "Eenmaal verwijderd, zal het bestand niet langer toegankelijk zijn. Alle doelen die gekoppeld zijn aan het hulpbron, zullen ook verwijderd worden.",
|
||||
"resourceMessageConfirm": "Om te bevestigen, typ de naam van de bron hieronder.",
|
||||
"resourceQuestionRemove": "Weet u zeker dat u de resource {selectedResource} uit de organisatie wilt verwijderen?",
|
||||
"resourceHTTP": "HTTPS bron",
|
||||
"resourceHTTPDescription": "Proxy verzoeken aan uw app via HTTPS via een subdomein of basisdomein.",
|
||||
"resourceRaw": "Ruwe TCP/UDP bron",
|
||||
"resourceRaw": "TCP/UDP bron",
|
||||
"resourceRawDescription": "Proxy verzoeken naar je app via TCP/UDP met behulp van een poortnummer.",
|
||||
"resourceCreate": "Bron maken",
|
||||
"resourceCreateDescription": "Volg de onderstaande stappen om een nieuwe bron te maken",
|
||||
|
@ -183,7 +183,7 @@
|
|||
"protocolSelect": "Selecteer een protocol",
|
||||
"resourcePortNumber": "Nummer van poort",
|
||||
"resourcePortNumberDescription": "Het externe poortnummer naar proxyverzoeken.",
|
||||
"cancel": "annuleren",
|
||||
"cancel": "Annuleren",
|
||||
"resourceConfig": "Configuratie tekstbouwstenen",
|
||||
"resourceConfigDescription": "Kopieer en plak deze configuratie-snippets om je TCP/UDP-bron in te stellen",
|
||||
"resourceAddEntrypoints": "Traefik: Entrypoints toevoegen",
|
||||
|
@ -212,7 +212,7 @@
|
|||
"saveGeneralSettings": "Algemene instellingen opslaan",
|
||||
"saveSettings": "Instellingen opslaan",
|
||||
"orgDangerZone": "Gevaarlijke zone",
|
||||
"orgDangerZoneDescription": "Als u deze instantie verwijdert, is er geen weg terug. Wees het alstublieft zeker.",
|
||||
"orgDangerZoneDescription": "Deze instantie verwijderen is onomkeerbaar. Bevestig alstublieft dat u wilt doorgaan.",
|
||||
"orgDelete": "Verwijder organisatie",
|
||||
"orgDeleteConfirm": "Bevestig Verwijderen Organisatie",
|
||||
"orgMessageRemove": "Deze actie is onomkeerbaar en zal alle bijbehorende gegevens verwijderen.",
|
||||
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "Er is een fout opgetreden tijdens het toevoegen van de rol.",
|
||||
"userSaved": "Gebruiker opgeslagen",
|
||||
"userSavedDescription": "De gebruiker is bijgewerkt.",
|
||||
"autoProvisioned": "Automatisch bevestigen",
|
||||
"autoProvisionedDescription": "Toestaan dat deze gebruiker automatisch wordt beheerd door een identiteitsprovider",
|
||||
"accessControlsDescription": "Beheer wat deze gebruiker toegang heeft tot en doet in de organisatie",
|
||||
"accessControlsSubmit": "Bewaar Toegangsbesturing",
|
||||
"roles": "Rollen",
|
||||
|
@ -499,8 +501,8 @@
|
|||
"targetStickySessionsDescription": "Behoud verbindingen op hetzelfde backend doel voor hun hele sessie.",
|
||||
"methodSelect": "Selecteer methode",
|
||||
"targetSubmit": "Doelwit toevoegen",
|
||||
"targetNoOne": "Geen doelwitten. Voeg een doel toe via het formulier.",
|
||||
"targetNoOneDescription": "Het toevoegen van meer dan één doel hierboven zal de load balancering mogelijk maken.",
|
||||
"targetNoOne": "Geen doel toegevoegd. Voeg deze toe via dit formulier.",
|
||||
"targetNoOneDescription": "Het toevoegen van meer dan één doel hierboven zal load balancering mogelijk maken.",
|
||||
"targetsSubmit": "Doelstellingen opslaan",
|
||||
"proxyAdditional": "Extra Proxy-instellingen",
|
||||
"proxyAdditionalDescription": "Configureer hoe de proxy-instellingen van uw bron worden afgehandeld",
|
||||
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Ongeldig IP-adresformaat",
|
||||
"ipAddressErrorInvalidOctet": "Ongeldige IP adres octet",
|
||||
"path": "Pad",
|
||||
"matchPath": "Overeenkomend pad",
|
||||
"ipAddressRange": "IP Bereik",
|
||||
"rulesErrorFetch": "Regels ophalen mislukt",
|
||||
"rulesErrorFetchDescription": "Er is een fout opgetreden bij het ophalen van de regels",
|
||||
|
@ -595,7 +598,7 @@
|
|||
"newtId": "Newt-ID",
|
||||
"newtSecretKey": "Nieuwe geheime sleutel",
|
||||
"architecture": "Architectuur",
|
||||
"sites": "Werkruimtes",
|
||||
"sites": "Verbindingen",
|
||||
"siteWgAnyClients": "Gebruik een willekeurige WireGuard client om verbinding te maken. Je moet je interne bronnen aanspreken met behulp van de peer IP.",
|
||||
"siteWgCompatibleAllClients": "Compatibel met alle WireGuard clients",
|
||||
"siteWgManualConfigurationRequired": "Handmatige configuratie vereist",
|
||||
|
@ -726,7 +729,7 @@
|
|||
"idpMessageConfirm": "Om dit te bevestigen, typt u de naam van onderstaande identiteitsprovider.",
|
||||
"idpConfirmDelete": "Bevestig verwijderen Identity Provider",
|
||||
"idpDelete": "Identity Provider verwijderen",
|
||||
"idp": "Identiteit aanbieders",
|
||||
"idp": "Identiteitsaanbieders",
|
||||
"idpSearch": "Identiteitsaanbieders zoeken...",
|
||||
"idpAdd": "Identity Provider toevoegen",
|
||||
"idpClientIdRequired": "Client-ID is vereist.",
|
||||
|
@ -798,7 +801,7 @@
|
|||
"defaultMappingsOrgDescription": "Deze expressie moet de org-ID teruggeven of waar om de gebruiker toegang te geven tot de organisatie.",
|
||||
"defaultMappingsSubmit": "Standaard toewijzingen opslaan",
|
||||
"orgPoliciesEdit": "Organisatie beleid bewerken",
|
||||
"org": "Rekening",
|
||||
"org": "Organisatie",
|
||||
"orgSelect": "Selecteer organisatie",
|
||||
"orgSearch": "Zoek in org",
|
||||
"orgNotFound": "Geen org gevonden.",
|
||||
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Verbonden",
|
||||
"idpErrorConnectingTo": "Er was een probleem bij het verbinden met {name}. Neem contact op met uw beheerder.",
|
||||
"idpErrorNotFound": "IdP niet gevonden",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Ongeldige uitnodiging",
|
||||
"inviteInvalidDescription": "Uitnodigingslink is ongeldig.",
|
||||
"inviteErrorWrongUser": "Uitnodiging is niet voor deze gebruiker",
|
||||
|
@ -971,10 +976,10 @@
|
|||
"supportKeyEnterDescription": "Ontmoet je eigen huisdier Pangolin!",
|
||||
"githubUsername": "GitHub-gebruikersnaam",
|
||||
"supportKeyInput": "Supporter Sleutel",
|
||||
"supportKeyBuy": "Koop Supportersleutel",
|
||||
"supportKeyBuy": "Koop supportersleutel",
|
||||
"logoutError": "Fout bij uitloggen",
|
||||
"signingAs": "Ingelogd als",
|
||||
"serverAdmin": "Server Beheerder",
|
||||
"serverAdmin": "Server beheer",
|
||||
"managedSelfhosted": "Beheerde Self-Hosted",
|
||||
"otpEnable": "Twee-factor inschakelen",
|
||||
"otpDisable": "Tweestapsverificatie uitschakelen",
|
||||
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Professionele editie vereist",
|
||||
"licenseTierProfessionalRequiredDescription": "Deze functie is alleen beschikbaar in de Professional Edition.",
|
||||
"actionGetOrg": "Krijg Organisatie",
|
||||
"updateOrgUser": "Org gebruiker bijwerken",
|
||||
"createOrgUser": "Org gebruiker aanmaken",
|
||||
"actionUpdateOrg": "Organisatie bijwerken",
|
||||
"actionUpdateUser": "Gebruiker bijwerken",
|
||||
"actionGetUser": "Gebruiker ophalen",
|
||||
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Site verwijderen",
|
||||
"actionGetSite": "Site ophalen",
|
||||
"actionListSites": "Sites weergeven",
|
||||
"actionApplyBlueprint": "Blauwdruk toepassen",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenDescription": "Voer het setup-token in vanaf de serverconsole.",
|
||||
"setupTokenRequired": "Setup-token is vereist",
|
||||
|
@ -1120,7 +1128,7 @@
|
|||
"sidebarOverview": "Overzicht.",
|
||||
"sidebarHome": "Startpagina",
|
||||
"sidebarSites": "Werkruimtes",
|
||||
"sidebarResources": "Hulpmiddelen",
|
||||
"sidebarResources": "Bronnen",
|
||||
"sidebarAccessControl": "Toegangs controle",
|
||||
"sidebarUsers": "Gebruikers",
|
||||
"sidebarInvitations": "Uitnodigingen",
|
||||
|
@ -1133,13 +1141,13 @@
|
|||
"sidebarLicense": "Licentie",
|
||||
"sidebarClients": "Clients (Bèta)",
|
||||
"sidebarDomains": "Domeinen",
|
||||
"enableDockerSocket": "Docker Socket inschakelen",
|
||||
"enableDockerSocketDescription": "Docker Socket-ontdekking inschakelen voor het invullen van containerinformatie. Socket-pad moet aan Newt worden verstrekt.",
|
||||
"enableDockerSocket": "Schakel Docker Blauwdruk in",
|
||||
"enableDockerSocketDescription": "Schakel Docker Socket label in voor blauwdruk labels. Pad naar Nieuw.",
|
||||
"enableDockerSocketLink": "Meer informatie",
|
||||
"viewDockerContainers": "Bekijk Docker containers",
|
||||
"containersIn": "Containers in {siteName}",
|
||||
"selectContainerDescription": "Selecteer een container om als hostnaam voor dit doel te gebruiken. Klik op een poort om een poort te gebruiken.",
|
||||
"containerName": "naam",
|
||||
"containerName": "Naam",
|
||||
"containerImage": "Afbeelding",
|
||||
"containerState": "Provincie",
|
||||
"containerNetworks": "Netwerken",
|
||||
|
@ -1234,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Update beschikbaar",
|
||||
"newtUpdateAvailableInfo": "Er is een nieuwe versie van Newt beschikbaar. Update naar de nieuwste versie voor de beste ervaring.",
|
||||
"domainPickerEnterDomain": "Domein",
|
||||
"domainPickerPlaceholder": "mijnapp.voorbeeld.com, api.v1.mijndomein.com, of gewoon mijnapp",
|
||||
"domainPickerPlaceholder": "mijnapp.voorbeeld.nl",
|
||||
"domainPickerDescription": "Voer de volledige domein van de bron in om beschikbare opties te zien.",
|
||||
"domainPickerDescriptionSaas": "Voer een volledig domein, subdomein of gewoon een naam in om beschikbare opties te zien",
|
||||
"domainPickerTabAll": "Alles",
|
||||
|
@ -1341,7 +1349,7 @@
|
|||
"olmId": "Olm ID",
|
||||
"olmSecretKey": "Olm Geheime Sleutel",
|
||||
"clientCredentialsSave": "Uw referenties opslaan",
|
||||
"clientCredentialsSaveDescription": "Je kunt dit slechts één keer zien. Kopieer het naar een beveiligde plek.",
|
||||
"clientCredentialsSaveDescription": "Je kunt dit slechts één keer zien. Kopieer deze naar een veilige plek.",
|
||||
"generalSettingsDescription": "Configureer de algemene instellingen voor deze client",
|
||||
"clientUpdated": "Klant bijgewerkt ",
|
||||
"clientUpdatedDescription": "De client is bijgewerkt.",
|
||||
|
@ -1392,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protocol",
|
||||
"editInternalResourceDialogSitePort": "Site Poort",
|
||||
"editInternalResourceDialogTargetConfiguration": "Doelconfiguratie",
|
||||
"editInternalResourceDialogDestinationIP": "Bestemming IP",
|
||||
"editInternalResourceDialogDestinationPort": "Bestemmingspoort",
|
||||
"editInternalResourceDialogCancel": "Annuleren",
|
||||
"editInternalResourceDialogSaveResource": "Sla bron op",
|
||||
"editInternalResourceDialogSuccess": "Succes",
|
||||
|
@ -1424,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Site Poort",
|
||||
"createInternalResourceDialogSitePortDescription": "Gebruik deze poort om toegang te krijgen tot de bron op de site wanneer verbonden met een client.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Doelconfiguratie",
|
||||
"createInternalResourceDialogDestinationIP": "Bestemming IP",
|
||||
"createInternalResourceDialogDestinationIPDescription": "Het IP-adres van de bron op het netwerk van de site.",
|
||||
"createInternalResourceDialogDestinationPort": "Bestemmingspoort",
|
||||
"createInternalResourceDialogDestinationIPDescription": "Het IP of hostnaam adres van de bron op het netwerk van de site.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "De poort op het bestemmings-IP waar de bron toegankelijk is.",
|
||||
"createInternalResourceDialogCancel": "Annuleren",
|
||||
"createInternalResourceDialogCreateResource": "Bron aanmaken",
|
||||
|
@ -1496,5 +1500,24 @@
|
|||
"convertButton": "Converteer deze node naar Beheerde Zelf-Hosted"
|
||||
},
|
||||
"internationaldomaindetected": "Internationaal Domein Gedetecteerd",
|
||||
"willbestoredas": "Zal worden opgeslagen als:"
|
||||
"willbestoredas": "Zal worden opgeslagen als:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC provider",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Aangepaste headers",
|
||||
"headersValidationError": "Headers moeten in het formaat zijn: Header-Naam: waarde.",
|
||||
"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": "Bestand bewerken: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Bestand bewerken: docker-compose.yml"
|
||||
}
|
||||
|
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "Wystąpił błąd podczas dodawania użytkownika do roli.",
|
||||
"userSaved": "Użytkownik zapisany",
|
||||
"userSavedDescription": "Użytkownik został zaktualizowany.",
|
||||
"autoProvisioned": "Przesłane automatycznie",
|
||||
"autoProvisionedDescription": "Pozwól temu użytkownikowi na automatyczne zarządzanie przez dostawcę tożsamości",
|
||||
"accessControlsDescription": "Zarządzaj tym, do czego użytkownik ma dostęp i co może robić w organizacji",
|
||||
"accessControlsSubmit": "Zapisz kontrole dostępu",
|
||||
"roles": "Role",
|
||||
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Nieprawidłowy format adresu IP",
|
||||
"ipAddressErrorInvalidOctet": "Nieprawidłowy oktet adresu IP",
|
||||
"path": "Ścieżka",
|
||||
"matchPath": "Ścieżka dopasowania",
|
||||
"ipAddressRange": "Zakres IP",
|
||||
"rulesErrorFetch": "Nie udało się pobrać reguł",
|
||||
"rulesErrorFetchDescription": "Wystąpił błąd podczas pobierania reguł",
|
||||
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Połączono",
|
||||
"idpErrorConnectingTo": "Wystąpił problem z połączeniem z {name}. Skontaktuj się z administratorem.",
|
||||
"idpErrorNotFound": "Nie znaleziono IdP",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Nieprawidłowe zaproszenie",
|
||||
"inviteInvalidDescription": "Link zapraszający jest nieprawidłowy.",
|
||||
"inviteErrorWrongUser": "Zaproszenie nie jest dla tego użytkownika",
|
||||
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Wymagana edycja Professional",
|
||||
"licenseTierProfessionalRequiredDescription": "Ta funkcja jest dostępna tylko w edycji Professional.",
|
||||
"actionGetOrg": "Pobierz organizację",
|
||||
"updateOrgUser": "Aktualizuj użytkownika Org",
|
||||
"createOrgUser": "Utwórz użytkownika Org",
|
||||
"actionUpdateOrg": "Aktualizuj organizację",
|
||||
"actionUpdateUser": "Zaktualizuj użytkownika",
|
||||
"actionGetUser": "Pobierz użytkownika",
|
||||
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Usuń witrynę",
|
||||
"actionGetSite": "Pobierz witrynę",
|
||||
"actionListSites": "Lista witryn",
|
||||
"actionApplyBlueprint": "Zastosuj schemat",
|
||||
"setupToken": "Skonfiguruj token",
|
||||
"setupTokenDescription": "Wprowadź token konfiguracji z konsoli serwera.",
|
||||
"setupTokenRequired": "Wymagany jest token konfiguracji",
|
||||
|
@ -1133,8 +1141,8 @@
|
|||
"sidebarLicense": "Licencja",
|
||||
"sidebarClients": "Klienci (Beta)",
|
||||
"sidebarDomains": "Domeny",
|
||||
"enableDockerSocket": "Włącz gniazdo dokera",
|
||||
"enableDockerSocketDescription": "Włącz wykrywanie Docker Socket w celu wypełnienia informacji o kontenerach. Ścieżka gniazda musi być dostarczona do Newt.",
|
||||
"enableDockerSocket": "Włącz schemat dokera",
|
||||
"enableDockerSocketDescription": "Włącz etykietowanie kieszeni dokującej dla etykiet schematów. Ścieżka do gniazda musi być dostarczona do Newt.",
|
||||
"enableDockerSocketLink": "Dowiedz się więcej",
|
||||
"viewDockerContainers": "Zobacz kontenery dokujące",
|
||||
"containersIn": "Pojemniki w {siteName}",
|
||||
|
@ -1234,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Dostępna aktualizacja",
|
||||
"newtUpdateAvailableInfo": "Nowa wersja Newt jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.",
|
||||
"domainPickerEnterDomain": "Domena",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com lub po prostu myapp",
|
||||
"domainPickerPlaceholder": "mojapp.example.com",
|
||||
"domainPickerDescription": "Wpisz pełną domenę zasobu, aby zobaczyć dostępne opcje.",
|
||||
"domainPickerDescriptionSaas": "Wprowadź pełną domenę, subdomenę lub po prostu nazwę, aby zobaczyć dostępne opcje",
|
||||
"domainPickerTabAll": "Wszystko",
|
||||
|
@ -1392,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protokół",
|
||||
"editInternalResourceDialogSitePort": "Port witryny",
|
||||
"editInternalResourceDialogTargetConfiguration": "Konfiguracja celu",
|
||||
"editInternalResourceDialogDestinationIP": "IP docelowe",
|
||||
"editInternalResourceDialogDestinationPort": "Port docelowy",
|
||||
"editInternalResourceDialogCancel": "Anuluj",
|
||||
"editInternalResourceDialogSaveResource": "Zapisz zasób",
|
||||
"editInternalResourceDialogSuccess": "Sukces",
|
||||
|
@ -1424,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Port witryny",
|
||||
"createInternalResourceDialogSitePortDescription": "Użyj tego portu, aby uzyskać dostęp do zasobu na stronie, gdy połączony z klientem.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Konfiguracja celu",
|
||||
"createInternalResourceDialogDestinationIP": "IP docelowe",
|
||||
"createInternalResourceDialogDestinationIPDescription": "Adres IP zasobu w sieci strony.",
|
||||
"createInternalResourceDialogDestinationPort": "Port docelowy",
|
||||
"createInternalResourceDialogDestinationIPDescription": "Adres IP lub nazwa hosta zasobu w sieci witryny.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "Port na docelowym IP, gdzie zasób jest dostępny.",
|
||||
"createInternalResourceDialogCancel": "Anuluj",
|
||||
"createInternalResourceDialogCreateResource": "Utwórz zasób",
|
||||
|
@ -1496,5 +1500,24 @@
|
|||
"convertButton": "Konwertuj ten węzeł do zarządzanego samodzielnie"
|
||||
},
|
||||
"internationaldomaindetected": "Wykryto międzynarodową domenę",
|
||||
"willbestoredas": "Będą przechowywane jako:"
|
||||
"willbestoredas": "Będą przechowywane jako:",
|
||||
"idpGoogleDescription": "Dostawca Google OAuth2/OIDC",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Niestandardowe nagłówki",
|
||||
"headersValidationError": "Nagłówki muszą być w formacie: Nazwa nagłówka: wartość.",
|
||||
"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": "Edytuj plik: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Edytuj plik: docker-compose.yml"
|
||||
}
|
||||
|
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "Ocorreu um erro ao adicionar usuário à função.",
|
||||
"userSaved": "Usuário salvo",
|
||||
"userSavedDescription": "O usuário foi atualizado.",
|
||||
"autoProvisioned": "Auto provisionado",
|
||||
"autoProvisionedDescription": "Permitir que este usuário seja gerenciado automaticamente pelo provedor de identidade",
|
||||
"accessControlsDescription": "Gerencie o que este usuário pode acessar e fazer na organização",
|
||||
"accessControlsSubmit": "Salvar Controles de Acesso",
|
||||
"roles": "Funções",
|
||||
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Formato de endereço IP inválido",
|
||||
"ipAddressErrorInvalidOctet": "Octeto de endereço IP inválido",
|
||||
"path": "Caminho",
|
||||
"matchPath": "Correspondência de caminho",
|
||||
"ipAddressRange": "Faixa de IP",
|
||||
"rulesErrorFetch": "Falha ao buscar regras",
|
||||
"rulesErrorFetchDescription": "Ocorreu um erro ao buscar regras",
|
||||
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Conectado",
|
||||
"idpErrorConnectingTo": "Ocorreu um problema ao ligar a {name}. Por favor, contacte o seu administrador.",
|
||||
"idpErrorNotFound": "IdP não encontrado",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Convite Inválido",
|
||||
"inviteInvalidDescription": "O link do convite é inválido.",
|
||||
"inviteErrorWrongUser": "O convite não é para este usuário",
|
||||
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Edição Profissional Necessária",
|
||||
"licenseTierProfessionalRequiredDescription": "Esta funcionalidade só está disponível na Edição Profissional.",
|
||||
"actionGetOrg": "Obter Organização",
|
||||
"updateOrgUser": "Atualizar usuário Org",
|
||||
"createOrgUser": "Criar usuário Org",
|
||||
"actionUpdateOrg": "Atualizar Organização",
|
||||
"actionUpdateUser": "Atualizar Usuário",
|
||||
"actionGetUser": "Obter Usuário",
|
||||
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Eliminar Site",
|
||||
"actionGetSite": "Obter Site",
|
||||
"actionListSites": "Listar Sites",
|
||||
"actionApplyBlueprint": "Aplicar Diagrama",
|
||||
"setupToken": "Configuração do Token",
|
||||
"setupTokenDescription": "Digite o token de configuração do console do servidor.",
|
||||
"setupTokenRequired": "Token de configuração é necessário",
|
||||
|
@ -1133,8 +1141,8 @@
|
|||
"sidebarLicense": "Tipo:",
|
||||
"sidebarClients": "Clientes (Beta)",
|
||||
"sidebarDomains": "Domínios",
|
||||
"enableDockerSocket": "Habilitar Docker Socket",
|
||||
"enableDockerSocketDescription": "Ativar a descoberta do Docker Socket para preencher informações do contêiner. O caminho do socket deve ser fornecido ao Newt.",
|
||||
"enableDockerSocket": "Habilitar o Diagrama Docker",
|
||||
"enableDockerSocketDescription": "Ativar a scraping de rótulo Docker para rótulos de diagramas. Caminho de Socket deve ser fornecido para Newt.",
|
||||
"enableDockerSocketLink": "Saiba mais",
|
||||
"viewDockerContainers": "Ver contêineres Docker",
|
||||
"containersIn": "Contêineres em {siteName}",
|
||||
|
@ -1234,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Nova Atualização Disponível",
|
||||
"newtUpdateAvailableInfo": "Uma nova versão do Newt está disponível. Atualize para a versão mais recente para uma melhor experiência.",
|
||||
"domainPickerEnterDomain": "Domínio",
|
||||
"domainPickerPlaceholder": "meuapp.exemplo.com, api.v1.meudominio.com, ou apenas meuapp",
|
||||
"domainPickerPlaceholder": "myapp.exemplo.com",
|
||||
"domainPickerDescription": "Insira o domínio completo do recurso para ver as opções disponíveis.",
|
||||
"domainPickerDescriptionSaas": "Insira um domínio completo, subdomínio ou apenas um nome para ver as opções disponíveis",
|
||||
"domainPickerTabAll": "Todos",
|
||||
|
@ -1392,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protocolo",
|
||||
"editInternalResourceDialogSitePort": "Porta do Site",
|
||||
"editInternalResourceDialogTargetConfiguration": "Configuração do Alvo",
|
||||
"editInternalResourceDialogDestinationIP": "IP de Destino",
|
||||
"editInternalResourceDialogDestinationPort": "Porta de Destino",
|
||||
"editInternalResourceDialogCancel": "Cancelar",
|
||||
"editInternalResourceDialogSaveResource": "Salvar Recurso",
|
||||
"editInternalResourceDialogSuccess": "Sucesso",
|
||||
|
@ -1424,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Porta do Site",
|
||||
"createInternalResourceDialogSitePortDescription": "Use esta porta para acessar o recurso no site quando conectado com um cliente.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Configuração do Alvo",
|
||||
"createInternalResourceDialogDestinationIP": "IP de Destino",
|
||||
"createInternalResourceDialogDestinationIPDescription": "O endereço IP do recurso na rede do site.",
|
||||
"createInternalResourceDialogDestinationPort": "Porta de Destino",
|
||||
"createInternalResourceDialogDestinationIPDescription": "O IP ou endereço do hostname do recurso na rede do site.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "A porta no IP de destino onde o recurso está acessível.",
|
||||
"createInternalResourceDialogCancel": "Cancelar",
|
||||
"createInternalResourceDialogCreateResource": "Criar Recurso",
|
||||
|
@ -1496,5 +1500,24 @@
|
|||
"convertButton": "Converter este nó para Auto-Hospedado Gerenciado"
|
||||
},
|
||||
"internationaldomaindetected": "Domínio Internacional Detectado",
|
||||
"willbestoredas": "Será armazenado como:"
|
||||
"willbestoredas": "Será armazenado como:",
|
||||
"idpGoogleDescription": "Provedor Google OAuth2/OIDC",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Cabeçalhos Personalizados",
|
||||
"headersValidationError": "Cabeçalhos devem estar no formato: Nome do Cabeçalho: valor.",
|
||||
"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": "Editar arquivo: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Editar arquivo: docker-compose.yml"
|
||||
}
|
||||
|
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "Произошла ошибка при добавлении пользователя в роль.",
|
||||
"userSaved": "Пользователь сохранён",
|
||||
"userSavedDescription": "Пользователь был обновлён.",
|
||||
"autoProvisioned": "Автоподбор",
|
||||
"autoProvisionedDescription": "Разрешить автоматическое управление этим пользователем",
|
||||
"accessControlsDescription": "Управляйте тем, к чему этот пользователь может получить доступ и что делать в организации",
|
||||
"accessControlsSubmit": "Сохранить контроль доступа",
|
||||
"roles": "Роли",
|
||||
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Неверный формат IP адреса",
|
||||
"ipAddressErrorInvalidOctet": "Неверный октет IP адреса",
|
||||
"path": "Путь",
|
||||
"matchPath": "Путь матча",
|
||||
"ipAddressRange": "Диапазон IP",
|
||||
"rulesErrorFetch": "Не удалось получить правила",
|
||||
"rulesErrorFetchDescription": "Произошла ошибка при получении правил",
|
||||
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Подключено",
|
||||
"idpErrorConnectingTo": "Возникла проблема при подключении к {name}. Пожалуйста, свяжитесь с вашим администратором.",
|
||||
"idpErrorNotFound": "IdP не найден",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Недействительное приглашение",
|
||||
"inviteInvalidDescription": "Ссылка на приглашение недействительна.",
|
||||
"inviteErrorWrongUser": "Приглашение не для этого пользователя",
|
||||
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Требуется профессиональная версия",
|
||||
"licenseTierProfessionalRequiredDescription": "Эта функция доступна только в профессиональной версии.",
|
||||
"actionGetOrg": "Получить организацию",
|
||||
"updateOrgUser": "Обновить пользователя Org",
|
||||
"createOrgUser": "Создать пользователя Org",
|
||||
"actionUpdateOrg": "Обновить организацию",
|
||||
"actionUpdateUser": "Обновить пользователя",
|
||||
"actionGetUser": "Получить пользователя",
|
||||
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Удалить сайт",
|
||||
"actionGetSite": "Получить сайт",
|
||||
"actionListSites": "Список сайтов",
|
||||
"actionApplyBlueprint": "Применить чертёж",
|
||||
"setupToken": "Код настройки",
|
||||
"setupTokenDescription": "Введите токен настройки из консоли сервера.",
|
||||
"setupTokenRequired": "Токен настройки обязателен",
|
||||
|
@ -1133,8 +1141,8 @@
|
|||
"sidebarLicense": "Лицензия",
|
||||
"sidebarClients": "Клиенты (бета)",
|
||||
"sidebarDomains": "Домены",
|
||||
"enableDockerSocket": "Включить Docker Socket",
|
||||
"enableDockerSocketDescription": "Включить обнаружение Docker Socket для заполнения информации о контейнерах. Путь к сокету должен быть предоставлен Newt.",
|
||||
"enableDockerSocket": "Включить чертёж Docker",
|
||||
"enableDockerSocketDescription": "Включить scraping ярлыка Docker Socket для ярлыков чертежей. Путь к сокету должен быть предоставлен в Newt.",
|
||||
"enableDockerSocketLink": "Узнать больше",
|
||||
"viewDockerContainers": "Просмотр контейнеров Docker",
|
||||
"containersIn": "Контейнеры в {siteName}",
|
||||
|
@ -1234,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Доступно обновление",
|
||||
"newtUpdateAvailableInfo": "Доступна новая версия Newt. Пожалуйста, обновитесь до последней версии для лучшего опыта.",
|
||||
"domainPickerEnterDomain": "Домен",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, или просто myapp",
|
||||
"domainPickerPlaceholder": "myapp.example.com",
|
||||
"domainPickerDescription": "Введите полный домен ресурса, чтобы увидеть доступные опции.",
|
||||
"domainPickerDescriptionSaas": "Введите полный домен, поддомен или просто имя, чтобы увидеть доступные опции",
|
||||
"domainPickerTabAll": "Все",
|
||||
|
@ -1392,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Протокол",
|
||||
"editInternalResourceDialogSitePort": "Порт сайта",
|
||||
"editInternalResourceDialogTargetConfiguration": "Настройка цели",
|
||||
"editInternalResourceDialogDestinationIP": "Целевая IP",
|
||||
"editInternalResourceDialogDestinationPort": "Целевой порт",
|
||||
"editInternalResourceDialogCancel": "Отмена",
|
||||
"editInternalResourceDialogSaveResource": "Сохранить ресурс",
|
||||
"editInternalResourceDialogSuccess": "Успешно",
|
||||
|
@ -1424,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Порт сайта",
|
||||
"createInternalResourceDialogSitePortDescription": "Используйте этот порт для доступа к ресурсу на сайте при подключении с клиентом.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Настройка цели",
|
||||
"createInternalResourceDialogDestinationIP": "Целевая IP",
|
||||
"createInternalResourceDialogDestinationIPDescription": "IP-адрес ресурса в сети сайта.",
|
||||
"createInternalResourceDialogDestinationPort": "Целевой порт",
|
||||
"createInternalResourceDialogDestinationIPDescription": "IP или адрес хоста ресурса в сети сайта.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "Порт на IP-адресе назначения, где доступен ресурс.",
|
||||
"createInternalResourceDialogCancel": "Отмена",
|
||||
"createInternalResourceDialogCreateResource": "Создать ресурс",
|
||||
|
@ -1496,5 +1500,24 @@
|
|||
"convertButton": "Конвертировать этот узел в управляемый себе-хост"
|
||||
},
|
||||
"internationaldomaindetected": "Обнаружен международный домен",
|
||||
"willbestoredas": "Будет храниться как:"
|
||||
"willbestoredas": "Будет храниться как:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC провайдер",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "Пользовательские заголовки",
|
||||
"headersValidationError": "Заголовки должны быть в формате: Название заголовка: значение.",
|
||||
"domainPickerProvidedDomain": "Домен предоставлен",
|
||||
"domainPickerFreeProvidedDomain": "Бесплатный домен",
|
||||
"domainPickerVerified": "Подтверждено",
|
||||
"domainPickerUnverified": "Не подтверждено",
|
||||
"domainPickerInvalidSubdomainStructure": "Этот поддомен содержит недопустимые символы или структуру. Он будет очищен автоматически при сохранении.",
|
||||
"domainPickerError": "Ошибка",
|
||||
"domainPickerErrorLoadDomains": "Не удалось загрузить домены организации",
|
||||
"domainPickerErrorCheckAvailability": "Не удалось проверить доступность домена",
|
||||
"domainPickerInvalidSubdomain": "Неверный поддомен",
|
||||
"domainPickerInvalidSubdomainRemoved": "Ввод \"{sub}\" был удален, потому что он недействителен.",
|
||||
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" не может быть действительным для {domain}.",
|
||||
"domainPickerSubdomainSanitized": "Субдомен очищен",
|
||||
"domainPickerSubdomainCorrected": "\"{sub}\" был исправлен на \"{sanitized}\"",
|
||||
"resourceAddEntrypointsEditFile": "Редактировать файл: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Редактировать файл: docker-compose.yml"
|
||||
}
|
||||
|
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "Kullanıcı role eklenirken bir hata oluştu.",
|
||||
"userSaved": "Kullanıcı kaydedildi",
|
||||
"userSavedDescription": "Kullanıcı güncellenmiştir.",
|
||||
"autoProvisioned": "Otomatik Sağlandı",
|
||||
"autoProvisionedDescription": "Bu kullanıcının kimlik sağlayıcısı tarafından otomatik olarak yönetilmesine izin ver",
|
||||
"accessControlsDescription": "Bu kullanıcının organizasyonda neleri erişebileceğini ve yapabileceğini yönetin",
|
||||
"accessControlsSubmit": "Erişim Kontrollerini Kaydet",
|
||||
"roles": "Roller",
|
||||
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "Geçersiz IP adresi formatı",
|
||||
"ipAddressErrorInvalidOctet": "Geçersiz IP adresi okteti",
|
||||
"path": "Yol",
|
||||
"matchPath": "Yol Eşleştir",
|
||||
"ipAddressRange": "IP Aralığı",
|
||||
"rulesErrorFetch": "Kurallar alınamadı",
|
||||
"rulesErrorFetchDescription": "Kurallar alınırken bir hata oluştu",
|
||||
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "Bağlandı",
|
||||
"idpErrorConnectingTo": "{name} ile bağlantı kurarken bir sorun meydana geldi. Lütfen yöneticiye danışın.",
|
||||
"idpErrorNotFound": "IdP bulunamadı",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "Geçersiz Davet",
|
||||
"inviteInvalidDescription": "Davet bağlantısı geçersiz.",
|
||||
"inviteErrorWrongUser": "Davet bu kullanıcı için değil",
|
||||
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "Profesyonel Sürüme Gereklidir",
|
||||
"licenseTierProfessionalRequiredDescription": "Bu özellik yalnızca Professional Edition'da kullanılabilir.",
|
||||
"actionGetOrg": "Kuruluşu Al",
|
||||
"updateOrgUser": "Organizasyon Kullanıcısını Güncelle",
|
||||
"createOrgUser": "Organizasyon Kullanıcısı Oluştur",
|
||||
"actionUpdateOrg": "Kuruluşu Güncelle",
|
||||
"actionUpdateUser": "Kullanıcıyı Güncelle",
|
||||
"actionGetUser": "Kullanıcıyı Getir",
|
||||
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "Siteyi Sil",
|
||||
"actionGetSite": "Siteyi Al",
|
||||
"actionListSites": "Siteleri Listele",
|
||||
"actionApplyBlueprint": "Planı Uygula",
|
||||
"setupToken": "Kurulum Simgesi",
|
||||
"setupTokenDescription": "Sunucu konsolundan kurulum simgesini girin.",
|
||||
"setupTokenRequired": "Kurulum simgesi gerekli",
|
||||
|
@ -1133,8 +1141,8 @@
|
|||
"sidebarLicense": "Lisans",
|
||||
"sidebarClients": "Müşteriler (Beta)",
|
||||
"sidebarDomains": "Alan Adları",
|
||||
"enableDockerSocket": "Docker Soketi Etkinleştir",
|
||||
"enableDockerSocketDescription": "Konteyner bilgilerini doldurmak için Docker Socket keşfini etkinleştirin. Socket yolu Newt'e sağlanmalıdır.",
|
||||
"enableDockerSocket": "Docker Soketini Etkinleştir",
|
||||
"enableDockerSocketDescription": "Plan etiketleri için Docker Socket etiket toplamasını etkinleştirin. Newt'e soket yolu sağlanmalıdır.",
|
||||
"enableDockerSocketLink": "Daha fazla bilgi",
|
||||
"viewDockerContainers": "Docker Konteynerlerini Görüntüle",
|
||||
"containersIn": "{siteName} içindeki konteynerler",
|
||||
|
@ -1234,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "Güncelleme Mevcut",
|
||||
"newtUpdateAvailableInfo": "Newt'in yeni bir versiyonu mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.",
|
||||
"domainPickerEnterDomain": "Domain",
|
||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com veya sadece myapp",
|
||||
"domainPickerPlaceholder": "myapp.example.com",
|
||||
"domainPickerDescription": "Mevcut seçenekleri görmek için kaynağın tam etki alanını girin.",
|
||||
"domainPickerDescriptionSaas": "Mevcut seçenekleri görmek için tam etki alanı, alt etki alanı veya sadece bir isim girin",
|
||||
"domainPickerTabAll": "Tümü",
|
||||
|
@ -1392,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "Protokol",
|
||||
"editInternalResourceDialogSitePort": "Site Bağlantı Noktası",
|
||||
"editInternalResourceDialogTargetConfiguration": "Hedef Yapılandırma",
|
||||
"editInternalResourceDialogDestinationIP": "Hedef IP",
|
||||
"editInternalResourceDialogDestinationPort": "Hedef Bağlantı Noktası",
|
||||
"editInternalResourceDialogCancel": "İptal",
|
||||
"editInternalResourceDialogSaveResource": "Kaynağı Kaydet",
|
||||
"editInternalResourceDialogSuccess": "Başarı",
|
||||
|
@ -1424,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "Site Bağlantı Noktası",
|
||||
"createInternalResourceDialogSitePortDescription": "İstemci ile bağlanıldığında site üzerindeki kaynağa erişmek için bu bağlantı noktasını kullanın.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Hedef Yapılandırma",
|
||||
"createInternalResourceDialogDestinationIP": "Hedef IP",
|
||||
"createInternalResourceDialogDestinationIPDescription": "Site ağındaki kaynağın IP adresi.",
|
||||
"createInternalResourceDialogDestinationPort": "Hedef Bağlantı Noktası",
|
||||
"createInternalResourceDialogDestinationIPDescription": "Kaynağın site ağındaki IP veya ana bilgisayar adresi.",
|
||||
"createInternalResourceDialogDestinationPortDescription": "Kaynağa erişilebilecek hedef IP üzerindeki bağlantı noktası.",
|
||||
"createInternalResourceDialogCancel": "İptal",
|
||||
"createInternalResourceDialogCreateResource": "Kaynak Oluştur",
|
||||
|
@ -1496,5 +1500,24 @@
|
|||
"convertButton": "Bu Düğümü Yönetilen Kendi Kendine Barındırma Dönüştürün"
|
||||
},
|
||||
"internationaldomaindetected": "Uluslararası Alan Adı Tespit Edildi",
|
||||
"willbestoredas": "Şu şekilde depolanacak:"
|
||||
"willbestoredas": "Şu şekilde depolanacak:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC sağlayıcısı",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC sağlayıcısı",
|
||||
"customHeaders": "Özel Başlıklar",
|
||||
"headersValidationError": "Başlıklar şu formatta olmalıdır: Başlık-Adı: değer.",
|
||||
"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": "Dosyayı düzenle: config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "Dosyayı düzenle: docker-compose.yml"
|
||||
}
|
||||
|
|
|
@ -454,6 +454,8 @@
|
|||
"accessRoleErrorAddDescription": "添加用户到角色时出错。",
|
||||
"userSaved": "用户已保存",
|
||||
"userSavedDescription": "用户已更新。",
|
||||
"autoProvisioned": "自动设置",
|
||||
"autoProvisionedDescription": "允许此用户由身份提供商自动管理",
|
||||
"accessControlsDescription": "管理此用户在组织中可以访问和做什么",
|
||||
"accessControlsSubmit": "保存访问控制",
|
||||
"roles": "角色",
|
||||
|
@ -511,6 +513,7 @@
|
|||
"ipAddressErrorInvalidFormat": "无效的 IP 地址格式",
|
||||
"ipAddressErrorInvalidOctet": "无效的 IP 地址",
|
||||
"path": "路径",
|
||||
"matchPath": "匹配路径",
|
||||
"ipAddressRange": "IP 范围",
|
||||
"rulesErrorFetch": "获取规则失败",
|
||||
"rulesErrorFetchDescription": "获取规则时出错",
|
||||
|
@ -911,6 +914,8 @@
|
|||
"idpConnectingToFinished": "已连接",
|
||||
"idpErrorConnectingTo": "无法连接到 {name},请联系管理员协助处理。",
|
||||
"idpErrorNotFound": "找不到 IdP",
|
||||
"idpGoogleAlt": "Google",
|
||||
"idpAzureAlt": "Azure",
|
||||
"inviteInvalid": "无效邀请",
|
||||
"inviteInvalidDescription": "邀请链接无效。",
|
||||
"inviteErrorWrongUser": "邀请不是该用户的",
|
||||
|
@ -982,6 +987,8 @@
|
|||
"licenseTierProfessionalRequired": "需要专业版",
|
||||
"licenseTierProfessionalRequiredDescription": "此功能仅在专业版可用。",
|
||||
"actionGetOrg": "获取组织",
|
||||
"updateOrgUser": "更新组织用户",
|
||||
"createOrgUser": "创建组织用户",
|
||||
"actionUpdateOrg": "更新组织",
|
||||
"actionUpdateUser": "更新用户",
|
||||
"actionGetUser": "获取用户",
|
||||
|
@ -991,6 +998,7 @@
|
|||
"actionDeleteSite": "删除站点",
|
||||
"actionGetSite": "获取站点",
|
||||
"actionListSites": "站点列表",
|
||||
"actionApplyBlueprint": "应用蓝图",
|
||||
"setupToken": "设置令牌",
|
||||
"setupTokenDescription": "从服务器控制台输入设置令牌。",
|
||||
"setupTokenRequired": "需要设置令牌",
|
||||
|
@ -1133,8 +1141,8 @@
|
|||
"sidebarLicense": "证书",
|
||||
"sidebarClients": "客户端(测试版)",
|
||||
"sidebarDomains": "域",
|
||||
"enableDockerSocket": "启用停靠套接字",
|
||||
"enableDockerSocketDescription": "启用 Docker Socket 发现以填充容器信息。必须向 Newt 提供 Socket 路径。",
|
||||
"enableDockerSocket": "启用 Docker 蓝图",
|
||||
"enableDockerSocketDescription": "启用 Docker Socket 标签擦除蓝图标签。套接字路径必须提供给新的。",
|
||||
"enableDockerSocketLink": "了解更多",
|
||||
"viewDockerContainers": "查看停靠容器",
|
||||
"containersIn": "{siteName} 中的容器",
|
||||
|
@ -1234,7 +1242,7 @@
|
|||
"newtUpdateAvailable": "更新可用",
|
||||
"newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。",
|
||||
"domainPickerEnterDomain": "域名",
|
||||
"domainPickerPlaceholder": "myapp.example.com、api.v1.mydomain.com 或仅 myapp",
|
||||
"domainPickerPlaceholder": "example.com",
|
||||
"domainPickerDescription": "输入资源的完整域名以查看可用选项。",
|
||||
"domainPickerDescriptionSaas": "输入完整域名、子域或名称以查看可用选项。",
|
||||
"domainPickerTabAll": "所有",
|
||||
|
@ -1392,8 +1400,6 @@
|
|||
"editInternalResourceDialogProtocol": "协议",
|
||||
"editInternalResourceDialogSitePort": "站点端口",
|
||||
"editInternalResourceDialogTargetConfiguration": "目标配置",
|
||||
"editInternalResourceDialogDestinationIP": "目标IP",
|
||||
"editInternalResourceDialogDestinationPort": "目标端口",
|
||||
"editInternalResourceDialogCancel": "取消",
|
||||
"editInternalResourceDialogSaveResource": "保存资源",
|
||||
"editInternalResourceDialogSuccess": "成功",
|
||||
|
@ -1424,9 +1430,7 @@
|
|||
"createInternalResourceDialogSitePort": "站点端口",
|
||||
"createInternalResourceDialogSitePortDescription": "使用此端口在连接到客户端时访问站点上的资源。",
|
||||
"createInternalResourceDialogTargetConfiguration": "目标配置",
|
||||
"createInternalResourceDialogDestinationIP": "目标IP",
|
||||
"createInternalResourceDialogDestinationIPDescription": "站点网络上资源的IP地址。",
|
||||
"createInternalResourceDialogDestinationPort": "目标端口",
|
||||
"createInternalResourceDialogDestinationIPDescription": "站点网络上资源的IP或主机名地址。",
|
||||
"createInternalResourceDialogDestinationPortDescription": "资源在目标IP上可访问的端口。",
|
||||
"createInternalResourceDialogCancel": "取消",
|
||||
"createInternalResourceDialogCreateResource": "创建资源",
|
||||
|
@ -1496,5 +1500,24 @@
|
|||
"convertButton": "将此节点转换为管理自托管的"
|
||||
},
|
||||
"internationaldomaindetected": "检测到国际域",
|
||||
"willbestoredas": "储存为:"
|
||||
"willbestoredas": "储存为:",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC 提供商",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"customHeaders": "自定义标题",
|
||||
"headersValidationError": "头部必须是格式:头部名称:值。",
|
||||
"domainPickerProvidedDomain": "提供的域",
|
||||
"domainPickerFreeProvidedDomain": "免费提供的域",
|
||||
"domainPickerVerified": "已验证",
|
||||
"domainPickerUnverified": "未验证",
|
||||
"domainPickerInvalidSubdomainStructure": "此子域包含无效的字符或结构。当您保存时,它将被自动清除。",
|
||||
"domainPickerError": "错误",
|
||||
"domainPickerErrorLoadDomains": "加载组织域名失败",
|
||||
"domainPickerErrorCheckAvailability": "检查域可用性失败",
|
||||
"domainPickerInvalidSubdomain": "无效的子域",
|
||||
"domainPickerInvalidSubdomainRemoved": "输入 \"{sub}\" 已被移除,因为其无效。",
|
||||
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" 无法为 {domain} 变为有效。",
|
||||
"domainPickerSubdomainSanitized": "子域已净化",
|
||||
"domainPickerSubdomainCorrected": "\"{sub}\" 已被更正为 \"{sanitized}\"",
|
||||
"resourceAddEntrypointsEditFile": "编辑文件:config/traefik/traefik_config.yml",
|
||||
"resourceExposePortsEditFile": "编辑文件:docker-compose.yml"
|
||||
}
|
||||
|
|
2264
package-lock.json
generated
2264
package-lock.json
generated
File diff suppressed because it is too large
Load diff
53
package.json
53
package.json
|
@ -21,13 +21,13 @@
|
|||
"db:clear-migrations": "rm -rf server/migrations",
|
||||
"build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs",
|
||||
"build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs",
|
||||
"start": "DB_TYPE=sqlite NODE_OPTIONS=--enable-source-maps NODE_ENV=development ENVIRONMENT=prod sh -c 'node dist/migrations.mjs && node dist/server.mjs'",
|
||||
"start": "ENVIRONMENT=prod node dist/migrations.mjs && ENVIRONMENT=prod NODE_ENV=development node --enable-source-maps dist/server.mjs",
|
||||
"email": "email dev --dir server/emails/templates --port 3005",
|
||||
"build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "^7.3.4",
|
||||
"@hookform/resolvers": "3.9.1",
|
||||
"@hookform/resolvers": "4.1.3",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@oslojs/crypto": "1.0.1",
|
||||
"@oslojs/encoding": "1.1.0",
|
||||
|
@ -49,15 +49,15 @@
|
|||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-toast": "1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-email/components": "0.5.0",
|
||||
"@react-email/components": "0.5.3",
|
||||
"@react-email/render": "^1.2.0",
|
||||
"@react-email/tailwind": "1.2.2",
|
||||
"@simplewebauthn/browser": "^13.1.0",
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"@simplewebauthn/server": "^9.0.3",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"arctic": "^3.7.0",
|
||||
"axios": "1.11.0",
|
||||
"axios": "^1.12.2",
|
||||
"better-sqlite3": "11.7.0",
|
||||
"canvas-confetti": "1.9.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
@ -68,11 +68,11 @@
|
|||
"cookies": "^0.9.1",
|
||||
"cors": "2.8.5",
|
||||
"crypto-js": "^4.2.0",
|
||||
"drizzle-orm": "0.44.4",
|
||||
"eslint": "9.33.0",
|
||||
"eslint-config-next": "15.4.6",
|
||||
"drizzle-orm": "0.44.5",
|
||||
"eslint": "9.35.0",
|
||||
"eslint-config-next": "15.5.3",
|
||||
"express": "5.1.0",
|
||||
"express-rate-limit": "8.0.1",
|
||||
"express-rate-limit": "8.1.0",
|
||||
"glob": "11.0.3",
|
||||
"helmet": "8.1.0",
|
||||
"http-errors": "2.0.0",
|
||||
|
@ -81,30 +81,29 @@
|
|||
"jmespath": "^0.16.0",
|
||||
"js-yaml": "4.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "0.539.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"moment": "2.30.1",
|
||||
"next": "15.4.6",
|
||||
"next-intl": "^4.3.4",
|
||||
"next": "15.5.3",
|
||||
"next-intl": "^4.3.9",
|
||||
"next-themes": "0.4.6",
|
||||
"node-cache": "5.1.2",
|
||||
"node-fetch": "3.3.2",
|
||||
"nodemailer": "7.0.5",
|
||||
"npm": "^11.5.2",
|
||||
"nodemailer": "7.0.6",
|
||||
"npm": "^11.6.0",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "^8.16.2",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"react-easy-sort": "^1.6.0",
|
||||
"react-easy-sort": "^1.7.0",
|
||||
"react-hook-form": "7.62.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"rebuild": "0.1.2",
|
||||
"semver": "^7.7.2",
|
||||
"source-map-support": "0.5.21",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tailwind-merge": "3.3.1",
|
||||
"tw-animate-css": "^1.3.7",
|
||||
"uuid": "^11.1.0",
|
||||
"tw-animate-css": "^1.3.8",
|
||||
"uuid": "^13.0.0",
|
||||
"vaul": "1.1.2",
|
||||
"winston": "3.17.0",
|
||||
"winston-daily-rotate-file": "5.0.0",
|
||||
|
@ -114,9 +113,9 @@
|
|||
"zod-validation-error": "3.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "1.49.0",
|
||||
"@dotenvx/dotenvx": "1.49.1",
|
||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@types/better-sqlite3": "7.6.12",
|
||||
"@types/cookie-parser": "1.4.9",
|
||||
"@types/cors": "2.8.19",
|
||||
|
@ -126,25 +125,25 @@
|
|||
"@types/jmespath": "^0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24",
|
||||
"@types/nodemailer": "6.4.17",
|
||||
"@types/node": "24.5.2",
|
||||
"@types/nodemailer": "7.0.1",
|
||||
"@types/pg": "8.15.5",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react": "19.1.13",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/yargs": "17.0.33",
|
||||
"drizzle-kit": "0.31.4",
|
||||
"esbuild": "0.25.9",
|
||||
"esbuild": "0.25.10",
|
||||
"esbuild-node-externals": "1.18.0",
|
||||
"postcss": "^8",
|
||||
"react-email": "4.2.8",
|
||||
"react-email": "4.2.11",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsx": "4.20.5",
|
||||
"typescript": "^5",
|
||||
"typescript-eslint": "^8.40.0"
|
||||
"typescript-eslint": "^8.44.0"
|
||||
},
|
||||
"overrides": {
|
||||
"emblor": {
|
||||
|
|
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 |
|
@ -101,7 +101,9 @@ export enum ActionsEnum {
|
|||
getApiKey = "getApiKey",
|
||||
createOrgDomain = "createOrgDomain",
|
||||
deleteOrgDomain = "deleteOrgDomain",
|
||||
restartOrgDomain = "restartOrgDomain"
|
||||
restartOrgDomain = "restartOrgDomain",
|
||||
updateOrgUser = "updateOrgUser",
|
||||
applyBlueprint = "applyBlueprint"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { join } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
import { db } from "@server/db";
|
||||
import { db, resources, siteResources } from "@server/db";
|
||||
import { exitNodes, sites } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { __DIRNAME } from "@server/lib/consts";
|
||||
|
@ -34,6 +34,44 @@ export async function getUniqueSiteName(orgId: string): Promise<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> {
|
||||
let loops = 0;
|
||||
const count = await db
|
||||
|
|
|
@ -50,3 +50,4 @@ function createDb() {
|
|||
|
||||
export const db = createDb();
|
||||
export default db;
|
||||
export type Transaction = Parameters<Parameters<typeof db["transaction"]>[0]>[0];
|
|
@ -71,6 +71,7 @@ export const resources = pgTable("resources", {
|
|||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
subdomain: varchar("subdomain"),
|
||||
fullDomain: varchar("fullDomain"),
|
||||
|
@ -95,6 +96,7 @@ export const resources = pgTable("resources", {
|
|||
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
headers: text("headers"), // comma-separated list of headers to add to the request
|
||||
});
|
||||
|
||||
export const targets = pgTable("targets", {
|
||||
|
@ -113,7 +115,9 @@ export const targets = pgTable("targets", {
|
|||
method: varchar("method"),
|
||||
port: integer("port").notNull(),
|
||||
internalPort: integer("internalPort"),
|
||||
enabled: boolean("enabled").notNull().default(true)
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
path: text("path"),
|
||||
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
||||
});
|
||||
|
||||
export const exitNodes = pgTable("exitNodes", {
|
||||
|
@ -127,7 +131,8 @@ export const exitNodes = pgTable("exitNodes", {
|
|||
maxConnections: integer("maxConnections"),
|
||||
online: boolean("online").notNull().default(false),
|
||||
lastPing: integer("lastPing"),
|
||||
type: text("type").default("gerbil") // gerbil, remoteExitNode
|
||||
type: text("type").default("gerbil"), // gerbil, remoteExitNode
|
||||
region: varchar("region")
|
||||
});
|
||||
|
||||
export const siteResources = pgTable("siteResources", { // this is for the clients
|
||||
|
@ -138,6 +143,7 @@ export const siteResources = pgTable("siteResources", { // this is for the clien
|
|||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
niceId: varchar("niceId").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
protocol: varchar("protocol").notNull(),
|
||||
proxyPort: integer("proxyPort").notNull(),
|
||||
|
@ -212,7 +218,8 @@ export const userOrgs = pgTable("userOrgs", {
|
|||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId),
|
||||
isOwner: boolean("isOwner").notNull().default(false)
|
||||
isOwner: boolean("isOwner").notNull().default(false),
|
||||
autoProvisioned: boolean("autoProvisioned").default(false)
|
||||
});
|
||||
|
||||
export const emailVerificationCodes = pgTable("emailVerificationCodes", {
|
||||
|
@ -458,6 +465,7 @@ export const idpOidcConfig = pgTable("idpOidcConfig", {
|
|||
idpId: integer("idpId")
|
||||
.notNull()
|
||||
.references(() => idp.idpId, { onDelete: "cascade" }),
|
||||
variant: varchar("variant").notNull().default("oidc"),
|
||||
clientId: varchar("clientId").notNull(),
|
||||
clientSecret: varchar("clientSecret").notNull(),
|
||||
authUrl: varchar("authUrl").notNull(),
|
||||
|
|
|
@ -18,6 +18,7 @@ function createDb() {
|
|||
|
||||
export const db = createDb();
|
||||
export default db;
|
||||
export type Transaction = Parameters<Parameters<typeof db["transaction"]>[0]>[0];
|
||||
|
||||
function checkFileExists(filePath: string): boolean {
|
||||
try {
|
||||
|
|
|
@ -77,6 +77,7 @@ export const resources = sqliteTable("resources", {
|
|||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: text("name").notNull(),
|
||||
subdomain: text("subdomain"),
|
||||
fullDomain: text("fullDomain"),
|
||||
|
@ -107,6 +108,7 @@ export const resources = sqliteTable("resources", {
|
|||
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
headers: text("headers"), // comma-separated list of headers to add to the request
|
||||
});
|
||||
|
||||
export const targets = sqliteTable("targets", {
|
||||
|
@ -125,7 +127,9 @@ export const targets = sqliteTable("targets", {
|
|||
method: text("method"),
|
||||
port: integer("port").notNull(),
|
||||
internalPort: integer("internalPort"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
path: text("path"),
|
||||
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
||||
});
|
||||
|
||||
export const exitNodes = sqliteTable("exitNodes", {
|
||||
|
@ -139,23 +143,28 @@ export const exitNodes = sqliteTable("exitNodes", {
|
|||
maxConnections: integer("maxConnections"),
|
||||
online: integer("online", { mode: "boolean" }).notNull().default(false),
|
||||
lastPing: integer("lastPing"),
|
||||
type: text("type").default("gerbil") // gerbil, remoteExitNode
|
||||
type: text("type").default("gerbil"), // gerbil, remoteExitNode
|
||||
region: text("region")
|
||||
});
|
||||
|
||||
export const siteResources = sqliteTable("siteResources", { // this is for the clients
|
||||
siteResourceId: integer("siteResourceId").primaryKey({ autoIncrement: true }),
|
||||
export const siteResources = sqliteTable("siteResources", {
|
||||
// this is for the clients
|
||||
siteResourceId: integer("siteResourceId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, { onDelete: "cascade" }),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: text("name").notNull(),
|
||||
protocol: text("protocol").notNull(),
|
||||
proxyPort: integer("proxyPort").notNull(),
|
||||
destinationPort: integer("destinationPort").notNull(),
|
||||
destinationIp: text("destinationIp").notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
|
||||
});
|
||||
|
||||
export const users = sqliteTable("user", {
|
||||
|
@ -259,7 +268,9 @@ export const clientSites = sqliteTable("clientSites", {
|
|||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, { onDelete: "cascade" }),
|
||||
isRelayed: integer("isRelayed", { mode: "boolean" }).notNull().default(false),
|
||||
isRelayed: integer("isRelayed", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
endpoint: text("endpoint")
|
||||
});
|
||||
|
||||
|
@ -317,7 +328,10 @@ export const userOrgs = sqliteTable("userOrgs", {
|
|||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId),
|
||||
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false)
|
||||
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
|
||||
autoProvisioned: integer("autoProvisioned", {
|
||||
mode: "boolean"
|
||||
}).default(false)
|
||||
});
|
||||
|
||||
export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
|
||||
|
@ -594,6 +608,7 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", {
|
|||
idpOauthConfigId: integer("idpOauthConfigId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
variant: text("variant").notNull().default("oidc"),
|
||||
idpId: integer("idpId")
|
||||
.notNull()
|
||||
.references(() => idp.idpId, { onDelete: "cascade" }),
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
#! /usr/bin/env node
|
||||
import "./extendZod.ts";
|
||||
import 'source-map-support/register.js'
|
||||
|
||||
import { runSetupFunctions } from "./setup";
|
||||
import { createApiServer } from "./apiServer";
|
||||
|
|
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(),
|
||||
rules: z.array(RuleSchema).optional()
|
||||
})
|
||||
.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";
|
||||
|
||||
// This is a placeholder value replaced by the build process
|
||||
export const APP_VERSION = "1.9.0";
|
||||
export const APP_VERSION = "1.10.1";
|
||||
|
||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||
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";
|
||||
|
||||
interface ExitNodeRequest {
|
||||
remoteType: string;
|
||||
remoteType?: string;
|
||||
localPath: string;
|
||||
method?: "POST" | "DELETE" | "GET" | "PUT";
|
||||
data?: any;
|
||||
|
|
|
@ -30,7 +30,8 @@ export async function listExitNodes(orgId: string, filterOnline = false) {
|
|||
maxConnections: exitNodes.maxConnections,
|
||||
online: exitNodes.online,
|
||||
lastPing: exitNodes.lastPing,
|
||||
type: exitNodes.type
|
||||
type: exitNodes.type,
|
||||
region: exitNodes.region
|
||||
})
|
||||
.from(exitNodes);
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
getValidCertificatesForDomains,
|
||||
getValidCertificatesForDomainsHybrid
|
||||
} from "./remoteCertificates";
|
||||
import { sendToExitNode } from "./exitNodeComms";
|
||||
|
||||
export class TraefikConfigManager {
|
||||
private intervalId: NodeJS.Timeout | null = null;
|
||||
|
@ -403,27 +404,11 @@ export class TraefikConfigManager {
|
|||
[exitNode] = await db.select().from(exitNodes).limit(1);
|
||||
}
|
||||
if (exitNode) {
|
||||
try {
|
||||
await axios.post(
|
||||
`${exitNode.reachableAt}/update-local-snis`,
|
||||
{ fullDomains: Array.from(domains) },
|
||||
{ headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (error) {
|
||||
// pull data out of the axios error to log
|
||||
if (axios.isAxiosError(error)) {
|
||||
logger.error("Error updating local SNI:", {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
url: error.config?.url,
|
||||
method: error.config?.method
|
||||
await sendToExitNode(exitNode, {
|
||||
localPath: "/update-local-snis",
|
||||
method: "POST",
|
||||
data: { fullDomains: Array.from(domains) }
|
||||
});
|
||||
} else {
|
||||
logger.error("Error updating local SNI:", error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.error(
|
||||
"No exit node found. Has gerbil registered yet?"
|
||||
|
|
|
@ -129,6 +129,40 @@ export function isValidDomain(domain: string): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
export function validateHeaders(headers: string): boolean {
|
||||
// Validate comma-separated headers in format "Header-Name: value"
|
||||
const headerPairs = headers.split(",").map((pair) => pair.trim());
|
||||
return headerPairs.every((pair) => {
|
||||
// Check if the pair contains exactly one colon
|
||||
const colonCount = (pair.match(/:/g) || []).length;
|
||||
if (colonCount !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const colonIndex = pair.indexOf(":");
|
||||
if (colonIndex === 0 || colonIndex === pair.length - 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const headerName = pair.substring(0, colonIndex).trim();
|
||||
const headerValue = pair.substring(colonIndex + 1).trim();
|
||||
|
||||
// Header name should not be empty and should contain valid characters
|
||||
// Header names are case-insensitive and can contain alphanumeric, hyphens
|
||||
const headerNameRegex = /^[a-zA-Z0-9\-_]+$/;
|
||||
if (!headerName || !headerNameRegex.test(headerName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Header value should not be empty and should not contain colons
|
||||
if (!headerValue || headerValue.includes(":")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const validTlds = [
|
||||
"AAA",
|
||||
"AARP",
|
||||
|
|
|
@ -19,6 +19,11 @@ export async function verifyApiKeySetResourceUsers(
|
|||
);
|
||||
}
|
||||
|
||||
if (apiKey.isRoot) {
|
||||
// Root keys can access any key in any org
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!req.apiKeyOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
|
@ -32,11 +37,6 @@ export async function verifyApiKeySetResourceUsers(
|
|||
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid user IDs"));
|
||||
}
|
||||
|
||||
if (apiKey.isRoot) {
|
||||
// Root keys can access any key in any org
|
||||
return next();
|
||||
}
|
||||
|
||||
if (userIds.length === 0) {
|
||||
return next();
|
||||
}
|
||||
|
|
|
@ -343,6 +343,12 @@ authenticated.get(
|
|||
verifyUserHasAction(ActionsEnum.getResource),
|
||||
resource.getResource
|
||||
);
|
||||
authenticated.get(
|
||||
"/org/:orgId/resource/:niceId",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.getResource),
|
||||
resource.getResource
|
||||
);
|
||||
authenticated.post(
|
||||
"/resource/:resourceId",
|
||||
verifyResourceAccess,
|
||||
|
@ -582,6 +588,14 @@ authenticated.put(
|
|||
user.createOrgUser
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/user/:userId",
|
||||
verifyOrgAccess,
|
||||
verifyUserAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateOrgUser),
|
||||
user.updateOrgUser
|
||||
);
|
||||
|
||||
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
|
||||
|
||||
authenticated.post(
|
||||
|
@ -932,7 +946,7 @@ authRouter.post(
|
|||
windowMs: 15 * 60 * 1000,
|
||||
max: 15,
|
||||
keyGenerator: (req) =>
|
||||
`requestEmailVerificationCode:${req.body.email || ipKeyGenerator(req.ip || "")}`,
|
||||
`requestEmailVerificationCode:${req.user?.email || ipKeyGenerator(req.ip || "")}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only request an email verification code ${15} times every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { db, idpOidcConfig } from "@server/db";
|
||||
import { domains, idp, orgDomains, users, idpOrg } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
@ -33,23 +33,21 @@ async function query(limit: number, offset: number) {
|
|||
idpId: idp.idpId,
|
||||
name: idp.name,
|
||||
type: idp.type,
|
||||
orgCount: sql<number>`count(${idpOrg.orgId})`
|
||||
variant: idpOidcConfig.variant,
|
||||
orgCount: sql<number>`count(${idpOrg.orgId})`,
|
||||
autoProvision: idp.autoProvision
|
||||
})
|
||||
.from(idp)
|
||||
.leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`)
|
||||
.groupBy(idp.idpId)
|
||||
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
|
||||
.groupBy(idp.idpId, idpOidcConfig.variant)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
return res;
|
||||
}
|
||||
|
||||
export type ListIdpsResponse = {
|
||||
idps: Array<{
|
||||
idpId: number;
|
||||
name: string;
|
||||
type: string;
|
||||
orgCount: number;
|
||||
}>;
|
||||
idps: Awaited<ReturnType<typeof query>>;
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
|
|
|
@ -24,7 +24,8 @@ import {
|
|||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyClientAccess,
|
||||
verifyClientsEnabled,
|
||||
verifyApiKeySiteResourceAccess
|
||||
verifyApiKeySiteResourceAccess,
|
||||
verifyOrgAccess
|
||||
} from "@server/middlewares";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { Router } from "express";
|
||||
|
@ -469,6 +470,21 @@ authenticated.get(
|
|||
user.listUsers
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/user",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.createOrgUser),
|
||||
user.createOrgUser
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/user/:userId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyUserAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateOrgUser),
|
||||
user.updateOrgUser
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/user/:userId",
|
||||
verifyApiKeyOrgAccess,
|
||||
|
@ -628,3 +644,10 @@ authenticated.post(
|
|||
verifyApiKeyHasAction(ActionsEnum.updateClient),
|
||||
client.updateClient
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/blueprint",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.applyBlueprint),
|
||||
org.applyBlueprint
|
||||
);
|
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
|
||||
} from "@server/lib/ip";
|
||||
import { selectBestExitNode, verifyExitNodeOrgAccess } from "@server/lib/exitNodes";
|
||||
import { fetchContainers } from "./dockerSocket";
|
||||
|
||||
export type ExitNodePingResult = {
|
||||
exitNodeId: number;
|
||||
|
@ -76,6 +77,15 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Docker socket enabled: ${oldSite.dockerSocketEnabled}`);
|
||||
|
||||
if (oldSite.dockerSocketEnabled) {
|
||||
logger.debug(
|
||||
"Site has docker socket enabled - requesting docker containers"
|
||||
);
|
||||
fetchContainers(newt.newtId);
|
||||
}
|
||||
|
||||
let siteSubnet = oldSite.subnet;
|
||||
let exitNodeIdToQuery = oldSite.exitNodeId;
|
||||
if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) {
|
||||
|
|
|
@ -2,6 +2,7 @@ import { MessageHandler } from "../ws";
|
|||
import logger from "@server/logger";
|
||||
import { dockerSocketCache } from "./dockerSocket";
|
||||
import { Newt } from "@server/db";
|
||||
import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint";
|
||||
|
||||
export const handleDockerStatusMessage: MessageHandler = async (context) => {
|
||||
const { message, client, sendToClient } = context;
|
||||
|
@ -57,4 +58,15 @@ export const handleDockerContainersMessage: MessageHandler = async (
|
|||
} else {
|
||||
logger.warn(`Newt ${newt.newtId} does not have Docker containers`);
|
||||
}
|
||||
|
||||
if (!newt.siteId) {
|
||||
logger.warn("Newt has no site!");
|
||||
return;
|
||||
}
|
||||
|
||||
await applyNewtDockerBlueprint(
|
||||
newt.siteId,
|
||||
newt.newtId,
|
||||
containers
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,3 +5,4 @@ export * from "./handleReceiveBandwidthMessage";
|
|||
export * from "./handleGetConfigMessage";
|
||||
export * from "./handleSocketMessages";
|
||||
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 "./listOrgs";
|
||||
export * from "./pickOrgDefaults";
|
||||
export * from "./applyBlueprint";
|
|
@ -21,6 +21,8 @@ import { subdomainSchema } from "@server/lib/schemas";
|
|||
import config from "@server/lib/config";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { build } from "@server/build";
|
||||
import { getUniqueResourceName } from "@server/db/names";
|
||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
|
||||
const createResourceParamsSchema = z
|
||||
.object({
|
||||
|
@ -193,76 +195,21 @@ async function createHttpResource(
|
|||
}
|
||||
|
||||
const { name, domainId } = parsedBody.data;
|
||||
let subdomain = parsedBody.data.subdomain;
|
||||
const subdomain = parsedBody.data.subdomain;
|
||||
|
||||
const [domainRes] = await db
|
||||
.select()
|
||||
.from(domains)
|
||||
.where(eq(domains.domainId, domainId))
|
||||
.leftJoin(
|
||||
orgDomains,
|
||||
and(eq(orgDomains.orgId, orgId), eq(orgDomains.domainId, domainId))
|
||||
);
|
||||
// Validate domain and construct full domain
|
||||
const domainResult = await validateAndConstructDomain(domainId, orgId, subdomain);
|
||||
|
||||
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) {
|
||||
if (!domainResult.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Domain with ID ${domainRes.domains.domainId} is not verified`
|
||||
domainResult.error
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let fullDomain = "";
|
||||
if (domainRes.domains.type == "ns") {
|
||||
if (subdomain) {
|
||||
fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
|
||||
} else {
|
||||
fullDomain = domainRes.domains.baseDomain;
|
||||
}
|
||||
} else if (domainRes.domains.type == "cname") {
|
||||
fullDomain = domainRes.domains.baseDomain;
|
||||
} else if (domainRes.domains.type == "wildcard") {
|
||||
if (subdomain) {
|
||||
// the subdomain cant have a dot in it
|
||||
const parsedSubdomain = subdomainSchema.safeParse(subdomain);
|
||||
if (!parsedSubdomain.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedSubdomain.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
|
||||
} else {
|
||||
fullDomain = domainRes.domains.baseDomain;
|
||||
}
|
||||
}
|
||||
|
||||
if (fullDomain === domainRes.domains.baseDomain) {
|
||||
subdomain = null;
|
||||
}
|
||||
|
||||
fullDomain = fullDomain.toLowerCase();
|
||||
const { fullDomain, subdomain: finalSubdomain } = domainResult;
|
||||
|
||||
logger.debug(`Full domain: ${fullDomain}`);
|
||||
|
||||
|
@ -283,15 +230,18 @@ async function createHttpResource(
|
|||
|
||||
let resource: Resource | undefined;
|
||||
|
||||
const niceId = await getUniqueResourceName(orgId);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const newResource = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
niceId,
|
||||
fullDomain,
|
||||
domainId,
|
||||
orgId,
|
||||
name,
|
||||
subdomain,
|
||||
subdomain: finalSubdomain,
|
||||
http: true,
|
||||
protocol: "tcp",
|
||||
ssl: true
|
||||
|
@ -391,10 +341,13 @@ async function createRawResource(
|
|||
|
||||
let resource: Resource | undefined;
|
||||
|
||||
const niceId = await getUniqueResourceName(orgId);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const newResource = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
niceId,
|
||||
orgId,
|
||||
name,
|
||||
http,
|
||||
|
|
|
@ -2,32 +2,72 @@ import { Request, Response, NextFunction } from "express";
|
|||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { Resource, resources, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import stoi from "@server/lib/stoi";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const getResourceSchema = z
|
||||
.object({
|
||||
resourceId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
.optional()
|
||||
.transform(stoi)
|
||||
.pipe(z.number().int().positive().optional())
|
||||
.optional(),
|
||||
niceId: z.string().optional(),
|
||||
orgId: z.string().optional()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type GetResourceResponse = Resource;
|
||||
async function query(resourceId?: number, niceId?: string, orgId?: string) {
|
||||
if (resourceId) {
|
||||
const [res] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
return res;
|
||||
} else if (niceId && orgId) {
|
||||
const [res] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(and(eq(resources.niceId, niceId), eq(resources.orgId, orgId)))
|
||||
.limit(1);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export type GetResourceResponse = NonNullable<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({
|
||||
method: "get",
|
||||
path: "/resource/{resourceId}",
|
||||
description: "Get a resource.",
|
||||
description: "Get a resource by resourceId.",
|
||||
tags: [OpenAPITags.Resource],
|
||||
request: {
|
||||
params: getResourceSchema
|
||||
params: z.object({
|
||||
resourceId: z.number()
|
||||
})
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
@ -48,29 +88,18 @@ export async function getResource(
|
|||
);
|
||||
}
|
||||
|
||||
const { resourceId } = parsedParams.data;
|
||||
const { resourceId, niceId, orgId } = parsedParams.data;
|
||||
|
||||
const [resp] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
const resource = resp;
|
||||
const resource = await query(resourceId, niceId, orgId);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found`
|
||||
)
|
||||
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: {
|
||||
...resource
|
||||
},
|
||||
return response<GetResourceResponse>(res, {
|
||||
data: resource,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource retrieved successfully",
|
||||
|
|
|
@ -32,6 +32,7 @@ export type GetResourceAuthInfoResponse = {
|
|||
url: string;
|
||||
whitelist: boolean;
|
||||
skipToIdpId: number | null;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export async function getResourceAuthInfo(
|
||||
|
@ -88,7 +89,8 @@ export async function getResourceAuthInfo(
|
|||
blockAccess: resource.blockAccess,
|
||||
url,
|
||||
whitelist: resource.emailWhitelistEnabled,
|
||||
skipToIdpId: resource.skipToIdpId
|
||||
skipToIdpId: resource.skipToIdpId,
|
||||
orgId: resource.orgId
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
|
|
@ -16,6 +16,7 @@ import logger from "@server/logger";
|
|||
import stoi from "@server/lib/stoi";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { warn } from "console";
|
||||
|
||||
const listResourcesParamsSchema = z
|
||||
.object({
|
||||
|
@ -54,7 +55,8 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
|
|||
protocol: resources.protocol,
|
||||
proxyPort: resources.proxyPort,
|
||||
enabled: resources.enabled,
|
||||
domainId: resources.domainId
|
||||
domainId: resources.domainId,
|
||||
niceId: resources.niceId
|
||||
})
|
||||
.from(resources)
|
||||
.leftJoin(
|
||||
|
|
|
@ -20,6 +20,8 @@ import { tlsNameSchema } from "@server/lib/schemas";
|
|||
import { subdomainSchema } from "@server/lib/schemas";
|
||||
import { registry } from "@server/openApi";
|
||||
import { OpenAPITags } from "@server/openApi";
|
||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
import { validateHeaders } from "@server/lib/validators";
|
||||
|
||||
const updateResourceParamsSchema = z
|
||||
.object({
|
||||
|
@ -44,7 +46,8 @@ const updateHttpResourceBodySchema = z
|
|||
stickySession: z.boolean().optional(),
|
||||
tlsServerName: z.string().nullable().optional(),
|
||||
setHostHeader: z.string().nullable().optional(),
|
||||
skipToIdpId: z.number().int().positive().nullable().optional()
|
||||
skipToIdpId: z.number().int().positive().nullable().optional(),
|
||||
headers: z.string().nullable().optional()
|
||||
})
|
||||
.strict()
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
|
@ -82,6 +85,18 @@ const updateHttpResourceBodySchema = z
|
|||
message:
|
||||
"Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header."
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.headers) {
|
||||
return validateHeaders(data.headers);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Invalid headers format. Use comma-separated format: 'Header-Name: value, Another-Header: another-value'. Header values cannot contain colons."
|
||||
}
|
||||
);
|
||||
|
||||
export type UpdateResourceResponse = Resource;
|
||||
|
@ -230,78 +245,19 @@ async function updateHttpResource(
|
|||
if (updateData.domainId) {
|
||||
const domainId = updateData.domainId;
|
||||
|
||||
const [domainRes] = await db
|
||||
.select()
|
||||
.from(domains)
|
||||
.where(eq(domains.domainId, domainId))
|
||||
.leftJoin(
|
||||
orgDomains,
|
||||
and(
|
||||
eq(orgDomains.orgId, resource.orgId),
|
||||
eq(orgDomains.domainId, domainId)
|
||||
)
|
||||
);
|
||||
// Validate domain and construct full domain
|
||||
const domainResult = await validateAndConstructDomain(domainId, resource.orgId, updateData.subdomain);
|
||||
|
||||
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) {
|
||||
if (!domainResult.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Domain with ID ${updateData.domainId} is not verified`
|
||||
domainResult.error
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let fullDomain = "";
|
||||
if (domainRes.domains.type == "ns") {
|
||||
if (updateData.subdomain) {
|
||||
fullDomain = `${updateData.subdomain}.${domainRes.domains.baseDomain}`;
|
||||
} else {
|
||||
fullDomain = domainRes.domains.baseDomain;
|
||||
}
|
||||
} else if (domainRes.domains.type == "cname") {
|
||||
fullDomain = domainRes.domains.baseDomain;
|
||||
} else if (domainRes.domains.type == "wildcard") {
|
||||
if (updateData.subdomain !== undefined) {
|
||||
// the subdomain cant have a dot in it
|
||||
const parsedSubdomain = subdomainSchema.safeParse(
|
||||
updateData.subdomain
|
||||
);
|
||||
if (!parsedSubdomain.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedSubdomain.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
fullDomain = `${updateData.subdomain}.${domainRes.domains.baseDomain}`;
|
||||
} else {
|
||||
fullDomain = domainRes.domains.baseDomain;
|
||||
}
|
||||
}
|
||||
|
||||
fullDomain = fullDomain.toLowerCase();
|
||||
const { fullDomain, subdomain: finalSubdomain } = domainResult;
|
||||
|
||||
logger.debug(`Full domain: ${fullDomain}`);
|
||||
|
||||
|
@ -332,9 +288,8 @@ async function updateHttpResource(
|
|||
.where(eq(resources.resourceId, resource.resourceId));
|
||||
}
|
||||
|
||||
if (fullDomain === domainRes.domains.baseDomain) {
|
||||
updateData.subdomain = null;
|
||||
}
|
||||
// Update the subdomain in the update data
|
||||
updateData.subdomain = finalSubdomain;
|
||||
}
|
||||
|
||||
const updatedResource = await db
|
||||
|
|
|
@ -139,7 +139,7 @@ export async function pickSiteDefaults(
|
|||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Organization retrieved successfully",
|
||||
message: "Site defaults chosen successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
|
@ -10,6 +10,7 @@ import { fromError } from "zod-validation-error";
|
|||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { addTargets } from "../client/targets";
|
||||
import { getUniqueSiteResourceName } from "@server/db/names";
|
||||
|
||||
const createSiteResourceParamsSchema = z
|
||||
.object({
|
||||
|
@ -121,11 +122,14 @@ export async function createSiteResource(
|
|||
);
|
||||
}
|
||||
|
||||
const niceId = await getUniqueSiteResourceName(orgId);
|
||||
|
||||
// Create the site resource
|
||||
const [newSiteResource] = await db
|
||||
.insert(siteResources)
|
||||
.values({
|
||||
siteId,
|
||||
niceId,
|
||||
orgId,
|
||||
name,
|
||||
protocol,
|
||||
|
|
|
@ -12,21 +12,72 @@ import { OpenAPITags, registry } from "@server/openApi";
|
|||
|
||||
const getSiteResourceParamsSchema = z
|
||||
.object({
|
||||
siteResourceId: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||
siteResourceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val ? Number(val) : undefined)
|
||||
.pipe(z.number().int().positive().optional())
|
||||
.optional(),
|
||||
siteId: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||
niceId: z.string().optional(),
|
||||
orgId: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type GetSiteResourceResponse = SiteResource;
|
||||
async function query(siteResourceId?: number, siteId?: number, niceId?: string, orgId?: string) {
|
||||
if (siteResourceId && siteId && orgId) {
|
||||
const [siteResource] = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(and(
|
||||
eq(siteResources.siteResourceId, siteResourceId),
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
))
|
||||
.limit(1);
|
||||
return siteResource;
|
||||
} else if (niceId && siteId && orgId) {
|
||||
const [siteResource] = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(and(
|
||||
eq(siteResources.niceId, niceId),
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
))
|
||||
.limit(1);
|
||||
return siteResource;
|
||||
}
|
||||
}
|
||||
|
||||
export type GetSiteResourceResponse = NonNullable<Awaited<ReturnType<typeof query>>>;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}",
|
||||
description: "Get a specific site resource.",
|
||||
description: "Get a specific site resource by siteResourceId.",
|
||||
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
||||
request: {
|
||||
params: getSiteResourceParamsSchema
|
||||
params: z.object({
|
||||
siteResourceId: z.number(),
|
||||
siteId: z.number(),
|
||||
orgId: z.string()
|
||||
})
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/site/{siteId}/resource/nice/{niceId}",
|
||||
description: "Get a specific site resource by niceId.",
|
||||
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
||||
request: {
|
||||
params: z.object({
|
||||
niceId: z.string(),
|
||||
siteId: z.number(),
|
||||
orgId: z.string()
|
||||
})
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
@ -47,18 +98,10 @@ export async function getSiteResource(
|
|||
);
|
||||
}
|
||||
|
||||
const { siteResourceId, siteId, orgId } = parsedParams.data;
|
||||
const { siteResourceId, siteId, niceId, orgId } = parsedParams.data;
|
||||
|
||||
// Get the site resource
|
||||
const [siteResource] = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(and(
|
||||
eq(siteResources.siteResourceId, siteResourceId),
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
))
|
||||
.limit(1);
|
||||
const siteResource = await query(siteResourceId, siteId, niceId, orgId);
|
||||
|
||||
if (!siteResource) {
|
||||
return next(
|
||||
|
|
|
@ -28,7 +28,7 @@ const updateSiteResourceSchema = z
|
|||
protocol: z.enum(["tcp", "udp"]).optional(),
|
||||
proxyPort: z.number().int().positive().optional(),
|
||||
destinationPort: z.number().int().positive().optional(),
|
||||
destinationIp: z.string().ip().optional(),
|
||||
destinationIp: z.string().optional(),
|
||||
enabled: z.boolean().optional()
|
||||
})
|
||||
.strict();
|
||||
|
|
|
@ -30,7 +30,9 @@ const createTargetSchema = z
|
|||
ip: z.string().refine(isTargetValid),
|
||||
method: z.string().optional().nullable(),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
enabled: z.boolean().default(true)
|
||||
enabled: z.boolean().default(true),
|
||||
path: z.string().optional().nullable(),
|
||||
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable()
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
@ -161,7 +163,7 @@ export async function createTarget(
|
|||
);
|
||||
}
|
||||
|
||||
const { internalPort, targetIps } = await pickPort(site.siteId!);
|
||||
const { internalPort, targetIps } = await pickPort(site.siteId!, db);
|
||||
|
||||
if (!internalPort) {
|
||||
return next(
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { db } from "@server/db";
|
||||
import { db, Transaction } from "@server/db";
|
||||
import { resources, targets } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const currentBannedPorts: number[] = [];
|
||||
|
||||
export async function pickPort(siteId: number): Promise<{
|
||||
export async function pickPort(siteId: number, trx: Transaction | typeof db): Promise<{
|
||||
internalPort: number;
|
||||
targetIps: string[];
|
||||
}> {
|
||||
|
@ -12,7 +12,7 @@ export async function pickPort(siteId: number): Promise<{
|
|||
const targetIps: string[] = [];
|
||||
const targetInternalPorts: number[] = [];
|
||||
|
||||
const targetsRes = await db
|
||||
const targetsRes = await trx
|
||||
.select()
|
||||
.from(targets)
|
||||
.where(eq(targets.siteId, siteId));
|
||||
|
|
|
@ -44,7 +44,9 @@ function queryTargets(resourceId: number) {
|
|||
enabled: targets.enabled,
|
||||
resourceId: targets.resourceId,
|
||||
siteId: targets.siteId,
|
||||
siteType: sites.type
|
||||
siteType: sites.type,
|
||||
path: targets.path,
|
||||
pathMatchType: targets.pathMatchType
|
||||
})
|
||||
.from(targets)
|
||||
.leftJoin(sites, eq(sites.siteId, targets.siteId))
|
||||
|
|
|
@ -26,7 +26,9 @@ const updateTargetBodySchema = z
|
|||
ip: z.string().refine(isTargetValid),
|
||||
method: z.string().min(1).max(10).optional().nullable(),
|
||||
port: z.number().int().min(1).max(65535).optional(),
|
||||
enabled: z.boolean().optional()
|
||||
enabled: z.boolean().optional(),
|
||||
path: z.string().optional().nullable(),
|
||||
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable()
|
||||
})
|
||||
.strict()
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
|
@ -153,7 +155,7 @@ export async function updateTarget(
|
|||
);
|
||||
}
|
||||
|
||||
const { internalPort, targetIps } = await pickPort(site.siteId!);
|
||||
const { internalPort, targetIps } = await pickPort(site.siteId!, db);
|
||||
|
||||
if (!internalPort) {
|
||||
return next(
|
||||
|
|
|
@ -54,7 +54,8 @@ export async function traefikConfigProvider(
|
|||
config.getRawConfig().traefik.site_types
|
||||
);
|
||||
|
||||
if (traefikConfig?.http?.middlewares) { // BECAUSE SOMETIMES THE CONFIG CAN BE EMPTY IF THERE IS NOTHING
|
||||
if (traefikConfig?.http?.middlewares) {
|
||||
// BECAUSE SOMETIMES THE CONFIG CAN BE EMPTY IF THERE IS NOTHING
|
||||
traefikConfig.http.middlewares[badgerMiddlewareName] = {
|
||||
plugin: {
|
||||
[badgerMiddlewareName]: {
|
||||
|
@ -104,11 +105,9 @@ export async function getTraefikConfig(
|
|||
};
|
||||
};
|
||||
|
||||
// Get all resources with related data
|
||||
const allResources = await db.transaction(async (tx) => {
|
||||
// Get resources with their targets and sites in a single optimized query
|
||||
// Start from sites on this exit node, then join to targets and resources
|
||||
const resourcesWithTargetsAndSites = await tx
|
||||
const resourcesWithTargetsAndSites = await db
|
||||
.select({
|
||||
// Resource fields
|
||||
resourceId: resources.resourceId,
|
||||
|
@ -124,6 +123,7 @@ export async function getTraefikConfig(
|
|||
tlsServerName: resources.tlsServerName,
|
||||
setHostHeader: resources.setHostHeader,
|
||||
enableProxy: resources.enableProxy,
|
||||
headers: resources.headers,
|
||||
// Target fields
|
||||
targetId: targets.targetId,
|
||||
targetEnabled: targets.enabled,
|
||||
|
@ -131,6 +131,9 @@ export async function getTraefikConfig(
|
|||
method: targets.method,
|
||||
port: targets.port,
|
||||
internalPort: targets.internalPort,
|
||||
path: targets.path,
|
||||
pathMatchType: targets.pathMatchType,
|
||||
|
||||
// Site fields
|
||||
siteId: sites.siteId,
|
||||
siteType: sites.type,
|
||||
|
@ -145,14 +148,11 @@ export async function getTraefikConfig(
|
|||
and(
|
||||
eq(targets.enabled, true),
|
||||
eq(resources.enabled, true),
|
||||
or(
|
||||
eq(sites.exitNodeId, exitNodeId),
|
||||
isNull(sites.exitNodeId)
|
||||
),
|
||||
or(eq(sites.exitNodeId, exitNodeId), isNull(sites.exitNodeId)),
|
||||
inArray(sites.type, siteTypes),
|
||||
config.getRawConfig().traefik.allow_raw_resources
|
||||
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
|
||||
: eq(resources.http, true),
|
||||
: eq(resources.http, true)
|
||||
)
|
||||
);
|
||||
|
||||
|
@ -161,9 +161,15 @@ export async function getTraefikConfig(
|
|||
|
||||
resourcesWithTargetsAndSites.forEach((row) => {
|
||||
const resourceId = row.resourceId;
|
||||
const targetPath = sanitizePath(row.path) || ""; // Handle null/undefined paths
|
||||
const pathMatchType = row.pathMatchType || "";
|
||||
|
||||
if (!resourcesMap.has(resourceId)) {
|
||||
resourcesMap.set(resourceId, {
|
||||
// Create a unique key combining resourceId and path+pathMatchType
|
||||
const pathKey = [targetPath, pathMatchType].filter(Boolean).join("-");
|
||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||
|
||||
if (!resourcesMap.has(mapKey)) {
|
||||
resourcesMap.set(mapKey, {
|
||||
resourceId: row.resourceId,
|
||||
fullDomain: row.fullDomain,
|
||||
ssl: row.ssl,
|
||||
|
@ -177,12 +183,15 @@ export async function getTraefikConfig(
|
|||
tlsServerName: row.tlsServerName,
|
||||
setHostHeader: row.setHostHeader,
|
||||
enableProxy: row.enableProxy,
|
||||
targets: []
|
||||
targets: [],
|
||||
headers: row.headers,
|
||||
path: row.path, // the targets will all have the same path
|
||||
pathMatchType: row.pathMatchType // the targets will all have the same pathMatchType
|
||||
});
|
||||
}
|
||||
|
||||
// Add target with its associated site data
|
||||
resourcesMap.get(resourceId).targets.push({
|
||||
resourcesMap.get(mapKey).targets.push({
|
||||
resourceId: row.resourceId,
|
||||
targetId: row.targetId,
|
||||
ip: row.ip,
|
||||
|
@ -200,10 +209,8 @@ export async function getTraefikConfig(
|
|||
});
|
||||
});
|
||||
|
||||
return Array.from(resourcesMap.values());
|
||||
});
|
||||
|
||||
if (!allResources.length) {
|
||||
// make sure we have at least one resource
|
||||
if (resourcesMap.size === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
@ -219,14 +226,15 @@ export async function getTraefikConfig(
|
|||
}
|
||||
};
|
||||
|
||||
for (const resource of allResources) {
|
||||
// get the key and the resource
|
||||
for (const [key, resource] of resourcesMap.entries()) {
|
||||
const targets = resource.targets;
|
||||
|
||||
const routerName = `${resource.resourceId}-router`;
|
||||
const serviceName = `${resource.resourceId}-service`;
|
||||
const routerName = `${key}-router`;
|
||||
const serviceName = `${key}-service`;
|
||||
const fullDomain = `${resource.fullDomain}`;
|
||||
const transportName = `${resource.resourceId}-transport`;
|
||||
const hostHeaderMiddlewareName = `${resource.resourceId}-host-header-middleware`;
|
||||
const transportName = `${key}-transport`;
|
||||
const headersMiddlewareName = `${key}-headers-middleware`;
|
||||
|
||||
if (!resource.enabled) {
|
||||
continue;
|
||||
|
@ -238,9 +246,6 @@ export async function getTraefikConfig(
|
|||
}
|
||||
|
||||
if (!resource.fullDomain) {
|
||||
logger.error(
|
||||
`Resource ${resource.resourceId} has no fullDomain`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
@ -296,16 +301,68 @@ export async function getTraefikConfig(
|
|||
const additionalMiddlewares =
|
||||
config.getRawConfig().traefik.additional_middlewares || [];
|
||||
|
||||
const routerMiddlewares = [
|
||||
badgerMiddlewareName,
|
||||
...additionalMiddlewares
|
||||
];
|
||||
|
||||
if (resource.headers && resource.headers.length > 0) {
|
||||
// if there are headers, parse them into an object
|
||||
const headersObj: { [key: string]: string } = {};
|
||||
const headersArr = resource.headers.split(",");
|
||||
for (const header of headersArr) {
|
||||
const [key, value] = header
|
||||
.split(":")
|
||||
.map((s: string) => s.trim());
|
||||
if (key && value) {
|
||||
headersObj[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (resource.setHostHeader) {
|
||||
headersObj["Host"] = resource.setHostHeader;
|
||||
}
|
||||
|
||||
// check if the object is not empty
|
||||
if (Object.keys(headersObj).length > 0) {
|
||||
// Add the headers middleware
|
||||
if (!config_output.http.middlewares) {
|
||||
config_output.http.middlewares = {};
|
||||
}
|
||||
config_output.http.middlewares[headersMiddlewareName] = {
|
||||
headers: {
|
||||
customRequestHeaders: headersObj
|
||||
}
|
||||
};
|
||||
|
||||
routerMiddlewares.push(headersMiddlewareName);
|
||||
}
|
||||
}
|
||||
|
||||
let rule = `Host(\`${fullDomain}\`)`;
|
||||
let priority = 100;
|
||||
if (resource.path && resource.pathMatchType) {
|
||||
priority += 1;
|
||||
// add path to rule based on match type
|
||||
if (resource.pathMatchType === "exact") {
|
||||
rule += ` && Path(\`${resource.path}\`)`;
|
||||
} else if (resource.pathMatchType === "prefix") {
|
||||
rule += ` && PathPrefix(\`${resource.path}\`)`;
|
||||
} else if (resource.pathMatchType === "regex") {
|
||||
rule += ` && PathRegexp(\`${resource.path}\`)`;
|
||||
}
|
||||
}
|
||||
|
||||
config_output.http.routers![routerName] = {
|
||||
entryPoints: [
|
||||
resource.ssl
|
||||
? config.getRawConfig().traefik.https_entrypoint
|
||||
: config.getRawConfig().traefik.http_entrypoint
|
||||
],
|
||||
middlewares: [badgerMiddlewareName, ...additionalMiddlewares],
|
||||
middlewares: routerMiddlewares,
|
||||
service: serviceName,
|
||||
rule: `Host(\`${fullDomain}\`)`,
|
||||
priority: 100,
|
||||
rule: rule,
|
||||
priority: priority,
|
||||
...(resource.ssl ? { tls } : {})
|
||||
};
|
||||
|
||||
|
@ -316,8 +373,8 @@ export async function getTraefikConfig(
|
|||
],
|
||||
middlewares: [redirectHttpsMiddlewareName],
|
||||
service: serviceName,
|
||||
rule: `Host(\`${fullDomain}\`)`,
|
||||
priority: 100
|
||||
rule: rule,
|
||||
priority: priority
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -334,7 +391,8 @@ export async function getTraefikConfig(
|
|||
targets as TargetWithSite[]
|
||||
).some((target: TargetWithSite) => target.site.online);
|
||||
|
||||
return (targets as TargetWithSite[])
|
||||
return (
|
||||
(targets as TargetWithSite[])
|
||||
.filter((target: TargetWithSite) => {
|
||||
if (!target.enabled) {
|
||||
return false;
|
||||
|
@ -382,7 +440,15 @@ export async function getTraefikConfig(
|
|||
url: `${target.method}://${ip}:${target.internalPort}`
|
||||
};
|
||||
}
|
||||
});
|
||||
})
|
||||
// filter out duplicates
|
||||
.filter(
|
||||
(v, i, a) =>
|
||||
a.findIndex(
|
||||
(t) => t && v && t.url === v.url
|
||||
) === i
|
||||
)
|
||||
);
|
||||
})(),
|
||||
...(resource.stickySession
|
||||
? {
|
||||
|
@ -413,27 +479,6 @@ export async function getTraefikConfig(
|
|||
serviceName
|
||||
].loadBalancer.serversTransport = transportName;
|
||||
}
|
||||
|
||||
// Add the host header middleware
|
||||
if (resource.setHostHeader) {
|
||||
if (!config_output.http.middlewares) {
|
||||
config_output.http.middlewares = {};
|
||||
}
|
||||
config_output.http.middlewares[hostHeaderMiddlewareName] = {
|
||||
headers: {
|
||||
customRequestHeaders: {
|
||||
Host: resource.setHostHeader
|
||||
}
|
||||
}
|
||||
};
|
||||
if (!config_output.http.routers![routerName].middlewares) {
|
||||
config_output.http.routers![routerName].middlewares = [];
|
||||
}
|
||||
config_output.http.routers![routerName].middlewares = [
|
||||
...config_output.http.routers![routerName].middlewares,
|
||||
hostHeaderMiddlewareName
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// Non-HTTP (TCP/UDP) configuration
|
||||
if (!resource.enableProxy) {
|
||||
|
@ -529,3 +574,13 @@ export async function getTraefikConfig(
|
|||
}
|
||||
return config_output;
|
||||
}
|
||||
|
||||
function sanitizePath(path: string | null | undefined): string | undefined {
|
||||
if (!path) return undefined;
|
||||
// clean any non alphanumeric characters from the path and replace with dashes
|
||||
// the path cant be too long either, so limit to 50 characters
|
||||
if (path.length > 50) {
|
||||
path = path.substring(0, 50);
|
||||
}
|
||||
return path.replace(/[^a-zA-Z0-9]/g, "");
|
||||
}
|
||||
|
|
|
@ -84,7 +84,14 @@ export async function createOrgUser(
|
|||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const { username, email, name, type, idpId, roleId } = parsedBody.data;
|
||||
const {
|
||||
username,
|
||||
email,
|
||||
name,
|
||||
type,
|
||||
idpId,
|
||||
roleId
|
||||
} = parsedBody.data;
|
||||
|
||||
const [role] = await db
|
||||
.select()
|
||||
|
@ -141,7 +148,12 @@ export async function createOrgUser(
|
|||
const [existingUser] = await trx
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username));
|
||||
.where(
|
||||
and(
|
||||
eq(users.username, username),
|
||||
eq(users.idpId, idpId)
|
||||
)
|
||||
);
|
||||
|
||||
if (existingUser) {
|
||||
const [existingOrgUser] = await trx
|
||||
|
@ -168,7 +180,8 @@ export async function createOrgUser(
|
|||
.values({
|
||||
orgId,
|
||||
userId: existingUser.userId,
|
||||
roleId: role.roleId
|
||||
roleId: role.roleId,
|
||||
autoProvisioned: false
|
||||
})
|
||||
.returning();
|
||||
} else {
|
||||
|
@ -184,7 +197,7 @@ export async function createOrgUser(
|
|||
type: "oidc",
|
||||
idpId,
|
||||
dateCreated: new Date().toISOString(),
|
||||
emailVerified: true
|
||||
emailVerified: true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
@ -193,7 +206,8 @@ export async function createOrgUser(
|
|||
.values({
|
||||
orgId,
|
||||
userId: newUser.userId,
|
||||
roleId: role.roleId
|
||||
roleId: role.roleId,
|
||||
autoProvisioned: false
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
|
@ -204,7 +218,6 @@ export async function createOrgUser(
|
|||
.from(userOrgs)
|
||||
.where(eq(userOrgs.orgId, orgId));
|
||||
});
|
||||
|
||||
} else {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "User type is required")
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { db, idp, idpOidcConfig } from "@server/db";
|
||||
import { roles, userOrgs, users } from "@server/db";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
|
@ -25,10 +25,18 @@ async function queryUser(orgId: string, userId: string) {
|
|||
isOwner: userOrgs.isOwner,
|
||||
isAdmin: roles.isAdmin,
|
||||
twoFactorEnabled: users.twoFactorEnabled,
|
||||
autoProvisioned: userOrgs.autoProvisioned,
|
||||
idpId: users.idpId,
|
||||
idpName: idp.name,
|
||||
idpType: idp.type,
|
||||
idpVariant: idpOidcConfig.variant,
|
||||
idpAutoProvision: idp.autoProvision
|
||||
})
|
||||
.from(userOrgs)
|
||||
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
||||
.leftJoin(users, eq(userOrgs.userId, users.userId))
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
.leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId))
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||
.limit(1);
|
||||
if (typeof user.roles === "string") {
|
||||
|
|
|
@ -14,3 +14,4 @@ export * from "./removeInvitation";
|
|||
export * from "./createOrgUser";
|
||||
export * from "./adminUpdateUser2FA";
|
||||
export * from "./adminGetUser";
|
||||
export * from "./updateOrgUser";
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { db, idpOidcConfig } from "@server/db";
|
||||
import { idp, roles, userOrgs, users } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
@ -61,12 +61,15 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
|
|||
isOwner: userOrgs.isOwner,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId,
|
||||
idpType: idp.type,
|
||||
idpVariant: idpOidcConfig.variant,
|
||||
twoFactorEnabled: users.twoFactorEnabled,
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
|
||||
.where(eq(userOrgs.orgId, orgId))
|
||||
.groupBy(users.userId)
|
||||
.limit(limit)
|
||||
|
|
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,
|
||||
handleDockerStatusMessage,
|
||||
handleDockerContainersMessage,
|
||||
handleNewtPingRequestMessage
|
||||
handleNewtPingRequestMessage,
|
||||
handleApplyBlueprintMessage
|
||||
} from "../newt";
|
||||
import {
|
||||
handleOlmRegisterMessage,
|
||||
|
@ -23,7 +24,8 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
|||
"olm/ping": handleOlmPingMessage,
|
||||
"newt/socket/status": handleDockerStatusMessage,
|
||||
"newt/socket/containers": handleDockerContainersMessage,
|
||||
"newt/ping/request": handleNewtPingRequestMessage
|
||||
"newt/ping/request": handleNewtPingRequestMessage,
|
||||
"newt/blueprint/apply": handleApplyBlueprintMessage,
|
||||
};
|
||||
|
||||
startOlmOfflineChecker(); // this is to handle the offline check for olms
|
||||
|
|
|
@ -9,6 +9,7 @@ import m1 from "./scriptsPg/1.6.0";
|
|||
import m2 from "./scriptsPg/1.7.0";
|
||||
import m3 from "./scriptsPg/1.8.0";
|
||||
import m4 from "./scriptsPg/1.9.0";
|
||||
import m5 from "./scriptsPg/1.10.0";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
|
@ -18,7 +19,8 @@ const migrations = [
|
|||
{ version: "1.6.0", run: m1 },
|
||||
{ version: "1.7.0", run: m2 },
|
||||
{ version: "1.8.0", run: m3 },
|
||||
{ version: "1.9.0", run: m4 }
|
||||
{ version: "1.9.0", run: m4 },
|
||||
{ version: "1.10.0", run: m5 },
|
||||
// Add new migrations here as they are created
|
||||
] as {
|
||||
version: string;
|
||||
|
|
|
@ -26,6 +26,8 @@ import m21 from "./scriptsSqlite/1.6.0";
|
|||
import m22 from "./scriptsSqlite/1.7.0";
|
||||
import m23 from "./scriptsSqlite/1.8.0";
|
||||
import m24 from "./scriptsSqlite/1.9.0";
|
||||
import m25 from "./scriptsSqlite/1.10.0";
|
||||
import m26 from "./scriptsSqlite/1.10.1";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
|
@ -51,6 +53,8 @@ const migrations = [
|
|||
{ version: "1.7.0", run: m22 },
|
||||
{ version: "1.8.0", run: m23 },
|
||||
{ version: "1.9.0", run: m24 },
|
||||
{ version: "1.10.0", run: m25 },
|
||||
{ version: "1.10.1", run: m26 },
|
||||
// Add new migrations here as they are created
|
||||
] as const;
|
||||
|
||||
|
|
147
server/setup/scriptsPg/1.10.0.ts
Normal file
147
server/setup/scriptsPg/1.10.0.ts
Normal file
|
@ -0,0 +1,147 @@
|
|||
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}
|
||||
`);
|
||||
}
|
||||
|
||||
// Handle auto-provisioned users for identity providers
|
||||
const autoProvisionIdps = await db.execute(sql`
|
||||
SELECT "idpId" FROM "idp" WHERE "autoProvision" = true
|
||||
`);
|
||||
|
||||
for (const idp of autoProvisionIdps.rows) {
|
||||
// Get all users with this identity provider
|
||||
const usersWithIdp = await db.execute(sql`
|
||||
SELECT "id" FROM "user" WHERE "idpId" = ${idp.idpId}
|
||||
`);
|
||||
|
||||
// Update userOrgs to set autoProvisioned to true for these users
|
||||
for (const user of usersWithIdp.rows) {
|
||||
await db.execute(sql`
|
||||
UPDATE "userOrgs" SET "autoProvisioned" = true WHERE "userId" = ${user.id}
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
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, "");
|
||||
}
|
136
server/setup/scriptsSqlite/1.10.0.ts
Normal file
136
server/setup/scriptsSqlite/1.10.0.ts
Normal file
|
@ -0,0 +1,136 @@
|
|||
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);
|
||||
|
||||
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 'siteResources' 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;
|
||||
`); // 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);
|
||||
}
|
||||
|
||||
// Handle auto-provisioned users for identity providers
|
||||
const autoProvisionIdps = db
|
||||
.prepare(
|
||||
"SELECT idpId FROM idp WHERE autoProvision = 1"
|
||||
)
|
||||
.all() as Array<{ idpId: number }>;
|
||||
|
||||
for (const idp of autoProvisionIdps) {
|
||||
// Get all users with this identity provider
|
||||
const usersWithIdp = db
|
||||
.prepare(
|
||||
"SELECT id FROM user WHERE idpId = ?"
|
||||
)
|
||||
.all(idp.idpId) as Array<{ id: string }>;
|
||||
|
||||
// Update userOrgs to set autoProvisioned to true for these users
|
||||
for (const user of usersWithIdp) {
|
||||
db.prepare(
|
||||
"UPDATE userOrgs SET autoProvisioned = 1 WHERE userId = ?"
|
||||
).run(user.id);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
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, "");
|
||||
}
|
69
server/setup/scriptsSqlite/1.10.1.ts
Normal file
69
server/setup/scriptsSqlite/1.10.1.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { APP_PATH } from "@server/lib/consts";
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
|
||||
const version = "1.10.1";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||
const db = new Database(location);
|
||||
|
||||
try {
|
||||
db.pragma("foreign_keys = OFF");
|
||||
|
||||
db.transaction(() => {
|
||||
db.exec(`ALTER TABLE "targets" RENAME TO "targets_old";
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "targets" (
|
||||
"targetId" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"resourceId" INTEGER NOT NULL,
|
||||
"siteId" INTEGER NOT NULL,
|
||||
"ip" TEXT NOT NULL,
|
||||
"method" TEXT,
|
||||
"port" INTEGER NOT NULL,
|
||||
"internalPort" INTEGER,
|
||||
"enabled" INTEGER NOT NULL DEFAULT 1,
|
||||
"path" TEXT,
|
||||
"pathMatchType" TEXT,
|
||||
FOREIGN KEY ("resourceId") REFERENCES "resources"("resourceId") ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY ("siteId") REFERENCES "sites"("siteId") ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "targets" (
|
||||
"targetId",
|
||||
"resourceId",
|
||||
"siteId",
|
||||
"ip",
|
||||
"method",
|
||||
"port",
|
||||
"internalPort",
|
||||
"enabled",
|
||||
"path",
|
||||
"pathMatchType"
|
||||
)
|
||||
SELECT
|
||||
targetId,
|
||||
resourceId,
|
||||
siteId,
|
||||
ip,
|
||||
method,
|
||||
port,
|
||||
internalPort,
|
||||
enabled,
|
||||
path,
|
||||
pathMatchType
|
||||
FROM "targets_old";
|
||||
--> statement-breakpoint
|
||||
DROP TABLE "targets_old";`);
|
||||
})();
|
||||
|
||||
db.pragma("foreign_keys = ON");
|
||||
|
||||
console.log(`Migrated database`);
|
||||
} catch (e) {
|
||||
console.log("Failed to migrate db:", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { cache } from "react";
|
||||
import OrganizationLandingCard from "./OrganizationLandingCard";
|
||||
import MemberResourcesPortal from "./MemberResourcesPortal";
|
||||
import OrganizationLandingCard from "../../components/OrganizationLandingCard";
|
||||
import MemberResourcesPortal from "../../components/MemberResourcesPortal";
|
||||
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { AxiosResponse } from "axios";
|
||||
import InvitationsTable, { InvitationRow } from "./InvitationsTable";
|
||||
import InvitationsTable, { InvitationRow } from "../../../../../components/InvitationsTable";
|
||||
import { GetOrgResponse } from "@server/routers/org";
|
||||
import { cache } from "react";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav";
|
||||
import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
interface AccessLayoutProps {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{
|
||||
resourceId: number | string;
|
||||
orgId: string;
|
||||
}>;
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import { GetOrgResponse } from "@server/routers/org";
|
|||
import { cache } from "react";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import { ListRolesResponse } from "@server/routers/role";
|
||||
import RolesTable, { RoleRow } from "./RolesTable";
|
||||
import RolesTable, { RoleRow } from "../../../../../components/RolesTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@ import {
|
|||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SetUserRolesResponse } from "@server/routers/user";
|
||||
|
@ -34,6 +35,8 @@ import { createApiClient } from "@app/lib/api";
|
|||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import IdpTypeBadge from "@app/components/IdpTypeBadge";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
export default function AccessControlsPage() {
|
||||
const { orgUser: user } = userOrgUserContext();
|
||||
|
@ -61,14 +64,16 @@ export default function AccessControlsPage() {
|
|||
text: z.string()
|
||||
})
|
||||
)
|
||||
.min(1, { message: t('accessRoleSelectPlease') })
|
||||
.min(1, { message: t('accessRoleSelectPlease') }),
|
||||
autoProvisioned: z.boolean()
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
username: user.username!,
|
||||
roles: []
|
||||
roles: [],
|
||||
autoProvisioned: user.autoProvisioned || false
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -80,10 +85,10 @@ export default function AccessControlsPage() {
|
|||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('accessRoleErrorFetch'),
|
||||
title: t("accessRoleErrorFetch"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('accessRoleErrorFetchDescription')
|
||||
t("accessRoleErrorFetchDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
@ -107,31 +112,38 @@ export default function AccessControlsPage() {
|
|||
text: i.name
|
||||
}))
|
||||
);
|
||||
form.setValue("autoProvisioned", user.autoProvisioned || false);
|
||||
}, []);
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setLoading(true);
|
||||
|
||||
const res = await api
|
||||
.post<
|
||||
AxiosResponse<SetUserRolesResponse>
|
||||
>(`/org/${user.orgId}/user/${user.userId}/roles`, { roleIds: values.roles.map((r) => parseInt(r.id)) })
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('accessRoleErrorAdd'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('accessRoleErrorAddDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
try {
|
||||
// Execute both API calls simultaneously
|
||||
const [roleRes, userRes] = await Promise.all([
|
||||
api.post<AxiosResponse<SetUserRolesResponse>>(`/org/${user.orgId}/user/${user.userId}/roles`, {
|
||||
roleIds: values.roles.map((r) => parseInt(r.id)) }
|
||||
),
|
||||
api.post(`/org/${orgId}/user/${user.userId}`, {
|
||||
autoProvisioned: values.autoProvisioned
|
||||
})
|
||||
]);
|
||||
|
||||
if (res && res.status === 200) {
|
||||
if (roleRes.status === 200 && userRes.status === 200) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t('userSaved'),
|
||||
description: t('userSavedDescription')
|
||||
title: t("userSaved"),
|
||||
description: t("userSavedDescription")
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("accessRoleErrorAdd"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("accessRoleErrorAddDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -142,9 +154,11 @@ export default function AccessControlsPage() {
|
|||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>{t('accessControls')}</SettingsSectionTitle>
|
||||
<SettingsSectionTitle>
|
||||
{t("accessControls")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t('accessControlsDescription')}
|
||||
{t("accessControlsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
|
@ -156,12 +170,29 @@ export default function AccessControlsPage() {
|
|||
className="space-y-4"
|
||||
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
|
||||
control={form.control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>Roles</FormLabel>
|
||||
<FormLabel>{t('roles')}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
|
@ -184,6 +215,10 @@ export default function AccessControlsPage() {
|
|||
...Tag[]
|
||||
]
|
||||
);
|
||||
// If auto provision is enabled, set it to false when role changes
|
||||
if (user.idpAutoProvision) {
|
||||
form.setValue("autoProvisioned", false);
|
||||
}
|
||||
}}
|
||||
enableAutocomplete={true}
|
||||
autocompleteOptions={
|
||||
|
@ -200,6 +235,35 @@ export default function AccessControlsPage() {
|
|||
</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>
|
||||
</SettingsSectionForm>
|
||||
|
@ -212,7 +276,7 @@ export default function AccessControlsPage() {
|
|||
disabled={loading}
|
||||
form="access-controls-form"
|
||||
>
|
||||
{t('accessControlsSubmit')}
|
||||
{t("accessControlsSubmit")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
|
|
@ -46,6 +46,7 @@ import { Checkbox } from "@app/components/ui/checkbox";
|
|||
import { ListIdpsResponse } from "@server/routers/idp";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
import Image from "next/image";
|
||||
|
||||
type UserType = "internal" | "oidc";
|
||||
|
||||
|
@ -53,6 +54,17 @@ interface IdpOption {
|
|||
idpId: number;
|
||||
name: 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() {
|
||||
|
@ -62,14 +74,14 @@ export default function Page() {
|
|||
const api = createApiClient({ env });
|
||||
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 [loading, setLoading] = useState(false);
|
||||
const [expiresInDays, setExpiresInDays] = useState(1);
|
||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
||||
const [idps, setIdps] = useState<IdpOption[]>([]);
|
||||
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 internalFormSchema = z.object({
|
||||
|
@ -80,7 +92,13 @@ export default function Page() {
|
|||
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") }),
|
||||
email: z
|
||||
.string()
|
||||
|
@ -88,19 +106,51 @@ export default function Page() {
|
|||
.optional()
|
||||
.or(z.literal("")),
|
||||
name: z.string().optional(),
|
||||
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }),
|
||||
idpId: z.string().min(1, { message: t("idpSelectPlease") })
|
||||
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
|
||||
});
|
||||
|
||||
const formatIdpType = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case "oidc":
|
||||
return t("idpGenericOidc");
|
||||
case "google":
|
||||
return t("idpGoogleDescription");
|
||||
case "azure":
|
||||
return t("idpAzureDescription");
|
||||
default:
|
||||
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 = [
|
||||
{ hours: 24, name: t("day", { count: 1 }) },
|
||||
{ hours: 48, name: t("day", { count: 2 }) },
|
||||
|
@ -120,45 +170,39 @@ export default function Page() {
|
|||
}
|
||||
});
|
||||
|
||||
const externalForm = useForm<z.infer<typeof externalFormSchema>>({
|
||||
resolver: zodResolver(externalFormSchema),
|
||||
const googleAzureForm = useForm<z.infer<typeof googleAzureFormSchema>>({
|
||||
resolver: zodResolver(googleAzureFormSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
name: "",
|
||||
roleId: ""
|
||||
}
|
||||
});
|
||||
|
||||
const genericOidcForm = useForm<z.infer<typeof genericOidcFormSchema>>({
|
||||
resolver: zodResolver(genericOidcFormSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
email: "",
|
||||
name: "",
|
||||
roleId: "",
|
||||
idpId: ""
|
||||
roleId: ""
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (userType === "internal") {
|
||||
if (selectedOption === "internal") {
|
||||
setSendEmail(env.email.emailEnabled);
|
||||
internalForm.reset();
|
||||
setInviteLink(null);
|
||||
setExpiresInDays(1);
|
||||
} else if (userType === "oidc") {
|
||||
externalForm.reset();
|
||||
} else if (selectedOption && selectedOption !== "internal") {
|
||||
googleAzureForm.reset();
|
||||
genericOidcForm.reset();
|
||||
}
|
||||
}, [userType, env.email.emailEnabled, internalForm, externalForm]);
|
||||
|
||||
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
|
||||
}
|
||||
]);
|
||||
}, [selectedOption, env.email.emailEnabled, internalForm, googleAzureForm, genericOidcForm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userType) {
|
||||
if (!selectedOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -199,20 +243,6 @@ export default function Page() {
|
|||
|
||||
if (res?.status === 200) {
|
||||
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 +256,33 @@ export default function Page() {
|
|||
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(
|
||||
values: z.infer<typeof internalFormSchema>
|
||||
) {
|
||||
|
@ -274,9 +331,52 @@ export default function Page() {
|
|||
setLoading(false);
|
||||
}
|
||||
|
||||
async function onSubmitExternal(
|
||||
values: z.infer<typeof externalFormSchema>
|
||||
async function onSubmitGoogleAzure(
|
||||
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);
|
||||
|
||||
const res = await api
|
||||
|
@ -285,7 +385,7 @@ export default function Page() {
|
|||
email: values.email,
|
||||
name: values.name,
|
||||
type: "oidc",
|
||||
idpId: parseInt(values.idpId),
|
||||
idpId: selectedUserOption.idpId,
|
||||
roleId: parseInt(values.roleId)
|
||||
})
|
||||
.catch((e) => {
|
||||
|
@ -330,7 +430,7 @@ export default function Page() {
|
|||
|
||||
<div>
|
||||
<SettingsContainer>
|
||||
{!inviteLink && build !== "saas" ? (
|
||||
{!inviteLink && build !== "saas" && dataLoaded ? (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
|
@ -342,15 +442,15 @@ export default function Page() {
|
|||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<StrategySelect
|
||||
options={userTypes}
|
||||
defaultValue={userType || undefined}
|
||||
options={userOptions}
|
||||
defaultValue={selectedOption || undefined}
|
||||
onChange={(value) => {
|
||||
setUserType(value as UserType);
|
||||
setSelectedOption(value);
|
||||
if (value === "internal") {
|
||||
internalForm.reset();
|
||||
} else if (value === "oidc") {
|
||||
externalForm.reset();
|
||||
setSelectedIdp(null);
|
||||
} else {
|
||||
googleAzureForm.reset();
|
||||
genericOidcForm.reset();
|
||||
}
|
||||
}}
|
||||
cols={2}
|
||||
|
@ -359,7 +459,7 @@ export default function Page() {
|
|||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
{userType === "internal" && dataLoaded && (
|
||||
{selectedOption === "internal" && dataLoaded && (
|
||||
<>
|
||||
{!inviteLink ? (
|
||||
<SettingsSection>
|
||||
|
@ -564,71 +664,7 @@ export default function Page() {
|
|||
</>
|
||||
)}
|
||||
|
||||
{userType !== "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 && (
|
||||
{selectedOption && selectedOption !== "internal" && dataLoaded && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
|
@ -640,52 +676,26 @@ export default function Page() {
|
|||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...externalForm}>
|
||||
{/* Google/Azure Form */}
|
||||
{(() => {
|
||||
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
|
||||
return selectedUserOption?.variant === "google" || selectedUserOption?.variant === "azure";
|
||||
})() && (
|
||||
<Form {...googleAzureForm}>
|
||||
<form
|
||||
onSubmit={externalForm.handleSubmit(
|
||||
onSubmitExternal
|
||||
onSubmit={googleAzureForm.handleSubmit(
|
||||
onSubmitGoogleAzure
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="create-user-form"
|
||||
>
|
||||
<FormField
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={
|
||||
externalForm.control
|
||||
}
|
||||
control={googleAzureForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"emailOptional"
|
||||
)}
|
||||
{t("email")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
@ -698,16 +708,12 @@ export default function Page() {
|
|||
/>
|
||||
|
||||
<FormField
|
||||
control={
|
||||
externalForm.control
|
||||
}
|
||||
control={googleAzureForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"nameOptional"
|
||||
)}
|
||||
{t("nameOptional")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
@ -720,9 +726,7 @@ export default function Page() {
|
|||
/>
|
||||
|
||||
<FormField
|
||||
control={
|
||||
externalForm.control
|
||||
}
|
||||
control={googleAzureForm.control}
|
||||
name="roleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
|
@ -730,36 +734,24 @@ export default function Page() {
|
|||
{t("role")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"accessRoleSelect"
|
||||
)}
|
||||
placeholder={t("accessRoleSelect")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{roles.map(
|
||||
(
|
||||
role
|
||||
) => (
|
||||
{roles.map((role) => (
|
||||
<SelectItem
|
||||
key={
|
||||
role.roleId
|
||||
}
|
||||
key={role.roleId}
|
||||
value={role.roleId.toString()}
|
||||
>
|
||||
{
|
||||
role.name
|
||||
}
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
|
@ -768,16 +760,122 @@ export default function Page() {
|
|||
/>
|
||||
</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"
|
||||
id="create-user-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>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-8">
|
||||
{userType && dataLoaded && (
|
||||
{selectedOption && dataLoaded && (
|
||||
<Button
|
||||
type={inviteLink ? "button" : "submit"}
|
||||
form={inviteLink ? undefined : "create-user-form"}
|
||||
|
|
|
@ -2,13 +2,13 @@ import { internal } from "@app/lib/api";
|
|||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { ListUsersResponse } from "@server/routers/user";
|
||||
import { AxiosResponse } from "axios";
|
||||
import UsersTable, { UserRow } from "./UsersTable";
|
||||
import UsersTable, { UserRow } from "../../../../../components/UsersTable";
|
||||
import { GetOrgResponse } from "@server/routers/org";
|
||||
import { cache } from "react";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav";
|
||||
import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
|
@ -77,6 +77,7 @@ export default async function UsersPage(props: UsersPageProps) {
|
|||
name: user.name,
|
||||
email: user.email,
|
||||
type: user.type,
|
||||
idpVariant: user.idpVariant,
|
||||
idpId: user.idpId,
|
||||
idpName: user.idpName || t('idpNameInternal'),
|
||||
status: t('userConfirmed'),
|
||||
|
|
|
@ -210,6 +210,11 @@ export default function Page() {
|
|||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault(); // block default enter refresh
|
||||
}
|
||||
}}
|
||||
className="space-y-4"
|
||||
id="create-site-form"
|
||||
>
|
||||
|
|
|
@ -2,7 +2,7 @@ import { internal } from "@app/lib/api";
|
|||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { AxiosResponse } from "axios";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import OrgApiKeysTable, { OrgApiKeyRow } from "./OrgApiKeysTable";
|
||||
import OrgApiKeysTable, { OrgApiKeyRow } from "../../../../components/OrgApiKeysTable";
|
||||
import { ListOrgApiKeysResponse } from "@server/routers/apiKeys";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
|
|
|
@ -3,14 +3,14 @@ import { AxiosResponse } from "axios";
|
|||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { GetClientResponse } from "@server/routers/client";
|
||||
import ClientInfoCard from "./ClientInfoCard";
|
||||
import ClientInfoCard from "../../../../../components/ClientInfoCard";
|
||||
import ClientProvider from "@app/providers/ClientProvider";
|
||||
import { redirect } from "next/navigation";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
|
||||
type SettingsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ clientId: number; orgId: string }>;
|
||||
params: Promise<{ clientId: number | string; orgId: string }>;
|
||||
}
|
||||
|
||||
export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function ClientPage(props: {
|
||||
params: Promise<{ orgId: string; clientId: number }>;
|
||||
params: Promise<{ orgId: string; clientId: number | string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
redirect(`/${params.orgId}/settings/clients/${params.clientId}/general`);
|
||||
|
|
|
@ -42,6 +42,10 @@ import {
|
|||
FaFreebsd,
|
||||
FaWindows
|
||||
} from "react-icons/fa";
|
||||
import {
|
||||
SiNixos,
|
||||
SiKubernetes
|
||||
} from "react-icons/si";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
@ -248,10 +252,14 @@ export default function Page() {
|
|||
return <FaApple className="h-4 w-4 mr-2" />;
|
||||
case "docker":
|
||||
return <FaDocker className="h-4 w-4 mr-2" />;
|
||||
case "kubernetes":
|
||||
return <SiKubernetes className="h-4 w-4 mr-2" />;
|
||||
case "podman":
|
||||
return <FaCubes className="h-4 w-4 mr-2" />;
|
||||
case "freebsd":
|
||||
return <FaFreebsd className="h-4 w-4 mr-2" />;
|
||||
case "nixos":
|
||||
return <SiNixos className="h-4 w-4 mr-2" />;
|
||||
default:
|
||||
return <Terminal className="h-4 w-4 mr-2" />;
|
||||
}
|
||||
|
@ -440,6 +448,11 @@ export default function Page() {
|
|||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault(); // block default enter refresh
|
||||
}
|
||||
}}
|
||||
className="space-y-4"
|
||||
id="create-client-form"
|
||||
>
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { ClientRow } from "./ClientsTable";
|
||||
import { ClientRow } from "../../../../components/ClientsTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { ListClientsResponse } from "@server/routers/client";
|
||||
import ClientsTable from "./ClientsTable";
|
||||
import ClientsTable from "../../../../components/ClientsTable";
|
||||
|
||||
type ClientsPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue