mirror of
https://github.com/fosrl/pangolin.git
synced 2025-08-18 08:18:43 +02:00
Merge branch 'main' into copilot/fix-1112
This commit is contained in:
commit
e9e6b0bc4f
77 changed files with 4113 additions and 1256 deletions
|
@ -42,10 +42,6 @@ _Pangolin tunnels your services to the internet so you can access anything from
|
||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href='https://www.ycombinator.com/launches/O0B-pangolin-open-source-secure-gateway-to-private-networks' target="_blank"><img src='https://www.ycombinator.com/launches/O0B-pangolin-open-source-secure-gateway-to-private-networks/upvote_embed.svg' alt='Launch YC: Pangolin – Open-source secure gateway to private networks'/ ></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
Pangolin is a self-hosted tunneled reverse proxy server with identity and access control, designed to securely expose private resources on distributed networks. Acting as a central hub, it connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports.
|
Pangolin is a self-hosted tunneled reverse proxy server with identity and access control, designed to securely expose private resources on distributed networks. Acting as a central hub, it connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports.
|
||||||
|
|
||||||
<img src="public/screenshots/hero.png" alt="Preview"/>
|
<img src="public/screenshots/hero.png" alt="Preview"/>
|
||||||
|
|
|
@ -31,6 +31,7 @@ services:
|
||||||
- SYS_MODULE
|
- SYS_MODULE
|
||||||
ports:
|
ports:
|
||||||
- 51820:51820/udp
|
- 51820:51820/udp
|
||||||
|
- 21820:21820/udp
|
||||||
- 443:443 # Port for traefik because of the network_mode
|
- 443:443 # Port for traefik because of the network_mode
|
||||||
- 80:80 # Port for traefik because of the network_mode
|
- 80:80 # Port for traefik because of the network_mode
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
all: update-versions go-build-release put-back
|
all: update-versions go-build-release put-back
|
||||||
|
dev-all: dev-update-versions dev-build dev-clean
|
||||||
|
|
||||||
go-build-release:
|
go-build-release:
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/installer_linux_amd64
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/installer_linux_amd64
|
||||||
|
@ -11,6 +12,12 @@ clean:
|
||||||
update-versions:
|
update-versions:
|
||||||
@echo "Fetching latest versions..."
|
@echo "Fetching latest versions..."
|
||||||
cp main.go main.go.bak && \
|
cp main.go main.go.bak && \
|
||||||
|
$(MAKE) dev-update-versions
|
||||||
|
|
||||||
|
put-back:
|
||||||
|
mv main.go.bak main.go
|
||||||
|
|
||||||
|
dev-update-versions:
|
||||||
PANGOLIN_VERSION=$$(curl -s https://api.github.com/repos/fosrl/pangolin/tags | jq -r '.[0].name') && \
|
PANGOLIN_VERSION=$$(curl -s https://api.github.com/repos/fosrl/pangolin/tags | jq -r '.[0].name') && \
|
||||||
GERBIL_VERSION=$$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') && \
|
GERBIL_VERSION=$$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') && \
|
||||||
BADGER_VERSION=$$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') && \
|
BADGER_VERSION=$$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') && \
|
||||||
|
@ -20,5 +27,11 @@ update-versions:
|
||||||
sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$$BADGER_VERSION\"/" main.go && \
|
sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$$BADGER_VERSION\"/" main.go && \
|
||||||
echo "Updated main.go with latest versions"
|
echo "Updated main.go with latest versions"
|
||||||
|
|
||||||
put-back:
|
dev-build: go-build-release
|
||||||
mv main.go.bak main.go
|
|
||||||
|
dev-clean:
|
||||||
|
@echo "Restoring version values ..."
|
||||||
|
sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"replaceme\"/" main.go && \
|
||||||
|
sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"replaceme\"/" main.go && \
|
||||||
|
sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"replaceme\"/" main.go
|
||||||
|
@echo "Restored version strings in main.go"
|
||||||
|
|
|
@ -22,10 +22,6 @@ gerbil:
|
||||||
start_port: 51820
|
start_port: 51820
|
||||||
base_endpoint: "{{.DashboardDomain}}"
|
base_endpoint: "{{.DashboardDomain}}"
|
||||||
|
|
||||||
orgs:
|
|
||||||
block_size: 24
|
|
||||||
subnet_group: 100.89.138.0/20
|
|
||||||
|
|
||||||
{{if .EnableEmail}}
|
{{if .EnableEmail}}
|
||||||
email:
|
email:
|
||||||
smtp_host: "{{.EmailSMTPHost}}"
|
smtp_host: "{{.EmailSMTPHost}}"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
services:
|
services:
|
||||||
crowdsec:
|
crowdsec:
|
||||||
image: crowdsecurity/crowdsec:latest
|
image: docker.io/crowdsecurity/crowdsec:latest
|
||||||
container_name: crowdsec
|
container_name: crowdsec
|
||||||
environment:
|
environment:
|
||||||
GID: "1000"
|
GID: "1000"
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
name: pangolin
|
name: pangolin
|
||||||
services:
|
services:
|
||||||
pangolin:
|
pangolin:
|
||||||
image: fosrl/pangolin:{{.PangolinVersion}}
|
image: docker.io/fosrl/pangolin:{{.PangolinVersion}}
|
||||||
container_name: pangolin
|
container_name: pangolin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
|
@ -13,7 +13,7 @@ services:
|
||||||
retries: 15
|
retries: 15
|
||||||
{{if .InstallGerbil}}
|
{{if .InstallGerbil}}
|
||||||
gerbil:
|
gerbil:
|
||||||
image: fosrl/gerbil:{{.GerbilVersion}}
|
image: docker.io/fosrl/gerbil:{{.GerbilVersion}}
|
||||||
container_name: gerbil
|
container_name: gerbil
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
|
@ -31,11 +31,12 @@ services:
|
||||||
- SYS_MODULE
|
- SYS_MODULE
|
||||||
ports:
|
ports:
|
||||||
- 51820:51820/udp
|
- 51820:51820/udp
|
||||||
|
- 21820:21820/udp
|
||||||
- 443:443 # Port for traefik because of the network_mode
|
- 443:443 # Port for traefik because of the network_mode
|
||||||
- 80:80 # Port for traefik because of the network_mode
|
- 80:80 # Port for traefik because of the network_mode
|
||||||
{{end}}
|
{{end}}
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:v3.4.1
|
image: docker.io/traefik:v3.4.1
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
{{if .InstallGerbil}}
|
{{if .InstallGerbil}}
|
||||||
|
|
|
@ -13,7 +13,7 @@ import (
|
||||||
|
|
||||||
func installCrowdsec(config Config) error {
|
func installCrowdsec(config Config) error {
|
||||||
|
|
||||||
if err := stopContainers(); err != nil {
|
if err := stopContainers(config.InstallationContainerType); err != nil {
|
||||||
return fmt.Errorf("failed to stop containers: %v", err)
|
return fmt.Errorf("failed to stop containers: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -72,12 +72,12 @@ func installCrowdsec(config Config) error {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := startContainers(); err != nil {
|
if err := startContainers(config.InstallationContainerType); err != nil {
|
||||||
return fmt.Errorf("failed to start containers: %v", err)
|
return fmt.Errorf("failed to start containers: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// get API key
|
// get API key
|
||||||
apiKey, err := GetCrowdSecAPIKey()
|
apiKey, err := GetCrowdSecAPIKey(config.InstallationContainerType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get API key: %v", err)
|
return fmt.Errorf("failed to get API key: %v", err)
|
||||||
}
|
}
|
||||||
|
@ -87,7 +87,7 @@ func installCrowdsec(config Config) error {
|
||||||
return fmt.Errorf("failed to replace bouncer key: %v", err)
|
return fmt.Errorf("failed to replace bouncer key: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := restartContainer("traefik"); err != nil {
|
if err := restartContainer("traefik", config.InstallationContainerType); err != nil {
|
||||||
return fmt.Errorf("failed to restart containers: %v", err)
|
return fmt.Errorf("failed to restart containers: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -110,9 +110,9 @@ func checkIsCrowdsecInstalledInCompose() bool {
|
||||||
return bytes.Contains(content, []byte("crowdsec:"))
|
return bytes.Contains(content, []byte("crowdsec:"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCrowdSecAPIKey() (string, error) {
|
func GetCrowdSecAPIKey(containerType SupportedContainer) (string, error) {
|
||||||
// First, ensure the container is running
|
// First, ensure the container is running
|
||||||
if err := waitForContainer("crowdsec"); err != nil {
|
if err := waitForContainer("crowdsec", containerType); err != nil {
|
||||||
return "", fmt.Errorf("waiting for container: %w", err)
|
return "", fmt.Errorf("waiting for container: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
docker
|
||||||
example.com
|
example.com
|
||||||
pangolin.example.com
|
pangolin.example.com
|
||||||
admin@example.com
|
admin@example.com
|
||||||
|
|
177
install/main.go
177
install/main.go
|
@ -7,17 +7,17 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"math/rand"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"os/user"
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
"math/rand"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
@ -33,6 +33,7 @@ func loadVersions(config *Config) {
|
||||||
var configFiles embed.FS
|
var configFiles embed.FS
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
InstallationContainerType SupportedContainer
|
||||||
PangolinVersion string
|
PangolinVersion string
|
||||||
GerbilVersion string
|
GerbilVersion string
|
||||||
BadgerVersion string
|
BadgerVersion string
|
||||||
|
@ -51,9 +52,74 @@ type Config struct {
|
||||||
Secret string
|
Secret string
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
type SupportedContainer string
|
||||||
reader := bufio.NewReader(os.Stdin)
|
|
||||||
|
|
||||||
|
const (
|
||||||
|
Docker SupportedContainer = "docker"
|
||||||
|
Podman SupportedContainer = "podman"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
|
||||||
|
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
|
||||||
|
|
||||||
|
fmt.Println("Welcome to the Pangolin installer!")
|
||||||
|
fmt.Println("This installer will help you set up Pangolin on your server.")
|
||||||
|
fmt.Println("")
|
||||||
|
fmt.Println("Please make sure you have the following prerequisites:")
|
||||||
|
fmt.Println("- Open TCP ports 80 and 443 and UDP ports 51820 and 21820 on your VPS and firewall.")
|
||||||
|
fmt.Println("- Point your domain to the VPS IP with A records.")
|
||||||
|
fmt.Println("")
|
||||||
|
fmt.Println("http://docs.fossorial.io/Getting%20Started/dns-networking")
|
||||||
|
fmt.Println("")
|
||||||
|
fmt.Println("Lets get started!")
|
||||||
|
fmt.Println("")
|
||||||
|
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
inputContainer := readString(reader, "Would you like to run Pangolin as Docker or Podman containers?", "docker")
|
||||||
|
|
||||||
|
chosenContainer := Docker
|
||||||
|
if strings.EqualFold(inputContainer, "docker") {
|
||||||
|
chosenContainer = Docker
|
||||||
|
} else if strings.EqualFold(inputContainer, "podman") {
|
||||||
|
chosenContainer = Podman
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Unrecognized container type: %s. Valid options are 'docker' or 'podman'.\n", inputContainer)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if chosenContainer == Podman {
|
||||||
|
if !isPodmanInstalled() {
|
||||||
|
fmt.Println("Podman or podman-compose is not installed. Please install both manually. Automated installation will be available in a later release.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := exec.Command("bash", "-c", "cat /etc/sysctl.conf | grep 'net.ipv4.ip_unprivileged_port_start='").Run(); err != nil {
|
||||||
|
fmt.Println("Would you like to configure ports >= 80 as unprivileged ports? This enables podman containers to listen on low-range ports.")
|
||||||
|
fmt.Println("Pangolin will experience startup issues if this is not configured, because it needs to listen on port 80/443 by default.")
|
||||||
|
approved := readBool(reader, "The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' >> /etc/sysctl.conf && sysctl -p\". Approve?", true)
|
||||||
|
if approved {
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
fmt.Println("You need to run the installer as root for such a configuration.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Podman containers are not able to listen on privileged ports. The official recommendation is to
|
||||||
|
// container low-range ports as unprivileged ports.
|
||||||
|
// Linux only.
|
||||||
|
|
||||||
|
if err := run("bash", "-c", "echo 'net.ipv4.ip_unprivileged_port_start=80' >> /etc/sysctl.conf && sysctl -p"); err != nil {
|
||||||
|
fmt.Sprintf("failed to configure unprivileged ports: %v.\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("You need to configure port forwarding or adjust the listening ports before running pangolin.")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("Unprivileged ports have been configured.")
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if chosenContainer == Docker {
|
||||||
// check if docker is not installed and the user is root
|
// check if docker is not installed and the user is root
|
||||||
if !isDockerInstalled() {
|
if !isDockerInstalled() {
|
||||||
if os.Geteuid() != 0 {
|
if os.Geteuid() != 0 {
|
||||||
|
@ -68,8 +134,13 @@ func main() {
|
||||||
fmt.Println("The installer will not be able to run docker commands without running it as root.")
|
fmt.Println("The installer will not be able to run docker commands without running it as root.")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// This shouldn't happen unless there's a third container runtime.
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
var config Config
|
var config Config
|
||||||
|
config.InstallationContainerType = chosenContainer
|
||||||
|
|
||||||
// check if there is already a config file
|
// check if there is already a config file
|
||||||
if _, err := os.Stat("config/config.yml"); err != nil {
|
if _, err := os.Stat("config/config.yml"); err != nil {
|
||||||
|
@ -86,7 +157,7 @@ func main() {
|
||||||
|
|
||||||
moveFile("config/docker-compose.yml", "docker-compose.yml")
|
moveFile("config/docker-compose.yml", "docker-compose.yml")
|
||||||
|
|
||||||
if !isDockerInstalled() && runtime.GOOS == "linux" {
|
if !isDockerInstalled() && runtime.GOOS == "linux" && chosenContainer == Docker {
|
||||||
if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
|
if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
|
||||||
installDocker()
|
installDocker()
|
||||||
// try to start docker service but ignore errors
|
// try to start docker service but ignore errors
|
||||||
|
@ -115,14 +186,15 @@ func main() {
|
||||||
|
|
||||||
fmt.Println("\n=== Starting installation ===")
|
fmt.Println("\n=== Starting installation ===")
|
||||||
|
|
||||||
if isDockerInstalled() {
|
if (isDockerInstalled() && chosenContainer == Docker) ||
|
||||||
|
(isPodmanInstalled() && chosenContainer == Podman) {
|
||||||
if readBool(reader, "Would you like to install and start the containers?", true) {
|
if readBool(reader, "Would you like to install and start the containers?", true) {
|
||||||
if err := pullContainers(); err != nil {
|
if err := pullContainers(chosenContainer); err != nil {
|
||||||
fmt.Println("Error: ", err)
|
fmt.Println("Error: ", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := startContainers(); err != nil {
|
if err := startContainers(chosenContainer); err != nil {
|
||||||
fmt.Println("Error: ", err)
|
fmt.Println("Error: ", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -137,6 +209,8 @@ func main() {
|
||||||
// check if crowdsec is installed
|
// check if crowdsec is installed
|
||||||
if readBool(reader, "Would you like to install CrowdSec?", false) {
|
if readBool(reader, "Would you like to install CrowdSec?", false) {
|
||||||
fmt.Println("This installer constitutes a minimal viable CrowdSec deployment. CrowdSec will add extra complexity to your Pangolin installation and may not work to the best of its abilities out of the box. Users are expected to implement configuration adjustments on their own to achieve the best security posture. Consult the CrowdSec documentation for detailed configuration instructions.")
|
fmt.Println("This installer constitutes a minimal viable CrowdSec deployment. CrowdSec will add extra complexity to your Pangolin installation and may not work to the best of its abilities out of the box. Users are expected to implement configuration adjustments on their own to achieve the best security posture. Consult the CrowdSec documentation for detailed configuration instructions.")
|
||||||
|
|
||||||
|
// BUG: crowdsec installation will be skipped if the user chooses to install on the first installation.
|
||||||
if readBool(reader, "Are you willing to manage CrowdSec?", false) {
|
if readBool(reader, "Are you willing to manage CrowdSec?", false) {
|
||||||
if config.DashboardDomain == "" {
|
if config.DashboardDomain == "" {
|
||||||
traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml", "config/traefik/dynamic_config.yml")
|
traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml", "config/traefik/dynamic_config.yml")
|
||||||
|
@ -240,7 +314,7 @@ func collectUserInput(reader *bufio.Reader) Config {
|
||||||
config.EmailSMTPHost = readString(reader, "Enter SMTP host", "")
|
config.EmailSMTPHost = readString(reader, "Enter SMTP host", "")
|
||||||
config.EmailSMTPPort = readInt(reader, "Enter SMTP port (default 587)", 587)
|
config.EmailSMTPPort = readInt(reader, "Enter SMTP port (default 587)", 587)
|
||||||
config.EmailSMTPUser = readString(reader, "Enter SMTP username", "")
|
config.EmailSMTPUser = readString(reader, "Enter SMTP username", "")
|
||||||
config.EmailSMTPPass = readString(reader, "Enter SMTP password", "")
|
config.EmailSMTPPass = readString(reader, "Enter SMTP password", "") // Should this be readPassword?
|
||||||
config.EmailNoReply = readString(reader, "Enter no-reply email address", "")
|
config.EmailNoReply = readString(reader, "Enter no-reply email address", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -330,7 +404,6 @@ func createConfigFiles(config Config) error {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error walking config files: %v", err)
|
return fmt.Errorf("error walking config files: %v", err)
|
||||||
}
|
}
|
||||||
|
@ -456,7 +529,15 @@ func startDockerService() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func isDockerInstalled() bool {
|
func isDockerInstalled() bool {
|
||||||
cmd := exec.Command("docker", "--version")
|
return isContainerInstalled("docker")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPodmanInstalled() bool {
|
||||||
|
return isContainerInstalled("podman") && isContainerInstalled("podman-compose")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isContainerInstalled(container string) bool {
|
||||||
|
cmd := exec.Command(container, "--version")
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
@ -533,9 +614,17 @@ func executeDockerComposeCommandWithArgs(args ...string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// pullContainers pulls the containers using the appropriate command.
|
// pullContainers pulls the containers using the appropriate command.
|
||||||
func pullContainers() error {
|
func pullContainers(containerType SupportedContainer) error {
|
||||||
fmt.Println("Pulling the container images...")
|
fmt.Println("Pulling the container images...")
|
||||||
|
if containerType == Podman {
|
||||||
|
if err := run("podman-compose", "-f", "docker-compose.yml", "pull"); err != nil {
|
||||||
|
return fmt.Errorf("failed to pull the containers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if containerType == Docker {
|
||||||
if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "pull", "--policy", "always"); err != nil {
|
if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "pull", "--policy", "always"); err != nil {
|
||||||
return fmt.Errorf("failed to pull the containers: %v", err)
|
return fmt.Errorf("failed to pull the containers: %v", err)
|
||||||
}
|
}
|
||||||
|
@ -543,9 +632,22 @@ func pullContainers() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("Unsupported container type: %s", containerType)
|
||||||
|
}
|
||||||
|
|
||||||
// startContainers starts the containers using the appropriate command.
|
// startContainers starts the containers using the appropriate command.
|
||||||
func startContainers() error {
|
func startContainers(containerType SupportedContainer) error {
|
||||||
fmt.Println("Starting containers...")
|
fmt.Println("Starting containers...")
|
||||||
|
|
||||||
|
if containerType == Podman {
|
||||||
|
if err := run("podman-compose", "-f", "docker-compose.yml", "up", "-d", "--force-recreate"); err != nil {
|
||||||
|
return fmt.Errorf("failed start containers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if containerType == Docker {
|
||||||
if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "up", "-d", "--force-recreate"); err != nil {
|
if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "up", "-d", "--force-recreate"); err != nil {
|
||||||
return fmt.Errorf("failed to start containers: %v", err)
|
return fmt.Errorf("failed to start containers: %v", err)
|
||||||
}
|
}
|
||||||
|
@ -553,10 +655,21 @@ func startContainers() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// stopContainers stops the containers using the appropriate command.
|
return fmt.Errorf("Unsupported container type: %s", containerType)
|
||||||
func stopContainers() error {
|
}
|
||||||
fmt.Println("Stopping containers...")
|
|
||||||
|
|
||||||
|
// stopContainers stops the containers using the appropriate command.
|
||||||
|
func stopContainers(containerType SupportedContainer) error {
|
||||||
|
fmt.Println("Stopping containers...")
|
||||||
|
if containerType == Podman {
|
||||||
|
if err := run("podman-compose", "-f", "docker-compose.yml", "down"); err != nil {
|
||||||
|
return fmt.Errorf("failed to stop containers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if containerType == Docker {
|
||||||
if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "down"); err != nil {
|
if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "down"); err != nil {
|
||||||
return fmt.Errorf("failed to stop containers: %v", err)
|
return fmt.Errorf("failed to stop containers: %v", err)
|
||||||
}
|
}
|
||||||
|
@ -564,10 +677,21 @@ func stopContainers() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// restartContainer restarts a specific container using the appropriate command.
|
return fmt.Errorf("Unsupported container type: %s", containerType)
|
||||||
func restartContainer(container string) error {
|
}
|
||||||
fmt.Println("Restarting containers...")
|
|
||||||
|
|
||||||
|
// restartContainer restarts a specific container using the appropriate command.
|
||||||
|
func restartContainer(container string, containerType SupportedContainer) error {
|
||||||
|
fmt.Println("Restarting containers...")
|
||||||
|
if containerType == Podman {
|
||||||
|
if err := run("podman-compose", "-f", "docker-compose.yml", "restart"); err != nil {
|
||||||
|
return fmt.Errorf("failed to stop the container \"%s\": %v", container, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if containerType == Docker {
|
||||||
if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "restart", container); err != nil {
|
if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "restart", container); err != nil {
|
||||||
return fmt.Errorf("failed to stop the container \"%s\": %v", container, err)
|
return fmt.Errorf("failed to stop the container \"%s\": %v", container, err)
|
||||||
}
|
}
|
||||||
|
@ -575,6 +699,9 @@ func restartContainer(container string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("Unsupported container type: %s", containerType)
|
||||||
|
}
|
||||||
|
|
||||||
func copyFile(src, dst string) error {
|
func copyFile(src, dst string) error {
|
||||||
source, err := os.Open(src)
|
source, err := os.Open(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -600,13 +727,13 @@ func moveFile(src, dst string) error {
|
||||||
return os.Remove(src)
|
return os.Remove(src)
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForContainer(containerName string) error {
|
func waitForContainer(containerName string, containerType SupportedContainer) error {
|
||||||
maxAttempts := 30
|
maxAttempts := 30
|
||||||
retryInterval := time.Second * 2
|
retryInterval := time.Second * 2
|
||||||
|
|
||||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||||
// Check if container is running
|
// Check if container is running
|
||||||
cmd := exec.Command("docker", "container", "inspect", "-f", "{{.State.Running}}", containerName)
|
cmd := exec.Command(string(containerType), "container", "inspect", "-f", "{{.State.Running}}", containerName)
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
cmd.Stdout = &out
|
cmd.Stdout = &out
|
||||||
|
|
||||||
|
@ -641,3 +768,11 @@ func generateRandomSecretKey() string {
|
||||||
}
|
}
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run external commands with stdio/stderr attached.
|
||||||
|
func run(name string, args ...string) error {
|
||||||
|
cmd := exec.Command(name, args...)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
|
@ -59,7 +59,6 @@
|
||||||
"siteErrorCreate": "Chyba při vytváření lokality",
|
"siteErrorCreate": "Chyba při vytváření lokality",
|
||||||
"siteErrorCreateKeyPair": "Nebyly nalezeny klíče nebo výchozí nastavení lokality",
|
"siteErrorCreateKeyPair": "Nebyly nalezeny klíče nebo výchozí nastavení lokality",
|
||||||
"siteErrorCreateDefaults": "Výchozí nastavení lokality nenalezeno",
|
"siteErrorCreateDefaults": "Výchozí nastavení lokality nenalezeno",
|
||||||
"siteNameDescription": "Toto je zobrazovaný název lokality.",
|
|
||||||
"method": "Způsob",
|
"method": "Způsob",
|
||||||
"siteMethodDescription": "Tímto způsobem budete vystavovat spojení.",
|
"siteMethodDescription": "Tímto způsobem budete vystavovat spojení.",
|
||||||
"siteLearnNewt": "Naučte se, jak nainstalovat Newt na svůj systém",
|
"siteLearnNewt": "Naučte se, jak nainstalovat Newt na svůj systém",
|
||||||
|
@ -1094,7 +1093,7 @@
|
||||||
"sidebarAllUsers": "All Users",
|
"sidebarAllUsers": "All Users",
|
||||||
"sidebarIdentityProviders": "Identity Providers",
|
"sidebarIdentityProviders": "Identity Providers",
|
||||||
"sidebarLicense": "License",
|
"sidebarLicense": "License",
|
||||||
"sidebarClients": "Clients",
|
"sidebarClients": "Clients (Beta)",
|
||||||
"sidebarDomains": "Domains",
|
"sidebarDomains": "Domains",
|
||||||
"enableDockerSocket": "Enable Docker Socket",
|
"enableDockerSocket": "Enable Docker Socket",
|
||||||
"enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.",
|
"enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.",
|
||||||
|
@ -1162,7 +1161,7 @@
|
||||||
"selectDomainTypeCnameName": "Single Domain (CNAME)",
|
"selectDomainTypeCnameName": "Single Domain (CNAME)",
|
||||||
"selectDomainTypeCnameDescription": "Just this specific domain. Use this for individual subdomains or specific domain entries.",
|
"selectDomainTypeCnameDescription": "Just this specific domain. Use this for individual subdomains or specific domain entries.",
|
||||||
"selectDomainTypeWildcardName": "Wildcard Domain",
|
"selectDomainTypeWildcardName": "Wildcard Domain",
|
||||||
"selectDomainTypeWildcardDescription": "This domain and its first level of subdomains.",
|
"selectDomainTypeWildcardDescription": "This domain and its subdomains.",
|
||||||
"domainDelegation": "Single Domain",
|
"domainDelegation": "Single Domain",
|
||||||
"selectType": "Select a type",
|
"selectType": "Select a type",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
|
@ -1196,7 +1195,7 @@
|
||||||
"sidebarExpand": "Expand",
|
"sidebarExpand": "Expand",
|
||||||
"newtUpdateAvailable": "Update Available",
|
"newtUpdateAvailable": "Update Available",
|
||||||
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
||||||
"domainPickerEnterDomain": "Enter your domain",
|
"domainPickerEnterDomain": "Domain",
|
||||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp",
|
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp",
|
||||||
"domainPickerDescription": "Enter the full domain of the resource to see available options.",
|
"domainPickerDescription": "Enter the full domain of the resource to see available options.",
|
||||||
"domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
|
"domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
|
||||||
|
@ -1206,7 +1205,7 @@
|
||||||
"domainPickerSortAsc": "A-Z",
|
"domainPickerSortAsc": "A-Z",
|
||||||
"domainPickerSortDesc": "Z-A",
|
"domainPickerSortDesc": "Z-A",
|
||||||
"domainPickerCheckingAvailability": "Checking availability...",
|
"domainPickerCheckingAvailability": "Checking availability...",
|
||||||
"domainPickerNoMatchingDomains": "No matching domains found for \"{userInput}\". Try a different domain or check your organization's domain settings.",
|
"domainPickerNoMatchingDomains": "No matching domains found. Try a different domain or check your organization's domain settings.",
|
||||||
"domainPickerOrganizationDomains": "Organization Domains",
|
"domainPickerOrganizationDomains": "Organization Domains",
|
||||||
"domainPickerProvidedDomains": "Provided Domains",
|
"domainPickerProvidedDomains": "Provided Domains",
|
||||||
"domainPickerSubdomain": "Subdomain: {subdomain}",
|
"domainPickerSubdomain": "Subdomain: {subdomain}",
|
||||||
|
@ -1266,6 +1265,7 @@
|
||||||
"createDomainName": "Name:",
|
"createDomainName": "Name:",
|
||||||
"createDomainValue": "Value:",
|
"createDomainValue": "Value:",
|
||||||
"createDomainCnameRecords": "CNAME Records",
|
"createDomainCnameRecords": "CNAME Records",
|
||||||
|
"createDomainARecords": "A Records",
|
||||||
"createDomainRecordNumber": "Record {number}",
|
"createDomainRecordNumber": "Record {number}",
|
||||||
"createDomainTxtRecords": "TXT Records",
|
"createDomainTxtRecords": "TXT Records",
|
||||||
"createDomainSaveTheseRecords": "Save These Records",
|
"createDomainSaveTheseRecords": "Save These Records",
|
||||||
|
@ -1273,5 +1273,50 @@
|
||||||
"createDomainDnsPropagation": "DNS Propagation",
|
"createDomainDnsPropagation": "DNS Propagation",
|
||||||
"createDomainDnsPropagationDescription": "DNS changes may take some time to propagate across the internet. This can take anywhere from a few minutes to 48 hours, depending on your DNS provider and TTL settings.",
|
"createDomainDnsPropagationDescription": "DNS changes may take some time to propagate across the internet. This can take anywhere from a few minutes to 48 hours, depending on your DNS provider and TTL settings.",
|
||||||
"resourcePortRequired": "Port number is required for non-HTTP resources",
|
"resourcePortRequired": "Port number is required for non-HTTP resources",
|
||||||
"resourcePortNotAllowed": "Port number should not be set for HTTP resources"
|
"resourcePortNotAllowed": "Port number should not be set for HTTP resources",
|
||||||
|
"signUpTerms": {
|
||||||
|
"IAgreeToThe": "I agree to the",
|
||||||
|
"termsOfService": "terms of service",
|
||||||
|
"and": "and",
|
||||||
|
"privacyPolicy": "privacy policy"
|
||||||
|
},
|
||||||
|
"siteRequired": "Site is required.",
|
||||||
|
"olmTunnel": "Olm Tunnel",
|
||||||
|
"olmTunnelDescription": "Use Olm for client connectivity",
|
||||||
|
"errorCreatingClient": "Error creating client",
|
||||||
|
"clientDefaultsNotFound": "Client defaults not found",
|
||||||
|
"createClient": "Create Client",
|
||||||
|
"createClientDescription": "Create a new client for connecting to your sites",
|
||||||
|
"seeAllClients": "See All Clients",
|
||||||
|
"clientInformation": "Client Information",
|
||||||
|
"clientNamePlaceholder": "Client name",
|
||||||
|
"address": "Address",
|
||||||
|
"subnetPlaceholder": "Subnet",
|
||||||
|
"addressDescription": "The address that this client will use for connectivity",
|
||||||
|
"selectSites": "Select sites",
|
||||||
|
"sitesDescription": "The client will have connectivity to the selected sites",
|
||||||
|
"clientInstallOlm": "Install Olm",
|
||||||
|
"clientInstallOlmDescription": "Get Olm running on your system",
|
||||||
|
"clientOlmCredentials": "Olm Credentials",
|
||||||
|
"clientOlmCredentialsDescription": "This is how Olm will authenticate with the server",
|
||||||
|
"olmEndpoint": "Olm Endpoint",
|
||||||
|
"olmId": "Olm ID",
|
||||||
|
"olmSecretKey": "Olm Secret Key",
|
||||||
|
"clientCredentialsSave": "Save Your Credentials",
|
||||||
|
"clientCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.",
|
||||||
|
"generalSettingsDescription": "Configure the general settings for this client",
|
||||||
|
"clientUpdated": "Client updated",
|
||||||
|
"clientUpdatedDescription": "The client has been updated.",
|
||||||
|
"clientUpdateFailed": "Failed to update client",
|
||||||
|
"clientUpdateError": "An error occurred while updating the client.",
|
||||||
|
"sitesFetchFailed": "Failed to fetch sites",
|
||||||
|
"sitesFetchError": "An error occurred while fetching sites.",
|
||||||
|
"olmErrorFetchReleases": "An error occurred while fetching Olm releases.",
|
||||||
|
"olmErrorFetchLatest": "An error occurred while fetching the latest Olm release.",
|
||||||
|
"remoteSubnets": "Remote Subnets",
|
||||||
|
"enterCidrRange": "Enter CIDR range",
|
||||||
|
"remoteSubnetsDescription": "Add CIDR ranges that can access this site remotely. Use format like 10.0.0.0/24 or 192.168.1.0/24.",
|
||||||
|
"resourceEnableProxy": "Enable Public Proxy",
|
||||||
|
"resourceEnableProxyDescription": "Enable public proxying to this resource. This allows access to the resource from outside the network through the cloud on an open port. Requires Traefik config.",
|
||||||
|
"externalProxyEnabled": "External Proxy Enabled"
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,6 @@
|
||||||
"siteErrorCreate": "Fehler beim Erstellen der Site",
|
"siteErrorCreate": "Fehler beim Erstellen der Site",
|
||||||
"siteErrorCreateKeyPair": "Schlüsselpaar oder Standardwerte nicht gefunden",
|
"siteErrorCreateKeyPair": "Schlüsselpaar oder Standardwerte nicht gefunden",
|
||||||
"siteErrorCreateDefaults": "Standardwerte der Site nicht gefunden",
|
"siteErrorCreateDefaults": "Standardwerte der Site nicht gefunden",
|
||||||
"siteNameDescription": "Dies ist der Anzeigename für die Site.",
|
|
||||||
"method": "Methode",
|
"method": "Methode",
|
||||||
"siteMethodDescription": "So werden Verbindungen freigegeben.",
|
"siteMethodDescription": "So werden Verbindungen freigegeben.",
|
||||||
"siteLearnNewt": "Wie du Newt auf deinem System installieren kannst",
|
"siteLearnNewt": "Wie du Newt auf deinem System installieren kannst",
|
||||||
|
@ -1094,7 +1093,7 @@
|
||||||
"sidebarAllUsers": "Alle Benutzer",
|
"sidebarAllUsers": "Alle Benutzer",
|
||||||
"sidebarIdentityProviders": "Identitätsanbieter",
|
"sidebarIdentityProviders": "Identitätsanbieter",
|
||||||
"sidebarLicense": "Lizenz",
|
"sidebarLicense": "Lizenz",
|
||||||
"sidebarClients": "Kunden",
|
"sidebarClients": "Clients (Beta)",
|
||||||
"sidebarDomains": "Domains",
|
"sidebarDomains": "Domains",
|
||||||
"enableDockerSocket": "Docker Socket aktivieren",
|
"enableDockerSocket": "Docker Socket aktivieren",
|
||||||
"enableDockerSocketDescription": "Docker Socket-Erkennung aktivieren, um Container-Informationen zu befüllen. Socket-Pfad muss Newt bereitgestellt werden.",
|
"enableDockerSocketDescription": "Docker Socket-Erkennung aktivieren, um Container-Informationen zu befüllen. Socket-Pfad muss Newt bereitgestellt werden.",
|
||||||
|
@ -1162,7 +1161,7 @@
|
||||||
"selectDomainTypeCnameName": "Einzelne Domain (CNAME)",
|
"selectDomainTypeCnameName": "Einzelne Domain (CNAME)",
|
||||||
"selectDomainTypeCnameDescription": "Nur diese spezifische Domain. Verwenden Sie dies für einzelne Subdomains oder spezifische Domaineinträge.",
|
"selectDomainTypeCnameDescription": "Nur diese spezifische Domain. Verwenden Sie dies für einzelne Subdomains oder spezifische Domaineinträge.",
|
||||||
"selectDomainTypeWildcardName": "Wildcard-Domain",
|
"selectDomainTypeWildcardName": "Wildcard-Domain",
|
||||||
"selectDomainTypeWildcardDescription": "Diese Domain und ihre erste Ebene der Subdomains.",
|
"selectDomainTypeWildcardDescription": "Diese Domain und ihre Subdomains.",
|
||||||
"domainDelegation": "Einzelne Domain",
|
"domainDelegation": "Einzelne Domain",
|
||||||
"selectType": "Typ auswählen",
|
"selectType": "Typ auswählen",
|
||||||
"actions": "Aktionen",
|
"actions": "Aktionen",
|
||||||
|
@ -1196,7 +1195,7 @@
|
||||||
"sidebarExpand": "Erweitern",
|
"sidebarExpand": "Erweitern",
|
||||||
"newtUpdateAvailable": "Update verfügbar",
|
"newtUpdateAvailable": "Update verfügbar",
|
||||||
"newtUpdateAvailableInfo": "Eine neue Version von Newt ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.",
|
"newtUpdateAvailableInfo": "Eine neue Version von Newt ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.",
|
||||||
"domainPickerEnterDomain": "Geben Sie Ihre Domain ein",
|
"domainPickerEnterDomain": "Domain",
|
||||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, oder einfach myapp",
|
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, oder einfach myapp",
|
||||||
"domainPickerDescription": "Geben Sie die vollständige Domäne der Ressource ein, um verfügbare Optionen zu sehen.",
|
"domainPickerDescription": "Geben Sie die vollständige Domäne der Ressource ein, um verfügbare Optionen zu sehen.",
|
||||||
"domainPickerDescriptionSaas": "Geben Sie eine vollständige Domäne, Subdomäne oder einfach einen Namen ein, um verfügbare Optionen zu sehen",
|
"domainPickerDescriptionSaas": "Geben Sie eine vollständige Domäne, Subdomäne oder einfach einen Namen ein, um verfügbare Optionen zu sehen",
|
||||||
|
@ -1206,7 +1205,7 @@
|
||||||
"domainPickerSortAsc": "A-Z",
|
"domainPickerSortAsc": "A-Z",
|
||||||
"domainPickerSortDesc": "Z-A",
|
"domainPickerSortDesc": "Z-A",
|
||||||
"domainPickerCheckingAvailability": "Verfügbarkeit prüfen...",
|
"domainPickerCheckingAvailability": "Verfügbarkeit prüfen...",
|
||||||
"domainPickerNoMatchingDomains": "Keine passenden Domains für \"{userInput}\" gefunden. Versuchen Sie es mit einer anderen Domain oder überprüfen Sie die Domain-Einstellungen Ihrer Organisation.",
|
"domainPickerNoMatchingDomains": "Keine passenden Domains gefunden. Versuchen Sie es mit einer anderen Domain oder überprüfen Sie die Domain-Einstellungen Ihrer Organisation.",
|
||||||
"domainPickerOrganizationDomains": "Organisations-Domains",
|
"domainPickerOrganizationDomains": "Organisations-Domains",
|
||||||
"domainPickerProvidedDomains": "Bereitgestellte Domains",
|
"domainPickerProvidedDomains": "Bereitgestellte Domains",
|
||||||
"domainPickerSubdomain": "Subdomain: {subdomain}",
|
"domainPickerSubdomain": "Subdomain: {subdomain}",
|
||||||
|
@ -1266,6 +1265,7 @@
|
||||||
"createDomainName": "Name:",
|
"createDomainName": "Name:",
|
||||||
"createDomainValue": "Wert:",
|
"createDomainValue": "Wert:",
|
||||||
"createDomainCnameRecords": "CNAME-Einträge",
|
"createDomainCnameRecords": "CNAME-Einträge",
|
||||||
|
"createDomainARecords": "A-Aufzeichnungen",
|
||||||
"createDomainRecordNumber": "Eintrag {number}",
|
"createDomainRecordNumber": "Eintrag {number}",
|
||||||
"createDomainTxtRecords": "TXT-Einträge",
|
"createDomainTxtRecords": "TXT-Einträge",
|
||||||
"createDomainSaveTheseRecords": "Diese Einträge speichern",
|
"createDomainSaveTheseRecords": "Diese Einträge speichern",
|
||||||
|
@ -1273,5 +1273,50 @@
|
||||||
"createDomainDnsPropagation": "DNS-Verbreitung",
|
"createDomainDnsPropagation": "DNS-Verbreitung",
|
||||||
"createDomainDnsPropagationDescription": "Es kann einige Zeit dauern, bis DNS-Änderungen im Internet verbreitet werden. Dies kann je nach Ihrem DNS-Provider und den TTL-Einstellungen von einigen Minuten bis zu 48 Stunden dauern.",
|
"createDomainDnsPropagationDescription": "Es kann einige Zeit dauern, bis DNS-Änderungen im Internet verbreitet werden. Dies kann je nach Ihrem DNS-Provider und den TTL-Einstellungen von einigen Minuten bis zu 48 Stunden dauern.",
|
||||||
"resourcePortRequired": "Portnummer ist für nicht-HTTP-Ressourcen erforderlich",
|
"resourcePortRequired": "Portnummer ist für nicht-HTTP-Ressourcen erforderlich",
|
||||||
"resourcePortNotAllowed": "Portnummer sollte für HTTP-Ressourcen nicht gesetzt werden"
|
"resourcePortNotAllowed": "Portnummer sollte für HTTP-Ressourcen nicht gesetzt werden",
|
||||||
|
"signUpTerms": {
|
||||||
|
"IAgreeToThe": "Ich stimme den",
|
||||||
|
"termsOfService": "Nutzungsbedingungen zu",
|
||||||
|
"and": "und",
|
||||||
|
"privacyPolicy": "Datenschutzrichtlinie"
|
||||||
|
},
|
||||||
|
"siteRequired": "Site ist erforderlich.",
|
||||||
|
"olmTunnel": "Olm Tunnel",
|
||||||
|
"olmTunnelDescription": "Nutzen Sie Olm für die Kundenverbindung",
|
||||||
|
"errorCreatingClient": "Fehler beim Erstellen des Clients",
|
||||||
|
"clientDefaultsNotFound": "Kundenvorgaben nicht gefunden",
|
||||||
|
"createClient": "Client erstellen",
|
||||||
|
"createClientDescription": "Erstellen Sie einen neuen Client für die Verbindung zu Ihren Sites.",
|
||||||
|
"seeAllClients": "Alle Clients anzeigen",
|
||||||
|
"clientInformation": "Kundeninformationen",
|
||||||
|
"clientNamePlaceholder": "Kundenname",
|
||||||
|
"address": "Adresse",
|
||||||
|
"subnetPlaceholder": "Subnetz",
|
||||||
|
"addressDescription": "Die Adresse, die dieser Client für die Verbindung verwenden wird.",
|
||||||
|
"selectSites": "Sites auswählen",
|
||||||
|
"sitesDescription": "Der Client wird zu den ausgewählten Sites eine Verbindung haben.",
|
||||||
|
"clientInstallOlm": "Olm installieren",
|
||||||
|
"clientInstallOlmDescription": "Olm auf Ihrem System zum Laufen bringen",
|
||||||
|
"clientOlmCredentials": "Olm-Zugangsdaten",
|
||||||
|
"clientOlmCredentialsDescription": "So authentifiziert sich Olm beim Server",
|
||||||
|
"olmEndpoint": "Olm-Endpunkt",
|
||||||
|
"olmId": "Olm-ID",
|
||||||
|
"olmSecretKey": "Olm-Geheimschlüssel",
|
||||||
|
"clientCredentialsSave": "Speichern Sie Ihre Zugangsdaten",
|
||||||
|
"clientCredentialsSaveDescription": "Sie können dies nur einmal sehen. Kopieren Sie es an einen sicheren Ort.",
|
||||||
|
"generalSettingsDescription": "Konfigurieren Sie die allgemeinen Einstellungen für diesen Client",
|
||||||
|
"clientUpdated": "Client aktualisiert",
|
||||||
|
"clientUpdatedDescription": "Der Client wurde aktualisiert.",
|
||||||
|
"clientUpdateFailed": "Fehler beim Aktualisieren des Clients",
|
||||||
|
"clientUpdateError": "Beim Aktualisieren des Clients ist ein Fehler aufgetreten.",
|
||||||
|
"sitesFetchFailed": "Fehler beim Abrufen von Sites",
|
||||||
|
"sitesFetchError": "Beim Abrufen von Sites ist ein Fehler aufgetreten.",
|
||||||
|
"olmErrorFetchReleases": "Beim Abrufen von Olm-Veröffentlichungen ist ein Fehler aufgetreten.",
|
||||||
|
"olmErrorFetchLatest": "Beim Abrufen der neuesten Olm-Veröffentlichung ist ein Fehler aufgetreten.",
|
||||||
|
"remoteSubnets": "Remote-Subnetze",
|
||||||
|
"enterCidrRange": "Geben Sie den CIDR-Bereich ein",
|
||||||
|
"remoteSubnetsDescription": "Fügen Sie CIDR-Bereiche hinzu, die aus der Ferne auf diese Site zugreifen können. Verwenden Sie das Format wie 10.0.0.0/24 oder 192.168.1.0/24.",
|
||||||
|
"resourceEnableProxy": "Öffentlichen Proxy aktivieren",
|
||||||
|
"resourceEnableProxyDescription": "Ermöglichen Sie öffentliches Proxieren zu dieser Ressource. Dies ermöglicht den Zugriff auf die Ressource von außerhalb des Netzwerks durch die Cloud über einen offenen Port. Erfordert Traefik-Config.",
|
||||||
|
"externalProxyEnabled": "Externer Proxy aktiviert"
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,6 @@
|
||||||
"siteErrorCreate": "Error creating site",
|
"siteErrorCreate": "Error creating site",
|
||||||
"siteErrorCreateKeyPair": "Key pair or site defaults not found",
|
"siteErrorCreateKeyPair": "Key pair or site defaults not found",
|
||||||
"siteErrorCreateDefaults": "Site defaults not found",
|
"siteErrorCreateDefaults": "Site defaults not found",
|
||||||
"siteNameDescription": "This is the display name for the site.",
|
|
||||||
"method": "Method",
|
"method": "Method",
|
||||||
"siteMethodDescription": "This is how you will expose connections.",
|
"siteMethodDescription": "This is how you will expose connections.",
|
||||||
"siteLearnNewt": "Learn how to install Newt on your system",
|
"siteLearnNewt": "Learn how to install Newt on your system",
|
||||||
|
@ -1094,7 +1093,7 @@
|
||||||
"sidebarAllUsers": "All Users",
|
"sidebarAllUsers": "All Users",
|
||||||
"sidebarIdentityProviders": "Identity Providers",
|
"sidebarIdentityProviders": "Identity Providers",
|
||||||
"sidebarLicense": "License",
|
"sidebarLicense": "License",
|
||||||
"sidebarClients": "Clients",
|
"sidebarClients": "Clients (Beta)",
|
||||||
"sidebarDomains": "Domains",
|
"sidebarDomains": "Domains",
|
||||||
"enableDockerSocket": "Enable Docker Socket",
|
"enableDockerSocket": "Enable Docker Socket",
|
||||||
"enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.",
|
"enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.",
|
||||||
|
@ -1196,7 +1195,7 @@
|
||||||
"sidebarExpand": "Expand",
|
"sidebarExpand": "Expand",
|
||||||
"newtUpdateAvailable": "Update Available",
|
"newtUpdateAvailable": "Update Available",
|
||||||
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
||||||
"domainPickerEnterDomain": "Enter your domain",
|
"domainPickerEnterDomain": "Domain",
|
||||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp",
|
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp",
|
||||||
"domainPickerDescription": "Enter the full domain of the resource to see available options.",
|
"domainPickerDescription": "Enter the full domain of the resource to see available options.",
|
||||||
"domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
|
"domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
|
||||||
|
@ -1206,7 +1205,7 @@
|
||||||
"domainPickerSortAsc": "A-Z",
|
"domainPickerSortAsc": "A-Z",
|
||||||
"domainPickerSortDesc": "Z-A",
|
"domainPickerSortDesc": "Z-A",
|
||||||
"domainPickerCheckingAvailability": "Checking availability...",
|
"domainPickerCheckingAvailability": "Checking availability...",
|
||||||
"domainPickerNoMatchingDomains": "No matching domains found for \"{userInput}\". Try a different domain or check your organization's domain settings.",
|
"domainPickerNoMatchingDomains": "No matching domains found. Try a different domain or check your organization's domain settings.",
|
||||||
"domainPickerOrganizationDomains": "Organization Domains",
|
"domainPickerOrganizationDomains": "Organization Domains",
|
||||||
"domainPickerProvidedDomains": "Provided Domains",
|
"domainPickerProvidedDomains": "Provided Domains",
|
||||||
"domainPickerSubdomain": "Subdomain: {subdomain}",
|
"domainPickerSubdomain": "Subdomain: {subdomain}",
|
||||||
|
@ -1274,5 +1273,50 @@
|
||||||
"createDomainDnsPropagation": "DNS Propagation",
|
"createDomainDnsPropagation": "DNS Propagation",
|
||||||
"createDomainDnsPropagationDescription": "DNS changes may take some time to propagate across the internet. This can take anywhere from a few minutes to 48 hours, depending on your DNS provider and TTL settings.",
|
"createDomainDnsPropagationDescription": "DNS changes may take some time to propagate across the internet. This can take anywhere from a few minutes to 48 hours, depending on your DNS provider and TTL settings.",
|
||||||
"resourcePortRequired": "Port number is required for non-HTTP resources",
|
"resourcePortRequired": "Port number is required for non-HTTP resources",
|
||||||
"resourcePortNotAllowed": "Port number should not be set for HTTP resources"
|
"resourcePortNotAllowed": "Port number should not be set for HTTP resources",
|
||||||
|
"signUpTerms": {
|
||||||
|
"IAgreeToThe": "I agree to the",
|
||||||
|
"termsOfService": "terms of service",
|
||||||
|
"and": "and",
|
||||||
|
"privacyPolicy": "privacy policy"
|
||||||
|
},
|
||||||
|
"siteRequired": "Site is required.",
|
||||||
|
"olmTunnel": "Olm Tunnel",
|
||||||
|
"olmTunnelDescription": "Use Olm for client connectivity",
|
||||||
|
"errorCreatingClient": "Error creating client",
|
||||||
|
"clientDefaultsNotFound": "Client defaults not found",
|
||||||
|
"createClient": "Create Client",
|
||||||
|
"createClientDescription": "Create a new client for connecting to your sites",
|
||||||
|
"seeAllClients": "See All Clients",
|
||||||
|
"clientInformation": "Client Information",
|
||||||
|
"clientNamePlaceholder": "Client name",
|
||||||
|
"address": "Address",
|
||||||
|
"subnetPlaceholder": "Subnet",
|
||||||
|
"addressDescription": "The address that this client will use for connectivity",
|
||||||
|
"selectSites": "Select sites",
|
||||||
|
"sitesDescription": "The client will have connectivity to the selected sites",
|
||||||
|
"clientInstallOlm": "Install Olm",
|
||||||
|
"clientInstallOlmDescription": "Get Olm running on your system",
|
||||||
|
"clientOlmCredentials": "Olm Credentials",
|
||||||
|
"clientOlmCredentialsDescription": "This is how Olm will authenticate with the server",
|
||||||
|
"olmEndpoint": "Olm Endpoint",
|
||||||
|
"olmId": "Olm ID",
|
||||||
|
"olmSecretKey": "Olm Secret Key",
|
||||||
|
"clientCredentialsSave": "Save Your Credentials",
|
||||||
|
"clientCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.",
|
||||||
|
"generalSettingsDescription": "Configure the general settings for this client",
|
||||||
|
"clientUpdated": "Client updated",
|
||||||
|
"clientUpdatedDescription": "The client has been updated.",
|
||||||
|
"clientUpdateFailed": "Failed to update client",
|
||||||
|
"clientUpdateError": "An error occurred while updating the client.",
|
||||||
|
"sitesFetchFailed": "Failed to fetch sites",
|
||||||
|
"sitesFetchError": "An error occurred while fetching sites.",
|
||||||
|
"olmErrorFetchReleases": "An error occurred while fetching Olm releases.",
|
||||||
|
"olmErrorFetchLatest": "An error occurred while fetching the latest Olm release.",
|
||||||
|
"remoteSubnets": "Remote Subnets",
|
||||||
|
"enterCidrRange": "Enter CIDR range",
|
||||||
|
"remoteSubnetsDescription": "Add CIDR ranges that can access this site remotely. Use format like 10.0.0.0/24 or 192.168.1.0/24.",
|
||||||
|
"resourceEnableProxy": "Enable Public Proxy",
|
||||||
|
"resourceEnableProxyDescription": "Enable public proxying to this resource. This allows access to the resource from outside the network through the cloud on an open port. Requires Traefik config.",
|
||||||
|
"externalProxyEnabled": "External Proxy Enabled"
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,6 @@
|
||||||
"siteErrorCreate": "Error al crear el sitio",
|
"siteErrorCreate": "Error al crear el sitio",
|
||||||
"siteErrorCreateKeyPair": "Por defecto no se encuentra el par de claves o el sitio",
|
"siteErrorCreateKeyPair": "Por defecto no se encuentra el par de claves o el sitio",
|
||||||
"siteErrorCreateDefaults": "Sitio por defecto no encontrado",
|
"siteErrorCreateDefaults": "Sitio por defecto no encontrado",
|
||||||
"siteNameDescription": "Este es el nombre para mostrar el sitio.",
|
|
||||||
"method": "Método",
|
"method": "Método",
|
||||||
"siteMethodDescription": "Así es como se expondrán las conexiones.",
|
"siteMethodDescription": "Así es como se expondrán las conexiones.",
|
||||||
"siteLearnNewt": "Aprende cómo instalar Newt en tu sistema",
|
"siteLearnNewt": "Aprende cómo instalar Newt en tu sistema",
|
||||||
|
@ -1094,7 +1093,7 @@
|
||||||
"sidebarAllUsers": "Todos los usuarios",
|
"sidebarAllUsers": "Todos los usuarios",
|
||||||
"sidebarIdentityProviders": "Proveedores de identidad",
|
"sidebarIdentityProviders": "Proveedores de identidad",
|
||||||
"sidebarLicense": "Licencia",
|
"sidebarLicense": "Licencia",
|
||||||
"sidebarClients": "Clientes",
|
"sidebarClients": "Clientes (Beta)",
|
||||||
"sidebarDomains": "Dominios",
|
"sidebarDomains": "Dominios",
|
||||||
"enableDockerSocket": "Habilitar conector Docker",
|
"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.",
|
"enableDockerSocketDescription": "Habilitar el descubrimiento de Docker Socket para completar la información del contenedor. La ruta del socket debe proporcionarse a Newt.",
|
||||||
|
@ -1162,7 +1161,7 @@
|
||||||
"selectDomainTypeCnameName": "Dominio único (CNAME)",
|
"selectDomainTypeCnameName": "Dominio único (CNAME)",
|
||||||
"selectDomainTypeCnameDescription": "Solo este dominio específico. Úsalo para subdominios individuales o entradas específicas de dominio.",
|
"selectDomainTypeCnameDescription": "Solo este dominio específico. Úsalo para subdominios individuales o entradas específicas de dominio.",
|
||||||
"selectDomainTypeWildcardName": "Dominio comodín",
|
"selectDomainTypeWildcardName": "Dominio comodín",
|
||||||
"selectDomainTypeWildcardDescription": "Este dominio y su primer nivel de subdominios.",
|
"selectDomainTypeWildcardDescription": "Este dominio y sus subdominios.",
|
||||||
"domainDelegation": "Dominio único",
|
"domainDelegation": "Dominio único",
|
||||||
"selectType": "Selecciona un tipo",
|
"selectType": "Selecciona un tipo",
|
||||||
"actions": "Acciones",
|
"actions": "Acciones",
|
||||||
|
@ -1196,7 +1195,7 @@
|
||||||
"sidebarExpand": "Expandir",
|
"sidebarExpand": "Expandir",
|
||||||
"newtUpdateAvailable": "Nueva actualización disponible",
|
"newtUpdateAvailable": "Nueva actualización disponible",
|
||||||
"newtUpdateAvailableInfo": "Hay una nueva versión de Newt disponible. Actualice a la última versión para la mejor experiencia.",
|
"newtUpdateAvailableInfo": "Hay una nueva versión de Newt disponible. Actualice a la última versión para la mejor experiencia.",
|
||||||
"domainPickerEnterDomain": "Ingresa tu dominio",
|
"domainPickerEnterDomain": "Dominio",
|
||||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.miDominio.com, o solo myapp",
|
"domainPickerPlaceholder": "myapp.example.com, api.v1.miDominio.com, o solo myapp",
|
||||||
"domainPickerDescription": "Ingresa el dominio completo del recurso para ver las opciones disponibles.",
|
"domainPickerDescription": "Ingresa el dominio completo del recurso para ver las opciones disponibles.",
|
||||||
"domainPickerDescriptionSaas": "Ingresa un dominio completo, subdominio o simplemente un nombre para ver las opciones disponibles",
|
"domainPickerDescriptionSaas": "Ingresa un dominio completo, subdominio o simplemente un nombre para ver las opciones disponibles",
|
||||||
|
@ -1206,7 +1205,7 @@
|
||||||
"domainPickerSortAsc": "A-Z",
|
"domainPickerSortAsc": "A-Z",
|
||||||
"domainPickerSortDesc": "Z-A",
|
"domainPickerSortDesc": "Z-A",
|
||||||
"domainPickerCheckingAvailability": "Comprobando disponibilidad...",
|
"domainPickerCheckingAvailability": "Comprobando disponibilidad...",
|
||||||
"domainPickerNoMatchingDomains": "No se encontraron dominios coincidentes para \"{userInput}\". Prueba con un dominio diferente o revisa la configuración de dominio de tu organización.",
|
"domainPickerNoMatchingDomains": "No se encontraron dominios que coincidan. Intente con un dominio diferente o verifique la configuración de dominios de su organización.",
|
||||||
"domainPickerOrganizationDomains": "Dominios de la organización",
|
"domainPickerOrganizationDomains": "Dominios de la organización",
|
||||||
"domainPickerProvidedDomains": "Dominios proporcionados",
|
"domainPickerProvidedDomains": "Dominios proporcionados",
|
||||||
"domainPickerSubdomain": "Subdominio: {subdomain}",
|
"domainPickerSubdomain": "Subdominio: {subdomain}",
|
||||||
|
@ -1266,6 +1265,7 @@
|
||||||
"createDomainName": "Nombre:",
|
"createDomainName": "Nombre:",
|
||||||
"createDomainValue": "Valor:",
|
"createDomainValue": "Valor:",
|
||||||
"createDomainCnameRecords": "Registros CNAME",
|
"createDomainCnameRecords": "Registros CNAME",
|
||||||
|
"createDomainARecords": "Registros A",
|
||||||
"createDomainRecordNumber": "Registro {number}",
|
"createDomainRecordNumber": "Registro {number}",
|
||||||
"createDomainTxtRecords": "Registros TXT",
|
"createDomainTxtRecords": "Registros TXT",
|
||||||
"createDomainSaveTheseRecords": "Guardar estos registros",
|
"createDomainSaveTheseRecords": "Guardar estos registros",
|
||||||
|
@ -1273,5 +1273,50 @@
|
||||||
"createDomainDnsPropagation": "Propagación DNS",
|
"createDomainDnsPropagation": "Propagación DNS",
|
||||||
"createDomainDnsPropagationDescription": "Los cambios de DNS pueden tardar un tiempo en propagarse a través de internet. Esto puede tardar desde unos pocos minutos hasta 48 horas, dependiendo de tu proveedor de DNS y la configuración de TTL.",
|
"createDomainDnsPropagationDescription": "Los cambios de DNS pueden tardar un tiempo en propagarse a través de internet. Esto puede tardar desde unos pocos minutos hasta 48 horas, dependiendo de tu proveedor de DNS y la configuración de TTL.",
|
||||||
"resourcePortRequired": "Se requiere número de puerto para recursos no HTTP",
|
"resourcePortRequired": "Se requiere número de puerto para recursos no HTTP",
|
||||||
"resourcePortNotAllowed": "El número de puerto no debe establecerse para recursos HTTP"
|
"resourcePortNotAllowed": "El número de puerto no debe establecerse para recursos HTTP",
|
||||||
|
"signUpTerms": {
|
||||||
|
"IAgreeToThe": "Estoy de acuerdo con los",
|
||||||
|
"termsOfService": "términos del servicio",
|
||||||
|
"and": "y",
|
||||||
|
"privacyPolicy": "política de privacidad"
|
||||||
|
},
|
||||||
|
"siteRequired": "El sitio es requerido.",
|
||||||
|
"olmTunnel": "Túnel Olm",
|
||||||
|
"olmTunnelDescription": "Usar Olm para la conectividad del cliente",
|
||||||
|
"errorCreatingClient": "Error al crear el cliente",
|
||||||
|
"clientDefaultsNotFound": "Configuración predeterminada del cliente no encontrada",
|
||||||
|
"createClient": "Crear cliente",
|
||||||
|
"createClientDescription": "Crear un cliente nuevo para conectar a sus sitios",
|
||||||
|
"seeAllClients": "Ver todos los clientes",
|
||||||
|
"clientInformation": "Información del cliente",
|
||||||
|
"clientNamePlaceholder": "Nombre del cliente",
|
||||||
|
"address": "Dirección",
|
||||||
|
"subnetPlaceholder": "Subred",
|
||||||
|
"addressDescription": "La dirección que este cliente utilizará para la conectividad",
|
||||||
|
"selectSites": "Seleccionar sitios",
|
||||||
|
"sitesDescription": "El cliente tendrá conectividad con los sitios seleccionados",
|
||||||
|
"clientInstallOlm": "Instalar Olm",
|
||||||
|
"clientInstallOlmDescription": "Obtén Olm funcionando en tu sistema",
|
||||||
|
"clientOlmCredentials": "Credenciales Olm",
|
||||||
|
"clientOlmCredentialsDescription": "Así es como Olm se autentificará con el servidor",
|
||||||
|
"olmEndpoint": "Punto final Olm",
|
||||||
|
"olmId": "ID de Olm",
|
||||||
|
"olmSecretKey": "Clave secreta de Olm",
|
||||||
|
"clientCredentialsSave": "Guarda tus credenciales",
|
||||||
|
"clientCredentialsSaveDescription": "Sólo podrás verlo una vez. Asegúrate de copiarlo a un lugar seguro.",
|
||||||
|
"generalSettingsDescription": "Configura la configuración general para este cliente",
|
||||||
|
"clientUpdated": "Cliente actualizado",
|
||||||
|
"clientUpdatedDescription": "El cliente ha sido actualizado.",
|
||||||
|
"clientUpdateFailed": "Error al actualizar el cliente",
|
||||||
|
"clientUpdateError": "Se ha producido un error al actualizar el cliente.",
|
||||||
|
"sitesFetchFailed": "Error al obtener los sitios",
|
||||||
|
"sitesFetchError": "Se ha producido un error al recuperar los sitios.",
|
||||||
|
"olmErrorFetchReleases": "Se ha producido un error al recuperar las versiones de Olm.",
|
||||||
|
"olmErrorFetchLatest": "Se ha producido un error al recuperar la última versión de Olm.",
|
||||||
|
"remoteSubnets": "Subredes remotas",
|
||||||
|
"enterCidrRange": "Ingresa el rango CIDR",
|
||||||
|
"remoteSubnetsDescription": "Agregue rangos CIDR que puedan acceder a este sitio de forma remota. Use un formato como 10.0.0.0/24 o 192.168.1.0/24.",
|
||||||
|
"resourceEnableProxy": "Habilitar proxy público",
|
||||||
|
"resourceEnableProxyDescription": "Habilite el proxy público para este recurso. Esto permite el acceso al recurso desde fuera de la red a través de la nube en un puerto abierto. Requiere configuración de Traefik.",
|
||||||
|
"externalProxyEnabled": "Proxy externo habilitado"
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,6 @@
|
||||||
"siteErrorCreate": "Erreur lors de la création du site",
|
"siteErrorCreate": "Erreur lors de la création du site",
|
||||||
"siteErrorCreateKeyPair": "Paire de clés ou site par défaut introuvable",
|
"siteErrorCreateKeyPair": "Paire de clés ou site par défaut introuvable",
|
||||||
"siteErrorCreateDefaults": "Les valeurs par défaut du site sont introuvables",
|
"siteErrorCreateDefaults": "Les valeurs par défaut du site sont introuvables",
|
||||||
"siteNameDescription": "Ceci est le nom d'affichage du site.",
|
|
||||||
"method": "Méthode",
|
"method": "Méthode",
|
||||||
"siteMethodDescription": "C'est ainsi que vous exposerez les connexions.",
|
"siteMethodDescription": "C'est ainsi que vous exposerez les connexions.",
|
||||||
"siteLearnNewt": "Apprenez à installer Newt sur votre système",
|
"siteLearnNewt": "Apprenez à installer Newt sur votre système",
|
||||||
|
@ -1094,7 +1093,7 @@
|
||||||
"sidebarAllUsers": "Tous les utilisateurs",
|
"sidebarAllUsers": "Tous les utilisateurs",
|
||||||
"sidebarIdentityProviders": "Fournisseurs d'identité",
|
"sidebarIdentityProviders": "Fournisseurs d'identité",
|
||||||
"sidebarLicense": "Licence",
|
"sidebarLicense": "Licence",
|
||||||
"sidebarClients": "Clients",
|
"sidebarClients": "Clients (Bêta)",
|
||||||
"sidebarDomains": "Domaines",
|
"sidebarDomains": "Domaines",
|
||||||
"enableDockerSocket": "Activer Docker Socket",
|
"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.",
|
"enableDockerSocketDescription": "Activer la découverte Docker Socket pour remplir les informations du conteneur. Le chemin du socket doit être fourni à Newt.",
|
||||||
|
@ -1162,7 +1161,7 @@
|
||||||
"selectDomainTypeCnameName": "Domaine unique (CNAME)",
|
"selectDomainTypeCnameName": "Domaine unique (CNAME)",
|
||||||
"selectDomainTypeCnameDescription": "Juste ce domaine spécifique. Utilisez ce paramètre pour des sous-domaines individuels ou des entrées de domaine spécifiques.",
|
"selectDomainTypeCnameDescription": "Juste ce domaine spécifique. Utilisez ce paramètre pour des sous-domaines individuels ou des entrées de domaine spécifiques.",
|
||||||
"selectDomainTypeWildcardName": "Domaine Générique",
|
"selectDomainTypeWildcardName": "Domaine Générique",
|
||||||
"selectDomainTypeWildcardDescription": "Ce domaine et son premier niveau de sous-domaines.",
|
"selectDomainTypeWildcardDescription": "Ce domaine et ses sous-domaines.",
|
||||||
"domainDelegation": "Domaine Unique",
|
"domainDelegation": "Domaine Unique",
|
||||||
"selectType": "Sélectionnez un type",
|
"selectType": "Sélectionnez un type",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
|
@ -1196,7 +1195,7 @@
|
||||||
"sidebarExpand": "Développer",
|
"sidebarExpand": "Développer",
|
||||||
"newtUpdateAvailable": "Mise à jour disponible",
|
"newtUpdateAvailable": "Mise à jour disponible",
|
||||||
"newtUpdateAvailableInfo": "Une nouvelle version de Newt est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.",
|
"newtUpdateAvailableInfo": "Une nouvelle version de Newt est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.",
|
||||||
"domainPickerEnterDomain": "Entrez votre domaine",
|
"domainPickerEnterDomain": "Domaine",
|
||||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, ou simplement myapp",
|
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, ou simplement myapp",
|
||||||
"domainPickerDescription": "Entrez le domaine complet de la ressource pour voir les options disponibles.",
|
"domainPickerDescription": "Entrez le domaine complet de la ressource pour voir les options disponibles.",
|
||||||
"domainPickerDescriptionSaas": "Entrez un domaine complet, un sous-domaine ou juste un nom pour voir les options disponibles",
|
"domainPickerDescriptionSaas": "Entrez un domaine complet, un sous-domaine ou juste un nom pour voir les options disponibles",
|
||||||
|
@ -1206,7 +1205,7 @@
|
||||||
"domainPickerSortAsc": "A-Z",
|
"domainPickerSortAsc": "A-Z",
|
||||||
"domainPickerSortDesc": "Z-A",
|
"domainPickerSortDesc": "Z-A",
|
||||||
"domainPickerCheckingAvailability": "Vérification de la disponibilité...",
|
"domainPickerCheckingAvailability": "Vérification de la disponibilité...",
|
||||||
"domainPickerNoMatchingDomains": "Aucun domaine correspondant trouvé pour \"{userInput}\". Essayez un autre domaine ou vérifiez les paramètres de domaine de votre organisation.",
|
"domainPickerNoMatchingDomains": "Aucun domaine correspondant trouvé. Essayez un autre domaine ou vérifiez les paramètres de domaine de votre organisation.",
|
||||||
"domainPickerOrganizationDomains": "Domaines de l'organisation",
|
"domainPickerOrganizationDomains": "Domaines de l'organisation",
|
||||||
"domainPickerProvidedDomains": "Domaines fournis",
|
"domainPickerProvidedDomains": "Domaines fournis",
|
||||||
"domainPickerSubdomain": "Sous-domaine : {subdomain}",
|
"domainPickerSubdomain": "Sous-domaine : {subdomain}",
|
||||||
|
@ -1266,6 +1265,7 @@
|
||||||
"createDomainName": "Nom :",
|
"createDomainName": "Nom :",
|
||||||
"createDomainValue": "Valeur :",
|
"createDomainValue": "Valeur :",
|
||||||
"createDomainCnameRecords": "Enregistrements CNAME",
|
"createDomainCnameRecords": "Enregistrements CNAME",
|
||||||
|
"createDomainARecords": "Enregistrements A",
|
||||||
"createDomainRecordNumber": "Enregistrement {number}",
|
"createDomainRecordNumber": "Enregistrement {number}",
|
||||||
"createDomainTxtRecords": "Enregistrements TXT",
|
"createDomainTxtRecords": "Enregistrements TXT",
|
||||||
"createDomainSaveTheseRecords": "Enregistrez ces enregistrements",
|
"createDomainSaveTheseRecords": "Enregistrez ces enregistrements",
|
||||||
|
@ -1273,5 +1273,50 @@
|
||||||
"createDomainDnsPropagation": "Propagation DNS",
|
"createDomainDnsPropagation": "Propagation DNS",
|
||||||
"createDomainDnsPropagationDescription": "Les modifications DNS peuvent mettre du temps à se propager sur internet. Cela peut prendre de quelques minutes à 48 heures selon votre fournisseur DNS et les réglages TTL.",
|
"createDomainDnsPropagationDescription": "Les modifications DNS peuvent mettre du temps à se propager sur internet. Cela peut prendre de quelques minutes à 48 heures selon votre fournisseur DNS et les réglages TTL.",
|
||||||
"resourcePortRequired": "Le numéro de port est requis pour les ressources non-HTTP",
|
"resourcePortRequired": "Le numéro de port est requis pour les ressources non-HTTP",
|
||||||
"resourcePortNotAllowed": "Le numéro de port ne doit pas être défini pour les ressources HTTP"
|
"resourcePortNotAllowed": "Le numéro de port ne doit pas être défini pour les ressources HTTP",
|
||||||
|
"signUpTerms": {
|
||||||
|
"IAgreeToThe": "Je suis d'accord avec",
|
||||||
|
"termsOfService": "les conditions d'utilisation",
|
||||||
|
"and": "et",
|
||||||
|
"privacyPolicy": "la politique de confidentialité"
|
||||||
|
},
|
||||||
|
"siteRequired": "Le site est requis.",
|
||||||
|
"olmTunnel": "Tunnel Olm",
|
||||||
|
"olmTunnelDescription": "Utilisez Olm pour la connectivité client",
|
||||||
|
"errorCreatingClient": "Erreur lors de la création du client",
|
||||||
|
"clientDefaultsNotFound": "Les paramètres par défaut du client sont introuvables",
|
||||||
|
"createClient": "Créer un client",
|
||||||
|
"createClientDescription": "Créez un nouveau client pour vous connecter à vos sites",
|
||||||
|
"seeAllClients": "Voir tous les clients",
|
||||||
|
"clientInformation": "Informations client",
|
||||||
|
"clientNamePlaceholder": "Nom du client",
|
||||||
|
"address": "Adresse",
|
||||||
|
"subnetPlaceholder": "Sous-réseau",
|
||||||
|
"addressDescription": "L'adresse que ce client utilisera pour la connectivité",
|
||||||
|
"selectSites": "Sélectionner des sites",
|
||||||
|
"sitesDescription": "Le client aura une connectivité vers les sites sélectionnés",
|
||||||
|
"clientInstallOlm": "Installer Olm",
|
||||||
|
"clientInstallOlmDescription": "Faites fonctionner Olm sur votre système",
|
||||||
|
"clientOlmCredentials": "Identifiants Olm",
|
||||||
|
"clientOlmCredentialsDescription": "C'est ainsi qu'Olm s'authentifiera auprès du serveur",
|
||||||
|
"olmEndpoint": "Point de terminaison Olm",
|
||||||
|
"olmId": "ID Olm",
|
||||||
|
"olmSecretKey": "Clé secrète Olm",
|
||||||
|
"clientCredentialsSave": "Enregistrez vos identifiants",
|
||||||
|
"clientCredentialsSaveDescription": "Vous ne pourrez voir cela qu'une seule fois. Assurez-vous de la copier dans un endroit sécurisé.",
|
||||||
|
"generalSettingsDescription": "Configurez les paramètres généraux pour ce client",
|
||||||
|
"clientUpdated": "Client mis à jour",
|
||||||
|
"clientUpdatedDescription": "Le client a été mis à jour.",
|
||||||
|
"clientUpdateFailed": "Échec de la mise à jour du client",
|
||||||
|
"clientUpdateError": "Une erreur s'est produite lors de la mise à jour du client.",
|
||||||
|
"sitesFetchFailed": "Échec de la récupération des sites",
|
||||||
|
"sitesFetchError": "Une erreur s'est produite lors de la récupération des sites.",
|
||||||
|
"olmErrorFetchReleases": "Une erreur s'est produite lors de la récupération des versions d'Olm.",
|
||||||
|
"olmErrorFetchLatest": "Une erreur s'est produite lors de la récupération de la dernière version d'Olm.",
|
||||||
|
"remoteSubnets": "Sous-réseaux distants",
|
||||||
|
"enterCidrRange": "Entrez la plage CIDR",
|
||||||
|
"remoteSubnetsDescription": "Ajoutez des plages CIDR pouvant accéder à ce site à distance. Utilisez le format comme 10.0.0.0/24 ou 192.168.1.0/24.",
|
||||||
|
"resourceEnableProxy": "Activer le proxy public",
|
||||||
|
"resourceEnableProxyDescription": "Activez le proxy public vers cette ressource. Cela permet d'accéder à la ressource depuis l'extérieur du réseau via le cloud sur un port ouvert. Nécessite la configuration de Traefik.",
|
||||||
|
"externalProxyEnabled": "Proxy externe activé"
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,6 @@
|
||||||
"siteErrorCreate": "Errore nella creazione del sito",
|
"siteErrorCreate": "Errore nella creazione del sito",
|
||||||
"siteErrorCreateKeyPair": "Coppia di chiavi o valori predefiniti del sito non trovati",
|
"siteErrorCreateKeyPair": "Coppia di chiavi o valori predefiniti del sito non trovati",
|
||||||
"siteErrorCreateDefaults": "Predefiniti del sito non trovati",
|
"siteErrorCreateDefaults": "Predefiniti del sito non trovati",
|
||||||
"siteNameDescription": "Questo è il nome visualizzato per il sito.",
|
|
||||||
"method": "Metodo",
|
"method": "Metodo",
|
||||||
"siteMethodDescription": "Questo è il modo in cui esporrete le connessioni.",
|
"siteMethodDescription": "Questo è il modo in cui esporrete le connessioni.",
|
||||||
"siteLearnNewt": "Scopri come installare Newt sul tuo sistema",
|
"siteLearnNewt": "Scopri come installare Newt sul tuo sistema",
|
||||||
|
@ -1094,7 +1093,7 @@
|
||||||
"sidebarAllUsers": "Tutti Gli Utenti",
|
"sidebarAllUsers": "Tutti Gli Utenti",
|
||||||
"sidebarIdentityProviders": "Fornitori Di Identità",
|
"sidebarIdentityProviders": "Fornitori Di Identità",
|
||||||
"sidebarLicense": "Licenza",
|
"sidebarLicense": "Licenza",
|
||||||
"sidebarClients": "Clienti",
|
"sidebarClients": "Clienti (Beta)",
|
||||||
"sidebarDomains": "Domini",
|
"sidebarDomains": "Domini",
|
||||||
"enableDockerSocket": "Abilita Docker Socket",
|
"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.",
|
"enableDockerSocketDescription": "Abilita il rilevamento Docker Socket per popolare le informazioni del contenitore. Il percorso del socket deve essere fornito a Newt.",
|
||||||
|
@ -1162,7 +1161,7 @@
|
||||||
"selectDomainTypeCnameName": "Dominio Singolo (CNAME)",
|
"selectDomainTypeCnameName": "Dominio Singolo (CNAME)",
|
||||||
"selectDomainTypeCnameDescription": "Solo questo dominio specifico. Usa questo per sottodomini individuali o specifiche voci di dominio.",
|
"selectDomainTypeCnameDescription": "Solo questo dominio specifico. Usa questo per sottodomini individuali o specifiche voci di dominio.",
|
||||||
"selectDomainTypeWildcardName": "Dominio Jolly",
|
"selectDomainTypeWildcardName": "Dominio Jolly",
|
||||||
"selectDomainTypeWildcardDescription": "Questo dominio e il suo primo livello di sottodomini.",
|
"selectDomainTypeWildcardDescription": "Questo dominio e i suoi sottodomini.",
|
||||||
"domainDelegation": "Dominio Singolo",
|
"domainDelegation": "Dominio Singolo",
|
||||||
"selectType": "Seleziona un tipo",
|
"selectType": "Seleziona un tipo",
|
||||||
"actions": "Azioni",
|
"actions": "Azioni",
|
||||||
|
@ -1196,7 +1195,7 @@
|
||||||
"sidebarExpand": "Espandi",
|
"sidebarExpand": "Espandi",
|
||||||
"newtUpdateAvailable": "Aggiornamento Disponibile",
|
"newtUpdateAvailable": "Aggiornamento Disponibile",
|
||||||
"newtUpdateAvailableInfo": "È disponibile una nuova versione di Newt. Si prega di aggiornare all'ultima versione per la migliore esperienza.",
|
"newtUpdateAvailableInfo": "È disponibile una nuova versione di Newt. Si prega di aggiornare all'ultima versione per la migliore esperienza.",
|
||||||
"domainPickerEnterDomain": "Inserisci il tuo dominio",
|
"domainPickerEnterDomain": "Dominio",
|
||||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, o semplicemente myapp",
|
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, o semplicemente myapp",
|
||||||
"domainPickerDescription": "Inserisci il dominio completo della risorsa per vedere le opzioni disponibili.",
|
"domainPickerDescription": "Inserisci il dominio completo della risorsa per vedere le opzioni disponibili.",
|
||||||
"domainPickerDescriptionSaas": "Inserisci un dominio completo, un sottodominio o semplicemente un nome per vedere le opzioni disponibili",
|
"domainPickerDescriptionSaas": "Inserisci un dominio completo, un sottodominio o semplicemente un nome per vedere le opzioni disponibili",
|
||||||
|
@ -1206,7 +1205,7 @@
|
||||||
"domainPickerSortAsc": "A-Z",
|
"domainPickerSortAsc": "A-Z",
|
||||||
"domainPickerSortDesc": "Z-A",
|
"domainPickerSortDesc": "Z-A",
|
||||||
"domainPickerCheckingAvailability": "Controllando la disponibilità...",
|
"domainPickerCheckingAvailability": "Controllando la disponibilità...",
|
||||||
"domainPickerNoMatchingDomains": "Nessun dominio corrispondente trovato per \"{userInput}\". Prova un altro dominio o controlla le impostazioni del dominio della tua organizzazione.",
|
"domainPickerNoMatchingDomains": "Nessun dominio corrispondente trovato. Prova un dominio diverso o verifica le impostazioni del dominio della tua organizzazione.",
|
||||||
"domainPickerOrganizationDomains": "Domini dell'Organizzazione",
|
"domainPickerOrganizationDomains": "Domini dell'Organizzazione",
|
||||||
"domainPickerProvidedDomains": "Domini Forniti",
|
"domainPickerProvidedDomains": "Domini Forniti",
|
||||||
"domainPickerSubdomain": "Sottodominio: {subdomain}",
|
"domainPickerSubdomain": "Sottodominio: {subdomain}",
|
||||||
|
@ -1266,6 +1265,7 @@
|
||||||
"createDomainName": "Nome:",
|
"createDomainName": "Nome:",
|
||||||
"createDomainValue": "Valore:",
|
"createDomainValue": "Valore:",
|
||||||
"createDomainCnameRecords": "Record CNAME",
|
"createDomainCnameRecords": "Record CNAME",
|
||||||
|
"createDomainARecords": "Record A",
|
||||||
"createDomainRecordNumber": "Record {number}",
|
"createDomainRecordNumber": "Record {number}",
|
||||||
"createDomainTxtRecords": "Record TXT",
|
"createDomainTxtRecords": "Record TXT",
|
||||||
"createDomainSaveTheseRecords": "Salva Questi Record",
|
"createDomainSaveTheseRecords": "Salva Questi Record",
|
||||||
|
@ -1273,5 +1273,50 @@
|
||||||
"createDomainDnsPropagation": "Propagazione DNS",
|
"createDomainDnsPropagation": "Propagazione DNS",
|
||||||
"createDomainDnsPropagationDescription": "Le modifiche DNS possono richiedere del tempo per propagarsi in Internet. Questo può richiedere da pochi minuti a 48 ore, a seconda del tuo provider DNS e delle impostazioni TTL.",
|
"createDomainDnsPropagationDescription": "Le modifiche DNS possono richiedere del tempo per propagarsi in Internet. Questo può richiedere da pochi minuti a 48 ore, a seconda del tuo provider DNS e delle impostazioni TTL.",
|
||||||
"resourcePortRequired": "Numero di porta richiesto per risorse non-HTTP",
|
"resourcePortRequired": "Numero di porta richiesto per risorse non-HTTP",
|
||||||
"resourcePortNotAllowed": "Il numero di porta non deve essere impostato per risorse HTTP"
|
"resourcePortNotAllowed": "Il numero di porta non deve essere impostato per risorse HTTP",
|
||||||
|
"signUpTerms": {
|
||||||
|
"IAgreeToThe": "Accetto i",
|
||||||
|
"termsOfService": "termini di servizio",
|
||||||
|
"and": "e",
|
||||||
|
"privacyPolicy": "informativa sulla privacy"
|
||||||
|
},
|
||||||
|
"siteRequired": "Il sito è richiesto.",
|
||||||
|
"olmTunnel": "Olm Tunnel",
|
||||||
|
"olmTunnelDescription": "Usa Olm per la connettività client",
|
||||||
|
"errorCreatingClient": "Errore nella creazione del client",
|
||||||
|
"clientDefaultsNotFound": "Impostazioni predefinite del client non trovate",
|
||||||
|
"createClient": "Crea Cliente",
|
||||||
|
"createClientDescription": "Crea un nuovo cliente per connettersi ai tuoi siti",
|
||||||
|
"seeAllClients": "Vedi Tutti i Clienti",
|
||||||
|
"clientInformation": "Informazioni sul Cliente",
|
||||||
|
"clientNamePlaceholder": "Nome Cliente",
|
||||||
|
"address": "Indirizzo",
|
||||||
|
"subnetPlaceholder": "Sottorete",
|
||||||
|
"addressDescription": "L'indirizzo che questo cliente utilizzerà per la connettività",
|
||||||
|
"selectSites": "Seleziona siti",
|
||||||
|
"sitesDescription": "Il cliente avrà connettività ai siti selezionati",
|
||||||
|
"clientInstallOlm": "Installa Olm",
|
||||||
|
"clientInstallOlmDescription": "Avvia Olm sul tuo sistema",
|
||||||
|
"clientOlmCredentials": "Credenziali Olm",
|
||||||
|
"clientOlmCredentialsDescription": "Ecco come Olm si autenticherà con il server",
|
||||||
|
"olmEndpoint": "Endpoint Olm",
|
||||||
|
"olmId": "ID Olm",
|
||||||
|
"olmSecretKey": "Chiave Segreta Olm",
|
||||||
|
"clientCredentialsSave": "Salva le Tue Credenziali",
|
||||||
|
"clientCredentialsSaveDescription": "Potrai vederlo solo una volta. Assicurati di copiarlo in un luogo sicuro.",
|
||||||
|
"generalSettingsDescription": "Configura le impostazioni generali per questo cliente",
|
||||||
|
"clientUpdated": "Cliente aggiornato",
|
||||||
|
"clientUpdatedDescription": "Il cliente è stato aggiornato.",
|
||||||
|
"clientUpdateFailed": "Impossibile aggiornare il cliente",
|
||||||
|
"clientUpdateError": "Si è verificato un errore durante l'aggiornamento del cliente.",
|
||||||
|
"sitesFetchFailed": "Impossibile recuperare i siti",
|
||||||
|
"sitesFetchError": "Si è verificato un errore durante il recupero dei siti.",
|
||||||
|
"olmErrorFetchReleases": "Si è verificato un errore durante il recupero delle versioni di Olm.",
|
||||||
|
"olmErrorFetchLatest": "Si è verificato un errore durante il recupero dell'ultima versione di Olm.",
|
||||||
|
"remoteSubnets": "Sottoreti Remote",
|
||||||
|
"enterCidrRange": "Inserisci l'intervallo CIDR",
|
||||||
|
"remoteSubnetsDescription": "Aggiungi intervalli CIDR che possono accedere a questo sito da remoto. Usa il formato come 10.0.0.0/24 o 192.168.1.0/24.",
|
||||||
|
"resourceEnableProxy": "Abilita Proxy Pubblico",
|
||||||
|
"resourceEnableProxyDescription": "Abilita il proxy pubblico a questa risorsa. Consente l'accesso alla risorsa dall'esterno della rete tramite il cloud su una porta aperta. Richiede la configurazione di Traefik.",
|
||||||
|
"externalProxyEnabled": "Proxy Esterno Abilitato"
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,6 @@
|
||||||
"siteErrorCreate": "사이트 생성 오류",
|
"siteErrorCreate": "사이트 생성 오류",
|
||||||
"siteErrorCreateKeyPair": "키 쌍 또는 사이트 기본값을 찾을 수 없습니다",
|
"siteErrorCreateKeyPair": "키 쌍 또는 사이트 기본값을 찾을 수 없습니다",
|
||||||
"siteErrorCreateDefaults": "사이트 기본값을 찾을 수 없습니다",
|
"siteErrorCreateDefaults": "사이트 기본값을 찾을 수 없습니다",
|
||||||
"siteNameDescription": "이것은 사이트의 표시 이름입니다.",
|
|
||||||
"method": "방법",
|
"method": "방법",
|
||||||
"siteMethodDescription": "이것이 연결을 노출하는 방법입니다.",
|
"siteMethodDescription": "이것이 연결을 노출하는 방법입니다.",
|
||||||
"siteLearnNewt": "시스템에 Newt 설치하는 방법 배우기",
|
"siteLearnNewt": "시스템에 Newt 설치하는 방법 배우기",
|
||||||
|
@ -1094,7 +1093,7 @@
|
||||||
"sidebarAllUsers": "모든 사용자",
|
"sidebarAllUsers": "모든 사용자",
|
||||||
"sidebarIdentityProviders": "신원 공급자",
|
"sidebarIdentityProviders": "신원 공급자",
|
||||||
"sidebarLicense": "라이선스",
|
"sidebarLicense": "라이선스",
|
||||||
"sidebarClients": "클라이언트",
|
"sidebarClients": "Clients (Beta)",
|
||||||
"sidebarDomains": "도메인",
|
"sidebarDomains": "도메인",
|
||||||
"enableDockerSocket": "Docker 소켓 활성화",
|
"enableDockerSocket": "Docker 소켓 활성화",
|
||||||
"enableDockerSocketDescription": "컨테이너 정보를 채우기 위해 Docker 소켓 검색을 활성화합니다. 소켓 경로는 Newt에 제공되어야 합니다.",
|
"enableDockerSocketDescription": "컨테이너 정보를 채우기 위해 Docker 소켓 검색을 활성화합니다. 소켓 경로는 Newt에 제공되어야 합니다.",
|
||||||
|
@ -1162,7 +1161,7 @@
|
||||||
"selectDomainTypeCnameName": "단일 도메인 (CNAME)",
|
"selectDomainTypeCnameName": "단일 도메인 (CNAME)",
|
||||||
"selectDomainTypeCnameDescription": "단일 하위 도메인 또는 특정 도메인 항목에 사용됩니다.",
|
"selectDomainTypeCnameDescription": "단일 하위 도메인 또는 특정 도메인 항목에 사용됩니다.",
|
||||||
"selectDomainTypeWildcardName": "와일드카드 도메인",
|
"selectDomainTypeWildcardName": "와일드카드 도메인",
|
||||||
"selectDomainTypeWildcardDescription": "이 도메인과 그 첫 번째 레벨의 하위 도메인입니다.",
|
"selectDomainTypeWildcardDescription": "This domain and its subdomains.",
|
||||||
"domainDelegation": "단일 도메인",
|
"domainDelegation": "단일 도메인",
|
||||||
"selectType": "유형 선택",
|
"selectType": "유형 선택",
|
||||||
"actions": "작업",
|
"actions": "작업",
|
||||||
|
@ -1196,7 +1195,7 @@
|
||||||
"sidebarExpand": "확장하기",
|
"sidebarExpand": "확장하기",
|
||||||
"newtUpdateAvailable": "업데이트 가능",
|
"newtUpdateAvailable": "업데이트 가능",
|
||||||
"newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
|
"newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
|
||||||
"domainPickerEnterDomain": "도메인 입력",
|
"domainPickerEnterDomain": "Domain",
|
||||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, 또는 그냥 myapp",
|
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, 또는 그냥 myapp",
|
||||||
"domainPickerDescription": "Enter the full domain of the resource to see available options.",
|
"domainPickerDescription": "Enter the full domain of the resource to see available options.",
|
||||||
"domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
|
"domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
|
||||||
|
@ -1206,7 +1205,7 @@
|
||||||
"domainPickerSortAsc": "A-Z",
|
"domainPickerSortAsc": "A-Z",
|
||||||
"domainPickerSortDesc": "Z-A",
|
"domainPickerSortDesc": "Z-A",
|
||||||
"domainPickerCheckingAvailability": "가용성을 확인 중...",
|
"domainPickerCheckingAvailability": "가용성을 확인 중...",
|
||||||
"domainPickerNoMatchingDomains": "\"{userInput}\"에 해당하는 도메인을 찾을 수 없습니다. 다른 도메인을 시도하거나 조직의 도메인 설정을 확인하세요.",
|
"domainPickerNoMatchingDomains": "No matching domains found. Try a different domain or check your organization's domain settings.",
|
||||||
"domainPickerOrganizationDomains": "조직 도메인",
|
"domainPickerOrganizationDomains": "조직 도메인",
|
||||||
"domainPickerProvidedDomains": "제공된 도메인",
|
"domainPickerProvidedDomains": "제공된 도메인",
|
||||||
"domainPickerSubdomain": "서브도메인: {subdomain}",
|
"domainPickerSubdomain": "서브도메인: {subdomain}",
|
||||||
|
@ -1266,6 +1265,7 @@
|
||||||
"createDomainName": "이름:",
|
"createDomainName": "이름:",
|
||||||
"createDomainValue": "값:",
|
"createDomainValue": "값:",
|
||||||
"createDomainCnameRecords": "CNAME 레코드",
|
"createDomainCnameRecords": "CNAME 레코드",
|
||||||
|
"createDomainARecords": "A Records",
|
||||||
"createDomainRecordNumber": "레코드 {number}",
|
"createDomainRecordNumber": "레코드 {number}",
|
||||||
"createDomainTxtRecords": "TXT 레코드",
|
"createDomainTxtRecords": "TXT 레코드",
|
||||||
"createDomainSaveTheseRecords": "이 레코드 저장",
|
"createDomainSaveTheseRecords": "이 레코드 저장",
|
||||||
|
@ -1273,5 +1273,50 @@
|
||||||
"createDomainDnsPropagation": "DNS 전파",
|
"createDomainDnsPropagation": "DNS 전파",
|
||||||
"createDomainDnsPropagationDescription": "DNS 변경 사항은 인터넷 전체에 전파되는 데 시간이 걸립니다. DNS 제공자와 TTL 설정에 따라 몇 분에서 48시간까지 걸릴 수 있습니다.",
|
"createDomainDnsPropagationDescription": "DNS 변경 사항은 인터넷 전체에 전파되는 데 시간이 걸립니다. DNS 제공자와 TTL 설정에 따라 몇 분에서 48시간까지 걸릴 수 있습니다.",
|
||||||
"resourcePortRequired": "HTTP 리소스가 아닌 경우 포트 번호가 필요합니다",
|
"resourcePortRequired": "HTTP 리소스가 아닌 경우 포트 번호가 필요합니다",
|
||||||
"resourcePortNotAllowed": "HTTP 리소스에 대해 포트 번호를 설정하지 마세요"
|
"resourcePortNotAllowed": "HTTP 리소스에 대해 포트 번호를 설정하지 마세요",
|
||||||
|
"signUpTerms": {
|
||||||
|
"IAgreeToThe": "I agree to the",
|
||||||
|
"termsOfService": "terms of service",
|
||||||
|
"and": "and",
|
||||||
|
"privacyPolicy": "privacy policy"
|
||||||
|
},
|
||||||
|
"siteRequired": "Site is required.",
|
||||||
|
"olmTunnel": "Olm Tunnel",
|
||||||
|
"olmTunnelDescription": "Use Olm for client connectivity",
|
||||||
|
"errorCreatingClient": "Error creating client",
|
||||||
|
"clientDefaultsNotFound": "Client defaults not found",
|
||||||
|
"createClient": "Create Client",
|
||||||
|
"createClientDescription": "Create a new client for connecting to your sites",
|
||||||
|
"seeAllClients": "See All Clients",
|
||||||
|
"clientInformation": "Client Information",
|
||||||
|
"clientNamePlaceholder": "Client name",
|
||||||
|
"address": "Address",
|
||||||
|
"subnetPlaceholder": "Subnet",
|
||||||
|
"addressDescription": "The address that this client will use for connectivity",
|
||||||
|
"selectSites": "Select sites",
|
||||||
|
"sitesDescription": "The client will have connectivity to the selected sites",
|
||||||
|
"clientInstallOlm": "Install Olm",
|
||||||
|
"clientInstallOlmDescription": "Get Olm running on your system",
|
||||||
|
"clientOlmCredentials": "Olm Credentials",
|
||||||
|
"clientOlmCredentialsDescription": "This is how Olm will authenticate with the server",
|
||||||
|
"olmEndpoint": "Olm Endpoint",
|
||||||
|
"olmId": "Olm ID",
|
||||||
|
"olmSecretKey": "Olm Secret Key",
|
||||||
|
"clientCredentialsSave": "Save Your Credentials",
|
||||||
|
"clientCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.",
|
||||||
|
"generalSettingsDescription": "Configure the general settings for this client",
|
||||||
|
"clientUpdated": "Client updated",
|
||||||
|
"clientUpdatedDescription": "The client has been updated.",
|
||||||
|
"clientUpdateFailed": "Failed to update client",
|
||||||
|
"clientUpdateError": "An error occurred while updating the client.",
|
||||||
|
"sitesFetchFailed": "Failed to fetch sites",
|
||||||
|
"sitesFetchError": "An error occurred while fetching sites.",
|
||||||
|
"olmErrorFetchReleases": "An error occurred while fetching Olm releases.",
|
||||||
|
"olmErrorFetchLatest": "An error occurred while fetching the latest Olm release.",
|
||||||
|
"remoteSubnets": "Remote Subnets",
|
||||||
|
"enterCidrRange": "Enter CIDR range",
|
||||||
|
"remoteSubnetsDescription": "Add CIDR ranges that can access this site remotely. Use format like 10.0.0.0/24 or 192.168.1.0/24.",
|
||||||
|
"resourceEnableProxy": "Enable Public Proxy",
|
||||||
|
"resourceEnableProxyDescription": "Enable public proxying to this resource. This allows access to the resource from outside the network through the cloud on an open port. Requires Traefik config.",
|
||||||
|
"externalProxyEnabled": "External Proxy Enabled"
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,6 @@
|
||||||
"siteErrorCreate": "Fout bij maken site",
|
"siteErrorCreate": "Fout bij maken site",
|
||||||
"siteErrorCreateKeyPair": "Key pair of site standaard niet gevonden",
|
"siteErrorCreateKeyPair": "Key pair of site standaard niet gevonden",
|
||||||
"siteErrorCreateDefaults": "Standaardinstellingen niet gevonden",
|
"siteErrorCreateDefaults": "Standaardinstellingen niet gevonden",
|
||||||
"siteNameDescription": "Dit is de weergavenaam van de site.",
|
|
||||||
"method": "Methode",
|
"method": "Methode",
|
||||||
"siteMethodDescription": "Op deze manier legt u verbindingen bloot.",
|
"siteMethodDescription": "Op deze manier legt u verbindingen bloot.",
|
||||||
"siteLearnNewt": "Leer hoe u Newt kunt installeren op uw systeem",
|
"siteLearnNewt": "Leer hoe u Newt kunt installeren op uw systeem",
|
||||||
|
@ -1094,7 +1093,7 @@
|
||||||
"sidebarAllUsers": "Alle gebruikers",
|
"sidebarAllUsers": "Alle gebruikers",
|
||||||
"sidebarIdentityProviders": "Identiteit aanbieders",
|
"sidebarIdentityProviders": "Identiteit aanbieders",
|
||||||
"sidebarLicense": "Licentie",
|
"sidebarLicense": "Licentie",
|
||||||
"sidebarClients": "Cliënten",
|
"sidebarClients": "Clients (Bèta)",
|
||||||
"sidebarDomains": "Domeinen",
|
"sidebarDomains": "Domeinen",
|
||||||
"enableDockerSocket": "Docker Socket inschakelen",
|
"enableDockerSocket": "Docker Socket inschakelen",
|
||||||
"enableDockerSocketDescription": "Docker Socket-ontdekking inschakelen voor het invullen van containerinformatie. Socket-pad moet aan Newt worden verstrekt.",
|
"enableDockerSocketDescription": "Docker Socket-ontdekking inschakelen voor het invullen van containerinformatie. Socket-pad moet aan Newt worden verstrekt.",
|
||||||
|
@ -1162,7 +1161,7 @@
|
||||||
"selectDomainTypeCnameName": "Enkel domein (CNAME)",
|
"selectDomainTypeCnameName": "Enkel domein (CNAME)",
|
||||||
"selectDomainTypeCnameDescription": "Alleen dit specifieke domein. Gebruik dit voor individuele subdomeinen of specifieke domeinvermeldingen.",
|
"selectDomainTypeCnameDescription": "Alleen dit specifieke domein. Gebruik dit voor individuele subdomeinen of specifieke domeinvermeldingen.",
|
||||||
"selectDomainTypeWildcardName": "Wildcard Domein",
|
"selectDomainTypeWildcardName": "Wildcard Domein",
|
||||||
"selectDomainTypeWildcardDescription": "Dit domein en zijn eerste niveau van subdomeinen.",
|
"selectDomainTypeWildcardDescription": "Dit domein en zijn subdomeinen.",
|
||||||
"domainDelegation": "Enkel domein",
|
"domainDelegation": "Enkel domein",
|
||||||
"selectType": "Selecteer een type",
|
"selectType": "Selecteer een type",
|
||||||
"actions": "acties",
|
"actions": "acties",
|
||||||
|
@ -1196,7 +1195,7 @@
|
||||||
"sidebarExpand": "Uitklappen",
|
"sidebarExpand": "Uitklappen",
|
||||||
"newtUpdateAvailable": "Update beschikbaar",
|
"newtUpdateAvailable": "Update beschikbaar",
|
||||||
"newtUpdateAvailableInfo": "Er is een nieuwe versie van Newt beschikbaar. Update naar de nieuwste versie voor de beste ervaring.",
|
"newtUpdateAvailableInfo": "Er is een nieuwe versie van Newt beschikbaar. Update naar de nieuwste versie voor de beste ervaring.",
|
||||||
"domainPickerEnterDomain": "Voer je domein in",
|
"domainPickerEnterDomain": "Domein",
|
||||||
"domainPickerPlaceholder": "mijnapp.voorbeeld.com, api.v1.mijndomein.com, of gewoon mijnapp",
|
"domainPickerPlaceholder": "mijnapp.voorbeeld.com, api.v1.mijndomein.com, of gewoon mijnapp",
|
||||||
"domainPickerDescription": "Voer de volledige domein van de bron in om beschikbare opties te zien.",
|
"domainPickerDescription": "Voer de volledige domein van de bron in om beschikbare opties te zien.",
|
||||||
"domainPickerDescriptionSaas": "Voer een volledig domein, subdomein of gewoon een naam in om beschikbare opties te zien",
|
"domainPickerDescriptionSaas": "Voer een volledig domein, subdomein of gewoon een naam in om beschikbare opties te zien",
|
||||||
|
@ -1206,7 +1205,7 @@
|
||||||
"domainPickerSortAsc": "A-Z",
|
"domainPickerSortAsc": "A-Z",
|
||||||
"domainPickerSortDesc": "Z-A",
|
"domainPickerSortDesc": "Z-A",
|
||||||
"domainPickerCheckingAvailability": "Beschikbaarheid controleren...",
|
"domainPickerCheckingAvailability": "Beschikbaarheid controleren...",
|
||||||
"domainPickerNoMatchingDomains": "Geen overeenkomende domeinen gevonden voor \"{userInput}\". Probeer een ander domein of controleer de domeininstellingen van je organisatie.",
|
"domainPickerNoMatchingDomains": "Geen overeenkomende domeinen gevonden. Probeer een ander domein of controleer de domeininstellingen van uw organisatie.",
|
||||||
"domainPickerOrganizationDomains": "Organisatiedomeinen",
|
"domainPickerOrganizationDomains": "Organisatiedomeinen",
|
||||||
"domainPickerProvidedDomains": "Aangeboden domeinen",
|
"domainPickerProvidedDomains": "Aangeboden domeinen",
|
||||||
"domainPickerSubdomain": "Subdomein: {subdomain}",
|
"domainPickerSubdomain": "Subdomein: {subdomain}",
|
||||||
|
@ -1266,6 +1265,7 @@
|
||||||
"createDomainName": "Naam:",
|
"createDomainName": "Naam:",
|
||||||
"createDomainValue": "Waarde:",
|
"createDomainValue": "Waarde:",
|
||||||
"createDomainCnameRecords": "CNAME-records",
|
"createDomainCnameRecords": "CNAME-records",
|
||||||
|
"createDomainARecords": "A Records",
|
||||||
"createDomainRecordNumber": "Record {number}",
|
"createDomainRecordNumber": "Record {number}",
|
||||||
"createDomainTxtRecords": "TXT-records",
|
"createDomainTxtRecords": "TXT-records",
|
||||||
"createDomainSaveTheseRecords": "Deze records opslaan",
|
"createDomainSaveTheseRecords": "Deze records opslaan",
|
||||||
|
@ -1273,5 +1273,50 @@
|
||||||
"createDomainDnsPropagation": "DNS-propagatie",
|
"createDomainDnsPropagation": "DNS-propagatie",
|
||||||
"createDomainDnsPropagationDescription": "DNS-wijzigingen kunnen enige tijd duren om over het internet te worden verspreid. Dit kan enkele minuten tot 48 uur duren, afhankelijk van je DNS-provider en TTL-instellingen.",
|
"createDomainDnsPropagationDescription": "DNS-wijzigingen kunnen enige tijd duren om over het internet te worden verspreid. Dit kan enkele minuten tot 48 uur duren, afhankelijk van je DNS-provider en TTL-instellingen.",
|
||||||
"resourcePortRequired": "Poortnummer is vereist voor niet-HTTP-bronnen",
|
"resourcePortRequired": "Poortnummer is vereist voor niet-HTTP-bronnen",
|
||||||
"resourcePortNotAllowed": "Poortnummer mag niet worden ingesteld voor HTTP-bronnen"
|
"resourcePortNotAllowed": "Poortnummer mag niet worden ingesteld voor HTTP-bronnen",
|
||||||
|
"signUpTerms": {
|
||||||
|
"IAgreeToThe": "Ik ga akkoord met de",
|
||||||
|
"termsOfService": "servicevoorwaarden",
|
||||||
|
"and": "en",
|
||||||
|
"privacyPolicy": "privacybeleid"
|
||||||
|
},
|
||||||
|
"siteRequired": "Site is vereist.",
|
||||||
|
"olmTunnel": "Olm Tunnel",
|
||||||
|
"olmTunnelDescription": "Gebruik Olm voor clientconnectiviteit",
|
||||||
|
"errorCreatingClient": "Fout bij het aanmaken van de client",
|
||||||
|
"clientDefaultsNotFound": "Standaardinstellingen van klant niet gevonden",
|
||||||
|
"createClient": "Client aanmaken",
|
||||||
|
"createClientDescription": "Maak een nieuwe client aan om verbinding te maken met uw sites",
|
||||||
|
"seeAllClients": "Alle clients bekijken",
|
||||||
|
"clientInformation": "Klantinformatie",
|
||||||
|
"clientNamePlaceholder": "Clientnaam",
|
||||||
|
"address": "Adres",
|
||||||
|
"subnetPlaceholder": "Subnet",
|
||||||
|
"addressDescription": "Het adres dat deze client zal gebruiken voor connectiviteit",
|
||||||
|
"selectSites": "Selecteer sites",
|
||||||
|
"sitesDescription": "De client heeft connectiviteit met de geselecteerde sites",
|
||||||
|
"clientInstallOlm": "Installeer Olm",
|
||||||
|
"clientInstallOlmDescription": "Laat Olm draaien op uw systeem",
|
||||||
|
"clientOlmCredentials": "Olm inloggegevens",
|
||||||
|
"clientOlmCredentialsDescription": "Dit is hoe Olm zich bij de server zal verifiëren",
|
||||||
|
"olmEndpoint": "Olm Eindpunt",
|
||||||
|
"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.",
|
||||||
|
"generalSettingsDescription": "Configureer de algemene instellingen voor deze client",
|
||||||
|
"clientUpdated": "Klant bijgewerkt ",
|
||||||
|
"clientUpdatedDescription": "De client is bijgewerkt.",
|
||||||
|
"clientUpdateFailed": "Het bijwerken van de client is mislukt",
|
||||||
|
"clientUpdateError": "Er is een fout opgetreden tijdens het bijwerken van de client.",
|
||||||
|
"sitesFetchFailed": "Het ophalen van sites is mislukt",
|
||||||
|
"sitesFetchError": "Er is een fout opgetreden bij het ophalen van sites.",
|
||||||
|
"olmErrorFetchReleases": "Er is een fout opgetreden bij het ophalen van Olm releases.",
|
||||||
|
"olmErrorFetchLatest": "Er is een fout opgetreden bij het ophalen van de nieuwste Olm release.",
|
||||||
|
"remoteSubnets": "Externe Subnets",
|
||||||
|
"enterCidrRange": "Voer CIDR-bereik in",
|
||||||
|
"remoteSubnetsDescription": "Voeg CIDR-bereiken toe die deze site op afstand kunnen openen. Gebruik een format zoals 10.0.0.0/24 of 192.168.1.0/24.",
|
||||||
|
"resourceEnableProxy": "Openbare proxy inschakelen",
|
||||||
|
"resourceEnableProxyDescription": "Schakel publieke proxy in voor deze resource. Dit maakt toegang tot de resource mogelijk vanuit het netwerk via de cloud met een open poort. Vereist Traefik-configuratie.",
|
||||||
|
"externalProxyEnabled": "Externe Proxy Ingeschakeld"
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,6 @@
|
||||||
"siteErrorCreate": "Błąd podczas tworzenia witryny",
|
"siteErrorCreate": "Błąd podczas tworzenia witryny",
|
||||||
"siteErrorCreateKeyPair": "Nie znaleziono pary kluczy lub domyślnych ustawień witryny",
|
"siteErrorCreateKeyPair": "Nie znaleziono pary kluczy lub domyślnych ustawień witryny",
|
||||||
"siteErrorCreateDefaults": "Nie znaleziono domyślnych ustawień witryny",
|
"siteErrorCreateDefaults": "Nie znaleziono domyślnych ustawień witryny",
|
||||||
"siteNameDescription": "To jest wyświetlana nazwa witryny.",
|
|
||||||
"method": "Metoda",
|
"method": "Metoda",
|
||||||
"siteMethodDescription": "W ten sposób ujawnisz połączenia.",
|
"siteMethodDescription": "W ten sposób ujawnisz połączenia.",
|
||||||
"siteLearnNewt": "Dowiedz się, jak zainstalować Newt w systemie",
|
"siteLearnNewt": "Dowiedz się, jak zainstalować Newt w systemie",
|
||||||
|
@ -1094,7 +1093,7 @@
|
||||||
"sidebarAllUsers": "Wszyscy użytkownicy",
|
"sidebarAllUsers": "Wszyscy użytkownicy",
|
||||||
"sidebarIdentityProviders": "Dostawcy tożsamości",
|
"sidebarIdentityProviders": "Dostawcy tożsamości",
|
||||||
"sidebarLicense": "Licencja",
|
"sidebarLicense": "Licencja",
|
||||||
"sidebarClients": "Klienci",
|
"sidebarClients": "Klienci (Beta)",
|
||||||
"sidebarDomains": "Domeny",
|
"sidebarDomains": "Domeny",
|
||||||
"enableDockerSocket": "Włącz gniazdo dokera",
|
"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.",
|
"enableDockerSocketDescription": "Włącz wykrywanie Docker Socket w celu wypełnienia informacji o kontenerach. Ścieżka gniazda musi być dostarczona do Newt.",
|
||||||
|
@ -1162,7 +1161,7 @@
|
||||||
"selectDomainTypeCnameName": "Pojedyncza domena (CNAME)",
|
"selectDomainTypeCnameName": "Pojedyncza domena (CNAME)",
|
||||||
"selectDomainTypeCnameDescription": "Tylko ta pojedyncza domena. Użyj tego dla poszczególnych subdomen lub wpisów specyficznych dla domeny.",
|
"selectDomainTypeCnameDescription": "Tylko ta pojedyncza domena. Użyj tego dla poszczególnych subdomen lub wpisów specyficznych dla domeny.",
|
||||||
"selectDomainTypeWildcardName": "Domena wieloznaczna",
|
"selectDomainTypeWildcardName": "Domena wieloznaczna",
|
||||||
"selectDomainTypeWildcardDescription": "Ta domena i jej pierwsza warstwa subdomen.",
|
"selectDomainTypeWildcardDescription": "Ta domena i jej subdomeny.",
|
||||||
"domainDelegation": "Pojedyncza domena",
|
"domainDelegation": "Pojedyncza domena",
|
||||||
"selectType": "Wybierz typ",
|
"selectType": "Wybierz typ",
|
||||||
"actions": "Akcje",
|
"actions": "Akcje",
|
||||||
|
@ -1196,7 +1195,7 @@
|
||||||
"sidebarExpand": "Rozwiń",
|
"sidebarExpand": "Rozwiń",
|
||||||
"newtUpdateAvailable": "Dostępna aktualizacja",
|
"newtUpdateAvailable": "Dostępna aktualizacja",
|
||||||
"newtUpdateAvailableInfo": "Nowa wersja Newt jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.",
|
"newtUpdateAvailableInfo": "Nowa wersja Newt jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.",
|
||||||
"domainPickerEnterDomain": "Wprowadź swoją domenę",
|
"domainPickerEnterDomain": "Domena",
|
||||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com lub po prostu myapp",
|
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com lub po prostu myapp",
|
||||||
"domainPickerDescription": "Wpisz pełną domenę zasobu, aby zobaczyć dostępne opcje.",
|
"domainPickerDescription": "Wpisz pełną domenę zasobu, aby zobaczyć dostępne opcje.",
|
||||||
"domainPickerDescriptionSaas": "Wprowadź pełną domenę, subdomenę lub po prostu nazwę, aby zobaczyć dostępne opcje",
|
"domainPickerDescriptionSaas": "Wprowadź pełną domenę, subdomenę lub po prostu nazwę, aby zobaczyć dostępne opcje",
|
||||||
|
@ -1206,7 +1205,7 @@
|
||||||
"domainPickerSortAsc": "A-Z",
|
"domainPickerSortAsc": "A-Z",
|
||||||
"domainPickerSortDesc": "Z-A",
|
"domainPickerSortDesc": "Z-A",
|
||||||
"domainPickerCheckingAvailability": "Sprawdzanie dostępności...",
|
"domainPickerCheckingAvailability": "Sprawdzanie dostępności...",
|
||||||
"domainPickerNoMatchingDomains": "Nie znaleziono żadnych pasujących domen dla \"{userInput}\". Spróbuj innej domeny lub sprawdź ustawienia domeny swojej organizacji.",
|
"domainPickerNoMatchingDomains": "Nie znaleziono pasujących domen. Spróbuj innej domeny lub sprawdź ustawienia domeny swojej organizacji.",
|
||||||
"domainPickerOrganizationDomains": "Domeny organizacji",
|
"domainPickerOrganizationDomains": "Domeny organizacji",
|
||||||
"domainPickerProvidedDomains": "Dostarczone domeny",
|
"domainPickerProvidedDomains": "Dostarczone domeny",
|
||||||
"domainPickerSubdomain": "Subdomena: {subdomain}",
|
"domainPickerSubdomain": "Subdomena: {subdomain}",
|
||||||
|
@ -1266,6 +1265,7 @@
|
||||||
"createDomainName": "Nazwa:",
|
"createDomainName": "Nazwa:",
|
||||||
"createDomainValue": "Wartość:",
|
"createDomainValue": "Wartość:",
|
||||||
"createDomainCnameRecords": "Rekordy CNAME",
|
"createDomainCnameRecords": "Rekordy CNAME",
|
||||||
|
"createDomainARecords": "Rekordy A",
|
||||||
"createDomainRecordNumber": "Rekord {number}",
|
"createDomainRecordNumber": "Rekord {number}",
|
||||||
"createDomainTxtRecords": "Rekordy TXT",
|
"createDomainTxtRecords": "Rekordy TXT",
|
||||||
"createDomainSaveTheseRecords": "Zapisz te rekordy",
|
"createDomainSaveTheseRecords": "Zapisz te rekordy",
|
||||||
|
@ -1273,5 +1273,50 @@
|
||||||
"createDomainDnsPropagation": "Propagacja DNS",
|
"createDomainDnsPropagation": "Propagacja DNS",
|
||||||
"createDomainDnsPropagationDescription": "Zmiany DNS mogą zająć trochę czasu na rozpropagowanie się w Internecie. Może to potrwać od kilku minut do 48 godzin, w zależności od dostawcy DNS i ustawień TTL.",
|
"createDomainDnsPropagationDescription": "Zmiany DNS mogą zająć trochę czasu na rozpropagowanie się w Internecie. Może to potrwać od kilku minut do 48 godzin, w zależności od dostawcy DNS i ustawień TTL.",
|
||||||
"resourcePortRequired": "Numer portu jest wymagany dla zasobów non-HTTP",
|
"resourcePortRequired": "Numer portu jest wymagany dla zasobów non-HTTP",
|
||||||
"resourcePortNotAllowed": "Numer portu nie powinien być ustawiony dla zasobów HTTP"
|
"resourcePortNotAllowed": "Numer portu nie powinien być ustawiony dla zasobów HTTP",
|
||||||
|
"signUpTerms": {
|
||||||
|
"IAgreeToThe": "Zgadzam się z",
|
||||||
|
"termsOfService": "warunkami usługi",
|
||||||
|
"and": "oraz",
|
||||||
|
"privacyPolicy": "polityką prywatności"
|
||||||
|
},
|
||||||
|
"siteRequired": "Strona jest wymagana.",
|
||||||
|
"olmTunnel": "Tunel Olm",
|
||||||
|
"olmTunnelDescription": "Użyj Olm do łączności klienta",
|
||||||
|
"errorCreatingClient": "Błąd podczas tworzenia klienta",
|
||||||
|
"clientDefaultsNotFound": "Nie znaleziono domyślnych ustawień klienta",
|
||||||
|
"createClient": "Utwórz Klienta",
|
||||||
|
"createClientDescription": "Utwórz nowego klienta do łączenia się z Twoimi witrynami",
|
||||||
|
"seeAllClients": "Zobacz Wszystkich Klientów",
|
||||||
|
"clientInformation": "Informacje o Kliencie",
|
||||||
|
"clientNamePlaceholder": "Nazwa klienta",
|
||||||
|
"address": "Adres",
|
||||||
|
"subnetPlaceholder": "Podsieć",
|
||||||
|
"addressDescription": "Adres, którego ten klient będzie używać do łączności",
|
||||||
|
"selectSites": "Wybierz witryny",
|
||||||
|
"sitesDescription": "Klient będzie miał łączność z wybranymi witrynami",
|
||||||
|
"clientInstallOlm": "Zainstaluj Olm",
|
||||||
|
"clientInstallOlmDescription": "Uruchom Olm na swoim systemie",
|
||||||
|
"clientOlmCredentials": "Poświadczenia Olm",
|
||||||
|
"clientOlmCredentialsDescription": "To jest sposób, w jaki Olm będzie się uwierzytelniać z serwerem",
|
||||||
|
"olmEndpoint": "Punkt Końcowy Olm",
|
||||||
|
"olmId": "Identyfikator Olm",
|
||||||
|
"olmSecretKey": "Tajny Klucz Olm",
|
||||||
|
"clientCredentialsSave": "Zapisz swoje poświadczenia",
|
||||||
|
"clientCredentialsSaveDescription": "Będziesz mógł zobaczyć to tylko raz. Upewnij się, że skopiujesz go w bezpieczne miejsce.",
|
||||||
|
"generalSettingsDescription": "Skonfiguruj ogólne ustawienia dla tego klienta",
|
||||||
|
"clientUpdated": "Klient zaktualizowany",
|
||||||
|
"clientUpdatedDescription": "Klient został zaktualizowany.",
|
||||||
|
"clientUpdateFailed": "Nie udało się zaktualizować klienta",
|
||||||
|
"clientUpdateError": "Wystąpił błąd podczas aktualizacji klienta.",
|
||||||
|
"sitesFetchFailed": "Nie udało się pobrać witryn",
|
||||||
|
"sitesFetchError": "Wystąpił błąd podczas pobierania witryn.",
|
||||||
|
"olmErrorFetchReleases": "Wystąpił błąd podczas pobierania wydań Olm.",
|
||||||
|
"olmErrorFetchLatest": "Wystąpił błąd podczas pobierania najnowszego wydania Olm.",
|
||||||
|
"remoteSubnets": "Zdalne Podsieci",
|
||||||
|
"enterCidrRange": "Wprowadź zakres CIDR",
|
||||||
|
"remoteSubnetsDescription": "Dodaj zakresy CIDR, które mogą uzyskać zdalny dostęp do tej witryny. Użyj formatu takiego jak 10.0.0.0/24 lub 192.168.1.0/24.",
|
||||||
|
"resourceEnableProxy": "Włącz publiczny proxy",
|
||||||
|
"resourceEnableProxyDescription": "Włącz publiczne proxy dla tego zasobu. To umożliwia dostęp do zasobu spoza sieci przez chmurę na otwartym porcie. Wymaga konfiguracji Traefik.",
|
||||||
|
"externalProxyEnabled": "Zewnętrzny Proxy Włączony"
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,6 @@
|
||||||
"siteErrorCreate": "Erro ao criar site",
|
"siteErrorCreate": "Erro ao criar site",
|
||||||
"siteErrorCreateKeyPair": "Par de chaves ou padrões do site não encontrados",
|
"siteErrorCreateKeyPair": "Par de chaves ou padrões do site não encontrados",
|
||||||
"siteErrorCreateDefaults": "Padrão do site não encontrado",
|
"siteErrorCreateDefaults": "Padrão do site não encontrado",
|
||||||
"siteNameDescription": "Este é o nome de exibição do site.",
|
|
||||||
"method": "Método",
|
"method": "Método",
|
||||||
"siteMethodDescription": "É assim que você irá expor as conexões.",
|
"siteMethodDescription": "É assim que você irá expor as conexões.",
|
||||||
"siteLearnNewt": "Saiba como instalar o Newt no seu sistema",
|
"siteLearnNewt": "Saiba como instalar o Newt no seu sistema",
|
||||||
|
@ -1094,7 +1093,7 @@
|
||||||
"sidebarAllUsers": "Todos os usuários",
|
"sidebarAllUsers": "Todos os usuários",
|
||||||
"sidebarIdentityProviders": "Provedores de identidade",
|
"sidebarIdentityProviders": "Provedores de identidade",
|
||||||
"sidebarLicense": "Tipo:",
|
"sidebarLicense": "Tipo:",
|
||||||
"sidebarClients": "Clientes",
|
"sidebarClients": "Clientes (Beta)",
|
||||||
"sidebarDomains": "Domínios",
|
"sidebarDomains": "Domínios",
|
||||||
"enableDockerSocket": "Habilitar Docker Socket",
|
"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.",
|
"enableDockerSocketDescription": "Ativar a descoberta do Docker Socket para preencher informações do contêiner. O caminho do socket deve ser fornecido ao Newt.",
|
||||||
|
@ -1162,7 +1161,7 @@
|
||||||
"selectDomainTypeCnameName": "Domínio Único (CNAME)",
|
"selectDomainTypeCnameName": "Domínio Único (CNAME)",
|
||||||
"selectDomainTypeCnameDescription": "Apenas este domínio específico. Use isso para subdomínios individuais ou entradas de domínio específicas.",
|
"selectDomainTypeCnameDescription": "Apenas este domínio específico. Use isso para subdomínios individuais ou entradas de domínio específicas.",
|
||||||
"selectDomainTypeWildcardName": "Domínio Coringa",
|
"selectDomainTypeWildcardName": "Domínio Coringa",
|
||||||
"selectDomainTypeWildcardDescription": "Este domínio e seu primeiro nível de subdomínios.",
|
"selectDomainTypeWildcardDescription": "Este domínio e seus subdomínios.",
|
||||||
"domainDelegation": "Domínio Único",
|
"domainDelegation": "Domínio Único",
|
||||||
"selectType": "Selecione um tipo",
|
"selectType": "Selecione um tipo",
|
||||||
"actions": "Ações",
|
"actions": "Ações",
|
||||||
|
@ -1196,7 +1195,7 @@
|
||||||
"sidebarExpand": "Expandir",
|
"sidebarExpand": "Expandir",
|
||||||
"newtUpdateAvailable": "Nova Atualização Disponível",
|
"newtUpdateAvailable": "Nova Atualização Disponível",
|
||||||
"newtUpdateAvailableInfo": "Uma nova versão do Newt está disponível. Atualize para a versão mais recente para uma melhor experiência.",
|
"newtUpdateAvailableInfo": "Uma nova versão do Newt está disponível. Atualize para a versão mais recente para uma melhor experiência.",
|
||||||
"domainPickerEnterDomain": "Insira seu domínio",
|
"domainPickerEnterDomain": "Domínio",
|
||||||
"domainPickerPlaceholder": "meuapp.exemplo.com, api.v1.meudominio.com, ou apenas meuapp",
|
"domainPickerPlaceholder": "meuapp.exemplo.com, api.v1.meudominio.com, ou apenas meuapp",
|
||||||
"domainPickerDescription": "Insira o domínio completo do recurso para ver as opções disponíveis.",
|
"domainPickerDescription": "Insira o domínio completo do recurso para ver as opções disponíveis.",
|
||||||
"domainPickerDescriptionSaas": "Insira um domínio completo, subdomínio ou apenas um nome para ver as opções disponíveis",
|
"domainPickerDescriptionSaas": "Insira um domínio completo, subdomínio ou apenas um nome para ver as opções disponíveis",
|
||||||
|
@ -1206,7 +1205,7 @@
|
||||||
"domainPickerSortAsc": "A-Z",
|
"domainPickerSortAsc": "A-Z",
|
||||||
"domainPickerSortDesc": "Z-A",
|
"domainPickerSortDesc": "Z-A",
|
||||||
"domainPickerCheckingAvailability": "Verificando disponibilidade...",
|
"domainPickerCheckingAvailability": "Verificando disponibilidade...",
|
||||||
"domainPickerNoMatchingDomains": "Nenhum domínio correspondente encontrado para \"{userInput}\". Tente um domínio diferente ou verifique as configurações de domínio da sua organização.",
|
"domainPickerNoMatchingDomains": "Nenhum domínio correspondente encontrado. Tente um domínio diferente ou verifique as configurações do domínio da sua organização.",
|
||||||
"domainPickerOrganizationDomains": "Domínios da Organização",
|
"domainPickerOrganizationDomains": "Domínios da Organização",
|
||||||
"domainPickerProvidedDomains": "Domínios Fornecidos",
|
"domainPickerProvidedDomains": "Domínios Fornecidos",
|
||||||
"domainPickerSubdomain": "Subdomínio: {subdomain}",
|
"domainPickerSubdomain": "Subdomínio: {subdomain}",
|
||||||
|
@ -1266,6 +1265,7 @@
|
||||||
"createDomainName": "Nome:",
|
"createDomainName": "Nome:",
|
||||||
"createDomainValue": "Valor:",
|
"createDomainValue": "Valor:",
|
||||||
"createDomainCnameRecords": "Registros CNAME",
|
"createDomainCnameRecords": "Registros CNAME",
|
||||||
|
"createDomainARecords": "Registros A",
|
||||||
"createDomainRecordNumber": "Registrar {number}",
|
"createDomainRecordNumber": "Registrar {number}",
|
||||||
"createDomainTxtRecords": "Registros TXT",
|
"createDomainTxtRecords": "Registros TXT",
|
||||||
"createDomainSaveTheseRecords": "Salvar Esses Registros",
|
"createDomainSaveTheseRecords": "Salvar Esses Registros",
|
||||||
|
@ -1273,5 +1273,50 @@
|
||||||
"createDomainDnsPropagation": "Propagação DNS",
|
"createDomainDnsPropagation": "Propagação DNS",
|
||||||
"createDomainDnsPropagationDescription": "Alterações no DNS podem levar algum tempo para se propagar pela internet. Pode levar de alguns minutos a 48 horas, dependendo do seu provedor de DNS e das configurações de TTL.",
|
"createDomainDnsPropagationDescription": "Alterações no DNS podem levar algum tempo para se propagar pela internet. Pode levar de alguns minutos a 48 horas, dependendo do seu provedor de DNS e das configurações de TTL.",
|
||||||
"resourcePortRequired": "Número da porta é obrigatório para recursos não-HTTP",
|
"resourcePortRequired": "Número da porta é obrigatório para recursos não-HTTP",
|
||||||
"resourcePortNotAllowed": "Número da porta não deve ser definido para recursos HTTP"
|
"resourcePortNotAllowed": "Número da porta não deve ser definido para recursos HTTP",
|
||||||
|
"signUpTerms": {
|
||||||
|
"IAgreeToThe": "Concordo com",
|
||||||
|
"termsOfService": "os termos de serviço",
|
||||||
|
"and": "e",
|
||||||
|
"privacyPolicy": "política de privacidade"
|
||||||
|
},
|
||||||
|
"siteRequired": "Site é obrigatório.",
|
||||||
|
"olmTunnel": "Olm Tunnel",
|
||||||
|
"olmTunnelDescription": "Use Olm para conectividade do cliente",
|
||||||
|
"errorCreatingClient": "Erro ao criar cliente",
|
||||||
|
"clientDefaultsNotFound": "Padrões do cliente não encontrados",
|
||||||
|
"createClient": "Criar Cliente",
|
||||||
|
"createClientDescription": "Crie um novo cliente para conectar aos seus sites",
|
||||||
|
"seeAllClients": "Ver Todos os Clientes",
|
||||||
|
"clientInformation": "Informações do Cliente",
|
||||||
|
"clientNamePlaceholder": "Nome do cliente",
|
||||||
|
"address": "Endereço",
|
||||||
|
"subnetPlaceholder": "Sub-rede",
|
||||||
|
"addressDescription": "O endereço que este cliente usará para conectividade",
|
||||||
|
"selectSites": "Selecionar sites",
|
||||||
|
"sitesDescription": "O cliente terá conectividade com os sites selecionados",
|
||||||
|
"clientInstallOlm": "Instalar Olm",
|
||||||
|
"clientInstallOlmDescription": "Execute o Olm em seu sistema",
|
||||||
|
"clientOlmCredentials": "Credenciais Olm",
|
||||||
|
"clientOlmCredentialsDescription": "É assim que Olm se autenticará com o servidor",
|
||||||
|
"olmEndpoint": "Endpoint Olm",
|
||||||
|
"olmId": "ID Olm",
|
||||||
|
"olmSecretKey": "Chave Secreta Olm",
|
||||||
|
"clientCredentialsSave": "Salve suas Credenciais",
|
||||||
|
"clientCredentialsSaveDescription": "Você só poderá ver isto uma vez. Certifique-se de copiá-las para um local seguro.",
|
||||||
|
"generalSettingsDescription": "Configure as configurações gerais para este cliente",
|
||||||
|
"clientUpdated": "Cliente atualizado",
|
||||||
|
"clientUpdatedDescription": "O cliente foi atualizado.",
|
||||||
|
"clientUpdateFailed": "Falha ao atualizar cliente",
|
||||||
|
"clientUpdateError": "Ocorreu um erro ao atualizar o cliente.",
|
||||||
|
"sitesFetchFailed": "Falha ao buscar sites",
|
||||||
|
"sitesFetchError": "Ocorreu um erro ao buscar sites.",
|
||||||
|
"olmErrorFetchReleases": "Ocorreu um erro ao buscar lançamentos do Olm.",
|
||||||
|
"olmErrorFetchLatest": "Ocorreu um erro ao buscar o lançamento mais recente do Olm.",
|
||||||
|
"remoteSubnets": "Sub-redes Remotas",
|
||||||
|
"enterCidrRange": "Insira o intervalo CIDR",
|
||||||
|
"remoteSubnetsDescription": "Adicione intervalos CIDR que podem acessar este site remotamente. Use o formato como 10.0.0.0/24 ou 192.168.1.0/24.",
|
||||||
|
"resourceEnableProxy": "Ativar Proxy Público",
|
||||||
|
"resourceEnableProxyDescription": "Permite proxy público para este recurso. Isso permite o acesso ao recurso de fora da rede através da nuvem em uma porta aberta. Requer configuração do Traefik.",
|
||||||
|
"externalProxyEnabled": "Proxy Externo Habilitado"
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,12 +6,12 @@
|
||||||
"setupOrgName": "Название организации",
|
"setupOrgName": "Название организации",
|
||||||
"orgDisplayName": "Это отображаемое имя вашей организации.",
|
"orgDisplayName": "Это отображаемое имя вашей организации.",
|
||||||
"orgId": "ID организации",
|
"orgId": "ID организации",
|
||||||
"setupIdentifierMessage": "Это уникальный идентификатор вашей организации. Он отличается от отображаемого имени.",
|
"setupIdentifierMessage": "Уникальный идентификатор вашей организации. Он задаётся отдельно от отображаемого имени.",
|
||||||
"setupErrorIdentifier": "ID организации уже занят. Выберите другой.",
|
"setupErrorIdentifier": "ID организации уже занят. Выберите другой.",
|
||||||
"componentsErrorNoMemberCreate": "Вы пока не состоите ни в одной организации. Создайте организацию для начала работы.",
|
"componentsErrorNoMemberCreate": "Вы пока не состоите ни в одной организации. Создайте организацию для начала работы.",
|
||||||
"componentsErrorNoMember": "Вы пока не состоите ни в одной организации.",
|
"componentsErrorNoMember": "Вы пока не состоите ни в одной организации.",
|
||||||
"welcome": "Welcome!",
|
"welcome": "Добро пожаловать!",
|
||||||
"welcomeTo": "Welcome to",
|
"welcomeTo": "Добро пожаловать в",
|
||||||
"componentsCreateOrg": "Создать организацию",
|
"componentsCreateOrg": "Создать организацию",
|
||||||
"componentsMember": "Вы состоите в {count, plural, =0 {0 организациях} one {# организации} few {# организациях} many {# организациях} other {# организациях}}.",
|
"componentsMember": "Вы состоите в {count, plural, =0 {0 организациях} one {# организации} few {# организациях} many {# организациях} other {# организациях}}.",
|
||||||
"componentsInvalidKey": "Обнаружены недействительные или просроченные лицензионные ключи. Соблюдайте условия лицензии для использования всех функций.",
|
"componentsInvalidKey": "Обнаружены недействительные или просроченные лицензионные ключи. Соблюдайте условия лицензии для использования всех функций.",
|
||||||
|
@ -59,7 +59,6 @@
|
||||||
"siteErrorCreate": "Ошибка при создании сайта",
|
"siteErrorCreate": "Ошибка при создании сайта",
|
||||||
"siteErrorCreateKeyPair": "Пара ключей или настройки сайта по умолчанию не найдены",
|
"siteErrorCreateKeyPair": "Пара ключей или настройки сайта по умолчанию не найдены",
|
||||||
"siteErrorCreateDefaults": "Настройки сайта по умолчанию не найдены",
|
"siteErrorCreateDefaults": "Настройки сайта по умолчанию не найдены",
|
||||||
"siteNameDescription": "Отображаемое имя сайта.",
|
|
||||||
"method": "Метод",
|
"method": "Метод",
|
||||||
"siteMethodDescription": "Это способ, которым вы будете открывать соединения.",
|
"siteMethodDescription": "Это способ, которым вы будете открывать соединения.",
|
||||||
"siteLearnNewt": "Узнайте, как установить Newt в вашей системе",
|
"siteLearnNewt": "Узнайте, как установить Newt в вашей системе",
|
||||||
|
@ -207,7 +206,7 @@
|
||||||
"orgGeneralSettings": "Настройки организации",
|
"orgGeneralSettings": "Настройки организации",
|
||||||
"orgGeneralSettingsDescription": "Управляйте данными и конфигурацией вашей организации",
|
"orgGeneralSettingsDescription": "Управляйте данными и конфигурацией вашей организации",
|
||||||
"saveGeneralSettings": "Сохранить общие настройки",
|
"saveGeneralSettings": "Сохранить общие настройки",
|
||||||
"saveSettings": "Save Settings",
|
"saveSettings": "Сохранить настройки",
|
||||||
"orgDangerZone": "Опасная зона",
|
"orgDangerZone": "Опасная зона",
|
||||||
"orgDangerZoneDescription": "Будьте осторожны: удалив организацию, вы не сможете восстановить её.",
|
"orgDangerZoneDescription": "Будьте осторожны: удалив организацию, вы не сможете восстановить её.",
|
||||||
"orgDelete": "Удалить организацию",
|
"orgDelete": "Удалить организацию",
|
||||||
|
@ -646,53 +645,53 @@
|
||||||
"resourcePincodeProtection": "Защита PIN-кодом {status}",
|
"resourcePincodeProtection": "Защита PIN-кодом {status}",
|
||||||
"resourcePincodeRemove": "PIN-код ресурса удалён",
|
"resourcePincodeRemove": "PIN-код ресурса удалён",
|
||||||
"resourcePincodeRemoveDescription": "PIN-код ресурса был успешно удалён",
|
"resourcePincodeRemoveDescription": "PIN-код ресурса был успешно удалён",
|
||||||
"resourcePincodeSetup": "Resource PIN code set",
|
"resourcePincodeSetup": "PIN-код ресурса установлен",
|
||||||
"resourcePincodeSetupDescription": "The resource pincode has been set successfully",
|
"resourcePincodeSetupDescription": "PIN-код ресурса был успешно установлен",
|
||||||
"resourcePincodeSetupTitle": "Set Pincode",
|
"resourcePincodeSetupTitle": "Установить PIN-код",
|
||||||
"resourcePincodeSetupTitleDescription": "Set a pincode to protect this resource",
|
"resourcePincodeSetupTitleDescription": "Установите PIN-код для защиты этого ресурса",
|
||||||
"resourceRoleDescription": "Admins can always access this resource.",
|
"resourceRoleDescription": "Администраторы всегда имеют доступ к этому ресурсу.",
|
||||||
"resourceUsersRoles": "Users & Roles",
|
"resourceUsersRoles": "Пользователи и роли",
|
||||||
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
|
"resourceUsersRolesDescription": "Выберите пользователей и роли с доступом к этому ресурсу",
|
||||||
"resourceUsersRolesSubmit": "Save Users & Roles",
|
"resourceUsersRolesSubmit": "Сохранить пользователей и роли",
|
||||||
"resourceWhitelistSave": "Saved successfully",
|
"resourceWhitelistSave": "Успешно сохранено",
|
||||||
"resourceWhitelistSaveDescription": "Whitelist settings have been saved",
|
"resourceWhitelistSaveDescription": "Настройки белого списка были сохранены",
|
||||||
"ssoUse": "Use Platform SSO",
|
"ssoUse": "Использовать Platform SSO",
|
||||||
"ssoUseDescription": "Существующим пользователям нужно будет войти только один раз для всех ресурсов с включенной этой опцией.",
|
"ssoUseDescription": "Существующим пользователям нужно будет войти только один раз для всех ресурсов с включенной этой опцией.",
|
||||||
"proxyErrorInvalidPort": "Invalid port number",
|
"proxyErrorInvalidPort": "Неверный номер порта",
|
||||||
"subdomainErrorInvalid": "Invalid subdomain",
|
"subdomainErrorInvalid": "Неверный поддомен",
|
||||||
"domainErrorFetch": "Error fetching domains",
|
"domainErrorFetch": "Ошибка при получении доменов",
|
||||||
"domainErrorFetchDescription": "An error occurred when fetching the domains",
|
"domainErrorFetchDescription": "Произошла ошибка при получении доменов",
|
||||||
"resourceErrorUpdate": "Failed to update resource",
|
"resourceErrorUpdate": "Не удалось обновить ресурс",
|
||||||
"resourceErrorUpdateDescription": "An error occurred while updating the resource",
|
"resourceErrorUpdateDescription": "Произошла ошибка при обновлении ресурса",
|
||||||
"resourceUpdated": "Resource updated",
|
"resourceUpdated": "Ресурс обновлён",
|
||||||
"resourceUpdatedDescription": "The resource has been updated successfully",
|
"resourceUpdatedDescription": "Ресурс был успешно обновлён",
|
||||||
"resourceErrorTransfer": "Failed to transfer resource",
|
"resourceErrorTransfer": "Не удалось перенести ресурс",
|
||||||
"resourceErrorTransferDescription": "An error occurred while transferring the resource",
|
"resourceErrorTransferDescription": "Произошла ошибка при переносе ресурса",
|
||||||
"resourceTransferred": "Resource transferred",
|
"resourceTransferred": "Ресурс перенесён",
|
||||||
"resourceTransferredDescription": "The resource has been transferred successfully",
|
"resourceTransferredDescription": "Ресурс был успешно перенесён",
|
||||||
"resourceErrorToggle": "Failed to toggle resource",
|
"resourceErrorToggle": "Не удалось переключить ресурс",
|
||||||
"resourceErrorToggleDescription": "An error occurred while updating the resource",
|
"resourceErrorToggleDescription": "Произошла ошибка при обновлении ресурса",
|
||||||
"resourceVisibilityTitle": "Visibility",
|
"resourceVisibilityTitle": "Видимость",
|
||||||
"resourceVisibilityTitleDescription": "Completely enable or disable resource visibility",
|
"resourceVisibilityTitleDescription": "Включите или отключите видимость ресурса",
|
||||||
"resourceGeneral": "General Settings",
|
"resourceGeneral": "Общие настройки",
|
||||||
"resourceGeneralDescription": "Configure the general settings for this resource",
|
"resourceGeneralDescription": "Настройте общие параметры этого ресурса",
|
||||||
"resourceEnable": "Enable Resource",
|
"resourceEnable": "Ресурс активен",
|
||||||
"resourceTransfer": "Transfer Resource",
|
"resourceTransfer": "Перенести ресурс",
|
||||||
"resourceTransferDescription": "Transfer this resource to a different site",
|
"resourceTransferDescription": "Перенесите этот ресурс на другой сайт",
|
||||||
"resourceTransferSubmit": "Transfer Resource",
|
"resourceTransferSubmit": "Перенести ресурс",
|
||||||
"siteDestination": "Destination Site",
|
"siteDestination": "Новый сайт для ресурса",
|
||||||
"searchSites": "Search sites",
|
"searchSites": "Поиск сайтов",
|
||||||
"accessRoleCreate": "Create Role",
|
"accessRoleCreate": "Создание роли",
|
||||||
"accessRoleCreateDescription": "Create a new role to group users and manage their permissions.",
|
"accessRoleCreateDescription": "Создайте новую роль для группы пользователей и выдавайте им разрешения.",
|
||||||
"accessRoleCreateSubmit": "Create Role",
|
"accessRoleCreateSubmit": "Создать роль",
|
||||||
"accessRoleCreated": "Role created",
|
"accessRoleCreated": "Роль создана",
|
||||||
"accessRoleCreatedDescription": "The role has been successfully created.",
|
"accessRoleCreatedDescription": "Роль была успешно создана.",
|
||||||
"accessRoleErrorCreate": "Failed to create role",
|
"accessRoleErrorCreate": "Не удалось создать роль",
|
||||||
"accessRoleErrorCreateDescription": "An error occurred while creating the role.",
|
"accessRoleErrorCreateDescription": "Произошла ошибка при создании роли.",
|
||||||
"accessRoleErrorNewRequired": "New role is required",
|
"accessRoleErrorNewRequired": "Новая роль обязательна",
|
||||||
"accessRoleErrorRemove": "Failed to remove role",
|
"accessRoleErrorRemove": "Не удалось удалить роль",
|
||||||
"accessRoleErrorRemoveDescription": "An error occurred while removing the role.",
|
"accessRoleErrorRemoveDescription": "Произошла ошибка при удалении роли.",
|
||||||
"accessRoleName": "Role Name",
|
"accessRoleName": "Название роли",
|
||||||
"accessRoleQuestionRemove": "Вы собираетесь удалить роль {name}. Это действие нельзя отменить.",
|
"accessRoleQuestionRemove": "Вы собираетесь удалить роль {name}. Это действие нельзя отменить.",
|
||||||
"accessRoleRemove": "Удалить роль",
|
"accessRoleRemove": "Удалить роль",
|
||||||
"accessRoleRemoveDescription": "Удалить роль из организации",
|
"accessRoleRemoveDescription": "Удалить роль из организации",
|
||||||
|
@ -726,86 +725,86 @@
|
||||||
"idpSearch": "Поиск поставщиков удостоверений...",
|
"idpSearch": "Поиск поставщиков удостоверений...",
|
||||||
"idpAdd": "Добавить поставщика удостоверений",
|
"idpAdd": "Добавить поставщика удостоверений",
|
||||||
"idpClientIdRequired": "ID клиента обязателен.",
|
"idpClientIdRequired": "ID клиента обязателен.",
|
||||||
"idpClientSecretRequired": "Client Secret is required.",
|
"idpClientSecretRequired": "Требуется секретный пароль клиента.",
|
||||||
"idpErrorAuthUrlInvalid": "Auth URL must be a valid URL.",
|
"idpErrorAuthUrlInvalid": "URL авторизации должен быть корректным URL.",
|
||||||
"idpErrorTokenUrlInvalid": "Token URL must be a valid URL.",
|
"idpErrorTokenUrlInvalid": "URL токена должен быть корректным URL.",
|
||||||
"idpPathRequired": "Identifier Path is required.",
|
"idpPathRequired": "Путь идентификатора обязателен.",
|
||||||
"idpScopeRequired": "Scopes are required.",
|
"idpScopeRequired": "Области действия обязательны.",
|
||||||
"idpOidcDescription": "Configure an OpenID Connect identity provider",
|
"idpOidcDescription": "Настройте поставщика удостоверений OpenID Connect",
|
||||||
"idpCreatedDescription": "Identity provider created successfully",
|
"idpCreatedDescription": "Поставщик удостоверений успешно создан",
|
||||||
"idpCreate": "Create Identity Provider",
|
"idpCreate": "Создать поставщика удостоверений",
|
||||||
"idpCreateDescription": "Configure a new identity provider for user authentication",
|
"idpCreateDescription": "Настройте нового поставщика удостоверений для аутентификации пользователей",
|
||||||
"idpSeeAll": "See All Identity Providers",
|
"idpSeeAll": "Посмотреть всех поставщиков удостоверений",
|
||||||
"idpSettingsDescription": "Configure the basic information for your identity provider",
|
"idpSettingsDescription": "Настройте базовую информацию для вашего поставщика удостоверений",
|
||||||
"idpDisplayName": "A display name for this identity provider",
|
"idpDisplayName": "Отображаемое имя для этого поставщика удостоверений",
|
||||||
"idpAutoProvisionUsers": "Auto Provision Users",
|
"idpAutoProvisionUsers": "Автоматическое создание пользователей",
|
||||||
"idpAutoProvisionUsersDescription": "При включении пользователи будут автоматически создаваться в системе при первом входе с возможностью сопоставления пользователей с ролями и организациями.",
|
"idpAutoProvisionUsersDescription": "При включении пользователи будут автоматически создаваться в системе при первом входе с возможностью сопоставления пользователей с ролями и организациями.",
|
||||||
"licenseBadge": "Professional",
|
"licenseBadge": "Профессиональная",
|
||||||
"idpType": "Provider Type",
|
"idpType": "Тип поставщика",
|
||||||
"idpTypeDescription": "Select the type of identity provider you want to configure",
|
"idpTypeDescription": "Выберите тип поставщика удостоверений, который вы хотите настроить",
|
||||||
"idpOidcConfigure": "OAuth2/OIDC Configuration",
|
"idpOidcConfigure": "Конфигурация OAuth2/OIDC",
|
||||||
"idpOidcConfigureDescription": "Настройте конечные точки и учётные данные поставщика OAuth2/OIDC",
|
"idpOidcConfigureDescription": "Настройте конечные точки и учётные данные поставщика OAuth2/OIDC",
|
||||||
"idpClientId": "Client ID",
|
"idpClientId": "ID клиента",
|
||||||
"idpClientIdDescription": "The OAuth2 client ID from your identity provider",
|
"idpClientIdDescription": "OAuth2 ID клиента от вашего поставщика удостоверений",
|
||||||
"idpClientSecret": "Client Secret",
|
"idpClientSecret": "Секрет клиента",
|
||||||
"idpClientSecretDescription": "The OAuth2 client secret from your identity provider",
|
"idpClientSecretDescription": "OAuth2 секрет клиента от вашего поставщика удостоверений",
|
||||||
"idpAuthUrl": "Authorization URL",
|
"idpAuthUrl": "URL авторизации",
|
||||||
"idpAuthUrlDescription": "The OAuth2 authorization endpoint URL",
|
"idpAuthUrlDescription": "URL конечной точки авторизации OAuth2",
|
||||||
"idpTokenUrl": "Token URL",
|
"idpTokenUrl": "URL токена",
|
||||||
"idpTokenUrlDescription": "The OAuth2 token endpoint URL",
|
"idpTokenUrlDescription": "URL конечной точки токена OAuth2",
|
||||||
"idpOidcConfigureAlert": "Important Information",
|
"idpOidcConfigureAlert": "Важная информация",
|
||||||
"idpOidcConfigureAlertDescription": "После создания поставщика удостоверений вам нужно будет настроить URL обратного вызова в настройках вашего поставщика удостоверений. URL обратного вызова будет предоставлен после успешного создания.",
|
"idpOidcConfigureAlertDescription": "После создания поставщика удостоверений вам нужно будет настроить URL обратного вызова в настройках вашего поставщика удостоверений. URL обратного вызова будет предоставлен после успешного создания.",
|
||||||
"idpToken": "Token Configuration",
|
"idpToken": "Конфигурация токена",
|
||||||
"idpTokenDescription": "Configure how to extract user information from the ID token",
|
"idpTokenDescription": "Настройте, как извлекать информацию о пользователе из ID токена",
|
||||||
"idpJmespathAbout": "About JMESPath",
|
"idpJmespathAbout": "О JMESPath",
|
||||||
"idpJmespathAboutDescription": "The paths below use JMESPath syntax to extract values from the ID token.",
|
"idpJmespathAboutDescription": "Пути ниже используют синтаксис JMESPath для извлечения значений из ID токена.",
|
||||||
"idpJmespathAboutDescriptionLink": "Learn more about JMESPath",
|
"idpJmespathAboutDescriptionLink": "Узнать больше о JMESPath",
|
||||||
"idpJmespathLabel": "Identifier Path",
|
"idpJmespathLabel": "Путь идентификатора",
|
||||||
"idpJmespathLabelDescription": "The path to the user identifier in the ID token",
|
"idpJmespathLabelDescription": "Путь к идентификатору пользователя в ID токене",
|
||||||
"idpJmespathEmailPathOptional": "Email Path (Optional)",
|
"idpJmespathEmailPathOptional": "Путь к email (необязательно)",
|
||||||
"idpJmespathEmailPathOptionalDescription": "The path to the user's email in the ID token",
|
"idpJmespathEmailPathOptionalDescription": "Путь к email пользователя в ID токене",
|
||||||
"idpJmespathNamePathOptional": "Name Path (Optional)",
|
"idpJmespathNamePathOptional": "Путь к имени (необязательно)",
|
||||||
"idpJmespathNamePathOptionalDescription": "The path to the user's name in the ID token",
|
"idpJmespathNamePathOptionalDescription": "Путь к имени пользователя в ID токене",
|
||||||
"idpOidcConfigureScopes": "Scopes",
|
"idpOidcConfigureScopes": "Области действия",
|
||||||
"idpOidcConfigureScopesDescription": "Space-separated list of OAuth2 scopes to request",
|
"idpOidcConfigureScopesDescription": "Список областей OAuth2, разделённых пробелами",
|
||||||
"idpSubmit": "Create Identity Provider",
|
"idpSubmit": "Создать поставщика удостоверений",
|
||||||
"orgPolicies": "Organization Policies",
|
"orgPolicies": "Политики организации",
|
||||||
"idpSettings": "Настройки {idpName}",
|
"idpSettings": "Настройки {idpName}",
|
||||||
"idpCreateSettingsDescription": "Configure the settings for your identity provider",
|
"idpCreateSettingsDescription": "Настройте параметры для вашего поставщика удостоверений",
|
||||||
"roleMapping": "Role Mapping",
|
"roleMapping": "Сопоставление ролей",
|
||||||
"orgMapping": "Organization Mapping",
|
"orgMapping": "Сопоставление организаций",
|
||||||
"orgPoliciesSearch": "Search organization policies...",
|
"orgPoliciesSearch": "Поиск политик организации...",
|
||||||
"orgPoliciesAdd": "Add Organization Policy",
|
"orgPoliciesAdd": "Добавить политику организации",
|
||||||
"orgRequired": "Organization is required",
|
"orgRequired": "Организация обязательна",
|
||||||
"error": "Error",
|
"error": "Ошибка",
|
||||||
"success": "Success",
|
"success": "Успешно",
|
||||||
"orgPolicyAddedDescription": "Policy added successfully",
|
"orgPolicyAddedDescription": "Политика успешно добавлена",
|
||||||
"orgPolicyUpdatedDescription": "Policy updated successfully",
|
"orgPolicyUpdatedDescription": "Политика успешно обновлена",
|
||||||
"orgPolicyDeletedDescription": "Policy deleted successfully",
|
"orgPolicyDeletedDescription": "Политика успешно удалена",
|
||||||
"defaultMappingsUpdatedDescription": "Default mappings updated successfully",
|
"defaultMappingsUpdatedDescription": "Сопоставления по умолчанию успешно обновлены",
|
||||||
"orgPoliciesAbout": "About Organization Policies",
|
"orgPoliciesAbout": "О политиках организации",
|
||||||
"orgPoliciesAboutDescription": "Политики организации используются для контроля доступа к организациям на основе ID токена пользователя. Вы можете указать выражения JMESPath для извлечения информации о роли и организации из ID токена.",
|
"orgPoliciesAboutDescription": "Политики организации используются для контроля доступа к организациям на основе ID токена пользователя. Вы можете указать выражения JMESPath для извлечения информации о роли и организации из ID токена.",
|
||||||
"orgPoliciesAboutDescriptionLink": "See documentation, for more information.",
|
"orgPoliciesAboutDescriptionLink": "См. документацию для получения дополнительной информации.",
|
||||||
"defaultMappingsOptional": "Default Mappings (Optional)",
|
"defaultMappingsOptional": "Сопоставления по умолчанию (необязательно)",
|
||||||
"defaultMappingsOptionalDescription": "Сопоставления по умолчанию используются, когда для организации не определена политика организации. Здесь вы можете указать сопоставления ролей и организаций по умолчанию.",
|
"defaultMappingsOptionalDescription": "Сопоставления по умолчанию используются, когда для организации не определена политика организации. Здесь вы можете указать сопоставления ролей и организаций по умолчанию.",
|
||||||
"defaultMappingsRole": "Default Role Mapping",
|
"defaultMappingsRole": "Сопоставление ролей по умолчанию",
|
||||||
"defaultMappingsRoleDescription": "Результат этого выражения должен возвращать имя роли, как определено в организации, в виде строки.",
|
"defaultMappingsRoleDescription": "Результат этого выражения должен возвращать имя роли, как определено в организации, в виде строки.",
|
||||||
"defaultMappingsOrg": "Default Organization Mapping",
|
"defaultMappingsOrg": "Сопоставление организаций по умолчанию",
|
||||||
"defaultMappingsOrgDescription": "Это выражение должно возвращать ID организации или true для разрешения доступа пользователя к организации.",
|
"defaultMappingsOrgDescription": "Это выражение должно возвращать ID организации или true для разрешения доступа пользователя к организации.",
|
||||||
"defaultMappingsSubmit": "Save Default Mappings",
|
"defaultMappingsSubmit": "Сохранить сопоставления по умолчанию",
|
||||||
"orgPoliciesEdit": "Edit Organization Policy",
|
"orgPoliciesEdit": "Редактировать политику организации",
|
||||||
"org": "Organization",
|
"org": "Организация",
|
||||||
"orgSelect": "Select organization",
|
"orgSelect": "Выберите организацию",
|
||||||
"orgSearch": "Search org",
|
"orgSearch": "Поиск организации",
|
||||||
"orgNotFound": "No org found.",
|
"orgNotFound": "Организация не найдена.",
|
||||||
"roleMappingPathOptional": "Role Mapping Path (Optional)",
|
"roleMappingPathOptional": "Путь сопоставления ролей (необязательно)",
|
||||||
"orgMappingPathOptional": "Organization Mapping Path (Optional)",
|
"orgMappingPathOptional": "Путь сопоставления организаций (необязательно)",
|
||||||
"orgPolicyUpdate": "Update Policy",
|
"orgPolicyUpdate": "Обновить политику",
|
||||||
"orgPolicyAdd": "Add Policy",
|
"orgPolicyAdd": "Добавить политику",
|
||||||
"orgPolicyConfig": "Configure access for an organization",
|
"orgPolicyConfig": "Настроить доступ для организации",
|
||||||
"idpUpdatedDescription": "Identity provider updated successfully",
|
"idpUpdatedDescription": "Поставщик удостоверений успешно обновлён",
|
||||||
"redirectUrl": "Redirect URL",
|
"redirectUrl": "URL редиректа",
|
||||||
"redirectUrlAbout": "About Redirect URL",
|
"redirectUrlAbout": "О редиректе URL",
|
||||||
"redirectUrlAboutDescription": "Это URL, на который пользователи будут перенаправлены после аутентификации. Вам нужно настроить этот URL в настройках вашего поставщика удостоверений.",
|
"redirectUrlAboutDescription": "Это URL, на который пользователи будут перенаправлены после аутентификации. Вам нужно настроить этот URL в настройках вашего поставщика удостоверений.",
|
||||||
"pangolinAuth": "Аутентификация - Pangolin",
|
"pangolinAuth": "Аутентификация - Pangolin",
|
||||||
"verificationCodeLengthRequirements": "Ваш код подтверждения должен состоять из 8 символов.",
|
"verificationCodeLengthRequirements": "Ваш код подтверждения должен состоять из 8 символов.",
|
||||||
|
@ -859,73 +858,73 @@
|
||||||
"accessTokenError": "Ошибка проверки токена доступа",
|
"accessTokenError": "Ошибка проверки токена доступа",
|
||||||
"accessGranted": "Доступ предоставлен",
|
"accessGranted": "Доступ предоставлен",
|
||||||
"accessUrlInvalid": "Неверный URL доступа",
|
"accessUrlInvalid": "Неверный URL доступа",
|
||||||
"accessGrantedDescription": "You have been granted access to this resource. Redirecting you...",
|
"accessGrantedDescription": "Вам был предоставлен доступ к этому ресурсу. Перенаправляем вас...",
|
||||||
"accessUrlInvalidDescription": "This shared access URL is invalid. Please contact the resource owner for a new URL.",
|
"accessUrlInvalidDescription": "Этот общий URL доступа недействителен. Пожалуйста, свяжитесь с владельцем ресурса для получения нового URL.",
|
||||||
"tokenInvalid": "Invalid token",
|
"tokenInvalid": "Неверный токен",
|
||||||
"pincodeInvalid": "Invalid code",
|
"pincodeInvalid": "Неверный код",
|
||||||
"passwordErrorRequestReset": "Failed to request reset:",
|
"passwordErrorRequestReset": "Не удалось запросить сброс:",
|
||||||
"passwordErrorReset": "Failed to reset password:",
|
"passwordErrorReset": "Не удалось сбросить пароль:",
|
||||||
"passwordResetSuccess": "Password reset successfully! Back to log in...",
|
"passwordResetSuccess": "Пароль успешно сброшен! Вернуться к входу...",
|
||||||
"passwordReset": "Reset Password",
|
"passwordReset": "Сброс пароля",
|
||||||
"passwordResetDescription": "Follow the steps to reset your password",
|
"passwordResetDescription": "Следуйте инструкциям для сброса вашего пароля",
|
||||||
"passwordResetSent": "We'll send a password reset code to this email address.",
|
"passwordResetSent": "Мы отправим код сброса пароля на этот email адрес.",
|
||||||
"passwordResetCode": "Reset Code",
|
"passwordResetCode": "Код сброса пароля",
|
||||||
"passwordResetCodeDescription": "Check your email for the reset code.",
|
"passwordResetCodeDescription": "Проверьте вашу почту для получения кода сброса пароля.",
|
||||||
"passwordNew": "New Password",
|
"passwordNew": "Новый пароль",
|
||||||
"passwordNewConfirm": "Confirm New Password",
|
"passwordNewConfirm": "Подтвердите новый пароль",
|
||||||
"pincodeAuth": "Authenticator Code",
|
"pincodeAuth": "Код аутентификатора",
|
||||||
"pincodeSubmit2": "Submit Code",
|
"pincodeSubmit2": "Отправить код",
|
||||||
"passwordResetSubmit": "Request Reset",
|
"passwordResetSubmit": "Запросить сброс",
|
||||||
"passwordBack": "Back to Password",
|
"passwordBack": "Назад к паролю",
|
||||||
"loginBack": "Go back to log in",
|
"loginBack": "Вернуться к входу",
|
||||||
"signup": "Sign up",
|
"signup": "Регистрация",
|
||||||
"loginStart": "Log in to get started",
|
"loginStart": "Войдите для начала работы",
|
||||||
"idpOidcTokenValidating": "Validating OIDC token",
|
"idpOidcTokenValidating": "Проверка OIDC токена",
|
||||||
"idpOidcTokenResponse": "Validate OIDC token response",
|
"idpOidcTokenResponse": "Проверить ответ OIDC токена",
|
||||||
"idpErrorOidcTokenValidating": "Error validating OIDC token",
|
"idpErrorOidcTokenValidating": "Ошибка проверки OIDC токена",
|
||||||
"idpConnectingTo": "Подключение к {name}",
|
"idpConnectingTo": "Подключение к {name}",
|
||||||
"idpConnectingToDescription": "Validating your identity",
|
"idpConnectingToDescription": "Проверка вашей личности",
|
||||||
"idpConnectingToProcess": "Connecting...",
|
"idpConnectingToProcess": "Подключение...",
|
||||||
"idpConnectingToFinished": "Connected",
|
"idpConnectingToFinished": "Подключено",
|
||||||
"idpErrorConnectingTo": "Возникла проблема при подключении к {name}. Пожалуйста, свяжитесь с вашим администратором.",
|
"idpErrorConnectingTo": "Возникла проблема при подключении к {name}. Пожалуйста, свяжитесь с вашим администратором.",
|
||||||
"idpErrorNotFound": "IdP not found",
|
"idpErrorNotFound": "IdP не найден",
|
||||||
"inviteInvalid": "Invalid Invite",
|
"inviteInvalid": "Недействительное приглашение",
|
||||||
"inviteInvalidDescription": "The invite link is invalid.",
|
"inviteInvalidDescription": "Ссылка на приглашение недействительна.",
|
||||||
"inviteErrorWrongUser": "Invite is not for this user",
|
"inviteErrorWrongUser": "Приглашение не для этого пользователя",
|
||||||
"inviteErrorUserNotExists": "User does not exist. Please create an account first.",
|
"inviteErrorUserNotExists": "Пользователь не существует. Пожалуйста, сначала создайте учетную запись.",
|
||||||
"inviteErrorLoginRequired": "You must be logged in to accept an invite",
|
"inviteErrorLoginRequired": "Вы должны войти, чтобы принять приглашение",
|
||||||
"inviteErrorExpired": "The invite may have expired",
|
"inviteErrorExpired": "Срок действия приглашения истек",
|
||||||
"inviteErrorRevoked": "The invite might have been revoked",
|
"inviteErrorRevoked": "Возможно, приглашение было отозвано",
|
||||||
"inviteErrorTypo": "There could be a typo in the invite link",
|
"inviteErrorTypo": "В пригласительной ссылке может быть опечатка",
|
||||||
"pangolinSetup": "Setup - Pangolin",
|
"pangolinSetup": "Настройка - Pangolin",
|
||||||
"orgNameRequired": "Organization name is required",
|
"orgNameRequired": "Название организации обязательно",
|
||||||
"orgIdRequired": "Organization ID is required",
|
"orgIdRequired": "ID организации обязателен",
|
||||||
"orgErrorCreate": "An error occurred while creating org",
|
"orgErrorCreate": "Произошла ошибка при создании организации",
|
||||||
"pageNotFound": "Page Not Found",
|
"pageNotFound": "Страница не найдена",
|
||||||
"pageNotFoundDescription": "Oops! The page you're looking for doesn't exist.",
|
"pageNotFoundDescription": "Упс! Страница, которую вы ищете, не существует.",
|
||||||
"overview": "Overview",
|
"overview": "Обзор",
|
||||||
"home": "Home",
|
"home": "Главная",
|
||||||
"accessControl": "Access Control",
|
"accessControl": "Контроль доступа",
|
||||||
"settings": "Settings",
|
"settings": "Настройки",
|
||||||
"usersAll": "All Users",
|
"usersAll": "Все пользователи",
|
||||||
"license": "License",
|
"license": "Лицензия",
|
||||||
"pangolinDashboard": "Dashboard - Pangolin",
|
"pangolinDashboard": "Дашборд - Pangolin",
|
||||||
"noResults": "No results found.",
|
"noResults": "Результаты не найдены.",
|
||||||
"terabytes": "{count} ТБ",
|
"terabytes": "{count} ТБ",
|
||||||
"gigabytes": "{count} ГБ",
|
"gigabytes": "{count} ГБ",
|
||||||
"megabytes": "{count} МБ",
|
"megabytes": "{count} МБ",
|
||||||
"tagsEntered": "Entered Tags",
|
"tagsEntered": "Введённые теги",
|
||||||
"tagsEnteredDescription": "These are the tags you`ve entered.",
|
"tagsEnteredDescription": "Это теги, которые вы ввели.",
|
||||||
"tagsWarnCannotBeLessThanZero": "maxTags and minTags cannot be less than 0",
|
"tagsWarnCannotBeLessThanZero": "maxTags и minTags не могут быть меньше 0",
|
||||||
"tagsWarnNotAllowedAutocompleteOptions": "Tag not allowed as per autocomplete options",
|
"tagsWarnNotAllowedAutocompleteOptions": "Тег не разрешён согласно опциям автозаполнения",
|
||||||
"tagsWarnInvalid": "Invalid tag as per validateTag",
|
"tagsWarnInvalid": "Недействительный тег согласно validateTag",
|
||||||
"tagWarnTooShort": "Tag {tagText} is too short",
|
"tagWarnTooShort": "Тег {tagText} слишком короткий",
|
||||||
"tagWarnTooLong": "Tag {tagText} is too long",
|
"tagWarnTooLong": "Тег {tagText} слишком длинный",
|
||||||
"tagsWarnReachedMaxNumber": "Reached the maximum number of tags allowed",
|
"tagsWarnReachedMaxNumber": "Достигнуто максимальное количество разрешённых тегов",
|
||||||
"tagWarnDuplicate": "Duplicate tag {tagText} not added",
|
"tagWarnDuplicate": "Дублирующий тег {tagText} не добавлен",
|
||||||
"supportKeyInvalid": "Invalid Key",
|
"supportKeyInvalid": "Недействительный ключ",
|
||||||
"supportKeyInvalidDescription": "Your supporter key is invalid.",
|
"supportKeyInvalidDescription": "Ваш ключ поддержки недействителен.",
|
||||||
"supportKeyValid": "Valid Key",
|
"supportKeyValid": "Действительный ключ",
|
||||||
"supportKeyValidDescription": "Your supporter key has been validated. Thank you for your support!",
|
"supportKeyValidDescription": "Your supporter key has been validated. Thank you for your support!",
|
||||||
"supportKeyErrorValidationDescription": "Failed to validate supporter key.",
|
"supportKeyErrorValidationDescription": "Failed to validate supporter key.",
|
||||||
"supportKey": "Support Development and Adopt a Pangolin!",
|
"supportKey": "Support Development and Adopt a Pangolin!",
|
||||||
|
@ -994,27 +993,27 @@
|
||||||
"actionListRole": "List Roles",
|
"actionListRole": "List Roles",
|
||||||
"actionUpdateRole": "Update Role",
|
"actionUpdateRole": "Update Role",
|
||||||
"actionListAllowedRoleResources": "List Allowed Role Resources",
|
"actionListAllowedRoleResources": "List Allowed Role Resources",
|
||||||
"actionInviteUser": "Invite User",
|
"actionInviteUser": "Пригласить пользователя",
|
||||||
"actionRemoveUser": "Remove User",
|
"actionRemoveUser": "Удалить пользователя",
|
||||||
"actionListUsers": "List Users",
|
"actionListUsers": "Список пользователей",
|
||||||
"actionAddUserRole": "Add User Role",
|
"actionAddUserRole": "Добавить роль пользователя",
|
||||||
"actionGenerateAccessToken": "Generate Access Token",
|
"actionGenerateAccessToken": "Сгенерировать токен доступа",
|
||||||
"actionDeleteAccessToken": "Delete Access Token",
|
"actionDeleteAccessToken": "Удалить токен доступа",
|
||||||
"actionListAccessTokens": "List Access Tokens",
|
"actionListAccessTokens": "Список токенов доступа",
|
||||||
"actionCreateResourceRule": "Create Resource Rule",
|
"actionCreateResourceRule": "Создать правило ресурса",
|
||||||
"actionDeleteResourceRule": "Delete Resource Rule",
|
"actionDeleteResourceRule": "Удалить правило ресурса",
|
||||||
"actionListResourceRules": "List Resource Rules",
|
"actionListResourceRules": "Список правил ресурса",
|
||||||
"actionUpdateResourceRule": "Update Resource Rule",
|
"actionUpdateResourceRule": "Обновить правило ресурса",
|
||||||
"actionListOrgs": "List Organizations",
|
"actionListOrgs": "Список организаций",
|
||||||
"actionCheckOrgId": "Check ID",
|
"actionCheckOrgId": "Проверить ID",
|
||||||
"actionCreateOrg": "Create Organization",
|
"actionCreateOrg": "Создать организацию",
|
||||||
"actionDeleteOrg": "Delete Organization",
|
"actionDeleteOrg": "Удалить организацию",
|
||||||
"actionListApiKeys": "List API Keys",
|
"actionListApiKeys": "Список API ключей",
|
||||||
"actionListApiKeyActions": "List API Key Actions",
|
"actionListApiKeyActions": "Список действий API ключа",
|
||||||
"actionSetApiKeyActions": "Set API Key Allowed Actions",
|
"actionSetApiKeyActions": "Установить разрешённые действия API ключа",
|
||||||
"actionCreateApiKey": "Create API Key",
|
"actionCreateApiKey": "Создать API ключ",
|
||||||
"actionDeleteApiKey": "Delete API Key",
|
"actionDeleteApiKey": "Удалить API ключ",
|
||||||
"actionCreateIdp": "Create IDP",
|
"actionCreateIdp": "Создать IDP",
|
||||||
"actionUpdateIdp": "Обновить IDP",
|
"actionUpdateIdp": "Обновить IDP",
|
||||||
"actionDeleteIdp": "Удалить IDP",
|
"actionDeleteIdp": "Удалить IDP",
|
||||||
"actionListIdps": "Список IDP",
|
"actionListIdps": "Список IDP",
|
||||||
|
@ -1053,19 +1052,19 @@
|
||||||
"otpErrorDisableDescription": "Произошла ошибка при отключении 2FA",
|
"otpErrorDisableDescription": "Произошла ошибка при отключении 2FA",
|
||||||
"otpRemove": "Отключить двухфакторную аутентификацию",
|
"otpRemove": "Отключить двухфакторную аутентификацию",
|
||||||
"otpRemoveDescription": "Отключить двухфакторную аутентификацию для вашей учётной записи",
|
"otpRemoveDescription": "Отключить двухфакторную аутентификацию для вашей учётной записи",
|
||||||
"otpRemoveSuccess": "Two-Factor Authentication Disabled",
|
"otpRemoveSuccess": "Двухфакторная аутентификация отключена",
|
||||||
"otpRemoveSuccessMessage": "Двухфакторная аутентификация была отключена для вашей учётной записи. Вы можете включить её снова в любое время.",
|
"otpRemoveSuccessMessage": "Двухфакторная аутентификация была отключена для вашей учётной записи. Вы можете включить её снова в любое время.",
|
||||||
"otpRemoveSubmit": "Disable 2FA",
|
"otpRemoveSubmit": "Отключить 2FA",
|
||||||
"paginator": "Page {current} of {last}",
|
"paginator": "Страница {current} из {last}",
|
||||||
"paginatorToFirst": "Go to first page",
|
"paginatorToFirst": "Перейти на первую страницу",
|
||||||
"paginatorToPrevious": "Go to previous page",
|
"paginatorToPrevious": "Перейти на предыдущую страницу",
|
||||||
"paginatorToNext": "Go to next page",
|
"paginatorToNext": "Перейти на следующую страницу",
|
||||||
"paginatorToLast": "Go to last page",
|
"paginatorToLast": "Перейти на последнюю страницу",
|
||||||
"copyText": "Copy text",
|
"copyText": "Скопировать текст",
|
||||||
"copyTextFailed": "Failed to copy text: ",
|
"copyTextFailed": "Не удалось скопировать текст: ",
|
||||||
"copyTextClipboard": "Copy to clipboard",
|
"copyTextClipboard": "Копировать в буфер обмена",
|
||||||
"inviteErrorInvalidConfirmation": "Invalid confirmation",
|
"inviteErrorInvalidConfirmation": "Неверное подтверждение",
|
||||||
"passwordRequired": "Password is required",
|
"passwordRequired": "Пароль обязателен",
|
||||||
"allowAll": "Разрешить всё",
|
"allowAll": "Разрешить всё",
|
||||||
"permissionsAllowAll": "Разрешить все разрешения",
|
"permissionsAllowAll": "Разрешить все разрешения",
|
||||||
"githubUsernameRequired": "Имя пользователя GitHub обязательно",
|
"githubUsernameRequired": "Имя пользователя GitHub обязательно",
|
||||||
|
@ -1094,7 +1093,7 @@
|
||||||
"sidebarAllUsers": "Все пользователи",
|
"sidebarAllUsers": "Все пользователи",
|
||||||
"sidebarIdentityProviders": "Поставщики удостоверений",
|
"sidebarIdentityProviders": "Поставщики удостоверений",
|
||||||
"sidebarLicense": "Лицензия",
|
"sidebarLicense": "Лицензия",
|
||||||
"sidebarClients": "Clients",
|
"sidebarClients": "Clients (Beta)",
|
||||||
"sidebarDomains": "Domains",
|
"sidebarDomains": "Domains",
|
||||||
"enableDockerSocket": "Включить Docker Socket",
|
"enableDockerSocket": "Включить Docker Socket",
|
||||||
"enableDockerSocketDescription": "Включить обнаружение Docker Socket для заполнения информации о контейнерах. Путь к сокету должен быть предоставлен Newt.",
|
"enableDockerSocketDescription": "Включить обнаружение Docker Socket для заполнения информации о контейнерах. Путь к сокету должен быть предоставлен Newt.",
|
||||||
|
@ -1162,7 +1161,7 @@
|
||||||
"selectDomainTypeCnameName": "Single Domain (CNAME)",
|
"selectDomainTypeCnameName": "Single Domain (CNAME)",
|
||||||
"selectDomainTypeCnameDescription": "Just this specific domain. Use this for individual subdomains or specific domain entries.",
|
"selectDomainTypeCnameDescription": "Just this specific domain. Use this for individual subdomains or specific domain entries.",
|
||||||
"selectDomainTypeWildcardName": "Wildcard Domain",
|
"selectDomainTypeWildcardName": "Wildcard Domain",
|
||||||
"selectDomainTypeWildcardDescription": "This domain and its first level of subdomains.",
|
"selectDomainTypeWildcardDescription": "This domain and its subdomains.",
|
||||||
"domainDelegation": "Single Domain",
|
"domainDelegation": "Single Domain",
|
||||||
"selectType": "Select a type",
|
"selectType": "Select a type",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
|
@ -1196,7 +1195,7 @@
|
||||||
"sidebarExpand": "Expand",
|
"sidebarExpand": "Expand",
|
||||||
"newtUpdateAvailable": "Update Available",
|
"newtUpdateAvailable": "Update Available",
|
||||||
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
|
||||||
"domainPickerEnterDomain": "Enter your domain",
|
"domainPickerEnterDomain": "Domain",
|
||||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp",
|
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp",
|
||||||
"domainPickerDescription": "Enter the full domain of the resource to see available options.",
|
"domainPickerDescription": "Enter the full domain of the resource to see available options.",
|
||||||
"domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
|
"domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
|
||||||
|
@ -1206,7 +1205,7 @@
|
||||||
"domainPickerSortAsc": "A-Z",
|
"domainPickerSortAsc": "A-Z",
|
||||||
"domainPickerSortDesc": "Z-A",
|
"domainPickerSortDesc": "Z-A",
|
||||||
"domainPickerCheckingAvailability": "Checking availability...",
|
"domainPickerCheckingAvailability": "Checking availability...",
|
||||||
"domainPickerNoMatchingDomains": "No matching domains found for \"{userInput}\". Try a different domain or check your organization's domain settings.",
|
"domainPickerNoMatchingDomains": "No matching domains found. Try a different domain or check your organization's domain settings.",
|
||||||
"domainPickerOrganizationDomains": "Organization Domains",
|
"domainPickerOrganizationDomains": "Organization Domains",
|
||||||
"domainPickerProvidedDomains": "Provided Domains",
|
"domainPickerProvidedDomains": "Provided Domains",
|
||||||
"domainPickerSubdomain": "Subdomain: {subdomain}",
|
"domainPickerSubdomain": "Subdomain: {subdomain}",
|
||||||
|
@ -1266,6 +1265,7 @@
|
||||||
"createDomainName": "Name:",
|
"createDomainName": "Name:",
|
||||||
"createDomainValue": "Value:",
|
"createDomainValue": "Value:",
|
||||||
"createDomainCnameRecords": "CNAME Records",
|
"createDomainCnameRecords": "CNAME Records",
|
||||||
|
"createDomainARecords": "A Records",
|
||||||
"createDomainRecordNumber": "Record {number}",
|
"createDomainRecordNumber": "Record {number}",
|
||||||
"createDomainTxtRecords": "TXT Records",
|
"createDomainTxtRecords": "TXT Records",
|
||||||
"createDomainSaveTheseRecords": "Save These Records",
|
"createDomainSaveTheseRecords": "Save These Records",
|
||||||
|
@ -1273,5 +1273,50 @@
|
||||||
"createDomainDnsPropagation": "DNS Propagation",
|
"createDomainDnsPropagation": "DNS Propagation",
|
||||||
"createDomainDnsPropagationDescription": "DNS changes may take some time to propagate across the internet. This can take anywhere from a few minutes to 48 hours, depending on your DNS provider and TTL settings.",
|
"createDomainDnsPropagationDescription": "DNS changes may take some time to propagate across the internet. This can take anywhere from a few minutes to 48 hours, depending on your DNS provider and TTL settings.",
|
||||||
"resourcePortRequired": "Port number is required for non-HTTP resources",
|
"resourcePortRequired": "Port number is required for non-HTTP resources",
|
||||||
"resourcePortNotAllowed": "Port number should not be set for HTTP resources"
|
"resourcePortNotAllowed": "Port number should not be set for HTTP resources",
|
||||||
|
"signUpTerms": {
|
||||||
|
"IAgreeToThe": "I agree to the",
|
||||||
|
"termsOfService": "terms of service",
|
||||||
|
"and": "and",
|
||||||
|
"privacyPolicy": "privacy policy"
|
||||||
|
},
|
||||||
|
"siteRequired": "Site is required.",
|
||||||
|
"olmTunnel": "Olm Tunnel",
|
||||||
|
"olmTunnelDescription": "Use Olm for client connectivity",
|
||||||
|
"errorCreatingClient": "Error creating client",
|
||||||
|
"clientDefaultsNotFound": "Client defaults not found",
|
||||||
|
"createClient": "Create Client",
|
||||||
|
"createClientDescription": "Create a new client for connecting to your sites",
|
||||||
|
"seeAllClients": "See All Clients",
|
||||||
|
"clientInformation": "Client Information",
|
||||||
|
"clientNamePlaceholder": "Client name",
|
||||||
|
"address": "Address",
|
||||||
|
"subnetPlaceholder": "Subnet",
|
||||||
|
"addressDescription": "The address that this client will use for connectivity",
|
||||||
|
"selectSites": "Select sites",
|
||||||
|
"sitesDescription": "The client will have connectivity to the selected sites",
|
||||||
|
"clientInstallOlm": "Install Olm",
|
||||||
|
"clientInstallOlmDescription": "Get Olm running on your system",
|
||||||
|
"clientOlmCredentials": "Olm Credentials",
|
||||||
|
"clientOlmCredentialsDescription": "This is how Olm will authenticate with the server",
|
||||||
|
"olmEndpoint": "Olm Endpoint",
|
||||||
|
"olmId": "Olm ID",
|
||||||
|
"olmSecretKey": "Olm Secret Key",
|
||||||
|
"clientCredentialsSave": "Save Your Credentials",
|
||||||
|
"clientCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.",
|
||||||
|
"generalSettingsDescription": "Configure the general settings for this client",
|
||||||
|
"clientUpdated": "Client updated",
|
||||||
|
"clientUpdatedDescription": "The client has been updated.",
|
||||||
|
"clientUpdateFailed": "Failed to update client",
|
||||||
|
"clientUpdateError": "An error occurred while updating the client.",
|
||||||
|
"sitesFetchFailed": "Failed to fetch sites",
|
||||||
|
"sitesFetchError": "An error occurred while fetching sites.",
|
||||||
|
"olmErrorFetchReleases": "An error occurred while fetching Olm releases.",
|
||||||
|
"olmErrorFetchLatest": "An error occurred while fetching the latest Olm release.",
|
||||||
|
"remoteSubnets": "Remote Subnets",
|
||||||
|
"enterCidrRange": "Enter CIDR range",
|
||||||
|
"remoteSubnetsDescription": "Add CIDR ranges that can access this site remotely. Use format like 10.0.0.0/24 or 192.168.1.0/24.",
|
||||||
|
"resourceEnableProxy": "Enable Public Proxy",
|
||||||
|
"resourceEnableProxyDescription": "Enable public proxying to this resource. This allows access to the resource from outside the network through the cloud on an open port. Requires Traefik config.",
|
||||||
|
"externalProxyEnabled": "External Proxy Enabled"
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,6 @@
|
||||||
"siteErrorCreate": "Site oluşturulurken hata",
|
"siteErrorCreate": "Site oluşturulurken hata",
|
||||||
"siteErrorCreateKeyPair": "Anahtar çifti veya site varsayılanları bulunamadı",
|
"siteErrorCreateKeyPair": "Anahtar çifti veya site varsayılanları bulunamadı",
|
||||||
"siteErrorCreateDefaults": "Site varsayılanları bulunamadı",
|
"siteErrorCreateDefaults": "Site varsayılanları bulunamadı",
|
||||||
"siteNameDescription": "Bu, site için görünen addır.",
|
|
||||||
"method": "Yöntem",
|
"method": "Yöntem",
|
||||||
"siteMethodDescription": "Bağlantıları nasıl açığa çıkaracağınız budur.",
|
"siteMethodDescription": "Bağlantıları nasıl açığa çıkaracağınız budur.",
|
||||||
"siteLearnNewt": "Newt'i sisteminize nasıl kuracağınızı öğrenin",
|
"siteLearnNewt": "Newt'i sisteminize nasıl kuracağınızı öğrenin",
|
||||||
|
@ -1094,7 +1093,7 @@
|
||||||
"sidebarAllUsers": "Tüm Kullanıcılar",
|
"sidebarAllUsers": "Tüm Kullanıcılar",
|
||||||
"sidebarIdentityProviders": "Kimlik Sağlayıcılar",
|
"sidebarIdentityProviders": "Kimlik Sağlayıcılar",
|
||||||
"sidebarLicense": "Lisans",
|
"sidebarLicense": "Lisans",
|
||||||
"sidebarClients": "Müşteriler",
|
"sidebarClients": "Müşteriler (Beta)",
|
||||||
"sidebarDomains": "Alan Adları",
|
"sidebarDomains": "Alan Adları",
|
||||||
"enableDockerSocket": "Docker Soketi Etkinleştir",
|
"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.",
|
"enableDockerSocketDescription": "Konteyner bilgilerini doldurmak için Docker Socket keşfini etkinleştirin. Socket yolu Newt'e sağlanmalıdır.",
|
||||||
|
@ -1162,7 +1161,7 @@
|
||||||
"selectDomainTypeCnameName": "Tekil Alan Adı (CNAME)",
|
"selectDomainTypeCnameName": "Tekil Alan Adı (CNAME)",
|
||||||
"selectDomainTypeCnameDescription": "Sadece bu belirli alan adı. Bireysel alt alan adları veya belirli alan adı girişleri için bunu kullanın.",
|
"selectDomainTypeCnameDescription": "Sadece bu belirli alan adı. Bireysel alt alan adları veya belirli alan adı girişleri için bunu kullanın.",
|
||||||
"selectDomainTypeWildcardName": "Wildcard Alan Adı",
|
"selectDomainTypeWildcardName": "Wildcard Alan Adı",
|
||||||
"selectDomainTypeWildcardDescription": "Bu alan adı ve onun ilk alt alan düzeyi.",
|
"selectDomainTypeWildcardDescription": "Bu domain ve alt alan adları.",
|
||||||
"domainDelegation": "Tekil Alan Adı",
|
"domainDelegation": "Tekil Alan Adı",
|
||||||
"selectType": "Bir tür seçin",
|
"selectType": "Bir tür seçin",
|
||||||
"actions": "İşlemler",
|
"actions": "İşlemler",
|
||||||
|
@ -1196,7 +1195,7 @@
|
||||||
"sidebarExpand": "Genişlet",
|
"sidebarExpand": "Genişlet",
|
||||||
"newtUpdateAvailable": "Güncelleme Mevcut",
|
"newtUpdateAvailable": "Güncelleme Mevcut",
|
||||||
"newtUpdateAvailableInfo": "Newt'in yeni bir versiyonu mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.",
|
"newtUpdateAvailableInfo": "Newt'in yeni bir versiyonu mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.",
|
||||||
"domainPickerEnterDomain": "Alan adınızı girin",
|
"domainPickerEnterDomain": "Domain",
|
||||||
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com veya sadece myapp",
|
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com veya sadece myapp",
|
||||||
"domainPickerDescription": "Mevcut seçenekleri görmek için kaynağın tam etki alanını girin.",
|
"domainPickerDescription": "Mevcut seçenekleri görmek için kaynağın tam etki alanını girin.",
|
||||||
"domainPickerDescriptionSaas": "Mevcut seçenekleri görmek için tam etki alanı, alt etki alanı veya sadece bir isim girin",
|
"domainPickerDescriptionSaas": "Mevcut seçenekleri görmek için tam etki alanı, alt etki alanı veya sadece bir isim girin",
|
||||||
|
@ -1206,7 +1205,7 @@
|
||||||
"domainPickerSortAsc": "A-Z",
|
"domainPickerSortAsc": "A-Z",
|
||||||
"domainPickerSortDesc": "Z-A",
|
"domainPickerSortDesc": "Z-A",
|
||||||
"domainPickerCheckingAvailability": "Kullanılabilirlik kontrol ediliyor...",
|
"domainPickerCheckingAvailability": "Kullanılabilirlik kontrol ediliyor...",
|
||||||
"domainPickerNoMatchingDomains": "\"{userInput}\" için uygun alan adı bulunamadı. Farklı bir alan adı deneyin veya organizasyonunuzun alan adı ayarlarını kontrol edin.",
|
"domainPickerNoMatchingDomains": "Eşleşen domain bulunamadı. Farklı bir domain deneyin veya organizasyonunuzun domain ayarlarını kontrol edin.",
|
||||||
"domainPickerOrganizationDomains": "Organizasyon Alan Adları",
|
"domainPickerOrganizationDomains": "Organizasyon Alan Adları",
|
||||||
"domainPickerProvidedDomains": "Sağlanan Alan Adları",
|
"domainPickerProvidedDomains": "Sağlanan Alan Adları",
|
||||||
"domainPickerSubdomain": "Alt Alan: {subdomain}",
|
"domainPickerSubdomain": "Alt Alan: {subdomain}",
|
||||||
|
@ -1266,6 +1265,7 @@
|
||||||
"createDomainName": "Ad:",
|
"createDomainName": "Ad:",
|
||||||
"createDomainValue": "Değer:",
|
"createDomainValue": "Değer:",
|
||||||
"createDomainCnameRecords": "CNAME Kayıtları",
|
"createDomainCnameRecords": "CNAME Kayıtları",
|
||||||
|
"createDomainARecords": "A Kayıtları",
|
||||||
"createDomainRecordNumber": "Kayıt {number}",
|
"createDomainRecordNumber": "Kayıt {number}",
|
||||||
"createDomainTxtRecords": "TXT Kayıtları",
|
"createDomainTxtRecords": "TXT Kayıtları",
|
||||||
"createDomainSaveTheseRecords": "Bu Kayıtları Kaydet",
|
"createDomainSaveTheseRecords": "Bu Kayıtları Kaydet",
|
||||||
|
@ -1273,5 +1273,50 @@
|
||||||
"createDomainDnsPropagation": "DNS Yayılması",
|
"createDomainDnsPropagation": "DNS Yayılması",
|
||||||
"createDomainDnsPropagationDescription": "DNS değişikliklerinin internet genelinde yayılması zaman alabilir. DNS sağlayıcınız ve TTL ayarlarına bağlı olarak bu birkaç dakika ile 48 saat arasında değişebilir.",
|
"createDomainDnsPropagationDescription": "DNS değişikliklerinin internet genelinde yayılması zaman alabilir. DNS sağlayıcınız ve TTL ayarlarına bağlı olarak bu birkaç dakika ile 48 saat arasında değişebilir.",
|
||||||
"resourcePortRequired": "HTTP dışı kaynaklar için bağlantı noktası numarası gereklidir",
|
"resourcePortRequired": "HTTP dışı kaynaklar için bağlantı noktası numarası gereklidir",
|
||||||
"resourcePortNotAllowed": "HTTP kaynakları için bağlantı noktası numarası ayarlanmamalı"
|
"resourcePortNotAllowed": "HTTP kaynakları için bağlantı noktası numarası ayarlanmamalı",
|
||||||
|
"signUpTerms": {
|
||||||
|
"IAgreeToThe": "Kabul ediyorum",
|
||||||
|
"termsOfService": "hizmet şartları",
|
||||||
|
"and": "ve",
|
||||||
|
"privacyPolicy": "gizlilik politikası"
|
||||||
|
},
|
||||||
|
"siteRequired": "Site gerekli.",
|
||||||
|
"olmTunnel": "Olm Tüneli",
|
||||||
|
"olmTunnelDescription": "Müşteri bağlantıları için Olm kullanın",
|
||||||
|
"errorCreatingClient": "Müşteri oluşturulurken hata oluştu",
|
||||||
|
"clientDefaultsNotFound": "Müşteri varsayılanları bulunamadı",
|
||||||
|
"createClient": "Müşteri Oluştur",
|
||||||
|
"createClientDescription": "Sitelerinize bağlanmak için yeni bir müşteri oluşturun",
|
||||||
|
"seeAllClients": "Tüm Müşterileri Gör",
|
||||||
|
"clientInformation": "Müşteri Bilgileri",
|
||||||
|
"clientNamePlaceholder": "Müşteri adı",
|
||||||
|
"address": "Adres",
|
||||||
|
"subnetPlaceholder": "Alt ağ",
|
||||||
|
"addressDescription": "Bu müşteri için bağlantıda kullanılacak adres",
|
||||||
|
"selectSites": "Siteleri seçin",
|
||||||
|
"sitesDescription": "Müşteri seçilen sitelere bağlantı kuracaktır",
|
||||||
|
"clientInstallOlm": "Olm Yükle",
|
||||||
|
"clientInstallOlmDescription": "Sisteminizde Olm çalıştırın",
|
||||||
|
"clientOlmCredentials": "Olm Kimlik Bilgileri",
|
||||||
|
"clientOlmCredentialsDescription": "Bu, Olm'in sunucu ile kimlik doğrulaması yapacağı yöntemdir",
|
||||||
|
"olmEndpoint": "Olm Uç Noktası",
|
||||||
|
"olmId": "Olm Kimliği",
|
||||||
|
"olmSecretKey": "Olm Gizli Anahtarı",
|
||||||
|
"clientCredentialsSave": "Kimlik Bilgilerinizi Kaydedin",
|
||||||
|
"clientCredentialsSaveDescription": "Bunu yalnızca bir kez görebileceksiniz. Güvenli bir yere kopyaladığınızdan emin olun.",
|
||||||
|
"generalSettingsDescription": "Bu müşteri için genel ayarları yapılandırın",
|
||||||
|
"clientUpdated": "Müşteri güncellendi",
|
||||||
|
"clientUpdatedDescription": "Müşteri güncellenmiştir.",
|
||||||
|
"clientUpdateFailed": "Müşteri güncellenemedi",
|
||||||
|
"clientUpdateError": "Müşteri güncellenirken bir hata oluştu.",
|
||||||
|
"sitesFetchFailed": "Siteler alınamadı",
|
||||||
|
"sitesFetchError": "Siteler alınırken bir hata oluştu.",
|
||||||
|
"olmErrorFetchReleases": "Olm yayınları alınırken bir hata oluştu.",
|
||||||
|
"olmErrorFetchLatest": "En son Olm yayını alınırken bir hata oluştu.",
|
||||||
|
"remoteSubnets": "Uzak Alt Ağlar",
|
||||||
|
"enterCidrRange": "CIDR aralığını girin",
|
||||||
|
"remoteSubnetsDescription": "Bu siteye uzaktan erişebilecek CIDR aralıklarını ekleyin. 10.0.0.0/24 veya 192.168.1.0/24 gibi formatlar kullanın.",
|
||||||
|
"resourceEnableProxy": "Genel Proxy'i Etkinleştir",
|
||||||
|
"resourceEnableProxyDescription": "Bu kaynağa genel proxy erişimini etkinleştirin. Bu sayede ağ dışından açık bir port üzerinden kaynağa bulut aracılığıyla erişim sağlanır. Traefik yapılandırması gereklidir.",
|
||||||
|
"externalProxyEnabled": "Dış Proxy Etkinleştirildi"
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,6 @@
|
||||||
"siteErrorCreate": "创建站点出错",
|
"siteErrorCreate": "创建站点出错",
|
||||||
"siteErrorCreateKeyPair": "找不到密钥对或站点默认值",
|
"siteErrorCreateKeyPair": "找不到密钥对或站点默认值",
|
||||||
"siteErrorCreateDefaults": "未找到站点默认值",
|
"siteErrorCreateDefaults": "未找到站点默认值",
|
||||||
"siteNameDescription": "这是站点的显示名称。",
|
|
||||||
"method": "方法",
|
"method": "方法",
|
||||||
"siteMethodDescription": "这是您将如何显示连接。",
|
"siteMethodDescription": "这是您将如何显示连接。",
|
||||||
"siteLearnNewt": "学习如何在您的系统上安装 Newt",
|
"siteLearnNewt": "学习如何在您的系统上安装 Newt",
|
||||||
|
@ -1094,7 +1093,7 @@
|
||||||
"sidebarAllUsers": "所有用户",
|
"sidebarAllUsers": "所有用户",
|
||||||
"sidebarIdentityProviders": "身份提供商",
|
"sidebarIdentityProviders": "身份提供商",
|
||||||
"sidebarLicense": "证书",
|
"sidebarLicense": "证书",
|
||||||
"sidebarClients": "客户",
|
"sidebarClients": "客户端(测试版)",
|
||||||
"sidebarDomains": "域",
|
"sidebarDomains": "域",
|
||||||
"enableDockerSocket": "启用停靠套接字",
|
"enableDockerSocket": "启用停靠套接字",
|
||||||
"enableDockerSocketDescription": "启用 Docker Socket 发现以填充容器信息。必须向 Newt 提供 Socket 路径。",
|
"enableDockerSocketDescription": "启用 Docker Socket 发现以填充容器信息。必须向 Newt 提供 Socket 路径。",
|
||||||
|
@ -1162,7 +1161,7 @@
|
||||||
"selectDomainTypeCnameName": "单个域(CNAME)",
|
"selectDomainTypeCnameName": "单个域(CNAME)",
|
||||||
"selectDomainTypeCnameDescription": "仅此特定域。用于单个子域或特定域条目。",
|
"selectDomainTypeCnameDescription": "仅此特定域。用于单个子域或特定域条目。",
|
||||||
"selectDomainTypeWildcardName": "通配符域",
|
"selectDomainTypeWildcardName": "通配符域",
|
||||||
"selectDomainTypeWildcardDescription": "此域及其第一级子域。",
|
"selectDomainTypeWildcardDescription": "此域名及其子域名。",
|
||||||
"domainDelegation": "单个域",
|
"domainDelegation": "单个域",
|
||||||
"selectType": "选择一个类型",
|
"selectType": "选择一个类型",
|
||||||
"actions": "操作",
|
"actions": "操作",
|
||||||
|
@ -1196,7 +1195,7 @@
|
||||||
"sidebarExpand": "展开",
|
"sidebarExpand": "展开",
|
||||||
"newtUpdateAvailable": "更新可用",
|
"newtUpdateAvailable": "更新可用",
|
||||||
"newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。",
|
"newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。",
|
||||||
"domainPickerEnterDomain": "输入您的域",
|
"domainPickerEnterDomain": "域名",
|
||||||
"domainPickerPlaceholder": "myapp.example.com、api.v1.mydomain.com 或仅 myapp",
|
"domainPickerPlaceholder": "myapp.example.com、api.v1.mydomain.com 或仅 myapp",
|
||||||
"domainPickerDescription": "输入资源的完整域名以查看可用选项。",
|
"domainPickerDescription": "输入资源的完整域名以查看可用选项。",
|
||||||
"domainPickerDescriptionSaas": "输入完整域名、子域或名称以查看可用选项。",
|
"domainPickerDescriptionSaas": "输入完整域名、子域或名称以查看可用选项。",
|
||||||
|
@ -1206,7 +1205,7 @@
|
||||||
"domainPickerSortAsc": "A-Z",
|
"domainPickerSortAsc": "A-Z",
|
||||||
"domainPickerSortDesc": "Z-A",
|
"domainPickerSortDesc": "Z-A",
|
||||||
"domainPickerCheckingAvailability": "检查可用性...",
|
"domainPickerCheckingAvailability": "检查可用性...",
|
||||||
"domainPickerNoMatchingDomains": "未找到 \"{userInput}\" 的匹配域。尝试其他域或检查您组织的域设置。",
|
"domainPickerNoMatchingDomains": "未找到匹配的域名。尝试不同的域名或检查您组织的域名设置。",
|
||||||
"domainPickerOrganizationDomains": "组织域",
|
"domainPickerOrganizationDomains": "组织域",
|
||||||
"domainPickerProvidedDomains": "提供的域",
|
"domainPickerProvidedDomains": "提供的域",
|
||||||
"domainPickerSubdomain": "子域:{subdomain}",
|
"domainPickerSubdomain": "子域:{subdomain}",
|
||||||
|
@ -1266,6 +1265,7 @@
|
||||||
"createDomainName": "名称:",
|
"createDomainName": "名称:",
|
||||||
"createDomainValue": "值:",
|
"createDomainValue": "值:",
|
||||||
"createDomainCnameRecords": "CNAME 记录",
|
"createDomainCnameRecords": "CNAME 记录",
|
||||||
|
"createDomainARecords": "A记录",
|
||||||
"createDomainRecordNumber": "记录 {number}",
|
"createDomainRecordNumber": "记录 {number}",
|
||||||
"createDomainTxtRecords": "TXT 记录",
|
"createDomainTxtRecords": "TXT 记录",
|
||||||
"createDomainSaveTheseRecords": "保存这些记录",
|
"createDomainSaveTheseRecords": "保存这些记录",
|
||||||
|
@ -1273,5 +1273,50 @@
|
||||||
"createDomainDnsPropagation": "DNS 传播",
|
"createDomainDnsPropagation": "DNS 传播",
|
||||||
"createDomainDnsPropagationDescription": "DNS 更改可能需要一些时间才能在互联网上传播。这可能需要从几分钟到 48 小时,具体取决于您的 DNS 提供商和 TTL 设置。",
|
"createDomainDnsPropagationDescription": "DNS 更改可能需要一些时间才能在互联网上传播。这可能需要从几分钟到 48 小时,具体取决于您的 DNS 提供商和 TTL 设置。",
|
||||||
"resourcePortRequired": "非 HTTP 资源必须输入端口号",
|
"resourcePortRequired": "非 HTTP 资源必须输入端口号",
|
||||||
"resourcePortNotAllowed": "HTTP 资源不应设置端口号"
|
"resourcePortNotAllowed": "HTTP 资源不应设置端口号",
|
||||||
|
"signUpTerms": {
|
||||||
|
"IAgreeToThe": "我同意",
|
||||||
|
"termsOfService": "服务条款",
|
||||||
|
"and": "和",
|
||||||
|
"privacyPolicy": "隐私政策"
|
||||||
|
},
|
||||||
|
"siteRequired": "需要站点。",
|
||||||
|
"olmTunnel": "Olm 隧道",
|
||||||
|
"olmTunnelDescription": "使用 Olm 进行客户端连接",
|
||||||
|
"errorCreatingClient": "创建客户端出错",
|
||||||
|
"clientDefaultsNotFound": "未找到客户端默认值",
|
||||||
|
"createClient": "创建客户端",
|
||||||
|
"createClientDescription": "创建一个新客户端来连接您的站点",
|
||||||
|
"seeAllClients": "查看所有客户端",
|
||||||
|
"clientInformation": "客户端信息",
|
||||||
|
"clientNamePlaceholder": "客户端名称",
|
||||||
|
"address": "地址",
|
||||||
|
"subnetPlaceholder": "子网",
|
||||||
|
"addressDescription": "此客户端将用于连接的地址",
|
||||||
|
"selectSites": "选择站点",
|
||||||
|
"sitesDescription": "客户端将与所选站点进行连接",
|
||||||
|
"clientInstallOlm": "安装 Olm",
|
||||||
|
"clientInstallOlmDescription": "在您的系统上运行 Olm",
|
||||||
|
"clientOlmCredentials": "Olm 凭据",
|
||||||
|
"clientOlmCredentialsDescription": "这是 Olm 服务器的身份验证方式",
|
||||||
|
"olmEndpoint": "Olm 端点",
|
||||||
|
"olmId": "Olm ID",
|
||||||
|
"olmSecretKey": "Olm 私钥",
|
||||||
|
"clientCredentialsSave": "保存您的凭据",
|
||||||
|
"clientCredentialsSaveDescription": "该信息仅会显示一次,请确保将其复制到安全位置。",
|
||||||
|
"generalSettingsDescription": "配置此客户端的常规设置",
|
||||||
|
"clientUpdated": "客户端已更新",
|
||||||
|
"clientUpdatedDescription": "客户端已更新。",
|
||||||
|
"clientUpdateFailed": "更新客户端失败",
|
||||||
|
"clientUpdateError": "更新客户端时出错。",
|
||||||
|
"sitesFetchFailed": "获取站点失败",
|
||||||
|
"sitesFetchError": "获取站点时出错。",
|
||||||
|
"olmErrorFetchReleases": "获取 Olm 发布版本时出错。",
|
||||||
|
"olmErrorFetchLatest": "获取最新 Olm 发布版本时出错。",
|
||||||
|
"remoteSubnets": "远程子网",
|
||||||
|
"enterCidrRange": "输入 CIDR 范围",
|
||||||
|
"remoteSubnetsDescription": "添加能远程访问此站点的 CIDR 范围。使用格式如 10.0.0.0/24 或 192.168.1.0/24。",
|
||||||
|
"resourceEnableProxy": "启用公共代理",
|
||||||
|
"resourceEnableProxyDescription": "启用到此资源的公共代理。这允许外部网络通过开放端口访问资源。需要 Traefik 配置。",
|
||||||
|
"externalProxyEnabled": "外部代理已启用"
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,8 @@ import {
|
||||||
boolean,
|
boolean,
|
||||||
integer,
|
integer,
|
||||||
bigint,
|
bigint,
|
||||||
real
|
real,
|
||||||
|
text
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { InferSelectModel } from "drizzle-orm";
|
import { InferSelectModel } from "drizzle-orm";
|
||||||
|
|
||||||
|
@ -58,7 +59,8 @@ export const sites = pgTable("sites", {
|
||||||
publicKey: varchar("publicKey"),
|
publicKey: varchar("publicKey"),
|
||||||
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
|
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
|
||||||
listenPort: integer("listenPort"),
|
listenPort: integer("listenPort"),
|
||||||
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true)
|
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
|
||||||
|
remoteSubnets: text("remoteSubnets") // comma-separated list of subnets that this site can access
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resources = pgTable("resources", {
|
export const resources = pgTable("resources", {
|
||||||
|
@ -92,7 +94,8 @@ export const resources = pgTable("resources", {
|
||||||
enabled: boolean("enabled").notNull().default(true),
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
stickySession: boolean("stickySession").notNull().default(false),
|
stickySession: boolean("stickySession").notNull().default(false),
|
||||||
tlsServerName: varchar("tlsServerName"),
|
tlsServerName: varchar("tlsServerName"),
|
||||||
setHostHeader: varchar("setHostHeader")
|
setHostHeader: varchar("setHostHeader"),
|
||||||
|
enableProxy: boolean("enableProxy").default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const targets = pgTable("targets", {
|
export const targets = pgTable("targets", {
|
||||||
|
@ -135,6 +138,8 @@ export const users = pgTable("user", {
|
||||||
twoFactorSecret: varchar("twoFactorSecret"),
|
twoFactorSecret: varchar("twoFactorSecret"),
|
||||||
emailVerified: boolean("emailVerified").notNull().default(false),
|
emailVerified: boolean("emailVerified").notNull().default(false),
|
||||||
dateCreated: varchar("dateCreated").notNull(),
|
dateCreated: varchar("dateCreated").notNull(),
|
||||||
|
termsAcceptedTimestamp: varchar("termsAcceptedTimestamp"),
|
||||||
|
termsVersion: varchar("termsVersion"),
|
||||||
serverAdmin: boolean("serverAdmin").notNull().default(false)
|
serverAdmin: boolean("serverAdmin").notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -504,8 +509,8 @@ export const clients = pgTable("clients", {
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
pubKey: varchar("pubKey"),
|
pubKey: varchar("pubKey"),
|
||||||
subnet: varchar("subnet").notNull(),
|
subnet: varchar("subnet").notNull(),
|
||||||
megabytesIn: integer("bytesIn"),
|
megabytesIn: real("bytesIn"),
|
||||||
megabytesOut: integer("bytesOut"),
|
megabytesOut: real("bytesOut"),
|
||||||
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
||||||
lastPing: varchar("lastPing"),
|
lastPing: varchar("lastPing"),
|
||||||
type: varchar("type").notNull(), // "olm"
|
type: varchar("type").notNull(), // "olm"
|
||||||
|
@ -539,7 +544,7 @@ export const olmSessions = pgTable("clientSession", {
|
||||||
olmId: varchar("olmId")
|
olmId: varchar("olmId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => olms.olmId, { onDelete: "cascade" }),
|
.references(() => olms.olmId, { onDelete: "cascade" }),
|
||||||
expiresAt: integer("expiresAt").notNull()
|
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userClients = pgTable("userClients", {
|
export const userClients = pgTable("userClients", {
|
||||||
|
@ -562,7 +567,9 @@ export const roleClients = pgTable("roleClients", {
|
||||||
|
|
||||||
export const securityKeys = pgTable("webauthnCredentials", {
|
export const securityKeys = pgTable("webauthnCredentials", {
|
||||||
credentialId: varchar("credentialId").primaryKey(),
|
credentialId: varchar("credentialId").primaryKey(),
|
||||||
userId: varchar("userId").notNull().references(() => users.userId, {
|
userId: varchar("userId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.userId, {
|
||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
}),
|
}),
|
||||||
publicKey: varchar("publicKey").notNull(),
|
publicKey: varchar("publicKey").notNull(),
|
||||||
|
|
|
@ -65,7 +65,8 @@ export const sites = sqliteTable("sites", {
|
||||||
listenPort: integer("listenPort"),
|
listenPort: integer("listenPort"),
|
||||||
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(true)
|
.default(true),
|
||||||
|
remoteSubnets: text("remoteSubnets"), // comma-separated list of subnets that this site can access
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resources = sqliteTable("resources", {
|
export const resources = sqliteTable("resources", {
|
||||||
|
@ -105,7 +106,8 @@ export const resources = sqliteTable("resources", {
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
tlsServerName: text("tlsServerName"),
|
tlsServerName: text("tlsServerName"),
|
||||||
setHostHeader: text("setHostHeader")
|
setHostHeader: text("setHostHeader"),
|
||||||
|
enableProxy: integer("enableProxy", { mode: "boolean" }).default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const targets = sqliteTable("targets", {
|
export const targets = sqliteTable("targets", {
|
||||||
|
@ -154,6 +156,8 @@ export const users = sqliteTable("user", {
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
dateCreated: text("dateCreated").notNull(),
|
dateCreated: text("dateCreated").notNull(),
|
||||||
|
termsAcceptedTimestamp: text("termsAcceptedTimestamp"),
|
||||||
|
termsVersion: text("termsVersion"),
|
||||||
serverAdmin: integer("serverAdmin", { mode: "boolean" })
|
serverAdmin: integer("serverAdmin", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false)
|
.default(false)
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
#! /usr/bin/env node
|
||||||
import "./extendZod.ts";
|
import "./extendZod.ts";
|
||||||
|
|
||||||
import { runSetupFunctions } from "./setup";
|
import { runSetupFunctions } from "./setup";
|
||||||
|
|
|
@ -2,7 +2,7 @@ import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
// This is a placeholder value replaced by the build process
|
// This is a placeholder value replaced by the build process
|
||||||
export const APP_VERSION = "1.7.3";
|
export const APP_VERSION = "1.8.0";
|
||||||
|
|
||||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||||
export const __DIRNAME = path.dirname(__FILENAME);
|
export const __DIRNAME = path.dirname(__FILENAME);
|
||||||
|
|
|
@ -213,7 +213,7 @@ export const configSchema = z
|
||||||
smtp_host: z.string().optional(),
|
smtp_host: z.string().optional(),
|
||||||
smtp_port: portSchema.optional(),
|
smtp_port: portSchema.optional(),
|
||||||
smtp_user: z.string().optional(),
|
smtp_user: z.string().optional(),
|
||||||
smtp_pass: z.string().optional(),
|
smtp_pass: z.string().optional().transform(getEnvOrYaml("EMAIL_SMTP_PASS")),
|
||||||
smtp_secure: z.boolean().optional(),
|
smtp_secure: z.boolean().optional(),
|
||||||
smtp_tls_reject_unauthorized: z.boolean().optional(),
|
smtp_tls_reject_unauthorized: z.boolean().optional(),
|
||||||
no_reply: z.string().email().optional()
|
no_reply: z.string().email().optional()
|
||||||
|
@ -229,9 +229,22 @@ export const configSchema = z
|
||||||
disable_local_sites: z.boolean().optional(),
|
disable_local_sites: z.boolean().optional(),
|
||||||
disable_basic_wireguard_sites: z.boolean().optional(),
|
disable_basic_wireguard_sites: z.boolean().optional(),
|
||||||
disable_config_managed_domains: z.boolean().optional(),
|
disable_config_managed_domains: z.boolean().optional(),
|
||||||
enable_clients: z.boolean().optional()
|
enable_clients: z.boolean().optional().default(true),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
dns: z
|
||||||
|
.object({
|
||||||
|
nameservers: z
|
||||||
|
.array(z.string().optional().optional())
|
||||||
|
.optional()
|
||||||
|
.default(["ns1.fossorial.io", "ns2.fossorial.io"]),
|
||||||
|
cname_extension: z.string().optional().default("fossorial.io")
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
|
.default({
|
||||||
|
nameservers: ["ns1.fossorial.io", "ns2.fossorial.io"],
|
||||||
|
cname_extension: "fossorial.io"
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
|
|
|
@ -106,21 +106,21 @@ export async function login(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has security keys registered
|
// // Check if user has security keys registered
|
||||||
const userSecurityKeys = await db
|
// const userSecurityKeys = await db
|
||||||
.select()
|
// .select()
|
||||||
.from(securityKeys)
|
// .from(securityKeys)
|
||||||
.where(eq(securityKeys.userId, existingUser.userId));
|
// .where(eq(securityKeys.userId, existingUser.userId));
|
||||||
|
//
|
||||||
if (userSecurityKeys.length > 0) {
|
// if (userSecurityKeys.length > 0) {
|
||||||
return response<LoginResponse>(res, {
|
// return response<LoginResponse>(res, {
|
||||||
data: { useSecurityKey: true },
|
// data: { useSecurityKey: true },
|
||||||
success: true,
|
// success: true,
|
||||||
error: false,
|
// error: false,
|
||||||
message: "Security key authentication required",
|
// message: "Security key authentication required",
|
||||||
status: HttpCode.OK
|
// status: HttpCode.OK
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (
|
if (
|
||||||
existingUser.twoFactorSetupRequested &&
|
existingUser.twoFactorSetupRequested &&
|
||||||
|
|
|
@ -21,15 +21,14 @@ import { hashPassword } from "@server/auth/password";
|
||||||
import { checkValidInvite } from "@server/auth/checkValidInvite";
|
import { checkValidInvite } from "@server/auth/checkValidInvite";
|
||||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
export const signupBodySchema = z.object({
|
export const signupBodySchema = z.object({
|
||||||
email: z
|
email: z.string().toLowerCase().email(),
|
||||||
.string()
|
|
||||||
.toLowerCase()
|
|
||||||
.email(),
|
|
||||||
password: passwordSchema,
|
password: passwordSchema,
|
||||||
inviteToken: z.string().optional(),
|
inviteToken: z.string().optional(),
|
||||||
inviteId: z.string().optional()
|
inviteId: z.string().optional(),
|
||||||
|
termsAcceptedTimestamp: z.string().nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SignUpBody = z.infer<typeof signupBodySchema>;
|
export type SignUpBody = z.infer<typeof signupBodySchema>;
|
||||||
|
@ -54,7 +53,8 @@ export async function signup(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { email, password, inviteToken, inviteId } = parsedBody.data;
|
const { email, password, inviteToken, inviteId, termsAcceptedTimestamp } =
|
||||||
|
parsedBody.data;
|
||||||
|
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
const userId = generateId(15);
|
const userId = generateId(15);
|
||||||
|
@ -161,13 +161,24 @@ export async function signup(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (build === "saas" && !termsAcceptedTimestamp) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"You must accept the terms of service and privacy policy"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await db.insert(users).values({
|
await db.insert(users).values({
|
||||||
userId: userId,
|
userId: userId,
|
||||||
type: UserType.Internal,
|
type: UserType.Internal,
|
||||||
username: email,
|
username: email,
|
||||||
email: email,
|
email: email,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
dateCreated: moment().toISOString()
|
dateCreated: moment().toISOString(),
|
||||||
|
termsAcceptedTimestamp: termsAcceptedTimestamp || null,
|
||||||
|
termsVersion: "1"
|
||||||
});
|
});
|
||||||
|
|
||||||
// give the user their default permissions:
|
// give the user their default permissions:
|
||||||
|
|
|
@ -14,7 +14,7 @@ import { OpenAPITags, registry } from "@server/openApi";
|
||||||
const getClientSchema = z
|
const getClientSchema = z
|
||||||
.object({
|
.object({
|
||||||
clientId: z.string().transform(stoi).pipe(z.number().int().positive()),
|
clientId: z.string().transform(stoi).pipe(z.number().int().positive()),
|
||||||
orgId: z.string().optional()
|
orgId: z.string()
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db, exitNodes, sites } from "@server/db";
|
||||||
import { clients, clientSites } from "@server/db";
|
import { clients, clientSites } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
@ -17,6 +17,7 @@ import {
|
||||||
addPeer as olmAddPeer,
|
addPeer as olmAddPeer,
|
||||||
deletePeer as olmDeletePeer
|
deletePeer as olmDeletePeer
|
||||||
} from "../olm/peers";
|
} from "../olm/peers";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
const updateClientParamsSchema = z
|
const updateClientParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -53,6 +54,11 @@ registry.registerPath({
|
||||||
responses: {}
|
responses: {}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface PeerDestination {
|
||||||
|
destinationIP: string;
|
||||||
|
destinationPort: number;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateClient(
|
export async function updateClient(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
|
@ -124,15 +130,22 @@ export async function updateClient(
|
||||||
);
|
);
|
||||||
for (const siteId of sitesAdded) {
|
for (const siteId of sitesAdded) {
|
||||||
if (!client.subnet || !client.pubKey || !client.endpoint) {
|
if (!client.subnet || !client.pubKey || !client.endpoint) {
|
||||||
logger.debug("Client subnet, pubKey or endpoint is not set");
|
logger.debug(
|
||||||
|
"Client subnet, pubKey or endpoint is not set"
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: WE NEED TO HANDLE THIS BETTER. RIGHT NOW WE ARE JUST GUESSING BASED ON THE OTHER SITES
|
||||||
|
// BUT REALLY WE NEED TO TRACK THE USERS PREFERENCE THAT THEY CHOSE IN THE CLIENTS
|
||||||
|
const isRelayed = true;
|
||||||
|
|
||||||
const site = await newtAddPeer(siteId, {
|
const site = await newtAddPeer(siteId, {
|
||||||
publicKey: client.pubKey,
|
publicKey: client.pubKey,
|
||||||
allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client
|
allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client
|
||||||
endpoint: client.endpoint
|
endpoint: isRelayed ? "" : client.endpoint
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!site) {
|
if (!site) {
|
||||||
logger.debug("Failed to add peer to newt - missing site");
|
logger.debug("Failed to add peer to newt - missing site");
|
||||||
continue;
|
continue;
|
||||||
|
@ -142,12 +155,49 @@ export async function updateClient(
|
||||||
logger.debug("Site endpoint or publicKey is not set");
|
logger.debug("Site endpoint or publicKey is not set");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let endpoint;
|
||||||
|
|
||||||
|
if (isRelayed) {
|
||||||
|
if (!site.exitNodeId) {
|
||||||
|
logger.warn(
|
||||||
|
`Site ${site.siteId} has no exit node, skipping`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the exit node for the site
|
||||||
|
const [exitNode] = await db
|
||||||
|
.select()
|
||||||
|
.from(exitNodes)
|
||||||
|
.where(eq(exitNodes.exitNodeId, site.exitNodeId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!exitNode) {
|
||||||
|
logger.warn(
|
||||||
|
`Exit node not found for site ${site.siteId}`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint = `${exitNode.endpoint}:21820`;
|
||||||
|
} else {
|
||||||
|
if (!endpoint) {
|
||||||
|
logger.warn(
|
||||||
|
`Site ${site.siteId} has no endpoint, skipping`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
endpoint = site.endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
await olmAddPeer(client.clientId, {
|
await olmAddPeer(client.clientId, {
|
||||||
siteId: siteId,
|
siteId: site.siteId,
|
||||||
endpoint: site.endpoint,
|
endpoint: endpoint,
|
||||||
publicKey: site.publicKey,
|
publicKey: site.publicKey,
|
||||||
serverIP: site.address,
|
serverIP: site.address,
|
||||||
serverPort: site.listenPort
|
serverPort: site.listenPort,
|
||||||
|
remoteSubnets: site.remoteSubnets
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -170,7 +220,11 @@ export async function updateClient(
|
||||||
logger.debug("Site endpoint or publicKey is not set");
|
logger.debug("Site endpoint or publicKey is not set");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await olmDeletePeer(client.clientId, site.siteId, site.publicKey);
|
await olmDeletePeer(
|
||||||
|
client.clientId,
|
||||||
|
site.siteId,
|
||||||
|
site.publicKey
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -201,6 +255,101 @@ export async function updateClient(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (client.endpoint) {
|
||||||
|
// get all sites for this client and join with exit nodes with site.exitNodeId
|
||||||
|
const sitesData = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.innerJoin(
|
||||||
|
clientSites,
|
||||||
|
eq(sites.siteId, clientSites.siteId)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
exitNodes,
|
||||||
|
eq(sites.exitNodeId, exitNodes.exitNodeId)
|
||||||
|
)
|
||||||
|
.where(eq(clientSites.clientId, client.clientId));
|
||||||
|
|
||||||
|
let exitNodeDestinations: {
|
||||||
|
reachableAt: string;
|
||||||
|
destinations: PeerDestination[];
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
for (const site of sitesData) {
|
||||||
|
if (!site.sites.subnet) {
|
||||||
|
logger.warn(
|
||||||
|
`Site ${site.sites.siteId} has no subnet, skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// find the destinations in the array
|
||||||
|
let destinations = exitNodeDestinations.find(
|
||||||
|
(d) => d.reachableAt === site.exitNodes?.reachableAt
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!destinations) {
|
||||||
|
destinations = {
|
||||||
|
reachableAt: site.exitNodes?.reachableAt || "",
|
||||||
|
destinations: [
|
||||||
|
{
|
||||||
|
destinationIP:
|
||||||
|
site.sites.subnet.split("/")[0],
|
||||||
|
destinationPort: site.sites.listenPort || 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// add to the existing destinations
|
||||||
|
destinations.destinations.push({
|
||||||
|
destinationIP: site.sites.subnet.split("/")[0],
|
||||||
|
destinationPort: site.sites.listenPort || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// update it in the array
|
||||||
|
exitNodeDestinations = exitNodeDestinations.filter(
|
||||||
|
(d) => d.reachableAt !== site.exitNodes?.reachableAt
|
||||||
|
);
|
||||||
|
exitNodeDestinations.push(destinations);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const destination of exitNodeDestinations) {
|
||||||
|
try {
|
||||||
|
logger.info(
|
||||||
|
`Updating destinations for exit node at ${destination.reachableAt}`
|
||||||
|
);
|
||||||
|
const payload = {
|
||||||
|
sourceIp: client.endpoint?.split(":")[0] || "",
|
||||||
|
sourcePort: parseInt(client.endpoint?.split(":")[1]) || 0,
|
||||||
|
destinations: destination.destinations
|
||||||
|
};
|
||||||
|
logger.info(
|
||||||
|
`Payload for update-destinations: ${JSON.stringify(payload, null, 2)}`
|
||||||
|
);
|
||||||
|
const response = await axios.post(
|
||||||
|
`${destination.reachableAt}/update-destinations`,
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("Destinations updated:", {
|
||||||
|
peer: response.data.status
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
throw new Error(
|
||||||
|
`Error communicating with Gerbil. Make sure Pangolin can reach the Gerbil HTTP API: ${error.response?.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch the updated client
|
// Fetch the updated client
|
||||||
const [updatedClient] = await trx
|
const [updatedClient] = await trx
|
||||||
.select()
|
.select()
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { generateId } from "@server/auth/sessions/app";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { isValidDomain } from "@server/lib/validators";
|
import { isValidDomain } from "@server/lib/validators";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -228,15 +229,15 @@ export async function createOrgDomain(
|
||||||
|
|
||||||
// TODO: This needs to be cross region and not hardcoded
|
// TODO: This needs to be cross region and not hardcoded
|
||||||
if (type === "ns") {
|
if (type === "ns") {
|
||||||
nsRecords = ["ns-east.fossorial.io", "ns-west.fossorial.io"];
|
nsRecords = config.getRawConfig().dns.nameservers as string[];
|
||||||
} else if (type === "cname") {
|
} else if (type === "cname") {
|
||||||
cnameRecords = [
|
cnameRecords = [
|
||||||
{
|
{
|
||||||
value: `${domainId}.cname.fossorial.io`,
|
value: `${domainId}.${config.getRawConfig().dns.cname_extension}`,
|
||||||
baseDomain: baseDomain
|
baseDomain: baseDomain
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: `_acme-challenge.${domainId}.cname.fossorial.io`,
|
value: `_acme-challenge.${domainId}.${config.getRawConfig().dns.cname_extension}`,
|
||||||
baseDomain: `_acme-challenge.${baseDomain}`
|
baseDomain: `_acme-challenge.${baseDomain}`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
|
@ -233,6 +233,12 @@ authenticated.get(
|
||||||
resource.listResources
|
resource.listResources
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/user-resources",
|
||||||
|
verifyOrgAccess,
|
||||||
|
resource.getUserResources
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/domains",
|
"/org/:orgId/domains",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
|
|
@ -8,7 +8,7 @@ export async function addPeer(exitNodeId: number, peer: {
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
allowedIps: string[];
|
allowedIps: string[];
|
||||||
}) {
|
}) {
|
||||||
|
logger.info(`Adding peer with public key ${peer.publicKey} to exit node ${exitNodeId}`);
|
||||||
const [exitNode] = await db.select().from(exitNodes).where(eq(exitNodes.exitNodeId, exitNodeId)).limit(1);
|
const [exitNode] = await db.select().from(exitNodes).where(eq(exitNodes.exitNodeId, exitNodeId)).limit(1);
|
||||||
if (!exitNode) {
|
if (!exitNode) {
|
||||||
throw new Error(`Exit node with ID ${exitNodeId} not found`);
|
throw new Error(`Exit node with ID ${exitNodeId} not found`);
|
||||||
|
@ -35,6 +35,7 @@ export async function addPeer(exitNodeId: number, peer: {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePeer(exitNodeId: number, publicKey: string) {
|
export async function deletePeer(exitNodeId: number, publicKey: string) {
|
||||||
|
logger.info(`Deleting peer with public key ${publicKey} from exit node ${exitNodeId}`);
|
||||||
const [exitNode] = await db.select().from(exitNodes).where(eq(exitNodes.exitNodeId, exitNodeId)).limit(1);
|
const [exitNode] = await db.select().from(exitNodes).where(eq(exitNodes.exitNodeId, exitNodeId)).limit(1);
|
||||||
if (!exitNode) {
|
if (!exitNode) {
|
||||||
throw new Error(`Exit node with ID ${exitNodeId} not found`);
|
throw new Error(`Exit node with ID ${exitNodeId} not found`);
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { clients, newts, olms, Site, sites, clientSites } from "@server/db";
|
import { clients, newts, olms, Site, sites, clientSites, exitNodes } from "@server/db";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
@ -9,6 +9,7 @@ import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { validateNewtSessionToken } from "@server/auth/sessions/newt";
|
import { validateNewtSessionToken } from "@server/auth/sessions/newt";
|
||||||
import { validateOlmSessionToken } from "@server/auth/sessions/olm";
|
import { validateOlmSessionToken } from "@server/auth/sessions/olm";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
// Define Zod schema for request validation
|
// Define Zod schema for request validation
|
||||||
const updateHolePunchSchema = z.object({
|
const updateHolePunchSchema = z.object({
|
||||||
|
@ -17,7 +18,8 @@ const updateHolePunchSchema = z.object({
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
ip: z.string(),
|
ip: z.string(),
|
||||||
port: z.number(),
|
port: z.number(),
|
||||||
timestamp: z.number()
|
timestamp: z.number(),
|
||||||
|
reachableAt: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
// New response type with multi-peer destination support
|
// New response type with multi-peer destination support
|
||||||
|
@ -43,8 +45,7 @@ export async function updateHolePunch(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { olmId, newtId, ip, port, timestamp, token } = parsedParams.data;
|
const { olmId, newtId, ip, port, timestamp, token, reachableAt } = parsedParams.data;
|
||||||
|
|
||||||
|
|
||||||
let currentSiteId: number | undefined;
|
let currentSiteId: number | undefined;
|
||||||
let destinations: PeerDestination[] = [];
|
let destinations: PeerDestination[] = [];
|
||||||
|
@ -95,37 +96,129 @@ export async function updateHolePunch(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all sites that this client is connected to
|
// // Get all sites that this client is connected to
|
||||||
const clientSitePairs = await db
|
// const clientSitePairs = await db
|
||||||
.select()
|
// .select()
|
||||||
.from(clientSites)
|
// .from(clientSites)
|
||||||
.where(eq(clientSites.clientId, client.clientId));
|
// .where(eq(clientSites.clientId, client.clientId));
|
||||||
|
|
||||||
if (clientSitePairs.length === 0) {
|
// if (clientSitePairs.length === 0) {
|
||||||
logger.warn(`No sites found for client: ${client.clientId}`);
|
// logger.warn(`No sites found for client: ${client.clientId}`);
|
||||||
return next(
|
// return next(
|
||||||
createHttpError(HttpCode.NOT_FOUND, "No sites found for client")
|
// createHttpError(HttpCode.NOT_FOUND, "No sites found for client")
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Get all sites details
|
// // Get all sites details
|
||||||
const siteIds = clientSitePairs.map(pair => pair.siteId);
|
// const siteIds = clientSitePairs.map(pair => pair.siteId);
|
||||||
|
|
||||||
for (const siteId of siteIds) {
|
// for (const siteId of siteIds) {
|
||||||
const [site] = await db
|
// const [site] = await db
|
||||||
|
// .select()
|
||||||
|
// .from(sites)
|
||||||
|
// .where(eq(sites.siteId, siteId));
|
||||||
|
|
||||||
|
// if (site && site.subnet && site.listenPort) {
|
||||||
|
// destinations.push({
|
||||||
|
// destinationIP: site.subnet.split("/")[0],
|
||||||
|
// destinationPort: site.listenPort
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// get all sites for this client and join with exit nodes with site.exitNodeId
|
||||||
|
const sitesData = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(eq(sites.siteId, siteId));
|
.innerJoin(clientSites, eq(sites.siteId, clientSites.siteId))
|
||||||
|
.leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId))
|
||||||
|
.where(eq(clientSites.clientId, client.clientId));
|
||||||
|
|
||||||
if (site && site.subnet && site.listenPort) {
|
let exitNodeDestinations: {
|
||||||
destinations.push({
|
reachableAt: string;
|
||||||
destinationIP: site.subnet.split("/")[0],
|
destinations: PeerDestination[];
|
||||||
destinationPort: site.listenPort
|
}[] = [];
|
||||||
|
|
||||||
|
for (const site of sitesData) {
|
||||||
|
if (!site.sites.subnet) {
|
||||||
|
logger.warn(`Site ${site.sites.siteId} has no subnet, skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// find the destinations in the array
|
||||||
|
let destinations = exitNodeDestinations.find(
|
||||||
|
(d) => d.reachableAt === site.exitNodes?.reachableAt
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!destinations) {
|
||||||
|
destinations = {
|
||||||
|
reachableAt: site.exitNodes?.reachableAt || "",
|
||||||
|
destinations: [
|
||||||
|
{
|
||||||
|
destinationIP: site.sites.subnet.split("/")[0],
|
||||||
|
destinationPort: site.sites.listenPort || 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// add to the existing destinations
|
||||||
|
destinations.destinations.push({
|
||||||
|
destinationIP: site.sites.subnet.split("/")[0],
|
||||||
|
destinationPort: site.sites.listenPort || 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// update it in the array
|
||||||
|
exitNodeDestinations = exitNodeDestinations.filter(
|
||||||
|
(d) => d.reachableAt !== site.exitNodes?.reachableAt
|
||||||
|
);
|
||||||
|
exitNodeDestinations.push(destinations);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug(JSON.stringify(exitNodeDestinations, null, 2));
|
||||||
|
|
||||||
|
for (const destination of exitNodeDestinations) {
|
||||||
|
// if its the current exit node skip it because it is replying with the same data
|
||||||
|
if (reachableAt && destination.reachableAt == reachableAt) {
|
||||||
|
logger.debug(`Skipping update for reachableAt: ${reachableAt}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${destination.reachableAt}/update-destinations`,
|
||||||
|
{
|
||||||
|
sourceIp: client.endpoint?.split(":")[0] || "",
|
||||||
|
sourcePort: client.endpoint?.split(":")[1] || 0,
|
||||||
|
destinations: destination.destinations
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("Destinations updated:", {
|
||||||
|
peer: response.data.status
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
throw new Error(
|
||||||
|
`Error communicating with Gerbil. Make sure Pangolin can reach the Gerbil HTTP API: ${error.response?.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the desinations back to the origin
|
||||||
|
destinations = exitNodeDestinations.find(
|
||||||
|
(d) => d.reachableAt === reachableAt
|
||||||
|
)?.destinations || [];
|
||||||
|
|
||||||
} else if (newtId) {
|
} else if (newtId) {
|
||||||
|
logger.debug(`Got hole punch with ip: ${ip}, port: ${port} for olmId: ${olmId}`);
|
||||||
|
|
||||||
const { session, newt: newtSession } =
|
const { session, newt: newtSession } =
|
||||||
await validateNewtSessionToken(token);
|
await validateNewtSessionToken(token);
|
||||||
|
|
||||||
|
@ -174,28 +267,29 @@ export async function updateHolePunch(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find all clients that connect to this site
|
// Find all clients that connect to this site
|
||||||
const sitesClientPairs = await db
|
// const sitesClientPairs = await db
|
||||||
.select()
|
// .select()
|
||||||
.from(clientSites)
|
// .from(clientSites)
|
||||||
.where(eq(clientSites.siteId, newt.siteId));
|
// .where(eq(clientSites.siteId, newt.siteId));
|
||||||
|
|
||||||
|
// THE NEWT IS NOT SENDING RAW WG TO THE GERBIL SO IDK IF WE REALLY NEED THIS - REMOVING
|
||||||
// Get client details for each client
|
// Get client details for each client
|
||||||
for (const pair of sitesClientPairs) {
|
// for (const pair of sitesClientPairs) {
|
||||||
const [client] = await db
|
// const [client] = await db
|
||||||
.select()
|
// .select()
|
||||||
.from(clients)
|
// .from(clients)
|
||||||
.where(eq(clients.clientId, pair.clientId));
|
// .where(eq(clients.clientId, pair.clientId));
|
||||||
|
|
||||||
if (client && client.endpoint) {
|
// if (client && client.endpoint) {
|
||||||
const [host, portStr] = client.endpoint.split(':');
|
// const [host, portStr] = client.endpoint.split(':');
|
||||||
if (host && portStr) {
|
// if (host && portStr) {
|
||||||
destinations.push({
|
// destinations.push({
|
||||||
destinationIP: host,
|
// destinationIP: host,
|
||||||
destinationPort: parseInt(portStr, 10)
|
// destinationPort: parseInt(portStr, 10)
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
// If this is a newt/site, also add other sites in the same org
|
// If this is a newt/site, also add other sites in the same org
|
||||||
// if (updatedSite.orgId) {
|
// if (updatedSite.orgId) {
|
||||||
|
|
|
@ -2,10 +2,18 @@ import { z } from "zod";
|
||||||
import { MessageHandler } from "../ws";
|
import { MessageHandler } from "../ws";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { db } from "@server/db";
|
import {
|
||||||
|
db,
|
||||||
|
ExitNode,
|
||||||
|
exitNodes,
|
||||||
|
resources,
|
||||||
|
Target,
|
||||||
|
targets
|
||||||
|
} from "@server/db";
|
||||||
import { clients, clientSites, Newt, sites } from "@server/db";
|
import { clients, clientSites, Newt, sites } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
import { updatePeer } from "../olm/peers";
|
import { updatePeer } from "../olm/peers";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
const inputSchema = z.object({
|
const inputSchema = z.object({
|
||||||
publicKey: z.string(),
|
publicKey: z.string(),
|
||||||
|
@ -87,6 +95,48 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let exitNode: ExitNode | undefined;
|
||||||
|
if (site.exitNodeId) {
|
||||||
|
[exitNode] = await db
|
||||||
|
.select()
|
||||||
|
.from(exitNodes)
|
||||||
|
.where(eq(exitNodes.exitNodeId, site.exitNodeId))
|
||||||
|
.limit(1);
|
||||||
|
if (exitNode.reachableAt) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${exitNode.reachableAt}/update-proxy-mapping`,
|
||||||
|
{
|
||||||
|
oldDestination: {
|
||||||
|
destinationIP: existingSite.subnet?.split("/")[0],
|
||||||
|
destinationPort: existingSite.listenPort
|
||||||
|
},
|
||||||
|
newDestination: {
|
||||||
|
destinationIP: site.subnet?.split("/")[0],
|
||||||
|
destinationPort: site.listenPort
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("Destinations updated:", {
|
||||||
|
peer: response.data.status
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
throw new Error(
|
||||||
|
`Error communicating with Gerbil. Make sure Pangolin can reach the Gerbil HTTP API: ${error.response?.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get all clients connected to this site
|
// Get all clients connected to this site
|
||||||
const clientsRes = await db
|
const clientsRes = await db
|
||||||
.select()
|
.select()
|
||||||
|
@ -107,33 +157,59 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
||||||
if (!client.clients.endpoint) {
|
if (!client.clients.endpoint) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!client.clients.online) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.map(async (client) => {
|
.map(async (client) => {
|
||||||
// Add or update this peer on the olm if it is connected
|
// Add or update this peer on the olm if it is connected
|
||||||
try {
|
try {
|
||||||
if (site.endpoint && site.publicKey) {
|
if (!site.publicKey) {
|
||||||
|
logger.warn(
|
||||||
|
`Site ${site.siteId} has no public key, skipping`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let endpoint = site.endpoint;
|
||||||
|
if (client.clientSites.isRelayed) {
|
||||||
|
if (!site.exitNodeId) {
|
||||||
|
logger.warn(
|
||||||
|
`Site ${site.siteId} has no exit node, skipping`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!exitNode) {
|
||||||
|
logger.warn(
|
||||||
|
`Exit node not found for site ${site.siteId}`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
endpoint = `${exitNode.endpoint}:21820`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!endpoint) {
|
||||||
|
logger.warn(
|
||||||
|
`Site ${site.siteId} has no endpoint, skipping`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
await updatePeer(client.clients.clientId, {
|
await updatePeer(client.clients.clientId, {
|
||||||
siteId: site.siteId,
|
siteId: site.siteId,
|
||||||
endpoint: site.endpoint,
|
endpoint: endpoint,
|
||||||
publicKey: site.publicKey,
|
publicKey: site.publicKey,
|
||||||
serverIP: site.address,
|
serverIP: site.address,
|
||||||
serverPort: site.listenPort
|
serverPort: site.listenPort,
|
||||||
|
remoteSubnets: site.remoteSubnets
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Failed to add/update peer ${client.clients.pubKey} to newt ${newt.newtId}: ${error}`
|
`Failed to add/update peer ${client.clients.pubKey} to olm ${newt.newtId}: ${error}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
publicKey: client.clients.pubKey!,
|
publicKey: client.clients.pubKey!,
|
||||||
allowedIps: [`${client.clients.subnet.split('/')[0]}/32`], // we want to only allow from that client
|
allowedIps: [`${client.clients.subnet.split("/")[0]}/32`], // we want to only allow from that client
|
||||||
endpoint: client.clientSites.isRelayed
|
endpoint: client.clientSites.isRelayed
|
||||||
? ""
|
? ""
|
||||||
: client.clients.endpoint! // if its relayed it should be localhost
|
: client.clients.endpoint! // if its relayed it should be localhost
|
||||||
|
@ -144,14 +220,96 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
||||||
// Filter out any null values from peers that didn't have an olm
|
// Filter out any null values from peers that didn't have an olm
|
||||||
const validPeers = peers.filter((peer) => peer !== null);
|
const validPeers = peers.filter((peer) => peer !== null);
|
||||||
|
|
||||||
|
// Improved version
|
||||||
|
const allResources = await db.transaction(async (tx) => {
|
||||||
|
// First get all resources for the site
|
||||||
|
const resourcesList = await tx
|
||||||
|
.select({
|
||||||
|
resourceId: resources.resourceId,
|
||||||
|
subdomain: resources.subdomain,
|
||||||
|
fullDomain: resources.fullDomain,
|
||||||
|
ssl: resources.ssl,
|
||||||
|
blockAccess: resources.blockAccess,
|
||||||
|
sso: resources.sso,
|
||||||
|
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
||||||
|
http: resources.http,
|
||||||
|
proxyPort: resources.proxyPort,
|
||||||
|
protocol: resources.protocol
|
||||||
|
})
|
||||||
|
.from(resources)
|
||||||
|
.where(and(eq(resources.siteId, siteId), eq(resources.http, false)));
|
||||||
|
|
||||||
|
// Get all enabled targets for these resources in a single query
|
||||||
|
const resourceIds = resourcesList.map((r) => r.resourceId);
|
||||||
|
const allTargets =
|
||||||
|
resourceIds.length > 0
|
||||||
|
? await tx
|
||||||
|
.select({
|
||||||
|
resourceId: targets.resourceId,
|
||||||
|
targetId: targets.targetId,
|
||||||
|
ip: targets.ip,
|
||||||
|
method: targets.method,
|
||||||
|
port: targets.port,
|
||||||
|
internalPort: targets.internalPort,
|
||||||
|
enabled: targets.enabled,
|
||||||
|
})
|
||||||
|
.from(targets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(targets.resourceId, resourceIds),
|
||||||
|
eq(targets.enabled, true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Combine the data in JS instead of using SQL for the JSON
|
||||||
|
return resourcesList.map((resource) => ({
|
||||||
|
...resource,
|
||||||
|
targets: allTargets.filter(
|
||||||
|
(target) => target.resourceId === resource.resourceId
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const { tcpTargets, udpTargets } = allResources.reduce(
|
||||||
|
(acc, resource) => {
|
||||||
|
// Skip resources with no targets
|
||||||
|
if (!resource.targets?.length) return acc;
|
||||||
|
|
||||||
|
// Format valid targets into strings
|
||||||
|
const formattedTargets = resource.targets
|
||||||
|
.filter(
|
||||||
|
(target: Target) =>
|
||||||
|
resource.proxyPort && target?.ip && target?.port
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
(target: Target) =>
|
||||||
|
`${resource.proxyPort}:${target.ip}:${target.port}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add to the appropriate protocol array
|
||||||
|
if (resource.protocol === "tcp") {
|
||||||
|
acc.tcpTargets.push(...formattedTargets);
|
||||||
|
} else {
|
||||||
|
acc.udpTargets.push(...formattedTargets);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ tcpTargets: [] as string[], udpTargets: [] as string[] }
|
||||||
|
);
|
||||||
|
|
||||||
// Build the configuration response
|
// Build the configuration response
|
||||||
const configResponse = {
|
const configResponse = {
|
||||||
ipAddress: site.address,
|
ipAddress: site.address,
|
||||||
peers: validPeers
|
peers: validPeers,
|
||||||
|
targets: {
|
||||||
|
udp: udpTargets,
|
||||||
|
tcp: tcpTargets
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.debug("Sending config: ", configResponse);
|
logger.debug("Sending config: ", configResponse);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
type: "newt/wg/receive-config",
|
type: "newt/wg/receive-config",
|
||||||
|
|
|
@ -4,7 +4,8 @@ import { sendToClient } from "../ws";
|
||||||
export function addTargets(
|
export function addTargets(
|
||||||
newtId: string,
|
newtId: string,
|
||||||
targets: Target[],
|
targets: Target[],
|
||||||
protocol: string
|
protocol: string,
|
||||||
|
port: number | null = null
|
||||||
) {
|
) {
|
||||||
//create a list of udp and tcp targets
|
//create a list of udp and tcp targets
|
||||||
const payloadTargets = targets.map((target) => {
|
const payloadTargets = targets.map((target) => {
|
||||||
|
@ -13,19 +14,32 @@ export function addTargets(
|
||||||
}:${target.port}`;
|
}:${target.port}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = {
|
sendToClient(newtId, {
|
||||||
type: `newt/${protocol}/add`,
|
type: `newt/${protocol}/add`,
|
||||||
data: {
|
data: {
|
||||||
targets: payloadTargets
|
targets: payloadTargets
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
sendToClient(newtId, payload);
|
|
||||||
|
const payloadTargetsResources = targets.map((target) => {
|
||||||
|
return `${port ? port + ":" : ""}${
|
||||||
|
target.ip
|
||||||
|
}:${target.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
sendToClient(newtId, {
|
||||||
|
type: `newt/wg/${protocol}/add`,
|
||||||
|
data: {
|
||||||
|
targets: [payloadTargetsResources[0]] // We can only use one target for WireGuard right now
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeTargets(
|
export function removeTargets(
|
||||||
newtId: string,
|
newtId: string,
|
||||||
targets: Target[],
|
targets: Target[],
|
||||||
protocol: string
|
protocol: string,
|
||||||
|
port: number | null = null
|
||||||
) {
|
) {
|
||||||
//create a list of udp and tcp targets
|
//create a list of udp and tcp targets
|
||||||
const payloadTargets = targets.map((target) => {
|
const payloadTargets = targets.map((target) => {
|
||||||
|
@ -34,11 +48,23 @@ export function removeTargets(
|
||||||
}:${target.port}`;
|
}:${target.port}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = {
|
sendToClient(newtId, {
|
||||||
type: `newt/${protocol}/remove`,
|
type: `newt/${protocol}/remove`,
|
||||||
data: {
|
data: {
|
||||||
targets: payloadTargets
|
targets: payloadTargets
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
sendToClient(newtId, payload);
|
|
||||||
|
const payloadTargetsResources = targets.map((target) => {
|
||||||
|
return `${port ? port + ":" : ""}${
|
||||||
|
target.ip
|
||||||
|
}:${target.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
sendToClient(newtId, {
|
||||||
|
type: `newt/wg/${protocol}/remove`,
|
||||||
|
data: {
|
||||||
|
targets: [payloadTargetsResources[0]] // We can only use one target for WireGuard right now
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { db } from "@server/db";
|
import { db, ExitNode } from "@server/db";
|
||||||
import { MessageHandler } from "../ws";
|
import { MessageHandler } from "../ws";
|
||||||
import {
|
import {
|
||||||
clients,
|
clients,
|
||||||
|
@ -28,7 +28,10 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const clientId = olm.clientId;
|
const clientId = olm.clientId;
|
||||||
const { publicKey } = message.data;
|
const { publicKey, relay } = message.data;
|
||||||
|
|
||||||
|
logger.debug(`Olm client ID: ${clientId}, Public Key: ${publicKey}, Relay: ${relay}`);
|
||||||
|
|
||||||
if (!publicKey) {
|
if (!publicKey) {
|
||||||
logger.warn("Public key not provided");
|
logger.warn("Public key not provided");
|
||||||
return;
|
return;
|
||||||
|
@ -58,9 +61,11 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||||
sendToClient(olm.olmId, {
|
sendToClient(olm.olmId, {
|
||||||
type: "olm/wg/holepunch",
|
type: "olm/wg/holepunch",
|
||||||
data: {
|
data: {
|
||||||
serverPubKey: exitNode.publicKey
|
serverPubKey: exitNode.publicKey,
|
||||||
|
endpoint: exitNode.endpoint,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (now - (client.lastHolePunch || 0) > 6) {
|
if (now - (client.lastHolePunch || 0) > 6) {
|
||||||
|
@ -84,7 +89,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||||
await db
|
await db
|
||||||
.update(clientSites)
|
.update(clientSites)
|
||||||
.set({
|
.set({
|
||||||
isRelayed: false
|
isRelayed: relay == true
|
||||||
})
|
})
|
||||||
.where(eq(clientSites.clientId, olm.clientId));
|
.where(eq(clientSites.clientId, olm.clientId));
|
||||||
}
|
}
|
||||||
|
@ -97,7 +102,15 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||||
.where(eq(clientSites.clientId, client.clientId));
|
.where(eq(clientSites.clientId, client.clientId));
|
||||||
|
|
||||||
// Prepare an array to store site configurations
|
// Prepare an array to store site configurations
|
||||||
const siteConfigurations = [];
|
let siteConfigurations = [];
|
||||||
|
logger.debug(`Found ${sitesData.length} sites for client ${client.clientId}`);
|
||||||
|
|
||||||
|
if (sitesData.length === 0) {
|
||||||
|
sendToClient(olm.olmId, {
|
||||||
|
type: "olm/register/no-sites",
|
||||||
|
data: {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Process each site
|
// Process each site
|
||||||
for (const { sites: site } of sitesData) {
|
for (const { sites: site } of sitesData) {
|
||||||
|
@ -114,12 +127,12 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (site.lastHolePunch && now - site.lastHolePunch > 6) {
|
// if (site.lastHolePunch && now - site.lastHolePunch > 6 && relay) {
|
||||||
logger.warn(
|
// logger.warn(
|
||||||
`Site ${site.siteId} last hole punch is too old, skipping`
|
// `Site ${site.siteId} last hole punch is too old, skipping`
|
||||||
);
|
// );
|
||||||
continue;
|
// continue;
|
||||||
}
|
// }
|
||||||
|
|
||||||
// If public key changed, delete old peer from this site
|
// If public key changed, delete old peer from this site
|
||||||
if (client.pubKey && client.pubKey != publicKey) {
|
if (client.pubKey && client.pubKey != publicKey) {
|
||||||
|
@ -142,7 +155,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||||
await addPeer(site.siteId, {
|
await addPeer(site.siteId, {
|
||||||
publicKey: publicKey,
|
publicKey: publicKey,
|
||||||
allowedIps: [`${client.subnet.split('/')[0]}/32`], // we want to only allow from that client
|
allowedIps: [`${client.subnet.split('/')[0]}/32`], // we want to only allow from that client
|
||||||
endpoint: client.endpoint
|
endpoint: relay ? "" : client.endpoint
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
@ -150,21 +163,36 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let endpoint = site.endpoint;
|
||||||
|
if (relay) {
|
||||||
|
const [exitNode] = await db
|
||||||
|
.select()
|
||||||
|
.from(exitNodes)
|
||||||
|
.where(eq(exitNodes.exitNodeId, site.exitNodeId))
|
||||||
|
.limit(1);
|
||||||
|
if (!exitNode) {
|
||||||
|
logger.warn(`Exit node not found for site ${site.siteId}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
endpoint = `${exitNode.endpoint}:21820`;
|
||||||
|
}
|
||||||
|
|
||||||
// Add site configuration to the array
|
// Add site configuration to the array
|
||||||
siteConfigurations.push({
|
siteConfigurations.push({
|
||||||
siteId: site.siteId,
|
siteId: site.siteId,
|
||||||
endpoint: site.endpoint,
|
endpoint: endpoint,
|
||||||
publicKey: site.publicKey,
|
publicKey: site.publicKey,
|
||||||
serverIP: site.address,
|
serverIP: site.address,
|
||||||
serverPort: site.listenPort
|
serverPort: site.listenPort,
|
||||||
|
remoteSubnets: site.remoteSubnets
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have no valid site configurations, don't send a connect message
|
// REMOVED THIS SO IT CREATES THE INTERFACE AND JUST WAITS FOR THE SITES
|
||||||
if (siteConfigurations.length === 0) {
|
// if (siteConfigurations.length === 0) {
|
||||||
logger.warn("No valid site configurations found");
|
// logger.warn("No valid site configurations found");
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Return connect message with all site configurations
|
// Return connect message with all site configurations
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { db } from "@server/db";
|
import { db, exitNodes, sites } from "@server/db";
|
||||||
import { MessageHandler } from "../ws";
|
import { MessageHandler } from "../ws";
|
||||||
import { clients, clientSites, Olm } from "@server/db";
|
import { clients, clientSites, Olm } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { updatePeer } from "../newt/peers";
|
import { updatePeer } from "../newt/peers";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
@ -30,29 +30,67 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => {
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
logger.warn("Site not found or does not have exit node");
|
logger.warn("Client not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// make sure we hand endpoints for both the site and the client and the lastHolePunch is not too old
|
// make sure we hand endpoints for both the site and the client and the lastHolePunch is not too old
|
||||||
if (!client.pubKey) {
|
if (!client.pubKey) {
|
||||||
logger.warn("Site or client has no endpoint or listen port");
|
logger.warn("Client has no endpoint or listen port");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { siteId } = message.data;
|
const { siteId } = message.data;
|
||||||
|
|
||||||
|
// Get the site
|
||||||
|
const [site] = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.siteId, siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!site || !site.exitNodeId) {
|
||||||
|
logger.warn("Site not found or has no exit node");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the site's exit node
|
||||||
|
const [exitNode] = await db
|
||||||
|
.select()
|
||||||
|
.from(exitNodes)
|
||||||
|
.where(eq(exitNodes.exitNodeId, site.exitNodeId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!exitNode) {
|
||||||
|
logger.warn("Exit node not found for site");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(clientSites)
|
.update(clientSites)
|
||||||
.set({
|
.set({
|
||||||
isRelayed: true
|
isRelayed: true
|
||||||
})
|
})
|
||||||
.where(eq(clientSites.clientId, olm.clientId));
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clientSites.clientId, olm.clientId),
|
||||||
|
eq(clientSites.siteId, siteId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// update the peer on the exit node
|
// update the peer on the exit node
|
||||||
await updatePeer(siteId, client.pubKey, {
|
await updatePeer(siteId, client.pubKey, {
|
||||||
endpoint: "" // this removes the endpoint
|
endpoint: "" // this removes the endpoint
|
||||||
});
|
});
|
||||||
|
|
||||||
|
sendToClient(olm.olmId, {
|
||||||
|
type: "olm/wg/peer/relay",
|
||||||
|
data: {
|
||||||
|
siteId: siteId,
|
||||||
|
endpoint: exitNode.endpoint,
|
||||||
|
publicKey: exitNode.publicKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
|
@ -12,6 +12,7 @@ export async function addPeer(
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
serverIP: string | null;
|
serverIP: string | null;
|
||||||
serverPort: number | null;
|
serverPort: number | null;
|
||||||
|
remoteSubnets: string | null; // optional, comma-separated list of subnets that this site can access
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const [olm] = await db
|
const [olm] = await db
|
||||||
|
@ -30,7 +31,8 @@ export async function addPeer(
|
||||||
publicKey: peer.publicKey,
|
publicKey: peer.publicKey,
|
||||||
endpoint: peer.endpoint,
|
endpoint: peer.endpoint,
|
||||||
serverIP: peer.serverIP,
|
serverIP: peer.serverIP,
|
||||||
serverPort: peer.serverPort
|
serverPort: peer.serverPort,
|
||||||
|
remoteSubnets: peer.remoteSubnets // optional, comma-separated list of subnets that this site can access
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -66,6 +68,7 @@ export async function updatePeer(
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
serverIP: string | null;
|
serverIP: string | null;
|
||||||
serverPort: number | null;
|
serverPort: number | null;
|
||||||
|
remoteSubnets?: string | null; // optional, comma-separated list of subnets that
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const [olm] = await db
|
const [olm] = await db
|
||||||
|
@ -84,7 +87,8 @@ export async function updatePeer(
|
||||||
publicKey: peer.publicKey,
|
publicKey: peer.publicKey,
|
||||||
endpoint: peer.endpoint,
|
endpoint: peer.endpoint,
|
||||||
serverIP: peer.serverIP,
|
serverIP: peer.serverIP,
|
||||||
serverPort: peer.serverPort
|
serverPort: peer.serverPort,
|
||||||
|
remoteSubnets: peer.remoteSubnets
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,8 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db, domains, orgDomains, resources } from "@server/db";
|
||||||
import {
|
import { newts, newtSessions, orgs, sites, userActions } from "@server/db";
|
||||||
newts,
|
import { eq, and, inArray, sql } from "drizzle-orm";
|
||||||
newtSessions,
|
|
||||||
orgs,
|
|
||||||
sites,
|
|
||||||
userActions
|
|
||||||
} from "@server/db";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
|
@ -126,6 +120,44 @@ export async function deleteOrg(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allOrgDomains = await trx
|
||||||
|
.select()
|
||||||
|
.from(orgDomains)
|
||||||
|
.innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(orgDomains.orgId, orgId),
|
||||||
|
eq(domains.configManaged, false)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// For each domain, check if it belongs to multiple organizations
|
||||||
|
const domainIdsToDelete: string[] = [];
|
||||||
|
for (const orgDomain of allOrgDomains) {
|
||||||
|
const domainId = orgDomain.domains.domainId;
|
||||||
|
|
||||||
|
// Count how many organizations this domain belongs to
|
||||||
|
const orgCount = await trx
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(orgDomains)
|
||||||
|
.where(eq(orgDomains.domainId, domainId));
|
||||||
|
|
||||||
|
// Only delete the domain if it belongs to exactly 1 organization (the one being deleted)
|
||||||
|
if (orgCount[0].count === 1) {
|
||||||
|
domainIdsToDelete.push(domainId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete domains that belong exclusively to this organization
|
||||||
|
if (domainIdsToDelete.length > 0) {
|
||||||
|
await trx
|
||||||
|
.delete(domains)
|
||||||
|
.where(inArray(domains.domainId, domainIdsToDelete));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete resources
|
||||||
|
await trx.delete(resources).where(eq(resources.orgId, orgId));
|
||||||
|
|
||||||
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
|
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -136,8 +168,11 @@ export async function deleteOrg(
|
||||||
data: {}
|
data: {}
|
||||||
};
|
};
|
||||||
// Don't await this to prevent blocking the response
|
// Don't await this to prevent blocking the response
|
||||||
sendToClient(newtId, payload).catch(error => {
|
sendToClient(newtId, payload).catch((error) => {
|
||||||
logger.error("Failed to send termination message to newt:", error);
|
logger.error(
|
||||||
|
"Failed to send termination message to newt:",
|
||||||
|
error
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -33,10 +33,7 @@ const createResourceParamsSchema = z
|
||||||
const createHttpResourceSchema = z
|
const createHttpResourceSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
subdomain: z
|
subdomain: z.string().nullable().optional(),
|
||||||
.string()
|
|
||||||
.nullable()
|
|
||||||
.optional(),
|
|
||||||
siteId: z.number(),
|
siteId: z.number(),
|
||||||
http: z.boolean(),
|
http: z.boolean(),
|
||||||
protocol: z.enum(["tcp", "udp"]),
|
protocol: z.enum(["tcp", "udp"]),
|
||||||
|
@ -59,7 +56,8 @@ const createRawResourceSchema = z
|
||||||
siteId: z.number(),
|
siteId: z.number(),
|
||||||
http: z.boolean(),
|
http: z.boolean(),
|
||||||
protocol: z.enum(["tcp", "udp"]),
|
protocol: z.enum(["tcp", "udp"]),
|
||||||
proxyPort: z.number().int().min(1).max(65535)
|
proxyPort: z.number().int().min(1).max(65535),
|
||||||
|
enableProxy: z.boolean().default(true)
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine(
|
.refine(
|
||||||
|
@ -88,12 +86,7 @@ registry.registerPath({
|
||||||
body: {
|
body: {
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema:
|
schema: createHttpResourceSchema.or(createRawResourceSchema)
|
||||||
build == "oss"
|
|
||||||
? createHttpResourceSchema.or(
|
|
||||||
createRawResourceSchema
|
|
||||||
)
|
|
||||||
: createHttpResourceSchema
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -156,7 +149,10 @@ export async function createResource(
|
||||||
{ siteId, orgId }
|
{ siteId, orgId }
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
if (!config.getRawConfig().flags?.allow_raw_resources && build == "oss") {
|
if (
|
||||||
|
!config.getRawConfig().flags?.allow_raw_resources &&
|
||||||
|
build == "oss"
|
||||||
|
) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
@ -378,7 +374,7 @@ async function createRawResource(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, http, protocol, proxyPort } = parsedBody.data;
|
const { name, http, protocol, proxyPort, enableProxy } = parsedBody.data;
|
||||||
|
|
||||||
// if http is false check to see if there is already a resource with the same port and protocol
|
// if http is false check to see if there is already a resource with the same port and protocol
|
||||||
const existingResource = await db
|
const existingResource = await db
|
||||||
|
@ -411,7 +407,8 @@ async function createRawResource(
|
||||||
name,
|
name,
|
||||||
http,
|
http,
|
||||||
protocol,
|
protocol,
|
||||||
proxyPort
|
proxyPort,
|
||||||
|
enableProxy
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
|
@ -103,7 +103,8 @@ export async function deleteResource(
|
||||||
removeTargets(
|
removeTargets(
|
||||||
newt.newtId,
|
newt.newtId,
|
||||||
targetsToBeRemoved,
|
targetsToBeRemoved,
|
||||||
deletedResource.protocol
|
deletedResource.protocol,
|
||||||
|
deletedResource.proxyPort
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
168
server/routers/resource/getUserResources.ts
Normal file
168
server/routers/resource/getUserResources.ts
Normal file
|
@ -0,0 +1,168 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { and, eq, or, inArray } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
resources,
|
||||||
|
userResources,
|
||||||
|
roleResources,
|
||||||
|
userOrgs,
|
||||||
|
roles,
|
||||||
|
resourcePassword,
|
||||||
|
resourcePincode,
|
||||||
|
resourceWhitelist,
|
||||||
|
sites
|
||||||
|
} from "@server/db";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { response } from "@server/lib/response";
|
||||||
|
|
||||||
|
export async function getUserResources(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const { orgId } = req.params;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First get the user's role in the organization
|
||||||
|
const userOrgResult = await db
|
||||||
|
.select({
|
||||||
|
roleId: userOrgs.roleId
|
||||||
|
})
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.userId, userId),
|
||||||
|
eq(userOrgs.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (userOrgResult.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.FORBIDDEN, "User not in organization")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRoleId = userOrgResult[0].roleId;
|
||||||
|
|
||||||
|
// Get resources accessible through direct assignment or role assignment
|
||||||
|
const directResourcesQuery = db
|
||||||
|
.select({ resourceId: userResources.resourceId })
|
||||||
|
.from(userResources)
|
||||||
|
.where(eq(userResources.userId, userId));
|
||||||
|
|
||||||
|
const roleResourcesQuery = db
|
||||||
|
.select({ resourceId: roleResources.resourceId })
|
||||||
|
.from(roleResources)
|
||||||
|
.where(eq(roleResources.roleId, userRoleId));
|
||||||
|
|
||||||
|
const [directResources, roleResourceResults] = await Promise.all([
|
||||||
|
directResourcesQuery,
|
||||||
|
roleResourcesQuery
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Combine all accessible resource IDs
|
||||||
|
const accessibleResourceIds = [
|
||||||
|
...directResources.map(r => r.resourceId),
|
||||||
|
...roleResourceResults.map(r => r.resourceId)
|
||||||
|
];
|
||||||
|
|
||||||
|
if (accessibleResourceIds.length === 0) {
|
||||||
|
return response(res, {
|
||||||
|
data: { resources: [] },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "No resources found",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get resource details for accessible resources
|
||||||
|
const resourcesData = await db
|
||||||
|
.select({
|
||||||
|
resourceId: resources.resourceId,
|
||||||
|
name: resources.name,
|
||||||
|
fullDomain: resources.fullDomain,
|
||||||
|
ssl: resources.ssl,
|
||||||
|
enabled: resources.enabled,
|
||||||
|
sso: resources.sso,
|
||||||
|
protocol: resources.protocol,
|
||||||
|
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
||||||
|
siteName: sites.name
|
||||||
|
})
|
||||||
|
.from(resources)
|
||||||
|
.leftJoin(sites, eq(sites.siteId, resources.siteId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(resources.resourceId, accessibleResourceIds),
|
||||||
|
eq(resources.orgId, orgId),
|
||||||
|
eq(resources.enabled, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for password, pincode, and whitelist protection for each resource
|
||||||
|
const resourcesWithAuth = await Promise.all(
|
||||||
|
resourcesData.map(async (resource) => {
|
||||||
|
const [passwordCheck, pincodeCheck, whitelistCheck] = await Promise.all([
|
||||||
|
db.select().from(resourcePassword).where(eq(resourcePassword.resourceId, resource.resourceId)).limit(1),
|
||||||
|
db.select().from(resourcePincode).where(eq(resourcePincode.resourceId, resource.resourceId)).limit(1),
|
||||||
|
db.select().from(resourceWhitelist).where(eq(resourceWhitelist.resourceId, resource.resourceId)).limit(1)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hasPassword = passwordCheck.length > 0;
|
||||||
|
const hasPincode = pincodeCheck.length > 0;
|
||||||
|
const hasWhitelist = whitelistCheck.length > 0 || resource.emailWhitelistEnabled;
|
||||||
|
|
||||||
|
return {
|
||||||
|
resourceId: resource.resourceId,
|
||||||
|
name: resource.name,
|
||||||
|
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
|
||||||
|
enabled: resource.enabled,
|
||||||
|
protected: !!(resource.sso || hasPassword || hasPincode || hasWhitelist),
|
||||||
|
protocol: resource.protocol,
|
||||||
|
sso: resource.sso,
|
||||||
|
password: hasPassword,
|
||||||
|
pincode: hasPincode,
|
||||||
|
whitelist: hasWhitelist,
|
||||||
|
siteName: resource.siteName
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: { resources: resourcesWithAuth },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "User resources retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching user resources:", error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Internal server error")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetUserResourcesResponse = {
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
resources: Array<{
|
||||||
|
resourceId: number;
|
||||||
|
name: string;
|
||||||
|
domain: string;
|
||||||
|
enabled: boolean;
|
||||||
|
protected: boolean;
|
||||||
|
protocol: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
|
@ -22,3 +22,4 @@ export * from "./createResourceRule";
|
||||||
export * from "./deleteResourceRule";
|
export * from "./deleteResourceRule";
|
||||||
export * from "./listResourceRules";
|
export * from "./listResourceRules";
|
||||||
export * from "./updateResourceRule";
|
export * from "./updateResourceRule";
|
||||||
|
export * from "./getUserResources";
|
|
@ -168,7 +168,8 @@ export async function transferResource(
|
||||||
removeTargets(
|
removeTargets(
|
||||||
newt.newtId,
|
newt.newtId,
|
||||||
resourceTargets,
|
resourceTargets,
|
||||||
updatedResource.protocol
|
updatedResource.protocol,
|
||||||
|
updatedResource.proxyPort
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -190,7 +191,8 @@ export async function transferResource(
|
||||||
addTargets(
|
addTargets(
|
||||||
newt.newtId,
|
newt.newtId,
|
||||||
resourceTargets,
|
resourceTargets,
|
||||||
updatedResource.protocol
|
updatedResource.protocol,
|
||||||
|
updatedResource.proxyPort
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,9 +34,7 @@ const updateResourceParamsSchema = z
|
||||||
const updateHttpResourceBodySchema = z
|
const updateHttpResourceBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
subdomain: subdomainSchema
|
subdomain: subdomainSchema.nullable().optional(),
|
||||||
.nullable()
|
|
||||||
.optional(),
|
|
||||||
ssl: z.boolean().optional(),
|
ssl: z.boolean().optional(),
|
||||||
sso: z.boolean().optional(),
|
sso: z.boolean().optional(),
|
||||||
blockAccess: z.boolean().optional(),
|
blockAccess: z.boolean().optional(),
|
||||||
|
@ -93,7 +91,8 @@ const updateRawResourceBodySchema = z
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
proxyPort: z.number().int().min(1).max(65535).optional(),
|
proxyPort: z.number().int().min(1).max(65535).optional(),
|
||||||
stickySession: z.boolean().optional(),
|
stickySession: z.boolean().optional(),
|
||||||
enabled: z.boolean().optional()
|
enabled: z.boolean().optional(),
|
||||||
|
enableProxy: z.boolean().optional()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
|
@ -121,12 +120,9 @@ registry.registerPath({
|
||||||
body: {
|
body: {
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema:
|
schema: updateHttpResourceBodySchema.and(
|
||||||
build == "oss"
|
|
||||||
? updateHttpResourceBodySchema.and(
|
|
||||||
updateRawResourceBodySchema
|
updateRawResourceBodySchema
|
||||||
)
|
)
|
||||||
: updateHttpResourceBodySchema
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -288,7 +284,9 @@ async function updateHttpResource(
|
||||||
} else if (domainRes.domains.type == "wildcard") {
|
} else if (domainRes.domains.type == "wildcard") {
|
||||||
if (updateData.subdomain !== undefined) {
|
if (updateData.subdomain !== undefined) {
|
||||||
// the subdomain cant have a dot in it
|
// the subdomain cant have a dot in it
|
||||||
const parsedSubdomain = subdomainSchema.safeParse(updateData.subdomain);
|
const parsedSubdomain = subdomainSchema.safeParse(
|
||||||
|
updateData.subdomain
|
||||||
|
);
|
||||||
if (!parsedSubdomain.success) {
|
if (!parsedSubdomain.success) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
@ -341,7 +339,7 @@ async function updateHttpResource(
|
||||||
|
|
||||||
const updatedResource = await db
|
const updatedResource = await db
|
||||||
.update(resources)
|
.update(resources)
|
||||||
.set({...updateData, })
|
.set({ ...updateData })
|
||||||
.where(eq(resources.resourceId, resource.resourceId))
|
.where(eq(resources.resourceId, resource.resourceId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { isValidCIDR } from "@server/lib/validators";
|
||||||
|
|
||||||
const updateSiteParamsSchema = z
|
const updateSiteParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -20,6 +21,9 @@ const updateSiteBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
dockerSocketEnabled: z.boolean().optional(),
|
dockerSocketEnabled: z.boolean().optional(),
|
||||||
|
remoteSubnets: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
// subdomain: z
|
// subdomain: z
|
||||||
// .string()
|
// .string()
|
||||||
// .min(1)
|
// .min(1)
|
||||||
|
@ -85,6 +89,21 @@ export async function updateSite(
|
||||||
const { siteId } = parsedParams.data;
|
const { siteId } = parsedParams.data;
|
||||||
const updateData = parsedBody.data;
|
const updateData = parsedBody.data;
|
||||||
|
|
||||||
|
// if remoteSubnets is provided, ensure it's a valid comma-separated list of cidrs
|
||||||
|
if (updateData.remoteSubnets) {
|
||||||
|
const subnets = updateData.remoteSubnets.split(",").map((s) => s.trim());
|
||||||
|
for (const subnet of subnets) {
|
||||||
|
if (!isValidCIDR(subnet)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
`Invalid CIDR format: ${subnet}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updatedSite = await db
|
const updatedSite = await db
|
||||||
.update(sites)
|
.update(sites)
|
||||||
.set(updateData)
|
.set(updateData)
|
||||||
|
|
|
@ -173,7 +173,7 @@ export async function createTarget(
|
||||||
.where(eq(newts.siteId, site.siteId))
|
.where(eq(newts.siteId, site.siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
addTargets(newt.newtId, newTarget, resource.protocol);
|
addTargets(newt.newtId, newTarget, resource.protocol, resource.proxyPort);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -105,7 +105,7 @@ export async function deleteTarget(
|
||||||
.where(eq(newts.siteId, site.siteId))
|
.where(eq(newts.siteId, site.siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
removeTargets(newt.newtId, [deletedTarget], resource.protocol);
|
removeTargets(newt.newtId, [deletedTarget], resource.protocol, resource.proxyPort);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -157,7 +157,7 @@ export async function updateTarget(
|
||||||
.where(eq(newts.siteId, site.siteId))
|
.where(eq(newts.siteId, site.siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
addTargets(newt.newtId, [updatedTarget], resource.protocol);
|
addTargets(newt.newtId, [updatedTarget], resource.protocol, resource.proxyPort);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|
|
@ -66,7 +66,8 @@ export async function traefikConfigProvider(
|
||||||
enabled: resources.enabled,
|
enabled: resources.enabled,
|
||||||
stickySession: resources.stickySession,
|
stickySession: resources.stickySession,
|
||||||
tlsServerName: resources.tlsServerName,
|
tlsServerName: resources.tlsServerName,
|
||||||
setHostHeader: resources.setHostHeader
|
setHostHeader: resources.setHostHeader,
|
||||||
|
enableProxy: resources.enableProxy
|
||||||
})
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.innerJoin(sites, eq(sites.siteId, resources.siteId))
|
.innerJoin(sites, eq(sites.siteId, resources.siteId))
|
||||||
|
@ -365,6 +366,10 @@ export async function traefikConfigProvider(
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Non-HTTP (TCP/UDP) configuration
|
// Non-HTTP (TCP/UDP) configuration
|
||||||
|
if (!resource.enableProxy) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const protocol = resource.protocol.toLowerCase();
|
const protocol = resource.protocol.toLowerCase();
|
||||||
const port = resource.proxyPort;
|
const port = resource.proxyPort;
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
#! /usr/bin/env node
|
||||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||||
import { db } from "../db/pg";
|
import { db } from "../db/pg";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
|
@ -6,6 +7,7 @@ import { __DIRNAME, APP_VERSION } from "@server/lib/consts";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import m1 from "./scriptsPg/1.6.0";
|
import m1 from "./scriptsPg/1.6.0";
|
||||||
import m2 from "./scriptsPg/1.7.0";
|
import m2 from "./scriptsPg/1.7.0";
|
||||||
|
import m3 from "./scriptsPg/1.8.0";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
|
@ -13,7 +15,8 @@ import m2 from "./scriptsPg/1.7.0";
|
||||||
// Define the migration list with versions and their corresponding functions
|
// Define the migration list with versions and their corresponding functions
|
||||||
const migrations = [
|
const migrations = [
|
||||||
{ version: "1.6.0", run: m1 },
|
{ version: "1.6.0", run: m1 },
|
||||||
{ version: "1.7.0", run: m2 }
|
{ version: "1.7.0", run: m2 },
|
||||||
|
{ version: "1.8.0", run: m3 }
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as {
|
] as {
|
||||||
version: string;
|
version: string;
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
#! /usr/bin/env node
|
||||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
import { db, exists } from "../db/sqlite";
|
import { db, exists } from "../db/sqlite";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
@ -23,6 +24,7 @@ import m19 from "./scriptsSqlite/1.3.0";
|
||||||
import m20 from "./scriptsSqlite/1.5.0";
|
import m20 from "./scriptsSqlite/1.5.0";
|
||||||
import m21 from "./scriptsSqlite/1.6.0";
|
import m21 from "./scriptsSqlite/1.6.0";
|
||||||
import m22 from "./scriptsSqlite/1.7.0";
|
import m22 from "./scriptsSqlite/1.7.0";
|
||||||
|
import m23 from "./scriptsSqlite/1.8.0";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
|
@ -46,6 +48,7 @@ const migrations = [
|
||||||
{ version: "1.5.0", run: m20 },
|
{ version: "1.5.0", run: m20 },
|
||||||
{ version: "1.6.0", run: m21 },
|
{ version: "1.6.0", run: m21 },
|
||||||
{ version: "1.7.0", run: m22 },
|
{ version: "1.7.0", run: m22 },
|
||||||
|
{ version: "1.8.0", run: m23 },
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
32
server/setup/scriptsPg/1.8.0.ts
Normal file
32
server/setup/scriptsPg/1.8.0.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { db } from "@server/db/pg/driver";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
const version = "1.8.0";
|
||||||
|
|
||||||
|
export default async function migration() {
|
||||||
|
console.log(`Running setup script ${version}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.execute(sql`
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE "clients" ALTER COLUMN "bytesIn" SET DATA TYPE real;
|
||||||
|
ALTER TABLE "clients" ALTER COLUMN "bytesOut" SET DATA TYPE real;
|
||||||
|
ALTER TABLE "clientSession" ALTER COLUMN "expiresAt" SET DATA TYPE bigint;
|
||||||
|
ALTER TABLE "resources" ADD COLUMN "enableProxy" boolean DEFAULT true;
|
||||||
|
ALTER TABLE "sites" ADD COLUMN "remoteSubnets" text;
|
||||||
|
ALTER TABLE "user" ADD COLUMN "termsAcceptedTimestamp" varchar;
|
||||||
|
ALTER TABLE "user" ADD COLUMN "termsVersion" varchar;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log(`Migrated database schema`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Unable to migrate database schema");
|
||||||
|
console.log(e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${version} migration complete`);
|
||||||
|
}
|
30
server/setup/scriptsSqlite/1.8.0.ts
Normal file
30
server/setup/scriptsSqlite/1.8.0.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { APP_PATH } from "@server/lib/consts";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const version = "1.8.0";
|
||||||
|
|
||||||
|
export default async function migration() {
|
||||||
|
console.log(`Running setup script ${version}...`);
|
||||||
|
|
||||||
|
const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||||
|
const db = new Database(location);
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.transaction(() => {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE 'resources' ADD 'enableProxy' integer DEFAULT 1;
|
||||||
|
ALTER TABLE 'sites' ADD 'remoteSubnets' text;
|
||||||
|
ALTER TABLE 'user' ADD 'termsAcceptedTimestamp' text;
|
||||||
|
ALTER TABLE 'user' ADD 'termsVersion' text;
|
||||||
|
`);
|
||||||
|
})();
|
||||||
|
|
||||||
|
console.log("Migrated database schema");
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Unable to migrate database schema");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${version} migration complete`);
|
||||||
|
}
|
718
src/app/[orgId]/MemberResourcesPortal.tsx
Normal file
718
src/app/[orgId]/MemberResourcesPortal.tsx
Normal file
|
@ -0,0 +1,718 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
ExternalLink,
|
||||||
|
Globe,
|
||||||
|
Search,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Key,
|
||||||
|
KeyRound,
|
||||||
|
Fingerprint,
|
||||||
|
AtSign,
|
||||||
|
Copy,
|
||||||
|
InfoIcon,
|
||||||
|
Combine
|
||||||
|
} from "lucide-react";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { GetUserResourcesResponse } from "@server/routers/resource/getUserResources";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { useToast } from "@app/hooks/useToast";
|
||||||
|
import { InfoPopup } from "@/components/ui/info-popup";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
// Update Resource type to include site information
|
||||||
|
type Resource = {
|
||||||
|
resourceId: number;
|
||||||
|
name: string;
|
||||||
|
domain: string;
|
||||||
|
enabled: boolean;
|
||||||
|
protected: boolean;
|
||||||
|
protocol: string;
|
||||||
|
// Auth method fields
|
||||||
|
sso?: boolean;
|
||||||
|
password?: boolean;
|
||||||
|
pincode?: boolean;
|
||||||
|
whitelist?: boolean;
|
||||||
|
// Site information
|
||||||
|
siteName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MemberResourcesPortalProps = {
|
||||||
|
orgId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Favicon component with fallback
|
||||||
|
const ResourceFavicon = ({
|
||||||
|
domain,
|
||||||
|
enabled
|
||||||
|
}: {
|
||||||
|
domain: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}) => {
|
||||||
|
const [faviconError, setFaviconError] = useState(false);
|
||||||
|
const [faviconLoaded, setFaviconLoaded] = useState(false);
|
||||||
|
|
||||||
|
// Extract domain for favicon URL
|
||||||
|
const cleanDomain = domain.replace(/^https?:\/\//, "").split("/")[0];
|
||||||
|
const faviconUrl = `https://www.google.com/s2/favicons?domain=${cleanDomain}&sz=32`;
|
||||||
|
|
||||||
|
const handleFaviconLoad = () => {
|
||||||
|
setFaviconLoaded(true);
|
||||||
|
setFaviconError(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFaviconError = () => {
|
||||||
|
setFaviconError(true);
|
||||||
|
setFaviconLoaded(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (faviconError || !enabled) {
|
||||||
|
return (
|
||||||
|
<Globe className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-4 w-4 flex-shrink-0">
|
||||||
|
{!faviconLoaded && (
|
||||||
|
<div className="absolute inset-0 bg-muted animate-pulse rounded-sm"></div>
|
||||||
|
)}
|
||||||
|
<img
|
||||||
|
src={faviconUrl}
|
||||||
|
alt={`${cleanDomain} favicon`}
|
||||||
|
className={`h-4 w-4 rounded-sm transition-opacity ${faviconLoaded ? "opacity-100" : "opacity-0"}`}
|
||||||
|
onLoad={handleFaviconLoad}
|
||||||
|
onError={handleFaviconError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resource Info component
|
||||||
|
const ResourceInfo = ({ resource }: { resource: Resource }) => {
|
||||||
|
const hasAuthMethods =
|
||||||
|
resource.sso ||
|
||||||
|
resource.password ||
|
||||||
|
resource.pincode ||
|
||||||
|
resource.whitelist;
|
||||||
|
|
||||||
|
const infoContent = (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* Site Information */}
|
||||||
|
{resource.siteName && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium mb-1.5">Site</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Combine className="h-4 w-4 text-foreground shrink-0" />
|
||||||
|
<span className="text-sm">{resource.siteName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Authentication Methods */}
|
||||||
|
{hasAuthMethods && (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
resource.siteName ? "border-t border-border pt-2" : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium mb-1.5">
|
||||||
|
Authentication Methods
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{resource.sso && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-blue-50/50 dark:bg-blue-950/50">
|
||||||
|
<Key className="h-3 w-3 text-blue-700 dark:text-blue-300" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm">
|
||||||
|
Single Sign-On (SSO)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{resource.password && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-purple-50/50 dark:bg-purple-950/50">
|
||||||
|
<KeyRound className="h-3 w-3 text-purple-700 dark:text-purple-300" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm">
|
||||||
|
Password Protected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{resource.pincode && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-emerald-50/50 dark:bg-emerald-950/50">
|
||||||
|
<Fingerprint className="h-3 w-3 text-emerald-700 dark:text-emerald-300" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm">PIN Code</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{resource.whitelist && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-amber-50/50 dark:bg-amber-950/50">
|
||||||
|
<AtSign className="h-3 w-3 text-amber-700 dark:text-amber-300" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm">Email Whitelist</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resource Status - if disabled */}
|
||||||
|
{!resource.enabled && (
|
||||||
|
<div
|
||||||
|
className={`${resource.siteName || hasAuthMethods ? "border-t border-border pt-2" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
|
||||||
|
<span className="text-sm text-destructive">
|
||||||
|
Resource Disabled
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <InfoPopup>{infoContent}</InfoPopup>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pagination component
|
||||||
|
const PaginationControls = ({
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
onPageChange,
|
||||||
|
totalItems,
|
||||||
|
itemsPerPage
|
||||||
|
}: {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
totalItems: number;
|
||||||
|
itemsPerPage: number;
|
||||||
|
}) => {
|
||||||
|
const startItem = (currentPage - 1) * itemsPerPage + 1;
|
||||||
|
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
|
||||||
|
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-8">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Showing {startItem}-{endItem} of {totalItems} resources
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
|
||||||
|
(page) => {
|
||||||
|
// Show first page, last page, current page, and 2 pages around current
|
||||||
|
const showPage =
|
||||||
|
page === 1 ||
|
||||||
|
page === totalPages ||
|
||||||
|
Math.abs(page - currentPage) <= 1;
|
||||||
|
|
||||||
|
const showEllipsis =
|
||||||
|
(page === 2 && currentPage > 4) ||
|
||||||
|
(page === totalPages - 1 &&
|
||||||
|
currentPage < totalPages - 3);
|
||||||
|
|
||||||
|
if (!showPage && !showEllipsis) return null;
|
||||||
|
|
||||||
|
if (showEllipsis) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={page}
|
||||||
|
className="px-2 text-muted-foreground"
|
||||||
|
>
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={page}
|
||||||
|
variant={
|
||||||
|
currentPage === page
|
||||||
|
? "default"
|
||||||
|
: "outline"
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(page)}
|
||||||
|
className="w-8 h-8 p-0"
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading skeleton component
|
||||||
|
const ResourceCardSkeleton = () => (
|
||||||
|
<Card className="rounded-lg bg-card text-card-foreground flex flex-col w-full animate-pulse">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="h-6 bg-muted rounded w-3/4"></div>
|
||||||
|
<div className="h-5 bg-muted rounded w-16"></div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-6 pb-6 flex-1 flex flex-col justify-between">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="h-4 w-4 bg-muted rounded"></div>
|
||||||
|
<div className="h-4 bg-muted rounded w-1/2"></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="h-4 w-4 bg-muted rounded"></div>
|
||||||
|
<div className="h-4 bg-muted rounded w-1/3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="h-8 bg-muted rounded w-full"></div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function MemberResourcesPortal({
|
||||||
|
orgId
|
||||||
|
}: MemberResourcesPortalProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [resources, setResources] = useState<Resource[]>([]);
|
||||||
|
const [filteredResources, setFilteredResources] = useState<Resource[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [sortBy, setSortBy] = useState("name-asc");
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const itemsPerPage = 12; // 3x4 grid on desktop
|
||||||
|
|
||||||
|
const fetchUserResources = async (isRefresh = false) => {
|
||||||
|
try {
|
||||||
|
if (isRefresh) {
|
||||||
|
setRefreshing(true);
|
||||||
|
} else {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await api.get<GetUserResourcesResponse>(
|
||||||
|
`/org/${orgId}/user-resources`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
setResources(response.data.data.resources);
|
||||||
|
setFilteredResources(response.data.data.resources);
|
||||||
|
} else {
|
||||||
|
setError("Failed to load resources");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching user resources:", err);
|
||||||
|
setError(
|
||||||
|
"Failed to load resources. Please check your connection and try again."
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUserResources();
|
||||||
|
}, [orgId, api]);
|
||||||
|
|
||||||
|
// Filter and sort resources
|
||||||
|
useEffect(() => {
|
||||||
|
const filtered = resources.filter(
|
||||||
|
(resource) =>
|
||||||
|
resource.name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchQuery.toLowerCase()) ||
|
||||||
|
resource.domain
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort resources
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case "name-asc":
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
case "name-desc":
|
||||||
|
return b.name.localeCompare(a.name);
|
||||||
|
case "domain-asc":
|
||||||
|
return a.domain.localeCompare(b.domain);
|
||||||
|
case "domain-desc":
|
||||||
|
return b.domain.localeCompare(a.domain);
|
||||||
|
case "status-enabled":
|
||||||
|
// Enabled first, then protected vs unprotected
|
||||||
|
if (a.enabled !== b.enabled) return b.enabled ? 1 : -1;
|
||||||
|
return b.protected ? 1 : -1;
|
||||||
|
case "status-disabled":
|
||||||
|
// Disabled first, then unprotected vs protected
|
||||||
|
if (a.enabled !== b.enabled) return a.enabled ? 1 : -1;
|
||||||
|
return a.protected ? 1 : -1;
|
||||||
|
default:
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setFilteredResources(filtered);
|
||||||
|
|
||||||
|
// Reset to first page when search/sort changes
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [resources, searchQuery, sortBy]);
|
||||||
|
|
||||||
|
// Calculate pagination
|
||||||
|
const totalPages = Math.ceil(filteredResources.length / itemsPerPage);
|
||||||
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
|
const paginatedResources = filteredResources.slice(
|
||||||
|
startIndex,
|
||||||
|
startIndex + itemsPerPage
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenResource = (resource: Resource) => {
|
||||||
|
// Open the resource in a new tab
|
||||||
|
window.open(resource.domain, "_blank");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
fetchUserResources(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetry = () => {
|
||||||
|
fetchUserResources();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
// Scroll to top when page changes
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-12xl">
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title="Resources"
|
||||||
|
description="Resources you have access to in this organization"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Search and Sort Controls - Skeleton */}
|
||||||
|
<div className="mb-6 flex flex-col sm:flex-row gap-4 justify-start">
|
||||||
|
<div className="relative w-full sm:w-80">
|
||||||
|
<div className="h-10 bg-muted rounded animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full sm:w-36">
|
||||||
|
<div className="h-10 bg-muted rounded animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading Skeletons */}
|
||||||
|
<div className="grid gap-4 sm:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 auto-cols-fr">
|
||||||
|
{Array.from({ length: 12 }).map((_, index) => (
|
||||||
|
<ResourceCardSkeleton key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-12xl">
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title="Resources"
|
||||||
|
description="Resources you have access to in this organization"
|
||||||
|
/>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
|
||||||
|
<div className="mb-6">
|
||||||
|
<AlertCircle className="h-16 w-16 text-destructive/60" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-foreground mb-3">
|
||||||
|
Unable to Load Resources
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground max-w-lg text-base mb-6">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={handleRetry}
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-12xl">
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title="Resources"
|
||||||
|
description="Resources you have access to in this organization"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Search and Sort Controls with Refresh */}
|
||||||
|
<div className="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-start flex-1">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative w-full sm:w-80">
|
||||||
|
<Input
|
||||||
|
placeholder="Search resources..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-8 bg-card"
|
||||||
|
/>
|
||||||
|
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort */}
|
||||||
|
<div className="w-full sm:w-36">
|
||||||
|
<Select value={sortBy} onValueChange={setSortBy}>
|
||||||
|
<SelectTrigger className="bg-card">
|
||||||
|
<SelectValue placeholder="Sort by..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="name-asc">
|
||||||
|
Name A-Z
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="name-desc">
|
||||||
|
Name Z-A
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="domain-asc">
|
||||||
|
Domain A-Z
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="domain-desc">
|
||||||
|
Domain Z-A
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="status-enabled">
|
||||||
|
Enabled First
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="status-disabled">
|
||||||
|
Disabled First
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Refresh Button */}
|
||||||
|
<Button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={refreshing}
|
||||||
|
className="gap-2 shrink-0"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resources Content */}
|
||||||
|
{filteredResources.length === 0 ? (
|
||||||
|
/* Enhanced Empty State */
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
|
||||||
|
<div className="mb-8 p-4 rounded-full bg-muted/20 dark:bg-muted/30">
|
||||||
|
{searchQuery ? (
|
||||||
|
<Search className="h-12 w-12 text-muted-foreground/70" />
|
||||||
|
) : (
|
||||||
|
<Globe className="h-12 w-12 text-muted-foreground/70" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-semibold text-foreground mb-3">
|
||||||
|
{searchQuery
|
||||||
|
? "No Resources Found"
|
||||||
|
: "No Resources Available"}
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground max-w-lg text-base mb-6">
|
||||||
|
{searchQuery
|
||||||
|
? `No resources match "${searchQuery}". Try adjusting your search terms or clearing the search to see all resources.`
|
||||||
|
: "You don't have access to any resources yet. Contact your administrator to get access to resources you need."}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
{searchQuery ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => setSearchQuery("")}
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
Clear Search
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
variant="outline"
|
||||||
|
disabled={refreshing}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
Refresh Resources
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Resources Grid */}
|
||||||
|
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr">
|
||||||
|
{paginatedResources.map((resource) => (
|
||||||
|
<Card key={resource.resourceId}>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center min-w-0 flex-1 gap-3 overflow-hidden">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger className="min-w-0 max-w-full">
|
||||||
|
<CardTitle className="text-lg font-bold text-foreground truncate group-hover:text-primary transition-colors">
|
||||||
|
{resource.name}
|
||||||
|
</CardTitle>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p className="max-w-xs break-words">
|
||||||
|
{resource.name}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<ResourceInfo resource={resource} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-3">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleOpenResource(resource)
|
||||||
|
}
|
||||||
|
className="text-sm text-muted-foreground font-medium text-left truncate flex-1"
|
||||||
|
disabled={!resource.enabled}
|
||||||
|
>
|
||||||
|
{resource.domain.replace(
|
||||||
|
/^https?:\/\//,
|
||||||
|
""
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
resource.domain
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "Copied to clipboard",
|
||||||
|
description:
|
||||||
|
"Resource URL has been copied to your clipboard.",
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 pt-0 mt-auto">
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
handleOpenResource(resource)
|
||||||
|
}
|
||||||
|
className="w-full h-9 transition-all group-hover:shadow-sm"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!resource.enabled}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 mr-2" />
|
||||||
|
Open Resource
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination Controls */}
|
||||||
|
<PaginationControls
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
totalItems={filteredResources.length}
|
||||||
|
itemsPerPage={itemsPerPage}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import UserProvider from "@app/providers/UserProvider";
|
import UserProvider from "@app/providers/UserProvider";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import OrganizationLandingCard from "./OrganizationLandingCard";
|
import OrganizationLandingCard from "./OrganizationLandingCard";
|
||||||
|
import MemberResourcesPortal from "./MemberResourcesPortal";
|
||||||
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
|
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
|
||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
|
@ -9,6 +10,9 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Layout } from "@app/components/Layout";
|
import { Layout } from "@app/components/Layout";
|
||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
|
import EnvProvider from "@app/providers/EnvProvider";
|
||||||
|
import { orgLangingNavItems } from "@app/app/navigation";
|
||||||
|
|
||||||
type OrgPageProps = {
|
type OrgPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
|
@ -17,6 +21,7 @@ type OrgPageProps = {
|
||||||
export default async function OrgPage(props: OrgPageProps) {
|
export default async function OrgPage(props: OrgPageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const orgId = params.orgId;
|
const orgId = params.orgId;
|
||||||
|
const env = pullEnv();
|
||||||
|
|
||||||
const getUser = cache(verifySession);
|
const getUser = cache(verifySession);
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
|
@ -25,7 +30,6 @@ export default async function OrgPage(props: OrgPageProps) {
|
||||||
redirect("/");
|
redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
let redirectToSettings = false;
|
|
||||||
let overview: GetOrgOverviewResponse | undefined;
|
let overview: GetOrgOverviewResponse | undefined;
|
||||||
try {
|
try {
|
||||||
const res = await internal.get<AxiosResponse<GetOrgOverviewResponse>>(
|
const res = await internal.get<AxiosResponse<GetOrgOverviewResponse>>(
|
||||||
|
@ -33,16 +37,14 @@ export default async function OrgPage(props: OrgPageProps) {
|
||||||
await authCookieHeader()
|
await authCookieHeader()
|
||||||
);
|
);
|
||||||
overview = res.data.data;
|
overview = res.data.data;
|
||||||
|
|
||||||
if (overview.isAdmin || overview.isOwner) {
|
|
||||||
redirectToSettings = true;
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
if (redirectToSettings) {
|
// If user is admin or owner, redirect to settings
|
||||||
|
if (overview?.isAdmin || overview?.isOwner) {
|
||||||
redirect(`/${orgId}/settings`);
|
redirect(`/${orgId}/settings`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For non-admin users, show the member resources portal
|
||||||
let orgs: ListUserOrgsResponse["orgs"] = [];
|
let orgs: ListUserOrgsResponse["orgs"] = [];
|
||||||
try {
|
try {
|
||||||
const getOrgs = cache(async () =>
|
const getOrgs = cache(async () =>
|
||||||
|
@ -60,24 +62,7 @@ export default async function OrgPage(props: OrgPageProps) {
|
||||||
return (
|
return (
|
||||||
<UserProvider user={user}>
|
<UserProvider user={user}>
|
||||||
<Layout orgId={orgId} navItems={[]} orgs={orgs}>
|
<Layout orgId={orgId} navItems={[]} orgs={orgs}>
|
||||||
{overview && (
|
{overview && <MemberResourcesPortal orgId={orgId} />}
|
||||||
<div className="w-full max-w-4xl mx-auto md:mt-32 mt-4">
|
|
||||||
<OrganizationLandingCard
|
|
||||||
overview={{
|
|
||||||
orgId: overview.orgId,
|
|
||||||
orgName: overview.orgName,
|
|
||||||
stats: {
|
|
||||||
users: overview.numUsers,
|
|
||||||
sites: overview.numSites,
|
|
||||||
resources: overview.numResources
|
|
||||||
},
|
|
||||||
isAdmin: overview.isAdmin,
|
|
||||||
isOwner: overview.isOwner,
|
|
||||||
userRole: overview.userRoleName
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Layout>
|
</Layout>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
);
|
);
|
||||||
|
|
|
@ -25,7 +25,6 @@ import { toast } from "@app/hooks/useToast";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import CreateClientFormModal from "./CreateClientsModal";
|
|
||||||
|
|
||||||
export type ClientRow = {
|
export type ClientRow = {
|
||||||
id: number;
|
id: number;
|
||||||
|
@ -76,42 +75,6 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<ClientRow>[] = [
|
const columns: ColumnDef<ClientRow>[] = [
|
||||||
{
|
|
||||||
id: "dots",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const clientRow = row.original;
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
|
||||||
<span className="sr-only">Open menu</span>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{/* <Link */}
|
|
||||||
{/* className="block w-full" */}
|
|
||||||
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
|
|
||||||
{/* > */}
|
|
||||||
{/* <DropdownMenuItem> */}
|
|
||||||
{/* View settings */}
|
|
||||||
{/* </DropdownMenuItem> */}
|
|
||||||
{/* </Link> */}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedClient(clientRow);
|
|
||||||
setIsDeleteModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="text-red-500">Delete</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
|
@ -243,6 +206,33 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
|
||||||
const clientRow = row.original;
|
const clientRow = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">Open menu</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{/* <Link */}
|
||||||
|
{/* className="block w-full" */}
|
||||||
|
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
|
||||||
|
{/* > */}
|
||||||
|
{/* <DropdownMenuItem> */}
|
||||||
|
{/* View settings */}
|
||||||
|
{/* </DropdownMenuItem> */}
|
||||||
|
{/* </Link> */}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedClient(clientRow);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-red-500">Delete</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
<Link
|
<Link
|
||||||
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
|
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
|
||||||
>
|
>
|
||||||
|
@ -259,15 +249,6 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CreateClientFormModal
|
|
||||||
open={isCreateModalOpen}
|
|
||||||
setOpen={setIsCreateModalOpen}
|
|
||||||
onCreate={(val) => {
|
|
||||||
setRows([val, ...rows]);
|
|
||||||
}}
|
|
||||||
orgId={orgId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{selectedClient && (
|
{selectedClient && (
|
||||||
<ConfirmDeleteDialog
|
<ConfirmDeleteDialog
|
||||||
open={isDeleteModalOpen}
|
open={isDeleteModalOpen}
|
||||||
|
@ -309,7 +290,7 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={rows}
|
data={rows}
|
||||||
addClient={() => {
|
addClient={() => {
|
||||||
setIsCreateModalOpen(true);
|
router.push(`/${orgId}/settings/clients/create`)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -1,349 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage
|
|
||||||
} from "@app/components/ui/form";
|
|
||||||
import { Input } from "@app/components/ui/input";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { z } from "zod";
|
|
||||||
import CopyTextBox from "@app/components/CopyTextBox";
|
|
||||||
import { Checkbox } from "@app/components/ui/checkbox";
|
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
|
||||||
import { createApiClient } from "@app/lib/api";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import { ClientRow } from "./ClientsTable";
|
|
||||||
import {
|
|
||||||
CreateClientBody,
|
|
||||||
CreateClientResponse,
|
|
||||||
PickClientDefaultsResponse
|
|
||||||
} from "@server/routers/client";
|
|
||||||
import { ListSitesResponse } from "@server/routers/site";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger
|
|
||||||
} from "@app/components/ui/popover";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList
|
|
||||||
} from "@app/components/ui/command";
|
|
||||||
import { ScrollArea } from "@app/components/ui/scroll-area";
|
|
||||||
import { Badge } from "@app/components/ui/badge";
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
|
||||||
|
|
||||||
const createClientFormSchema = z.object({
|
|
||||||
name: z
|
|
||||||
.string()
|
|
||||||
.min(2, {
|
|
||||||
message: "Name must be at least 2 characters."
|
|
||||||
})
|
|
||||||
.max(30, {
|
|
||||||
message: "Name must not be longer than 30 characters."
|
|
||||||
}),
|
|
||||||
siteIds: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
id: z.string(),
|
|
||||||
text: z.string()
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.refine((val) => val.length > 0, {
|
|
||||||
message: "At least one site is required."
|
|
||||||
}),
|
|
||||||
subnet: z.string().min(1, {
|
|
||||||
message: "Subnet is required."
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
type CreateClientFormValues = z.infer<typeof createClientFormSchema>;
|
|
||||||
|
|
||||||
const defaultValues: Partial<CreateClientFormValues> = {
|
|
||||||
name: "",
|
|
||||||
siteIds: [],
|
|
||||||
subnet: ""
|
|
||||||
};
|
|
||||||
|
|
||||||
type CreateClientFormProps = {
|
|
||||||
onCreate?: (client: ClientRow) => void;
|
|
||||||
setLoading?: (loading: boolean) => void;
|
|
||||||
setChecked?: (checked: boolean) => void;
|
|
||||||
orgId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CreateClientForm({
|
|
||||||
onCreate,
|
|
||||||
setLoading,
|
|
||||||
setChecked,
|
|
||||||
orgId
|
|
||||||
}: CreateClientFormProps) {
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
const { env } = useEnvContext();
|
|
||||||
|
|
||||||
const [sites, setSites] = useState<Tag[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isChecked, setIsChecked] = useState(false);
|
|
||||||
const [clientDefaults, setClientDefaults] =
|
|
||||||
useState<PickClientDefaultsResponse | null>(null);
|
|
||||||
const [olmCommand, setOlmCommand] = useState<string | null>(null);
|
|
||||||
const [selectedSites, setSelectedSites] = useState<
|
|
||||||
Array<{ id: number; name: string }>
|
|
||||||
>([]);
|
|
||||||
const [activeSitesTagIndex, setActiveSitesTagIndex] = useState<
|
|
||||||
number | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
const handleCheckboxChange = (checked: boolean) => {
|
|
||||||
setIsChecked(checked);
|
|
||||||
if (setChecked) {
|
|
||||||
setChecked(checked);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const form = useForm<CreateClientFormValues>({
|
|
||||||
resolver: zodResolver(createClientFormSchema),
|
|
||||||
defaultValues
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
|
|
||||||
// reset all values
|
|
||||||
setLoading?.(false);
|
|
||||||
setIsLoading(false);
|
|
||||||
form.reset();
|
|
||||||
setChecked?.(false);
|
|
||||||
setClientDefaults(null);
|
|
||||||
setSelectedSites([]);
|
|
||||||
|
|
||||||
const fetchSites = async () => {
|
|
||||||
const res = await api.get<AxiosResponse<ListSitesResponse>>(
|
|
||||||
`/org/${orgId}/sites/`
|
|
||||||
);
|
|
||||||
const sites = res.data.data.sites.filter(
|
|
||||||
(s) => s.type === "newt" && s.subnet
|
|
||||||
);
|
|
||||||
setSites(
|
|
||||||
sites.map((site) => ({
|
|
||||||
id: site.siteId.toString(),
|
|
||||||
text: site.name
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchDefaults = async () => {
|
|
||||||
api.get(`/org/${orgId}/pick-client-defaults`)
|
|
||||||
.catch((e) => {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: `Error fetching client defaults`,
|
|
||||||
description: formatAxiosError(e)
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
if (res && res.status === 200) {
|
|
||||||
const data = res.data.data;
|
|
||||||
setClientDefaults(data);
|
|
||||||
const olmConfig = `olm --id ${data?.olmId} --secret ${data?.olmSecret} --endpoint ${env.app.dashboardUrl}`;
|
|
||||||
setOlmCommand(olmConfig);
|
|
||||||
|
|
||||||
// Set the subnet value from client defaults
|
|
||||||
if (data?.subnet) {
|
|
||||||
form.setValue("subnet", data.subnet);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
fetchSites();
|
|
||||||
fetchDefaults();
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
async function onSubmit(data: CreateClientFormValues) {
|
|
||||||
setLoading?.(true);
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
if (!clientDefaults) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Error creating client",
|
|
||||||
description: "Client defaults not found"
|
|
||||||
});
|
|
||||||
setLoading?.(false);
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
name: data.name,
|
|
||||||
siteIds: data.siteIds.map((site) => parseInt(site.id)),
|
|
||||||
olmId: clientDefaults.olmId,
|
|
||||||
secret: clientDefaults.olmSecret,
|
|
||||||
subnet: data.subnet,
|
|
||||||
type: "olm"
|
|
||||||
} as CreateClientBody;
|
|
||||||
|
|
||||||
const res = await api
|
|
||||||
.put<
|
|
||||||
AxiosResponse<CreateClientResponse>
|
|
||||||
>(`/org/${orgId}/client`, payload)
|
|
||||||
.catch((e) => {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Error creating client",
|
|
||||||
description: formatAxiosError(e)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res && res.status === 201) {
|
|
||||||
const data = res.data.data;
|
|
||||||
|
|
||||||
onCreate?.({
|
|
||||||
name: data.name,
|
|
||||||
id: data.clientId,
|
|
||||||
subnet: data.subnet,
|
|
||||||
mbIn: "0 MB",
|
|
||||||
mbOut: "0 MB",
|
|
||||||
orgId: orgId as string,
|
|
||||||
online: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading?.(false);
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
className="space-y-4"
|
|
||||||
id="create-client-form"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder="Client name"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="subnet"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Address</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder="Subnet"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
The address that this client will use for
|
|
||||||
connectivity.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="siteIds"
|
|
||||||
render={(field) => (
|
|
||||||
<FormItem className="flex flex-col">
|
|
||||||
<FormLabel>Sites</FormLabel>
|
|
||||||
<TagInput
|
|
||||||
{...field}
|
|
||||||
activeTagIndex={activeSitesTagIndex}
|
|
||||||
setActiveTagIndex={setActiveSitesTagIndex}
|
|
||||||
placeholder="Select sites"
|
|
||||||
size="sm"
|
|
||||||
tags={form.getValues().siteIds}
|
|
||||||
setTags={(newTags) => {
|
|
||||||
form.setValue(
|
|
||||||
"siteIds",
|
|
||||||
newTags as [Tag, ...Tag[]]
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
enableAutocomplete={true}
|
|
||||||
autocompleteOptions={sites}
|
|
||||||
allowDuplicates={false}
|
|
||||||
restrictTagsToAutocompleteOptions={true}
|
|
||||||
sortTags={true}
|
|
||||||
/>
|
|
||||||
<FormDescription>
|
|
||||||
The client will have connectivity to the
|
|
||||||
selected sites. The sites must be configured
|
|
||||||
to accept client connections.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{olmCommand && (
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="mb-2">
|
|
||||||
<div className="mx-auto">
|
|
||||||
<CopyTextBox
|
|
||||||
text={olmCommand}
|
|
||||||
wrapText={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
You will only be able to see the configuration
|
|
||||||
once.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id="terms"
|
|
||||||
checked={isChecked}
|
|
||||||
onCheckedChange={handleCheckboxChange}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="terms"
|
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
||||||
>
|
|
||||||
I have copied the config
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,80 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
Credenza,
|
|
||||||
CredenzaBody,
|
|
||||||
CredenzaClose,
|
|
||||||
CredenzaContent,
|
|
||||||
CredenzaDescription,
|
|
||||||
CredenzaFooter,
|
|
||||||
CredenzaHeader,
|
|
||||||
CredenzaTitle
|
|
||||||
} from "@app/components/Credenza";
|
|
||||||
import CreateClientForm from "./CreateClientsForm";
|
|
||||||
import { ClientRow } from "./ClientsTable";
|
|
||||||
|
|
||||||
type CreateClientFormProps = {
|
|
||||||
open: boolean;
|
|
||||||
setOpen: (open: boolean) => void;
|
|
||||||
onCreate?: (client: ClientRow) => void;
|
|
||||||
orgId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CreateClientFormModal({
|
|
||||||
open,
|
|
||||||
setOpen,
|
|
||||||
onCreate,
|
|
||||||
orgId
|
|
||||||
}: CreateClientFormProps) {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [isChecked, setIsChecked] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Credenza
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(val) => {
|
|
||||||
setOpen(val);
|
|
||||||
setLoading(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CredenzaContent>
|
|
||||||
<CredenzaHeader>
|
|
||||||
<CredenzaTitle>Create Client</CredenzaTitle>
|
|
||||||
<CredenzaDescription>
|
|
||||||
Create a new client to connect to your sites
|
|
||||||
</CredenzaDescription>
|
|
||||||
</CredenzaHeader>
|
|
||||||
<CredenzaBody>
|
|
||||||
<div className="max-w-md">
|
|
||||||
<CreateClientForm
|
|
||||||
setLoading={(val) => setLoading(val)}
|
|
||||||
setChecked={(val) => setIsChecked(val)}
|
|
||||||
onCreate={onCreate}
|
|
||||||
orgId={orgId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CredenzaBody>
|
|
||||||
<CredenzaFooter>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
form="create-client-form"
|
|
||||||
loading={loading}
|
|
||||||
disabled={loading || !isChecked}
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Create Client
|
|
||||||
</Button>
|
|
||||||
<CredenzaClose asChild>
|
|
||||||
<Button variant="outline">Close</Button>
|
|
||||||
</CredenzaClose>
|
|
||||||
</CredenzaFooter>
|
|
||||||
</CredenzaContent>
|
|
||||||
</Credenza>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -9,38 +9,40 @@ import {
|
||||||
InfoSections,
|
InfoSections,
|
||||||
InfoSectionTitle
|
InfoSectionTitle
|
||||||
} from "@app/components/InfoSection";
|
} from "@app/components/InfoSection";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
type ClientInfoCardProps = {};
|
type ClientInfoCardProps = {};
|
||||||
|
|
||||||
export default function SiteInfoCard({}: ClientInfoCardProps) {
|
export default function SiteInfoCard({}: ClientInfoCardProps) {
|
||||||
const { client, updateClient } = useClientContext();
|
const { client, updateClient } = useClientContext();
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert>
|
<Alert>
|
||||||
<InfoIcon className="h-4 w-4" />
|
<InfoIcon className="h-4 w-4" />
|
||||||
<AlertTitle className="font-semibold">Client Information</AlertTitle>
|
<AlertTitle className="font-semibold">{t("clientInformation")}</AlertTitle>
|
||||||
<AlertDescription className="mt-4">
|
<AlertDescription className="mt-4">
|
||||||
<InfoSections cols={2}>
|
<InfoSections cols={2}>
|
||||||
<>
|
<>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>Status</InfoSectionTitle>
|
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{client.online ? (
|
{client.online ? (
|
||||||
<div className="text-green-500 flex items-center space-x-2">
|
<div className="text-green-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
<span>Online</span>
|
<span>{t("online")}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-neutral-500 flex items-center space-x-2">
|
<div className="text-neutral-500 flex items-center space-x-2">
|
||||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||||
<span>Offline</span>
|
<span>{t("offline")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
</>
|
</>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>Address</InfoSectionTitle>
|
<InfoSectionTitle>{t("address")}</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{client.subnet.split("/")[0]}
|
{client.subnet.split("/")[0]}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
|
|
|
@ -34,6 +34,7 @@ import { useEffect, useState } from "react";
|
||||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { ListSitesResponse } from "@server/routers/site";
|
import { ListSitesResponse } from "@server/routers/site";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
const GeneralFormSchema = z.object({
|
const GeneralFormSchema = z.object({
|
||||||
name: z.string().nonempty("Name is required"),
|
name: z.string().nonempty("Name is required"),
|
||||||
|
@ -48,6 +49,7 @@ const GeneralFormSchema = z.object({
|
||||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
|
const t = useTranslations();
|
||||||
const { client, updateClient } = useClientContext();
|
const { client, updateClient } = useClientContext();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
@ -119,18 +121,18 @@ export default function GeneralPage() {
|
||||||
updateClient({ name: data.name });
|
updateClient({ name: data.name });
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Client updated",
|
title: t("clientUpdated"),
|
||||||
description: "The client has been updated."
|
description: t("clientUpdatedDescription")
|
||||||
});
|
});
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Failed to update client",
|
title: t("clientUpdateFailed"),
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(
|
||||||
e,
|
e,
|
||||||
"An error occurred while updating the client."
|
t("clientUpdateError")
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -143,10 +145,10 @@ export default function GeneralPage() {
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
General Settings
|
{t("generalSettings")}
|
||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
Configure the general settings for this client
|
{t("generalSettingsDescription")}
|
||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
|
@ -163,15 +165,11 @@ export default function GeneralPage() {
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name</FormLabel>
|
<FormLabel>{t("name")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
<FormDescription>
|
|
||||||
This is the display name of the
|
|
||||||
client.
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -181,12 +179,12 @@ export default function GeneralPage() {
|
||||||
name="siteIds"
|
name="siteIds"
|
||||||
render={(field) => (
|
render={(field) => (
|
||||||
<FormItem className="flex flex-col">
|
<FormItem className="flex flex-col">
|
||||||
<FormLabel>Sites</FormLabel>
|
<FormLabel>{t("sites")}</FormLabel>
|
||||||
<TagInput
|
<TagInput
|
||||||
{...field}
|
{...field}
|
||||||
activeTagIndex={activeSitesTagIndex}
|
activeTagIndex={activeSitesTagIndex}
|
||||||
setActiveTagIndex={setActiveSitesTagIndex}
|
setActiveTagIndex={setActiveSitesTagIndex}
|
||||||
placeholder="Select sites"
|
placeholder={t("selectSites")}
|
||||||
size="sm"
|
size="sm"
|
||||||
tags={form.getValues().siteIds}
|
tags={form.getValues().siteIds}
|
||||||
setTags={(newTags) => {
|
setTags={(newTags) => {
|
||||||
|
@ -202,9 +200,7 @@ export default function GeneralPage() {
|
||||||
sortTags={true}
|
sortTags={true}
|
||||||
/>
|
/>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
The client will have connectivity to the
|
{t("sitesDescription")}
|
||||||
selected sites. The sites must be configured
|
|
||||||
to accept client connections.
|
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
@ -222,7 +218,7 @@ export default function GeneralPage() {
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Save Settings
|
{t("saveSettings")}
|
||||||
</Button>
|
</Button>
|
||||||
</SettingsSectionFooter>
|
</SettingsSectionFooter>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
708
src/app/[orgId]/settings/clients/create/page.tsx
Normal file
708
src/app/[orgId]/settings/clients/create/page.tsx
Normal file
|
@ -0,0 +1,708 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { StrategySelect } from "@app/components/StrategySelect";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { createElement, useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import { InfoIcon, Terminal } from "lucide-react";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
|
import {
|
||||||
|
InfoSection,
|
||||||
|
InfoSectionContent,
|
||||||
|
InfoSections,
|
||||||
|
InfoSectionTitle
|
||||||
|
} from "@app/components/InfoSection";
|
||||||
|
import {
|
||||||
|
FaApple,
|
||||||
|
FaCubes,
|
||||||
|
FaDocker,
|
||||||
|
FaFreebsd,
|
||||||
|
FaWindows
|
||||||
|
} from "react-icons/fa";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import {
|
||||||
|
CreateClientBody,
|
||||||
|
CreateClientResponse,
|
||||||
|
PickClientDefaultsResponse
|
||||||
|
} from "@server/routers/client";
|
||||||
|
import { ListSitesResponse } from "@server/routers/site";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
type ClientType = "olm";
|
||||||
|
|
||||||
|
interface TunnelTypeOption {
|
||||||
|
id: ClientType;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Commands = {
|
||||||
|
mac: Record<string, string[]>;
|
||||||
|
linux: Record<string, string[]>;
|
||||||
|
windows: Record<string, string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const platforms = ["linux", "mac", "windows"] as const;
|
||||||
|
|
||||||
|
type Platform = (typeof platforms)[number];
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const { orgId } = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const createClientFormSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(2, { message: t("nameMin", { len: 2 }) })
|
||||||
|
.max(30, { message: t("nameMax", { len: 30 }) }),
|
||||||
|
method: z.enum(["olm"]),
|
||||||
|
siteIds: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
text: z.string()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.refine((val) => val.length > 0, {
|
||||||
|
message: t("siteRequired")
|
||||||
|
}),
|
||||||
|
subnet: z.string().ip().min(1, {
|
||||||
|
message: t("subnetRequired")
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
type CreateClientFormValues = z.infer<typeof createClientFormSchema>;
|
||||||
|
|
||||||
|
const [tunnelTypes, setTunnelTypes] = useState<
|
||||||
|
ReadonlyArray<TunnelTypeOption>
|
||||||
|
>([
|
||||||
|
{
|
||||||
|
id: "olm",
|
||||||
|
title: t("olmTunnel"),
|
||||||
|
description: t("olmTunnelDescription"),
|
||||||
|
disabled: true
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [loadingPage, setLoadingPage] = useState(true);
|
||||||
|
const [sites, setSites] = useState<Tag[]>([]);
|
||||||
|
const [activeSitesTagIndex, setActiveSitesTagIndex] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const [platform, setPlatform] = useState<Platform>("linux");
|
||||||
|
const [architecture, setArchitecture] = useState("amd64");
|
||||||
|
const [commands, setCommands] = useState<Commands | null>(null);
|
||||||
|
|
||||||
|
const [olmId, setOlmId] = useState("");
|
||||||
|
const [olmSecret, setOlmSecret] = useState("");
|
||||||
|
const [olmCommand, setOlmCommand] = useState("");
|
||||||
|
|
||||||
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
|
|
||||||
|
const [clientDefaults, setClientDefaults] =
|
||||||
|
useState<PickClientDefaultsResponse | null>(null);
|
||||||
|
|
||||||
|
const hydrateCommands = (
|
||||||
|
id: string,
|
||||||
|
secret: string,
|
||||||
|
endpoint: string,
|
||||||
|
version: string
|
||||||
|
) => {
|
||||||
|
const commands = {
|
||||||
|
mac: {
|
||||||
|
"Apple Silicon (arm64)": [
|
||||||
|
`curl -L -o olm "https://github.com/fosrl/olm/releases/download/${version}/olm_darwin_arm64" && chmod +x ./olm`,
|
||||||
|
`sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||||
|
],
|
||||||
|
"Intel x64 (amd64)": [
|
||||||
|
`curl -L -o olm "https://github.com/fosrl/olm/releases/download/${version}/olm_darwin_amd64" && chmod +x ./olm`,
|
||||||
|
`sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||||
|
]
|
||||||
|
},
|
||||||
|
linux: {
|
||||||
|
amd64: [
|
||||||
|
`wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_amd64" && chmod +x ./olm`,
|
||||||
|
`sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||||
|
],
|
||||||
|
arm64: [
|
||||||
|
`wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_arm64" && chmod +x ./olm`,
|
||||||
|
`sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||||
|
],
|
||||||
|
arm32: [
|
||||||
|
`wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_arm32" && chmod +x ./olm`,
|
||||||
|
`sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||||
|
],
|
||||||
|
arm32v6: [
|
||||||
|
`wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_arm32v6" && chmod +x ./olm`,
|
||||||
|
`sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||||
|
],
|
||||||
|
riscv64: [
|
||||||
|
`wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_riscv64" && chmod +x ./olm`,
|
||||||
|
`sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||||
|
]
|
||||||
|
},
|
||||||
|
windows: {
|
||||||
|
x64: [
|
||||||
|
`curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`,
|
||||||
|
`# Run the installer to install olm and wintun`,
|
||||||
|
`olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setCommands(commands);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getArchitectures = () => {
|
||||||
|
switch (platform) {
|
||||||
|
case "linux":
|
||||||
|
return ["amd64", "arm64", "arm32", "arm32v6", "riscv64"];
|
||||||
|
case "mac":
|
||||||
|
return ["Apple Silicon (arm64)", "Intel x64 (amd64)"];
|
||||||
|
case "windows":
|
||||||
|
return ["x64"];
|
||||||
|
default:
|
||||||
|
return ["x64"];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlatformName = (platformName: string) => {
|
||||||
|
switch (platformName) {
|
||||||
|
case "windows":
|
||||||
|
return "Windows";
|
||||||
|
case "mac":
|
||||||
|
return "macOS";
|
||||||
|
case "docker":
|
||||||
|
return "Docker";
|
||||||
|
default:
|
||||||
|
return "Linux";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCommand = () => {
|
||||||
|
const placeholder = [t("unknownCommand")];
|
||||||
|
if (!commands) {
|
||||||
|
return placeholder;
|
||||||
|
}
|
||||||
|
let platformCommands = commands[platform as keyof Commands];
|
||||||
|
|
||||||
|
if (!platformCommands) {
|
||||||
|
// get first key
|
||||||
|
const firstPlatform = Object.keys(commands)[0] as Platform;
|
||||||
|
platformCommands = commands[firstPlatform as keyof Commands];
|
||||||
|
|
||||||
|
setPlatform(firstPlatform);
|
||||||
|
}
|
||||||
|
|
||||||
|
let architectureCommands = platformCommands[architecture];
|
||||||
|
if (!architectureCommands) {
|
||||||
|
// get first key
|
||||||
|
const firstArchitecture = Object.keys(platformCommands)[0];
|
||||||
|
architectureCommands = platformCommands[firstArchitecture];
|
||||||
|
|
||||||
|
setArchitecture(firstArchitecture);
|
||||||
|
}
|
||||||
|
|
||||||
|
return architectureCommands || placeholder;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlatformIcon = (platformName: string) => {
|
||||||
|
switch (platformName) {
|
||||||
|
case "windows":
|
||||||
|
return <FaWindows className="h-4 w-4 mr-2" />;
|
||||||
|
case "mac":
|
||||||
|
return <FaApple className="h-4 w-4 mr-2" />;
|
||||||
|
case "docker":
|
||||||
|
return <FaDocker 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" />;
|
||||||
|
default:
|
||||||
|
return <Terminal className="h-4 w-4 mr-2" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = useForm<CreateClientFormValues>({
|
||||||
|
resolver: zodResolver(createClientFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
method: "olm",
|
||||||
|
siteIds: [],
|
||||||
|
subnet: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(data: CreateClientFormValues) {
|
||||||
|
setCreateLoading(true);
|
||||||
|
|
||||||
|
if (!clientDefaults) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("errorCreatingClient"),
|
||||||
|
description: t("clientDefaultsNotFound")
|
||||||
|
});
|
||||||
|
setCreateLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: CreateClientBody = {
|
||||||
|
name: data.name,
|
||||||
|
type: data.method as "olm",
|
||||||
|
siteIds: data.siteIds.map((site) => parseInt(site.id)),
|
||||||
|
olmId: clientDefaults.olmId,
|
||||||
|
secret: clientDefaults.olmSecret,
|
||||||
|
subnet: data.subnet
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await api
|
||||||
|
.put<
|
||||||
|
AxiosResponse<CreateClientResponse>
|
||||||
|
>(`/org/${orgId}/client`, payload)
|
||||||
|
.catch((e) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("errorCreatingClient"),
|
||||||
|
description: formatAxiosError(e)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.status === 201) {
|
||||||
|
const data = res.data.data;
|
||||||
|
router.push(`/${orgId}/settings/clients/${data.clientId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreateLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
setLoadingPage(true);
|
||||||
|
|
||||||
|
// Fetch available sites
|
||||||
|
|
||||||
|
const res = await api.get<AxiosResponse<ListSitesResponse>>(
|
||||||
|
`/org/${orgId}/sites/`
|
||||||
|
);
|
||||||
|
const sites = res.data.data.sites.filter(
|
||||||
|
(s) => s.type === "newt" && s.subnet
|
||||||
|
);
|
||||||
|
setSites(
|
||||||
|
sites.map((site) => ({
|
||||||
|
id: site.siteId.toString(),
|
||||||
|
text: site.name
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
let olmVersion = "latest";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.github.com/repos/fosrl/olm/releases/latest`
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
t("olmErrorFetchReleases", {
|
||||||
|
err: response.statusText
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const latestVersion = data.tag_name;
|
||||||
|
olmVersion = latestVersion;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
t("olmErrorFetchLatest", {
|
||||||
|
err:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: String(error)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await api
|
||||||
|
.get(`/org/${orgId}/pick-client-defaults`)
|
||||||
|
.catch((e) => {
|
||||||
|
form.setValue("method", "olm");
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (res && res.status === 200) {
|
||||||
|
const data = res.data.data;
|
||||||
|
|
||||||
|
setClientDefaults(data);
|
||||||
|
|
||||||
|
const olmId = data.olmId;
|
||||||
|
const olmSecret = data.olmSecret;
|
||||||
|
const olmCommand = `olm --id ${olmId} --secret ${olmSecret} --endpoint ${env.app.dashboardUrl}`;
|
||||||
|
|
||||||
|
setOlmId(olmId);
|
||||||
|
setOlmSecret(olmSecret);
|
||||||
|
setOlmCommand(olmCommand);
|
||||||
|
|
||||||
|
hydrateCommands(
|
||||||
|
olmId,
|
||||||
|
olmSecret,
|
||||||
|
env.app.dashboardUrl,
|
||||||
|
olmVersion
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.subnet) {
|
||||||
|
form.setValue("subnet", data.subnet);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTunnelTypes((prev: any) => {
|
||||||
|
return prev.map((item: any) => {
|
||||||
|
return { ...item, disabled: false };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoadingPage(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<HeaderTitle
|
||||||
|
title={t("createClient")}
|
||||||
|
description={t("createClientDescription")}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/${orgId}/settings/clients`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("seeAllClients")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loadingPage && (
|
||||||
|
<div>
|
||||||
|
<SettingsContainer>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("clientInformation")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
id="create-client-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("name")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="subnet"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("address")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={t("subnetPlaceholder")}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
{t("addressDescription")}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="siteIds"
|
||||||
|
render={(field) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel>
|
||||||
|
{t("sites")}
|
||||||
|
</FormLabel>
|
||||||
|
<TagInput
|
||||||
|
{...field}
|
||||||
|
activeTagIndex={
|
||||||
|
activeSitesTagIndex
|
||||||
|
}
|
||||||
|
setActiveTagIndex={
|
||||||
|
setActiveSitesTagIndex
|
||||||
|
}
|
||||||
|
placeholder={t("selectSites")}
|
||||||
|
size="sm"
|
||||||
|
tags={
|
||||||
|
form.getValues()
|
||||||
|
.siteIds
|
||||||
|
}
|
||||||
|
setTags={(
|
||||||
|
olmags
|
||||||
|
) => {
|
||||||
|
form.setValue(
|
||||||
|
"siteIds",
|
||||||
|
olmags as [
|
||||||
|
Tag,
|
||||||
|
...Tag[]
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
enableAutocomplete={
|
||||||
|
true
|
||||||
|
}
|
||||||
|
autocompleteOptions={
|
||||||
|
sites
|
||||||
|
}
|
||||||
|
allowDuplicates={
|
||||||
|
false
|
||||||
|
}
|
||||||
|
restrictTagsToAutocompleteOptions={
|
||||||
|
true
|
||||||
|
}
|
||||||
|
sortTags={true}
|
||||||
|
/>
|
||||||
|
<FormDescription>
|
||||||
|
{t("sitesDescription")}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{form.watch("method") === "olm" && (
|
||||||
|
<>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("clientOlmCredentials")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("clientOlmCredentialsDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<InfoSections cols={3}>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>
|
||||||
|
{t("olmEndpoint")}
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
<CopyToClipboard
|
||||||
|
text={
|
||||||
|
env.app.dashboardUrl
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>
|
||||||
|
{t("olmId")}
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
<CopyToClipboard
|
||||||
|
text={olmId}
|
||||||
|
/>
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>
|
||||||
|
{t("olmSecretKey")}
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
<CopyToClipboard
|
||||||
|
text={olmSecret}
|
||||||
|
/>
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
</InfoSections>
|
||||||
|
|
||||||
|
<Alert variant="neutral" className="">
|
||||||
|
<InfoIcon className="h-4 w-4" />
|
||||||
|
<AlertTitle className="font-semibold">
|
||||||
|
{t("clientCredentialsSave")}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t(
|
||||||
|
"clientCredentialsSaveDescription"
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("clientInstallOlm")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("clientInstallOlmDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold mb-3">
|
||||||
|
{t("operatingSystem")}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||||
|
{platforms.map((os) => (
|
||||||
|
<Button
|
||||||
|
key={os}
|
||||||
|
variant={
|
||||||
|
platform === os
|
||||||
|
? "squareOutlinePrimary"
|
||||||
|
: "squareOutline"
|
||||||
|
}
|
||||||
|
className={`flex-1 min-w-[120px] ${platform === os ? "bg-primary/10" : ""} shadow-none`}
|
||||||
|
onClick={() => {
|
||||||
|
setPlatform(os);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getPlatformIcon(os)}
|
||||||
|
{getPlatformName(os)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="font-bold mb-3">
|
||||||
|
{["docker", "podman"].includes(
|
||||||
|
platform
|
||||||
|
)
|
||||||
|
? t("method")
|
||||||
|
: t("architecture")}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||||
|
{getArchitectures().map(
|
||||||
|
(arch) => (
|
||||||
|
<Button
|
||||||
|
key={arch}
|
||||||
|
variant={
|
||||||
|
architecture ===
|
||||||
|
arch
|
||||||
|
? "squareOutlinePrimary"
|
||||||
|
: "squareOutline"
|
||||||
|
}
|
||||||
|
className={`flex-1 min-w-[120px] ${architecture === arch ? "bg-primary/10" : ""} shadow-none`}
|
||||||
|
onClick={() =>
|
||||||
|
setArchitecture(
|
||||||
|
arch
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{arch}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="pt-4">
|
||||||
|
<p className="font-bold mb-3">
|
||||||
|
{t("commands")}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
<CopyTextBox
|
||||||
|
text={getCommand().join(
|
||||||
|
"\n"
|
||||||
|
)}
|
||||||
|
outline={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SettingsContainer>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 mt-8">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/${orgId}/settings/clients`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
loading={createLoading}
|
||||||
|
disabled={createLoading}
|
||||||
|
onClick={() => {
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("createClient")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -48,7 +48,7 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsSectionTitle
|
<SettingsSectionTitle
|
||||||
title="Manage Clients"
|
title="Manage Clients (beta)"
|
||||||
description="Clients are devices that can connect to your sites"
|
description="Clients are devices that can connect to your sites"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,7 @@ import { useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { RotateCw } from "lucide-react";
|
import { RotateCw } from "lucide-react";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
type ResourceInfoBoxType = {};
|
type ResourceInfoBoxType = {};
|
||||||
|
|
||||||
|
@ -34,7 +35,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||||
<Alert>
|
<Alert>
|
||||||
<InfoIcon className="h-4 w-4" />
|
<InfoIcon className="h-4 w-4" />
|
||||||
<AlertTitle className="font-semibold">
|
<AlertTitle className="font-semibold">
|
||||||
{t('resourceInfo')}
|
{t("resourceInfo")}
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription className="mt-4">
|
<AlertDescription className="mt-4">
|
||||||
<InfoSections cols={4}>
|
<InfoSections cols={4}>
|
||||||
|
@ -42,7 +43,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||||
<>
|
<>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>
|
<InfoSectionTitle>
|
||||||
{t('authentication')}
|
{t("authentication")}
|
||||||
</InfoSectionTitle>
|
</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{authInfo.password ||
|
{authInfo.password ||
|
||||||
|
@ -51,12 +52,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||||
authInfo.whitelist ? (
|
authInfo.whitelist ? (
|
||||||
<div className="flex items-start space-x-2 text-green-500">
|
<div className="flex items-start space-x-2 text-green-500">
|
||||||
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
||||||
<span>{t('protected')}</span>
|
<span>{t("protected")}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center space-x-2 text-yellow-500">
|
<div className="flex items-center space-x-2 text-yellow-500">
|
||||||
<ShieldOff className="w-4 h-4" />
|
<ShieldOff className="w-4 h-4" />
|
||||||
<span>{t('notProtected')}</span>
|
<span>{t("notProtected")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
|
@ -71,7 +72,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>{t('site')}</InfoSectionTitle>
|
<InfoSectionTitle>{t("site")}</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{resource.siteName}
|
{resource.siteName}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
|
@ -98,7 +99,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>{t('protocol')}</InfoSectionTitle>
|
<InfoSectionTitle>
|
||||||
|
{t("protocol")}
|
||||||
|
</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
<span>
|
<span>
|
||||||
{resource.protocol.toUpperCase()}
|
{resource.protocol.toUpperCase()}
|
||||||
|
@ -106,7 +109,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>{t('port')}</InfoSectionTitle>
|
<InfoSectionTitle>{t("port")}</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
<CopyToClipboard
|
<CopyToClipboard
|
||||||
text={resource.proxyPort!.toString()}
|
text={resource.proxyPort!.toString()}
|
||||||
|
@ -114,13 +117,29 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||||
/>
|
/>
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
|
{build == "oss" && (
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>
|
||||||
|
{t("externalProxyEnabled")}
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
<span>
|
||||||
|
{resource.enableProxy
|
||||||
|
? t("enabled")
|
||||||
|
: t("disabled")}
|
||||||
|
</span>
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>{t('visibility')}</InfoSectionTitle>
|
<InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
<span>
|
<span>
|
||||||
{resource.enabled ? t('enabled') : t('disabled')}
|
{resource.enabled
|
||||||
|
? t("enabled")
|
||||||
|
: t("disabled")}
|
||||||
</span>
|
</span>
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
|
|
|
@ -66,6 +66,7 @@ import {
|
||||||
} from "@server/routers/resource";
|
} from "@server/routers/resource";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Checkbox } from "@app/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Credenza,
|
Credenza,
|
||||||
CredenzaBody,
|
CredenzaBody,
|
||||||
|
@ -78,6 +79,7 @@ import {
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import DomainPicker from "@app/components/DomainPicker";
|
import DomainPicker from "@app/components/DomainPicker";
|
||||||
import { Globe } from "lucide-react";
|
import { Globe } from "lucide-react";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
const TransferFormSchema = z.object({
|
const TransferFormSchema = z.object({
|
||||||
siteId: z.number()
|
siteId: z.number()
|
||||||
|
@ -118,25 +120,31 @@ export default function GeneralForm() {
|
||||||
fullDomain: string;
|
fullDomain: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const GeneralFormSchema = z.object({
|
const GeneralFormSchema = z
|
||||||
|
.object({
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
subdomain: z.string().optional(),
|
subdomain: z.string().optional(),
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
domainId: z.string().optional(),
|
domainId: z.string().optional(),
|
||||||
proxyPort: z.number().int().min(1).max(65535).optional()
|
proxyPort: z.number().int().min(1).max(65535).optional(),
|
||||||
}).refine((data) => {
|
enableProxy: z.boolean().optional()
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
// For non-HTTP resources, proxyPort should be defined
|
// For non-HTTP resources, proxyPort should be defined
|
||||||
if (!resource.http) {
|
if (!resource.http) {
|
||||||
return data.proxyPort !== undefined;
|
return data.proxyPort !== undefined;
|
||||||
}
|
}
|
||||||
// For HTTP resources, proxyPort should be undefined
|
// For HTTP resources, proxyPort should be undefined
|
||||||
return data.proxyPort === undefined;
|
return data.proxyPort === undefined;
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
message: !resource.http
|
message: !resource.http
|
||||||
? "Port number is required for non-HTTP resources"
|
? "Port number is required for non-HTTP resources"
|
||||||
: "Port number should not be set for HTTP resources",
|
: "Port number should not be set for HTTP resources",
|
||||||
path: ["proxyPort"]
|
path: ["proxyPort"]
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||||
|
|
||||||
|
@ -147,7 +155,8 @@ export default function GeneralForm() {
|
||||||
name: resource.name,
|
name: resource.name,
|
||||||
subdomain: resource.subdomain ? resource.subdomain : undefined,
|
subdomain: resource.subdomain ? resource.subdomain : undefined,
|
||||||
domainId: resource.domainId || undefined,
|
domainId: resource.domainId || undefined,
|
||||||
proxyPort: resource.proxyPort || undefined
|
proxyPort: resource.proxyPort || undefined,
|
||||||
|
enableProxy: resource.enableProxy || false
|
||||||
},
|
},
|
||||||
mode: "onChange"
|
mode: "onChange"
|
||||||
});
|
});
|
||||||
|
@ -211,7 +220,10 @@ export default function GeneralForm() {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
subdomain: data.subdomain,
|
subdomain: data.subdomain,
|
||||||
domainId: data.domainId,
|
domainId: data.domainId,
|
||||||
proxyPort: data.proxyPort
|
proxyPort: data.proxyPort,
|
||||||
|
...(!resource.http && {
|
||||||
|
enableProxy: data.enableProxy
|
||||||
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
|
@ -238,7 +250,10 @@ export default function GeneralForm() {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
subdomain: data.subdomain,
|
subdomain: data.subdomain,
|
||||||
fullDomain: resource.fullDomain,
|
fullDomain: resource.fullDomain,
|
||||||
proxyPort: data.proxyPort
|
proxyPort: data.proxyPort,
|
||||||
|
...(!resource.http && {
|
||||||
|
enableProxy: data.enableProxy
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
@ -357,16 +372,29 @@ export default function GeneralForm() {
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("resourcePortNumber")}
|
{t(
|
||||||
|
"resourcePortNumber"
|
||||||
|
)}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={field.value ?? ""}
|
value={
|
||||||
onChange={(e) =>
|
field.value ??
|
||||||
|
""
|
||||||
|
}
|
||||||
|
onChange={(
|
||||||
|
e
|
||||||
|
) =>
|
||||||
field.onChange(
|
field.onChange(
|
||||||
e.target.value
|
e
|
||||||
? parseInt(e.target.value)
|
.target
|
||||||
|
.value
|
||||||
|
? parseInt(
|
||||||
|
e
|
||||||
|
.target
|
||||||
|
.value
|
||||||
|
)
|
||||||
: undefined
|
: undefined
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -374,11 +402,49 @@ export default function GeneralForm() {
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("resourcePortNumberDescription")}
|
{t(
|
||||||
|
"resourcePortNumberDescription"
|
||||||
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{build == "oss" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enableProxy"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
variant={
|
||||||
|
"outlinePrimarySquare"
|
||||||
|
}
|
||||||
|
checked={
|
||||||
|
field.value
|
||||||
|
}
|
||||||
|
onCheckedChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="space-y-1 leading-none">
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"resourceEnableProxy"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"resourceEnableProxyDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -574,6 +640,7 @@ export default function GeneralForm() {
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
<DomainPicker
|
<DomainPicker
|
||||||
orgId={orgId as string}
|
orgId={orgId as string}
|
||||||
|
cols={1}
|
||||||
onDomainChange={(res) => {
|
onDomainChange={(res) => {
|
||||||
const selected = {
|
const selected = {
|
||||||
domainId: res.domainId,
|
domainId: res.domainId,
|
||||||
|
|
|
@ -25,6 +25,7 @@ import { Controller, useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Checkbox } from "@app/components/ui/checkbox";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { ListSitesResponse } from "@server/routers/site";
|
import { ListSitesResponse } from "@server/routers/site";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
|
@ -64,6 +65,7 @@ import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import DomainPicker from "@app/components/DomainPicker";
|
import DomainPicker from "@app/components/DomainPicker";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
const baseResourceFormSchema = z.object({
|
const baseResourceFormSchema = z.object({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
|
@ -72,13 +74,14 @@ const baseResourceFormSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
const httpResourceFormSchema = z.object({
|
const httpResourceFormSchema = z.object({
|
||||||
domainId: z.string().optional(),
|
domainId: z.string().nonempty(),
|
||||||
subdomain: z.string().optional()
|
subdomain: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
const tcpUdpResourceFormSchema = z.object({
|
const tcpUdpResourceFormSchema = z.object({
|
||||||
protocol: z.string(),
|
protocol: z.string(),
|
||||||
proxyPort: z.number().int().min(1).max(65535)
|
proxyPort: z.number().int().min(1).max(65535),
|
||||||
|
enableProxy: z.boolean().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
type BaseResourceFormValues = z.infer<typeof baseResourceFormSchema>;
|
type BaseResourceFormValues = z.infer<typeof baseResourceFormSchema>;
|
||||||
|
@ -144,7 +147,8 @@ export default function Page() {
|
||||||
resolver: zodResolver(tcpUdpResourceFormSchema),
|
resolver: zodResolver(tcpUdpResourceFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
protocol: "tcp",
|
protocol: "tcp",
|
||||||
proxyPort: undefined
|
proxyPort: undefined,
|
||||||
|
enableProxy: false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -166,13 +170,14 @@ export default function Page() {
|
||||||
Object.assign(payload, {
|
Object.assign(payload, {
|
||||||
subdomain: httpData.subdomain,
|
subdomain: httpData.subdomain,
|
||||||
domainId: httpData.domainId,
|
domainId: httpData.domainId,
|
||||||
protocol: "tcp",
|
protocol: "tcp"
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const tcpUdpData = tcpUdpForm.getValues();
|
const tcpUdpData = tcpUdpForm.getValues();
|
||||||
Object.assign(payload, {
|
Object.assign(payload, {
|
||||||
protocol: tcpUdpData.protocol,
|
protocol: tcpUdpData.protocol,
|
||||||
proxyPort: tcpUdpData.proxyPort
|
proxyPort: tcpUdpData.proxyPort,
|
||||||
|
enableProxy: tcpUdpData.enableProxy
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -198,8 +203,15 @@ export default function Page() {
|
||||||
if (isHttp) {
|
if (isHttp) {
|
||||||
router.push(`/${orgId}/settings/resources/${id}`);
|
router.push(`/${orgId}/settings/resources/${id}`);
|
||||||
} else {
|
} else {
|
||||||
|
const tcpUdpData = tcpUdpForm.getValues();
|
||||||
|
// Only show config snippets if enableProxy is explicitly true
|
||||||
|
if (tcpUdpData.enableProxy === true) {
|
||||||
setShowSnippets(true);
|
setShowSnippets(true);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
// If enableProxy is false or undefined, go directly to resource page
|
||||||
|
router.push(`/${orgId}/settings/resources/${id}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -265,9 +277,9 @@ export default function Page() {
|
||||||
if (res?.status === 200) {
|
if (res?.status === 200) {
|
||||||
const domains = res.data.data.domains;
|
const domains = res.data.data.domains;
|
||||||
setBaseDomains(domains);
|
setBaseDomains(domains);
|
||||||
if (domains.length) {
|
// if (domains.length) {
|
||||||
httpForm.setValue("domainId", domains[0].domainId);
|
// httpForm.setValue("domainId", domains[0].domainId);
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -603,6 +615,46 @@ export default function Page() {
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{build == "oss" && (
|
||||||
|
<FormField
|
||||||
|
control={
|
||||||
|
tcpUdpForm.control
|
||||||
|
}
|
||||||
|
name="enableProxy"
|
||||||
|
render={({
|
||||||
|
field
|
||||||
|
}) => (
|
||||||
|
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
variant={
|
||||||
|
"outlinePrimarySquare"
|
||||||
|
}
|
||||||
|
checked={
|
||||||
|
field.value
|
||||||
|
}
|
||||||
|
onCheckedChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="space-y-1 leading-none">
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"resourceEnableProxy"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"resourceEnableProxyDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</SettingsSectionForm>
|
</SettingsSectionForm>
|
||||||
|
@ -632,6 +684,8 @@ export default function Page() {
|
||||||
? await httpForm.trigger()
|
? await httpForm.trigger()
|
||||||
: await tcpUdpForm.trigger();
|
: await tcpUdpForm.trigger();
|
||||||
|
|
||||||
|
console.log(httpForm.getValues());
|
||||||
|
|
||||||
if (baseValid && settingsValid) {
|
if (baseValid && settingsValid) {
|
||||||
onSubmit();
|
onSubmit();
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,10 +33,17 @@ import { useState } from "react";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||||
|
|
||||||
const GeneralFormSchema = z.object({
|
const GeneralFormSchema = z.object({
|
||||||
name: z.string().nonempty("Name is required"),
|
name: z.string().nonempty("Name is required"),
|
||||||
dockerSocketEnabled: z.boolean().optional()
|
dockerSocketEnabled: z.boolean().optional(),
|
||||||
|
remoteSubnets: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
text: z.string()
|
||||||
|
})
|
||||||
|
).optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||||
|
@ -44,9 +51,11 @@ type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
const { site, updateSite } = useSiteContext();
|
const { site, updateSite } = useSiteContext();
|
||||||
|
|
||||||
|
const { env } = useEnvContext();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [activeCidrTagIndex, setActiveCidrTagIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
@ -55,7 +64,13 @@ export default function GeneralPage() {
|
||||||
resolver: zodResolver(GeneralFormSchema),
|
resolver: zodResolver(GeneralFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: site?.name,
|
name: site?.name,
|
||||||
dockerSocketEnabled: site?.dockerSocketEnabled ?? false
|
dockerSocketEnabled: site?.dockerSocketEnabled ?? false,
|
||||||
|
remoteSubnets: site?.remoteSubnets
|
||||||
|
? site.remoteSubnets.split(',').map((subnet, index) => ({
|
||||||
|
id: subnet.trim(),
|
||||||
|
text: subnet.trim()
|
||||||
|
}))
|
||||||
|
: []
|
||||||
},
|
},
|
||||||
mode: "onChange"
|
mode: "onChange"
|
||||||
});
|
});
|
||||||
|
@ -66,7 +81,8 @@ export default function GeneralPage() {
|
||||||
await api
|
await api
|
||||||
.post(`/site/${site?.siteId}`, {
|
.post(`/site/${site?.siteId}`, {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
dockerSocketEnabled: data.dockerSocketEnabled
|
dockerSocketEnabled: data.dockerSocketEnabled,
|
||||||
|
remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || ''
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
|
@ -81,7 +97,8 @@ export default function GeneralPage() {
|
||||||
|
|
||||||
updateSite({
|
updateSite({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
dockerSocketEnabled: data.dockerSocketEnabled
|
dockerSocketEnabled: data.dockerSocketEnabled,
|
||||||
|
remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || ''
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
@ -124,12 +141,47 @@ export default function GeneralPage() {
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
<FormDescription>
|
|
||||||
{t("siteNameDescription")}
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="remoteSubnets"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("remoteSubnets")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<TagInput
|
||||||
|
{...field}
|
||||||
|
activeTagIndex={activeCidrTagIndex}
|
||||||
|
setActiveTagIndex={setActiveCidrTagIndex}
|
||||||
|
placeholder={t("enterCidrRange")}
|
||||||
|
size="sm"
|
||||||
|
tags={form.getValues().remoteSubnets || []}
|
||||||
|
setTags={(newSubnets) => {
|
||||||
|
form.setValue(
|
||||||
|
"remoteSubnets",
|
||||||
|
newSubnets as Tag[]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
validateTag={(tag) => {
|
||||||
|
// Basic CIDR validation regex
|
||||||
|
const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
|
||||||
|
return cidrRegex.test(tag);
|
||||||
|
}}
|
||||||
|
allowDuplicates={false}
|
||||||
|
sortTags={true}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t("remoteSubnetsDescription")}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{site && site.type === "newt" && (
|
{site && site.type === "newt" && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|
|
@ -42,6 +42,7 @@ import {
|
||||||
FaFreebsd,
|
FaFreebsd,
|
||||||
FaWindows
|
FaWindows
|
||||||
} from "react-icons/fa";
|
} from "react-icons/fa";
|
||||||
|
import { SiNixos } from "react-icons/si";
|
||||||
import { Checkbox } from "@app/components/ui/checkbox";
|
import { Checkbox } from "@app/components/ui/checkbox";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
import { generateKeypair } from "../[niceId]/wireguardConfig";
|
import { generateKeypair } from "../[niceId]/wireguardConfig";
|
||||||
|
@ -74,6 +75,7 @@ type Commands = {
|
||||||
windows: Record<string, string[]>;
|
windows: Record<string, string[]>;
|
||||||
docker: Record<string, string[]>;
|
docker: Record<string, string[]>;
|
||||||
podman: Record<string, string[]>;
|
podman: Record<string, string[]>;
|
||||||
|
nixos: Record<string, string[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const platforms = [
|
const platforms = [
|
||||||
|
@ -82,7 +84,8 @@ const platforms = [
|
||||||
"podman",
|
"podman",
|
||||||
"mac",
|
"mac",
|
||||||
"windows",
|
"windows",
|
||||||
"freebsd"
|
"freebsd",
|
||||||
|
"nixos"
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type Platform = (typeof platforms)[number];
|
type Platform = (typeof platforms)[number];
|
||||||
|
@ -285,6 +288,14 @@ WantedBy=default.target`
|
||||||
"Podman Run": [
|
"Podman Run": [
|
||||||
`podman run -dit docker.io/fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
`podman run -dit docker.io/fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
nixos: {
|
||||||
|
x86_64: [
|
||||||
|
`nix run 'nixpkgs#fosrl-newt' --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||||
|
],
|
||||||
|
aarch64: [
|
||||||
|
`nix run 'nixpkgs#fosrl-newt' --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||||
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
setCommands(commands);
|
setCommands(commands);
|
||||||
|
@ -304,6 +315,8 @@ WantedBy=default.target`
|
||||||
return ["Podman Quadlet", "Podman Run"];
|
return ["Podman Quadlet", "Podman Run"];
|
||||||
case "freebsd":
|
case "freebsd":
|
||||||
return ["amd64", "arm64"];
|
return ["amd64", "arm64"];
|
||||||
|
case "nixos":
|
||||||
|
return ["x86_64", "aarch64"];
|
||||||
default:
|
default:
|
||||||
return ["x64"];
|
return ["x64"];
|
||||||
}
|
}
|
||||||
|
@ -321,6 +334,8 @@ WantedBy=default.target`
|
||||||
return "Podman";
|
return "Podman";
|
||||||
case "freebsd":
|
case "freebsd":
|
||||||
return "FreeBSD";
|
return "FreeBSD";
|
||||||
|
case "nixos":
|
||||||
|
return "NixOS";
|
||||||
default:
|
default:
|
||||||
return "Linux";
|
return "Linux";
|
||||||
}
|
}
|
||||||
|
@ -365,6 +380,8 @@ WantedBy=default.target`
|
||||||
return <FaCubes className="h-4 w-4 mr-2" />;
|
return <FaCubes className="h-4 w-4 mr-2" />;
|
||||||
case "freebsd":
|
case "freebsd":
|
||||||
return <FaFreebsd className="h-4 w-4 mr-2" />;
|
return <FaFreebsd className="h-4 w-4 mr-2" />;
|
||||||
|
case "nixos":
|
||||||
|
return <SiNixos className="h-4 w-4 mr-2" />;
|
||||||
default:
|
default:
|
||||||
return <Terminal className="h-4 w-4 mr-2" />;
|
return <Terminal className="h-4 w-4 mr-2" />;
|
||||||
}
|
}
|
||||||
|
@ -587,11 +604,6 @@ WantedBy=default.target`
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
<FormDescription>
|
|
||||||
{t(
|
|
||||||
"siteNameDescription"
|
|
||||||
)}
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
@ -33,6 +34,7 @@ import Image from "next/image";
|
||||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import BrandingLogo from "@app/components/BrandingLogo";
|
import BrandingLogo from "@app/components/BrandingLogo";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
type SignupFormProps = {
|
type SignupFormProps = {
|
||||||
redirect?: string;
|
redirect?: string;
|
||||||
|
@ -44,7 +46,19 @@ const formSchema = z
|
||||||
.object({
|
.object({
|
||||||
email: z.string().email({ message: "Invalid email address" }),
|
email: z.string().email({ message: "Invalid email address" }),
|
||||||
password: passwordSchema,
|
password: passwordSchema,
|
||||||
confirmPassword: passwordSchema
|
confirmPassword: passwordSchema,
|
||||||
|
agreeToTerms: z.boolean().refine(
|
||||||
|
(val) => {
|
||||||
|
if (build === "saas") {
|
||||||
|
val === true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"You must agree to the terms of service and privacy policy"
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.refine((data) => data.password === data.confirmPassword, {
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
path: ["confirmPassword"],
|
path: ["confirmPassword"],
|
||||||
|
@ -64,13 +78,15 @@ export default function SignupForm({
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [termsAgreedAt, setTermsAgreedAt] = useState<string | null>(null);
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
confirmPassword: ""
|
confirmPassword: "",
|
||||||
|
agreeToTerms: false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -85,7 +101,8 @@ export default function SignupForm({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
inviteId,
|
inviteId,
|
||||||
inviteToken
|
inviteToken,
|
||||||
|
termsAcceptedTimestamp: termsAgreedAt
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
@ -120,14 +137,23 @@ export default function SignupForm({
|
||||||
return t("authCreateAccount");
|
return t("authCreateAccount");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleTermsChange = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
const isoNow = new Date().toISOString();
|
||||||
|
console.log("Terms agreed at:", isoNow);
|
||||||
|
setTermsAgreedAt(isoNow);
|
||||||
|
form.setValue("agreeToTerms", true);
|
||||||
|
} else {
|
||||||
|
form.setValue("agreeToTerms", false);
|
||||||
|
setTermsAgreedAt(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full max-w-md shadow-md">
|
<Card className="w-full max-w-md shadow-md">
|
||||||
<CardHeader className="border-b">
|
<CardHeader className="border-b">
|
||||||
<div className="flex flex-row items-center justify-center">
|
<div className="flex flex-row items-center justify-center">
|
||||||
<BrandingLogo
|
<BrandingLogo height={58} width={175} />
|
||||||
height={58}
|
|
||||||
width={175}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center space-y-1 pt-3">
|
<div className="text-center space-y-1 pt-3">
|
||||||
<p className="text-muted-foreground">{getSubtitle()}</p>
|
<p className="text-muted-foreground">{getSubtitle()}</p>
|
||||||
|
@ -180,6 +206,54 @@ export default function SignupForm({
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{build === "saas" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="agreeToTerms"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
field.onChange(checked);
|
||||||
|
handleTermsChange(
|
||||||
|
checked as boolean
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="leading-none">
|
||||||
|
<FormLabel className="text-sm font-normal">
|
||||||
|
{t("signUpTerms.IAgreeToThe")}
|
||||||
|
<a
|
||||||
|
href="https://digpangolin.com/terms-of-service.html"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"signUpTerms.termsOfService"
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
{t("signUpTerms.and")}
|
||||||
|
<a
|
||||||
|
href="https://digpangolin.com/privacy-policy.html"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"signUpTerms.privacyPolicy"
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</FormLabel>
|
||||||
|
<FormMessage />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
|
|
|
@ -12,15 +12,24 @@ import {
|
||||||
KeyRound,
|
KeyRound,
|
||||||
TicketCheck,
|
TicketCheck,
|
||||||
User,
|
User,
|
||||||
Globe,
|
Globe, // Added from 'dev' branch
|
||||||
MonitorUp
|
MonitorUp // Added from 'dev' branch
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export type SidebarNavSection = {
|
export type SidebarNavSection = { // Added from 'dev' branch
|
||||||
heading: string;
|
heading: string;
|
||||||
items: SidebarNavItem[];
|
items: SidebarNavItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Merged from 'user-management-and-resources' branch
|
||||||
|
export const orgLangingNavItems: SidebarNavItem[] = [
|
||||||
|
{
|
||||||
|
title: "sidebarAccount",
|
||||||
|
href: "/{orgId}",
|
||||||
|
icon: <User className="h-4 w-4" />
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
export const orgNavSections = (
|
export const orgNavSections = (
|
||||||
enableClients: boolean = true
|
enableClients: boolean = true
|
||||||
): SidebarNavSection[] => [
|
): SidebarNavSection[] => [
|
||||||
|
|
|
@ -49,6 +49,7 @@ type DomainOption = {
|
||||||
|
|
||||||
interface DomainPickerProps {
|
interface DomainPickerProps {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
|
cols?: number;
|
||||||
onDomainChange?: (domainInfo: {
|
onDomainChange?: (domainInfo: {
|
||||||
domainId: string;
|
domainId: string;
|
||||||
domainNamespaceId?: string;
|
domainNamespaceId?: string;
|
||||||
|
@ -61,6 +62,7 @@ interface DomainPickerProps {
|
||||||
|
|
||||||
export default function DomainPicker({
|
export default function DomainPicker({
|
||||||
orgId,
|
orgId,
|
||||||
|
cols,
|
||||||
onDomainChange
|
onDomainChange
|
||||||
}: DomainPickerProps) {
|
}: DomainPickerProps) {
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
@ -127,9 +129,6 @@ export default function DomainPicker({
|
||||||
|
|
||||||
if (!userInput.trim()) return options;
|
if (!userInput.trim()) return options;
|
||||||
|
|
||||||
// Check if input is more than one level deep (contains multiple dots)
|
|
||||||
const isMultiLevel = (userInput.match(/\./g) || []).length > 1;
|
|
||||||
|
|
||||||
// Add organization domain options
|
// Add organization domain options
|
||||||
organizationDomains.forEach((orgDomain) => {
|
organizationDomains.forEach((orgDomain) => {
|
||||||
if (orgDomain.type === "cname") {
|
if (orgDomain.type === "cname") {
|
||||||
|
@ -309,6 +308,7 @@ export default function DomainPicker({
|
||||||
<Input
|
<Input
|
||||||
id="domain-input"
|
id="domain-input"
|
||||||
value={userInput}
|
value={userInput}
|
||||||
|
className="max-w-xl"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
// Only allow letters, numbers, hyphens, and periods
|
// Only allow letters, numbers, hyphens, and periods
|
||||||
const validInput = e.target.value.replace(
|
const validInput = e.target.value.replace(
|
||||||
|
@ -316,6 +316,8 @@ export default function DomainPicker({
|
||||||
""
|
""
|
||||||
);
|
);
|
||||||
setUserInput(validInput);
|
setUserInput(validInput);
|
||||||
|
// Clear selection when input changes
|
||||||
|
setSelectedOption(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
@ -382,7 +384,7 @@ export default function DomainPicker({
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{t("domainPickerNoMatchingDomains", { userInput })}
|
{t("domainPickerNoMatchingDomains")}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
@ -393,23 +395,25 @@ export default function DomainPicker({
|
||||||
{/* Organization Domains */}
|
{/* Organization Domains */}
|
||||||
{organizationOptions.length > 0 && (
|
{organizationOptions.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{build !== "oss" && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Building2 className="h-4 w-4" />
|
<Building2 className="h-4 w-4" />
|
||||||
<h4 className="text-sm font-medium">
|
<h4 className="text-sm font-medium">
|
||||||
{t("domainPickerOrganizationDomains")}
|
{t("domainPickerOrganizationDomains")}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
)}
|
||||||
|
<div className={`grid gap-2 ${cols ? `grid-cols-${cols}` : 'grid-cols-1 sm:grid-cols-2'}`}>
|
||||||
{organizationOptions.map((option) => (
|
{organizationOptions.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option.id}
|
key={option.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-all p-3 rounded-lg border",
|
"transition-all p-3 rounded-lg border",
|
||||||
selectedOption?.id === option.id
|
selectedOption?.id === option.id
|
||||||
? "border-primary bg-primary/5"
|
? "border-primary bg-primary/10"
|
||||||
: "border-input",
|
: "border-input hover:bg-accent",
|
||||||
option.verified
|
option.verified
|
||||||
? "cursor-pointer hover:bg-accent"
|
? "cursor-pointer"
|
||||||
: "cursor-not-allowed opacity-60"
|
: "cursor-not-allowed opacity-60"
|
||||||
)}
|
)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
@ -456,10 +460,6 @@ export default function DomainPicker({
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{selectedOption?.id ===
|
|
||||||
option.id && (
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-primary" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
@ -476,14 +476,14 @@ export default function DomainPicker({
|
||||||
{t("domainPickerProvidedDomains")}
|
{t("domainPickerProvidedDomains")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className={`grid gap-2 ${cols ? `grid-cols-${cols}` : 'grid-cols-1 sm:grid-cols-2'}`}>
|
||||||
{providedOptions.map((option) => (
|
{providedOptions.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option.id}
|
key={option.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-all p-3 rounded-lg border",
|
"transition-all p-3 rounded-lg border",
|
||||||
selectedOption?.id === option.id
|
selectedOption?.id === option.id
|
||||||
? "border-primary bg-primary/5"
|
? "border-primary bg-primary/10"
|
||||||
: "border-input",
|
: "border-input",
|
||||||
"cursor-pointer hover:bg-accent"
|
"cursor-pointer hover:bg-accent"
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -63,7 +63,6 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [securityKeyLoading, setSecurityKeyLoading] = useState(false);
|
|
||||||
const hasIdp = idps && idps.length > 0;
|
const hasIdp = idps && idps.length > 0;
|
||||||
|
|
||||||
const [mfaRequested, setMfaRequested] = useState(false);
|
const [mfaRequested, setMfaRequested] = useState(false);
|
||||||
|
@ -72,14 +71,12 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
email: z.string().email({ message: t('emailInvalid') }),
|
email: z.string().email({ message: t("emailInvalid") }),
|
||||||
password: z
|
password: z.string().min(8, { message: t("passwordRequirementsChars") })
|
||||||
.string()
|
|
||||||
.min(8, { message: t('passwordRequirementsChars') })
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const mfaSchema = z.object({
|
const mfaSchema = z.object({
|
||||||
code: z.string().length(6, { message: t('pincodeInvalid') })
|
code: z.string().length(6, { message: t("pincodeInvalid") })
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
@ -99,17 +96,23 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
|
|
||||||
async function initiateSecurityKeyAuth() {
|
async function initiateSecurityKeyAuth() {
|
||||||
setShowSecurityKeyPrompt(true);
|
setShowSecurityKeyPrompt(true);
|
||||||
setSecurityKeyLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Start WebAuthn authentication without email
|
// Start WebAuthn authentication without email
|
||||||
const startRes = await api.post("/auth/security-key/authenticate/start", {});
|
const startRes = await api.post(
|
||||||
|
"/auth/security-key/authenticate/start",
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
if (!startRes) {
|
if (!startRes) {
|
||||||
setError(t('securityKeyAuthError', {
|
setError(
|
||||||
defaultValue: "Failed to start security key authentication"
|
t("securityKeyAuthError", {
|
||||||
}));
|
defaultValue:
|
||||||
|
"Failed to start security key authentication"
|
||||||
|
})
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -125,7 +128,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
{ credential },
|
{ credential },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'X-Temp-Session-Id': tempSessionId
|
"X-Temp-Session-Id": tempSessionId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -136,39 +139,61 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.name === 'NotAllowedError') {
|
if (error.name === "NotAllowedError") {
|
||||||
if (error.message.includes('denied permission')) {
|
if (error.message.includes("denied permission")) {
|
||||||
setError(t('securityKeyPermissionDenied', {
|
setError(
|
||||||
defaultValue: "Please allow access to your security key to continue signing in."
|
t("securityKeyPermissionDenied", {
|
||||||
}));
|
defaultValue:
|
||||||
|
"Please allow access to your security key to continue signing in."
|
||||||
|
})
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setError(t('securityKeyRemovedTooQuickly', {
|
setError(
|
||||||
defaultValue: "Please keep your security key connected until the sign-in process completes."
|
t("securityKeyRemovedTooQuickly", {
|
||||||
}));
|
defaultValue:
|
||||||
|
"Please keep your security key connected until the sign-in process completes."
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (error.name === 'NotSupportedError') {
|
} else if (error.name === "NotSupportedError") {
|
||||||
setError(t('securityKeyNotSupported', {
|
setError(
|
||||||
defaultValue: "Your security key may not be compatible. Please try a different security key."
|
t("securityKeyNotSupported", {
|
||||||
}));
|
defaultValue:
|
||||||
|
"Your security key may not be compatible. Please try a different security key."
|
||||||
|
})
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setError(t('securityKeyUnknownError', {
|
setError(
|
||||||
defaultValue: "There was a problem using your security key. Please try again."
|
t("securityKeyUnknownError", {
|
||||||
}));
|
defaultValue:
|
||||||
|
"There was a problem using your security key. Please try again."
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.isAxiosError) {
|
if (e.isAxiosError) {
|
||||||
setError(formatAxiosError(e, t('securityKeyAuthError', {
|
setError(
|
||||||
defaultValue: "Failed to authenticate with security key"
|
formatAxiosError(
|
||||||
})));
|
e,
|
||||||
|
t("securityKeyAuthError", {
|
||||||
|
defaultValue:
|
||||||
|
"Failed to authenticate with security key"
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setError(e.message || t('securityKeyAuthError', {
|
setError(
|
||||||
defaultValue: "Failed to authenticate with security key"
|
e.message ||
|
||||||
}));
|
t("securityKeyAuthError", {
|
||||||
|
defaultValue:
|
||||||
|
"Failed to authenticate with security key"
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSecurityKeyLoading(false);
|
setLoading(false);
|
||||||
setShowSecurityKeyPrompt(false);
|
setShowSecurityKeyPrompt(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -182,11 +207,14 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
setShowSecurityKeyPrompt(false);
|
setShowSecurityKeyPrompt(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.post<AxiosResponse<LoginResponse>>("/auth/login", {
|
const res = await api.post<AxiosResponse<LoginResponse>>(
|
||||||
|
"/auth/login",
|
||||||
|
{
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
code
|
code
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const data = res.data.data;
|
const data = res.data.data;
|
||||||
|
|
||||||
|
@ -212,7 +240,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data?.twoFactorSetupRequired) {
|
if (data?.twoFactorSetupRequired) {
|
||||||
const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ''}`;
|
const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ""}`;
|
||||||
router.push(setupUrl);
|
router.push(setupUrl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -222,16 +250,22 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.isAxiosError) {
|
if (e.isAxiosError) {
|
||||||
const errorMessage = formatAxiosError(e, t('loginError', {
|
const errorMessage = formatAxiosError(
|
||||||
|
e,
|
||||||
|
t("loginError", {
|
||||||
defaultValue: "Failed to log in"
|
defaultValue: "Failed to log in"
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setError(e.message || t('loginError', {
|
setError(
|
||||||
|
e.message ||
|
||||||
|
t("loginError", {
|
||||||
defaultValue: "Failed to log in"
|
defaultValue: "Failed to log in"
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -251,7 +285,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
console.log(res);
|
console.log(res);
|
||||||
|
|
||||||
if (!res) {
|
if (!res) {
|
||||||
setError(t('loginError'));
|
setError(t("loginError"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -268,8 +302,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
<Alert>
|
<Alert>
|
||||||
<FingerprintIcon className="w-5 h-5 mr-2" />
|
<FingerprintIcon className="w-5 h-5 mr-2" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{t('securityKeyPrompt', {
|
{t("securityKeyPrompt", {
|
||||||
defaultValue: "Please verify your identity using your security key. Make sure your security key is connected and ready."
|
defaultValue:
|
||||||
|
"Please verify your identity using your security key. Make sure your security key is connected and ready."
|
||||||
})}
|
})}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
@ -288,7 +323,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
name="email"
|
name="email"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('email')}</FormLabel>
|
<FormLabel>{t("email")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
@ -303,7 +338,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
name="password"
|
name="password"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('password')}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("password")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
|
@ -320,18 +357,18 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
|
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
|
||||||
className="text-sm text-muted-foreground"
|
className="text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
{t('passwordForgot')}
|
{t("passwordForgot")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col space-y-2">
|
<div className="flex flex-col space-y-2">
|
||||||
<Button type="submit" disabled={loading}>
|
<Button
|
||||||
{loading ? t('idpConnectingToProcess', {
|
type="submit"
|
||||||
defaultValue: "Connecting..."
|
disabled={loading}
|
||||||
}) : t('login', {
|
loading={loading}
|
||||||
defaultValue: "Log in"
|
>
|
||||||
})}
|
{t("login")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -342,11 +379,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
{mfaRequested && (
|
{mfaRequested && (
|
||||||
<>
|
<>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-medium">
|
<h3 className="text-lg font-medium">{t("otpAuth")}</h3>
|
||||||
{t('otpAuth')}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t('otpAuthDescription')}
|
{t("otpAuthDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Form {...mfaForm}>
|
<Form {...mfaForm}>
|
||||||
|
@ -368,10 +403,16 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
pattern={
|
pattern={
|
||||||
REGEXP_ONLY_DIGITS_AND_CHARS
|
REGEXP_ONLY_DIGITS_AND_CHARS
|
||||||
}
|
}
|
||||||
onChange={(value: string) => {
|
onChange={(
|
||||||
|
value: string
|
||||||
|
) => {
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
if (value.length === 6) {
|
if (
|
||||||
mfaForm.handleSubmit(onSubmit)();
|
value.length === 6
|
||||||
|
) {
|
||||||
|
mfaForm.handleSubmit(
|
||||||
|
onSubmit
|
||||||
|
)();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -422,7 +463,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{t('otpAuthSubmit')}
|
{t("otpAuthSubmit")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -433,11 +474,11 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={initiateSecurityKeyAuth}
|
onClick={initiateSecurityKeyAuth}
|
||||||
loading={securityKeyLoading}
|
loading={loading}
|
||||||
disabled={securityKeyLoading || showSecurityKeyPrompt}
|
disabled={loading || showSecurityKeyPrompt}
|
||||||
>
|
>
|
||||||
<FingerprintIcon className="w-4 h-4 mr-2" />
|
<FingerprintIcon className="w-4 h-4 mr-2" />
|
||||||
{t('securityKeyLogin', {
|
{t("securityKeyLogin", {
|
||||||
defaultValue: "Sign in with security key"
|
defaultValue: "Sign in with security key"
|
||||||
})}
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -450,7 +491,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
<span className="px-2 bg-card text-muted-foreground">
|
<span className="px-2 bg-card text-muted-foreground">
|
||||||
{t('idpContinue')}
|
{t("idpContinue")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -483,7 +524,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
mfaForm.reset();
|
mfaForm.reset();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('otpAuthBack')}
|
{t("otpAuthBack")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -11,11 +11,12 @@ import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
interface InfoPopupProps {
|
interface InfoPopupProps {
|
||||||
text?: string;
|
text?: string;
|
||||||
info: string;
|
info?: string;
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InfoPopup({ text, info, trigger }: InfoPopupProps) {
|
export function InfoPopup({ text, info, trigger, children }: InfoPopupProps) {
|
||||||
const defaultTrigger = (
|
const defaultTrigger = (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
@ -35,7 +36,12 @@ export function InfoPopup({ text, info, trigger }: InfoPopupProps) {
|
||||||
{trigger ?? defaultTrigger}
|
{trigger ?? defaultTrigger}
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-80">
|
<PopoverContent className="w-80">
|
||||||
<p className="text-sm text-muted-foreground">{info}</p>
|
{children ||
|
||||||
|
(info && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{info}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
|
||||||
<ToastPrimitives.Viewport
|
<ToastPrimitives.Viewport
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed bottom-0 left-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 md:max-w-[420px]",
|
"fixed bottom-0 left-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 md:top-0 md:bottom-auto md:left-1/2 md:-translate-x-1/2 md:max-w-[420px]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue