mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-23 13:26:41 +00:00
Compare commits
53 Commits
1.0.0-beta
...
1.0.0-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8445e83c7c | ||
|
|
89a59b25fc | ||
|
|
57a37a01ce | ||
|
|
f8add1f098 | ||
|
|
0bd0cc76fb | ||
|
|
06e4fbac68 | ||
|
|
e82df67063 | ||
|
|
84f94bb727 | ||
|
|
20f1a6372b | ||
|
|
06c434a5ea | ||
|
|
b83dadb14b | ||
|
|
492e53edf3 | ||
|
|
3d9557b65c | ||
|
|
332804ed71 | ||
|
|
de70c62ea8 | ||
|
|
e4789c6b08 | ||
|
|
ec9d02a735 | ||
|
|
ae73a2f3f4 | ||
|
|
d8183bfd0d | ||
|
|
e11748fe30 | ||
|
|
ccbe56e110 | ||
|
|
ff37e07ce6 | ||
|
|
f59f0ee57d | ||
|
|
372932985d | ||
|
|
c877bb1187 | ||
|
|
5f95500b6f | ||
|
|
3194dc56eb | ||
|
|
e49fb646b0 | ||
|
|
fd11fb81d6 | ||
|
|
82f990eb8b | ||
|
|
851bedb2e5 | ||
|
|
e6c42e9610 | ||
|
|
d3d523b2b8 | ||
|
|
532d3696c2 | ||
|
|
dabd4a055c | ||
|
|
7bf820a4bf | ||
|
|
b862e1aeef | ||
|
|
bdee036ab4 | ||
|
|
62238948e0 | ||
|
|
489f6bed17 | ||
|
|
6aa4908446 | ||
|
|
d5a220a004 | ||
|
|
a418195b28 | ||
|
|
2ff6d1d117 | ||
|
|
8dd30c88ab | ||
|
|
7797c6c770 | ||
|
|
40922fedb8 | ||
|
|
4c1366ef91 | ||
|
|
f61d442989 | ||
|
|
60449afca5 | ||
|
|
81c4199e87 | ||
|
|
a7b8ffaf9f | ||
|
|
d22c7826fe |
2
.github/workflows/cicd.yml
vendored
2
.github/workflows/cicd.yml
vendored
@@ -64,7 +64,7 @@ jobs:
|
|||||||
- name: Build installer
|
- name: Build installer
|
||||||
working-directory: install
|
working-directory: install
|
||||||
run: |
|
run: |
|
||||||
make release
|
make go-build-release
|
||||||
|
|
||||||
- name: Upload artifacts from /install/bin
|
- name: Upload artifacts from /install/bin
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -9,6 +9,13 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 align="center">Tunneled Mesh Reverse Proxy Server with Access Control</h3>
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
_Your own self-hosted zero trust tunnel._
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<h5>
|
<h5>
|
||||||
<a href="https://docs.fossorial.io/Getting%20Started/quick-install">
|
<a href="https://docs.fossorial.io/Getting%20Started/quick-install">
|
||||||
@@ -21,13 +28,6 @@
|
|||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 align="center">Tunneled Mesh Reverse Proxy Server with Access Control</h3>
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
_Your own self-hosted zero trust tunnel._
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
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/sites.png" alt="Preview"/>
|
<img src="public/screenshots/sites.png" alt="Preview"/>
|
||||||
@@ -108,7 +108,11 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
|
|||||||
|
|
||||||
1. **Deploy the Central Server**:
|
1. **Deploy the Central Server**:
|
||||||
|
|
||||||
- Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs.
|
- Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like RackNerd, Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs.
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Many of our users have had a great experience with [RackNerd](https://my.racknerd.com/aff.php?aff=13788). Depending on promotions, you can likely get a **VPS with 1 vCPU, 1GB RAM, and ~20GB SSD for just around $12/year**. That's a great deal!
|
||||||
|
> We are part of the [RackNerd](https://my.racknerd.com/aff.php?aff=13788) affiliate program, so if you sign up using [our link](https://my.racknerd.com/aff.php?aff=13788), we receive a small commission which helps us maintain the project and keep it free for everyone.
|
||||||
|
|
||||||
2. **Domain Configuration**:
|
2. **Domain Configuration**:
|
||||||
|
|
||||||
@@ -147,7 +151,7 @@ View the [project board](https://github.com/orgs/fosrl/projects/1) for more deta
|
|||||||
|
|
||||||
## Licensing
|
## Licensing
|
||||||
|
|
||||||
Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us.
|
Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io).
|
||||||
|
|
||||||
## Contributions
|
## Contributions
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
app:
|
app:
|
||||||
dashboard_url: "http://localhost:3002"
|
dashboard_url: "http://localhost:3002"
|
||||||
base_domain: "localhost"
|
|
||||||
log_level: "info"
|
log_level: "info"
|
||||||
save_logs: false
|
save_logs: false
|
||||||
|
|
||||||
|
domains:
|
||||||
|
domain1:
|
||||||
|
base_domain: "example.com"
|
||||||
|
cert_resolver: "letsencrypt"
|
||||||
|
|
||||||
server:
|
server:
|
||||||
external_port: 3000
|
external_port: 3000
|
||||||
internal_port: 3001
|
internal_port: 3001
|
||||||
@@ -14,7 +18,6 @@ server:
|
|||||||
resource_session_request_param: "p_session_request"
|
resource_session_request_param: "p_session_request"
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
cert_resolver: "letsencrypt"
|
|
||||||
http_entrypoint: "web"
|
http_entrypoint: "web"
|
||||||
https_entrypoint: "websecure"
|
https_entrypoint: "websecure"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
version: "3.7"
|
name: pangolin
|
||||||
|
|
||||||
services:
|
services:
|
||||||
pangolin:
|
pangolin:
|
||||||
image: fosrl/pangolin:latest
|
image: fosrl/pangolin:latest
|
||||||
@@ -32,7 +31,6 @@ services:
|
|||||||
- SYS_MODULE
|
- SYS_MODULE
|
||||||
ports:
|
ports:
|
||||||
- 51820:51820/udp
|
- 51820:51820/udp
|
||||||
- 8080:8080 # Port for traefik because of the network_mode
|
|
||||||
- 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
|
||||||
|
|
||||||
@@ -47,8 +45,8 @@ services:
|
|||||||
command:
|
command:
|
||||||
- --configFile=/etc/traefik/traefik_config.yml
|
- --configFile=/etc/traefik/traefik_config.yml
|
||||||
volumes:
|
volumes:
|
||||||
- ./traefik:/etc/traefik:ro # Volume to store the Traefik configuration
|
- ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
|
||||||
- ./letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
all: build
|
all: update-versions go-build-release put-back
|
||||||
|
|
||||||
build:
|
go-build-release:
|
||||||
CGO_ENABLED=0 go build -o bin/installer
|
|
||||||
|
|
||||||
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
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f bin/installer
|
|
||||||
rm -f bin/installer_linux_amd64
|
rm -f bin/installer_linux_amd64
|
||||||
rm -f bin/installer_linux_arm64
|
rm -f bin/installer_linux_arm64
|
||||||
|
|
||||||
|
update-versions:
|
||||||
|
@echo "Fetching latest versions..."
|
||||||
|
cp main.go main.go.bak && \
|
||||||
|
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') && \
|
||||||
|
BADGER_VERSION=$$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') && \
|
||||||
|
echo "Latest versions - Pangolin: $$PANGOLIN_VERSION, Gerbil: $$GERBIL_VERSION, Badger: $$BADGER_VERSION" && \
|
||||||
|
sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$$PANGOLIN_VERSION\"/" main.go && \
|
||||||
|
sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$$GERBIL_VERSION\"/" main.go && \
|
||||||
|
sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$$BADGER_VERSION\"/" main.go && \
|
||||||
|
echo "Updated main.go with latest versions"
|
||||||
|
|
||||||
|
put-back:
|
||||||
|
mv main.go.bak main.go
|
||||||
353
install/config.go
Normal file
353
install/config.go
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TraefikConfig represents the structure of the main Traefik configuration
|
||||||
|
type TraefikConfig struct {
|
||||||
|
Experimental struct {
|
||||||
|
Plugins struct {
|
||||||
|
Badger struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
} `yaml:"badger"`
|
||||||
|
} `yaml:"plugins"`
|
||||||
|
} `yaml:"experimental"`
|
||||||
|
CertificatesResolvers struct {
|
||||||
|
LetsEncrypt struct {
|
||||||
|
Acme struct {
|
||||||
|
Email string `yaml:"email"`
|
||||||
|
} `yaml:"acme"`
|
||||||
|
} `yaml:"letsencrypt"`
|
||||||
|
} `yaml:"certificatesResolvers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DynamicConfig represents the structure of the dynamic configuration
|
||||||
|
type DynamicConfig struct {
|
||||||
|
HTTP struct {
|
||||||
|
Routers map[string]struct {
|
||||||
|
Rule string `yaml:"rule"`
|
||||||
|
} `yaml:"routers"`
|
||||||
|
} `yaml:"http"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigValues holds the extracted configuration values
|
||||||
|
type ConfigValues struct {
|
||||||
|
DashboardDomain string
|
||||||
|
LetsEncryptEmail string
|
||||||
|
BadgerVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadTraefikConfig reads and extracts values from Traefik configuration files
|
||||||
|
func ReadTraefikConfig(mainConfigPath, dynamicConfigPath string) (*ConfigValues, error) {
|
||||||
|
// Read main config file
|
||||||
|
mainConfigData, err := os.ReadFile(mainConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error reading main config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mainConfig TraefikConfig
|
||||||
|
if err := yaml.Unmarshal(mainConfigData, &mainConfig); err != nil {
|
||||||
|
return nil, fmt.Errorf("error parsing main config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read dynamic config file
|
||||||
|
dynamicConfigData, err := os.ReadFile(dynamicConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error reading dynamic config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dynamicConfig DynamicConfig
|
||||||
|
if err := yaml.Unmarshal(dynamicConfigData, &dynamicConfig); err != nil {
|
||||||
|
return nil, fmt.Errorf("error parsing dynamic config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract values
|
||||||
|
values := &ConfigValues{
|
||||||
|
BadgerVersion: mainConfig.Experimental.Plugins.Badger.Version,
|
||||||
|
LetsEncryptEmail: mainConfig.CertificatesResolvers.LetsEncrypt.Acme.Email,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract DashboardDomain from router rules
|
||||||
|
// Look for it in the main router rules
|
||||||
|
for _, router := range dynamicConfig.HTTP.Routers {
|
||||||
|
if router.Rule != "" {
|
||||||
|
// Extract domain from Host(`mydomain.com`)
|
||||||
|
if domain := extractDomainFromRule(router.Rule); domain != "" {
|
||||||
|
values.DashboardDomain = domain
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractDomainFromRule extracts the domain from a router rule
|
||||||
|
func extractDomainFromRule(rule string) string {
|
||||||
|
// Look for the Host(`mydomain.com`) pattern
|
||||||
|
if start := findPattern(rule, "Host(`"); start != -1 {
|
||||||
|
end := findPattern(rule[start:], "`)")
|
||||||
|
if end != -1 {
|
||||||
|
return rule[start+6 : start+end]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// findPattern finds the start of a pattern in a string
|
||||||
|
func findPattern(s, pattern string) int {
|
||||||
|
return bytes.Index([]byte(s), []byte(pattern))
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyDockerService(sourceFile, destFile, serviceName string) error {
|
||||||
|
// Read source file
|
||||||
|
sourceData, err := os.ReadFile(sourceFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading source file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read destination file
|
||||||
|
destData, err := os.ReadFile(destFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading destination file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse source Docker Compose YAML
|
||||||
|
var sourceCompose map[string]interface{}
|
||||||
|
if err := yaml.Unmarshal(sourceData, &sourceCompose); err != nil {
|
||||||
|
return fmt.Errorf("error parsing source Docker Compose file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse destination Docker Compose YAML
|
||||||
|
var destCompose map[string]interface{}
|
||||||
|
if err := yaml.Unmarshal(destData, &destCompose); err != nil {
|
||||||
|
return fmt.Errorf("error parsing destination Docker Compose file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get services section from source
|
||||||
|
sourceServices, ok := sourceCompose["services"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("services section not found in source file or has invalid format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the specific service configuration
|
||||||
|
serviceConfig, ok := sourceServices[serviceName]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("service '%s' not found in source file", serviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create services section in destination
|
||||||
|
destServices, ok := destCompose["services"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
// If services section doesn't exist, create it
|
||||||
|
destServices = make(map[string]interface{})
|
||||||
|
destCompose["services"] = destServices
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update service in destination
|
||||||
|
destServices[serviceName] = serviceConfig
|
||||||
|
|
||||||
|
// Marshal updated destination YAML
|
||||||
|
// Use yaml.v3 encoder to preserve formatting and comments
|
||||||
|
// updatedData, err := yaml.Marshal(destCompose)
|
||||||
|
updatedData, err := MarshalYAMLWithIndent(destCompose, 2)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error marshaling updated Docker Compose file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write updated YAML back to destination file
|
||||||
|
if err := os.WriteFile(destFile, updatedData, 0644); err != nil {
|
||||||
|
return fmt.Errorf("error writing to destination file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func backupConfig() error {
|
||||||
|
// Backup docker-compose.yml
|
||||||
|
if _, err := os.Stat("docker-compose.yml"); err == nil {
|
||||||
|
if err := copyFile("docker-compose.yml", "docker-compose.yml.backup"); err != nil {
|
||||||
|
return fmt.Errorf("failed to backup docker-compose.yml: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup config directory
|
||||||
|
if _, err := os.Stat("config"); err == nil {
|
||||||
|
cmd := exec.Command("tar", "-czvf", "config.tar.gz", "config")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("failed to backup config directory: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarshalYAMLWithIndent(data interface{}, indent int) ([]byte, error) {
|
||||||
|
buffer := new(bytes.Buffer)
|
||||||
|
encoder := yaml.NewEncoder(buffer)
|
||||||
|
encoder.SetIndent(indent)
|
||||||
|
|
||||||
|
err := encoder.Encode(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer encoder.Close()
|
||||||
|
return buffer.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceInFile(filepath, oldStr, newStr string) error {
|
||||||
|
// Read the file content
|
||||||
|
content, err := os.ReadFile(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the string
|
||||||
|
newContent := strings.Replace(string(content), oldStr, newStr, -1)
|
||||||
|
|
||||||
|
// Write the modified content back to the file
|
||||||
|
err = os.WriteFile(filepath, []byte(newContent), 0644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error writing file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckAndAddTraefikLogVolume(composePath string) error {
|
||||||
|
// Read the docker-compose.yml file
|
||||||
|
data, err := os.ReadFile(composePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading compose file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse YAML into a generic map
|
||||||
|
var compose map[string]interface{}
|
||||||
|
if err := yaml.Unmarshal(data, &compose); err != nil {
|
||||||
|
return fmt.Errorf("error parsing compose file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get services section
|
||||||
|
services, ok := compose["services"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("services section not found or invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get traefik service
|
||||||
|
traefik, ok := services["traefik"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("traefik service not found or invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check volumes
|
||||||
|
logVolume := "./config/traefik/logs:/var/log/traefik"
|
||||||
|
var volumes []interface{}
|
||||||
|
|
||||||
|
if existingVolumes, ok := traefik["volumes"].([]interface{}); ok {
|
||||||
|
// Check if volume already exists
|
||||||
|
for _, v := range existingVolumes {
|
||||||
|
if v.(string) == logVolume {
|
||||||
|
fmt.Println("Traefik log volume is already configured")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
volumes = existingVolumes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new volume
|
||||||
|
volumes = append(volumes, logVolume)
|
||||||
|
traefik["volumes"] = volumes
|
||||||
|
|
||||||
|
// Write updated config back to file
|
||||||
|
newData, err := MarshalYAMLWithIndent(compose, 2)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error marshaling updated compose file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(composePath, newData, 0644); err != nil {
|
||||||
|
return fmt.Errorf("error writing updated compose file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Added traefik log volume and created logs directory")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeYAML merges two YAML files, where the contents of the second file
|
||||||
|
// are merged into the first file. In case of conflicts, values from the
|
||||||
|
// second file take precedence.
|
||||||
|
func MergeYAML(baseFile, overlayFile string) error {
|
||||||
|
// Read the base YAML file
|
||||||
|
baseContent, err := os.ReadFile(baseFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading base file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the overlay YAML file
|
||||||
|
overlayContent, err := os.ReadFile(overlayFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading overlay file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse base YAML into a map
|
||||||
|
var baseMap map[string]interface{}
|
||||||
|
if err := yaml.Unmarshal(baseContent, &baseMap); err != nil {
|
||||||
|
return fmt.Errorf("error parsing base YAML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse overlay YAML into a map
|
||||||
|
var overlayMap map[string]interface{}
|
||||||
|
if err := yaml.Unmarshal(overlayContent, &overlayMap); err != nil {
|
||||||
|
return fmt.Errorf("error parsing overlay YAML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge the overlay into the base
|
||||||
|
merged := mergeMap(baseMap, overlayMap)
|
||||||
|
|
||||||
|
// Marshal the merged result back to YAML
|
||||||
|
mergedContent, err := MarshalYAMLWithIndent(merged, 2)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error marshaling merged YAML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the merged content back to the base file
|
||||||
|
if err := os.WriteFile(baseFile, mergedContent, 0644); err != nil {
|
||||||
|
return fmt.Errorf("error writing merged YAML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeMap recursively merges two maps
|
||||||
|
func mergeMap(base, overlay map[string]interface{}) map[string]interface{} {
|
||||||
|
result := make(map[string]interface{})
|
||||||
|
|
||||||
|
// Copy all key-values from base map
|
||||||
|
for k, v := range base {
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge overlay values
|
||||||
|
for k, v := range overlay {
|
||||||
|
// If both maps have the same key and both values are maps, merge recursively
|
||||||
|
if baseVal, ok := base[k]; ok {
|
||||||
|
if baseMap, isBaseMap := baseVal.(map[string]interface{}); isBaseMap {
|
||||||
|
if overlayMap, isOverlayMap := v.(map[string]interface{}); isOverlayMap {
|
||||||
|
result[k] = mergeMap(baseMap, overlayMap)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise, overlay value takes precedence
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
app:
|
app:
|
||||||
dashboard_url: "https://{{.DashboardDomain}}"
|
dashboard_url: "https://{{.DashboardDomain}}"
|
||||||
base_domain: "{{.BaseDomain}}"
|
|
||||||
log_level: "info"
|
log_level: "info"
|
||||||
save_logs: false
|
save_logs: false
|
||||||
|
|
||||||
|
domains:
|
||||||
|
domain1:
|
||||||
|
base_domain: "{{.BaseDomain}}"
|
||||||
|
cert_resolver: "letsencrypt"
|
||||||
|
|
||||||
server:
|
server:
|
||||||
external_port: 3000
|
external_port: 3000
|
||||||
internal_port: 3001
|
internal_port: 3001
|
||||||
18
install/config/crowdsec/acquis.yaml
Normal file
18
install/config/crowdsec/acquis.yaml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
filenames:
|
||||||
|
- /var/log/auth.log
|
||||||
|
- /var/log/syslog
|
||||||
|
labels:
|
||||||
|
type: syslog
|
||||||
|
---
|
||||||
|
poll_without_inotify: false
|
||||||
|
filenames:
|
||||||
|
- /var/log/traefik/*.log
|
||||||
|
labels:
|
||||||
|
type: traefik
|
||||||
|
---
|
||||||
|
listen_addr: 0.0.0.0:7422
|
||||||
|
appsec_config: crowdsecurity/appsec-default
|
||||||
|
name: myAppSecComponent
|
||||||
|
source: appsec
|
||||||
|
labels:
|
||||||
|
type: appsec
|
||||||
35
install/config/crowdsec/docker-compose.yml
Normal file
35
install/config/crowdsec/docker-compose.yml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
services:
|
||||||
|
crowdsec:
|
||||||
|
image: crowdsecurity/crowdsec:latest
|
||||||
|
container_name: crowdsec
|
||||||
|
environment:
|
||||||
|
GID: "1000"
|
||||||
|
COLLECTIONS: crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
|
||||||
|
ENROLL_INSTANCE_NAME: "pangolin-crowdsec"
|
||||||
|
PARSERS: crowdsecurity/whitelists
|
||||||
|
ACQUIRE_FILES: "/var/log/traefik/*.log"
|
||||||
|
ENROLL_TAGS: docker
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "cscli", "capi", "status"]
|
||||||
|
depends_on:
|
||||||
|
- gerbil # Wait for gerbil to be healthy
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=false" # Disable traefik for crowdsec
|
||||||
|
volumes:
|
||||||
|
# crowdsec container data
|
||||||
|
- ./config/crowdsec:/etc/crowdsec # crowdsec config
|
||||||
|
- ./config/crowdsec/db:/var/lib/crowdsec/data # crowdsec db
|
||||||
|
# log bind mounts into crowdsec
|
||||||
|
- ./config/crowdsec_logs/auth.log:/var/log/auth.log:ro # auth.log
|
||||||
|
- ./config/crowdsec_logs/syslog:/var/log/syslog:ro # syslog
|
||||||
|
- ./config/crowdsec_logs:/var/log # crowdsec logs
|
||||||
|
- ./config/traefik/logs:/var/log/traefik # traefik logs
|
||||||
|
ports:
|
||||||
|
- 9090:9090 # port mapping for local firewall bouncers
|
||||||
|
- 6060:6060 # metrics endpoint for prometheus
|
||||||
|
expose:
|
||||||
|
- 9090 # http api for bouncers
|
||||||
|
- 6060 # metrics endpoint for prometheus
|
||||||
|
- 7422 # appsec waf endpoint
|
||||||
|
restart: unless-stopped
|
||||||
|
command: -t # Add test config flag to verify configuration
|
||||||
108
install/config/crowdsec/dynamic_config.yml
Normal file
108
install/config/crowdsec/dynamic_config.yml
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
http:
|
||||||
|
middlewares:
|
||||||
|
redirect-to-https:
|
||||||
|
redirectScheme:
|
||||||
|
scheme: https
|
||||||
|
default-whitelist: # Whitelist middleware for internal IPs
|
||||||
|
ipWhiteList: # Internal IP addresses
|
||||||
|
sourceRange: # Internal IP addresses
|
||||||
|
- "10.0.0.0/8" # Internal IP addresses
|
||||||
|
- "192.168.0.0/16" # Internal IP addresses
|
||||||
|
- "172.16.0.0/12" # Internal IP addresses
|
||||||
|
# Basic security headers
|
||||||
|
security-headers:
|
||||||
|
headers:
|
||||||
|
customResponseHeaders: # Custom response headers
|
||||||
|
Server: "" # Remove server header
|
||||||
|
X-Powered-By: "" # Remove powered by header
|
||||||
|
X-Forwarded-Proto: "https" # Set forwarded proto to https
|
||||||
|
sslProxyHeaders: # SSL proxy headers
|
||||||
|
X-Forwarded-Proto: "https" # Set forwarded proto to https
|
||||||
|
hostsProxyHeaders: # Hosts proxy headers
|
||||||
|
- "X-Forwarded-Host" # Set forwarded host
|
||||||
|
contentTypeNosniff: true # Prevent MIME sniffing
|
||||||
|
customFrameOptionsValue: "SAMEORIGIN" # Set frame options
|
||||||
|
referrerPolicy: "strict-origin-when-cross-origin" # Set referrer policy
|
||||||
|
forceSTSHeader: true # Force STS header
|
||||||
|
stsIncludeSubdomains: true # Include subdomains
|
||||||
|
stsSeconds: 63072000 # STS seconds
|
||||||
|
stsPreload: true # Preload STS
|
||||||
|
# CrowdSec configuration with proper IP forwarding
|
||||||
|
crowdsec:
|
||||||
|
plugin:
|
||||||
|
crowdsec:
|
||||||
|
enabled: true # Enable CrowdSec plugin
|
||||||
|
logLevel: INFO # Log level
|
||||||
|
updateIntervalSeconds: 15 # Update interval
|
||||||
|
updateMaxFailure: 0 # Update max failure
|
||||||
|
defaultDecisionSeconds: 15 # Default decision seconds
|
||||||
|
httpTimeoutSeconds: 10 # HTTP timeout
|
||||||
|
crowdsecMode: live # CrowdSec mode
|
||||||
|
crowdsecAppsecEnabled: true # Enable AppSec
|
||||||
|
crowdsecAppsecHost: crowdsec:7422 # CrowdSec IP address which you noted down later
|
||||||
|
crowdsecAppsecFailureBlock: true # Block on failure
|
||||||
|
crowdsecAppsecUnreachableBlock: true # Block on unreachable
|
||||||
|
crowdsecLapiKey: "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK" # CrowdSec API key which you noted down later
|
||||||
|
crowdsecLapiHost: crowdsec:8080 # CrowdSec
|
||||||
|
crowdsecLapiScheme: http # CrowdSec API scheme
|
||||||
|
forwardedHeadersTrustedIPs: # Forwarded headers trusted IPs
|
||||||
|
- "0.0.0.0/0" # All IP addresses are trusted for forwarded headers (CHANGE MADE HERE)
|
||||||
|
clientTrustedIPs: # Client trusted IPs (CHANGE MADE HERE)
|
||||||
|
- "10.0.0.0/8" # Internal LAN IP addresses
|
||||||
|
- "172.16.0.0/12" # Internal LAN IP addresses
|
||||||
|
- "192.168.0.0/16" # Internal LAN IP addresses
|
||||||
|
- "100.89.137.0/20" # Internal LAN IP addresses
|
||||||
|
|
||||||
|
routers:
|
||||||
|
# HTTP to HTTPS redirect router
|
||||||
|
main-app-router-redirect:
|
||||||
|
rule: "Host(`{{.DashboardDomain}}`)" # Dynamic Domain Name
|
||||||
|
service: next-service
|
||||||
|
entryPoints:
|
||||||
|
- web
|
||||||
|
middlewares:
|
||||||
|
- redirect-to-https
|
||||||
|
|
||||||
|
# Next.js router (handles everything except API and WebSocket paths)
|
||||||
|
next-router:
|
||||||
|
rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)" # Dynamic Domain Name
|
||||||
|
service: next-service
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
middlewares:
|
||||||
|
- security-headers # Add security headers middleware
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
|
|
||||||
|
# API router (handles /api/v1 paths)
|
||||||
|
api-router:
|
||||||
|
rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)" # Dynamic Domain Name
|
||||||
|
service: api-service
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
middlewares:
|
||||||
|
- security-headers # Add security headers middleware
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
|
|
||||||
|
# WebSocket router
|
||||||
|
ws-router:
|
||||||
|
rule: "Host(`{{.DashboardDomain}}`)" # Dynamic Domain Name
|
||||||
|
service: api-service
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
middlewares:
|
||||||
|
- security-headers # Add security headers middleware
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
|
|
||||||
|
services:
|
||||||
|
next-service:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://pangolin:3002" # Next.js server
|
||||||
|
|
||||||
|
api-service:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://pangolin:3000" # API/WebSocket server
|
||||||
25
install/config/crowdsec/profiles.yaml
Normal file
25
install/config/crowdsec/profiles.yaml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
name: captcha_remediation
|
||||||
|
filters:
|
||||||
|
- Alert.Remediation == true && Alert.GetScope() == "Ip" && Alert.GetScenario() contains "http"
|
||||||
|
decisions:
|
||||||
|
- type: captcha
|
||||||
|
duration: 4h
|
||||||
|
on_success: break
|
||||||
|
|
||||||
|
---
|
||||||
|
name: default_ip_remediation
|
||||||
|
filters:
|
||||||
|
- Alert.Remediation == true && Alert.GetScope() == "Ip"
|
||||||
|
decisions:
|
||||||
|
- type: ban
|
||||||
|
duration: 4h
|
||||||
|
on_success: break
|
||||||
|
|
||||||
|
---
|
||||||
|
name: default_range_remediation
|
||||||
|
filters:
|
||||||
|
- Alert.Remediation == true && Alert.GetScope() == "Range"
|
||||||
|
decisions:
|
||||||
|
- type: ban
|
||||||
|
duration: 4h
|
||||||
|
on_success: break
|
||||||
87
install/config/crowdsec/traefik_config.yml
Normal file
87
install/config/crowdsec/traefik_config.yml
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
api:
|
||||||
|
insecure: true
|
||||||
|
dashboard: true
|
||||||
|
|
||||||
|
providers:
|
||||||
|
http:
|
||||||
|
endpoint: "http://pangolin:3001/api/v1/traefik-config"
|
||||||
|
pollInterval: "5s"
|
||||||
|
file:
|
||||||
|
filename: "/etc/traefik/dynamic_config.yml"
|
||||||
|
|
||||||
|
experimental:
|
||||||
|
plugins:
|
||||||
|
badger:
|
||||||
|
moduleName: "github.com/fosrl/badger"
|
||||||
|
version: "{{.BadgerVersion}}"
|
||||||
|
crowdsec: # CrowdSec plugin configuration added
|
||||||
|
moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||||
|
version: "v1.3.5"
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: "INFO"
|
||||||
|
format: "json" # Log format changed to json for better parsing
|
||||||
|
|
||||||
|
accessLog: # We enable access logs as json
|
||||||
|
filePath: "/var/log/traefik/access.log"
|
||||||
|
format: json
|
||||||
|
filters:
|
||||||
|
statusCodes:
|
||||||
|
- "200-299" # Success codes
|
||||||
|
- "400-499" # Client errors
|
||||||
|
- "500-599" # Server errors
|
||||||
|
retryAttempts: true
|
||||||
|
minDuration: "100ms" # Increased to focus on slower requests
|
||||||
|
bufferingSize: 100 # Add buffering for better performance
|
||||||
|
fields:
|
||||||
|
defaultMode: drop # Start with dropping all fields
|
||||||
|
names:
|
||||||
|
ClientAddr: keep # Keep client address for IP tracking
|
||||||
|
ClientHost: keep # Keep client host for IP tracking
|
||||||
|
RequestMethod: keep # Keep request method for tracking
|
||||||
|
RequestPath: keep # Keep request path for tracking
|
||||||
|
RequestProtocol: keep # Keep request protocol for tracking
|
||||||
|
DownstreamStatus: keep # Keep downstream status for tracking
|
||||||
|
DownstreamContentSize: keep # Keep downstream content size for tracking
|
||||||
|
Duration: keep # Keep request duration for tracking
|
||||||
|
ServiceName: keep # Keep service name for tracking
|
||||||
|
StartUTC: keep # Keep start time for tracking
|
||||||
|
TLSVersion: keep # Keep TLS version for tracking
|
||||||
|
TLSCipher: keep # Keep TLS cipher for tracking
|
||||||
|
RetryAttempts: keep # Keep retry attempts for tracking
|
||||||
|
headers:
|
||||||
|
defaultMode: drop # Start with dropping all headers
|
||||||
|
names:
|
||||||
|
User-Agent: keep # Keep user agent for tracking
|
||||||
|
X-Real-Ip: keep # Keep real IP for tracking
|
||||||
|
X-Forwarded-For: keep # Keep forwarded IP for tracking
|
||||||
|
X-Forwarded-Proto: keep # Keep forwarded protocol for tracking
|
||||||
|
Content-Type: keep # Keep content type for tracking
|
||||||
|
Authorization: redact # Redact sensitive information
|
||||||
|
Cookie: redact # Redact sensitive information
|
||||||
|
|
||||||
|
certificatesResolvers:
|
||||||
|
letsencrypt:
|
||||||
|
acme:
|
||||||
|
httpChallenge:
|
||||||
|
entryPoint: web
|
||||||
|
email: "{{.LetsEncryptEmail}}"
|
||||||
|
storage: "/letsencrypt/acme.json"
|
||||||
|
caServer: "https://acme-v02.api.letsencrypt.org/directory"
|
||||||
|
|
||||||
|
entryPoints:
|
||||||
|
web:
|
||||||
|
address: ":80"
|
||||||
|
websecure:
|
||||||
|
address: ":443"
|
||||||
|
transport:
|
||||||
|
respondingTimeouts:
|
||||||
|
readTimeout: "30m"
|
||||||
|
http:
|
||||||
|
tls:
|
||||||
|
certResolver: "letsencrypt"
|
||||||
|
middlewares:
|
||||||
|
- crowdsec@file
|
||||||
|
|
||||||
|
serversTransport:
|
||||||
|
insecureSkipVerify: true
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
name: pangolin
|
||||||
services:
|
services:
|
||||||
pangolin:
|
pangolin:
|
||||||
image: fosrl/pangolin:{{.PangolinVersion}}
|
image: fosrl/pangolin:{{.PangolinVersion}}
|
||||||
@@ -10,7 +11,6 @@ services:
|
|||||||
interval: "3s"
|
interval: "3s"
|
||||||
timeout: "3s"
|
timeout: "3s"
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
{{if .InstallGerbil}}
|
{{if .InstallGerbil}}
|
||||||
gerbil:
|
gerbil:
|
||||||
image: fosrl/gerbil:{{.GerbilVersion}}
|
image: fosrl/gerbil:{{.GerbilVersion}}
|
||||||
@@ -34,15 +34,13 @@ services:
|
|||||||
- 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.3.3
|
image: traefik:v3.3.3
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
{{if .InstallGerbil}}
|
{{if .InstallGerbil}}
|
||||||
network_mode: service:gerbil # Ports appear on the gerbil service
|
network_mode: service:gerbil # Ports appear on the gerbil service
|
||||||
{{end}}
|
{{end}}{{if not .InstallGerbil}}
|
||||||
{{if not .InstallGerbil}}
|
|
||||||
ports:
|
ports:
|
||||||
- 443:443
|
- 443:443
|
||||||
- 80:80
|
- 80:80
|
||||||
@@ -55,6 +53,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
|
- ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
|
||||||
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
||||||
|
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
121
install/crowdsec.go
Normal file
121
install/crowdsec.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func installCrowdsec(config Config) error {
|
||||||
|
|
||||||
|
if err := stopContainers(); err != nil {
|
||||||
|
return fmt.Errorf("failed to stop containers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run installation steps
|
||||||
|
if err := backupConfig(); err != nil {
|
||||||
|
return fmt.Errorf("backup failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := createConfigFiles(config); err != nil {
|
||||||
|
fmt.Printf("Error creating config files: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.MkdirAll("config/crowdsec/db", 0755)
|
||||||
|
os.MkdirAll("config/crowdsec_logs/syslog", 0755)
|
||||||
|
os.MkdirAll("config/traefik/logs", 0755)
|
||||||
|
|
||||||
|
if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil {
|
||||||
|
fmt.Printf("Error copying docker service: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := MergeYAML("config/traefik/traefik_config.yml", "config/crowdsec/traefik_config.yml"); err != nil {
|
||||||
|
fmt.Printf("Error copying entry points: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
// delete the 2nd file
|
||||||
|
if err := os.Remove("config/crowdsec/traefik_config.yml"); err != nil {
|
||||||
|
fmt.Printf("Error removing file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := MergeYAML("config/traefik/dynamic_config.yml", "config/crowdsec/dynamic_config.yml"); err != nil {
|
||||||
|
fmt.Printf("Error copying entry points: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
// delete the 2nd file
|
||||||
|
if err := os.Remove("config/crowdsec/dynamic_config.yml"); err != nil {
|
||||||
|
fmt.Printf("Error removing file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove("config/crowdsec/docker-compose.yml"); err != nil {
|
||||||
|
fmt.Printf("Error removing file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := CheckAndAddTraefikLogVolume("docker-compose.yml"); err != nil {
|
||||||
|
fmt.Printf("Error checking and adding Traefik log volume: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := startContainers(); err != nil {
|
||||||
|
return fmt.Errorf("failed to start containers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// get API key
|
||||||
|
apiKey, err := GetCrowdSecAPIKey()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get API key: %v", err)
|
||||||
|
}
|
||||||
|
config.TraefikBouncerKey = apiKey
|
||||||
|
|
||||||
|
if err := replaceInFile("config/traefik/dynamic_config.yml", "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK", config.TraefikBouncerKey); err != nil {
|
||||||
|
return fmt.Errorf("failed to replace bouncer key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := restartContainer("traefik"); err != nil {
|
||||||
|
return fmt.Errorf("failed to restart containers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkIsCrowdsecInstalledInCompose() bool {
|
||||||
|
// Read docker-compose.yml
|
||||||
|
content, err := os.ReadFile("docker-compose.yml")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for crowdsec service
|
||||||
|
return bytes.Contains(content, []byte("crowdsec:"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCrowdSecAPIKey() (string, error) {
|
||||||
|
// First, ensure the container is running
|
||||||
|
if err := waitForContainer("crowdsec"); err != nil {
|
||||||
|
return "", fmt.Errorf("waiting for container: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the command to get the API key
|
||||||
|
cmd := exec.Command("docker", "exec", "crowdsec", "cscli", "bouncers", "add", "traefik-bouncer", "-o", "raw")
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd.Stdout = &out
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return "", fmt.Errorf("executing command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim any whitespace from the output
|
||||||
|
apiKey := strings.TrimSpace(out.String())
|
||||||
|
if apiKey == "" {
|
||||||
|
return "", fmt.Errorf("empty API key returned")
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiKey, nil
|
||||||
|
}
|
||||||
@@ -5,4 +5,5 @@ go 1.23.0
|
|||||||
require (
|
require (
|
||||||
golang.org/x/sys v0.29.0 // indirect
|
golang.org/x/sys v0.29.0 // indirect
|
||||||
golang.org/x/term v0.28.0 // indirect
|
golang.org/x/term v0.28.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,3 +2,6 @@ golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
|||||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
12
install/input.txt
Normal file
12
install/input.txt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
example.com
|
||||||
|
pangolin.example.com
|
||||||
|
admin@example.com
|
||||||
|
yes
|
||||||
|
admin@example.com
|
||||||
|
Password123!
|
||||||
|
Password123!
|
||||||
|
yes
|
||||||
|
no
|
||||||
|
no
|
||||||
|
no
|
||||||
|
yes
|
||||||
308
install/main.go
308
install/main.go
@@ -4,13 +4,16 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"bytes"
|
||||||
"text/template"
|
"text/template"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
@@ -24,7 +27,7 @@ func loadVersions(config *Config) {
|
|||||||
config.BadgerVersion = "replaceme"
|
config.BadgerVersion = "replaceme"
|
||||||
}
|
}
|
||||||
|
|
||||||
//go:embed fs/*
|
//go:embed config/*
|
||||||
var configFiles embed.FS
|
var configFiles embed.FS
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -45,6 +48,8 @@ type Config struct {
|
|||||||
EmailSMTPPass string
|
EmailSMTPPass string
|
||||||
EmailNoReply string
|
EmailNoReply string
|
||||||
InstallGerbil bool
|
InstallGerbil bool
|
||||||
|
TraefikBouncerKey string
|
||||||
|
DoCrowdsecInstall bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -56,9 +61,12 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var config Config
|
||||||
|
config.DoCrowdsecInstall = false
|
||||||
|
|
||||||
// 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 {
|
||||||
config := collectUserInput(reader)
|
config = collectUserInput(reader)
|
||||||
|
|
||||||
loadVersions(&config)
|
loadVersions(&config)
|
||||||
|
|
||||||
@@ -67,18 +75,53 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
moveFile("config/docker-compose.yml", "docker-compose.yml")
|
||||||
|
|
||||||
if !isDockerInstalled() && runtime.GOOS == "linux" {
|
if !isDockerInstalled() && runtime.GOOS == "linux" {
|
||||||
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n=== Starting installation ===")
|
||||||
|
|
||||||
|
if isDockerInstalled() {
|
||||||
|
if readBool(reader, "Would you like to install and start the containers?", true) {
|
||||||
|
pullAndStartContainers()
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("Config file already exists... skipping configuration")
|
fmt.Println("Looks like you already installed, so I am going to do the setup...")
|
||||||
}
|
}
|
||||||
|
|
||||||
if isDockerInstalled() {
|
if !checkIsCrowdsecInstalledInCompose() {
|
||||||
if readBool(reader, "Would you like to install and start the containers?", true) {
|
fmt.Println("\n=== Crowdsec Install ===")
|
||||||
pullAndStartContainers()
|
// check if crowdsec is installed
|
||||||
|
if readBool(reader, "Would you like to install Crowdsec?", true) {
|
||||||
|
|
||||||
|
if config.DashboardDomain == "" {
|
||||||
|
traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml", "config/traefik/dynamic_config.yml")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error reading config: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
config.DashboardDomain = traefikConfig.DashboardDomain
|
||||||
|
config.LetsEncryptEmail = traefikConfig.LetsEncryptEmail
|
||||||
|
config.BadgerVersion = traefikConfig.BadgerVersion
|
||||||
|
|
||||||
|
// print the values and check if they are right
|
||||||
|
fmt.Println("Detected values:")
|
||||||
|
fmt.Printf("Dashboard Domain: %s\n", config.DashboardDomain)
|
||||||
|
fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail)
|
||||||
|
fmt.Printf("Badger Version: %s\n", config.BadgerVersion)
|
||||||
|
|
||||||
|
if !readBool(reader, "Are these values correct?", true) {
|
||||||
|
config = collectUserInput(reader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.DoCrowdsecInstall = true
|
||||||
|
installCrowdsec(config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,22 +142,24 @@ func readString(reader *bufio.Reader, prompt string, defaultValue string) string
|
|||||||
return input
|
return input
|
||||||
}
|
}
|
||||||
|
|
||||||
func readPassword(prompt string) string {
|
func readPassword(prompt string, reader *bufio.Reader) string {
|
||||||
fmt.Print(prompt + ": ")
|
if term.IsTerminal(int(syscall.Stdin)) {
|
||||||
|
fmt.Print(prompt + ": ")
|
||||||
// Read password without echo
|
// Read password without echo if we're in a terminal
|
||||||
password, err := term.ReadPassword(int(syscall.Stdin))
|
password, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
fmt.Println() // Add a newline since ReadPassword doesn't add one
|
fmt.Println() // Add a newline since ReadPassword doesn't add one
|
||||||
|
if err != nil {
|
||||||
if err != nil {
|
return ""
|
||||||
return ""
|
}
|
||||||
}
|
input := strings.TrimSpace(string(password))
|
||||||
|
if input == "" {
|
||||||
input := strings.TrimSpace(string(password))
|
return readPassword(prompt, reader)
|
||||||
if input == "" {
|
}
|
||||||
return readPassword(prompt)
|
return input
|
||||||
}
|
} else {
|
||||||
return input
|
// Fallback to reading from stdin if not in a terminal
|
||||||
|
return readString(reader, prompt, "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool {
|
func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool {
|
||||||
@@ -150,8 +195,8 @@ func collectUserInput(reader *bufio.Reader) Config {
|
|||||||
fmt.Println("\n=== Admin User Configuration ===")
|
fmt.Println("\n=== Admin User Configuration ===")
|
||||||
config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain)
|
config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain)
|
||||||
for {
|
for {
|
||||||
pass1 := readPassword("Create admin user password")
|
pass1 := readPassword("Create admin user password", reader)
|
||||||
pass2 := readPassword("Confirm admin user password")
|
pass2 := readPassword("Confirm admin user password", reader)
|
||||||
|
|
||||||
if pass1 != pass2 {
|
if pass1 != pass2 {
|
||||||
fmt.Println("Passwords do not match")
|
fmt.Println("Passwords do not match")
|
||||||
@@ -261,31 +306,33 @@ func createConfigFiles(config Config) error {
|
|||||||
os.MkdirAll("config/logs", 0755)
|
os.MkdirAll("config/logs", 0755)
|
||||||
|
|
||||||
// Walk through all embedded files
|
// Walk through all embedded files
|
||||||
err := fs.WalkDir(configFiles, "fs", func(path string, d fs.DirEntry, err error) error {
|
err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip the root fs directory itself
|
// Skip the root fs directory itself
|
||||||
if path == "fs" {
|
if path == "config" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the relative path by removing the "fs/" prefix
|
if !config.DoCrowdsecInstall && strings.Contains(path, "crowdsec") {
|
||||||
relPath := strings.TrimPrefix(path, "fs/")
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.DoCrowdsecInstall && !strings.Contains(path, "crowdsec") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// skip .DS_Store
|
// skip .DS_Store
|
||||||
if strings.Contains(relPath, ".DS_Store") {
|
if strings.Contains(path, ".DS_Store") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the full output path under "config/"
|
|
||||||
outPath := filepath.Join("config", relPath)
|
|
||||||
|
|
||||||
if d.IsDir() {
|
if d.IsDir() {
|
||||||
// Create directory
|
// Create directory
|
||||||
if err := os.MkdirAll(outPath, 0755); err != nil {
|
if err := os.MkdirAll(path, 0755); err != nil {
|
||||||
return fmt.Errorf("failed to create directory %s: %v", outPath, err)
|
return fmt.Errorf("failed to create directory %s: %v", path, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -303,14 +350,14 @@ func createConfigFiles(config Config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ensure parent directory exists
|
// Ensure parent directory exists
|
||||||
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
return fmt.Errorf("failed to create parent directory for %s: %v", outPath, err)
|
return fmt.Errorf("failed to create parent directory for %s: %v", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create output file
|
// Create output file
|
||||||
outFile, err := os.Create(outPath)
|
outFile, err := os.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create %s: %v", outPath, err)
|
return fmt.Errorf("failed to create %s: %v", path, err)
|
||||||
}
|
}
|
||||||
defer outFile.Close()
|
defer outFile.Close()
|
||||||
|
|
||||||
@@ -326,30 +373,10 @@ func createConfigFiles(config Config) error {
|
|||||||
return fmt.Errorf("error walking config files: %v", err)
|
return fmt.Errorf("error walking config files: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// get the current directory
|
|
||||||
dir, err := os.Getwd()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get current directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sourcePath := filepath.Join(dir, "config/docker-compose.yml")
|
|
||||||
destPath := filepath.Join(dir, "docker-compose.yml")
|
|
||||||
|
|
||||||
// Check if source file exists
|
|
||||||
if _, err := os.Stat(sourcePath); err != nil {
|
|
||||||
return fmt.Errorf("source docker-compose.yml not found: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to move the file
|
|
||||||
err = os.Rename(sourcePath, destPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to move docker-compose.yml from %s to %s: %v",
|
|
||||||
sourcePath, destPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func installDocker() error {
|
func installDocker() error {
|
||||||
// Detect Linux distribution
|
// Detect Linux distribution
|
||||||
cmd := exec.Command("cat", "/etc/os-release")
|
cmd := exec.Command("cat", "/etc/os-release")
|
||||||
@@ -490,3 +517,166 @@ func pullAndStartContainers() error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bring containers down
|
||||||
|
func stopContainers() error {
|
||||||
|
fmt.Println("Stopping containers...")
|
||||||
|
|
||||||
|
// Check which docker compose command is available
|
||||||
|
var useNewStyle bool
|
||||||
|
checkCmd := exec.Command("docker", "compose", "version")
|
||||||
|
if err := checkCmd.Run(); err == nil {
|
||||||
|
useNewStyle = true
|
||||||
|
} else {
|
||||||
|
// Check if docker-compose (old style) is available
|
||||||
|
checkCmd = exec.Command("docker-compose", "version")
|
||||||
|
if err := checkCmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to execute docker compose commands
|
||||||
|
executeCommand := func(args ...string) error {
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if useNewStyle {
|
||||||
|
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("docker-compose", args...)
|
||||||
|
}
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := executeCommand("-f", "docker-compose.yml", "down"); err != nil {
|
||||||
|
return fmt.Errorf("failed to stop containers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// just start containers
|
||||||
|
func startContainers() error {
|
||||||
|
fmt.Println("Starting containers...")
|
||||||
|
|
||||||
|
// Check which docker compose command is available
|
||||||
|
var useNewStyle bool
|
||||||
|
checkCmd := exec.Command("docker", "compose", "version")
|
||||||
|
if err := checkCmd.Run(); err == nil {
|
||||||
|
useNewStyle = true
|
||||||
|
} else {
|
||||||
|
// Check if docker-compose (old style) is available
|
||||||
|
checkCmd = exec.Command("docker-compose", "version")
|
||||||
|
if err := checkCmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to execute docker compose commands
|
||||||
|
executeCommand := func(args ...string) error {
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if useNewStyle {
|
||||||
|
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("docker-compose", args...)
|
||||||
|
}
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := executeCommand("-f", "docker-compose.yml", "up", "-d"); err != nil {
|
||||||
|
return fmt.Errorf("failed to start containers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartContainer(container string) error {
|
||||||
|
fmt.Printf("Restarting %s container...\n", container)
|
||||||
|
|
||||||
|
// Check which docker compose command is available
|
||||||
|
var useNewStyle bool
|
||||||
|
checkCmd := exec.Command("docker", "compose", "version")
|
||||||
|
if err := checkCmd.Run(); err == nil {
|
||||||
|
useNewStyle = true
|
||||||
|
} else {
|
||||||
|
// Check if docker-compose (old style) is available
|
||||||
|
checkCmd = exec.Command("docker-compose", "version")
|
||||||
|
if err := checkCmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to execute docker compose commands
|
||||||
|
executeCommand := func(args ...string) error {
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if useNewStyle {
|
||||||
|
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("docker-compose", args...)
|
||||||
|
}
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := executeCommand("-f", "docker-compose.yml", "restart", container); err != nil {
|
||||||
|
return fmt.Errorf("failed to restart %s container: %v", container, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
source, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer source.Close()
|
||||||
|
|
||||||
|
destination, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer destination.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(destination, source)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveFile(src, dst string) error {
|
||||||
|
if err := copyFile(src, dst); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.Remove(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForContainer(containerName string) error {
|
||||||
|
maxAttempts := 30
|
||||||
|
retryInterval := time.Second * 2
|
||||||
|
|
||||||
|
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||||
|
// Check if container is running
|
||||||
|
cmd := exec.Command("docker", "container", "inspect", "-f", "{{.State.Running}}", containerName)
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd.Stdout = &out
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
// If the container doesn't exist or there's another error, wait and retry
|
||||||
|
time.Sleep(retryInterval)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning := strings.TrimSpace(out.String()) == "true"
|
||||||
|
if isRunning {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container exists but isn't running yet, wait and retry
|
||||||
|
time.Sleep(retryInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("container %s did not start within %v seconds", containerName, maxAttempts*int(retryInterval.Seconds()))
|
||||||
|
}
|
||||||
@@ -50,7 +50,6 @@
|
|||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"drizzle-orm": "0.38.3",
|
"drizzle-orm": "0.38.3",
|
||||||
"emblor": "1.4.7",
|
|
||||||
"eslint": "9.17.0",
|
"eslint": "9.17.0",
|
||||||
"eslint-config-next": "15.1.3",
|
"eslint-config-next": "15.1.3",
|
||||||
"express": "4.21.2",
|
"express": "4.21.2",
|
||||||
@@ -71,6 +70,7 @@
|
|||||||
"qrcode.react": "4.2.0",
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
|
"react-easy-sort": "^1.6.0",
|
||||||
"react-hook-form": "7.54.2",
|
"react-hook-form": "7.54.2",
|
||||||
"rebuild": "0.1.2",
|
"rebuild": "0.1.2",
|
||||||
"semver": "7.6.3",
|
"semver": "7.6.3",
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export enum ActionsEnum {
|
|||||||
deleteResourceRule = "deleteResourceRule",
|
deleteResourceRule = "deleteResourceRule",
|
||||||
listResourceRules = "listResourceRules",
|
listResourceRules = "listResourceRules",
|
||||||
updateResourceRule = "updateResourceRule",
|
updateResourceRule = "updateResourceRule",
|
||||||
|
listOrgDomains = "listOrgDomains",
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
users
|
users
|
||||||
} from "@server/db/schema";
|
} from "@server/db/schema";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import type { RandomReader } from "@oslojs/crypto/random";
|
import type { RandomReader } from "@oslojs/crypto/random";
|
||||||
import { generateRandomString } from "@oslojs/crypto/random";
|
import { generateRandomString } from "@oslojs/crypto/random";
|
||||||
@@ -95,12 +95,36 @@ export async function validateSessionToken(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function invalidateSession(sessionId: string): Promise<void> {
|
export async function invalidateSession(sessionId: string): Promise<void> {
|
||||||
await db.delete(resourceSessions).where(eq(resourceSessions.userSessionId, sessionId));
|
try {
|
||||||
await db.delete(sessions).where(eq(sessions.sessionId, sessionId));
|
await db.transaction(async (trx) => {
|
||||||
|
await trx
|
||||||
|
.delete(resourceSessions)
|
||||||
|
.where(eq(resourceSessions.userSessionId, sessionId));
|
||||||
|
await trx.delete(sessions).where(eq(sessions.sessionId, sessionId));
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to invalidate session", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function invalidateAllSessions(userId: string): Promise<void> {
|
export async function invalidateAllSessions(userId: string): Promise<void> {
|
||||||
await db.delete(sessions).where(eq(sessions.userId, userId));
|
try {
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
const userSessions = await trx
|
||||||
|
.select()
|
||||||
|
.from(sessions)
|
||||||
|
.where(eq(sessions.userId, userId));
|
||||||
|
await trx.delete(resourceSessions).where(
|
||||||
|
inArray(
|
||||||
|
resourceSessions.userSessionId,
|
||||||
|
userSessions.map((s) => s.sessionId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await trx.delete(sessions).where(eq(sessions.userId, userId));
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to all invalidate user sessions", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeSessionCookie(
|
export function serializeSessionCookie(
|
||||||
|
|||||||
@@ -170,9 +170,9 @@ export function serializeResourceSessionCookie(
|
|||||||
isHttp: boolean = false
|
isHttp: boolean = false
|
||||||
): string {
|
): string {
|
||||||
if (!isHttp) {
|
if (!isHttp) {
|
||||||
return `${cookieName}_s=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${"." + domain}`;
|
return `${cookieName}_s=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${"." + domain}`;
|
||||||
} else {
|
} else {
|
||||||
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${"." + domain}`;
|
return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${"." + domain}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,9 +182,9 @@ export function createBlankResourceSessionTokenCookie(
|
|||||||
isHttp: boolean = false
|
isHttp: boolean = false
|
||||||
): string {
|
): string {
|
||||||
if (!isHttp) {
|
if (!isHttp) {
|
||||||
return `${cookieName}_s=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${"." + domain}`;
|
return `${cookieName}_s=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${"." + domain}`;
|
||||||
} else {
|
} else {
|
||||||
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${"." + domain}`;
|
return `${cookieName}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Domain=${"." + domain}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
import { InferSelectModel } from "drizzle-orm";
|
import { InferSelectModel } from "drizzle-orm";
|
||||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
|
export const domains = sqliteTable("domains", {
|
||||||
|
domainId: text("domainId").primaryKey(),
|
||||||
|
baseDomain: text("baseDomain").notNull(),
|
||||||
|
configManaged: integer("configManaged", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false)
|
||||||
|
});
|
||||||
|
|
||||||
export const orgs = sqliteTable("orgs", {
|
export const orgs = sqliteTable("orgs", {
|
||||||
orgId: text("orgId").primaryKey(),
|
orgId: text("orgId").primaryKey(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull()
|
||||||
domain: text("domain").notNull()
|
});
|
||||||
|
|
||||||
|
export const orgDomains = sqliteTable("orgDomains", {
|
||||||
|
orgId: text("orgId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
|
domainId: text("domainId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => domains.domainId, { onDelete: "cascade" })
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sites = sqliteTable("sites", {
|
export const sites = sqliteTable("sites", {
|
||||||
@@ -43,6 +59,9 @@ export const resources = sqliteTable("resources", {
|
|||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
subdomain: text("subdomain"),
|
subdomain: text("subdomain"),
|
||||||
fullDomain: text("fullDomain"),
|
fullDomain: text("fullDomain"),
|
||||||
|
domainId: text("domainId").references(() => domains.domainId, {
|
||||||
|
onDelete: "set null"
|
||||||
|
}),
|
||||||
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
||||||
blockAccess: integer("blockAccess", { mode: "boolean" })
|
blockAccess: integer("blockAccess", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -55,7 +74,9 @@ export const resources = sqliteTable("resources", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
isBaseDomain: integer("isBaseDomain", { mode: "boolean" }),
|
isBaseDomain: integer("isBaseDomain", { mode: "boolean" }),
|
||||||
applyRules: integer("applyRules", { mode: "boolean" }).notNull().default(false)
|
applyRules: integer("applyRules", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const targets = sqliteTable("targets", {
|
export const targets = sqliteTable("targets", {
|
||||||
@@ -417,3 +438,4 @@ export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
|
|||||||
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
|
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
|
||||||
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
||||||
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
||||||
|
export type Domain = InferSelectModel<typeof domains>;
|
||||||
|
|||||||
@@ -34,15 +34,49 @@ const configSchema = z.object({
|
|||||||
.transform(getEnvOrYaml("APP_DASHBOARDURL"))
|
.transform(getEnvOrYaml("APP_DASHBOARDURL"))
|
||||||
.pipe(z.string().url())
|
.pipe(z.string().url())
|
||||||
.transform((url) => url.toLowerCase()),
|
.transform((url) => url.toLowerCase()),
|
||||||
base_domain: hostnameSchema
|
|
||||||
.optional()
|
|
||||||
.transform(getEnvOrYaml("APP_BASEDOMAIN"))
|
|
||||||
.pipe(hostnameSchema)
|
|
||||||
.transform((url) => url.toLowerCase()),
|
|
||||||
log_level: z.enum(["debug", "info", "warn", "error"]),
|
log_level: z.enum(["debug", "info", "warn", "error"]),
|
||||||
save_logs: z.boolean(),
|
save_logs: z.boolean(),
|
||||||
log_failed_attempts: z.boolean().optional()
|
log_failed_attempts: z.boolean().optional()
|
||||||
}),
|
}),
|
||||||
|
domains: z
|
||||||
|
.record(
|
||||||
|
z.string(),
|
||||||
|
z.object({
|
||||||
|
base_domain: hostnameSchema.transform((url) =>
|
||||||
|
url.toLowerCase()
|
||||||
|
),
|
||||||
|
cert_resolver: z.string().optional(),
|
||||||
|
prefer_wildcard_cert: z.boolean().optional()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(domains) => {
|
||||||
|
const keys = Object.keys(domains);
|
||||||
|
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "At least one domain must be defined"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(domains) => {
|
||||||
|
const envBaseDomain = process.env.APP_BASE_DOMAIN;
|
||||||
|
|
||||||
|
if (envBaseDomain) {
|
||||||
|
return hostnameSchema.safeParse(envBaseDomain).success;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "APP_BASE_DOMAIN must be a valid hostname"
|
||||||
|
}
|
||||||
|
),
|
||||||
server: z.object({
|
server: z.object({
|
||||||
external_port: portSchema
|
external_port: portSchema
|
||||||
.optional()
|
.optional()
|
||||||
@@ -88,8 +122,6 @@ const configSchema = z.object({
|
|||||||
traefik: z.object({
|
traefik: z.object({
|
||||||
http_entrypoint: z.string(),
|
http_entrypoint: z.string(),
|
||||||
https_entrypoint: z.string().optional(),
|
https_entrypoint: z.string().optional(),
|
||||||
cert_resolver: z.string().optional(),
|
|
||||||
prefer_wildcard_cert: z.boolean().optional(),
|
|
||||||
additional_middlewares: z.array(z.string()).optional()
|
additional_middlewares: z.array(z.string()).optional()
|
||||||
}),
|
}),
|
||||||
gerbil: z.object({
|
gerbil: z.object({
|
||||||
@@ -168,8 +200,6 @@ export class Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public loadEnvironment() {}
|
|
||||||
|
|
||||||
public loadConfig() {
|
public loadConfig() {
|
||||||
const loadConfig = (configPath: string) => {
|
const loadConfig = (configPath: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -276,6 +306,17 @@ export class Config {
|
|||||||
: "false";
|
: "false";
|
||||||
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
|
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
|
||||||
|
|
||||||
|
if (process.env.APP_BASE_DOMAIN) {
|
||||||
|
console.log(
|
||||||
|
`DEPRECATED! APP_BASE_DOMAIN is deprecated and will be removed in a future release. Use the domains section in the configuration file instead. See https://docs.fossorial.io/Pangolin/Configuration/config for more information.`
|
||||||
|
);
|
||||||
|
|
||||||
|
parsedConfig.data.domains.domain1 = {
|
||||||
|
base_domain: process.env.APP_BASE_DOMAIN,
|
||||||
|
cert_resolver: "letsencrypt"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
this.rawConfig = parsedConfig.data;
|
this.rawConfig = parsedConfig.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,16 +324,16 @@ export class Config {
|
|||||||
return this.rawConfig;
|
return this.rawConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getBaseDomain(): string {
|
|
||||||
return this.rawConfig.app.base_domain;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getNoReplyEmail(): string | undefined {
|
public getNoReplyEmail(): string | undefined {
|
||||||
return (
|
return (
|
||||||
this.rawConfig.email?.no_reply || this.rawConfig.email?.smtp_user
|
this.rawConfig.email?.no_reply || this.rawConfig.email?.smtp_user
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getDomain(domainId: string) {
|
||||||
|
return this.rawConfig.domains[domainId];
|
||||||
|
}
|
||||||
|
|
||||||
private createTraefikConfig() {
|
private createTraefikConfig() {
|
||||||
try {
|
try {
|
||||||
// check if traefik_config.yml and dynamic_config.yml exists in APP_PATH/traefik
|
// check if traefik_config.yml and dynamic_config.yml exists in APP_PATH/traefik
|
||||||
|
|||||||
@@ -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.0.0-beta.13";
|
export const APP_VERSION = "1.0.0-beta.15";
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
183
server/lib/ip.test.ts
Normal file
183
server/lib/ip.test.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { cidrToRange, findNextAvailableCidr } from "./ip";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares two objects for deep equality
|
||||||
|
* @param actual The actual value to test
|
||||||
|
* @param expected The expected value to compare against
|
||||||
|
* @param message The message to display if assertion fails
|
||||||
|
* @throws Error if objects are not equal
|
||||||
|
*/
|
||||||
|
export function assertEqualsObj<T>(actual: T, expected: T, message: string): void {
|
||||||
|
const actualStr = JSON.stringify(actual);
|
||||||
|
const expectedStr = JSON.stringify(expected);
|
||||||
|
if (actualStr !== expectedStr) {
|
||||||
|
throw new Error(`${message}\nExpected: ${expectedStr}\nActual: ${actualStr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares two primitive values for equality
|
||||||
|
* @param actual The actual value to test
|
||||||
|
* @param expected The expected value to compare against
|
||||||
|
* @param message The message to display if assertion fails
|
||||||
|
* @throws Error if values are not equal
|
||||||
|
*/
|
||||||
|
export function assertEquals<T>(actual: T, expected: T, message: string): void {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`${message}\nExpected: ${expected}\nActual: ${actual}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests if a function throws an expected error
|
||||||
|
* @param fn The function to test
|
||||||
|
* @param expectedError The expected error message or part of it
|
||||||
|
* @param message The message to display if assertion fails
|
||||||
|
* @throws Error if function doesn't throw or throws unexpected error
|
||||||
|
*/
|
||||||
|
export function assertThrows(
|
||||||
|
fn: () => void,
|
||||||
|
expectedError: string,
|
||||||
|
message: string
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
throw new Error(`${message}: Expected to throw "${expectedError}"`);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
throw new Error(`${message}\nUnexpected error type: ${typeof error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!error.message.includes(expectedError)) {
|
||||||
|
throw new Error(
|
||||||
|
`${message}\nExpected error: ${expectedError}\nActual error: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Test cases
|
||||||
|
function testFindNextAvailableCidr() {
|
||||||
|
console.log("Running findNextAvailableCidr tests...");
|
||||||
|
|
||||||
|
// Test 1: Basic IPv4 allocation
|
||||||
|
{
|
||||||
|
const existing = ["10.0.0.0/16", "10.1.0.0/16"];
|
||||||
|
const result = findNextAvailableCidr(existing, 16, "10.0.0.0/8");
|
||||||
|
assertEquals(result, "10.2.0.0/16", "Basic IPv4 allocation failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Finding gap between allocations
|
||||||
|
{
|
||||||
|
const existing = ["10.0.0.0/16", "10.2.0.0/16"];
|
||||||
|
const result = findNextAvailableCidr(existing, 16, "10.0.0.0/8");
|
||||||
|
assertEquals(result, "10.1.0.0/16", "Finding gap between allocations failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: No available space
|
||||||
|
{
|
||||||
|
const existing = ["10.0.0.0/8"];
|
||||||
|
const result = findNextAvailableCidr(existing, 8, "10.0.0.0/8");
|
||||||
|
assertEquals(result, null, "No available space test failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// // Test 4: IPv6 allocation
|
||||||
|
// {
|
||||||
|
// const existing = ["2001:db8::/32", "2001:db8:1::/32"];
|
||||||
|
// const result = findNextAvailableCidr(existing, 32, "2001:db8::/16");
|
||||||
|
// assertEquals(result, "2001:db8:2::/32", "Basic IPv6 allocation failed");
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Test 5: Mixed IP versions
|
||||||
|
// {
|
||||||
|
// const existing = ["10.0.0.0/16", "2001:db8::/32"];
|
||||||
|
// assertThrows(
|
||||||
|
// () => findNextAvailableCidr(existing, 16),
|
||||||
|
// "All CIDRs must be of the same IP version",
|
||||||
|
// "Mixed IP versions test failed"
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Test 6: Empty input
|
||||||
|
{
|
||||||
|
const existing: string[] = [];
|
||||||
|
const result = findNextAvailableCidr(existing, 16);
|
||||||
|
assertEquals(result, null, "Empty input test failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: Block size alignment
|
||||||
|
{
|
||||||
|
const existing = ["10.0.0.0/24"];
|
||||||
|
const result = findNextAvailableCidr(existing, 24, "10.0.0.0/16");
|
||||||
|
assertEquals(result, "10.0.1.0/24", "Block size alignment test failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8: Block size alignment
|
||||||
|
{
|
||||||
|
const existing: string[] = [];
|
||||||
|
const result = findNextAvailableCidr(existing, 24, "10.0.0.0/16");
|
||||||
|
assertEquals(result, "10.0.0.0/24", "Block size alignment test failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 9: Large block size request
|
||||||
|
{
|
||||||
|
const existing = ["10.0.0.0/24", "10.0.1.0/24"];
|
||||||
|
const result = findNextAvailableCidr(existing, 16, "10.0.0.0/16");
|
||||||
|
assertEquals(result, null, "Large block size request test failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("All findNextAvailableCidr tests passed!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// function testCidrToRange() {
|
||||||
|
// console.log("Running cidrToRange tests...");
|
||||||
|
|
||||||
|
// // Test 1: Basic IPv4 conversion
|
||||||
|
// {
|
||||||
|
// const result = cidrToRange("192.168.0.0/24");
|
||||||
|
// assertEqualsObj(result, {
|
||||||
|
// start: BigInt("3232235520"),
|
||||||
|
// end: BigInt("3232235775")
|
||||||
|
// }, "Basic IPv4 conversion failed");
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Test 2: IPv6 conversion
|
||||||
|
// {
|
||||||
|
// const result = cidrToRange("2001:db8::/32");
|
||||||
|
// assertEqualsObj(result, {
|
||||||
|
// start: BigInt("42540766411282592856903984951653826560"),
|
||||||
|
// end: BigInt("42540766411282592875350729025363378175")
|
||||||
|
// }, "IPv6 conversion failed");
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Test 3: Invalid prefix length
|
||||||
|
// {
|
||||||
|
// assertThrows(
|
||||||
|
// () => cidrToRange("192.168.0.0/33"),
|
||||||
|
// "Invalid prefix length for IPv4",
|
||||||
|
// "Invalid IPv4 prefix test failed"
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Test 4: Invalid IPv6 prefix
|
||||||
|
// {
|
||||||
|
// assertThrows(
|
||||||
|
// () => cidrToRange("2001:db8::/129"),
|
||||||
|
// "Invalid prefix length for IPv6",
|
||||||
|
// "Invalid IPv6 prefix test failed"
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// console.log("All cidrToRange tests passed!");
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Run all tests
|
||||||
|
try {
|
||||||
|
// testCidrToRange();
|
||||||
|
testFindNextAvailableCidr();
|
||||||
|
console.log("All tests passed successfully!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Test failed:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
154
server/lib/ip.ts
154
server/lib/ip.ts
@@ -3,58 +3,162 @@ interface IPRange {
|
|||||||
end: bigint;
|
end: bigint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IPVersion = 4 | 6;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts IP address string to BigInt for numerical operations
|
* Detects IP version from address string
|
||||||
|
*/
|
||||||
|
function detectIpVersion(ip: string): IPVersion {
|
||||||
|
return ip.includes(':') ? 6 : 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts IPv4 or IPv6 address string to BigInt for numerical operations
|
||||||
*/
|
*/
|
||||||
function ipToBigInt(ip: string): bigint {
|
function ipToBigInt(ip: string): bigint {
|
||||||
return ip.split('.')
|
const version = detectIpVersion(ip);
|
||||||
.reduce((acc, octet) => BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(parseInt(octet))), BigInt(0));
|
|
||||||
|
if (version === 4) {
|
||||||
|
return ip.split('.')
|
||||||
|
.reduce((acc, octet) => {
|
||||||
|
const num = parseInt(octet);
|
||||||
|
if (isNaN(num) || num < 0 || num > 255) {
|
||||||
|
throw new Error(`Invalid IPv4 octet: ${octet}`);
|
||||||
|
}
|
||||||
|
return BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(num));
|
||||||
|
}, BigInt(0));
|
||||||
|
} else {
|
||||||
|
// Handle IPv6
|
||||||
|
// Expand :: notation
|
||||||
|
let fullAddress = ip;
|
||||||
|
if (ip.includes('::')) {
|
||||||
|
const parts = ip.split('::');
|
||||||
|
if (parts.length > 2) throw new Error('Invalid IPv6 address: multiple :: found');
|
||||||
|
const missing = 8 - (parts[0].split(':').length + parts[1].split(':').length);
|
||||||
|
const padding = Array(missing).fill('0').join(':');
|
||||||
|
fullAddress = `${parts[0]}:${padding}:${parts[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullAddress.split(':')
|
||||||
|
.reduce((acc, hextet) => {
|
||||||
|
const num = parseInt(hextet || '0', 16);
|
||||||
|
if (isNaN(num) || num < 0 || num > 65535) {
|
||||||
|
throw new Error(`Invalid IPv6 hextet: ${hextet}`);
|
||||||
|
}
|
||||||
|
return BigInt.asUintN(128, (acc << BigInt(16)) + BigInt(num));
|
||||||
|
}, BigInt(0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts BigInt to IP address string
|
* Converts BigInt to IP address string
|
||||||
*/
|
*/
|
||||||
function bigIntToIp(num: bigint): string {
|
function bigIntToIp(num: bigint, version: IPVersion): string {
|
||||||
const octets: number[] = [];
|
if (version === 4) {
|
||||||
for (let i = 0; i < 4; i++) {
|
const octets: number[] = [];
|
||||||
octets.unshift(Number(num & BigInt(255)));
|
for (let i = 0; i < 4; i++) {
|
||||||
num = num >> BigInt(8);
|
octets.unshift(Number(num & BigInt(255)));
|
||||||
|
num = num >> BigInt(8);
|
||||||
|
}
|
||||||
|
return octets.join('.');
|
||||||
|
} else {
|
||||||
|
const hextets: string[] = [];
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
hextets.unshift(Number(num & BigInt(65535)).toString(16).padStart(4, '0'));
|
||||||
|
num = num >> BigInt(16);
|
||||||
|
}
|
||||||
|
// Compress zero sequences
|
||||||
|
let maxZeroStart = -1;
|
||||||
|
let maxZeroLength = 0;
|
||||||
|
let currentZeroStart = -1;
|
||||||
|
let currentZeroLength = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < hextets.length; i++) {
|
||||||
|
if (hextets[i] === '0000') {
|
||||||
|
if (currentZeroStart === -1) currentZeroStart = i;
|
||||||
|
currentZeroLength++;
|
||||||
|
if (currentZeroLength > maxZeroLength) {
|
||||||
|
maxZeroLength = currentZeroLength;
|
||||||
|
maxZeroStart = currentZeroStart;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentZeroStart = -1;
|
||||||
|
currentZeroLength = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxZeroLength > 1) {
|
||||||
|
hextets.splice(maxZeroStart, maxZeroLength, '');
|
||||||
|
if (maxZeroStart === 0) hextets.unshift('');
|
||||||
|
if (maxZeroStart + maxZeroLength === 8) hextets.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return hextets.map(h => h === '0000' ? '0' : h.replace(/^0+/, '')).join(':');
|
||||||
}
|
}
|
||||||
return octets.join('.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts CIDR to IP range
|
* Converts CIDR to IP range
|
||||||
*/
|
*/
|
||||||
function cidrToRange(cidr: string): IPRange {
|
export function cidrToRange(cidr: string): IPRange {
|
||||||
const [ip, prefix] = cidr.split('/');
|
const [ip, prefix] = cidr.split('/');
|
||||||
|
const version = detectIpVersion(ip);
|
||||||
const prefixBits = parseInt(prefix);
|
const prefixBits = parseInt(prefix);
|
||||||
const ipBigInt = ipToBigInt(ip);
|
const ipBigInt = ipToBigInt(ip);
|
||||||
const mask = BigInt.asUintN(64, (BigInt(1) << BigInt(32 - prefixBits)) - BigInt(1));
|
|
||||||
|
// Validate prefix length
|
||||||
|
const maxPrefix = version === 4 ? 32 : 128;
|
||||||
|
if (prefixBits < 0 || prefixBits > maxPrefix) {
|
||||||
|
throw new Error(`Invalid prefix length for IPv${version}: ${prefix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shiftBits = BigInt(maxPrefix - prefixBits);
|
||||||
|
const mask = BigInt.asUintN(version === 4 ? 64 : 128, (BigInt(1) << shiftBits) - BigInt(1));
|
||||||
const start = ipBigInt & ~mask;
|
const start = ipBigInt & ~mask;
|
||||||
const end = start | mask;
|
const end = start | mask;
|
||||||
|
|
||||||
return { start, end };
|
return { start, end };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds the next available CIDR block given existing allocations
|
* Finds the next available CIDR block given existing allocations
|
||||||
* @param existingCidrs Array of existing CIDR blocks
|
* @param existingCidrs Array of existing CIDR blocks
|
||||||
* @param blockSize Desired prefix length for the new block (e.g., 24 for /24)
|
* @param blockSize Desired prefix length for the new block
|
||||||
* @param startCidr Optional CIDR to start searching from (default: "0.0.0.0/0")
|
* @param startCidr Optional CIDR to start searching from
|
||||||
* @returns Next available CIDR block or null if none found
|
* @returns Next available CIDR block or null if none found
|
||||||
*/
|
*/
|
||||||
export function findNextAvailableCidr(
|
export function findNextAvailableCidr(
|
||||||
existingCidrs: string[],
|
existingCidrs: string[],
|
||||||
blockSize: number,
|
blockSize: number,
|
||||||
startCidr: string = "0.0.0.0/0"
|
startCidr?: string
|
||||||
): string | null {
|
): string | null {
|
||||||
|
|
||||||
|
if (!startCidr && existingCidrs.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no existing CIDRs, use the IP version from startCidr
|
||||||
|
const version = startCidr
|
||||||
|
? detectIpVersion(startCidr.split('/')[0])
|
||||||
|
: 4; // Default to IPv4 if no startCidr provided
|
||||||
|
|
||||||
|
// Use appropriate default startCidr if none provided
|
||||||
|
startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0");
|
||||||
|
|
||||||
|
// If there are existing CIDRs, ensure all are same version
|
||||||
|
if (existingCidrs.length > 0 &&
|
||||||
|
existingCidrs.some(cidr => detectIpVersion(cidr.split('/')[0]) !== version)) {
|
||||||
|
throw new Error('All CIDRs must be of the same IP version');
|
||||||
|
}
|
||||||
|
|
||||||
// Convert existing CIDRs to ranges and sort them
|
// Convert existing CIDRs to ranges and sort them
|
||||||
const existingRanges = existingCidrs
|
const existingRanges = existingCidrs
|
||||||
.map(cidr => cidrToRange(cidr))
|
.map(cidr => cidrToRange(cidr))
|
||||||
.sort((a, b) => (a.start < b.start ? -1 : 1));
|
.sort((a, b) => (a.start < b.start ? -1 : 1));
|
||||||
|
|
||||||
// Calculate block size
|
// Calculate block size
|
||||||
const blockSizeBigInt = BigInt(1) << BigInt(32 - blockSize);
|
const maxPrefix = version === 4 ? 32 : 128;
|
||||||
|
const blockSizeBigInt = BigInt(1) << BigInt(maxPrefix - blockSize);
|
||||||
|
|
||||||
// Start from the beginning of the given CIDR
|
// Start from the beginning of the given CIDR
|
||||||
let current = cidrToRange(startCidr).start;
|
let current = cidrToRange(startCidr).start;
|
||||||
@@ -63,7 +167,6 @@ export function findNextAvailableCidr(
|
|||||||
// Iterate through existing ranges
|
// Iterate through existing ranges
|
||||||
for (let i = 0; i <= existingRanges.length; i++) {
|
for (let i = 0; i <= existingRanges.length; i++) {
|
||||||
const nextRange = existingRanges[i];
|
const nextRange = existingRanges[i];
|
||||||
|
|
||||||
// Align current to block size
|
// Align current to block size
|
||||||
const alignedCurrent = current + ((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt);
|
const alignedCurrent = current + ((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt);
|
||||||
|
|
||||||
@@ -74,7 +177,7 @@ export function findNextAvailableCidr(
|
|||||||
|
|
||||||
// If we're at the end of existing ranges or found a gap
|
// If we're at the end of existing ranges or found a gap
|
||||||
if (!nextRange || alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start) {
|
if (!nextRange || alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start) {
|
||||||
return `${bigIntToIp(alignedCurrent)}/${blockSize}`;
|
return `${bigIntToIp(alignedCurrent, version)}/${blockSize}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move current pointer to after the current range
|
// Move current pointer to after the current range
|
||||||
@@ -85,12 +188,19 @@ export function findNextAvailableCidr(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a given IP address is within a CIDR range
|
* Checks if a given IP address is within a CIDR range
|
||||||
* @param ip IP address to check
|
* @param ip IP address to check
|
||||||
* @param cidr CIDR range to check against
|
* @param cidr CIDR range to check against
|
||||||
* @returns boolean indicating if IP is within the CIDR range
|
* @returns boolean indicating if IP is within the CIDR range
|
||||||
*/
|
*/
|
||||||
export function isIpInCidr(ip: string, cidr: string): boolean {
|
export function isIpInCidr(ip: string, cidr: string): boolean {
|
||||||
|
const ipVersion = detectIpVersion(ip);
|
||||||
|
const cidrVersion = detectIpVersion(cidr.split('/')[0]);
|
||||||
|
|
||||||
|
if (ipVersion !== cidrVersion) {
|
||||||
|
throw new Error('IP address and CIDR must be of the same version');
|
||||||
|
}
|
||||||
|
|
||||||
const ipBigInt = ipToBigInt(ip);
|
const ipBigInt = ipToBigInt(ip);
|
||||||
const range = cidrToRange(cidr);
|
const range = cidrToRange(cidr);
|
||||||
return ipBigInt >= range.start && ipBigInt <= range.end;
|
return ipBigInt >= range.start && ipBigInt <= range.end;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function isValidUrlGlobPattern(pattern: string): boolean {
|
|||||||
for (let i = 0; i < segments.length; i++) {
|
for (let i = 0; i < segments.length; i++) {
|
||||||
const segment = segments[i];
|
const segment = segments[i];
|
||||||
|
|
||||||
// Empty segments are not allowed (double slashes)
|
// Empty segments are not allowed (double slashes), except at the end
|
||||||
if (!segment && i !== segments.length - 1) {
|
if (!segment && i !== segments.length - 1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -34,11 +34,63 @@ export function isValidUrlGlobPattern(pattern: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for invalid characters
|
// Check each character in the segment
|
||||||
if (!/^[a-zA-Z0-9_*-]*$/.test(segment)) {
|
for (let j = 0; j < segment.length; j++) {
|
||||||
return false;
|
const char = segment[j];
|
||||||
|
|
||||||
|
// Check for percent-encoded sequences
|
||||||
|
if (char === "%" && j + 2 < segment.length) {
|
||||||
|
const hex1 = segment[j + 1];
|
||||||
|
const hex2 = segment[j + 2];
|
||||||
|
if (
|
||||||
|
!/^[0-9A-Fa-f]$/.test(hex1) ||
|
||||||
|
!/^[0-9A-Fa-f]$/.test(hex2)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
j += 2; // Skip the next two characters
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow:
|
||||||
|
// - unreserved (A-Z a-z 0-9 - . _ ~)
|
||||||
|
// - sub-delims (! $ & ' ( ) * + , ; =)
|
||||||
|
// - @ : for compatibility with some systems
|
||||||
|
if (!/^[A-Za-z0-9\-._~!$&'()*+,;=@:]$/.test(char)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isUrlValid(url: string | undefined) {
|
||||||
|
if (!url) return true; // the link is optional in the schema so if it's empty it's valid
|
||||||
|
var pattern = new RegExp(
|
||||||
|
"^(https?:\\/\\/)?" + // protocol
|
||||||
|
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name
|
||||||
|
"((\\d{1,3}\\.){3}\\d{1,3}))" + // OR ip (v4) address
|
||||||
|
"(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + // port and path
|
||||||
|
"(\\?[;&a-z\\d%_.~+=-]*)?" + // query string
|
||||||
|
"(\\#[-a-z\\d_]*)?$",
|
||||||
|
"i"
|
||||||
|
);
|
||||||
|
return !!pattern.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTargetValid(value: string | undefined) {
|
||||||
|
if (!value) return true;
|
||||||
|
|
||||||
|
const DOMAIN_REGEX =
|
||||||
|
/^[a-zA-Z0-9_](?:[a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?(?:\.[a-zA-Z0-9_](?:[a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?)*$/;
|
||||||
|
const IPV4_REGEX =
|
||||||
|
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||||
|
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
|
||||||
|
|
||||||
|
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DOMAIN_REGEX.test(value);
|
||||||
|
}
|
||||||
|
|||||||
@@ -149,8 +149,6 @@ export async function resetPassword(
|
|||||||
|
|
||||||
const passwordHash = await hashPassword(newPassword);
|
const passwordHash = await hashPassword(newPassword);
|
||||||
|
|
||||||
await invalidateAllSessions(resetRequest[0].userId);
|
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx
|
await trx
|
||||||
.update(users)
|
.update(users)
|
||||||
@@ -162,11 +160,21 @@ export async function resetPassword(
|
|||||||
.where(eq(passwordResetTokens.email, email));
|
.where(eq(passwordResetTokens.email, email));
|
||||||
});
|
});
|
||||||
|
|
||||||
await sendEmail(ConfirmPasswordReset({ email }), {
|
try {
|
||||||
from: config.getNoReplyEmail(),
|
await invalidateAllSessions(resetRequest[0].userId);
|
||||||
to: email,
|
} catch (e) {
|
||||||
subject: "Password Reset Confirmation"
|
logger.error("Failed to invalidate user sessions", e);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendEmail(ConfirmPasswordReset({ email }), {
|
||||||
|
from: config.getNoReplyEmail(),
|
||||||
|
to: email,
|
||||||
|
subject: "Password Reset Confirmation"
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to send password reset confirmation email", e);
|
||||||
|
}
|
||||||
|
|
||||||
return response<ResetPasswordResponse>(res, {
|
return response<ResetPasswordResponse>(res, {
|
||||||
data: null,
|
data: null,
|
||||||
|
|||||||
@@ -90,7 +90,15 @@ export async function verifyResourceSession(
|
|||||||
|
|
||||||
const clientIp = requestIp?.split(":")[0];
|
const clientIp = requestIp?.split(":")[0];
|
||||||
|
|
||||||
const resourceCacheKey = `resource:${host}`;
|
let cleanHost = host;
|
||||||
|
// if the host ends with :443 or :80 remove it
|
||||||
|
if (cleanHost.endsWith(":443")) {
|
||||||
|
cleanHost = cleanHost.slice(0, -4);
|
||||||
|
} else if (cleanHost.endsWith(":80")) {
|
||||||
|
cleanHost = cleanHost.slice(0, -3);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceCacheKey = `resource:${cleanHost}`;
|
||||||
let resourceData:
|
let resourceData:
|
||||||
| {
|
| {
|
||||||
resource: Resource | null;
|
resource: Resource | null;
|
||||||
@@ -111,11 +119,11 @@ export async function verifyResourceSession(
|
|||||||
resourcePassword,
|
resourcePassword,
|
||||||
eq(resourcePassword.resourceId, resources.resourceId)
|
eq(resourcePassword.resourceId, resources.resourceId)
|
||||||
)
|
)
|
||||||
.where(eq(resources.fullDomain, host))
|
.where(eq(resources.fullDomain, cleanHost))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
logger.debug("Resource not found", host);
|
logger.debug("Resource not found", cleanHost);
|
||||||
return notAllowed(res);
|
return notAllowed(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +139,7 @@ export async function verifyResourceSession(
|
|||||||
const { resource, pincode, password } = resourceData;
|
const { resource, pincode, password } = resourceData;
|
||||||
|
|
||||||
if (!resource) {
|
if (!resource) {
|
||||||
logger.debug("Resource not found", host);
|
logger.debug("Resource not found", cleanHost);
|
||||||
return notAllowed(res);
|
return notAllowed(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,16 +150,6 @@ export async function verifyResourceSession(
|
|||||||
return notAllowed(res);
|
return notAllowed(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
!resource.sso &&
|
|
||||||
!pincode &&
|
|
||||||
!password &&
|
|
||||||
!resource.emailWhitelistEnabled
|
|
||||||
) {
|
|
||||||
logger.debug("Resource allowed because no auth");
|
|
||||||
return allowed(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
// check the rules
|
// check the rules
|
||||||
if (resource.applyRules) {
|
if (resource.applyRules) {
|
||||||
const action = await checkRules(
|
const action = await checkRules(
|
||||||
@@ -171,6 +169,16 @@ export async function verifyResourceSession(
|
|||||||
// otherwise its undefined and we pass
|
// otherwise its undefined and we pass
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!resource.sso &&
|
||||||
|
!pincode &&
|
||||||
|
!password &&
|
||||||
|
!resource.emailWhitelistEnabled
|
||||||
|
) {
|
||||||
|
logger.debug("Resource allowed because no auth");
|
||||||
|
return allowed(res);
|
||||||
|
}
|
||||||
|
|
||||||
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(
|
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(
|
||||||
resource.resourceId
|
resource.resourceId
|
||||||
)}?redirect=${encodeURIComponent(originalRequestURL)}`;
|
)}?redirect=${encodeURIComponent(originalRequestURL)}`;
|
||||||
|
|||||||
1
server/routers/domain/index.ts
Normal file
1
server/routers/domain/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./listDomains";
|
||||||
109
server/routers/domain/listDomains.ts
Normal file
109
server/routers/domain/listDomains.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { domains, orgDomains, users } from "@server/db/schema";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const listDomainsParamsSchema = z
|
||||||
|
.object({
|
||||||
|
orgId: z.string()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const listDomainsSchema = z
|
||||||
|
.object({
|
||||||
|
limit: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("1000")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().nonnegative()),
|
||||||
|
offset: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("0")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().nonnegative())
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
async function queryDomains(orgId: string, limit: number, offset: number) {
|
||||||
|
const res = await db
|
||||||
|
.select({
|
||||||
|
domainId: domains.domainId,
|
||||||
|
baseDomain: domains.baseDomain
|
||||||
|
})
|
||||||
|
.from(orgDomains)
|
||||||
|
.where(eq(orgDomains.orgId, orgId))
|
||||||
|
.innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListDomainsResponse = {
|
||||||
|
domains: NonNullable<Awaited<ReturnType<typeof queryDomains>>>;
|
||||||
|
pagination: { total: number; limit: number; offset: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listDomains(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedQuery = listDomainsSchema.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedQuery.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { limit, offset } = parsedQuery.data;
|
||||||
|
|
||||||
|
const parsedParams = listDomainsParamsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
|
const domains = await queryDomains(orgId.toString(), limit, offset);
|
||||||
|
|
||||||
|
const [{ count }] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(users);
|
||||||
|
|
||||||
|
return response<ListDomainsResponse>(res, {
|
||||||
|
data: {
|
||||||
|
domains,
|
||||||
|
pagination: {
|
||||||
|
total: count,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Users retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import config from "@server/lib/config";
|
|||||||
import * as site from "./site";
|
import * as site from "./site";
|
||||||
import * as org from "./org";
|
import * as org from "./org";
|
||||||
import * as resource from "./resource";
|
import * as resource from "./resource";
|
||||||
|
import * as domain from "./domain";
|
||||||
import * as target from "./target";
|
import * as target from "./target";
|
||||||
import * as user from "./user";
|
import * as user from "./user";
|
||||||
import * as auth from "./auth";
|
import * as auth from "./auth";
|
||||||
@@ -133,6 +134,13 @@ authenticated.get(
|
|||||||
resource.listResources
|
resource.listResources
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/domains",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listOrgDomains),
|
||||||
|
domain.listDomains
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/org/:orgId/create-invite",
|
"/org/:orgId/create-invite",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
|||||||
@@ -2,7 +2,15 @@ import { Request, Response, NextFunction } from "express";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { Org, orgs, roleActions, roles, userOrgs } from "@server/db/schema";
|
import {
|
||||||
|
domains,
|
||||||
|
Org,
|
||||||
|
orgDomains,
|
||||||
|
orgs,
|
||||||
|
roleActions,
|
||||||
|
roles,
|
||||||
|
userOrgs
|
||||||
|
} from "@server/db/schema";
|
||||||
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";
|
||||||
@@ -16,7 +24,6 @@ const createOrgSchema = z
|
|||||||
.object({
|
.object({
|
||||||
orgId: z.string(),
|
orgId: z.string(),
|
||||||
name: z.string().min(1).max(255)
|
name: z.string().min(1).max(255)
|
||||||
// domain: z.string().min(1).max(255).optional(),
|
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -82,14 +89,16 @@ export async function createOrg(
|
|||||||
let org: Org | null = null;
|
let org: Org | null = null;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
const domain = config.getBaseDomain();
|
const allDomains = await trx
|
||||||
|
.select()
|
||||||
|
.from(domains)
|
||||||
|
.where(eq(domains.configManaged, true));
|
||||||
|
|
||||||
const newOrg = await trx
|
const newOrg = await trx
|
||||||
.insert(orgs)
|
.insert(orgs)
|
||||||
.values({
|
.values({
|
||||||
orgId,
|
orgId,
|
||||||
name,
|
name
|
||||||
domain
|
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -109,6 +118,13 @@ export async function createOrg(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await trx.insert(orgDomains).values(
|
||||||
|
allDomains.map((domain) => ({
|
||||||
|
orgId: newOrg[0].orgId,
|
||||||
|
domainId: domain.domainId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
await trx.insert(userOrgs).values({
|
await trx.insert(userOrgs).values({
|
||||||
userId: req.user!.userId,
|
userId: req.user!.userId,
|
||||||
orgId: newOrg[0].orgId,
|
orgId: newOrg[0].orgId,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { SqliteError } from "better-sqlite3";
|
|
||||||
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 } from "@server/db";
|
||||||
import {
|
import {
|
||||||
|
domains,
|
||||||
|
orgDomains,
|
||||||
orgs,
|
orgs,
|
||||||
Resource,
|
Resource,
|
||||||
resources,
|
resources,
|
||||||
@@ -27,69 +28,29 @@ const createResourceParamsSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const createResourceSchema = z
|
const createHttpResourceSchema = z
|
||||||
.object({
|
.object({
|
||||||
subdomain: z.string().optional(),
|
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
|
subdomain: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((val) => val?.toLowerCase()),
|
||||||
|
isBaseDomain: z.boolean().optional(),
|
||||||
siteId: z.number(),
|
siteId: z.number(),
|
||||||
http: z.boolean(),
|
http: z.boolean(),
|
||||||
protocol: z.string(),
|
protocol: z.string(),
|
||||||
proxyPort: z.number().optional(),
|
domainId: z.string()
|
||||||
isBaseDomain: z.boolean().optional()
|
|
||||||
})
|
})
|
||||||
|
.strict()
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (!data.http) {
|
if (data.subdomain) {
|
||||||
return z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(1)
|
|
||||||
.max(65535)
|
|
||||||
.safeParse(data.proxyPort).success;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "Invalid port number",
|
|
||||||
path: ["proxyPort"]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
if (data.http && !data.isBaseDomain) {
|
|
||||||
return subdomainSchema.safeParse(data.subdomain).success;
|
return subdomainSchema.safeParse(data.subdomain).success;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
{
|
{ message: "Invalid subdomain" }
|
||||||
message: "Invalid subdomain",
|
|
||||||
path: ["subdomain"]
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
if (!config.getRawConfig().flags?.allow_raw_resources) {
|
|
||||||
if (data.proxyPort !== undefined) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "Proxy port cannot be set"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
// .refine(
|
|
||||||
// (data) => {
|
|
||||||
// if (data.proxyPort === 443 || data.proxyPort === 80) {
|
|
||||||
// return false;
|
|
||||||
// }
|
|
||||||
// return true;
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// message: "Port 80 and 443 are reserved for http and https resources"
|
|
||||||
// }
|
|
||||||
// )
|
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (!config.getRawConfig().flags?.allow_base_domain_resources) {
|
if (!config.getRawConfig().flags?.allow_base_domain_resources) {
|
||||||
@@ -104,6 +65,29 @@ const createResourceSchema = z
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const createRawResourceSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
siteId: z.number(),
|
||||||
|
http: z.boolean(),
|
||||||
|
protocol: z.string(),
|
||||||
|
proxyPort: z.number().int().min(1).max(65535)
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (!config.getRawConfig().flags?.allow_raw_resources) {
|
||||||
|
if (data.proxyPort !== undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Proxy port cannot be set"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export type CreateResourceResponse = Resource;
|
export type CreateResourceResponse = Resource;
|
||||||
|
|
||||||
export async function createResource(
|
export async function createResource(
|
||||||
@@ -112,18 +96,6 @@ export async function createResource(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const parsedBody = createResourceSchema.safeParse(req.body);
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let { name, subdomain, protocol, proxyPort, http, isBaseDomain } = parsedBody.data;
|
|
||||||
|
|
||||||
// Validate request params
|
// Validate request params
|
||||||
const parsedParams = createResourceParamsSchema.safeParse(req.params);
|
const parsedParams = createResourceParamsSchema.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
@@ -159,99 +131,25 @@ export async function createResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let fullDomain = "";
|
if (typeof req.body.http !== "boolean") {
|
||||||
if (isBaseDomain) {
|
return next(
|
||||||
fullDomain = org[0].domain;
|
createHttpError(HttpCode.BAD_REQUEST, "http field is required")
|
||||||
} else {
|
);
|
||||||
fullDomain = `${subdomain}.${org[0].domain}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// if http is false check to see if there is already a resource with the same port and protocol
|
const { http } = req.body;
|
||||||
if (!http) {
|
|
||||||
const existingResource = await db
|
|
||||||
.select()
|
|
||||||
.from(resources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resources.protocol, protocol),
|
|
||||||
eq(resources.proxyPort, proxyPort!)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingResource.length > 0) {
|
if (http) {
|
||||||
return next(
|
return await createHttpResource(
|
||||||
createHttpError(
|
{ req, res, next },
|
||||||
HttpCode.CONFLICT,
|
{ siteId, orgId }
|
||||||
"Resource with that protocol and port already exists"
|
);
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// make sure the full domain is unique
|
return await createRawResource(
|
||||||
const existingResource = await db
|
{ req, res, next },
|
||||||
.select()
|
{ siteId, orgId }
|
||||||
.from(resources)
|
);
|
||||||
.where(eq(resources.fullDomain, fullDomain));
|
|
||||||
|
|
||||||
if (existingResource.length > 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.CONFLICT,
|
|
||||||
"Resource with that domain already exists"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
|
||||||
const newResource = await trx
|
|
||||||
.insert(resources)
|
|
||||||
.values({
|
|
||||||
siteId,
|
|
||||||
fullDomain: http ? fullDomain : null,
|
|
||||||
orgId,
|
|
||||||
name,
|
|
||||||
subdomain,
|
|
||||||
http,
|
|
||||||
protocol,
|
|
||||||
proxyPort,
|
|
||||||
ssl: true,
|
|
||||||
isBaseDomain
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
const adminRole = await db
|
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (adminRole.length === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await trx.insert(roleResources).values({
|
|
||||||
roleId: adminRole[0].roleId,
|
|
||||||
resourceId: newResource[0].resourceId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (req.userOrgRoleId != adminRole[0].roleId) {
|
|
||||||
// make sure the user can access the resource
|
|
||||||
await trx.insert(userResources).values({
|
|
||||||
userId: req.user?.userId!,
|
|
||||||
resourceId: newResource[0].resourceId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
response<CreateResourceResponse>(res, {
|
|
||||||
data: newResource[0],
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Resource created successfully",
|
|
||||||
status: HttpCode.CREATED
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
return next(
|
return next(
|
||||||
@@ -259,3 +157,245 @@ export async function createResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createHttpResource(
|
||||||
|
route: {
|
||||||
|
req: Request;
|
||||||
|
res: Response;
|
||||||
|
next: NextFunction;
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
siteId: number;
|
||||||
|
orgId: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { req, res, next } = route;
|
||||||
|
const { siteId, orgId } = meta;
|
||||||
|
|
||||||
|
const parsedBody = createHttpResourceSchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, subdomain, isBaseDomain, http, protocol, domainId } =
|
||||||
|
parsedBody.data;
|
||||||
|
|
||||||
|
const [orgDomain] = await db
|
||||||
|
.select()
|
||||||
|
.from(orgDomains)
|
||||||
|
.where(
|
||||||
|
and(eq(orgDomains.orgId, orgId), eq(orgDomains.domainId, domainId))
|
||||||
|
)
|
||||||
|
.leftJoin(domains, eq(orgDomains.domainId, domains.domainId));
|
||||||
|
|
||||||
|
if (!orgDomain || !orgDomain.domains) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Domain with ID ${parsedBody.data.domainId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = orgDomain.domains;
|
||||||
|
|
||||||
|
let fullDomain = "";
|
||||||
|
if (isBaseDomain) {
|
||||||
|
fullDomain = domain.baseDomain;
|
||||||
|
} else {
|
||||||
|
fullDomain = `${subdomain}.${domain.baseDomain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Full domain: ${fullDomain}`);
|
||||||
|
|
||||||
|
// make sure the full domain is unique
|
||||||
|
const existingResource = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.fullDomain, fullDomain));
|
||||||
|
|
||||||
|
if (existingResource.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Resource with that domain already exists"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resource: Resource | undefined;
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
const newResource = await trx
|
||||||
|
.insert(resources)
|
||||||
|
.values({
|
||||||
|
siteId,
|
||||||
|
fullDomain,
|
||||||
|
domainId,
|
||||||
|
orgId,
|
||||||
|
name,
|
||||||
|
subdomain,
|
||||||
|
http,
|
||||||
|
protocol,
|
||||||
|
ssl: true,
|
||||||
|
isBaseDomain
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const adminRole = await db
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (adminRole.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.insert(roleResources).values({
|
||||||
|
roleId: adminRole[0].roleId,
|
||||||
|
resourceId: newResource[0].resourceId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.userOrgRoleId != adminRole[0].roleId) {
|
||||||
|
// make sure the user can access the resource
|
||||||
|
await trx.insert(userResources).values({
|
||||||
|
userId: req.user?.userId!,
|
||||||
|
resourceId: newResource[0].resourceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resource = newResource[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to create resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<CreateResourceResponse>(res, {
|
||||||
|
data: resource,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Http resource created successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRawResource(
|
||||||
|
route: {
|
||||||
|
req: Request;
|
||||||
|
res: Response;
|
||||||
|
next: NextFunction;
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
siteId: number;
|
||||||
|
orgId: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { req, res, next } = route;
|
||||||
|
const { siteId, orgId } = meta;
|
||||||
|
|
||||||
|
const parsedBody = createRawResourceSchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, http, protocol, proxyPort } = parsedBody.data;
|
||||||
|
|
||||||
|
// if http is false check to see if there is already a resource with the same port and protocol
|
||||||
|
const existingResource = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.protocol, protocol),
|
||||||
|
eq(resources.proxyPort, proxyPort!)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingResource.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Resource with that protocol and port already exists"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resource: Resource | undefined;
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
const newResource = await trx
|
||||||
|
.insert(resources)
|
||||||
|
.values({
|
||||||
|
siteId,
|
||||||
|
orgId,
|
||||||
|
name,
|
||||||
|
http,
|
||||||
|
protocol,
|
||||||
|
proxyPort
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const adminRole = await db
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (adminRole.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.insert(roleResources).values({
|
||||||
|
roleId: adminRole[0].roleId,
|
||||||
|
resourceId: newResource[0].resourceId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.userOrgRoleId != adminRole[0].roleId) {
|
||||||
|
// make sure the user can access the resource
|
||||||
|
await trx.insert(userResources).values({
|
||||||
|
userId: req.user?.userId!,
|
||||||
|
resourceId: newResource[0].resourceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resource = newResource[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to create resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<CreateResourceResponse>(res, {
|
||||||
|
data: resource,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Non-http resource created successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
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 } from "@server/db";
|
||||||
import { orgs, resources, sites } from "@server/db/schema";
|
import {
|
||||||
import { eq, or, and } from "drizzle-orm";
|
domains,
|
||||||
|
Org,
|
||||||
|
orgDomains,
|
||||||
|
orgs,
|
||||||
|
Resource,
|
||||||
|
resources
|
||||||
|
} from "@server/db/schema";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -20,17 +27,53 @@ const updateResourceParamsSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const updateResourceBodySchema = z
|
const updateHttpResourceBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
subdomain: subdomainSchema.optional(),
|
subdomain: subdomainSchema
|
||||||
|
.optional()
|
||||||
|
.transform((val) => val?.toLowerCase()),
|
||||||
ssl: z.boolean().optional(),
|
ssl: z.boolean().optional(),
|
||||||
sso: z.boolean().optional(),
|
sso: z.boolean().optional(),
|
||||||
blockAccess: z.boolean().optional(),
|
blockAccess: z.boolean().optional(),
|
||||||
proxyPort: z.number().int().min(1).max(65535).optional(),
|
|
||||||
emailWhitelistEnabled: z.boolean().optional(),
|
emailWhitelistEnabled: z.boolean().optional(),
|
||||||
isBaseDomain: z.boolean().optional(),
|
isBaseDomain: z.boolean().optional(),
|
||||||
applyRules: z.boolean().optional(),
|
applyRules: z.boolean().optional(),
|
||||||
|
domainId: z.string().optional()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
|
message: "At least one field must be provided for update"
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.subdomain) {
|
||||||
|
return subdomainSchema.safeParse(data.subdomain).success;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{ message: "Invalid subdomain" }
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (!config.getRawConfig().flags?.allow_base_domain_resources) {
|
||||||
|
if (data.isBaseDomain) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Base domain resources are not allowed"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type UpdateResourceResponse = Resource;
|
||||||
|
|
||||||
|
const updateRawResourceBodySchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
proxyPort: z.number().int().min(1).max(65535).optional()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
@@ -46,30 +89,6 @@ const updateResourceBodySchema = z
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
{ message: "Cannot update proxyPort" }
|
{ message: "Cannot update proxyPort" }
|
||||||
)
|
|
||||||
// .refine(
|
|
||||||
// (data) => {
|
|
||||||
// if (data.proxyPort === 443 || data.proxyPort === 80) {
|
|
||||||
// return false;
|
|
||||||
// }
|
|
||||||
// return true;
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// message: "Port 80 and 443 are reserved for http and https resources"
|
|
||||||
// }
|
|
||||||
// )
|
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
if (!config.getRawConfig().flags?.allow_base_domain_resources) {
|
|
||||||
if (data.isBaseDomain) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "Base domain resources are not allowed"
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export async function updateResource(
|
export async function updateResource(
|
||||||
@@ -88,18 +107,7 @@ export async function updateResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedBody = updateResourceBodySchema.safeParse(req.body);
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { resourceId } = parsedParams.data;
|
const { resourceId } = parsedParams.data;
|
||||||
const updateData = parsedBody.data;
|
|
||||||
|
|
||||||
const [result] = await db
|
const [result] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -119,117 +127,33 @@ export async function updateResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updateData.subdomain) {
|
if (resource.http) {
|
||||||
if (!resource.http) {
|
// HANDLE UPDATING HTTP RESOURCES
|
||||||
return next(
|
return await updateHttpResource(
|
||||||
createHttpError(
|
{
|
||||||
HttpCode.BAD_REQUEST,
|
req,
|
||||||
"Cannot update subdomain for non-http resource"
|
res,
|
||||||
)
|
next
|
||||||
);
|
},
|
||||||
}
|
{
|
||||||
|
resource,
|
||||||
const valid = subdomainSchema.safeParse(
|
org
|
||||||
updateData.subdomain
|
}
|
||||||
).success;
|
);
|
||||||
if (!valid) {
|
} else {
|
||||||
return next(
|
// HANDLE UPDATING RAW TCP/UDP RESOURCES
|
||||||
createHttpError(
|
return await updateRawResource(
|
||||||
HttpCode.BAD_REQUEST,
|
{
|
||||||
"Invalid subdomain provided"
|
req,
|
||||||
)
|
res,
|
||||||
);
|
next
|
||||||
}
|
},
|
||||||
}
|
{
|
||||||
|
resource,
|
||||||
if (updateData.proxyPort) {
|
org
|
||||||
const proxyPort = updateData.proxyPort;
|
}
|
||||||
const existingResource = await db
|
|
||||||
.select()
|
|
||||||
.from(resources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resources.protocol, resource.protocol),
|
|
||||||
eq(resources.proxyPort, proxyPort!)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
existingResource.length > 0 &&
|
|
||||||
existingResource[0].resourceId !== resourceId
|
|
||||||
) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.CONFLICT,
|
|
||||||
"Resource with that protocol and port already exists"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!org?.domain) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Resource does not have a domain"
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let fullDomain: string | undefined;
|
|
||||||
if (updateData.isBaseDomain) {
|
|
||||||
fullDomain = org.domain;
|
|
||||||
} else if (updateData.subdomain) {
|
|
||||||
fullDomain = `${updateData.subdomain}.${org.domain}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatePayload = {
|
|
||||||
...updateData,
|
|
||||||
...(fullDomain && { fullDomain })
|
|
||||||
};
|
|
||||||
|
|
||||||
if (
|
|
||||||
fullDomain &&
|
|
||||||
(updatePayload.subdomain !== undefined ||
|
|
||||||
updatePayload.isBaseDomain !== undefined)
|
|
||||||
) {
|
|
||||||
const [existingDomain] = await db
|
|
||||||
.select()
|
|
||||||
.from(resources)
|
|
||||||
.where(eq(resources.fullDomain, fullDomain));
|
|
||||||
|
|
||||||
if (existingDomain && existingDomain.resourceId !== resourceId) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.CONFLICT,
|
|
||||||
"Resource with that domain already exists"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedResource = await db
|
|
||||||
.update(resources)
|
|
||||||
.set(updatePayload)
|
|
||||||
.where(eq(resources.resourceId, resourceId))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (updatedResource.length === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Resource with ID ${resourceId} not found`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: updatedResource[0],
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Resource updated successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
return next(
|
return next(
|
||||||
@@ -237,3 +161,186 @@ export async function updateResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateHttpResource(
|
||||||
|
route: {
|
||||||
|
req: Request;
|
||||||
|
res: Response;
|
||||||
|
next: NextFunction;
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
resource: Resource;
|
||||||
|
org: Org;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { next, req, res } = route;
|
||||||
|
const { resource, org } = meta;
|
||||||
|
|
||||||
|
const parsedBody = updateHttpResourceBodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = parsedBody.data;
|
||||||
|
|
||||||
|
if (updateData.domainId) {
|
||||||
|
const [existingDomain] = await db
|
||||||
|
.select()
|
||||||
|
.from(orgDomains)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(orgDomains.orgId, org.orgId),
|
||||||
|
eq(orgDomains.domainId, updateData.domainId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(domains, eq(orgDomains.domainId, domains.domainId));
|
||||||
|
|
||||||
|
if (!existingDomain) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, `Domain not found`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainId = updateData.domainId || resource.domainId!;
|
||||||
|
const subdomain = updateData.subdomain || resource.subdomain;
|
||||||
|
|
||||||
|
const [domain] = await db
|
||||||
|
.select()
|
||||||
|
.from(domains)
|
||||||
|
.where(eq(domains.domainId, domainId));
|
||||||
|
|
||||||
|
let fullDomain: string | null = null;
|
||||||
|
if (updateData.isBaseDomain) {
|
||||||
|
fullDomain = domain.baseDomain;
|
||||||
|
} else if (subdomain && domain) {
|
||||||
|
fullDomain = `${subdomain}.${domain.baseDomain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullDomain) {
|
||||||
|
const [existingDomain] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.fullDomain, fullDomain));
|
||||||
|
|
||||||
|
if (
|
||||||
|
existingDomain &&
|
||||||
|
existingDomain.resourceId !== resource.resourceId
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Resource with that domain already exists"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePayload = {
|
||||||
|
...updateData,
|
||||||
|
fullDomain
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedResource = await db
|
||||||
|
.update(resources)
|
||||||
|
.set(updatePayload)
|
||||||
|
.where(eq(resources.resourceId, resource.resourceId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (updatedResource.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resource.resourceId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: updatedResource[0],
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "HTTP resource updated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateRawResource(
|
||||||
|
route: {
|
||||||
|
req: Request;
|
||||||
|
res: Response;
|
||||||
|
next: NextFunction;
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
resource: Resource;
|
||||||
|
org: Org;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { next, req, res } = route;
|
||||||
|
const { resource } = meta;
|
||||||
|
|
||||||
|
const parsedBody = updateRawResourceBodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = parsedBody.data;
|
||||||
|
|
||||||
|
if (updateData.proxyPort) {
|
||||||
|
const proxyPort = updateData.proxyPort;
|
||||||
|
const existingResource = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.protocol, resource.protocol),
|
||||||
|
eq(resources.proxyPort, proxyPort!)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
existingResource.length > 0 &&
|
||||||
|
existingResource[0].resourceId !== resource.resourceId
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Resource with that protocol and port already exists"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedResource = await db
|
||||||
|
.update(resources)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(resources.resourceId, resource.resourceId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (updatedResource.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resource.resourceId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: updatedResource[0],
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Non-http Resource updated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,34 +12,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { addTargets } from "../newt/targets";
|
import { addTargets } from "../newt/targets";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { pickPort } from "./helpers";
|
import { pickPort } from "./helpers";
|
||||||
|
import { isTargetValid } from "@server/lib/validators";
|
||||||
// Regular expressions for validation
|
|
||||||
const DOMAIN_REGEX =
|
|
||||||
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
|
||||||
const IPV4_REGEX =
|
|
||||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
|
||||||
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
|
|
||||||
|
|
||||||
// Schema for domain names and IP addresses
|
|
||||||
const domainSchema = z
|
|
||||||
.string()
|
|
||||||
.min(1, "Domain cannot be empty")
|
|
||||||
.max(255, "Domain name too long")
|
|
||||||
.refine(
|
|
||||||
(value) => {
|
|
||||||
// Check if it's a valid IP address (v4 or v6)
|
|
||||||
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a valid domain name
|
|
||||||
return DOMAIN_REGEX.test(value);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "Invalid domain name or IP address format",
|
|
||||||
path: ["domain"]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const createTargetParamsSchema = z
|
const createTargetParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -52,7 +25,7 @@ const createTargetParamsSchema = z
|
|||||||
|
|
||||||
const createTargetSchema = z
|
const createTargetSchema = z
|
||||||
.object({
|
.object({
|
||||||
ip: domainSchema,
|
ip: z.string().refine(isTargetValid),
|
||||||
method: z.string().optional().nullable(),
|
method: z.string().optional().nullable(),
|
||||||
port: z.number().int().min(1).max(65535),
|
port: z.number().int().min(1).max(65535),
|
||||||
enabled: z.boolean().default(true)
|
enabled: z.boolean().default(true)
|
||||||
|
|||||||
@@ -11,34 +11,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { addPeer } from "../gerbil/peers";
|
import { addPeer } from "../gerbil/peers";
|
||||||
import { addTargets } from "../newt/targets";
|
import { addTargets } from "../newt/targets";
|
||||||
import { pickPort } from "./helpers";
|
import { pickPort } from "./helpers";
|
||||||
|
import { isTargetValid } from "@server/lib/validators";
|
||||||
// Regular expressions for validation
|
|
||||||
const DOMAIN_REGEX =
|
|
||||||
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
|
||||||
const IPV4_REGEX =
|
|
||||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
|
||||||
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
|
|
||||||
|
|
||||||
// Schema for domain names and IP addresses
|
|
||||||
const domainSchema = z
|
|
||||||
.string()
|
|
||||||
.min(1, "Domain cannot be empty")
|
|
||||||
.max(255, "Domain name too long")
|
|
||||||
.refine(
|
|
||||||
(value) => {
|
|
||||||
// Check if it's a valid IP address (v4 or v6)
|
|
||||||
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a valid domain name
|
|
||||||
return DOMAIN_REGEX.test(value);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "Invalid domain name or IP address format",
|
|
||||||
path: ["domain"]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateTargetParamsSchema = z
|
const updateTargetParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -48,7 +21,7 @@ const updateTargetParamsSchema = z
|
|||||||
|
|
||||||
const updateTargetBodySchema = z
|
const updateTargetBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
ip: domainSchema.optional(),
|
ip: z.string().refine(isTargetValid),
|
||||||
method: z.string().min(1).max(10).optional().nullable(),
|
method: z.string().min(1).max(10).optional().nullable(),
|
||||||
port: z.number().int().min(1).max(65535).optional(),
|
port: z.number().int().min(1).max(65535).optional(),
|
||||||
enabled: z.boolean().optional()
|
enabled: z.boolean().optional()
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export async function traefikConfigProvider(
|
|||||||
proxyPort: resources.proxyPort,
|
proxyPort: resources.proxyPort,
|
||||||
protocol: resources.protocol,
|
protocol: resources.protocol,
|
||||||
isBaseDomain: resources.isBaseDomain,
|
isBaseDomain: resources.isBaseDomain,
|
||||||
|
domainId: resources.domainId,
|
||||||
// Site fields
|
// Site fields
|
||||||
site: {
|
site: {
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
@@ -34,8 +35,7 @@ export async function traefikConfigProvider(
|
|||||||
},
|
},
|
||||||
// Org fields
|
// Org fields
|
||||||
org: {
|
org: {
|
||||||
orgId: orgs.orgId,
|
orgId: orgs.orgId
|
||||||
domain: orgs.domain
|
|
||||||
},
|
},
|
||||||
// Targets as a subquery
|
// Targets as a subquery
|
||||||
targets: sql<string>`json_group_array(json_object(
|
targets: sql<string>`json_group_array(json_object(
|
||||||
@@ -105,15 +105,22 @@ export async function traefikConfigProvider(
|
|||||||
const site = resource.site;
|
const site = resource.site;
|
||||||
const org = resource.org;
|
const org = resource.org;
|
||||||
|
|
||||||
if (!org.domain) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const routerName = `${resource.resourceId}-router`;
|
const routerName = `${resource.resourceId}-router`;
|
||||||
const serviceName = `${resource.resourceId}-service`;
|
const serviceName = `${resource.resourceId}-service`;
|
||||||
const fullDomain = `${resource.fullDomain}`;
|
const fullDomain = `${resource.fullDomain}`;
|
||||||
|
|
||||||
if (resource.http) {
|
if (resource.http) {
|
||||||
|
if (!resource.domainId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resource.fullDomain) {
|
||||||
|
logger.error(
|
||||||
|
`Resource ${resource.resourceId} has no fullDomain`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// HTTP configuration remains the same
|
// HTTP configuration remains the same
|
||||||
if (!resource.subdomain && !resource.isBaseDomain) {
|
if (!resource.subdomain && !resource.isBaseDomain) {
|
||||||
continue;
|
continue;
|
||||||
@@ -136,9 +143,18 @@ export async function traefikConfigProvider(
|
|||||||
wildCard = `*.${domainParts.slice(1).join(".")}`;
|
wildCard = `*.${domainParts.slice(1).join(".")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const configDomain = config.getDomain(resource.domainId);
|
||||||
|
|
||||||
|
if (!configDomain) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to get domain from config for resource ${resource.resourceId}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const tls = {
|
const tls = {
|
||||||
certResolver: config.getRawConfig().traefik.cert_resolver,
|
certResolver: configDomain.cert_resolver,
|
||||||
...(config.getRawConfig().traefik.prefer_wildcard_cert
|
...(configDomain.prefer_wildcard_cert
|
||||||
? {
|
? {
|
||||||
domains: [
|
domains: [
|
||||||
{
|
{
|
||||||
|
|||||||
79
server/setup/clearStaleData.ts
Normal file
79
server/setup/clearStaleData.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { db } from "@server/db";
|
||||||
|
import {
|
||||||
|
emailVerificationCodes,
|
||||||
|
newtSessions,
|
||||||
|
passwordResetTokens,
|
||||||
|
resourceAccessToken,
|
||||||
|
resourceOtp,
|
||||||
|
resourceSessions,
|
||||||
|
sessions,
|
||||||
|
userInvites
|
||||||
|
} from "@server/db/schema";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { lt } from "drizzle-orm";
|
||||||
|
|
||||||
|
export async function clearStaleData() {
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(sessions)
|
||||||
|
.where(lt(sessions.expiresAt, new Date().getTime()));
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Error clearing expired sessions:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(newtSessions)
|
||||||
|
.where(lt(newtSessions.expiresAt, new Date().getTime()));
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Error clearing expired newtSessions:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(emailVerificationCodes)
|
||||||
|
.where(lt(emailVerificationCodes.expiresAt, new Date().getTime()));
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Error clearing expired emailVerificationCodes:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(passwordResetTokens)
|
||||||
|
.where(lt(passwordResetTokens.expiresAt, new Date().getTime()));
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Error clearing expired passwordResetTokens:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(userInvites)
|
||||||
|
.where(lt(userInvites.expiresAt, new Date().getTime()));
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Error clearing expired userInvites:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(resourceAccessToken)
|
||||||
|
.where(lt(resourceAccessToken.expiresAt, new Date().getTime()));
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Error clearing expired resourceAccessToken:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(resourceSessions)
|
||||||
|
.where(lt(resourceSessions.expiresAt, new Date().getTime()));
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Error clearing expired resourceSessions:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(resourceOtp)
|
||||||
|
.where(lt(resourceOtp.expiresAt, new Date().getTime()));
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Error clearing expired resourceOtp:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,103 @@
|
|||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { exitNodes, orgs, resources } from "../db/schema";
|
import { domains, exitNodes, orgDomains, orgs, resources } from "../db/schema";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { eq, ne } from "drizzle-orm";
|
import { eq, ne } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
export async function copyInConfig() {
|
export async function copyInConfig() {
|
||||||
const domain = config.getBaseDomain();
|
|
||||||
const endpoint = config.getRawConfig().gerbil.base_endpoint;
|
const endpoint = config.getRawConfig().gerbil.base_endpoint;
|
||||||
const listenPort = config.getRawConfig().gerbil.start_port;
|
const listenPort = config.getRawConfig().gerbil.start_port;
|
||||||
|
|
||||||
// update the domain on all of the orgs where the domain is not equal to the new domain
|
|
||||||
// TODO: eventually each org could have a unique domain that we do not want to overwrite, so this will be unnecessary
|
|
||||||
await db.update(orgs).set({ domain }).where(ne(orgs.domain, domain));
|
|
||||||
|
|
||||||
// TODO: eventually each exit node could have a different endpoint
|
|
||||||
await db.update(exitNodes).set({ endpoint }).where(ne(exitNodes.endpoint, endpoint));
|
|
||||||
// TODO: eventually each exit node could have a different port
|
|
||||||
await db.update(exitNodes).set({ listenPort }).where(ne(exitNodes.listenPort, listenPort));
|
|
||||||
|
|
||||||
// update all resources fullDomain to use the new domain
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
const allResources = await trx.select().from(resources);
|
const rawDomains = config.getRawConfig().domains;
|
||||||
|
|
||||||
|
const configDomains = Object.entries(rawDomains).map(
|
||||||
|
([key, value]) => ({
|
||||||
|
domainId: key,
|
||||||
|
baseDomain: value.base_domain.toLowerCase()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingDomains = await trx
|
||||||
|
.select()
|
||||||
|
.from(domains)
|
||||||
|
.where(eq(domains.configManaged, true));
|
||||||
|
const existingDomainKeys = new Set(
|
||||||
|
existingDomains.map((d) => d.domainId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const configDomainKeys = new Set(configDomains.map((d) => d.domainId));
|
||||||
|
for (const existingDomain of existingDomains) {
|
||||||
|
if (!configDomainKeys.has(existingDomain.domainId)) {
|
||||||
|
await trx
|
||||||
|
.delete(domains)
|
||||||
|
.where(eq(domains.domainId, existingDomain.domainId))
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { domainId, baseDomain } of configDomains) {
|
||||||
|
if (existingDomainKeys.has(domainId)) {
|
||||||
|
await trx
|
||||||
|
.update(domains)
|
||||||
|
.set({ baseDomain })
|
||||||
|
.where(eq(domains.domainId, domainId))
|
||||||
|
.execute();
|
||||||
|
} else {
|
||||||
|
await trx
|
||||||
|
.insert(domains)
|
||||||
|
.values({ domainId, baseDomain, configManaged: true })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allOrgs = await trx.select().from(orgs);
|
||||||
|
|
||||||
|
const existingOrgDomains = await trx.select().from(orgDomains);
|
||||||
|
const existingOrgDomainSet = new Set(
|
||||||
|
existingOrgDomains.map((od) => `${od.orgId}-${od.domainId}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
const newOrgDomains = [];
|
||||||
|
for (const org of allOrgs) {
|
||||||
|
for (const domain of configDomains) {
|
||||||
|
const key = `${org.orgId}-${domain.domainId}`;
|
||||||
|
if (!existingOrgDomainSet.has(key)) {
|
||||||
|
newOrgDomains.push({
|
||||||
|
orgId: org.orgId,
|
||||||
|
domainId: domain.domainId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newOrgDomains.length > 0) {
|
||||||
|
await trx.insert(orgDomains).values(newOrgDomains).execute();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
const allResources = await trx
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.leftJoin(domains, eq(domains.domainId, resources.domainId));
|
||||||
|
|
||||||
|
for (const { resources: resource, domains: domain } of allResources) {
|
||||||
|
if (!resource || !domain) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!domain.configManaged) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
for (const resource of allResources) {
|
|
||||||
let fullDomain = "";
|
let fullDomain = "";
|
||||||
if (resource.isBaseDomain) {
|
if (resource.isBaseDomain) {
|
||||||
fullDomain = domain;
|
fullDomain = domain.baseDomain;
|
||||||
} else {
|
} else {
|
||||||
fullDomain = `${resource.subdomain}.${domain}`;
|
fullDomain = `${resource.subdomain}.${domain.baseDomain}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.update(resources)
|
.update(resources)
|
||||||
.set({ fullDomain })
|
.set({ fullDomain })
|
||||||
@@ -36,5 +105,14 @@ export async function copyInConfig() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Updated orgs with new domain (${domain})`);
|
// TODO: eventually each exit node could have a different endpoint
|
||||||
|
await db
|
||||||
|
.update(exitNodes)
|
||||||
|
.set({ endpoint })
|
||||||
|
.where(ne(exitNodes.endpoint, endpoint));
|
||||||
|
// TODO: eventually each exit node could have a different port
|
||||||
|
await db
|
||||||
|
.update(exitNodes)
|
||||||
|
.set({ listenPort })
|
||||||
|
.where(ne(exitNodes.listenPort, listenPort));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { ensureActions } from "./ensureActions";
|
|||||||
import { copyInConfig } from "./copyInConfig";
|
import { copyInConfig } from "./copyInConfig";
|
||||||
import { setupServerAdmin } from "./setupServerAdmin";
|
import { setupServerAdmin } from "./setupServerAdmin";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { clearStaleData } from "./clearStaleData";
|
||||||
|
|
||||||
export async function runSetupFunctions() {
|
export async function runSetupFunctions() {
|
||||||
try {
|
try {
|
||||||
await copyInConfig(); // copy in the config to the db as needed
|
await copyInConfig(); // copy in the config to the db as needed
|
||||||
await setupServerAdmin();
|
await setupServerAdmin();
|
||||||
await ensureActions(); // make sure all of the actions are in the db and the roles
|
await ensureActions(); // make sure all of the actions are in the db and the roles
|
||||||
|
await clearStaleData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error running setup functions:", error);
|
logger.error("Error running setup functions:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import m6 from "./scripts/1.0.0-beta9";
|
|||||||
import m7 from "./scripts/1.0.0-beta10";
|
import m7 from "./scripts/1.0.0-beta10";
|
||||||
import m8 from "./scripts/1.0.0-beta12";
|
import m8 from "./scripts/1.0.0-beta12";
|
||||||
import m13 from "./scripts/1.0.0-beta13";
|
import m13 from "./scripts/1.0.0-beta13";
|
||||||
|
import m15 from "./scripts/1.0.0-beta15";
|
||||||
|
|
||||||
// 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
|
||||||
@@ -29,7 +30,8 @@ const migrations = [
|
|||||||
{ version: "1.0.0-beta.9", run: m6 },
|
{ version: "1.0.0-beta.9", run: m6 },
|
||||||
{ version: "1.0.0-beta.10", run: m7 },
|
{ version: "1.0.0-beta.10", run: m7 },
|
||||||
{ version: "1.0.0-beta.12", run: m8 },
|
{ version: "1.0.0-beta.12", run: m8 },
|
||||||
{ version: "1.0.0-beta.13", run: m13 }
|
{ version: "1.0.0-beta.13", run: m13 },
|
||||||
|
{ version: "1.0.0-beta.15", run: m15 }
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
129
server/setup/scripts/1.0.0-beta15.ts
Normal file
129
server/setup/scripts/1.0.0-beta15.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import db from "@server/db";
|
||||||
|
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
|
import fs from "fs";
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { domains, orgDomains, resources } from "@server/db/schema";
|
||||||
|
|
||||||
|
const version = "1.0.0-beta.15";
|
||||||
|
|
||||||
|
export default async function migration() {
|
||||||
|
console.log(`Running setup script ${version}...`);
|
||||||
|
|
||||||
|
let domain = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Determine which config file exists
|
||||||
|
const filePaths = [configFilePath1, configFilePath2];
|
||||||
|
let filePath = "";
|
||||||
|
for (const path of filePaths) {
|
||||||
|
if (fs.existsSync(path)) {
|
||||||
|
filePath = path;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
throw new Error(
|
||||||
|
`No config file found (expected config.yml or config.yaml).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and parse the YAML file
|
||||||
|
let rawConfig: any;
|
||||||
|
const fileContents = fs.readFileSync(filePath, "utf8");
|
||||||
|
rawConfig = yaml.load(fileContents);
|
||||||
|
|
||||||
|
const baseDomain = rawConfig.app.base_domain;
|
||||||
|
const certResolver = rawConfig.traefik.cert_resolver;
|
||||||
|
const preferWildcardCert = rawConfig.traefik.prefer_wildcard_cert;
|
||||||
|
|
||||||
|
delete rawConfig.traefik.prefer_wildcard_cert;
|
||||||
|
delete rawConfig.traefik.cert_resolver;
|
||||||
|
delete rawConfig.app.base_domain;
|
||||||
|
|
||||||
|
rawConfig.domains = {
|
||||||
|
domain1: {
|
||||||
|
base_domain: baseDomain
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (certResolver) {
|
||||||
|
rawConfig.domains.domain1.cert_resolver = certResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferWildcardCert) {
|
||||||
|
rawConfig.domains.domain1.prefer_wildcard_cert = preferWildcardCert;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the updated YAML back to the file
|
||||||
|
const updatedYaml = yaml.dump(rawConfig);
|
||||||
|
fs.writeFileSync(filePath, updatedYaml, "utf8");
|
||||||
|
|
||||||
|
domain = baseDomain;
|
||||||
|
|
||||||
|
console.log(`Moved base_domain to new domains section`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(
|
||||||
|
`Unable to migrate config file and move base_domain to domains. Error: ${e}`
|
||||||
|
);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.transaction((trx) => {
|
||||||
|
trx.run(sql`CREATE TABLE 'domains' (
|
||||||
|
'domainId' text PRIMARY KEY NOT NULL,
|
||||||
|
'baseDomain' text NOT NULL,
|
||||||
|
'configManaged' integer DEFAULT false NOT NULL
|
||||||
|
);`);
|
||||||
|
|
||||||
|
trx.run(sql`CREATE TABLE 'orgDomains' (
|
||||||
|
'orgId' text NOT NULL,
|
||||||
|
'domainId' text NOT NULL,
|
||||||
|
FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY ('domainId') REFERENCES 'domains'('domainId') ON UPDATE no action ON DELETE cascade
|
||||||
|
);`);
|
||||||
|
|
||||||
|
trx.run(
|
||||||
|
sql`ALTER TABLE 'resources' ADD 'domainId' text REFERENCES domains(domainId);`
|
||||||
|
);
|
||||||
|
trx.run(sql`ALTER TABLE 'orgs' DROP COLUMN 'domain';`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Migrated database schema`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Unable to migrate database schema");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
await trx
|
||||||
|
.insert(domains)
|
||||||
|
.values({
|
||||||
|
domainId: "domain1",
|
||||||
|
baseDomain: domain,
|
||||||
|
configManaged: true
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
await trx.update(resources).set({ domainId: "domain1" });
|
||||||
|
const existingOrgDomains = await trx.select().from(orgDomains);
|
||||||
|
for (const orgDomain of existingOrgDomains) {
|
||||||
|
await trx
|
||||||
|
.insert(orgDomains)
|
||||||
|
.values({ orgId: orgDomain.orgId, domainId: "domain1" })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Updated resources table with new domainId`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(
|
||||||
|
`Unable to update resources table with new domainId. Error: ${e}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${version} migration complete`);
|
||||||
|
}
|
||||||
@@ -136,7 +136,6 @@ export default function CreateRoleForm({
|
|||||||
<FormLabel>Role Name</FormLabel>
|
<FormLabel>Role Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter name for the role"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -152,7 +151,6 @@ export default function CreateRoleForm({
|
|||||||
<FormLabel>Description</FormLabel>
|
<FormLabel>Description</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Describe the role"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -195,7 +195,6 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
|
|||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter an email"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
|
|||||||
|
|
||||||
<SidebarSettings
|
<SidebarSettings
|
||||||
sidebarNavItems={sidebarNavItems}
|
sidebarNavItems={sidebarNavItems}
|
||||||
limitWidth={true}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</SidebarSettings>
|
</SidebarSettings>
|
||||||
|
|||||||
@@ -210,11 +210,11 @@ export default function GeneralPage() {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
This is the display name of the
|
This is the display name of the
|
||||||
org
|
organization.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -238,7 +238,6 @@ export default function GeneralPage() {
|
|||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
<AlertTriangle className="h-5 w-5" />
|
|
||||||
Danger Zone
|
Danger Zone
|
||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
|
|||||||
@@ -65,10 +65,12 @@ import { SquareArrowOutUpRight } from "lucide-react";
|
|||||||
import CopyTextBox from "@app/components/CopyTextBox";
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
||||||
import { Label } from "@app/components/ui/label";
|
import { Label } from "@app/components/ui/label";
|
||||||
|
import { ListDomainsResponse } from "@server/routers/domain";
|
||||||
|
|
||||||
const createResourceFormSchema = z
|
const createResourceFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
subdomain: z.string().optional(),
|
subdomain: z.string().optional(),
|
||||||
|
domainId: z.string().min(1).optional(),
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
siteId: z.number(),
|
siteId: z.number(),
|
||||||
http: z.boolean(),
|
http: z.boolean(),
|
||||||
@@ -117,6 +119,7 @@ export default function CreateResourceForm({
|
|||||||
open,
|
open,
|
||||||
setOpen
|
setOpen
|
||||||
}: CreateResourceFormProps) {
|
}: CreateResourceFormProps) {
|
||||||
|
const [formKey, setFormKey] = useState(0);
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -129,7 +132,9 @@ export default function CreateResourceForm({
|
|||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
||||||
const [domainSuffix, setDomainSuffix] = useState<string>(org.org.domain);
|
const [baseDomains, setBaseDomains] = useState<
|
||||||
|
{ domainId: string; baseDomain: string }[]
|
||||||
|
>([]);
|
||||||
const [showSnippets, setShowSnippets] = useState(false);
|
const [showSnippets, setShowSnippets] = useState(false);
|
||||||
const [resourceId, setResourceId] = useState<number | null>(null);
|
const [resourceId, setResourceId] = useState<number | null>(null);
|
||||||
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
|
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
|
||||||
@@ -140,6 +145,7 @@ export default function CreateResourceForm({
|
|||||||
resolver: zodResolver(createResourceFormSchema),
|
resolver: zodResolver(createResourceFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
subdomain: "",
|
subdomain: "",
|
||||||
|
domainId: "",
|
||||||
name: "",
|
name: "",
|
||||||
http: true,
|
http: true,
|
||||||
protocol: "tcp"
|
protocol: "tcp"
|
||||||
@@ -161,17 +167,56 @@ export default function CreateResourceForm({
|
|||||||
reset();
|
reset();
|
||||||
|
|
||||||
const fetchSites = async () => {
|
const fetchSites = async () => {
|
||||||
const res = await api.get<AxiosResponse<ListSitesResponse>>(
|
const res = await api
|
||||||
`/org/${orgId}/sites/`
|
.get<AxiosResponse<ListSitesResponse>>(`/org/${orgId}/sites/`)
|
||||||
);
|
.catch((e) => {
|
||||||
setSites(res.data.data.sites);
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error fetching sites",
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
"An error occurred when fetching the sites"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
if (res.data.data.sites.length > 0) {
|
if (res?.status === 200) {
|
||||||
form.setValue("siteId", res.data.data.sites[0].siteId);
|
setSites(res.data.data.sites);
|
||||||
|
|
||||||
|
if (res.data.data.sites.length > 0) {
|
||||||
|
form.setValue("siteId", res.data.data.sites[0].siteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchDomains = async () => {
|
||||||
|
const res = await api
|
||||||
|
.get<
|
||||||
|
AxiosResponse<ListDomainsResponse>
|
||||||
|
>(`/org/${orgId}/domains/`)
|
||||||
|
.catch((e) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error fetching domains",
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
"An error occurred when fetching the domains"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res?.status === 200) {
|
||||||
|
const domains = res.data.data.domains;
|
||||||
|
setBaseDomains(domains);
|
||||||
|
if (domains.length) {
|
||||||
|
form.setValue("domainId", domains[0].domainId);
|
||||||
|
setFormKey((k) => k + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchSites();
|
fetchSites();
|
||||||
|
fetchDomains();
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
async function onSubmit(data: CreateResourceFormValues) {
|
async function onSubmit(data: CreateResourceFormValues) {
|
||||||
@@ -181,11 +226,12 @@ export default function CreateResourceForm({
|
|||||||
{
|
{
|
||||||
name: data.name,
|
name: data.name,
|
||||||
subdomain: data.http ? data.subdomain : undefined,
|
subdomain: data.http ? data.subdomain : undefined,
|
||||||
|
domainId: data.http ? data.domainId : undefined,
|
||||||
http: data.http,
|
http: data.http,
|
||||||
protocol: data.protocol,
|
protocol: data.protocol,
|
||||||
proxyPort: data.http ? undefined : data.proxyPort,
|
proxyPort: data.http ? undefined : data.proxyPort,
|
||||||
siteId: data.siteId,
|
siteId: data.siteId,
|
||||||
isBaseDomain: data.isBaseDomain
|
isBaseDomain: data.http ? undefined : data.isBaseDomain
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -237,34 +283,12 @@ export default function CreateResourceForm({
|
|||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
{!showSnippets && (
|
{!showSnippets && (
|
||||||
<Form {...form}>
|
<Form {...form} key={formKey}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="create-resource-form"
|
id="create-resource-form"
|
||||||
>
|
>
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="Resource name"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
This is the name that will
|
|
||||||
be displayed for this
|
|
||||||
resource.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!env.flags.allowRawResources || (
|
{!env.flags.allowRawResources || (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -278,7 +302,8 @@ export default function CreateResourceForm({
|
|||||||
<FormDescription>
|
<FormDescription>
|
||||||
Toggle if this is an
|
Toggle if this is an
|
||||||
HTTP resource or a
|
HTTP resource or a
|
||||||
raw TCP/UDP resource
|
raw TCP/UDP
|
||||||
|
resource.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -296,6 +321,24 @@ export default function CreateResourceForm({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
This is display name for the
|
||||||
|
resource.
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{form.watch("http") &&
|
{form.watch("http") &&
|
||||||
env.flags.allowBaseDomainResources && (
|
env.flags.allowBaseDomainResources && (
|
||||||
<div>
|
<div>
|
||||||
@@ -335,60 +378,137 @@ export default function CreateResourceForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{form.watch("http") && (
|
{form.watch("http") && (
|
||||||
<FormField
|
<>
|
||||||
control={form.control}
|
{domainType === "subdomain" ? (
|
||||||
name="subdomain"
|
<div className="w-fill space-y-2">
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
{!env.flags
|
{!env.flags
|
||||||
.allowBaseDomainResources && (
|
.allowBaseDomainResources && (
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
Subdomain
|
Subdomain
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
)}
|
)}
|
||||||
{domainType ===
|
<div className="flex">
|
||||||
"subdomain" ? (
|
<div className="w-full mr-1">
|
||||||
<FormControl>
|
<FormField
|
||||||
<CustomDomainInput
|
control={
|
||||||
value={
|
form.control
|
||||||
field.value ??
|
|
||||||
""
|
|
||||||
}
|
|
||||||
domainSuffix={
|
|
||||||
domainSuffix
|
|
||||||
}
|
|
||||||
placeholder="Subdomain"
|
|
||||||
onChange={(
|
|
||||||
value
|
|
||||||
) =>
|
|
||||||
form.setValue(
|
|
||||||
"subdomain",
|
|
||||||
value
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
name="subdomain"
|
||||||
|
render={({
|
||||||
|
field
|
||||||
|
}) => (
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
className="text-right"
|
||||||
|
placeholder="Enter subdomain"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</div>
|
||||||
) : (
|
<div className="max-w-1/2">
|
||||||
<FormControl>
|
<FormField
|
||||||
<Input
|
control={
|
||||||
value={
|
form.control
|
||||||
domainSuffix
|
|
||||||
}
|
}
|
||||||
readOnly
|
name="domainId"
|
||||||
disabled
|
render={({
|
||||||
|
field
|
||||||
|
}) => (
|
||||||
|
<FormItem>
|
||||||
|
<Select
|
||||||
|
onValueChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
value={
|
||||||
|
field.value
|
||||||
|
}
|
||||||
|
defaultValue={
|
||||||
|
field.value
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{baseDomains.map(
|
||||||
|
(
|
||||||
|
option
|
||||||
|
) => (
|
||||||
|
<SelectItem
|
||||||
|
key={
|
||||||
|
option.domainId
|
||||||
|
}
|
||||||
|
value={
|
||||||
|
option.domainId
|
||||||
|
}
|
||||||
|
>
|
||||||
|
.
|
||||||
|
{
|
||||||
|
option.baseDomain
|
||||||
|
}
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="domainId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<Select
|
||||||
|
onValueChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
defaultValue={
|
||||||
|
field.value
|
||||||
|
}
|
||||||
|
{...field}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{baseDomains.map(
|
||||||
|
(
|
||||||
|
option
|
||||||
|
) => (
|
||||||
|
<SelectItem
|
||||||
|
key={
|
||||||
|
option.domainId
|
||||||
|
}
|
||||||
|
value={
|
||||||
|
option.domainId
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
option.baseDomain
|
||||||
|
}
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
<FormDescription>
|
/>
|
||||||
This is the fully
|
|
||||||
qualified domain name
|
|
||||||
that will be used to
|
|
||||||
access the resource.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
)}
|
||||||
/>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!form.watch("http") && (
|
{!form.watch("http") && (
|
||||||
@@ -436,11 +556,11 @@ export default function CreateResourceForm({
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
The protocol to use
|
The protocol to use
|
||||||
for the resource
|
for the resource.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -455,7 +575,6 @@ export default function CreateResourceForm({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Enter port number"
|
|
||||||
value={
|
value={
|
||||||
field.value ??
|
field.value ??
|
||||||
""
|
""
|
||||||
@@ -474,13 +593,13 @@ export default function CreateResourceForm({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
The port number to
|
The port number to
|
||||||
proxy requests to
|
proxy requests to
|
||||||
(required for
|
(required for
|
||||||
non-HTTP resources)
|
non-HTTP resources).
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -520,7 +639,7 @@ export default function CreateResourceForm({
|
|||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="p-0">
|
<PopoverContent className="p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="Search site..." />
|
<CommandInput placeholder="Search site" />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
No site
|
No site
|
||||||
@@ -563,11 +682,12 @@ export default function CreateResourceForm({
|
|||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<FormDescription>
|
|
||||||
This is the site that will
|
|
||||||
be used in the dashboard.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
This site will provide
|
||||||
|
connectivity to the
|
||||||
|
resource.
|
||||||
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,27 +2,68 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
interface DomainOption {
|
||||||
|
baseDomain: string;
|
||||||
|
domainId: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface CustomDomainInputProps {
|
interface CustomDomainInputProps {
|
||||||
domainSuffix: string;
|
domainOptions: DomainOption[];
|
||||||
|
selectedDomainId?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
value: string;
|
value: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string, selectedDomainId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CustomDomainInput({
|
export default function CustomDomainInput({
|
||||||
domainSuffix,
|
domainOptions,
|
||||||
placeholder = "Enter subdomain",
|
selectedDomainId,
|
||||||
|
placeholder = "Subdomain",
|
||||||
value: defaultValue,
|
value: defaultValue,
|
||||||
onChange
|
onChange
|
||||||
}: CustomDomainInputProps) {
|
}: CustomDomainInputProps) {
|
||||||
const [value, setValue] = React.useState(defaultValue);
|
const [value, setValue] = React.useState(defaultValue);
|
||||||
|
const [selectedDomain, setSelectedDomain] = React.useState<DomainOption>();
|
||||||
|
|
||||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
React.useEffect(() => {
|
||||||
|
if (domainOptions.length) {
|
||||||
|
if (selectedDomainId) {
|
||||||
|
const selectedDomainOption = domainOptions.find(
|
||||||
|
(option) => option.domainId === selectedDomainId
|
||||||
|
);
|
||||||
|
setSelectedDomain(selectedDomainOption || domainOptions[0]);
|
||||||
|
} else {
|
||||||
|
setSelectedDomain(domainOptions[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [domainOptions]);
|
||||||
|
|
||||||
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!selectedDomain) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const newValue = event.target.value;
|
const newValue = event.target.value;
|
||||||
setValue(newValue);
|
setValue(newValue);
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
onChange(newValue);
|
onChange(newValue, selectedDomain.domainId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDomainChange = (domainId: string) => {
|
||||||
|
const newSelectedDomain =
|
||||||
|
domainOptions.find((option) => option.domainId === domainId) ||
|
||||||
|
domainOptions[0];
|
||||||
|
setSelectedDomain(newSelectedDomain);
|
||||||
|
if (onChange) {
|
||||||
|
onChange(value, newSelectedDomain.domainId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,12 +74,28 @@ export default function CustomDomainInput({
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={handleChange}
|
onChange={handleInputChange}
|
||||||
className="rounded-r-none w-full"
|
className="w-1/2 mr-1 text-right"
|
||||||
/>
|
/>
|
||||||
<div className="max-w-1/2 flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-muted-foreground">
|
<Select
|
||||||
<span className="text-sm truncate">.{domainSuffix}</span>
|
onValueChange={handleDomainChange}
|
||||||
</div>
|
value={selectedDomain?.domainId}
|
||||||
|
defaultValue={selectedDomain?.domainId}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-1/2 pr-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{domainOptions.map((option) => (
|
||||||
|
<SelectItem
|
||||||
|
key={option.domainId}
|
||||||
|
value={option.domainId}
|
||||||
|
>
|
||||||
|
.{option.baseDomain}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
|
import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
|
||||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
import { Separator } from "@app/components/ui/separator";
|
import { Separator } from "@app/components/ui/separator";
|
||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
@@ -17,17 +15,9 @@ import {
|
|||||||
type ResourceInfoBoxType = {};
|
type ResourceInfoBoxType = {};
|
||||||
|
|
||||||
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const { org } = useOrgContext();
|
|
||||||
const { resource, authInfo } = useResourceContext();
|
const { resource, authInfo } = useResourceContext();
|
||||||
|
|
||||||
let fullUrl = `${resource.ssl ? "https" : "http"}://`;
|
let fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||||
if (resource.isBaseDomain) {
|
|
||||||
fullUrl = fullUrl + org.org.domain;
|
|
||||||
} else {
|
|
||||||
fullUrl = fullUrl + `${resource.subdomain}.${org.org.domain}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert>
|
<Alert>
|
||||||
@@ -52,7 +42,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
||||||
<span>
|
<span>
|
||||||
This resource is protected with
|
This resource is protected with
|
||||||
at least one auth method.
|
at least one authentication method.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -136,17 +136,16 @@ export default function SetResourcePasswordForm({
|
|||||||
<Input
|
<Input
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Your secure password"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Users will be able to access
|
Users will be able to access
|
||||||
this resource by entering this
|
this resource by entering this
|
||||||
password. It must be at least 4
|
password. It must be at least 4
|
||||||
characters long.
|
characters long.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -167,13 +167,13 @@ export default function SetResourcePincodeForm({
|
|||||||
</InputOTP>
|
</InputOTP>
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Users will be able to access
|
Users will be able to access
|
||||||
this resource by entering this
|
this resource by entering this
|
||||||
PIN code. It must be at least 6
|
PIN code. It must be at least 6
|
||||||
digits long.
|
digits long.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,14 +8,12 @@ import { useResourceContext } from "@app/hooks/useResourceContext";
|
|||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import {
|
import {
|
||||||
GetResourceAuthInfoResponse,
|
|
||||||
GetResourceWhitelistResponse,
|
GetResourceWhitelistResponse,
|
||||||
ListResourceRolesResponse,
|
ListResourceRolesResponse,
|
||||||
ListResourceUsersResponse
|
ListResourceUsersResponse
|
||||||
} from "@server/routers/resource";
|
} from "@server/routers/resource";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { set, z } from "zod";
|
import { set, z } from "zod";
|
||||||
import { Tag } from "emblor";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import {
|
||||||
@@ -27,12 +25,8 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { TagInput } from "emblor";
|
|
||||||
// import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
|
||||||
import { ListUsersResponse } from "@server/routers/user";
|
import { ListUsersResponse } from "@server/routers/user";
|
||||||
import { Switch } from "@app/components/ui/switch";
|
import { Binary, Key } from "lucide-react";
|
||||||
import { Label } from "@app/components/ui/label";
|
|
||||||
import { Binary, Key, ShieldCheck } from "lucide-react";
|
|
||||||
import SetResourcePasswordForm from "./SetResourcePasswordForm";
|
import SetResourcePasswordForm from "./SetResourcePasswordForm";
|
||||||
import SetResourcePincodeForm from "./SetResourcePincodeForm";
|
import SetResourcePincodeForm from "./SetResourcePincodeForm";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
@@ -44,11 +38,12 @@ import {
|
|||||||
SettingsSectionHeader,
|
SettingsSectionHeader,
|
||||||
SettingsSectionDescription,
|
SettingsSectionDescription,
|
||||||
SettingsSectionBody,
|
SettingsSectionBody,
|
||||||
SettingsSectionForm,
|
|
||||||
SettingsSectionFooter
|
SettingsSectionFooter
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
|
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
const UsersRolesFormSchema = z.object({
|
const UsersRolesFormSchema = z.object({
|
||||||
roles: z.array(
|
roles: z.array(
|
||||||
@@ -82,6 +77,7 @@ export default function ResourceAuthenticationPage() {
|
|||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
const api = createApiClient({ env });
|
const api = createApiClient({ env });
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const [pageLoading, setPageLoading] = useState(true);
|
const [pageLoading, setPageLoading] = useState(true);
|
||||||
|
|
||||||
@@ -236,6 +232,7 @@ export default function ResourceAuthenticationPage() {
|
|||||||
title: "Saved successfully",
|
title: "Saved successfully",
|
||||||
description: "Whitelist settings have been saved"
|
description: "Whitelist settings have been saved"
|
||||||
});
|
});
|
||||||
|
router.refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast({
|
toast({
|
||||||
@@ -283,6 +280,7 @@ export default function ResourceAuthenticationPage() {
|
|||||||
title: "Saved successfully",
|
title: "Saved successfully",
|
||||||
description: "Authentication settings have been saved"
|
description: "Authentication settings have been saved"
|
||||||
});
|
});
|
||||||
|
router.refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast({
|
toast({
|
||||||
@@ -314,6 +312,7 @@ export default function ResourceAuthenticationPage() {
|
|||||||
updateAuthInfo({
|
updateAuthInfo({
|
||||||
password: false
|
password: false
|
||||||
});
|
});
|
||||||
|
router.refresh();
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
@@ -344,6 +343,7 @@ export default function ResourceAuthenticationPage() {
|
|||||||
updateAuthInfo({
|
updateAuthInfo({
|
||||||
pincode: false
|
pincode: false
|
||||||
});
|
});
|
||||||
|
router.refresh();
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
@@ -429,7 +429,6 @@ export default function ResourceAuthenticationPage() {
|
|||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>Roles</FormLabel>
|
<FormLabel>Roles</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
{/* @ts-ignore */}
|
|
||||||
<TagInput
|
<TagInput
|
||||||
{...field}
|
{...field}
|
||||||
activeTagIndex={
|
activeTagIndex={
|
||||||
@@ -438,7 +437,7 @@ export default function ResourceAuthenticationPage() {
|
|||||||
setActiveTagIndex={
|
setActiveTagIndex={
|
||||||
setActiveRolesTagIndex
|
setActiveRolesTagIndex
|
||||||
}
|
}
|
||||||
placeholder="Enter a role"
|
placeholder="Select a role"
|
||||||
tags={
|
tags={
|
||||||
usersRolesForm.getValues()
|
usersRolesForm.getValues()
|
||||||
.roles
|
.roles
|
||||||
@@ -477,13 +476,11 @@ export default function ResourceAuthenticationPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
These roles will be able
|
|
||||||
to access this resource.
|
|
||||||
Admins can always access
|
Admins can always access
|
||||||
this resource.
|
this resource.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -494,7 +491,6 @@ export default function ResourceAuthenticationPage() {
|
|||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>Users</FormLabel>
|
<FormLabel>Users</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
{/* @ts-ignore */}
|
|
||||||
<TagInput
|
<TagInput
|
||||||
{...field}
|
{...field}
|
||||||
activeTagIndex={
|
activeTagIndex={
|
||||||
@@ -503,7 +499,7 @@ export default function ResourceAuthenticationPage() {
|
|||||||
setActiveTagIndex={
|
setActiveTagIndex={
|
||||||
setActiveUsersTagIndex
|
setActiveUsersTagIndex
|
||||||
}
|
}
|
||||||
placeholder="Enter a user"
|
placeholder="Select a user"
|
||||||
tags={
|
tags={
|
||||||
usersRolesForm.getValues()
|
usersRolesForm.getValues()
|
||||||
.users
|
.users
|
||||||
@@ -542,15 +538,6 @@ export default function ResourceAuthenticationPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
|
||||||
Users added here will be
|
|
||||||
able to access this
|
|
||||||
resource. A user will
|
|
||||||
always have access to a
|
|
||||||
resource if they have a
|
|
||||||
role that has access to
|
|
||||||
it.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -732,7 +719,9 @@ export default function ResourceAuthenticationPage() {
|
|||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Press enter to add an email after typing it in the input field.
|
Press enter to add an
|
||||||
|
email after typing it in
|
||||||
|
the input field.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -62,39 +62,11 @@ import {
|
|||||||
SettingsSectionFooter
|
SettingsSectionFooter
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { useSiteContext } from "@app/hooks/useSiteContext";
|
import { useRouter } from "next/navigation";
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { isTargetValid } from "@server/lib/validators";
|
||||||
|
|
||||||
// Regular expressions for validation
|
|
||||||
const DOMAIN_REGEX =
|
|
||||||
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
|
||||||
const IPV4_REGEX =
|
|
||||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
|
||||||
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
|
|
||||||
|
|
||||||
// Schema for domain names and IP addresses
|
|
||||||
const domainSchema = z
|
|
||||||
.string()
|
|
||||||
.min(1, "Domain cannot be empty")
|
|
||||||
.max(255, "Domain name too long")
|
|
||||||
.refine(
|
|
||||||
(value) => {
|
|
||||||
// Check if it's a valid IP address (v4 or v6)
|
|
||||||
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a valid domain name
|
|
||||||
return DOMAIN_REGEX.test(value);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "Invalid domain name or IP address format",
|
|
||||||
path: ["domain"]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const addTargetSchema = z.object({
|
const addTargetSchema = z.object({
|
||||||
ip: domainSchema,
|
ip: z.string().refine(isTargetValid),
|
||||||
method: z.string().nullable(),
|
method: z.string().nullable(),
|
||||||
port: z.coerce.number().int().positive()
|
port: z.coerce.number().int().positive()
|
||||||
// protocol: z.string(),
|
// protocol: z.string(),
|
||||||
@@ -125,6 +97,7 @@ export default function ReverseProxyTargets(props: {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const [pageLoading, setPageLoading] = useState(true);
|
const [pageLoading, setPageLoading] = useState(true);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const addTargetForm = useForm({
|
const addTargetForm = useForm({
|
||||||
resolver: zodResolver(addTargetSchema),
|
resolver: zodResolver(addTargetSchema),
|
||||||
@@ -299,6 +272,7 @@ export default function ReverseProxyTargets(props: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setTargetsToRemove([]);
|
setTargetsToRemove([]);
|
||||||
|
router.refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
toast({
|
toast({
|
||||||
@@ -339,6 +313,7 @@ export default function ReverseProxyTargets(props: {
|
|||||||
title: "SSL Configuration",
|
title: "SSL Configuration",
|
||||||
description: "SSL configuration updated successfully"
|
description: "SSL configuration updated successfully"
|
||||||
});
|
});
|
||||||
|
router.refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,6 +421,7 @@ export default function ReverseProxyTargets(props: {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="http">http</SelectItem>
|
<SelectItem value="http">http</SelectItem>
|
||||||
<SelectItem value="https">https</SelectItem>
|
<SelectItem value="https">https</SelectItem>
|
||||||
|
<SelectItem value="h2c">h2c</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
@@ -461,7 +437,13 @@ export default function ReverseProxyTargets(props: {
|
|||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getFilteredRowModel: getFilteredRowModel()
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
state: {
|
||||||
|
pagination: {
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (pageLoading) {
|
if (pageLoading) {
|
||||||
@@ -477,8 +459,7 @@ export default function ReverseProxyTargets(props: {
|
|||||||
SSL Configuration
|
SSL Configuration
|
||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
Setup SSL to secure your connections with
|
Setup SSL to secure your connections with Let's Encrypt certificates
|
||||||
LetsEncrypt certificates
|
|
||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
@@ -542,6 +523,9 @@ export default function ReverseProxyTargets(props: {
|
|||||||
<SelectItem value="https">
|
<SelectItem value="https">
|
||||||
https
|
https
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="h2c">
|
||||||
|
h2c
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import { useEffect, useState } from "react";
|
|||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { GetResourceAuthInfoResponse } from "@server/routers/resource";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import {
|
import {
|
||||||
SettingsContainer,
|
SettingsContainer,
|
||||||
@@ -53,6 +52,15 @@ import { subdomainSchema } from "@server/lib/schemas";
|
|||||||
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
||||||
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
||||||
import { Label } from "@app/components/ui/label";
|
import { Label } from "@app/components/ui/label";
|
||||||
|
import { ListDomainsResponse } from "@server/routers/domain";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@app/components/ui/select";
|
||||||
|
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||||
|
|
||||||
const GeneralFormSchema = z
|
const GeneralFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -60,7 +68,8 @@ const GeneralFormSchema = z
|
|||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
proxyPort: z.number().optional(),
|
proxyPort: z.number().optional(),
|
||||||
http: z.boolean(),
|
http: z.boolean(),
|
||||||
isBaseDomain: z.boolean().optional()
|
isBaseDomain: z.boolean().optional(),
|
||||||
|
domainId: z.string().optional()
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
@@ -100,6 +109,7 @@ type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
|||||||
type TransferFormValues = z.infer<typeof TransferFormSchema>;
|
type TransferFormValues = z.infer<typeof TransferFormSchema>;
|
||||||
|
|
||||||
export default function GeneralForm() {
|
export default function GeneralForm() {
|
||||||
|
const [formKey, setFormKey] = useState(0);
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { resource, updateResource } = useResourceContext();
|
const { resource, updateResource } = useResourceContext();
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
@@ -113,9 +123,11 @@ export default function GeneralForm() {
|
|||||||
|
|
||||||
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
||||||
const [saveLoading, setSaveLoading] = useState(false);
|
const [saveLoading, setSaveLoading] = useState(false);
|
||||||
const [domainSuffix, setDomainSuffix] = useState(org.org.domain);
|
|
||||||
const [transferLoading, setTransferLoading] = useState(false);
|
const [transferLoading, setTransferLoading] = useState(false);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [baseDomains, setBaseDomains] = useState<
|
||||||
|
ListDomainsResponse["domains"]
|
||||||
|
>([]);
|
||||||
|
|
||||||
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
|
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
|
||||||
resource.isBaseDomain ? "basedomain" : "subdomain"
|
resource.isBaseDomain ? "basedomain" : "subdomain"
|
||||||
@@ -128,7 +140,8 @@ export default function GeneralForm() {
|
|||||||
subdomain: resource.subdomain ? resource.subdomain : undefined,
|
subdomain: resource.subdomain ? resource.subdomain : undefined,
|
||||||
proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
|
proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
|
||||||
http: resource.http,
|
http: resource.http,
|
||||||
isBaseDomain: resource.isBaseDomain ? true : false
|
isBaseDomain: resource.isBaseDomain ? true : false,
|
||||||
|
domainId: resource.domainId || undefined
|
||||||
},
|
},
|
||||||
mode: "onChange"
|
mode: "onChange"
|
||||||
});
|
});
|
||||||
@@ -147,6 +160,31 @@ export default function GeneralForm() {
|
|||||||
);
|
);
|
||||||
setSites(res.data.data.sites);
|
setSites(res.data.data.sites);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchDomains = async () => {
|
||||||
|
const res = await api
|
||||||
|
.get<
|
||||||
|
AxiosResponse<ListDomainsResponse>
|
||||||
|
>(`/org/${orgId}/domains/`)
|
||||||
|
.catch((e) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error fetching domains",
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
"An error occurred when fetching the domains"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res?.status === 200) {
|
||||||
|
const domains = res.data.data.domains;
|
||||||
|
setBaseDomains(domains);
|
||||||
|
setFormKey((key) => key + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDomains();
|
||||||
fetchSites();
|
fetchSites();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -154,12 +192,16 @@ export default function GeneralForm() {
|
|||||||
setSaveLoading(true);
|
setSaveLoading(true);
|
||||||
|
|
||||||
const res = await api
|
const res = await api
|
||||||
.post(`resource/${resource?.resourceId}`, {
|
.post<AxiosResponse<UpdateResourceResponse>>(
|
||||||
name: data.name,
|
`resource/${resource?.resourceId}`,
|
||||||
subdomain: data.subdomain,
|
{
|
||||||
proxyPort: data.proxyPort,
|
name: data.name,
|
||||||
isBaseDomain: data.isBaseDomain
|
subdomain: data.http ? data.subdomain : undefined,
|
||||||
})
|
proxyPort: data.proxyPort,
|
||||||
|
isBaseDomain: data.http ? data.isBaseDomain : undefined,
|
||||||
|
domainId: data.http ? data.domainId : undefined
|
||||||
|
}
|
||||||
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
@@ -177,12 +219,17 @@ export default function GeneralForm() {
|
|||||||
description: "The resource has been updated successfully"
|
description: "The resource has been updated successfully"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const resource = res.data.data;
|
||||||
|
|
||||||
updateResource({
|
updateResource({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
subdomain: data.subdomain,
|
subdomain: data.subdomain,
|
||||||
proxyPort: data.proxyPort,
|
proxyPort: data.proxyPort,
|
||||||
isBaseDomain: data.isBaseDomain
|
isBaseDomain: data.isBaseDomain,
|
||||||
|
fullDomain: resource.fullDomain
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
}
|
}
|
||||||
setSaveLoading(false);
|
setSaveLoading(false);
|
||||||
}
|
}
|
||||||
@@ -229,7 +276,7 @@ export default function GeneralForm() {
|
|||||||
|
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SettingsSectionForm>
|
<SettingsSectionForm>
|
||||||
<Form {...form}>
|
<Form {...form} key={formKey}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
@@ -244,11 +291,11 @@ export default function GeneralForm() {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
This is the display name of the
|
This is the display name of the
|
||||||
resource.
|
resource.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -292,60 +339,139 @@ export default function GeneralForm() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField
|
{domainType === "subdomain" ? (
|
||||||
control={form.control}
|
<div className="w-fill space-y-2">
|
||||||
name="subdomain"
|
{!env.flags
|
||||||
render={({ field }) => (
|
.allowBaseDomainResources && (
|
||||||
<FormItem>
|
<FormLabel>
|
||||||
{!env.flags
|
Subdomain
|
||||||
.allowBaseDomainResources && (
|
</FormLabel>
|
||||||
<FormLabel>
|
)}
|
||||||
Subdomain
|
<div className="flex">
|
||||||
</FormLabel>
|
<div className="w-full mr-1">
|
||||||
)}
|
<FormField
|
||||||
|
control={
|
||||||
{domainType ===
|
form.control
|
||||||
"subdomain" ? (
|
}
|
||||||
<FormControl>
|
name="subdomain"
|
||||||
<CustomDomainInput
|
render={({
|
||||||
value={
|
field
|
||||||
field.value ||
|
}) => (
|
||||||
""
|
<FormItem>
|
||||||
}
|
<FormControl>
|
||||||
domainSuffix={
|
<Input
|
||||||
domainSuffix
|
{...field}
|
||||||
}
|
className="text-right"
|
||||||
placeholder="Enter subdomain"
|
placeholder="Enter subdomain"
|
||||||
onChange={(
|
/>
|
||||||
value
|
</FormControl>
|
||||||
) =>
|
<FormMessage />
|
||||||
form.setValue(
|
</FormItem>
|
||||||
"subdomain",
|
)}
|
||||||
value
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-1/2">
|
||||||
|
<FormField
|
||||||
|
control={
|
||||||
|
form.control
|
||||||
|
}
|
||||||
|
name="domainId"
|
||||||
|
render={({
|
||||||
|
field
|
||||||
|
}) => (
|
||||||
|
<FormItem>
|
||||||
|
<Select
|
||||||
|
onValueChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
defaultValue={
|
||||||
|
field.value
|
||||||
|
}
|
||||||
|
value={
|
||||||
|
field.value
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{baseDomains.map(
|
||||||
|
(
|
||||||
|
option
|
||||||
|
) => (
|
||||||
|
<SelectItem
|
||||||
|
key={
|
||||||
|
option.domainId
|
||||||
|
}
|
||||||
|
value={
|
||||||
|
option.domainId
|
||||||
|
}
|
||||||
|
>
|
||||||
|
.
|
||||||
|
{
|
||||||
|
option.baseDomain
|
||||||
|
}
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="domainId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<Select
|
||||||
|
onValueChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
defaultValue={
|
||||||
|
field.value ||
|
||||||
|
baseDomains[0]
|
||||||
|
?.domainId
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{baseDomains.map(
|
||||||
|
(
|
||||||
|
option
|
||||||
|
) => (
|
||||||
|
<SelectItem
|
||||||
|
key={
|
||||||
|
option.domainId
|
||||||
|
}
|
||||||
|
value={
|
||||||
|
option.domainId
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
option.baseDomain
|
||||||
|
}
|
||||||
|
</SelectItem>
|
||||||
)
|
)
|
||||||
}
|
)}
|
||||||
/>
|
</SelectContent>
|
||||||
</FormControl>
|
</Select>
|
||||||
) : (
|
<FormMessage />
|
||||||
<FormControl>
|
</FormItem>
|
||||||
<Input
|
)}
|
||||||
value={
|
/>
|
||||||
domainSuffix
|
)}
|
||||||
}
|
|
||||||
readOnly
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
<FormDescription>
|
|
||||||
This is the subdomain
|
|
||||||
that will be used to
|
|
||||||
access the resource.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -361,7 +487,6 @@ export default function GeneralForm() {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Enter port number"
|
|
||||||
value={
|
value={
|
||||||
field.value ?? ""
|
field.value ?? ""
|
||||||
}
|
}
|
||||||
@@ -378,12 +503,12 @@ export default function GeneralForm() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
This is the port that will
|
This is the port that will
|
||||||
be used to access the
|
be used to access the
|
||||||
resource.
|
resource.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -427,7 +552,7 @@ export default function GeneralForm() {
|
|||||||
control={transferForm.control}
|
control={transferForm.control}
|
||||||
name="siteId"
|
name="siteId"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-col">
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
Destination Site
|
Destination Site
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
@@ -460,7 +585,7 @@ export default function GeneralForm() {
|
|||||||
<PopoverContent className="w-full p-0">
|
<PopoverContent className="w-full p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search sites..."
|
placeholder="Search sites"
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
@@ -503,10 +628,6 @@ export default function GeneralForm() {
|
|||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<FormDescription>
|
|
||||||
Select the new site to transfer
|
|
||||||
this resource to.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -522,7 +643,6 @@ export default function GeneralForm() {
|
|||||||
loading={transferLoading}
|
loading={transferLoading}
|
||||||
disabled={transferLoading}
|
disabled={transferLoading}
|
||||||
form="transfer-form"
|
form="transfer-form"
|
||||||
variant="destructive"
|
|
||||||
>
|
>
|
||||||
Transfer Resource
|
Transfer Resource
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ import {
|
|||||||
isValidUrlGlobPattern
|
isValidUrlGlobPattern
|
||||||
} from "@server/lib/validators";
|
} from "@server/lib/validators";
|
||||||
import { Switch } from "@app/components/ui/switch";
|
import { Switch } from "@app/components/ui/switch";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
// Schema for rule validation
|
// Schema for rule validation
|
||||||
const addRuleSchema = z.object({
|
const addRuleSchema = z.object({
|
||||||
@@ -91,9 +92,9 @@ enum RuleAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum RuleMatch {
|
enum RuleMatch {
|
||||||
|
PATH = "Path",
|
||||||
IP = "IP",
|
IP = "IP",
|
||||||
CIDR = "IP Range",
|
CIDR = "IP Range",
|
||||||
PATH = "Path"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ResourceRules(props: {
|
export default function ResourceRules(props: {
|
||||||
@@ -107,6 +108,7 @@ export default function ResourceRules(props: {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [pageLoading, setPageLoading] = useState(true);
|
const [pageLoading, setPageLoading] = useState(true);
|
||||||
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules);
|
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const addRuleForm = useForm({
|
const addRuleForm = useForm({
|
||||||
resolver: zodResolver(addRuleSchema),
|
resolver: zodResolver(addRuleSchema),
|
||||||
@@ -253,6 +255,7 @@ export default function ResourceRules(props: {
|
|||||||
title: "Enable Rules",
|
title: "Enable Rules",
|
||||||
description: "Rule evaluation has been updated"
|
description: "Rule evaluation has been updated"
|
||||||
});
|
});
|
||||||
|
router.refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,6 +373,7 @@ export default function ResourceRules(props: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setRulesToRemove([]);
|
setRulesToRemove([]);
|
||||||
|
router.refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
toast({
|
toast({
|
||||||
@@ -465,9 +469,9 @@ export default function ResourceRules(props: {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
|
||||||
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
|
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
|
||||||
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
|
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
|
||||||
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
@@ -520,7 +524,13 @@ export default function ResourceRules(props: {
|
|||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getFilteredRowModel: getFilteredRowModel()
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
state: {
|
||||||
|
pagination: {
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (pageLoading) {
|
if (pageLoading) {
|
||||||
@@ -590,7 +600,7 @@ export default function ResourceRules(props: {
|
|||||||
<SwitchInput
|
<SwitchInput
|
||||||
id="rules-toggle"
|
id="rules-toggle"
|
||||||
label="Enable Rules"
|
label="Enable Rules"
|
||||||
defaultChecked={resource.applyRules}
|
defaultChecked={rulesEnabled}
|
||||||
onCheckedChange={async (val) => {
|
onCheckedChange={async (val) => {
|
||||||
await saveApplyRules(val);
|
await saveApplyRules(val);
|
||||||
}}
|
}}
|
||||||
@@ -661,17 +671,17 @@ export default function ResourceRules(props: {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
{resource.http && (
|
||||||
|
<SelectItem value="PATH">
|
||||||
|
{RuleMatch.PATH}
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
<SelectItem value="IP">
|
<SelectItem value="IP">
|
||||||
{RuleMatch.IP}
|
{RuleMatch.IP}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="CIDR">
|
<SelectItem value="CIDR">
|
||||||
{RuleMatch.CIDR}
|
{RuleMatch.CIDR}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
{resource.http && (
|
|
||||||
<SelectItem value="PATH">
|
|
||||||
{RuleMatch.PATH}
|
|
||||||
</SelectItem>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ export default function CreateShareLinkForm({
|
|||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="p-0">
|
<PopoverContent className="p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="Search resources..." />
|
<CommandInput placeholder="Search resources" />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
No
|
No
|
||||||
@@ -374,7 +374,6 @@ export default function CreateShareLinkForm({
|
|||||||
</Label>
|
</Label>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter title"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -437,7 +436,6 @@ export default function CreateShareLinkForm({
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
placeholder="Enter duration"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -272,17 +272,13 @@ PersistentKeepalive = 5`
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name</FormLabel>
|
<FormLabel>Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input autoComplete="off" {...field} />
|
||||||
autoComplete="off"
|
|
||||||
placeholder="Site name"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
|
||||||
This is the name that will be displayed for
|
|
||||||
this site.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
This is the the display name for the
|
||||||
|
site.
|
||||||
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -319,10 +315,10 @@ PersistentKeepalive = 5`
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
This is how you will expose connections.
|
This is how you will expose connections.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -354,7 +350,7 @@ PersistentKeepalive = 5`
|
|||||||
) : form.watch("method") === "wireguard" &&
|
) : form.watch("method") === "wireguard" &&
|
||||||
isLoading ? (
|
isLoading ? (
|
||||||
<p>Loading WireGuard configuration...</p>
|
<p>Loading WireGuard configuration...</p>
|
||||||
) : form.watch("method") === "newt" ? (
|
) : form.watch("method") === "newt" && siteDefaults ? (
|
||||||
<>
|
<>
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<Collapsible
|
<Collapsible
|
||||||
@@ -376,8 +372,8 @@ PersistentKeepalive = 5`
|
|||||||
className="p-0 flex items-center justify-between w-full"
|
className="p-0 flex items-center justify-between w-full"
|
||||||
>
|
>
|
||||||
<h4 className="text-sm font-semibold">
|
<h4 className="text-sm font-semibold">
|
||||||
Expand for Docker Deployment
|
Expand for Docker
|
||||||
Details
|
Deployment Details
|
||||||
</h4>
|
</h4>
|
||||||
<div>
|
<div>
|
||||||
<ChevronsUpDown className="h-4 w-4" />
|
<ChevronsUpDown className="h-4 w-4" />
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
const GeneralFormSchema = z.object({
|
const GeneralFormSchema = z.object({
|
||||||
name: z.string()
|
name: z.string().nonempty("Name is required")
|
||||||
});
|
});
|
||||||
|
|
||||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||||
@@ -114,11 +114,11 @@ export default function GeneralPage() {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
This is the display name of the
|
This is the display name of the
|
||||||
site
|
site.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import { Loader2 } from "lucide-react";
|
|||||||
import { Alert, AlertDescription } from "../../../components/ui/alert";
|
import { Alert, AlertDescription } from "../../../components/ui/alert";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
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 { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||||
@@ -223,16 +223,13 @@ export default function ResetPasswordForm({
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input {...field} />
|
||||||
placeholder="Enter your email"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
We'll send a password reset
|
We'll send a password reset
|
||||||
code to this email address.
|
code to this email address.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -255,7 +252,6 @@ export default function ResetPasswordForm({
|
|||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Email"
|
|
||||||
{...field}
|
{...field}
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
@@ -276,12 +272,15 @@ export default function ResetPasswordForm({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter reset code sent to your email"
|
|
||||||
type="password"
|
type="password"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
Check your email for the
|
||||||
|
reset code.
|
||||||
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -298,7 +297,6 @@ export default function ResetPasswordForm({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Password"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -317,7 +315,6 @@ export default function ResetPasswordForm({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Confirm Password"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -349,7 +346,9 @@ export default function ResetPasswordForm({
|
|||||||
<InputOTP
|
<InputOTP
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
{...field}
|
{...field}
|
||||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
pattern={
|
||||||
|
REGEXP_ONLY_DIGITS_AND_CHARS
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<InputOTPGroup>
|
<InputOTPGroup>
|
||||||
<InputOTPSlot
|
<InputOTPSlot
|
||||||
|
|||||||
@@ -263,7 +263,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isAllowed) {
|
if (isAllowed) {
|
||||||
window.location.href = props.redirect;
|
// window.location.href = props.redirect;
|
||||||
|
router.refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,7 +449,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter password"
|
|
||||||
type="password"
|
type="password"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
@@ -517,7 +517,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter email"
|
|
||||||
type="email"
|
type="email"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
@@ -576,7 +575,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter OTP"
|
|
||||||
type="password"
|
type="password"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export default function SignupForm({
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Email" {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -160,7 +160,6 @@ export default function SignupForm({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Password"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -177,7 +176,6 @@ export default function SignupForm({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Confirm Password"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -145,7 +145,6 @@ export default function VerifyEmailForm({
|
|||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Email"
|
|
||||||
{...field}
|
{...field}
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
@@ -196,12 +195,12 @@ export default function VerifyEmailForm({
|
|||||||
</InputOTP>
|
</InputOTP>
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
We sent a verification code to your
|
We sent a verification code to your
|
||||||
email address. Please enter the code
|
email address. Please enter the code
|
||||||
to verify your email address.
|
to verify your email address.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -200,7 +200,6 @@ export default function StepperForm() {
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Name your new organization"
|
|
||||||
type="text"
|
type="text"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -242,7 +241,6 @@ export default function StepperForm() {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter unique organization ID"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -135,7 +135,6 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter your password"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -200,7 +200,6 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter your password"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -246,7 +245,6 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="code"
|
type="code"
|
||||||
placeholder="Enter the 6-digit code from your authenticator app"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -147,7 +147,6 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
|||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter your email"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -166,7 +165,6 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter your password"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
353
src/components/tags/autocomplete.tsx
Normal file
353
src/components/tags/autocomplete.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
// import { Command, CommandList, CommandItem, CommandGroup, CommandEmpty } from '../ui/command';
|
||||||
|
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
|
||||||
|
type AutocompleteProps = {
|
||||||
|
tags: TagType[];
|
||||||
|
setTags: React.Dispatch<React.SetStateAction<TagType[]>>;
|
||||||
|
setInputValue: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
setTagCount: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
autocompleteOptions: TagType[];
|
||||||
|
maxTags?: number;
|
||||||
|
onTagAdd?: (tag: string) => void;
|
||||||
|
onTagRemove?: (tag: string) => void;
|
||||||
|
allowDuplicates: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
inlineTags?: boolean;
|
||||||
|
classStyleProps: TagInputStyleClassesProps["autoComplete"];
|
||||||
|
usePortal?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Autocomplete: React.FC<AutocompleteProps> = ({
|
||||||
|
tags,
|
||||||
|
setTags,
|
||||||
|
setInputValue,
|
||||||
|
setTagCount,
|
||||||
|
autocompleteOptions,
|
||||||
|
maxTags,
|
||||||
|
onTagAdd,
|
||||||
|
onTagRemove,
|
||||||
|
allowDuplicates,
|
||||||
|
inlineTags,
|
||||||
|
children,
|
||||||
|
classStyleProps,
|
||||||
|
usePortal
|
||||||
|
}) => {
|
||||||
|
const triggerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const popoverContentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const [popoverWidth, setPopoverWidth] = useState<number>(0);
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
const [inputFocused, setInputFocused] = useState(false);
|
||||||
|
const [popooverContentTop, setPopoverContentTop] = useState<number>(0);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
|
||||||
|
|
||||||
|
// Dynamically calculate the top position for the popover content
|
||||||
|
useEffect(() => {
|
||||||
|
if (!triggerContainerRef.current || !triggerRef.current) return;
|
||||||
|
setPopoverContentTop(
|
||||||
|
triggerContainerRef.current?.getBoundingClientRect().bottom -
|
||||||
|
triggerRef.current?.getBoundingClientRect().bottom
|
||||||
|
);
|
||||||
|
}, [tags]);
|
||||||
|
|
||||||
|
// Close the popover when clicking outside of it
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOutsideClick = (
|
||||||
|
event: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
isPopoverOpen &&
|
||||||
|
triggerContainerRef.current &&
|
||||||
|
popoverContentRef.current &&
|
||||||
|
!triggerContainerRef.current.contains(event.target as Node) &&
|
||||||
|
!popoverContentRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleOutsideClick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleOutsideClick);
|
||||||
|
};
|
||||||
|
}, [isPopoverOpen]);
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback(
|
||||||
|
(open: boolean) => {
|
||||||
|
if (open && triggerContainerRef.current) {
|
||||||
|
const { width } =
|
||||||
|
triggerContainerRef.current.getBoundingClientRect();
|
||||||
|
setPopoverWidth(width);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
setIsPopoverOpen(open);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[inputFocused]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInputFocus = (
|
||||||
|
event:
|
||||||
|
| React.FocusEvent<HTMLInputElement>
|
||||||
|
| React.FocusEvent<HTMLTextAreaElement>
|
||||||
|
) => {
|
||||||
|
if (triggerContainerRef.current) {
|
||||||
|
const { width } =
|
||||||
|
triggerContainerRef.current.getBoundingClientRect();
|
||||||
|
setPopoverWidth(width);
|
||||||
|
setIsPopoverOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only set inputFocused to true if the popover is already open.
|
||||||
|
// This will prevent the popover from opening due to an input focus if it was initially closed.
|
||||||
|
if (isPopoverOpen) {
|
||||||
|
setInputFocused(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userOnFocus = (children as React.ReactElement<any>).props.onFocus;
|
||||||
|
if (userOnFocus) userOnFocus(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputBlur = (
|
||||||
|
event:
|
||||||
|
| React.FocusEvent<HTMLInputElement>
|
||||||
|
| React.FocusEvent<HTMLTextAreaElement>
|
||||||
|
) => {
|
||||||
|
setInputFocused(false);
|
||||||
|
|
||||||
|
// Allow the popover to close if no other interactions keep it open
|
||||||
|
if (!isPopoverOpen) {
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userOnBlur = (children as React.ReactElement<any>).props.onBlur;
|
||||||
|
if (userOnBlur) userOnBlur(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (!isPopoverOpen) return;
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case "ArrowUp":
|
||||||
|
event.preventDefault();
|
||||||
|
setSelectedIndex((prevIndex) =>
|
||||||
|
prevIndex <= 0
|
||||||
|
? autocompleteOptions.length - 1
|
||||||
|
: prevIndex - 1
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
event.preventDefault();
|
||||||
|
setSelectedIndex((prevIndex) =>
|
||||||
|
prevIndex === autocompleteOptions.length - 1
|
||||||
|
? 0
|
||||||
|
: prevIndex + 1
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "Enter":
|
||||||
|
event.preventDefault();
|
||||||
|
if (selectedIndex !== -1) {
|
||||||
|
toggleTag(autocompleteOptions[selectedIndex]);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTag = (option: TagType) => {
|
||||||
|
// Check if the tag already exists in the array
|
||||||
|
const index = tags.findIndex((tag) => tag.text === option.text);
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
// Tag exists, remove it
|
||||||
|
const newTags = tags.filter((_, i) => i !== index);
|
||||||
|
setTags(newTags);
|
||||||
|
setTagCount((prevCount) => prevCount - 1);
|
||||||
|
if (onTagRemove) {
|
||||||
|
onTagRemove(option.text);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Tag doesn't exist, add it if allowed
|
||||||
|
if (
|
||||||
|
!allowDuplicates &&
|
||||||
|
tags.some((tag) => tag.text === option.text)
|
||||||
|
) {
|
||||||
|
// If duplicates aren't allowed and a tag with the same text exists, do nothing
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the tag if it doesn't exceed max tags, if applicable
|
||||||
|
if (!maxTags || tags.length < maxTags) {
|
||||||
|
setTags([...tags, option]);
|
||||||
|
setTagCount((prevCount) => prevCount + 1);
|
||||||
|
setInputValue("");
|
||||||
|
if (onTagAdd) {
|
||||||
|
onTagAdd(option.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const childrenWithProps = React.cloneElement(
|
||||||
|
children as React.ReactElement<any>,
|
||||||
|
{
|
||||||
|
onKeyDown: handleKeyDown,
|
||||||
|
onFocus: handleInputFocus,
|
||||||
|
onBlur: handleInputBlur,
|
||||||
|
ref: inputRef
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||||
|
classStyleProps?.command
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Popover
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
modal={usePortal}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative h-full flex items-center rounded-md border-2 bg-transparent pr-3"
|
||||||
|
ref={triggerContainerRef}
|
||||||
|
>
|
||||||
|
{childrenWithProps}
|
||||||
|
<PopoverTrigger asChild ref={triggerRef}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
role="combobox"
|
||||||
|
className={cn(
|
||||||
|
`hover:bg-transparent ${!inlineTags ? "ml-auto" : ""}`,
|
||||||
|
classStyleProps?.popoverTrigger
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setIsPopoverOpen(!isPopoverOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={`lucide lucide-chevron-down h-4 w-4 shrink-0 opacity-50 ${isPopoverOpen ? "rotate-180" : "rotate-0"}`}
|
||||||
|
>
|
||||||
|
<path d="m6 9 6 6 6-6"></path>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
</div>
|
||||||
|
<PopoverContent
|
||||||
|
ref={popoverContentRef}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
forceMount
|
||||||
|
className={cn(
|
||||||
|
`p-0 relative`,
|
||||||
|
classStyleProps?.popoverContent
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
top: `${popooverContentTop}px`,
|
||||||
|
marginLeft: `calc(-${popoverWidth}px + 36px)`,
|
||||||
|
width: `${popoverWidth}px`,
|
||||||
|
minWidth: `${popoverWidth}px`,
|
||||||
|
zIndex: 9999
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"max-h-[300px] overflow-y-auto overflow-x-hidden",
|
||||||
|
classStyleProps?.commandList
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
minHeight: "68px"
|
||||||
|
}}
|
||||||
|
key={autocompleteOptions.length}
|
||||||
|
>
|
||||||
|
{autocompleteOptions.length > 0 ? (
|
||||||
|
<div
|
||||||
|
key={autocompleteOptions.length}
|
||||||
|
role="group"
|
||||||
|
className={cn(
|
||||||
|
"overflow-y-auto overflow-hidden p-1 text-foreground",
|
||||||
|
classStyleProps?.commandGroup
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
minHeight: "68px"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground font-medium text-sm py-1.5 px-2 pb-2">
|
||||||
|
Suggestions
|
||||||
|
</span>
|
||||||
|
<div role="separator" className="py-0.5" />
|
||||||
|
{autocompleteOptions.map((option, index) => {
|
||||||
|
const isSelected = index === selectedIndex;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={option.id}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-accent",
|
||||||
|
isSelected &&
|
||||||
|
"bg-accent text-accent-foreground",
|
||||||
|
classStyleProps?.commandItem
|
||||||
|
)}
|
||||||
|
data-value={option.text}
|
||||||
|
onClick={() => toggleTag(option)}
|
||||||
|
>
|
||||||
|
<div className="w-full flex items-center gap-2">
|
||||||
|
{option.text}
|
||||||
|
{tags.some(
|
||||||
|
(tag) =>
|
||||||
|
tag.text === option.text
|
||||||
|
) && (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="lucide lucide-check"
|
||||||
|
>
|
||||||
|
<path d="M20 6 9 17l-5-5"></path>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-6 text-center text-sm">
|
||||||
|
No results found.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
949
src/components/tags/tag-input.tsx
Normal file
949
src/components/tags/tag-input.tsx
Normal file
@@ -0,0 +1,949 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { type VariantProps } from "class-variance-authority";
|
||||||
|
// import { CommandInput } from '../ui/command';
|
||||||
|
import { TagPopover } from "./tag-popover";
|
||||||
|
import { TagList } from "./tag-list";
|
||||||
|
import { tagVariants } from "./tag";
|
||||||
|
import { Autocomplete } from "./autocomplete";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
|
||||||
|
export enum Delimiter {
|
||||||
|
Comma = ",",
|
||||||
|
Enter = "Enter"
|
||||||
|
}
|
||||||
|
|
||||||
|
type OmittedInputProps = Omit<
|
||||||
|
React.InputHTMLAttributes<HTMLInputElement>,
|
||||||
|
"size" | "value"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type Tag = {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TagInputStyleClassesProps {
|
||||||
|
inlineTagsContainer?: string;
|
||||||
|
tagPopover?: {
|
||||||
|
popoverTrigger?: string;
|
||||||
|
popoverContent?: string;
|
||||||
|
};
|
||||||
|
tagList?: {
|
||||||
|
container?: string;
|
||||||
|
sortableList?: string;
|
||||||
|
};
|
||||||
|
autoComplete?: {
|
||||||
|
command?: string;
|
||||||
|
popoverTrigger?: string;
|
||||||
|
popoverContent?: string;
|
||||||
|
commandList?: string;
|
||||||
|
commandGroup?: string;
|
||||||
|
commandItem?: string;
|
||||||
|
};
|
||||||
|
tag?: {
|
||||||
|
body?: string;
|
||||||
|
closeButton?: string;
|
||||||
|
};
|
||||||
|
input?: string;
|
||||||
|
clearAllButton?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagInputProps
|
||||||
|
extends OmittedInputProps,
|
||||||
|
VariantProps<typeof tagVariants> {
|
||||||
|
placeholder?: string;
|
||||||
|
tags: Tag[];
|
||||||
|
setTags: React.Dispatch<React.SetStateAction<Tag[]>>;
|
||||||
|
enableAutocomplete?: boolean;
|
||||||
|
autocompleteOptions?: Tag[];
|
||||||
|
maxTags?: number;
|
||||||
|
minTags?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onTagAdd?: (tag: string) => void;
|
||||||
|
onTagRemove?: (tag: string) => void;
|
||||||
|
allowDuplicates?: boolean;
|
||||||
|
validateTag?: (tag: string) => boolean;
|
||||||
|
delimiter?: Delimiter;
|
||||||
|
showCount?: boolean;
|
||||||
|
placeholderWhenFull?: string;
|
||||||
|
sortTags?: boolean;
|
||||||
|
delimiterList?: string[];
|
||||||
|
truncate?: number;
|
||||||
|
minLength?: number;
|
||||||
|
maxLength?: number;
|
||||||
|
usePopoverForTags?: boolean;
|
||||||
|
value?:
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| readonly string[]
|
||||||
|
| { id: string; text: string }[];
|
||||||
|
autocompleteFilter?: (option: string) => boolean;
|
||||||
|
direction?: "row" | "column";
|
||||||
|
onInputChange?: (value: string) => void;
|
||||||
|
customTagRenderer?: (tag: Tag, isActiveTag: boolean) => React.ReactNode;
|
||||||
|
onFocus?: React.FocusEventHandler<HTMLInputElement>;
|
||||||
|
onBlur?: React.FocusEventHandler<HTMLInputElement>;
|
||||||
|
onTagClick?: (tag: Tag) => void;
|
||||||
|
draggable?: boolean;
|
||||||
|
inputFieldPosition?: "bottom" | "top";
|
||||||
|
clearAll?: boolean;
|
||||||
|
onClearAll?: () => void;
|
||||||
|
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
restrictTagsToAutocompleteOptions?: boolean;
|
||||||
|
inlineTags?: boolean;
|
||||||
|
activeTagIndex: number | null;
|
||||||
|
setActiveTagIndex: React.Dispatch<React.SetStateAction<number | null>>;
|
||||||
|
styleClasses?: TagInputStyleClassesProps;
|
||||||
|
usePortal?: boolean;
|
||||||
|
addOnPaste?: boolean;
|
||||||
|
addTagsOnBlur?: boolean;
|
||||||
|
generateTagId?: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
placeholder,
|
||||||
|
tags,
|
||||||
|
setTags,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
shape,
|
||||||
|
enableAutocomplete,
|
||||||
|
autocompleteOptions,
|
||||||
|
maxTags,
|
||||||
|
delimiter = Delimiter.Comma,
|
||||||
|
onTagAdd,
|
||||||
|
onTagRemove,
|
||||||
|
allowDuplicates,
|
||||||
|
showCount,
|
||||||
|
validateTag,
|
||||||
|
placeholderWhenFull = "Max tags reached",
|
||||||
|
sortTags,
|
||||||
|
delimiterList,
|
||||||
|
truncate,
|
||||||
|
autocompleteFilter,
|
||||||
|
borderStyle,
|
||||||
|
textCase,
|
||||||
|
interaction,
|
||||||
|
animation,
|
||||||
|
textStyle,
|
||||||
|
minLength,
|
||||||
|
maxLength,
|
||||||
|
direction = "row",
|
||||||
|
onInputChange,
|
||||||
|
customTagRenderer,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
onTagClick,
|
||||||
|
draggable = false,
|
||||||
|
inputFieldPosition = "bottom",
|
||||||
|
clearAll = false,
|
||||||
|
onClearAll,
|
||||||
|
usePopoverForTags = false,
|
||||||
|
inputProps = {},
|
||||||
|
restrictTagsToAutocompleteOptions,
|
||||||
|
inlineTags = true,
|
||||||
|
addTagsOnBlur = false,
|
||||||
|
activeTagIndex,
|
||||||
|
setActiveTagIndex,
|
||||||
|
styleClasses = {},
|
||||||
|
disabled = false,
|
||||||
|
usePortal = false,
|
||||||
|
addOnPaste = false,
|
||||||
|
generateTagId = uuid
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [inputValue, setInputValue] = React.useState("");
|
||||||
|
const [tagCount, setTagCount] = React.useState(
|
||||||
|
Math.max(0, tags.length)
|
||||||
|
);
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(maxTags !== undefined && maxTags < 0) ||
|
||||||
|
(props.minTags !== undefined && props.minTags < 0)
|
||||||
|
) {
|
||||||
|
console.warn("maxTags and minTags cannot be less than 0");
|
||||||
|
// error
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
if (addOnPaste && newValue.includes(delimiter)) {
|
||||||
|
const splitValues = newValue
|
||||||
|
.split(delimiter)
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter((v) => v);
|
||||||
|
splitValues.forEach((value) => {
|
||||||
|
if (!value) return; // Skip empty strings from split
|
||||||
|
|
||||||
|
const newTagText = value.trim();
|
||||||
|
|
||||||
|
// Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true
|
||||||
|
if (
|
||||||
|
restrictTagsToAutocompleteOptions &&
|
||||||
|
!autocompleteOptions?.some(
|
||||||
|
(option) => option.text === newTagText
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
"Tag not allowed as per autocomplete options"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validateTag && !validateTag(newTagText)) {
|
||||||
|
console.warn("Invalid tag as per validateTag");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minLength && newTagText.length < minLength) {
|
||||||
|
console.warn(`Tag "${newTagText}" is too short`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxLength && newTagText.length > maxLength) {
|
||||||
|
console.warn(`Tag "${newTagText}" is too long`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTagId = generateTagId();
|
||||||
|
|
||||||
|
// Add tag if duplicates are allowed or tag does not already exist
|
||||||
|
if (
|
||||||
|
allowDuplicates ||
|
||||||
|
!tags.some((tag) => tag.text === newTagText)
|
||||||
|
) {
|
||||||
|
if (maxTags === undefined || tags.length < maxTags) {
|
||||||
|
// Check for maxTags limit
|
||||||
|
const newTag = { id: newTagId, text: newTagText };
|
||||||
|
setTags((prevTags) => [...prevTags, newTag]);
|
||||||
|
onTagAdd?.(newTagText);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
"Reached the maximum number of tags allowed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`Duplicate tag "${newTagText}" not added`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setInputValue("");
|
||||||
|
} else {
|
||||||
|
setInputValue(newValue);
|
||||||
|
}
|
||||||
|
onInputChange?.(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputFocus = (
|
||||||
|
event: React.FocusEvent<HTMLInputElement>
|
||||||
|
) => {
|
||||||
|
setActiveTagIndex(null); // Reset active tag index when the input field gains focus
|
||||||
|
onFocus?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputBlur = (event: React.FocusEvent<HTMLInputElement>) => {
|
||||||
|
if (addTagsOnBlur && inputValue.trim()) {
|
||||||
|
const newTagText = inputValue.trim();
|
||||||
|
|
||||||
|
if (validateTag && !validateTag(newTagText)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minLength && newTagText.length < minLength) {
|
||||||
|
console.warn("Tag is too short");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxLength && newTagText.length > maxLength) {
|
||||||
|
console.warn("Tag is too long");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(allowDuplicates ||
|
||||||
|
!tags.some((tag) => tag.text === newTagText)) &&
|
||||||
|
(maxTags === undefined || tags.length < maxTags)
|
||||||
|
) {
|
||||||
|
const newTagId = generateTagId();
|
||||||
|
setTags([...tags, { id: newTagId, text: newTagText }]);
|
||||||
|
onTagAdd?.(newTagText);
|
||||||
|
setTagCount((prevTagCount) => prevTagCount + 1);
|
||||||
|
setInputValue("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBlur?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (
|
||||||
|
delimiterList
|
||||||
|
? delimiterList.includes(e.key)
|
||||||
|
: e.key === delimiter || e.key === Delimiter.Enter
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
const newTagText = inputValue.trim();
|
||||||
|
|
||||||
|
// Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true
|
||||||
|
if (
|
||||||
|
restrictTagsToAutocompleteOptions &&
|
||||||
|
!autocompleteOptions?.some(
|
||||||
|
(option) => option.text === newTagText
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// error
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validateTag && !validateTag(newTagText)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minLength && newTagText.length < minLength) {
|
||||||
|
console.warn("Tag is too short");
|
||||||
|
// error
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate maxLength
|
||||||
|
if (maxLength && newTagText.length > maxLength) {
|
||||||
|
// error
|
||||||
|
console.warn("Tag is too long");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTagId = generateTagId();
|
||||||
|
|
||||||
|
if (
|
||||||
|
newTagText &&
|
||||||
|
(allowDuplicates ||
|
||||||
|
!tags.some((tag) => tag.text === newTagText)) &&
|
||||||
|
(maxTags === undefined || tags.length < maxTags)
|
||||||
|
) {
|
||||||
|
setTags([...tags, { id: newTagId, text: newTagText }]);
|
||||||
|
onTagAdd?.(newTagText);
|
||||||
|
setTagCount((prevTagCount) => prevTagCount + 1);
|
||||||
|
}
|
||||||
|
setInputValue("");
|
||||||
|
} else {
|
||||||
|
switch (e.key) {
|
||||||
|
case "Delete":
|
||||||
|
if (activeTagIndex !== null) {
|
||||||
|
e.preventDefault();
|
||||||
|
const newTags = [...tags];
|
||||||
|
newTags.splice(activeTagIndex, 1);
|
||||||
|
setTags(newTags);
|
||||||
|
setActiveTagIndex((prev) =>
|
||||||
|
newTags.length === 0
|
||||||
|
? null
|
||||||
|
: prev! >= newTags.length
|
||||||
|
? newTags.length - 1
|
||||||
|
: prev
|
||||||
|
);
|
||||||
|
setTagCount((prevTagCount) => prevTagCount - 1);
|
||||||
|
onTagRemove?.(tags[activeTagIndex].text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Backspace":
|
||||||
|
if (activeTagIndex !== null) {
|
||||||
|
e.preventDefault();
|
||||||
|
const newTags = [...tags];
|
||||||
|
newTags.splice(activeTagIndex, 1);
|
||||||
|
setTags(newTags);
|
||||||
|
setActiveTagIndex((prev) =>
|
||||||
|
prev! === 0 ? null : prev! - 1
|
||||||
|
);
|
||||||
|
setTagCount((prevTagCount) => prevTagCount - 1);
|
||||||
|
onTagRemove?.(tags[activeTagIndex].text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "ArrowRight":
|
||||||
|
e.preventDefault();
|
||||||
|
if (activeTagIndex === null) {
|
||||||
|
setActiveTagIndex(0);
|
||||||
|
} else {
|
||||||
|
setActiveTagIndex((prev) =>
|
||||||
|
prev! + 1 >= tags.length ? 0 : prev! + 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "ArrowLeft":
|
||||||
|
e.preventDefault();
|
||||||
|
if (activeTagIndex === null) {
|
||||||
|
setActiveTagIndex(tags.length - 1);
|
||||||
|
} else {
|
||||||
|
setActiveTagIndex((prev) =>
|
||||||
|
prev! === 0 ? tags.length - 1 : prev! - 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Home":
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveTagIndex(0);
|
||||||
|
break;
|
||||||
|
case "End":
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveTagIndex(tags.length - 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTag = (idToRemove: string) => {
|
||||||
|
setTags(tags.filter((tag) => tag.id !== idToRemove));
|
||||||
|
onTagRemove?.(
|
||||||
|
tags.find((tag) => tag.id === idToRemove)?.text || ""
|
||||||
|
);
|
||||||
|
setTagCount((prevTagCount) => prevTagCount - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSortEnd = (oldIndex: number, newIndex: number) => {
|
||||||
|
setTags((currentTags) => {
|
||||||
|
const newTags = [...currentTags];
|
||||||
|
const [removedTag] = newTags.splice(oldIndex, 1);
|
||||||
|
newTags.splice(newIndex, 0, removedTag);
|
||||||
|
|
||||||
|
return newTags;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearAll = () => {
|
||||||
|
if (!onClearAll) {
|
||||||
|
setActiveTagIndex(-1);
|
||||||
|
setTags([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClearAll?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
// const filteredAutocompleteOptions = autocompleteFilter
|
||||||
|
// ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text))
|
||||||
|
// : autocompleteOptions;
|
||||||
|
const filteredAutocompleteOptions = useMemo(() => {
|
||||||
|
return (autocompleteOptions || []).filter((option) =>
|
||||||
|
option.text
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(inputValue ? inputValue.toLowerCase() : "")
|
||||||
|
);
|
||||||
|
}, [inputValue, autocompleteOptions]);
|
||||||
|
|
||||||
|
const displayedTags = sortTags ? [...tags].sort() : tags;
|
||||||
|
|
||||||
|
const truncatedTags = truncate
|
||||||
|
? tags.map((tag) => ({
|
||||||
|
id: tag.id,
|
||||||
|
text:
|
||||||
|
tag.text?.length > truncate
|
||||||
|
? `${tag.text.substring(0, truncate)}...`
|
||||||
|
: tag.text
|
||||||
|
}))
|
||||||
|
: displayedTags;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-full flex ${!inlineTags && tags.length > 0 ? "gap-3" : ""} ${
|
||||||
|
inputFieldPosition === "bottom"
|
||||||
|
? "flex-col"
|
||||||
|
: inputFieldPosition === "top"
|
||||||
|
? "flex-col-reverse"
|
||||||
|
: "flex-row"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!usePopoverForTags &&
|
||||||
|
(!inlineTags ? (
|
||||||
|
<TagList
|
||||||
|
tags={truncatedTags}
|
||||||
|
customTagRenderer={customTagRenderer}
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
shape={shape}
|
||||||
|
borderStyle={borderStyle}
|
||||||
|
textCase={textCase}
|
||||||
|
interaction={interaction}
|
||||||
|
animation={animation}
|
||||||
|
textStyle={textStyle}
|
||||||
|
onTagClick={onTagClick}
|
||||||
|
draggable={draggable}
|
||||||
|
onSortEnd={onSortEnd}
|
||||||
|
onRemoveTag={removeTag}
|
||||||
|
direction={direction}
|
||||||
|
inlineTags={inlineTags}
|
||||||
|
activeTagIndex={activeTagIndex}
|
||||||
|
setActiveTagIndex={setActiveTagIndex}
|
||||||
|
classStyleProps={{
|
||||||
|
tagListClasses: styleClasses?.tagList,
|
||||||
|
tagClasses: styleClasses?.tag
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
!enableAutocomplete && (
|
||||||
|
<div className="w-full">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
`flex flex-row flex-wrap items-center gap-2 p-2 w-full rounded-md border-2 border-input bg-background text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50`,
|
||||||
|
styleClasses?.inlineTagsContainer
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TagList
|
||||||
|
tags={truncatedTags}
|
||||||
|
customTagRenderer={customTagRenderer}
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
shape={shape}
|
||||||
|
borderStyle={borderStyle}
|
||||||
|
textCase={textCase}
|
||||||
|
interaction={interaction}
|
||||||
|
animation={animation}
|
||||||
|
textStyle={textStyle}
|
||||||
|
onTagClick={onTagClick}
|
||||||
|
draggable={draggable}
|
||||||
|
onSortEnd={onSortEnd}
|
||||||
|
onRemoveTag={removeTag}
|
||||||
|
direction={direction}
|
||||||
|
inlineTags={inlineTags}
|
||||||
|
activeTagIndex={activeTagIndex}
|
||||||
|
setActiveTagIndex={setActiveTagIndex}
|
||||||
|
classStyleProps={{
|
||||||
|
tagListClasses:
|
||||||
|
styleClasses?.tagList,
|
||||||
|
tagClasses: styleClasses?.tag
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
placeholder={
|
||||||
|
maxTags !== undefined &&
|
||||||
|
tags.length >= maxTags
|
||||||
|
? placeholderWhenFull
|
||||||
|
: placeholder
|
||||||
|
}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={handleInputFocus}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
{...inputProps}
|
||||||
|
className={cn(
|
||||||
|
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit",
|
||||||
|
// className,
|
||||||
|
styleClasses?.input
|
||||||
|
)}
|
||||||
|
autoComplete={
|
||||||
|
enableAutocomplete ? "on" : "off"
|
||||||
|
}
|
||||||
|
list={
|
||||||
|
enableAutocomplete
|
||||||
|
? "autocomplete-options"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
disabled ||
|
||||||
|
(maxTags !== undefined &&
|
||||||
|
tags.length >= maxTags)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
{enableAutocomplete ? (
|
||||||
|
<div className="w-full">
|
||||||
|
<Autocomplete
|
||||||
|
tags={tags}
|
||||||
|
setTags={setTags}
|
||||||
|
setInputValue={setInputValue}
|
||||||
|
autocompleteOptions={
|
||||||
|
filteredAutocompleteOptions as Tag[]
|
||||||
|
}
|
||||||
|
setTagCount={setTagCount}
|
||||||
|
maxTags={maxTags}
|
||||||
|
onTagAdd={onTagAdd}
|
||||||
|
onTagRemove={onTagRemove}
|
||||||
|
allowDuplicates={allowDuplicates ?? false}
|
||||||
|
inlineTags={inlineTags}
|
||||||
|
usePortal={usePortal}
|
||||||
|
classStyleProps={{
|
||||||
|
command: styleClasses?.autoComplete?.command,
|
||||||
|
popoverTrigger:
|
||||||
|
styleClasses?.autoComplete?.popoverTrigger,
|
||||||
|
popoverContent:
|
||||||
|
styleClasses?.autoComplete?.popoverContent,
|
||||||
|
commandList:
|
||||||
|
styleClasses?.autoComplete?.commandList,
|
||||||
|
commandGroup:
|
||||||
|
styleClasses?.autoComplete?.commandGroup,
|
||||||
|
commandItem:
|
||||||
|
styleClasses?.autoComplete?.commandItem
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!usePopoverForTags ? (
|
||||||
|
!inlineTags ? (
|
||||||
|
// <CommandInput
|
||||||
|
// placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
|
||||||
|
// ref={inputRef}
|
||||||
|
// value={inputValue}
|
||||||
|
// disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
|
||||||
|
// onChangeCapture={handleInputChange}
|
||||||
|
// onKeyDown={handleKeyDown}
|
||||||
|
// onFocus={handleInputFocus}
|
||||||
|
// onBlur={handleInputBlur}
|
||||||
|
// className={cn(
|
||||||
|
// 'w-full',
|
||||||
|
// // className,
|
||||||
|
// styleClasses?.input,
|
||||||
|
// )}
|
||||||
|
// />
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
placeholder={
|
||||||
|
maxTags !== undefined &&
|
||||||
|
tags.length >= maxTags
|
||||||
|
? placeholderWhenFull
|
||||||
|
: placeholder
|
||||||
|
}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={handleInputFocus}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
{...inputProps}
|
||||||
|
className={cn(
|
||||||
|
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit",
|
||||||
|
// className,
|
||||||
|
styleClasses?.input
|
||||||
|
)}
|
||||||
|
autoComplete={
|
||||||
|
enableAutocomplete ? "on" : "off"
|
||||||
|
}
|
||||||
|
list={
|
||||||
|
enableAutocomplete
|
||||||
|
? "autocomplete-options"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
disabled ||
|
||||||
|
(maxTags !== undefined &&
|
||||||
|
tags.length >= maxTags)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
`flex flex-row flex-wrap items-center p-2 gap-2 h-fit w-full bg-background text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50`,
|
||||||
|
styleClasses?.inlineTagsContainer
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TagList
|
||||||
|
tags={truncatedTags}
|
||||||
|
customTagRenderer={
|
||||||
|
customTagRenderer
|
||||||
|
}
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
shape={shape}
|
||||||
|
borderStyle={borderStyle}
|
||||||
|
textCase={textCase}
|
||||||
|
interaction={interaction}
|
||||||
|
animation={animation}
|
||||||
|
textStyle={textStyle}
|
||||||
|
onTagClick={onTagClick}
|
||||||
|
draggable={draggable}
|
||||||
|
onSortEnd={onSortEnd}
|
||||||
|
onRemoveTag={removeTag}
|
||||||
|
direction={direction}
|
||||||
|
inlineTags={inlineTags}
|
||||||
|
activeTagIndex={activeTagIndex}
|
||||||
|
setActiveTagIndex={
|
||||||
|
setActiveTagIndex
|
||||||
|
}
|
||||||
|
classStyleProps={{
|
||||||
|
tagListClasses:
|
||||||
|
styleClasses?.tagList,
|
||||||
|
tagClasses: styleClasses?.tag
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
{/* <CommandInput
|
||||||
|
placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
|
||||||
|
ref={inputRef}
|
||||||
|
value={inputValue}
|
||||||
|
disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
|
||||||
|
onChangeCapture={handleInputChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={handleInputFocus}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
inlineTags={inlineTags}
|
||||||
|
className={cn(
|
||||||
|
'border-0 flex-1 w-fit h-5',
|
||||||
|
// className,
|
||||||
|
styleClasses?.input,
|
||||||
|
)}
|
||||||
|
/> */}
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
placeholder={
|
||||||
|
maxTags !== undefined &&
|
||||||
|
tags.length >= maxTags
|
||||||
|
? placeholderWhenFull
|
||||||
|
: placeholder
|
||||||
|
}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={handleInputFocus}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
{...inputProps}
|
||||||
|
className={cn(
|
||||||
|
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit",
|
||||||
|
// className,
|
||||||
|
styleClasses?.input
|
||||||
|
)}
|
||||||
|
autoComplete={
|
||||||
|
enableAutocomplete
|
||||||
|
? "on"
|
||||||
|
: "off"
|
||||||
|
}
|
||||||
|
list={
|
||||||
|
enableAutocomplete
|
||||||
|
? "autocomplete-options"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
disabled ||
|
||||||
|
(maxTags !== undefined &&
|
||||||
|
tags.length >= maxTags)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<TagPopover
|
||||||
|
tags={truncatedTags}
|
||||||
|
customTagRenderer={customTagRenderer}
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
shape={shape}
|
||||||
|
borderStyle={borderStyle}
|
||||||
|
textCase={textCase}
|
||||||
|
interaction={interaction}
|
||||||
|
animation={animation}
|
||||||
|
textStyle={textStyle}
|
||||||
|
onTagClick={onTagClick}
|
||||||
|
draggable={draggable}
|
||||||
|
onSortEnd={onSortEnd}
|
||||||
|
onRemoveTag={removeTag}
|
||||||
|
direction={direction}
|
||||||
|
activeTagIndex={activeTagIndex}
|
||||||
|
setActiveTagIndex={setActiveTagIndex}
|
||||||
|
classStyleProps={{
|
||||||
|
popoverClasses:
|
||||||
|
styleClasses?.tagPopover,
|
||||||
|
tagListClasses: styleClasses?.tagList,
|
||||||
|
tagClasses: styleClasses?.tag
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{/* <CommandInput
|
||||||
|
placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
|
||||||
|
ref={inputRef}
|
||||||
|
value={inputValue}
|
||||||
|
disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
|
||||||
|
onChangeCapture={handleInputChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={handleInputFocus}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
className={cn(
|
||||||
|
'w-full',
|
||||||
|
// className,
|
||||||
|
styleClasses?.input,
|
||||||
|
)}
|
||||||
|
/> */}
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
placeholder={
|
||||||
|
maxTags !== undefined &&
|
||||||
|
tags.length >= maxTags
|
||||||
|
? placeholderWhenFull
|
||||||
|
: placeholder
|
||||||
|
}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={handleInputFocus}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
{...inputProps}
|
||||||
|
className={cn(
|
||||||
|
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit",
|
||||||
|
// className,
|
||||||
|
styleClasses?.input
|
||||||
|
)}
|
||||||
|
autoComplete={
|
||||||
|
enableAutocomplete ? "on" : "off"
|
||||||
|
}
|
||||||
|
list={
|
||||||
|
enableAutocomplete
|
||||||
|
? "autocomplete-options"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
disabled ||
|
||||||
|
(maxTags !== undefined &&
|
||||||
|
tags.length >= maxTags)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TagPopover>
|
||||||
|
)}
|
||||||
|
</Autocomplete>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full">
|
||||||
|
{!usePopoverForTags ? (
|
||||||
|
!inlineTags ? (
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
placeholder={
|
||||||
|
maxTags !== undefined &&
|
||||||
|
tags.length >= maxTags
|
||||||
|
? placeholderWhenFull
|
||||||
|
: placeholder
|
||||||
|
}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={handleInputFocus}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
{...inputProps}
|
||||||
|
className={cn(
|
||||||
|
styleClasses?.input
|
||||||
|
// className
|
||||||
|
)}
|
||||||
|
autoComplete={
|
||||||
|
enableAutocomplete ? "on" : "off"
|
||||||
|
}
|
||||||
|
list={
|
||||||
|
enableAutocomplete
|
||||||
|
? "autocomplete-options"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
disabled ||
|
||||||
|
(maxTags !== undefined &&
|
||||||
|
tags.length >= maxTags)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
) : (
|
||||||
|
<TagPopover
|
||||||
|
tags={truncatedTags}
|
||||||
|
customTagRenderer={customTagRenderer}
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
shape={shape}
|
||||||
|
borderStyle={borderStyle}
|
||||||
|
textCase={textCase}
|
||||||
|
interaction={interaction}
|
||||||
|
animation={animation}
|
||||||
|
textStyle={textStyle}
|
||||||
|
onTagClick={onTagClick}
|
||||||
|
draggable={draggable}
|
||||||
|
onSortEnd={onSortEnd}
|
||||||
|
onRemoveTag={removeTag}
|
||||||
|
direction={direction}
|
||||||
|
activeTagIndex={activeTagIndex}
|
||||||
|
setActiveTagIndex={setActiveTagIndex}
|
||||||
|
classStyleProps={{
|
||||||
|
popoverClasses: styleClasses?.tagPopover,
|
||||||
|
tagListClasses: styleClasses?.tagList,
|
||||||
|
tagClasses: styleClasses?.tag
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
placeholder={
|
||||||
|
maxTags !== undefined &&
|
||||||
|
tags.length >= maxTags
|
||||||
|
? placeholderWhenFull
|
||||||
|
: placeholder
|
||||||
|
}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={handleInputFocus}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
{...inputProps}
|
||||||
|
autoComplete={
|
||||||
|
enableAutocomplete ? "on" : "off"
|
||||||
|
}
|
||||||
|
list={
|
||||||
|
enableAutocomplete
|
||||||
|
? "autocomplete-options"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
disabled ||
|
||||||
|
(maxTags !== undefined &&
|
||||||
|
tags.length >= maxTags)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"border-0 w-full",
|
||||||
|
styleClasses?.input
|
||||||
|
// className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TagPopover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCount && maxTags && (
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-muted-foreground text-sm mt-1 ml-auto">
|
||||||
|
{`${tagCount}`}/{`${maxTags}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{clearAll && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
className={cn("mt-2", styleClasses?.clearAllButton)}
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TagInput.displayName = "TagInput";
|
||||||
|
|
||||||
|
export function uuid() {
|
||||||
|
return crypto.getRandomValues(new Uint32Array(1))[0].toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { TagInput };
|
||||||
205
src/components/tags/tag-list.tsx
Normal file
205
src/components/tags/tag-list.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
|
||||||
|
import { Tag, TagProps } from "./tag";
|
||||||
|
import SortableList, { SortableItem } from "react-easy-sort";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
|
||||||
|
export type TagListProps = {
|
||||||
|
tags: TagType[];
|
||||||
|
customTagRenderer?: (tag: TagType, isActiveTag: boolean) => React.ReactNode;
|
||||||
|
direction?: TagProps["direction"];
|
||||||
|
onSortEnd: (oldIndex: number, newIndex: number) => void;
|
||||||
|
className?: string;
|
||||||
|
inlineTags?: boolean;
|
||||||
|
activeTagIndex?: number | null;
|
||||||
|
setActiveTagIndex?: (index: number | null) => void;
|
||||||
|
classStyleProps: {
|
||||||
|
tagListClasses: TagInputStyleClassesProps["tagList"];
|
||||||
|
tagClasses: TagInputStyleClassesProps["tag"];
|
||||||
|
};
|
||||||
|
disabled?: boolean;
|
||||||
|
} & Omit<TagProps, "tagObj">;
|
||||||
|
|
||||||
|
const DropTarget: React.FC = () => {
|
||||||
|
return <div className={cn("h-full rounded-md bg-secondary/50")} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TagList: React.FC<TagListProps> = ({
|
||||||
|
tags,
|
||||||
|
customTagRenderer,
|
||||||
|
direction,
|
||||||
|
draggable,
|
||||||
|
onSortEnd,
|
||||||
|
className,
|
||||||
|
inlineTags,
|
||||||
|
activeTagIndex,
|
||||||
|
setActiveTagIndex,
|
||||||
|
classStyleProps,
|
||||||
|
disabled,
|
||||||
|
...tagListProps
|
||||||
|
}) => {
|
||||||
|
const [draggedTagId, setDraggedTagId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleMouseDown = (id: string) => {
|
||||||
|
setDraggedTagId(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setDraggedTagId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!inlineTags ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-md w-full",
|
||||||
|
// className,
|
||||||
|
{
|
||||||
|
"flex flex-wrap gap-2": direction === "row",
|
||||||
|
"flex flex-col gap-2": direction === "column"
|
||||||
|
},
|
||||||
|
classStyleProps?.tagListClasses?.container
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{draggable ? (
|
||||||
|
<SortableList
|
||||||
|
onSortEnd={onSortEnd}
|
||||||
|
// className="flex flex-wrap gap-2 list"
|
||||||
|
className={`flex flex-wrap gap-2 list ${classStyleProps?.tagListClasses?.sortableList}`}
|
||||||
|
dropTarget={<DropTarget />}
|
||||||
|
>
|
||||||
|
{tags.map((tagObj, index) => (
|
||||||
|
<SortableItem key={tagObj.id}>
|
||||||
|
<div
|
||||||
|
onMouseDown={() =>
|
||||||
|
handleMouseDown(tagObj.id)
|
||||||
|
}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
className={cn(
|
||||||
|
{
|
||||||
|
"border border-solid border-primary rounded-md":
|
||||||
|
draggedTagId === tagObj.id
|
||||||
|
},
|
||||||
|
"transition-all duration-200 ease-in-out"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{customTagRenderer ? (
|
||||||
|
customTagRenderer(
|
||||||
|
tagObj,
|
||||||
|
index === activeTagIndex
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Tag
|
||||||
|
tagObj={tagObj}
|
||||||
|
isActiveTag={
|
||||||
|
index === activeTagIndex
|
||||||
|
}
|
||||||
|
direction={direction}
|
||||||
|
draggable={draggable}
|
||||||
|
tagClasses={
|
||||||
|
classStyleProps?.tagClasses
|
||||||
|
}
|
||||||
|
{...tagListProps}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SortableItem>
|
||||||
|
))}
|
||||||
|
</SortableList>
|
||||||
|
) : (
|
||||||
|
tags.map((tagObj, index) =>
|
||||||
|
customTagRenderer ? (
|
||||||
|
customTagRenderer(
|
||||||
|
tagObj,
|
||||||
|
index === activeTagIndex
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Tag
|
||||||
|
key={tagObj.id}
|
||||||
|
tagObj={tagObj}
|
||||||
|
isActiveTag={index === activeTagIndex}
|
||||||
|
direction={direction}
|
||||||
|
draggable={draggable}
|
||||||
|
tagClasses={classStyleProps?.tagClasses}
|
||||||
|
{...tagListProps}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{draggable ? (
|
||||||
|
<SortableList
|
||||||
|
onSortEnd={onSortEnd}
|
||||||
|
className="flex flex-wrap gap-2 list"
|
||||||
|
dropTarget={<DropTarget />}
|
||||||
|
>
|
||||||
|
{tags.map((tagObj, index) => (
|
||||||
|
<SortableItem key={tagObj.id}>
|
||||||
|
<div
|
||||||
|
onMouseDown={() =>
|
||||||
|
handleMouseDown(tagObj.id)
|
||||||
|
}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
className={cn(
|
||||||
|
{
|
||||||
|
"border border-solid border-primary rounded-md":
|
||||||
|
draggedTagId === tagObj.id
|
||||||
|
},
|
||||||
|
"transition-all duration-200 ease-in-out"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{customTagRenderer ? (
|
||||||
|
customTagRenderer(
|
||||||
|
tagObj,
|
||||||
|
index === activeTagIndex
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Tag
|
||||||
|
tagObj={tagObj}
|
||||||
|
isActiveTag={
|
||||||
|
index === activeTagIndex
|
||||||
|
}
|
||||||
|
direction={direction}
|
||||||
|
draggable={draggable}
|
||||||
|
tagClasses={
|
||||||
|
classStyleProps?.tagClasses
|
||||||
|
}
|
||||||
|
{...tagListProps}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SortableItem>
|
||||||
|
))}
|
||||||
|
</SortableList>
|
||||||
|
) : (
|
||||||
|
tags.map((tagObj, index) =>
|
||||||
|
customTagRenderer ? (
|
||||||
|
customTagRenderer(
|
||||||
|
tagObj,
|
||||||
|
index === activeTagIndex
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Tag
|
||||||
|
key={tagObj.id}
|
||||||
|
tagObj={tagObj}
|
||||||
|
isActiveTag={index === activeTagIndex}
|
||||||
|
direction={direction}
|
||||||
|
draggable={draggable}
|
||||||
|
tagClasses={classStyleProps?.tagClasses}
|
||||||
|
{...tagListProps}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
207
src/components/tags/tag-popover.tsx
Normal file
207
src/components/tags/tag-popover.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||||
|
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
|
||||||
|
import { TagList, TagListProps } from "./tag-list";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
|
||||||
|
type TagPopoverProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
tags: TagType[];
|
||||||
|
customTagRenderer?: (tag: TagType, isActiveTag: boolean) => React.ReactNode;
|
||||||
|
activeTagIndex?: number | null;
|
||||||
|
setActiveTagIndex?: (index: number | null) => void;
|
||||||
|
classStyleProps: {
|
||||||
|
popoverClasses: TagInputStyleClassesProps["tagPopover"];
|
||||||
|
tagListClasses: TagInputStyleClassesProps["tagList"];
|
||||||
|
tagClasses: TagInputStyleClassesProps["tag"];
|
||||||
|
};
|
||||||
|
disabled?: boolean;
|
||||||
|
usePortal?: boolean;
|
||||||
|
} & TagListProps;
|
||||||
|
|
||||||
|
export const TagPopover: React.FC<TagPopoverProps> = ({
|
||||||
|
children,
|
||||||
|
tags,
|
||||||
|
customTagRenderer,
|
||||||
|
activeTagIndex,
|
||||||
|
setActiveTagIndex,
|
||||||
|
classStyleProps,
|
||||||
|
disabled,
|
||||||
|
usePortal,
|
||||||
|
...tagProps
|
||||||
|
}) => {
|
||||||
|
const triggerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const popoverContentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const [popoverWidth, setPopoverWidth] = useState<number>(0);
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
const [inputFocused, setInputFocused] = useState(false);
|
||||||
|
const [sideOffset, setSideOffset] = useState<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
if (triggerContainerRef.current && triggerRef.current) {
|
||||||
|
setPopoverWidth(triggerContainerRef.current.offsetWidth);
|
||||||
|
setSideOffset(
|
||||||
|
triggerContainerRef.current.offsetWidth -
|
||||||
|
triggerRef?.current?.offsetWidth
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleResize(); // Call on mount and layout changes
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize); // Adjust on window resize
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, [triggerContainerRef, triggerRef]);
|
||||||
|
|
||||||
|
// Close the popover when clicking outside of it
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOutsideClick = (
|
||||||
|
event: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
isPopoverOpen &&
|
||||||
|
triggerContainerRef.current &&
|
||||||
|
popoverContentRef.current &&
|
||||||
|
!triggerContainerRef.current.contains(event.target as Node) &&
|
||||||
|
!popoverContentRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleOutsideClick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleOutsideClick);
|
||||||
|
};
|
||||||
|
}, [isPopoverOpen]);
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback(
|
||||||
|
(open: boolean) => {
|
||||||
|
if (open && triggerContainerRef.current) {
|
||||||
|
setPopoverWidth(triggerContainerRef.current.offsetWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
setIsPopoverOpen(open);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[inputFocused]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInputFocus = (
|
||||||
|
event:
|
||||||
|
| React.FocusEvent<HTMLInputElement>
|
||||||
|
| React.FocusEvent<HTMLTextAreaElement>
|
||||||
|
) => {
|
||||||
|
// Only set inputFocused to true if the popover is already open.
|
||||||
|
// This will prevent the popover from opening due to an input focus if it was initially closed.
|
||||||
|
if (isPopoverOpen) {
|
||||||
|
setInputFocused(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userOnFocus = (children as React.ReactElement<any>).props.onFocus;
|
||||||
|
if (userOnFocus) userOnFocus(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputBlur = (
|
||||||
|
event:
|
||||||
|
| React.FocusEvent<HTMLInputElement>
|
||||||
|
| React.FocusEvent<HTMLTextAreaElement>
|
||||||
|
) => {
|
||||||
|
setInputFocused(false);
|
||||||
|
|
||||||
|
// Allow the popover to close if no other interactions keep it open
|
||||||
|
if (!isPopoverOpen) {
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userOnBlur = (children as React.ReactElement<any>).props.onBlur;
|
||||||
|
if (userOnBlur) userOnBlur(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
modal={usePortal}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative flex items-center rounded-md border border-input bg-transparent pr-3"
|
||||||
|
ref={triggerContainerRef}
|
||||||
|
>
|
||||||
|
{React.cloneElement(children as React.ReactElement<any>, {
|
||||||
|
onFocus: handleInputFocus,
|
||||||
|
onBlur: handleInputBlur,
|
||||||
|
ref: inputRef
|
||||||
|
})}
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
ref={triggerRef}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
role="combobox"
|
||||||
|
className={cn(
|
||||||
|
`hover:bg-transparent`,
|
||||||
|
classStyleProps?.popoverClasses?.popoverTrigger
|
||||||
|
)}
|
||||||
|
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={`lucide lucide-chevron-down h-4 w-4 shrink-0 opacity-50 ${isPopoverOpen ? "rotate-180" : "rotate-0"}`}
|
||||||
|
>
|
||||||
|
<path d="m6 9 6 6 6-6"></path>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
</div>
|
||||||
|
<PopoverContent
|
||||||
|
ref={popoverContentRef}
|
||||||
|
className={cn(
|
||||||
|
`w-full space-y-3`,
|
||||||
|
classStyleProps?.popoverClasses?.popoverContent
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
marginLeft: `-${sideOffset}px`,
|
||||||
|
width: `${popoverWidth}px`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h4 className="text-sm font-medium leading-none">
|
||||||
|
Entered Tags
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-muted-foregrounsd text-left">
|
||||||
|
These are the tags you've entered.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<TagList
|
||||||
|
tags={tags}
|
||||||
|
customTagRenderer={customTagRenderer}
|
||||||
|
activeTagIndex={activeTagIndex}
|
||||||
|
setActiveTagIndex={setActiveTagIndex}
|
||||||
|
classStyleProps={{
|
||||||
|
tagListClasses: classStyleProps?.tagListClasses,
|
||||||
|
tagClasses: classStyleProps?.tagClasses
|
||||||
|
}}
|
||||||
|
{...tagProps}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
169
src/components/tags/tag.tsx
Normal file
169
src/components/tags/tag.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import {
|
||||||
|
TagInputProps,
|
||||||
|
TagInputStyleClassesProps,
|
||||||
|
type Tag as TagType
|
||||||
|
} from "./tag-input";
|
||||||
|
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
|
||||||
|
export const tagVariants = cva(
|
||||||
|
"transition-all border inline-flex items-center text-sm pl-2 rounded-md",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
primary:
|
||||||
|
"bg-primary border-primary text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive border-destructive text-destructive-foreground hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: "text-xs h-7",
|
||||||
|
md: "text-sm h-8",
|
||||||
|
lg: "text-base h-9",
|
||||||
|
xl: "text-lg h-10"
|
||||||
|
},
|
||||||
|
shape: {
|
||||||
|
default: "rounded-sm",
|
||||||
|
rounded: "rounded-lg",
|
||||||
|
square: "rounded-none",
|
||||||
|
pill: "rounded-full"
|
||||||
|
},
|
||||||
|
borderStyle: {
|
||||||
|
default: "border-solid",
|
||||||
|
none: "border-none",
|
||||||
|
dashed: "border-dashed",
|
||||||
|
dotted: "border-dotted",
|
||||||
|
double: "border-double"
|
||||||
|
},
|
||||||
|
textCase: {
|
||||||
|
uppercase: "uppercase",
|
||||||
|
lowercase: "lowercase",
|
||||||
|
capitalize: "capitalize"
|
||||||
|
},
|
||||||
|
interaction: {
|
||||||
|
clickable: "cursor-pointer hover:shadow-md",
|
||||||
|
nonClickable: "cursor-default"
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
none: "",
|
||||||
|
fadeIn: "animate-fadeIn",
|
||||||
|
slideIn: "animate-slideIn",
|
||||||
|
bounce: "animate-bounce"
|
||||||
|
},
|
||||||
|
textStyle: {
|
||||||
|
normal: "font-normal",
|
||||||
|
bold: "font-bold",
|
||||||
|
italic: "italic",
|
||||||
|
underline: "underline",
|
||||||
|
lineThrough: "line-through"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "md",
|
||||||
|
shape: "default",
|
||||||
|
borderStyle: "default",
|
||||||
|
interaction: "nonClickable",
|
||||||
|
animation: "fadeIn",
|
||||||
|
textStyle: "normal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TagProps = {
|
||||||
|
tagObj: TagType;
|
||||||
|
variant: TagInputProps["variant"];
|
||||||
|
size: TagInputProps["size"];
|
||||||
|
shape: TagInputProps["shape"];
|
||||||
|
borderStyle: TagInputProps["borderStyle"];
|
||||||
|
textCase: TagInputProps["textCase"];
|
||||||
|
interaction: TagInputProps["interaction"];
|
||||||
|
animation: TagInputProps["animation"];
|
||||||
|
textStyle: TagInputProps["textStyle"];
|
||||||
|
onRemoveTag: (id: string) => void;
|
||||||
|
isActiveTag?: boolean;
|
||||||
|
tagClasses?: TagInputStyleClassesProps["tag"];
|
||||||
|
disabled?: boolean;
|
||||||
|
} & Pick<TagInputProps, "direction" | "onTagClick" | "draggable">;
|
||||||
|
|
||||||
|
export const Tag: React.FC<TagProps> = ({
|
||||||
|
tagObj,
|
||||||
|
direction,
|
||||||
|
draggable,
|
||||||
|
onTagClick,
|
||||||
|
onRemoveTag,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
shape,
|
||||||
|
borderStyle,
|
||||||
|
textCase,
|
||||||
|
interaction,
|
||||||
|
animation,
|
||||||
|
textStyle,
|
||||||
|
isActiveTag,
|
||||||
|
tagClasses,
|
||||||
|
disabled
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={tagObj.id}
|
||||||
|
draggable={draggable}
|
||||||
|
className={cn(
|
||||||
|
tagVariants({
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
shape,
|
||||||
|
borderStyle,
|
||||||
|
textCase,
|
||||||
|
interaction,
|
||||||
|
animation,
|
||||||
|
textStyle
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
"justify-between w-full": direction === "column",
|
||||||
|
"cursor-pointer": draggable,
|
||||||
|
"ring-ring ring-offset-2 ring-2 ring-offset-background":
|
||||||
|
isActiveTag
|
||||||
|
},
|
||||||
|
tagClasses?.body
|
||||||
|
)}
|
||||||
|
onClick={() => onTagClick?.(tagObj)}
|
||||||
|
>
|
||||||
|
{tagObj.text}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation(); // Prevent event from bubbling up to the tag span
|
||||||
|
onRemoveTag(tagObj.id);
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
`py-1 px-3 h-full hover:bg-transparent`,
|
||||||
|
tagClasses?.closeButton
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="lucide lucide-x"
|
||||||
|
>
|
||||||
|
<path d="M18 6 6 18"></path>
|
||||||
|
<path d="m6 6 12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -15,9 +15,9 @@ const buttonVariants = cva(
|
|||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
outline:
|
outline:
|
||||||
"border border-input bg-card hover:bg-accent hover:text-accent-foreground",
|
"border-2 border-input bg-card hover:bg-accent hover:text-accent-foreground",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary border border-input text-secondary-foreground hover:bg-secondary/80",
|
"bg-secondary border border-input border-2 text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
text: "",
|
text: "",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
<input
|
<input
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-md border border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-base md:file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-9 w-full rounded-md border-2 border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-base md:file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -39,7 +39,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-md border border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-base md:file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-9 w-full rounded-md border-2 border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-base md:file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const RadioGroupItem = React.forwardRef<
|
|||||||
<RadioGroupPrimitive.Item
|
<RadioGroupPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"aspect-square h-4 w-4 rounded-full border-2 border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full items-center justify-between border border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
"flex h-9 w-full items-center justify-between border-2 border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
className,
|
className,
|
||||||
"rounded-md"
|
"rounded-md"
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user