Merge pull request #244 from fosrl/dev

multiple domains, crowdsec, and more
This commit is contained in:
Milo Schwartz 2025-02-27 10:52:06 -05:00 committed by GitHub
commit 06e4fbac68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 4594 additions and 723 deletions

View file

@ -1,9 +1,13 @@
app:
dashboard_url: "http://localhost:3002"
base_domain: "localhost"
log_level: "info"
save_logs: false
domains:
domain1:
base_domain: "example.com"
cert_resolver: "letsencrypt"
server:
external_port: 3000
internal_port: 3001
@ -14,7 +18,6 @@ server:
resource_session_request_param: "p_session_request"
traefik:
cert_resolver: "letsencrypt"
http_entrypoint: "web"
https_entrypoint: "websecure"

View file

@ -1,5 +1,4 @@
version: "3.7"
name: pangolin
services:
pangolin:
image: fosrl/pangolin:latest
@ -32,7 +31,6 @@ services:
- SYS_MODULE
ports:
- 51820:51820/udp
- 8080:8080 # 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
@ -47,8 +45,8 @@ services:
command:
- --configFile=/etc/traefik/traefik_config.yml
volumes:
- ./traefik:/etc/traefik:ro # Volume to store the Traefik configuration
- ./letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
- ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
networks:
default:

View file

@ -1,13 +1,24 @@
all: build
all: update-versions go-build-release put-back
build:
CGO_ENABLED=0 go build -o bin/installer
release:
go-build-release:
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
clean:
rm -f bin/installer
rm -f bin/installer_linux_amd64
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
View 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
}

View file

@ -1,9 +1,13 @@
app:
dashboard_url: "https://{{.DashboardDomain}}"
base_domain: "{{.BaseDomain}}"
log_level: "info"
save_logs: false
domains:
domain1:
base_domain: "{{.BaseDomain}}"
cert_resolver: "letsencrypt"
server:
external_port: 3000
internal_port: 3001

View 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

View 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

View 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

View 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

View 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

View file

@ -1,3 +1,4 @@
name: pangolin
services:
pangolin:
image: fosrl/pangolin:{{.PangolinVersion}}
@ -10,7 +11,6 @@ services:
interval: "3s"
timeout: "3s"
retries: 5
{{if .InstallGerbil}}
gerbil:
image: fosrl/gerbil:{{.GerbilVersion}}
@ -34,15 +34,13 @@ services:
- 443:443 # Port for traefik because of the network_mode
- 80:80 # Port for traefik because of the network_mode
{{end}}
traefik:
image: traefik:v3.3.3
container_name: traefik
restart: unless-stopped
{{if .InstallGerbil}}
network_mode: service:gerbil # Ports appear on the gerbil service
{{end}}
{{if not .InstallGerbil}}
{{end}}{{if not .InstallGerbil}}
ports:
- 443:443
- 80:80
@ -55,6 +53,7 @@ services:
volumes:
- ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
networks:
default:

121
install/crowdsec.go Normal file
View 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
}

View file

@ -5,4 +5,5 @@ go 1.23.0
require (
golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.28.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View file

@ -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/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
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
View file

@ -0,0 +1,12 @@
example.com
pangolin.example.com
admin@example.com
yes
admin@example.com
Password123!
Password123!
yes
no
no
no
yes

View file

@ -4,13 +4,16 @@ import (
"bufio"
"embed"
"fmt"
"io"
"io/fs"
"os"
"time"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
"bytes"
"text/template"
"unicode"
@ -24,7 +27,7 @@ func loadVersions(config *Config) {
config.BadgerVersion = "replaceme"
}
//go:embed fs/*
//go:embed config/*
var configFiles embed.FS
type Config struct {
@ -45,6 +48,8 @@ type Config struct {
EmailSMTPPass string
EmailNoReply string
InstallGerbil bool
TraefikBouncerKey string
DoCrowdsecInstall bool
}
func main() {
@ -56,9 +61,12 @@ func main() {
os.Exit(1)
}
var config Config
config.DoCrowdsecInstall = false
// check if there is already a config file
if _, err := os.Stat("config/config.yml"); err != nil {
config := collectUserInput(reader)
config = collectUserInput(reader)
loadVersions(&config)
@ -67,18 +75,53 @@ func main() {
os.Exit(1)
}
moveFile("config/docker-compose.yml", "docker-compose.yml")
if !isDockerInstalled() && runtime.GOOS == "linux" {
if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
installDocker()
}
}
fmt.Println("\n=== Starting installation ===")
if isDockerInstalled() {
if readBool(reader, "Would you like to install and start the containers?", true) {
pullAndStartContainers()
}
}
} 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 readBool(reader, "Would you like to install and start the containers?", true) {
pullAndStartContainers()
if !checkIsCrowdsecInstalledInCompose() {
fmt.Println("\n=== Crowdsec Install ===")
// 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
}
func readPassword(prompt string) string {
fmt.Print(prompt + ": ")
// Read password without echo
password, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println() // Add a newline since ReadPassword doesn't add one
if err != nil {
return ""
}
input := strings.TrimSpace(string(password))
if input == "" {
return readPassword(prompt)
}
return input
func readPassword(prompt string, reader *bufio.Reader) string {
if term.IsTerminal(int(syscall.Stdin)) {
fmt.Print(prompt + ": ")
// Read password without echo if we're in a terminal
password, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println() // Add a newline since ReadPassword doesn't add one
if err != nil {
return ""
}
input := strings.TrimSpace(string(password))
if input == "" {
return readPassword(prompt, reader)
}
return input
} else {
// Fallback to reading from stdin if not in a terminal
return readString(reader, prompt, "")
}
}
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 ===")
config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain)
for {
pass1 := readPassword("Create admin user password")
pass2 := readPassword("Confirm admin user password")
pass1 := readPassword("Create admin user password", reader)
pass2 := readPassword("Confirm admin user password", reader)
if pass1 != pass2 {
fmt.Println("Passwords do not match")
@ -261,31 +306,33 @@ func createConfigFiles(config Config) error {
os.MkdirAll("config/logs", 0755)
// 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 {
return err
}
// Skip the root fs directory itself
if path == "fs" {
if path == "config" {
return nil
}
// Get the relative path by removing the "fs/" prefix
relPath := strings.TrimPrefix(path, "fs/")
if !config.DoCrowdsecInstall && strings.Contains(path, "crowdsec") {
return nil
}
if config.DoCrowdsecInstall && !strings.Contains(path, "crowdsec") {
return nil
}
// skip .DS_Store
if strings.Contains(relPath, ".DS_Store") {
if strings.Contains(path, ".DS_Store") {
return nil
}
// Create the full output path under "config/"
outPath := filepath.Join("config", relPath)
if d.IsDir() {
// Create directory
if err := os.MkdirAll(outPath, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %v", outPath, err)
if err := os.MkdirAll(path, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %v", path, err)
}
return nil
}
@ -303,14 +350,14 @@ func createConfigFiles(config Config) error {
}
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
return fmt.Errorf("failed to create parent directory for %s: %v", outPath, err)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("failed to create parent directory for %s: %v", path, err)
}
// Create output file
outFile, err := os.Create(outPath)
outFile, err := os.Create(path)
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()
@ -326,30 +373,10 @@ func createConfigFiles(config Config) error {
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
}
func installDocker() error {
// Detect Linux distribution
cmd := exec.Command("cat", "/etc/os-release")
@ -490,3 +517,166 @@ func pullAndStartContainers() error {
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()))
}

View file

@ -50,7 +50,6 @@
"cookie-parser": "1.4.7",
"cors": "2.8.5",
"drizzle-orm": "0.38.3",
"emblor": "1.4.7",
"eslint": "9.17.0",
"eslint-config-next": "15.1.3",
"express": "4.21.2",
@ -71,6 +70,7 @@
"qrcode.react": "4.2.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-easy-sort": "^1.6.0",
"react-hook-form": "7.54.2",
"rebuild": "0.1.2",
"semver": "7.6.3",

View file

@ -62,6 +62,7 @@ export enum ActionsEnum {
deleteResourceRule = "deleteResourceRule",
listResourceRules = "listResourceRules",
updateResourceRule = "updateResourceRule",
listOrgDomains = "listOrgDomains",
}
export async function checkUserActionPermission(

View file

@ -170,9 +170,9 @@ export function serializeResourceSessionCookie(
isHttp: boolean = false
): string {
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 {
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
): string {
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 {
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${"." + domain}`;
return `${cookieName}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Domain=${"." + domain}`;
}
}

View file

@ -1,10 +1,26 @@
import { InferSelectModel } from "drizzle-orm";
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", {
orgId: text("orgId").primaryKey(),
name: text("name").notNull(),
domain: text("domain").notNull()
name: text("name").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", {
@ -43,6 +59,9 @@ export const resources = sqliteTable("resources", {
name: text("name").notNull(),
subdomain: text("subdomain"),
fullDomain: text("fullDomain"),
domainId: text("domainId").references(() => domains.domainId, {
onDelete: "set null"
}),
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
blockAccess: integer("blockAccess", { mode: "boolean" })
.notNull()
@ -55,7 +74,9 @@ export const resources = sqliteTable("resources", {
.notNull()
.default(false),
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", {
@ -417,3 +438,4 @@ export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
export type ResourceRule = InferSelectModel<typeof resourceRules>;
export type Domain = InferSelectModel<typeof domains>;

View file

@ -34,15 +34,49 @@ const configSchema = z.object({
.transform(getEnvOrYaml("APP_DASHBOARDURL"))
.pipe(z.string().url())
.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"]),
save_logs: z.boolean(),
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({
external_port: portSchema
.optional()
@ -88,8 +122,6 @@ const configSchema = z.object({
traefik: z.object({
http_entrypoint: z.string(),
https_entrypoint: z.string().optional(),
cert_resolver: z.string().optional(),
prefer_wildcard_cert: z.boolean().optional(),
additional_middlewares: z.array(z.string()).optional()
}),
gerbil: z.object({
@ -168,8 +200,6 @@ export class Config {
}
}
public loadEnvironment() {}
public loadConfig() {
const loadConfig = (configPath: string) => {
try {
@ -276,6 +306,17 @@ export class Config {
: "false";
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;
}
@ -283,16 +324,16 @@ export class Config {
return this.rawConfig;
}
public getBaseDomain(): string {
return this.rawConfig.app.base_domain;
}
public getNoReplyEmail(): string | undefined {
return (
this.rawConfig.email?.no_reply || this.rawConfig.email?.smtp_user
);
}
public getDomain(domainId: string) {
return this.rawConfig.domains[domainId];
}
private createTraefikConfig() {
try {
// check if traefik_config.yml and dynamic_config.yml exists in APP_PATH/traefik

View file

@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process
export const APP_VERSION = "1.0.0-beta.14";
export const APP_VERSION = "1.0.0-beta.15";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);

View file

@ -90,7 +90,15 @@ export async function verifyResourceSession(
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:
| {
resource: Resource | null;
@ -111,11 +119,11 @@ export async function verifyResourceSession(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.where(eq(resources.fullDomain, host))
.where(eq(resources.fullDomain, cleanHost))
.limit(1);
if (!result) {
logger.debug("Resource not found", host);
logger.debug("Resource not found", cleanHost);
return notAllowed(res);
}
@ -131,7 +139,7 @@ export async function verifyResourceSession(
const { resource, pincode, password } = resourceData;
if (!resource) {
logger.debug("Resource not found", host);
logger.debug("Resource not found", cleanHost);
return notAllowed(res);
}
@ -142,16 +150,6 @@ export async function verifyResourceSession(
return notAllowed(res);
}
if (
!resource.sso &&
!pincode &&
!password &&
!resource.emailWhitelistEnabled
) {
logger.debug("Resource allowed because no auth");
return allowed(res);
}
// check the rules
if (resource.applyRules) {
const action = await checkRules(
@ -171,6 +169,16 @@ export async function verifyResourceSession(
// 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(
resource.resourceId
)}?redirect=${encodeURIComponent(originalRequestURL)}`;

View file

@ -0,0 +1 @@
export * from "./listDomains";

View 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")
);
}
}

View file

@ -3,6 +3,7 @@ import config from "@server/lib/config";
import * as site from "./site";
import * as org from "./org";
import * as resource from "./resource";
import * as domain from "./domain";
import * as target from "./target";
import * as user from "./user";
import * as auth from "./auth";
@ -133,6 +134,13 @@ authenticated.get(
resource.listResources
);
authenticated.get(
"/org/:orgId/domains",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listOrgDomains),
domain.listDomains
);
authenticated.post(
"/org/:orgId/create-invite",
verifyOrgAccess,

View file

@ -2,7 +2,15 @@ import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
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 HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@ -16,7 +24,6 @@ const createOrgSchema = z
.object({
orgId: z.string(),
name: z.string().min(1).max(255)
// domain: z.string().min(1).max(255).optional(),
})
.strict();
@ -82,14 +89,16 @@ export async function createOrg(
let org: Org | null = null;
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
.insert(orgs)
.values({
orgId,
name,
domain
name
})
.returning();
@ -109,6 +118,13 @@ export async function createOrg(
return;
}
await trx.insert(orgDomains).values(
allDomains.map((domain) => ({
orgId: newOrg[0].orgId,
domainId: domain.domainId
}))
);
await trx.insert(userOrgs).values({
userId: req.user!.userId,
orgId: newOrg[0].orgId,

View file

@ -1,8 +1,9 @@
import { SqliteError } from "better-sqlite3";
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import {
domains,
orgDomains,
orgs,
Resource,
resources,
@ -27,69 +28,29 @@ const createResourceParamsSchema = z
})
.strict();
const createResourceSchema = z
const createHttpResourceSchema = z
.object({
subdomain: z.string().optional(),
name: z.string().min(1).max(255),
subdomain: z
.string()
.optional()
.transform((val) => val?.toLowerCase()),
isBaseDomain: z.boolean().optional(),
siteId: z.number(),
http: z.boolean(),
protocol: z.string(),
proxyPort: z.number().optional(),
isBaseDomain: z.boolean().optional()
domainId: z.string()
})
.strict()
.refine(
(data) => {
if (!data.http) {
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) {
if (data.subdomain) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
},
{
message: "Invalid subdomain",
path: ["subdomain"]
}
{ message: "Invalid 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(
(data) => {
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 async function createResource(
@ -112,18 +96,6 @@ export async function createResource(
next: NextFunction
): Promise<any> {
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
const parsedParams = createResourceParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
@ -159,99 +131,25 @@ export async function createResource(
);
}
let fullDomain = "";
if (isBaseDomain) {
fullDomain = org[0].domain;
} else {
fullDomain = `${subdomain}.${org[0].domain}`;
if (typeof req.body.http !== "boolean") {
return next(
createHttpError(HttpCode.BAD_REQUEST, "http field is required")
);
}
// if http is false check to see if there is already a resource with the same port and protocol
if (!http) {
const existingResource = await db
.select()
.from(resources)
.where(
and(
eq(resources.protocol, protocol),
eq(resources.proxyPort, proxyPort!)
)
);
const { http } = req.body;
if (existingResource.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource with that protocol and port already exists"
)
);
}
if (http) {
return await createHttpResource(
{ req, res, next },
{ siteId, orgId }
);
} else {
// 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"
)
);
}
return await createRawResource(
{ req, res, next },
{ siteId, orgId }
);
}
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) {
logger.error(error);
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
});
}

View file

@ -1,8 +1,15 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { orgs, resources, sites } from "@server/db/schema";
import { eq, or, and } from "drizzle-orm";
import {
domains,
Org,
orgDomains,
orgs,
Resource,
resources
} from "@server/db/schema";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@ -20,17 +27,53 @@ const updateResourceParamsSchema = z
})
.strict();
const updateResourceBodySchema = z
const updateHttpResourceBodySchema = z
.object({
name: z.string().min(1).max(255).optional(),
subdomain: subdomainSchema.optional(),
subdomain: subdomainSchema
.optional()
.transform((val) => val?.toLowerCase()),
ssl: z.boolean().optional(),
sso: z.boolean().optional(),
blockAccess: z.boolean().optional(),
proxyPort: z.number().int().min(1).max(65535).optional(),
emailWhitelistEnabled: z.boolean().optional(),
isBaseDomain: 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()
.refine((data) => Object.keys(data).length > 0, {
@ -46,30 +89,6 @@ const updateResourceBodySchema = z
return true;
},
{ 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(
@ -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 updateData = parsedBody.data;
const [result] = await db
.select()
@ -119,117 +127,33 @@ export async function updateResource(
);
}
if (updateData.subdomain) {
if (!resource.http) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Cannot update subdomain for non-http resource"
)
);
}
const valid = subdomainSchema.safeParse(
updateData.subdomain
).success;
if (!valid) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid subdomain provided"
)
);
}
}
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 !== 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"
)
if (resource.http) {
// HANDLE UPDATING HTTP RESOURCES
return await updateHttpResource(
{
req,
res,
next
},
{
resource,
org
}
);
} else {
// HANDLE UPDATING RAW TCP/UDP RESOURCES
return await updateRawResource(
{
req,
res,
next
},
{
resource,
org
}
);
}
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) {
logger.error(error);
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
});
}

View file

@ -26,6 +26,7 @@ export async function traefikConfigProvider(
proxyPort: resources.proxyPort,
protocol: resources.protocol,
isBaseDomain: resources.isBaseDomain,
domainId: resources.domainId,
// Site fields
site: {
siteId: sites.siteId,
@ -34,8 +35,7 @@ export async function traefikConfigProvider(
},
// Org fields
org: {
orgId: orgs.orgId,
domain: orgs.domain
orgId: orgs.orgId
},
// Targets as a subquery
targets: sql<string>`json_group_array(json_object(
@ -105,15 +105,22 @@ export async function traefikConfigProvider(
const site = resource.site;
const org = resource.org;
if (!org.domain) {
continue;
}
const routerName = `${resource.resourceId}-router`;
const serviceName = `${resource.resourceId}-service`;
const fullDomain = `${resource.fullDomain}`;
if (resource.http) {
if (!resource.domainId) {
continue;
}
if (!resource.fullDomain) {
logger.error(
`Resource ${resource.resourceId} has no fullDomain`
);
continue;
}
// HTTP configuration remains the same
if (!resource.subdomain && !resource.isBaseDomain) {
continue;
@ -136,9 +143,18 @@ export async function traefikConfigProvider(
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 = {
certResolver: config.getRawConfig().traefik.cert_resolver,
...(config.getRawConfig().traefik.prefer_wildcard_cert
certResolver: configDomain.cert_resolver,
...(configDomain.prefer_wildcard_cert
? {
domains: [
{

View 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);
}
}

View file

@ -1,34 +1,103 @@
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 { eq, ne } from "drizzle-orm";
import logger from "@server/logger";
export async function copyInConfig() {
const domain = config.getBaseDomain();
const endpoint = config.getRawConfig().gerbil.base_endpoint;
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) => {
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 = "";
if (resource.isBaseDomain) {
fullDomain = domain;
fullDomain = domain.baseDomain;
} else {
fullDomain = `${resource.subdomain}.${domain}`;
fullDomain = `${resource.subdomain}.${domain.baseDomain}`;
}
await trx
.update(resources)
.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));
}

View file

@ -2,12 +2,14 @@ import { ensureActions } from "./ensureActions";
import { copyInConfig } from "./copyInConfig";
import { setupServerAdmin } from "./setupServerAdmin";
import logger from "@server/logger";
import { clearStaleData } from "./clearStaleData";
export async function runSetupFunctions() {
try {
await copyInConfig(); // copy in the config to the db as needed
await setupServerAdmin();
await ensureActions(); // make sure all of the actions are in the db and the roles
await clearStaleData();
} catch (error) {
logger.error("Error running setup functions:", error);
process.exit(1);

View file

@ -15,6 +15,7 @@ import m6 from "./scripts/1.0.0-beta9";
import m7 from "./scripts/1.0.0-beta10";
import m8 from "./scripts/1.0.0-beta12";
import m13 from "./scripts/1.0.0-beta13";
import m15 from "./scripts/1.0.0-beta15";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// 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.10", run: m7 },
{ 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
] as const;

View 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`);
}

View file

@ -136,7 +136,6 @@ export default function CreateRoleForm({
<FormLabel>Role Name</FormLabel>
<FormControl>
<Input
placeholder="Enter name for the role"
{...field}
/>
</FormControl>
@ -152,7 +151,6 @@ export default function CreateRoleForm({
<FormLabel>Description</FormLabel>
<FormControl>
<Input
placeholder="Describe the role"
{...field}
/>
</FormControl>

View file

@ -195,7 +195,6 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Enter an email"
{...field}
/>
</FormControl>

View file

@ -210,11 +210,11 @@ export default function GeneralPage() {
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This is the display name of the
org
organization.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@ -238,7 +238,6 @@ export default function GeneralPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
<AlertTriangle className="h-5 w-5" />
Danger Zone
</SettingsSectionTitle>
<SettingsSectionDescription>

View file

@ -65,10 +65,12 @@ import { SquareArrowOutUpRight } from "lucide-react";
import CopyTextBox from "@app/components/CopyTextBox";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Label } from "@app/components/ui/label";
import { ListDomainsResponse } from "@server/routers/domain";
const createResourceFormSchema = z
.object({
subdomain: z.string().optional(),
domainId: z.string().min(1).optional(),
name: z.string().min(1).max(255),
siteId: z.number(),
http: z.boolean(),
@ -117,6 +119,7 @@ export default function CreateResourceForm({
open,
setOpen
}: CreateResourceFormProps) {
const [formKey, setFormKey] = useState(0);
const api = createApiClient(useEnvContext());
const [loading, setLoading] = useState(false);
@ -129,7 +132,9 @@ export default function CreateResourceForm({
const { env } = useEnvContext();
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 [resourceId, setResourceId] = useState<number | null>(null);
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
@ -140,6 +145,7 @@ export default function CreateResourceForm({
resolver: zodResolver(createResourceFormSchema),
defaultValues: {
subdomain: "",
domainId: "",
name: "",
http: true,
protocol: "tcp"
@ -161,17 +167,56 @@ export default function CreateResourceForm({
reset();
const fetchSites = async () => {
const res = await api.get<AxiosResponse<ListSitesResponse>>(
`/org/${orgId}/sites/`
);
setSites(res.data.data.sites);
const res = await api
.get<AxiosResponse<ListSitesResponse>>(`/org/${orgId}/sites/`)
.catch((e) => {
toast({
variant: "destructive",
title: "Error fetching sites",
description: formatAxiosError(
e,
"An error occurred when fetching the sites"
)
});
});
if (res.data.data.sites.length > 0) {
form.setValue("siteId", res.data.data.sites[0].siteId);
if (res?.status === 200) {
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();
fetchDomains();
}, [open]);
async function onSubmit(data: CreateResourceFormValues) {
@ -181,11 +226,12 @@ export default function CreateResourceForm({
{
name: data.name,
subdomain: data.http ? data.subdomain : undefined,
domainId: data.http ? data.domainId : undefined,
http: data.http,
protocol: data.protocol,
proxyPort: data.http ? undefined : data.proxyPort,
siteId: data.siteId,
isBaseDomain: data.isBaseDomain
isBaseDomain: data.http ? undefined : data.isBaseDomain
}
)
.catch((e) => {
@ -237,34 +283,12 @@ export default function CreateResourceForm({
</CredenzaHeader>
<CredenzaBody>
{!showSnippets && (
<Form {...form}>
<Form {...form} key={formKey}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
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 || (
<FormField
control={form.control}
@ -278,7 +302,8 @@ export default function CreateResourceForm({
<FormDescription>
Toggle if this is an
HTTP resource or a
raw TCP/UDP resource
raw TCP/UDP
resource.
</FormDescription>
</div>
<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") &&
env.flags.allowBaseDomainResources && (
<div>
@ -335,60 +378,137 @@ export default function CreateResourceForm({
)}
{form.watch("http") && (
<FormField
control={form.control}
name="subdomain"
render={({ field }) => (
<FormItem>
<>
{domainType === "subdomain" ? (
<div className="w-fill space-y-2">
{!env.flags
.allowBaseDomainResources && (
<FormLabel>
Subdomain
</FormLabel>
)}
{domainType ===
"subdomain" ? (
<FormControl>
<CustomDomainInput
value={
field.value ??
""
}
domainSuffix={
domainSuffix
}
placeholder="Subdomain"
onChange={(
value
) =>
form.setValue(
"subdomain",
value
)
<div className="flex">
<div className="w-full mr-1">
<FormField
control={
form.control
}
name="subdomain"
render={({
field
}) => (
<FormControl>
<Input
{...field}
className="text-right"
placeholder="Enter subdomain"
/>
</FormControl>
)}
/>
</FormControl>
) : (
<FormControl>
<Input
value={
domainSuffix
</div>
<div className="max-w-1/2">
<FormField
control={
form.control
}
readOnly
disabled
name="domainId"
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") && (
@ -436,11 +556,11 @@ export default function CreateResourceForm({
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
<FormDescription>
The protocol to use
for the resource
for the resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@ -455,7 +575,6 @@ export default function CreateResourceForm({
<FormControl>
<Input
type="number"
placeholder="Enter port number"
value={
field.value ??
""
@ -474,13 +593,13 @@ export default function CreateResourceForm({
}
/>
</FormControl>
<FormMessage />
<FormDescription>
The port number to
proxy requests to
(required for
non-HTTP resources)
non-HTTP resources).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@ -520,7 +639,7 @@ export default function CreateResourceForm({
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder="Search site..." />
<CommandInput placeholder="Search site" />
<CommandList>
<CommandEmpty>
No site
@ -563,11 +682,12 @@ export default function CreateResourceForm({
</Command>
</PopoverContent>
</Popover>
<FormDescription>
This is the site that will
be used in the dashboard.
</FormDescription>
<FormMessage />
<FormDescription>
This site will provide
connectivity to the
resource.
</FormDescription>
</FormItem>
)}
/>

View file

@ -2,27 +2,68 @@
import * as React from "react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
interface DomainOption {
baseDomain: string;
domainId: string;
}
interface CustomDomainInputProps {
domainSuffix: string;
domainOptions: DomainOption[];
selectedDomainId?: string;
placeholder?: string;
value: string;
onChange?: (value: string) => void;
onChange?: (value: string, selectedDomainId: string) => void;
}
export default function CustomDomainInput({
domainSuffix,
placeholder = "Enter subdomain",
domainOptions,
selectedDomainId,
placeholder = "Subdomain",
value: defaultValue,
onChange
}: CustomDomainInputProps) {
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;
setValue(newValue);
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"
placeholder={placeholder}
value={value}
onChange={handleChange}
className="rounded-r-none w-full"
onChange={handleInputChange}
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">
<span className="text-sm truncate">.{domainSuffix}</span>
</div>
<Select
onValueChange={handleDomainChange}
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>
);

View file

@ -1,9 +1,7 @@
"use client";
import { useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { Separator } from "@app/components/ui/separator";
import CopyToClipboard from "@app/components/CopyToClipboard";
@ -17,17 +15,9 @@ import {
type ResourceInfoBoxType = {};
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
const [copied, setCopied] = useState(false);
const { org } = useOrgContext();
const { resource, authInfo } = useResourceContext();
let fullUrl = `${resource.ssl ? "https" : "http"}://`;
if (resource.isBaseDomain) {
fullUrl = fullUrl + org.org.domain;
} else {
fullUrl = fullUrl + `${resource.subdomain}.${org.org.domain}`;
}
let fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
return (
<Alert>
@ -52,7 +42,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<ShieldCheck className="w-4 h-4 mt-0.5" />
<span>
This resource is protected with
at least one auth method.
at least one authentication method.
</span>
</div>
) : (

View file

@ -136,17 +136,16 @@ export default function SetResourcePasswordForm({
<Input
autoComplete="off"
type="password"
placeholder="Your secure password"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
Users will be able to access
this resource by entering this
password. It must be at least 4
characters long.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>

View file

@ -167,13 +167,13 @@ export default function SetResourcePincodeForm({
</InputOTP>
</div>
</FormControl>
<FormMessage />
<FormDescription>
Users will be able to access
this resource by entering this
PIN code. It must be at least 6
digits long.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>

View file

@ -8,14 +8,12 @@ import { useResourceContext } from "@app/hooks/useResourceContext";
import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api";
import {
GetResourceAuthInfoResponse,
GetResourceWhitelistResponse,
ListResourceRolesResponse,
ListResourceUsersResponse
} from "@server/routers/resource";
import { Button } from "@app/components/ui/button";
import { set, z } from "zod";
import { Tag } from "emblor";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
@ -27,12 +25,8 @@ import {
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { TagInput } from "emblor";
// import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ListUsersResponse } from "@server/routers/user";
import { Switch } from "@app/components/ui/switch";
import { Label } from "@app/components/ui/label";
import { Binary, Key, ShieldCheck } from "lucide-react";
import { Binary, Key } from "lucide-react";
import SetResourcePasswordForm from "./SetResourcePasswordForm";
import SetResourcePincodeForm from "./SetResourcePincodeForm";
import { createApiClient } from "@app/lib/api";
@ -44,11 +38,11 @@ import {
SettingsSectionHeader,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
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({
@ -435,7 +429,6 @@ export default function ResourceAuthenticationPage() {
<FormItem className="flex flex-col items-start">
<FormLabel>Roles</FormLabel>
<FormControl>
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
@ -444,7 +437,7 @@ export default function ResourceAuthenticationPage() {
setActiveTagIndex={
setActiveRolesTagIndex
}
placeholder="Enter a role"
placeholder="Select a role"
tags={
usersRolesForm.getValues()
.roles
@ -483,13 +476,11 @@ export default function ResourceAuthenticationPage() {
}}
/>
</FormControl>
<FormMessage />
<FormDescription>
These roles will be able
to access this resource.
Admins can always access
this resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@ -500,7 +491,6 @@ export default function ResourceAuthenticationPage() {
<FormItem className="flex flex-col items-start">
<FormLabel>Users</FormLabel>
<FormControl>
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
@ -509,7 +499,7 @@ export default function ResourceAuthenticationPage() {
setActiveTagIndex={
setActiveUsersTagIndex
}
placeholder="Enter a user"
placeholder="Select a user"
tags={
usersRolesForm.getValues()
.users
@ -548,15 +538,6 @@ export default function ResourceAuthenticationPage() {
}}
/>
</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 />
</FormItem>
)}
@ -738,7 +719,9 @@ export default function ResourceAuthenticationPage() {
/>
</FormControl>
<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>
</FormItem>
)}

View file

@ -421,6 +421,7 @@ export default function ReverseProxyTargets(props: {
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
</SelectContent>
</Select>
)
@ -436,7 +437,13 @@ export default function ReverseProxyTargets(props: {
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel()
getFilteredRowModel: getFilteredRowModel(),
state: {
pagination: {
pageIndex: 0,
pageSize: 1000
}
}
});
if (pageLoading) {
@ -452,8 +459,7 @@ export default function ReverseProxyTargets(props: {
SSL Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Setup SSL to secure your connections with
LetsEncrypt certificates
Setup SSL to secure your connections with Let's Encrypt certificates
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@ -517,6 +523,9 @@ export default function ReverseProxyTargets(props: {
<SelectItem value="https">
https
</SelectItem>
<SelectItem value="h2c">
h2c
</SelectItem>
</SelectContent>
</Select>
</FormControl>

View file

@ -33,7 +33,6 @@ import { useEffect, useState } from "react";
import { AxiosResponse } from "axios";
import { useParams, useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { GetResourceAuthInfoResponse } from "@server/routers/resource";
import { toast } from "@app/hooks/useToast";
import {
SettingsContainer,
@ -53,6 +52,15 @@ import { subdomainSchema } from "@server/lib/schemas";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
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
.object({
@ -60,7 +68,8 @@ const GeneralFormSchema = z
name: z.string().min(1).max(255),
proxyPort: z.number().optional(),
http: z.boolean(),
isBaseDomain: z.boolean().optional()
isBaseDomain: z.boolean().optional(),
domainId: z.string().optional()
})
.refine(
(data) => {
@ -100,6 +109,7 @@ type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
type TransferFormValues = z.infer<typeof TransferFormSchema>;
export default function GeneralForm() {
const [formKey, setFormKey] = useState(0);
const params = useParams();
const { resource, updateResource } = useResourceContext();
const { org } = useOrgContext();
@ -113,9 +123,11 @@ export default function GeneralForm() {
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [saveLoading, setSaveLoading] = useState(false);
const [domainSuffix, setDomainSuffix] = useState(org.org.domain);
const [transferLoading, setTransferLoading] = useState(false);
const [open, setOpen] = useState(false);
const [baseDomains, setBaseDomains] = useState<
ListDomainsResponse["domains"]
>([]);
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
resource.isBaseDomain ? "basedomain" : "subdomain"
@ -128,7 +140,8 @@ export default function GeneralForm() {
subdomain: resource.subdomain ? resource.subdomain : undefined,
proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
http: resource.http,
isBaseDomain: resource.isBaseDomain ? true : false
isBaseDomain: resource.isBaseDomain ? true : false,
domainId: resource.domainId || undefined
},
mode: "onChange"
});
@ -147,6 +160,31 @@ export default function GeneralForm() {
);
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();
}, []);
@ -154,12 +192,16 @@ export default function GeneralForm() {
setSaveLoading(true);
const res = await api
.post(`resource/${resource?.resourceId}`, {
name: data.name,
subdomain: data.subdomain,
proxyPort: data.proxyPort,
isBaseDomain: data.isBaseDomain
})
.post<AxiosResponse<UpdateResourceResponse>>(
`resource/${resource?.resourceId}`,
{
name: data.name,
subdomain: data.http ? data.subdomain : undefined,
proxyPort: data.proxyPort,
isBaseDomain: data.http ? data.isBaseDomain : undefined,
domainId: data.http ? data.domainId : undefined
}
)
.catch((e) => {
toast({
variant: "destructive",
@ -177,12 +219,17 @@ export default function GeneralForm() {
description: "The resource has been updated successfully"
});
const resource = res.data.data;
updateResource({
name: data.name,
subdomain: data.subdomain,
proxyPort: data.proxyPort,
isBaseDomain: data.isBaseDomain
isBaseDomain: data.isBaseDomain,
fullDomain: resource.fullDomain
});
router.refresh();
}
setSaveLoading(false);
}
@ -229,7 +276,7 @@ export default function GeneralForm() {
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<Form {...form} key={formKey}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
@ -244,11 +291,11 @@ export default function GeneralForm() {
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This is the display name of the
resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@ -292,60 +339,139 @@ export default function GeneralForm() {
</div>
)}
<FormField
control={form.control}
name="subdomain"
render={({ field }) => (
<FormItem>
{!env.flags
.allowBaseDomainResources && (
<FormLabel>
Subdomain
</FormLabel>
)}
{domainType ===
"subdomain" ? (
<FormControl>
<CustomDomainInput
value={
field.value ||
""
}
domainSuffix={
domainSuffix
}
placeholder="Enter subdomain"
onChange={(
value
) =>
form.setValue(
"subdomain",
value
{domainType === "subdomain" ? (
<div className="w-fill space-y-2">
{!env.flags
.allowBaseDomainResources && (
<FormLabel>
Subdomain
</FormLabel>
)}
<div className="flex">
<div className="w-full mr-1">
<FormField
control={
form.control
}
name="subdomain"
render={({
field
}) => (
<FormItem>
<FormControl>
<Input
{...field}
className="text-right"
placeholder="Enter subdomain"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</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>
)
}
/>
</FormControl>
) : (
<FormControl>
<Input
value={
domainSuffix
}
readOnly
disabled
/>
</FormControl>
)}
<FormDescription>
This is the subdomain
that will be used to
access the resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
@ -361,7 +487,6 @@ export default function GeneralForm() {
<FormControl>
<Input
type="number"
placeholder="Enter port number"
value={
field.value ?? ""
}
@ -378,12 +503,12 @@ export default function GeneralForm() {
}
/>
</FormControl>
<FormMessage />
<FormDescription>
This is the port that will
be used to access the
resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@ -427,7 +552,7 @@ export default function GeneralForm() {
control={transferForm.control}
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormItem>
<FormLabel>
Destination Site
</FormLabel>
@ -460,7 +585,7 @@ export default function GeneralForm() {
<PopoverContent className="w-full p-0">
<Command>
<CommandInput
placeholder="Search sites..."
placeholder="Search sites"
className="h-9"
/>
<CommandEmpty>
@ -503,10 +628,6 @@ export default function GeneralForm() {
</Command>
</PopoverContent>
</Popover>
<FormDescription>
Select the new site to transfer
this resource to.
</FormDescription>
<FormMessage />
</FormItem>
)}
@ -522,7 +643,6 @@ export default function GeneralForm() {
loading={transferLoading}
disabled={transferLoading}
form="transfer-form"
variant="destructive"
>
Transfer Resource
</Button>

View file

@ -92,9 +92,9 @@ enum RuleAction {
}
enum RuleMatch {
PATH = "Path",
IP = "IP",
CIDR = "IP Range",
PATH = "Path"
}
export default function ResourceRules(props: {
@ -469,9 +469,9 @@ export default function ResourceRules(props: {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
</SelectContent>
</Select>
)
@ -524,7 +524,13 @@ export default function ResourceRules(props: {
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel()
getFilteredRowModel: getFilteredRowModel(),
state: {
pagination: {
pageIndex: 0,
pageSize: 1000
}
}
});
if (pageLoading) {
@ -665,17 +671,17 @@ export default function ResourceRules(props: {
<SelectValue />
</SelectTrigger>
<SelectContent>
{resource.http && (
<SelectItem value="PATH">
{RuleMatch.PATH}
</SelectItem>
)}
<SelectItem value="IP">
{RuleMatch.IP}
</SelectItem>
<SelectItem value="CIDR">
{RuleMatch.CIDR}
</SelectItem>
{resource.http && (
<SelectItem value="PATH">
{RuleMatch.PATH}
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>

View file

@ -305,7 +305,7 @@ export default function CreateShareLinkForm({
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder="Search resources..." />
<CommandInput placeholder="Search resources" />
<CommandList>
<CommandEmpty>
No
@ -374,7 +374,6 @@ export default function CreateShareLinkForm({
</Label>
<FormControl>
<Input
placeholder="Enter title"
{...field}
/>
</FormControl>
@ -437,7 +436,6 @@ export default function CreateShareLinkForm({
<Input
type="number"
min={1}
placeholder="Enter duration"
{...field}
/>
</FormControl>

View file

@ -272,17 +272,13 @@ PersistentKeepalive = 5`
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder="Site name"
{...field}
/>
<Input autoComplete="off" {...field} />
</FormControl>
<FormDescription>
This is the name that will be displayed for
this site.
</FormDescription>
<FormMessage />
<FormDescription>
This is the the display name for the
site.
</FormDescription>
</FormItem>
)}
/>
@ -319,10 +315,10 @@ PersistentKeepalive = 5`
</SelectContent>
</Select>
</FormControl>
<FormMessage />
<FormDescription>
This is how you will expose connections.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@ -354,7 +350,7 @@ PersistentKeepalive = 5`
) : form.watch("method") === "wireguard" &&
isLoading ? (
<p>Loading WireGuard configuration...</p>
) : form.watch("method") === "newt" ? (
) : form.watch("method") === "newt" && siteDefaults ? (
<>
<div className="mb-2">
<Collapsible
@ -376,8 +372,8 @@ PersistentKeepalive = 5`
className="p-0 flex items-center justify-between w-full"
>
<h4 className="text-sm font-semibold">
Expand for Docker Deployment
Details
Expand for Docker
Deployment Details
</h4>
<div>
<ChevronsUpDown className="h-4 w-4" />

View file

@ -33,7 +33,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useState } from "react";
const GeneralFormSchema = z.object({
name: z.string()
name: z.string().nonempty("Name is required")
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@ -114,11 +114,11 @@ export default function GeneralPage() {
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This is the display name of the
site
site.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>

View file

@ -38,7 +38,7 @@ import { Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "../../../components/ui/alert";
import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
@ -223,16 +223,13 @@ export default function ResetPasswordForm({
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Enter your email"
{...field}
/>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
We'll send a password reset
code to this email address.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@ -255,7 +252,6 @@ export default function ResetPasswordForm({
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Email"
{...field}
disabled
/>
@ -276,12 +272,15 @@ export default function ResetPasswordForm({
</FormLabel>
<FormControl>
<Input
placeholder="Enter reset code sent to your email"
type="password"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
Check your email for the
reset code.
</FormDescription>
</FormItem>
)}
/>
@ -298,7 +297,6 @@ export default function ResetPasswordForm({
<FormControl>
<Input
type="password"
placeholder="Password"
{...field}
/>
</FormControl>
@ -317,7 +315,6 @@ export default function ResetPasswordForm({
<FormControl>
<Input
type="password"
placeholder="Confirm Password"
{...field}
/>
</FormControl>
@ -349,7 +346,9 @@ export default function ResetPasswordForm({
<InputOTP
maxLength={6}
{...field}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
pattern={
REGEXP_ONLY_DIGITS_AND_CHARS
}
>
<InputOTPGroup>
<InputOTPSlot

View file

@ -263,7 +263,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
}
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>
<FormControl>
<Input
placeholder="Enter password"
type="password"
{...field}
/>
@ -517,7 +517,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
</FormLabel>
<FormControl>
<Input
placeholder="Enter email"
type="email"
{...field}
/>
@ -576,7 +575,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
</FormLabel>
<FormControl>
<Input
placeholder="Enter OTP"
type="password"
{...field}
/>

View file

@ -145,7 +145,7 @@ export default function SignupForm({
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="Email" {...field} />
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -160,7 +160,6 @@ export default function SignupForm({
<FormControl>
<Input
type="password"
placeholder="Password"
{...field}
/>
</FormControl>
@ -177,7 +176,6 @@ export default function SignupForm({
<FormControl>
<Input
type="password"
placeholder="Confirm Password"
{...field}
/>
</FormControl>

View file

@ -145,7 +145,6 @@ export default function VerifyEmailForm({
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Email"
{...field}
disabled
/>
@ -196,12 +195,12 @@ export default function VerifyEmailForm({
</InputOTP>
</div>
</FormControl>
<FormMessage />
<FormDescription>
We sent a verification code to your
email address. Please enter the code
to verify your email address.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>

View file

@ -200,7 +200,6 @@ export default function StepperForm() {
</FormLabel>
<FormControl>
<Input
placeholder="Name your new organization"
type="text"
{...field}
onChange={(e) => {
@ -242,7 +241,6 @@ export default function StepperForm() {
<FormControl>
<Input
type="text"
placeholder="Enter unique organization ID"
{...field}
/>
</FormControl>

View file

@ -135,7 +135,6 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
/>
</FormControl>

View file

@ -200,7 +200,6 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
/>
</FormControl>
@ -246,7 +245,6 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
<FormControl>
<Input
type="code"
placeholder="Enter the 6-digit code from your authenticator app"
{...field}
/>
</FormControl>

View file

@ -147,7 +147,6 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Enter your email"
{...field}
/>
</FormControl>
@ -166,7 +165,6 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
/>
</FormControl>

View 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>
);
};

View 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 };

View 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}
/>
)
)
)}
</>
)}
</>
);
};

View 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&apos;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
View 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>
);
};

View file

@ -15,9 +15,9 @@ const buttonVariants = cva(
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
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:
"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",
text: "",
link: "text-primary underline-offset-4 hover:underline",

View file

@ -15,7 +15,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={showPassword ? "text" : "password"}
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
)}
ref={ref}
@ -39,7 +39,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
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
)}
ref={ref}

View file

@ -28,7 +28,7 @@ const RadioGroupItem = React.forwardRef<
<RadioGroupPrimitive.Item
ref={ref}
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
)}
{...props}

View file

@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
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,
"rounded-md"
)}