mirror of
https://github.com/fosrl/pangolin.git
synced 2025-08-24 11:15:31 +02:00
Merge branch 'dev' into hybrid
This commit is contained in:
commit
7bf98c0c40
119 changed files with 9999 additions and 3106 deletions
|
@ -6,12 +6,15 @@ type ResetUserSecurityKeysArgs = {
|
|||
email: string;
|
||||
};
|
||||
|
||||
export const resetUserSecurityKeys: CommandModule<{}, ResetUserSecurityKeysArgs> = {
|
||||
export const resetUserSecurityKeys: CommandModule<
|
||||
{},
|
||||
ResetUserSecurityKeysArgs
|
||||
> = {
|
||||
command: "reset-user-security-keys",
|
||||
describe: "Reset a user's security keys (passkeys) by deleting all their webauthn credentials",
|
||||
describe:
|
||||
"Reset a user's security keys (passkeys) by deleting all their webauthn credentials",
|
||||
builder: (yargs) => {
|
||||
return yargs
|
||||
.option("email", {
|
||||
return yargs.option("email", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
describe: "User email address"
|
||||
|
@ -48,7 +51,9 @@ export const resetUserSecurityKeys: CommandModule<{}, ResetUserSecurityKeysArgs>
|
|||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Found ${userSecurityKeys.length} security key(s) for user '${email}'`);
|
||||
console.log(
|
||||
`Found ${userSecurityKeys.length} security key(s) for user '${email}'`
|
||||
);
|
||||
|
||||
// Delete all security keys for the user
|
||||
await db
|
||||
|
|
|
@ -32,7 +32,8 @@ export const setAdminCredentials: CommandModule<{}, SetAdminCredentialsArgs> = {
|
|||
},
|
||||
handler: async (argv: { email: string; password: string }) => {
|
||||
try {
|
||||
const { email, password } = argv;
|
||||
let { email, password } = argv;
|
||||
email = email.trim().toLowerCase();
|
||||
|
||||
const parsed = passwordSchema.safeParse(password);
|
||||
|
||||
|
|
|
@ -2,47 +2,27 @@
|
|||
# https://docs.digpangolin.com/self-host/advanced/config-file
|
||||
|
||||
app:
|
||||
dashboard_url: "http://localhost:3002"
|
||||
log_level: "info"
|
||||
save_logs: false
|
||||
dashboard_url: http://localhost:3002
|
||||
log_level: debug
|
||||
|
||||
domains:
|
||||
domain1:
|
||||
base_domain: "example.com"
|
||||
cert_resolver: "letsencrypt"
|
||||
base_domain: example.com
|
||||
|
||||
server:
|
||||
external_port: 3000
|
||||
internal_port: 3001
|
||||
next_port: 3002
|
||||
internal_hostname: "pangolin"
|
||||
session_cookie_name: "p_session_token"
|
||||
resource_access_token_param: "p_token"
|
||||
secret: "your_secret_key_here"
|
||||
resource_access_token_headers:
|
||||
id: "P-Access-Token-Id"
|
||||
token: "P-Access-Token"
|
||||
resource_session_request_param: "p_session_request"
|
||||
|
||||
traefik:
|
||||
http_entrypoint: "web"
|
||||
https_entrypoint: "websecure"
|
||||
secret: my_secret_key
|
||||
|
||||
gerbil:
|
||||
start_port: 51820
|
||||
base_endpoint: "localhost"
|
||||
block_size: 24
|
||||
site_block_size: 30
|
||||
subnet_group: 100.89.137.0/20
|
||||
use_subdomain: true
|
||||
base_endpoint: example.com
|
||||
|
||||
rate_limits:
|
||||
global:
|
||||
window_minutes: 1
|
||||
max_requests: 500
|
||||
orgs:
|
||||
block_size: 24
|
||||
subnet_group: 100.90.137.0/20
|
||||
|
||||
flags:
|
||||
require_email_verification: false
|
||||
disable_signup_without_invite: true
|
||||
disable_user_create_org: true
|
||||
allow_raw_resources: true
|
||||
enable_integration_api: true
|
||||
enable_clients: true
|
||||
|
|
138
install/main.go
138
install/main.go
|
@ -77,7 +77,7 @@ func main() {
|
|||
fmt.Println("Lets get started!")
|
||||
fmt.Println("")
|
||||
|
||||
|
||||
if os.Geteuid() == 0 { // WE NEED TO BE SUDO TO CHECK THIS
|
||||
for _, p := range []int{80, 443} {
|
||||
if err := checkPortsAvailable(p); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
|
@ -86,7 +86,7 @@ func main() {
|
|||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
inputContainer := readString(reader, "Would you like to run Pangolin as Docker or Podman containers?", "docker")
|
||||
|
@ -215,6 +215,28 @@ func main() {
|
|||
}
|
||||
} else {
|
||||
fmt.Println("Looks like you already installed, so I am going to do the setup...")
|
||||
|
||||
// Read existing config to get DashboardDomain
|
||||
traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml", "config/traefik/dynamic_config.yml")
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Could not read existing config: %v\n", err)
|
||||
fmt.Println("You may need to manually enter your domain information.")
|
||||
config = collectUserInput(reader)
|
||||
} else {
|
||||
config.DashboardDomain = traefikConfig.DashboardDomain
|
||||
config.LetsEncryptEmail = traefikConfig.LetsEncryptEmail
|
||||
config.BadgerVersion = traefikConfig.BadgerVersion
|
||||
|
||||
// Show detected values and allow user to confirm or re-enter
|
||||
fmt.Println("Detected existing configuration:")
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !checkIsCrowdsecInstalledInCompose() {
|
||||
|
@ -252,6 +274,23 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
// Setup Token Section
|
||||
fmt.Println("\n=== Setup Token ===")
|
||||
|
||||
// Check if containers were started during this installation
|
||||
containersStarted := false
|
||||
if (isDockerInstalled() && chosenContainer == Docker) ||
|
||||
(isPodmanInstalled() && chosenContainer == Podman) {
|
||||
// Try to fetch and display the token if containers are running
|
||||
containersStarted = true
|
||||
printSetupToken(chosenContainer, config.DashboardDomain)
|
||||
}
|
||||
|
||||
// If containers weren't started or token wasn't found, show instructions
|
||||
if !containersStarted {
|
||||
showSetupTokenInstructions(chosenContainer, config.DashboardDomain)
|
||||
}
|
||||
|
||||
fmt.Println("Installation complete!")
|
||||
fmt.Printf("\nTo complete the initial setup, please visit:\nhttps://%s/auth/initial-setup\n", config.DashboardDomain)
|
||||
}
|
||||
|
@ -315,10 +354,16 @@ func collectUserInput(reader *bufio.Reader) Config {
|
|||
// Basic configuration
|
||||
fmt.Println("\n=== Basic Configuration ===")
|
||||
config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
|
||||
config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", "pangolin."+config.BaseDomain)
|
||||
config.EnableIPv6 = readBool(reader, "Is your server IPv6 capable?", true)
|
||||
|
||||
// Set default dashboard domain after base domain is collected
|
||||
defaultDashboardDomain := ""
|
||||
if config.BaseDomain != "" {
|
||||
defaultDashboardDomain = "pangolin." + config.BaseDomain
|
||||
}
|
||||
config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", defaultDashboardDomain)
|
||||
config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "")
|
||||
config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunneled connections", true)
|
||||
config.EnableIPv6 = readBool(reader, "Is your server IPv6 capable?", true)
|
||||
|
||||
// Email configuration
|
||||
fmt.Println("\n=== Email Configuration ===")
|
||||
|
@ -769,6 +814,91 @@ func waitForContainer(containerName string, containerType SupportedContainer) er
|
|||
return fmt.Errorf("container %s did not start within %v seconds", containerName, maxAttempts*int(retryInterval.Seconds()))
|
||||
}
|
||||
|
||||
func printSetupToken(containerType SupportedContainer, dashboardDomain string) {
|
||||
fmt.Println("Waiting for Pangolin to generate setup token...")
|
||||
|
||||
// Wait for Pangolin to be healthy
|
||||
if err := waitForContainer("pangolin", containerType); err != nil {
|
||||
fmt.Println("Warning: Pangolin container did not become healthy in time.")
|
||||
return
|
||||
}
|
||||
|
||||
// Give a moment for the setup token to be generated
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Fetch logs
|
||||
var cmd *exec.Cmd
|
||||
if containerType == Docker {
|
||||
cmd = exec.Command("docker", "logs", "pangolin")
|
||||
} else {
|
||||
cmd = exec.Command("podman", "logs", "pangolin")
|
||||
}
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
fmt.Println("Warning: Could not fetch Pangolin logs to find setup token.")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse for setup token
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for i, line := range lines {
|
||||
if strings.Contains(line, "=== SETUP TOKEN GENERATED ===") || strings.Contains(line, "=== SETUP TOKEN EXISTS ===") {
|
||||
// Look for "Token: ..." in the next few lines
|
||||
for j := i + 1; j < i+5 && j < len(lines); j++ {
|
||||
trimmedLine := strings.TrimSpace(lines[j])
|
||||
if strings.Contains(trimmedLine, "Token:") {
|
||||
// Extract token after "Token:"
|
||||
tokenStart := strings.Index(trimmedLine, "Token:")
|
||||
if tokenStart != -1 {
|
||||
token := strings.TrimSpace(trimmedLine[tokenStart+6:])
|
||||
fmt.Printf("Setup token: %s\n", token)
|
||||
fmt.Println("")
|
||||
fmt.Println("This token is required to register the first admin account in the web UI at:")
|
||||
fmt.Printf("https://%s/auth/initial-setup\n", dashboardDomain)
|
||||
fmt.Println("")
|
||||
fmt.Println("Save this token securely. It will be invalid after the first admin is created.")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println("Warning: Could not find a setup token in Pangolin logs.")
|
||||
}
|
||||
|
||||
func showSetupTokenInstructions(containerType SupportedContainer, dashboardDomain string) {
|
||||
fmt.Println("\n=== Setup Token Instructions ===")
|
||||
fmt.Println("To get your setup token, you need to:")
|
||||
fmt.Println("")
|
||||
fmt.Println("1. Start the containers:")
|
||||
if containerType == Docker {
|
||||
fmt.Println(" docker-compose up -d")
|
||||
} else {
|
||||
fmt.Println(" podman-compose up -d")
|
||||
}
|
||||
fmt.Println("")
|
||||
fmt.Println("2. Wait for the Pangolin container to start and generate the token")
|
||||
fmt.Println("")
|
||||
fmt.Println("3. Check the container logs for the setup token:")
|
||||
if containerType == Docker {
|
||||
fmt.Println(" docker logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'")
|
||||
} else {
|
||||
fmt.Println(" podman logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'")
|
||||
}
|
||||
fmt.Println("")
|
||||
fmt.Println("4. Look for output like:")
|
||||
fmt.Println(" === SETUP TOKEN GENERATED ===")
|
||||
fmt.Println(" Token: [your-token-here]")
|
||||
fmt.Println(" Use this token on the initial setup page")
|
||||
fmt.Println("")
|
||||
fmt.Println("5. Use the token to complete initial setup at:")
|
||||
fmt.Printf(" https://%s/auth/initial-setup\n", dashboardDomain)
|
||||
fmt.Println("")
|
||||
fmt.Println("The setup token is required to register the first admin account.")
|
||||
fmt.Println("Save it securely - it will be invalid after the first admin is created.")
|
||||
fmt.Println("================================")
|
||||
}
|
||||
|
||||
func generateRandomSecretKey() string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
const length = 32
|
||||
|
|
|
@ -833,6 +833,24 @@
|
|||
"pincodeRequirementsLength": "PIN must be exactly 6 digits",
|
||||
"pincodeRequirementsChars": "PIN must only contain numbers",
|
||||
"passwordRequirementsLength": "Password must be at least 1 character long",
|
||||
"passwordRequirementsTitle": "Password requirements:",
|
||||
"passwordRequirementLength": "At least 8 characters long",
|
||||
"passwordRequirementUppercase": "At least one uppercase letter",
|
||||
"passwordRequirementLowercase": "At least one lowercase letter",
|
||||
"passwordRequirementNumber": "At least one number",
|
||||
"passwordRequirementSpecial": "At least one special character",
|
||||
"passwordRequirementsMet": "✓ Password meets all requirements",
|
||||
"passwordStrength": "Password strength",
|
||||
"passwordStrengthWeak": "Weak",
|
||||
"passwordStrengthMedium": "Medium",
|
||||
"passwordStrengthStrong": "Strong",
|
||||
"passwordRequirements": "Requirements:",
|
||||
"passwordRequirementLengthText": "8+ characters",
|
||||
"passwordRequirementUppercaseText": "Uppercase letter (A-Z)",
|
||||
"passwordRequirementLowercaseText": "Lowercase letter (a-z)",
|
||||
"passwordRequirementNumberText": "Number (0-9)",
|
||||
"passwordRequirementSpecialText": "Special character (!@#$%...)",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"otpEmailRequirementsLength": "OTP must be at least 1 character long",
|
||||
"otpEmailSent": "OTP Sent",
|
||||
"otpEmailSentDescription": "An OTP has been sent to your email",
|
||||
|
@ -967,6 +985,9 @@
|
|||
"actionDeleteSite": "Delete Site",
|
||||
"actionGetSite": "Get Site",
|
||||
"actionListSites": "List Sites",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenPlaceholder": "Enter the setup token from the server console",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
"actionUpdateSite": "Update Site",
|
||||
"actionListSiteRoles": "List Allowed Site Roles",
|
||||
"actionCreateResource": "Create Resource",
|
||||
|
|
|
@ -833,6 +833,24 @@
|
|||
"pincodeRequirementsLength": "PIN must be exactly 6 digits",
|
||||
"pincodeRequirementsChars": "PIN must only contain numbers",
|
||||
"passwordRequirementsLength": "Password must be at least 1 character long",
|
||||
"passwordRequirementsTitle": "Password requirements:",
|
||||
"passwordRequirementLength": "At least 8 characters long",
|
||||
"passwordRequirementUppercase": "At least one uppercase letter",
|
||||
"passwordRequirementLowercase": "At least one lowercase letter",
|
||||
"passwordRequirementNumber": "At least one number",
|
||||
"passwordRequirementSpecial": "At least one special character",
|
||||
"passwordRequirementsMet": "✓ Password meets all requirements",
|
||||
"passwordStrength": "Password strength",
|
||||
"passwordStrengthWeak": "Weak",
|
||||
"passwordStrengthMedium": "Medium",
|
||||
"passwordStrengthStrong": "Strong",
|
||||
"passwordRequirements": "Requirements:",
|
||||
"passwordRequirementLengthText": "8+ characters",
|
||||
"passwordRequirementUppercaseText": "Uppercase letter (A-Z)",
|
||||
"passwordRequirementLowercaseText": "Lowercase letter (a-z)",
|
||||
"passwordRequirementNumberText": "Number (0-9)",
|
||||
"passwordRequirementSpecialText": "Special character (!@#$%...)",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"otpEmailRequirementsLength": "OTP must be at least 1 character long",
|
||||
"otpEmailSent": "OTP Sent",
|
||||
"otpEmailSentDescription": "An OTP has been sent to your email",
|
||||
|
@ -967,6 +985,9 @@
|
|||
"actionDeleteSite": "Delete Site",
|
||||
"actionGetSite": "Get Site",
|
||||
"actionListSites": "List Sites",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenPlaceholder": "Enter the setup token from the server console",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
"actionUpdateSite": "Update Site",
|
||||
"actionListSiteRoles": "List Allowed Site Roles",
|
||||
"actionCreateResource": "Create Resource",
|
||||
|
|
|
@ -833,6 +833,24 @@
|
|||
"pincodeRequirementsLength": "PIN muss genau 6 Ziffern lang sein",
|
||||
"pincodeRequirementsChars": "PIN darf nur Zahlen enthalten",
|
||||
"passwordRequirementsLength": "Passwort muss mindestens 1 Zeichen lang sein",
|
||||
"passwordRequirementsTitle": "Password requirements:",
|
||||
"passwordRequirementLength": "At least 8 characters long",
|
||||
"passwordRequirementUppercase": "At least one uppercase letter",
|
||||
"passwordRequirementLowercase": "At least one lowercase letter",
|
||||
"passwordRequirementNumber": "At least one number",
|
||||
"passwordRequirementSpecial": "At least one special character",
|
||||
"passwordRequirementsMet": "✓ Password meets all requirements",
|
||||
"passwordStrength": "Password strength",
|
||||
"passwordStrengthWeak": "Weak",
|
||||
"passwordStrengthMedium": "Medium",
|
||||
"passwordStrengthStrong": "Strong",
|
||||
"passwordRequirements": "Requirements:",
|
||||
"passwordRequirementLengthText": "8+ characters",
|
||||
"passwordRequirementUppercaseText": "Uppercase letter (A-Z)",
|
||||
"passwordRequirementLowercaseText": "Lowercase letter (a-z)",
|
||||
"passwordRequirementNumberText": "Number (0-9)",
|
||||
"passwordRequirementSpecialText": "Special character (!@#$%...)",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"otpEmailRequirementsLength": "OTP muss mindestens 1 Zeichen lang sein",
|
||||
"otpEmailSent": "OTP gesendet",
|
||||
"otpEmailSentDescription": "Ein OTP wurde an Ihre E-Mail gesendet",
|
||||
|
@ -967,6 +985,9 @@
|
|||
"actionDeleteSite": "Standort löschen",
|
||||
"actionGetSite": "Standort abrufen",
|
||||
"actionListSites": "Standorte auflisten",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenPlaceholder": "Enter the setup token from the server console",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
"actionUpdateSite": "Standorte aktualisieren",
|
||||
"actionListSiteRoles": "Erlaubte Standort-Rollen auflisten",
|
||||
"actionCreateResource": "Ressource erstellen",
|
||||
|
|
|
@ -166,7 +166,7 @@
|
|||
"siteSelect": "Select site",
|
||||
"siteSearch": "Search site",
|
||||
"siteNotFound": "No site found.",
|
||||
"siteSelectionDescription": "This site will provide connectivity to the resource.",
|
||||
"siteSelectionDescription": "This site will provide connectivity to the target.",
|
||||
"resourceType": "Resource Type",
|
||||
"resourceTypeDescription": "Determine how you want to access your resource",
|
||||
"resourceHTTPSSettings": "HTTPS Settings",
|
||||
|
@ -197,6 +197,7 @@
|
|||
"general": "General",
|
||||
"generalSettings": "General Settings",
|
||||
"proxy": "Proxy",
|
||||
"internal": "Internal",
|
||||
"rules": "Rules",
|
||||
"resourceSettingDescription": "Configure the settings on your resource",
|
||||
"resourceSetting": "{resourceName} Settings",
|
||||
|
@ -490,7 +491,7 @@
|
|||
"targetTlsSniDescription": "The TLS Server Name to use for SNI. Leave empty to use the default.",
|
||||
"targetTlsSubmit": "Save Settings",
|
||||
"targets": "Targets Configuration",
|
||||
"targetsDescription": "Set up targets to route traffic to your services",
|
||||
"targetsDescription": "Set up targets to route traffic to your backend services",
|
||||
"targetStickySessions": "Enable Sticky Sessions",
|
||||
"targetStickySessionsDescription": "Keep connections on the same backend target for their entire session.",
|
||||
"methodSelect": "Select method",
|
||||
|
@ -833,6 +834,24 @@
|
|||
"pincodeRequirementsLength": "PIN must be exactly 6 digits",
|
||||
"pincodeRequirementsChars": "PIN must only contain numbers",
|
||||
"passwordRequirementsLength": "Password must be at least 1 character long",
|
||||
"passwordRequirementsTitle": "Password requirements:",
|
||||
"passwordRequirementLength": "At least 8 characters long",
|
||||
"passwordRequirementUppercase": "At least one uppercase letter",
|
||||
"passwordRequirementLowercase": "At least one lowercase letter",
|
||||
"passwordRequirementNumber": "At least one number",
|
||||
"passwordRequirementSpecial": "At least one special character",
|
||||
"passwordRequirementsMet": "✓ Password meets all requirements",
|
||||
"passwordStrength": "Password strength",
|
||||
"passwordStrengthWeak": "Weak",
|
||||
"passwordStrengthMedium": "Medium",
|
||||
"passwordStrengthStrong": "Strong",
|
||||
"passwordRequirements": "Requirements:",
|
||||
"passwordRequirementLengthText": "8+ characters",
|
||||
"passwordRequirementUppercaseText": "Uppercase letter (A-Z)",
|
||||
"passwordRequirementLowercaseText": "Lowercase letter (a-z)",
|
||||
"passwordRequirementNumberText": "Number (0-9)",
|
||||
"passwordRequirementSpecialText": "Special character (!@#$%...)",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"otpEmailRequirementsLength": "OTP must be at least 1 character long",
|
||||
"otpEmailSent": "OTP Sent",
|
||||
"otpEmailSentDescription": "An OTP has been sent to your email",
|
||||
|
@ -967,6 +986,9 @@
|
|||
"actionDeleteSite": "Delete Site",
|
||||
"actionGetSite": "Get Site",
|
||||
"actionListSites": "List Sites",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenDescription": "Enter the setup token from the server console.",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
"actionUpdateSite": "Update Site",
|
||||
"actionListSiteRoles": "List Allowed Site Roles",
|
||||
"actionCreateResource": "Create Resource",
|
||||
|
@ -1323,5 +1345,107 @@
|
|||
"remoteSubnetsDescription": "Add CIDR ranges that can be accessed from this site remotely using clients. Use format like 10.0.0.0/24. This ONLY applies to VPN client connectivity.",
|
||||
"resourceEnableProxy": "Enable Public Proxy",
|
||||
"resourceEnableProxyDescription": "Enable public proxying to this resource. This allows access to the resource from outside the network through the cloud on an open port. Requires Traefik config.",
|
||||
"externalProxyEnabled": "External Proxy Enabled"
|
||||
"externalProxyEnabled": "External Proxy Enabled",
|
||||
"addNewTarget": "Add New Target",
|
||||
"targetsList": "Targets List",
|
||||
"targetErrorDuplicateTargetFound": "Duplicate target found",
|
||||
"httpMethod": "HTTP Method",
|
||||
"selectHttpMethod": "Select HTTP method",
|
||||
"domainPickerSubdomainLabel": "Subdomain",
|
||||
"domainPickerBaseDomainLabel": "Base Domain",
|
||||
"domainPickerSearchDomains": "Search domains...",
|
||||
"domainPickerNoDomainsFound": "No domains found",
|
||||
"domainPickerLoadingDomains": "Loading domains...",
|
||||
"domainPickerSelectBaseDomain": "Select base domain...",
|
||||
"domainPickerNotAvailableForCname": "Not available for CNAME domains",
|
||||
"domainPickerEnterSubdomainOrLeaveBlank": "Enter subdomain or leave blank to use base domain.",
|
||||
"domainPickerEnterSubdomainToSearch": "Enter a subdomain to search and select from available free domains.",
|
||||
"domainPickerFreeDomains": "Free Domains",
|
||||
"domainPickerSearchForAvailableDomains": "Search for available domains",
|
||||
"resourceDomain": "Domain",
|
||||
"resourceEditDomain": "Edit Domain",
|
||||
"siteName": "Site Name",
|
||||
"proxyPort": "Port",
|
||||
"resourcesTableProxyResources": "Proxy Resources",
|
||||
"resourcesTableClientResources": "Client Resources",
|
||||
"resourcesTableNoProxyResourcesFound": "No proxy resources found.",
|
||||
"resourcesTableNoInternalResourcesFound": "No internal resources found.",
|
||||
"resourcesTableDestination": "Destination",
|
||||
"resourcesTableTheseResourcesForUseWith": "These resources are for use with",
|
||||
"resourcesTableClients": "Clients",
|
||||
"resourcesTableAndOnlyAccessibleInternally": "and are only accessible internally when connected with a client.",
|
||||
"editInternalResourceDialogEditClientResource": "Edit Client Resource",
|
||||
"editInternalResourceDialogUpdateResourceProperties": "Update the resource properties and target configuration for {resourceName}.",
|
||||
"editInternalResourceDialogResourceProperties": "Resource Properties",
|
||||
"editInternalResourceDialogName": "Name",
|
||||
"editInternalResourceDialogProtocol": "Protocol",
|
||||
"editInternalResourceDialogSitePort": "Site Port",
|
||||
"editInternalResourceDialogTargetConfiguration": "Target Configuration",
|
||||
"editInternalResourceDialogDestinationIP": "Destination IP",
|
||||
"editInternalResourceDialogDestinationPort": "Destination Port",
|
||||
"editInternalResourceDialogCancel": "Cancel",
|
||||
"editInternalResourceDialogSaveResource": "Save Resource",
|
||||
"editInternalResourceDialogSuccess": "Success",
|
||||
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Internal resource updated successfully",
|
||||
"editInternalResourceDialogError": "Error",
|
||||
"editInternalResourceDialogFailedToUpdateInternalResource": "Failed to update internal resource",
|
||||
"editInternalResourceDialogNameRequired": "Name is required",
|
||||
"editInternalResourceDialogNameMaxLength": "Name must be less than 255 characters",
|
||||
"editInternalResourceDialogProxyPortMin": "Proxy port must be at least 1",
|
||||
"editInternalResourceDialogProxyPortMax": "Proxy port must be less than 65536",
|
||||
"editInternalResourceDialogInvalidIPAddressFormat": "Invalid IP address format",
|
||||
"editInternalResourceDialogDestinationPortMin": "Destination port must be at least 1",
|
||||
"editInternalResourceDialogDestinationPortMax": "Destination port must be less than 65536",
|
||||
"createInternalResourceDialogNoSitesAvailable": "No Sites Available",
|
||||
"createInternalResourceDialogNoSitesAvailableDescription": "You need to have at least one Newt site with a subnet configured to create internal resources.",
|
||||
"createInternalResourceDialogClose": "Close",
|
||||
"createInternalResourceDialogCreateClientResource": "Create Client Resource",
|
||||
"createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will be accessible to clients connected to the selected site.",
|
||||
"createInternalResourceDialogResourceProperties": "Resource Properties",
|
||||
"createInternalResourceDialogName": "Name",
|
||||
"createInternalResourceDialogSite": "Site",
|
||||
"createInternalResourceDialogSelectSite": "Select site...",
|
||||
"createInternalResourceDialogSearchSites": "Search sites...",
|
||||
"createInternalResourceDialogNoSitesFound": "No sites found.",
|
||||
"createInternalResourceDialogProtocol": "Protocol",
|
||||
"createInternalResourceDialogTcp": "TCP",
|
||||
"createInternalResourceDialogUdp": "UDP",
|
||||
"createInternalResourceDialogSitePort": "Site Port",
|
||||
"createInternalResourceDialogSitePortDescription": "Use this port to access the resource on the site when connected with a client.",
|
||||
"createInternalResourceDialogTargetConfiguration": "Target Configuration",
|
||||
"createInternalResourceDialogDestinationIP": "Destination IP",
|
||||
"createInternalResourceDialogDestinationIPDescription": "The IP address of the resource on the site's network.",
|
||||
"createInternalResourceDialogDestinationPort": "Destination Port",
|
||||
"createInternalResourceDialogDestinationPortDescription": "The port on the destination IP where the resource is accessible.",
|
||||
"createInternalResourceDialogCancel": "Cancel",
|
||||
"createInternalResourceDialogCreateResource": "Create Resource",
|
||||
"createInternalResourceDialogSuccess": "Success",
|
||||
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Internal resource created successfully",
|
||||
"createInternalResourceDialogError": "Error",
|
||||
"createInternalResourceDialogFailedToCreateInternalResource": "Failed to create internal resource",
|
||||
"createInternalResourceDialogNameRequired": "Name is required",
|
||||
"createInternalResourceDialogNameMaxLength": "Name must be less than 255 characters",
|
||||
"createInternalResourceDialogPleaseSelectSite": "Please select a site",
|
||||
"createInternalResourceDialogProxyPortMin": "Proxy port must be at least 1",
|
||||
"createInternalResourceDialogProxyPortMax": "Proxy port must be less than 65536",
|
||||
"createInternalResourceDialogInvalidIPAddressFormat": "Invalid IP address format",
|
||||
"createInternalResourceDialogDestinationPortMin": "Destination port must be at least 1",
|
||||
"createInternalResourceDialogDestinationPortMax": "Destination port must be less than 65536",
|
||||
"siteConfiguration": "Configuration",
|
||||
"siteAcceptClientConnections": "Accept Client Connections",
|
||||
"siteAcceptClientConnectionsDescription": "Allow other devices to connect through this Newt instance as a gateway using clients.",
|
||||
"siteAddress": "Site Address",
|
||||
"siteAddressDescription": "Specify the IP address of the host for clients to connect to.",
|
||||
"autoLoginExternalIdp": "Auto Login with External IDP",
|
||||
"autoLoginExternalIdpDescription": "Immediately redirect the user to the external IDP for authentication.",
|
||||
"selectIdp": "Select IDP",
|
||||
"selectIdpPlaceholder": "Choose an IDP...",
|
||||
"selectIdpRequired": "Please select an IDP when auto login is enabled.",
|
||||
"autoLoginTitle": "Redirecting",
|
||||
"autoLoginDescription": "Redirecting you to the external identity provider for authentication.",
|
||||
"autoLoginProcessing": "Preparing authentication...",
|
||||
"autoLoginRedirecting": "Redirecting to login...",
|
||||
"autoLoginError": "Auto Login Error",
|
||||
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
|
||||
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL."
|
||||
}
|
||||
|
|
|
@ -833,6 +833,24 @@
|
|||
"pincodeRequirementsLength": "El PIN debe tener exactamente 6 dígitos",
|
||||
"pincodeRequirementsChars": "El PIN sólo debe contener números",
|
||||
"passwordRequirementsLength": "La contraseña debe tener al menos 1 carácter",
|
||||
"passwordRequirementsTitle": "Password requirements:",
|
||||
"passwordRequirementLength": "At least 8 characters long",
|
||||
"passwordRequirementUppercase": "At least one uppercase letter",
|
||||
"passwordRequirementLowercase": "At least one lowercase letter",
|
||||
"passwordRequirementNumber": "At least one number",
|
||||
"passwordRequirementSpecial": "At least one special character",
|
||||
"passwordRequirementsMet": "✓ Password meets all requirements",
|
||||
"passwordStrength": "Password strength",
|
||||
"passwordStrengthWeak": "Weak",
|
||||
"passwordStrengthMedium": "Medium",
|
||||
"passwordStrengthStrong": "Strong",
|
||||
"passwordRequirements": "Requirements:",
|
||||
"passwordRequirementLengthText": "8+ characters",
|
||||
"passwordRequirementUppercaseText": "Uppercase letter (A-Z)",
|
||||
"passwordRequirementLowercaseText": "Lowercase letter (a-z)",
|
||||
"passwordRequirementNumberText": "Number (0-9)",
|
||||
"passwordRequirementSpecialText": "Special character (!@#$%...)",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"otpEmailRequirementsLength": "OTP debe tener al menos 1 carácter",
|
||||
"otpEmailSent": "OTP enviado",
|
||||
"otpEmailSentDescription": "Un OTP ha sido enviado a tu correo electrónico",
|
||||
|
@ -967,6 +985,9 @@
|
|||
"actionDeleteSite": "Eliminar sitio",
|
||||
"actionGetSite": "Obtener sitio",
|
||||
"actionListSites": "Listar sitios",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenPlaceholder": "Enter the setup token from the server console",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
"actionUpdateSite": "Actualizar sitio",
|
||||
"actionListSiteRoles": "Lista de roles permitidos del sitio",
|
||||
"actionCreateResource": "Crear Recurso",
|
||||
|
|
|
@ -833,6 +833,24 @@
|
|||
"pincodeRequirementsLength": "Le code PIN doit comporter exactement 6 chiffres",
|
||||
"pincodeRequirementsChars": "Le code PIN ne doit contenir que des chiffres",
|
||||
"passwordRequirementsLength": "Le mot de passe doit comporter au moins 1 caractère",
|
||||
"passwordRequirementsTitle": "Password requirements:",
|
||||
"passwordRequirementLength": "At least 8 characters long",
|
||||
"passwordRequirementUppercase": "At least one uppercase letter",
|
||||
"passwordRequirementLowercase": "At least one lowercase letter",
|
||||
"passwordRequirementNumber": "At least one number",
|
||||
"passwordRequirementSpecial": "At least one special character",
|
||||
"passwordRequirementsMet": "✓ Password meets all requirements",
|
||||
"passwordStrength": "Password strength",
|
||||
"passwordStrengthWeak": "Weak",
|
||||
"passwordStrengthMedium": "Medium",
|
||||
"passwordStrengthStrong": "Strong",
|
||||
"passwordRequirements": "Requirements:",
|
||||
"passwordRequirementLengthText": "8+ characters",
|
||||
"passwordRequirementUppercaseText": "Uppercase letter (A-Z)",
|
||||
"passwordRequirementLowercaseText": "Lowercase letter (a-z)",
|
||||
"passwordRequirementNumberText": "Number (0-9)",
|
||||
"passwordRequirementSpecialText": "Special character (!@#$%...)",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"otpEmailRequirementsLength": "L'OTP doit comporter au moins 1 caractère",
|
||||
"otpEmailSent": "OTP envoyé",
|
||||
"otpEmailSentDescription": "Un OTP a été envoyé à votre e-mail",
|
||||
|
@ -967,6 +985,9 @@
|
|||
"actionDeleteSite": "Supprimer un site",
|
||||
"actionGetSite": "Obtenir un site",
|
||||
"actionListSites": "Lister les sites",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenPlaceholder": "Enter the setup token from the server console",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
"actionUpdateSite": "Mettre à jour un site",
|
||||
"actionListSiteRoles": "Lister les rôles autorisés du site",
|
||||
"actionCreateResource": "Créer une ressource",
|
||||
|
|
|
@ -833,6 +833,24 @@
|
|||
"pincodeRequirementsLength": "Il PIN deve essere esattamente di 6 cifre",
|
||||
"pincodeRequirementsChars": "Il PIN deve contenere solo numeri",
|
||||
"passwordRequirementsLength": "La password deve essere lunga almeno 1 carattere",
|
||||
"passwordRequirementsTitle": "Password requirements:",
|
||||
"passwordRequirementLength": "At least 8 characters long",
|
||||
"passwordRequirementUppercase": "At least one uppercase letter",
|
||||
"passwordRequirementLowercase": "At least one lowercase letter",
|
||||
"passwordRequirementNumber": "At least one number",
|
||||
"passwordRequirementSpecial": "At least one special character",
|
||||
"passwordRequirementsMet": "✓ Password meets all requirements",
|
||||
"passwordStrength": "Password strength",
|
||||
"passwordStrengthWeak": "Weak",
|
||||
"passwordStrengthMedium": "Medium",
|
||||
"passwordStrengthStrong": "Strong",
|
||||
"passwordRequirements": "Requirements:",
|
||||
"passwordRequirementLengthText": "8+ characters",
|
||||
"passwordRequirementUppercaseText": "Uppercase letter (A-Z)",
|
||||
"passwordRequirementLowercaseText": "Lowercase letter (a-z)",
|
||||
"passwordRequirementNumberText": "Number (0-9)",
|
||||
"passwordRequirementSpecialText": "Special character (!@#$%...)",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"otpEmailRequirementsLength": "L'OTP deve essere lungo almeno 1 carattere",
|
||||
"otpEmailSent": "OTP Inviato",
|
||||
"otpEmailSentDescription": "Un OTP è stato inviato alla tua email",
|
||||
|
@ -967,6 +985,9 @@
|
|||
"actionDeleteSite": "Elimina Sito",
|
||||
"actionGetSite": "Ottieni Sito",
|
||||
"actionListSites": "Elenca Siti",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenPlaceholder": "Enter the setup token from the server console",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
"actionUpdateSite": "Aggiorna Sito",
|
||||
"actionListSiteRoles": "Elenca Ruoli Sito Consentiti",
|
||||
"actionCreateResource": "Crea Risorsa",
|
||||
|
|
|
@ -833,6 +833,24 @@
|
|||
"pincodeRequirementsLength": "PIN은 정확히 6자리여야 합니다",
|
||||
"pincodeRequirementsChars": "PIN은 숫자만 포함해야 합니다.",
|
||||
"passwordRequirementsLength": "비밀번호는 최소 1자 이상이어야 합니다",
|
||||
"passwordRequirementsTitle": "Password requirements:",
|
||||
"passwordRequirementLength": "At least 8 characters long",
|
||||
"passwordRequirementUppercase": "At least one uppercase letter",
|
||||
"passwordRequirementLowercase": "At least one lowercase letter",
|
||||
"passwordRequirementNumber": "At least one number",
|
||||
"passwordRequirementSpecial": "At least one special character",
|
||||
"passwordRequirementsMet": "✓ Password meets all requirements",
|
||||
"passwordStrength": "Password strength",
|
||||
"passwordStrengthWeak": "Weak",
|
||||
"passwordStrengthMedium": "Medium",
|
||||
"passwordStrengthStrong": "Strong",
|
||||
"passwordRequirements": "Requirements:",
|
||||
"passwordRequirementLengthText": "8+ characters",
|
||||
"passwordRequirementUppercaseText": "Uppercase letter (A-Z)",
|
||||
"passwordRequirementLowercaseText": "Lowercase letter (a-z)",
|
||||
"passwordRequirementNumberText": "Number (0-9)",
|
||||
"passwordRequirementSpecialText": "Special character (!@#$%...)",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"otpEmailRequirementsLength": "OTP는 최소 1자 이상이어야 합니다",
|
||||
"otpEmailSent": "OTP 전송됨",
|
||||
"otpEmailSentDescription": "OTP가 귀하의 이메일로 전송되었습니다.",
|
||||
|
@ -967,6 +985,9 @@
|
|||
"actionDeleteSite": "사이트 삭제",
|
||||
"actionGetSite": "사이트 가져오기",
|
||||
"actionListSites": "사이트 목록",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenPlaceholder": "Enter the setup token from the server console",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
"actionUpdateSite": "사이트 업데이트",
|
||||
"actionListSiteRoles": "허용된 사이트 역할 목록",
|
||||
"actionCreateResource": "리소스 생성",
|
||||
|
|
|
@ -833,6 +833,24 @@
|
|||
"pincodeRequirementsLength": "PIN må være nøyaktig 6 siffer",
|
||||
"pincodeRequirementsChars": "PIN må kun inneholde tall",
|
||||
"passwordRequirementsLength": "Passord må være minst 1 tegn langt",
|
||||
"passwordRequirementsTitle": "Password requirements:",
|
||||
"passwordRequirementLength": "At least 8 characters long",
|
||||
"passwordRequirementUppercase": "At least one uppercase letter",
|
||||
"passwordRequirementLowercase": "At least one lowercase letter",
|
||||
"passwordRequirementNumber": "At least one number",
|
||||
"passwordRequirementSpecial": "At least one special character",
|
||||
"passwordRequirementsMet": "✓ Password meets all requirements",
|
||||
"passwordStrength": "Password strength",
|
||||
"passwordStrengthWeak": "Weak",
|
||||
"passwordStrengthMedium": "Medium",
|
||||
"passwordStrengthStrong": "Strong",
|
||||
"passwordRequirements": "Requirements:",
|
||||
"passwordRequirementLengthText": "8+ characters",
|
||||
"passwordRequirementUppercaseText": "Uppercase letter (A-Z)",
|
||||
"passwordRequirementLowercaseText": "Lowercase letter (a-z)",
|
||||
"passwordRequirementNumberText": "Number (0-9)",
|
||||
"passwordRequirementSpecialText": "Special character (!@#$%...)",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"otpEmailRequirementsLength": "OTP må være minst 1 tegn lang.",
|
||||
"otpEmailSent": "OTP sendt",
|
||||
"otpEmailSentDescription": "En OTP er sendt til din e-post",
|
||||
|
@ -967,6 +985,9 @@
|
|||
"actionDeleteSite": "Slett område",
|
||||
"actionGetSite": "Hent område",
|
||||
"actionListSites": "List opp områder",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenPlaceholder": "Enter the setup token from the server console",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
"actionUpdateSite": "Oppdater område",
|
||||
"actionListSiteRoles": "List opp tillatte områderoller",
|
||||
"actionCreateResource": "Opprett ressurs",
|
||||
|
|
|
@ -833,6 +833,24 @@
|
|||
"pincodeRequirementsLength": "Pincode moet precies 6 cijfers zijn",
|
||||
"pincodeRequirementsChars": "Pincode mag alleen cijfers bevatten",
|
||||
"passwordRequirementsLength": "Wachtwoord moet ten minste 1 teken lang zijn",
|
||||
"passwordRequirementsTitle": "Password requirements:",
|
||||
"passwordRequirementLength": "At least 8 characters long",
|
||||
"passwordRequirementUppercase": "At least one uppercase letter",
|
||||
"passwordRequirementLowercase": "At least one lowercase letter",
|
||||
"passwordRequirementNumber": "At least one number",
|
||||
"passwordRequirementSpecial": "At least one special character",
|
||||
"passwordRequirementsMet": "✓ Password meets all requirements",
|
||||
"passwordStrength": "Password strength",
|
||||
"passwordStrengthWeak": "Weak",
|
||||
"passwordStrengthMedium": "Medium",
|
||||
"passwordStrengthStrong": "Strong",
|
||||
"passwordRequirements": "Requirements:",
|
||||
"passwordRequirementLengthText": "8+ characters",
|
||||
"passwordRequirementUppercaseText": "Uppercase letter (A-Z)",
|
||||
"passwordRequirementLowercaseText": "Lowercase letter (a-z)",
|
||||
"passwordRequirementNumberText": "Number (0-9)",
|
||||
"passwordRequirementSpecialText": "Special character (!@#$%...)",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"otpEmailRequirementsLength": "OTP moet minstens 1 teken lang zijn",
|
||||
"otpEmailSent": "OTP verzonden",
|
||||
"otpEmailSentDescription": "Een OTP is naar uw e-mail verzonden",
|
||||
|
@ -967,6 +985,9 @@
|
|||
"actionDeleteSite": "Site verwijderen",
|
||||
"actionGetSite": "Site ophalen",
|
||||
"actionListSites": "Sites weergeven",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenPlaceholder": "Enter the setup token from the server console",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
"actionUpdateSite": "Site bijwerken",
|
||||
"actionListSiteRoles": "Toon toegestane sitenollen",
|
||||
"actionCreateResource": "Bron maken",
|
||||
|
|
|
@ -833,6 +833,24 @@
|
|||
"pincodeRequirementsLength": "PIN musi składać się dokładnie z 6 cyfr",
|
||||
"pincodeRequirementsChars": "PIN może zawierać tylko cyfry",
|
||||
"passwordRequirementsLength": "Hasło musi mieć co najmniej 1 znak",
|
||||
"passwordRequirementsTitle": "Password requirements:",
|
||||
"passwordRequirementLength": "At least 8 characters long",
|
||||
"passwordRequirementUppercase": "At least one uppercase letter",
|
||||
"passwordRequirementLowercase": "At least one lowercase letter",
|
||||
"passwordRequirementNumber": "At least one number",
|
||||
"passwordRequirementSpecial": "At least one special character",
|
||||
"passwordRequirementsMet": "✓ Password meets all requirements",
|
||||
"passwordStrength": "Password strength",
|
||||
"passwordStrengthWeak": "Weak",
|
||||
"passwordStrengthMedium": "Medium",
|
||||
"passwordStrengthStrong": "Strong",
|
||||
"passwordRequirements": "Requirements:",
|
||||
"passwordRequirementLengthText": "8+ characters",
|
||||
"passwordRequirementUppercaseText": "Uppercase letter (A-Z)",
|
||||
"passwordRequirementLowercaseText": "Lowercase letter (a-z)",
|
||||
"passwordRequirementNumberText": "Number (0-9)",
|
||||
"passwordRequirementSpecialText": "Special character (!@#$%...)",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"otpEmailRequirementsLength": "Kod jednorazowy musi mieć co najmniej 1 znak",
|
||||
"otpEmailSent": "Kod jednorazowy wysłany",
|
||||
"otpEmailSentDescription": "Kod jednorazowy został wysłany na Twój e-mail",
|
||||
|
@ -967,6 +985,9 @@
|
|||
"actionDeleteSite": "Usuń witrynę",
|
||||
"actionGetSite": "Pobierz witrynę",
|
||||
"actionListSites": "Lista witryn",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenPlaceholder": "Enter the setup token from the server console",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
"actionUpdateSite": "Aktualizuj witrynę",
|
||||
"actionListSiteRoles": "Lista dozwolonych ról witryny",
|
||||
"actionCreateResource": "Utwórz zasób",
|
||||
|
|
|
@ -833,6 +833,24 @@
|
|||
"pincodeRequirementsLength": "O PIN deve ter exatamente 6 dígitos",
|
||||
"pincodeRequirementsChars": "O PIN deve conter apenas números",
|
||||
"passwordRequirementsLength": "A palavra-passe deve ter pelo menos 1 caractere",
|
||||
"passwordRequirementsTitle": "Password requirements:",
|
||||
"passwordRequirementLength": "At least 8 characters long",
|
||||
"passwordRequirementUppercase": "At least one uppercase letter",
|
||||
"passwordRequirementLowercase": "At least one lowercase letter",
|
||||
"passwordRequirementNumber": "At least one number",
|
||||
"passwordRequirementSpecial": "At least one special character",
|
||||
"passwordRequirementsMet": "✓ Password meets all requirements",
|
||||
"passwordStrength": "Password strength",
|
||||
"passwordStrengthWeak": "Weak",
|
||||
"passwordStrengthMedium": "Medium",
|
||||
"passwordStrengthStrong": "Strong",
|
||||
"passwordRequirements": "Requirements:",
|
||||
"passwordRequirementLengthText": "8+ characters",
|
||||
"passwordRequirementUppercaseText": "Uppercase letter (A-Z)",
|
||||
"passwordRequirementLowercaseText": "Lowercase letter (a-z)",
|
||||
"passwordRequirementNumberText": "Number (0-9)",
|
||||
"passwordRequirementSpecialText": "Special character (!@#$%...)",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"otpEmailRequirementsLength": "O OTP deve ter pelo menos 1 caractere",
|
||||
"otpEmailSent": "OTP Enviado",
|
||||
"otpEmailSentDescription": "Um OTP foi enviado para o seu email",
|
||||
|
@ -967,6 +985,9 @@
|
|||
"actionDeleteSite": "Eliminar Site",
|
||||
"actionGetSite": "Obter Site",
|
||||
"actionListSites": "Listar Sites",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenPlaceholder": "Enter the setup token from the server console",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
"actionUpdateSite": "Atualizar Site",
|
||||
"actionListSiteRoles": "Listar Funções Permitidas do Site",
|
||||
"actionCreateResource": "Criar Recurso",
|
||||
|
|
|
@ -833,6 +833,24 @@
|
|||
"pincodeRequirementsLength": "PIN должен состоять ровно из 6 цифр",
|
||||
"pincodeRequirementsChars": "PIN должен содержать только цифры",
|
||||
"passwordRequirementsLength": "Пароль должен быть не менее 1 символа",
|
||||
"passwordRequirementsTitle": "Password requirements:",
|
||||
"passwordRequirementLength": "At least 8 characters long",
|
||||
"passwordRequirementUppercase": "At least one uppercase letter",
|
||||
"passwordRequirementLowercase": "At least one lowercase letter",
|
||||
"passwordRequirementNumber": "At least one number",
|
||||
"passwordRequirementSpecial": "At least one special character",
|
||||
"passwordRequirementsMet": "✓ Password meets all requirements",
|
||||
"passwordStrength": "Password strength",
|
||||
"passwordStrengthWeak": "Weak",
|
||||
"passwordStrengthMedium": "Medium",
|
||||
"passwordStrengthStrong": "Strong",
|
||||
"passwordRequirements": "Requirements:",
|
||||
"passwordRequirementLengthText": "8+ characters",
|
||||
"passwordRequirementUppercaseText": "Uppercase letter (A-Z)",
|
||||
"passwordRequirementLowercaseText": "Lowercase letter (a-z)",
|
||||
"passwordRequirementNumberText": "Number (0-9)",
|
||||
"passwordRequirementSpecialText": "Special character (!@#$%...)",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"otpEmailRequirementsLength": "OTP должен быть не менее 1 символа",
|
||||
"otpEmailSent": "OTP отправлен",
|
||||
"otpEmailSentDescription": "OTP был отправлен на ваш email",
|
||||
|
@ -967,6 +985,9 @@
|
|||
"actionDeleteSite": "Удалить сайт",
|
||||
"actionGetSite": "Получить сайт",
|
||||
"actionListSites": "Список сайтов",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenPlaceholder": "Enter the setup token from the server console",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
"actionUpdateSite": "Обновить сайт",
|
||||
"actionListSiteRoles": "Список разрешенных ролей сайта",
|
||||
"actionCreateResource": "Создать ресурс",
|
||||
|
|
|
@ -833,6 +833,24 @@
|
|||
"pincodeRequirementsLength": "PIN kesinlikle 6 haneli olmalıdır",
|
||||
"pincodeRequirementsChars": "PIN sadece numaralardan oluşmalıdır",
|
||||
"passwordRequirementsLength": "Şifre en az 1 karakter uzunluğunda olmalıdır",
|
||||
"passwordRequirementsTitle": "Password requirements:",
|
||||
"passwordRequirementLength": "At least 8 characters long",
|
||||
"passwordRequirementUppercase": "At least one uppercase letter",
|
||||
"passwordRequirementLowercase": "At least one lowercase letter",
|
||||
"passwordRequirementNumber": "At least one number",
|
||||
"passwordRequirementSpecial": "At least one special character",
|
||||
"passwordRequirementsMet": "✓ Password meets all requirements",
|
||||
"passwordStrength": "Password strength",
|
||||
"passwordStrengthWeak": "Weak",
|
||||
"passwordStrengthMedium": "Medium",
|
||||
"passwordStrengthStrong": "Strong",
|
||||
"passwordRequirements": "Requirements:",
|
||||
"passwordRequirementLengthText": "8+ characters",
|
||||
"passwordRequirementUppercaseText": "Uppercase letter (A-Z)",
|
||||
"passwordRequirementLowercaseText": "Lowercase letter (a-z)",
|
||||
"passwordRequirementNumberText": "Number (0-9)",
|
||||
"passwordRequirementSpecialText": "Special character (!@#$%...)",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"otpEmailRequirementsLength": "OTP en az 1 karakter uzunluğunda olmalıdır",
|
||||
"otpEmailSent": "OTP Gönderildi",
|
||||
"otpEmailSentDescription": "E-posta adresinize bir OTP gönderildi",
|
||||
|
@ -967,6 +985,9 @@
|
|||
"actionDeleteSite": "Siteyi Sil",
|
||||
"actionGetSite": "Siteyi Al",
|
||||
"actionListSites": "Siteleri Listele",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenPlaceholder": "Enter the setup token from the server console",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
"actionUpdateSite": "Siteyi Güncelle",
|
||||
"actionListSiteRoles": "İzin Verilen Site Rolleri Listele",
|
||||
"actionCreateResource": "Kaynak Oluştur",
|
||||
|
|
|
@ -833,6 +833,24 @@
|
|||
"pincodeRequirementsLength": "PIN码必须是6位数字",
|
||||
"pincodeRequirementsChars": "PIN 必须只包含数字",
|
||||
"passwordRequirementsLength": "密码必须至少 1 个字符长",
|
||||
"passwordRequirementsTitle": "Password requirements:",
|
||||
"passwordRequirementLength": "At least 8 characters long",
|
||||
"passwordRequirementUppercase": "At least one uppercase letter",
|
||||
"passwordRequirementLowercase": "At least one lowercase letter",
|
||||
"passwordRequirementNumber": "At least one number",
|
||||
"passwordRequirementSpecial": "At least one special character",
|
||||
"passwordRequirementsMet": "✓ Password meets all requirements",
|
||||
"passwordStrength": "Password strength",
|
||||
"passwordStrengthWeak": "Weak",
|
||||
"passwordStrengthMedium": "Medium",
|
||||
"passwordStrengthStrong": "Strong",
|
||||
"passwordRequirements": "Requirements:",
|
||||
"passwordRequirementLengthText": "8+ characters",
|
||||
"passwordRequirementUppercaseText": "Uppercase letter (A-Z)",
|
||||
"passwordRequirementLowercaseText": "Lowercase letter (a-z)",
|
||||
"passwordRequirementNumberText": "Number (0-9)",
|
||||
"passwordRequirementSpecialText": "Special character (!@#$%...)",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"otpEmailRequirementsLength": "OTP 必须至少 1 个字符长",
|
||||
"otpEmailSent": "OTP 已发送",
|
||||
"otpEmailSentDescription": "OTP 已经发送到您的电子邮件",
|
||||
|
@ -967,6 +985,9 @@
|
|||
"actionDeleteSite": "删除站点",
|
||||
"actionGetSite": "获取站点",
|
||||
"actionListSites": "站点列表",
|
||||
"setupToken": "Setup Token",
|
||||
"setupTokenPlaceholder": "Enter the setup token from the server console",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
"actionUpdateSite": "更新站点",
|
||||
"actionListSiteRoles": "允许站点角色列表",
|
||||
"actionCreateResource": "创建资源",
|
||||
|
|
3219
package-lock.json
generated
3219
package-lock.json
generated
File diff suppressed because it is too large
Load diff
25
package.json
25
package.json
|
@ -52,9 +52,9 @@
|
|||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@react-email/components": "0.5.0",
|
||||
"@react-email/render": "^1.2.0",
|
||||
"@react-email/tailwind": "1.2.2",
|
||||
"@simplewebauthn/browser": "^13.1.0",
|
||||
"@simplewebauthn/server": "^9.0.3",
|
||||
"@react-email/tailwind": "1.2.2",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"arctic": "^3.7.0",
|
||||
|
@ -70,9 +70,9 @@
|
|||
"cors": "2.8.5",
|
||||
"crypto-js": "^4.2.0",
|
||||
"drizzle-orm": "0.44.4",
|
||||
"eslint": "9.32.0",
|
||||
"eslint": "9.33.0",
|
||||
"eslint-config-next": "15.4.6",
|
||||
"express": "4.21.2",
|
||||
"express": "5.1.0",
|
||||
"express-rate-limit": "7.5.1",
|
||||
"glob": "11.0.3",
|
||||
"helmet": "8.1.0",
|
||||
|
@ -82,7 +82,7 @@
|
|||
"jmespath": "^0.16.0",
|
||||
"js-yaml": "4.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "0.536.0",
|
||||
"lucide-react": "0.539.0",
|
||||
"moment": "2.30.1",
|
||||
"next": "15.4.6",
|
||||
"next-intl": "^4.3.4",
|
||||
|
@ -93,6 +93,7 @@
|
|||
"npm": "^11.5.2",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "^8.16.2",
|
||||
"posthog-node": "^5.7.0",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
|
@ -109,9 +110,9 @@
|
|||
"winston": "3.17.0",
|
||||
"winston-daily-rotate-file": "5.0.0",
|
||||
"ws": "8.18.3",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "3.25.76",
|
||||
"zod-validation-error": "3.5.2",
|
||||
"yargs": "18.0.0"
|
||||
"zod-validation-error": "3.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "1.48.4",
|
||||
|
@ -121,30 +122,30 @@
|
|||
"@types/cookie-parser": "1.4.9",
|
||||
"@types/cors": "2.8.19",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/express": "5.0.0",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/express-session": "^1.18.2",
|
||||
"@types/jmespath": "^0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24",
|
||||
"@types/nodemailer": "6.4.17",
|
||||
"@types/pg": "8.15.4",
|
||||
"@types/react": "19.1.9",
|
||||
"@types/pg": "8.15.5",
|
||||
"@types/react": "19.1.10",
|
||||
"@types/react-dom": "19.1.7",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/yargs": "17.0.33",
|
||||
"drizzle-kit": "0.31.4",
|
||||
"esbuild": "0.25.6",
|
||||
"esbuild": "0.25.9",
|
||||
"esbuild-node-externals": "1.18.0",
|
||||
"postcss": "^8",
|
||||
"react-email": "4.2.8",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsx": "4.20.3",
|
||||
"tsx": "4.20.4",
|
||||
"typescript": "^5",
|
||||
"typescript-eslint": "^8.39.0"
|
||||
"typescript-eslint": "^8.39.1"
|
||||
},
|
||||
"overrides": {
|
||||
"emblor": {
|
||||
|
|
|
@ -69,6 +69,11 @@ export enum ActionsEnum {
|
|||
deleteResourceRule = "deleteResourceRule",
|
||||
listResourceRules = "listResourceRules",
|
||||
updateResourceRule = "updateResourceRule",
|
||||
createSiteResource = "createSiteResource",
|
||||
deleteSiteResource = "deleteSiteResource",
|
||||
getSiteResource = "getSiteResource",
|
||||
listSiteResources = "listSiteResources",
|
||||
updateSiteResource = "updateSiteResource",
|
||||
createClient = "createClient",
|
||||
deleteClient = "deleteClient",
|
||||
updateClient = "updateClient",
|
||||
|
|
|
@ -66,11 +66,6 @@ export const sites = pgTable("sites", {
|
|||
|
||||
export const resources = pgTable("resources", {
|
||||
resourceId: serial("resourceId").primaryKey(),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
|
@ -96,7 +91,10 @@ export const resources = pgTable("resources", {
|
|||
stickySession: boolean("stickySession").notNull().default(false),
|
||||
tlsServerName: varchar("tlsServerName"),
|
||||
setHostHeader: varchar("setHostHeader"),
|
||||
enableProxy: boolean("enableProxy").default(true)
|
||||
enableProxy: boolean("enableProxy").default(true),
|
||||
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
});
|
||||
|
||||
export const targets = pgTable("targets", {
|
||||
|
@ -106,6 +104,11 @@ export const targets = pgTable("targets", {
|
|||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
ip: varchar("ip").notNull(),
|
||||
method: varchar("method"),
|
||||
port: integer("port").notNull(),
|
||||
|
@ -127,6 +130,22 @@ export const exitNodes = pgTable("exitNodes", {
|
|||
type: text("type").default("gerbil") // gerbil, remoteExitNode
|
||||
});
|
||||
|
||||
export const siteResources = pgTable("siteResources", { // this is for the clients
|
||||
siteResourceId: serial("siteResourceId").primaryKey(),
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, { onDelete: "cascade" }),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
name: varchar("name").notNull(),
|
||||
protocol: varchar("protocol").notNull(),
|
||||
proxyPort: integer("proxyPort").notNull(),
|
||||
destinationPort: integer("destinationPort").notNull(),
|
||||
destinationIp: varchar("destinationIp").notNull(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
});
|
||||
|
||||
export const users = pgTable("user", {
|
||||
userId: varchar("id").primaryKey(),
|
||||
email: varchar("email"),
|
||||
|
@ -539,6 +558,7 @@ export const olms = pgTable("olms", {
|
|||
olmId: varchar("id").primaryKey(),
|
||||
secretHash: varchar("secretHash").notNull(),
|
||||
dateCreated: varchar("dateCreated").notNull(),
|
||||
version: text("version"),
|
||||
clientId: integer("clientId").references(() => clients.clientId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
|
@ -596,6 +616,14 @@ export const webauthnChallenge = pgTable("webauthnChallenge", {
|
|||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull() // Unix timestamp
|
||||
});
|
||||
|
||||
export const setupTokens = pgTable("setupTokens", {
|
||||
tokenId: varchar("tokenId").primaryKey(),
|
||||
token: varchar("token").notNull(),
|
||||
used: boolean("used").notNull().default(false),
|
||||
dateCreated: varchar("dateCreated").notNull(),
|
||||
dateUsed: varchar("dateUsed")
|
||||
});
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
|
@ -641,3 +669,6 @@ export type OlmSession = InferSelectModel<typeof olmSessions>;
|
|||
export type UserClient = InferSelectModel<typeof userClients>;
|
||||
export type RoleClient = InferSelectModel<typeof roleClients>;
|
||||
export type OrgDomains = InferSelectModel<typeof orgDomains>;
|
||||
export type SiteResource = InferSelectModel<typeof siteResources>;
|
||||
export type SetupToken = InferSelectModel<typeof setupTokens>;
|
||||
export type HostMeta = InferSelectModel<typeof hostMeta>;
|
||||
|
|
|
@ -67,16 +67,11 @@ export const sites = sqliteTable("sites", {
|
|||
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
remoteSubnets: text("remoteSubnets"), // comma-separated list of subnets that this site can access
|
||||
remoteSubnets: text("remoteSubnets") // comma-separated list of subnets that this site can access
|
||||
});
|
||||
|
||||
export const resources = sqliteTable("resources", {
|
||||
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
|
@ -109,6 +104,9 @@ export const resources = sqliteTable("resources", {
|
|||
tlsServerName: text("tlsServerName"),
|
||||
setHostHeader: text("setHostHeader"),
|
||||
enableProxy: integer("enableProxy", { mode: "boolean" }).default(true),
|
||||
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
});
|
||||
|
||||
export const targets = sqliteTable("targets", {
|
||||
|
@ -118,6 +116,11 @@ export const targets = sqliteTable("targets", {
|
|||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
ip: text("ip").notNull(),
|
||||
method: text("method"),
|
||||
port: integer("port").notNull(),
|
||||
|
@ -139,6 +142,22 @@ export const exitNodes = sqliteTable("exitNodes", {
|
|||
type: text("type").default("gerbil") // gerbil, remoteExitNode
|
||||
});
|
||||
|
||||
export const siteResources = sqliteTable("siteResources", { // this is for the clients
|
||||
siteResourceId: integer("siteResourceId").primaryKey({ autoIncrement: true }),
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, { onDelete: "cascade" }),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
protocol: text("protocol").notNull(),
|
||||
proxyPort: integer("proxyPort").notNull(),
|
||||
destinationPort: integer("destinationPort").notNull(),
|
||||
destinationIp: text("destinationIp").notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
});
|
||||
|
||||
export const users = sqliteTable("user", {
|
||||
userId: text("id").primaryKey(),
|
||||
email: text("email"),
|
||||
|
@ -169,7 +188,9 @@ export const users = sqliteTable("user", {
|
|||
|
||||
export const securityKeys = sqliteTable("webauthnCredentials", {
|
||||
credentialId: text("credentialId").primaryKey(),
|
||||
userId: text("userId").notNull().references(() => users.userId, {
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
publicKey: text("publicKey").notNull(),
|
||||
|
@ -190,6 +211,14 @@ export const webauthnChallenge = sqliteTable("webauthnChallenge", {
|
|||
expiresAt: integer("expiresAt").notNull() // Unix timestamp
|
||||
});
|
||||
|
||||
export const setupTokens = sqliteTable("setupTokens", {
|
||||
tokenId: text("tokenId").primaryKey(),
|
||||
token: text("token").notNull(),
|
||||
used: integer("used", { mode: "boolean" }).notNull().default(false),
|
||||
dateCreated: text("dateCreated").notNull(),
|
||||
dateUsed: text("dateUsed")
|
||||
});
|
||||
|
||||
export const newts = sqliteTable("newt", {
|
||||
newtId: text("id").primaryKey(),
|
||||
secretHash: text("secretHash").notNull(),
|
||||
|
@ -238,6 +267,7 @@ export const olms = sqliteTable("olms", {
|
|||
olmId: text("id").primaryKey(),
|
||||
secretHash: text("secretHash").notNull(),
|
||||
dateCreated: text("dateCreated").notNull(),
|
||||
version: text("version"),
|
||||
clientId: integer("clientId").references(() => clients.clientId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
|
@ -682,4 +712,7 @@ export type Idp = InferSelectModel<typeof idp>;
|
|||
export type ApiKey = InferSelectModel<typeof apiKeys>;
|
||||
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
|
||||
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
|
||||
export type SiteResource = InferSelectModel<typeof siteResources>;
|
||||
export type OrgDomains = InferSelectModel<typeof orgDomains>;
|
||||
export type SetupToken = InferSelectModel<typeof setupTokens>;
|
||||
export type HostMeta = InferSelectModel<typeof hostMeta>;
|
||||
|
|
|
@ -9,11 +9,17 @@ import { ApiKey, ApiKeyOrg, Session, User, UserOrg } from "@server/db";
|
|||
import { createIntegrationApiServer } from "./integrationApiServer";
|
||||
import { createHybridClientServer } from "./hybridServer";
|
||||
import config from "@server/lib/config";
|
||||
import { setHostMeta } from "@server/lib/hostMeta";
|
||||
import { initTelemetryClient } from "./lib/telemetry.js";
|
||||
|
||||
async function startServers() {
|
||||
await setHostMeta();
|
||||
|
||||
await config.initServer();
|
||||
await runSetupFunctions();
|
||||
|
||||
initTelemetryClient();
|
||||
|
||||
// Start all servers
|
||||
const apiServer = createApiServer();
|
||||
const internalServer = createInternalServer();
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import { db } from "@server/db";
|
||||
import { db, HostMeta } from "@server/db";
|
||||
import { hostMeta } from "@server/db";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
let gotHostMeta: HostMeta | undefined;
|
||||
|
||||
export async function setHostMeta() {
|
||||
const [existing] = await db.select().from(hostMeta).limit(1);
|
||||
|
||||
|
@ -15,3 +17,12 @@ export async function setHostMeta() {
|
|||
.insert(hostMeta)
|
||||
.values({ hostMetaId: id, createdAt: new Date().getTime() });
|
||||
}
|
||||
|
||||
export async function getHostMeta() {
|
||||
if (gotHostMeta) {
|
||||
return gotHostMeta;
|
||||
}
|
||||
const [meta] = await db.select().from(hostMeta).limit(1);
|
||||
gotHostMeta = meta;
|
||||
return meta;
|
||||
}
|
|
@ -3,8 +3,6 @@ import yaml from "js-yaml";
|
|||
import { configFilePath1, configFilePath2 } from "./consts";
|
||||
import { z } from "zod";
|
||||
import stoi from "./stoi";
|
||||
import { build } from "@server/build";
|
||||
import { setAdminCredentials } from "@cli/commands/setAdminCredentials";
|
||||
|
||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||
|
||||
|
@ -26,7 +24,13 @@ export const configSchema = z
|
|||
.optional()
|
||||
.default("info"),
|
||||
save_logs: z.boolean().optional().default(false),
|
||||
log_failed_attempts: z.boolean().optional().default(false)
|
||||
log_failed_attempts: z.boolean().optional().default(false),
|
||||
telmetry: z
|
||||
.object({
|
||||
anonymous_usage: z.boolean().optional().default(true)
|
||||
})
|
||||
.optional()
|
||||
.default({})
|
||||
}),
|
||||
hybrid: z
|
||||
.object({
|
||||
|
|
295
server/lib/telemetry.ts
Normal file
295
server/lib/telemetry.ts
Normal file
|
@ -0,0 +1,295 @@
|
|||
import { PostHog } from "posthog-node";
|
||||
import config from "./config";
|
||||
import { getHostMeta } from "./hostMeta";
|
||||
import logger from "@server/logger";
|
||||
import { apiKeys, db, roles } from "@server/db";
|
||||
import { sites, users, orgs, resources, clients, idp } from "@server/db";
|
||||
import { eq, count, notInArray } from "drizzle-orm";
|
||||
import { APP_VERSION } from "./consts";
|
||||
import crypto from "crypto";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
class TelemetryClient {
|
||||
private client: PostHog | null = null;
|
||||
private enabled: boolean;
|
||||
private intervalId: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor() {
|
||||
const enabled = config.getRawConfig().app.telmetry.anonymous_usage;
|
||||
this.enabled = enabled;
|
||||
const dev = process.env.ENVIRONMENT !== "prod";
|
||||
|
||||
if (this.enabled && !dev) {
|
||||
this.client = new PostHog(
|
||||
"phc_QYuATSSZt6onzssWcYJbXLzQwnunIpdGGDTYhzK3VjX",
|
||||
{
|
||||
host: "https://digpangolin.com/relay-O7yI"
|
||||
}
|
||||
);
|
||||
|
||||
process.on("exit", () => {
|
||||
this.client?.shutdown();
|
||||
});
|
||||
|
||||
this.sendStartupEvents().catch((err) => {
|
||||
logger.error("Failed to send startup telemetry:", err);
|
||||
});
|
||||
|
||||
this.startAnalyticsInterval();
|
||||
|
||||
logger.info(
|
||||
"Pangolin now gathers anonymous usage data to help us better understand how the software is used and guide future improvements and feature development. You can find more details, including instructions for opting out of this anonymous data collection, at: https://docs.digpangolin.com/telemetry"
|
||||
);
|
||||
} else if (!this.enabled && !dev) {
|
||||
logger.info(
|
||||
"Analytics usage statistics collection is disabled. If you enable this, you can help us make Pangolin better for everyone. Learn more at: https://docs.digpangolin.com/telemetry"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private startAnalyticsInterval() {
|
||||
this.intervalId = setInterval(
|
||||
() => {
|
||||
this.collectAndSendAnalytics().catch((err) => {
|
||||
logger.error("Failed to collect analytics:", err);
|
||||
});
|
||||
},
|
||||
6 * 60 * 60 * 1000
|
||||
);
|
||||
|
||||
this.collectAndSendAnalytics().catch((err) => {
|
||||
logger.error("Failed to collect initial analytics:", err);
|
||||
});
|
||||
}
|
||||
|
||||
private anon(value: string): string {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(value.toLowerCase())
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
private async getSystemStats() {
|
||||
try {
|
||||
const [sitesCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(sites);
|
||||
const [usersCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(users);
|
||||
const [usersInternalCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(users)
|
||||
.where(eq(users.type, UserType.Internal));
|
||||
const [usersOidcCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(users)
|
||||
.where(eq(users.type, UserType.OIDC));
|
||||
const [orgsCount] = await db.select({ count: count() }).from(orgs);
|
||||
const [resourcesCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(resources);
|
||||
const [clientsCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(clients);
|
||||
const [idpCount] = await db.select({ count: count() }).from(idp);
|
||||
const [onlineSitesCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(sites)
|
||||
.where(eq(sites.online, true));
|
||||
const [numApiKeys] = await db
|
||||
.select({ count: count() })
|
||||
.from(apiKeys);
|
||||
const [customRoles] = await db
|
||||
.select({ count: count() })
|
||||
.from(roles)
|
||||
.where(notInArray(roles.name, ["Admin", "Member"]));
|
||||
|
||||
const adminUsers = await db
|
||||
.select({ email: users.email })
|
||||
.from(users)
|
||||
.where(eq(users.serverAdmin, true));
|
||||
|
||||
const resourceDetails = await db
|
||||
.select({
|
||||
name: resources.name,
|
||||
sso: resources.sso,
|
||||
protocol: resources.protocol,
|
||||
http: resources.http
|
||||
})
|
||||
.from(resources);
|
||||
|
||||
const siteDetails = await db
|
||||
.select({
|
||||
siteName: sites.name,
|
||||
megabytesIn: sites.megabytesIn,
|
||||
megabytesOut: sites.megabytesOut,
|
||||
type: sites.type,
|
||||
online: sites.online
|
||||
})
|
||||
.from(sites);
|
||||
|
||||
const supporterKey = config.getSupporterData();
|
||||
|
||||
return {
|
||||
numSites: sitesCount.count,
|
||||
numUsers: usersCount.count,
|
||||
numUsersInternal: usersInternalCount.count,
|
||||
numUsersOidc: usersOidcCount.count,
|
||||
numOrganizations: orgsCount.count,
|
||||
numResources: resourcesCount.count,
|
||||
numClients: clientsCount.count,
|
||||
numIdentityProviders: idpCount.count,
|
||||
numSitesOnline: onlineSitesCount.count,
|
||||
resources: resourceDetails,
|
||||
adminUsers: adminUsers.map((u) => u.email),
|
||||
sites: siteDetails,
|
||||
appVersion: APP_VERSION,
|
||||
numApiKeys: numApiKeys.count,
|
||||
numCustomRoles: customRoles.count,
|
||||
supporterStatus: {
|
||||
valid: supporterKey?.valid || false,
|
||||
tier: supporterKey?.tier || "None",
|
||||
githubUsername: supporterKey?.githubUsername || null
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Failed to collect system stats:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendStartupEvents() {
|
||||
if (!this.enabled || !this.client) return;
|
||||
|
||||
const hostMeta = await getHostMeta();
|
||||
if (!hostMeta) return;
|
||||
|
||||
const stats = await this.getSystemStats();
|
||||
|
||||
this.client.capture({
|
||||
distinctId: hostMeta.hostMetaId,
|
||||
event: "supporter_status",
|
||||
properties: {
|
||||
valid: stats.supporterStatus.valid,
|
||||
tier: stats.supporterStatus.tier,
|
||||
github_username: stats.supporterStatus.githubUsername
|
||||
? this.anon(stats.supporterStatus.githubUsername)
|
||||
: "None"
|
||||
}
|
||||
});
|
||||
|
||||
this.client.capture({
|
||||
distinctId: hostMeta.hostMetaId,
|
||||
event: "host_startup",
|
||||
properties: {
|
||||
host_id: hostMeta.hostMetaId,
|
||||
app_version: stats.appVersion,
|
||||
install_timestamp: hostMeta.createdAt
|
||||
}
|
||||
});
|
||||
|
||||
for (const email of stats.adminUsers) {
|
||||
// There should only be on admin user, but just in case
|
||||
if (email) {
|
||||
this.client.capture({
|
||||
distinctId: this.anon(email),
|
||||
event: "admin_user",
|
||||
properties: {
|
||||
host_id: hostMeta.hostMetaId,
|
||||
app_version: stats.appVersion,
|
||||
hashed_email: this.anon(email)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async collectAndSendAnalytics() {
|
||||
if (!this.enabled || !this.client) return;
|
||||
|
||||
try {
|
||||
const hostMeta = await getHostMeta();
|
||||
if (!hostMeta) {
|
||||
logger.warn(
|
||||
"Telemetry: Host meta not found, skipping analytics"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = await this.getSystemStats();
|
||||
|
||||
this.client.capture({
|
||||
distinctId: hostMeta.hostMetaId,
|
||||
event: "system_analytics",
|
||||
properties: {
|
||||
app_version: stats.appVersion,
|
||||
num_sites: stats.numSites,
|
||||
num_users: stats.numUsers,
|
||||
num_users_internal: stats.numUsersInternal,
|
||||
num_users_oidc: stats.numUsersOidc,
|
||||
num_organizations: stats.numOrganizations,
|
||||
num_resources: stats.numResources,
|
||||
num_clients: stats.numClients,
|
||||
num_identity_providers: stats.numIdentityProviders,
|
||||
num_sites_online: stats.numSitesOnline,
|
||||
resources: stats.resources.map((r) => ({
|
||||
name: this.anon(r.name),
|
||||
sso_enabled: r.sso,
|
||||
protocol: r.protocol,
|
||||
http_enabled: r.http
|
||||
})),
|
||||
sites: stats.sites.map((s) => ({
|
||||
site_name: this.anon(s.siteName),
|
||||
megabytes_in: s.megabytesIn,
|
||||
megabytes_out: s.megabytesOut,
|
||||
type: s.type,
|
||||
online: s.online
|
||||
})),
|
||||
num_api_keys: stats.numApiKeys,
|
||||
num_custom_roles: stats.numCustomRoles
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to send analytics:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async sendTelemetry(eventName: string, properties: Record<string, any>) {
|
||||
if (!this.enabled || !this.client) return;
|
||||
|
||||
const hostMeta = await getHostMeta();
|
||||
if (!hostMeta) {
|
||||
logger.warn("Telemetry: Host meta not found, skipping telemetry");
|
||||
return;
|
||||
}
|
||||
|
||||
this.client.groupIdentify({
|
||||
groupType: "host_id",
|
||||
groupKey: hostMeta.hostMetaId,
|
||||
properties
|
||||
});
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
|
||||
if (this.enabled && this.client) {
|
||||
this.client.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let telemetryClient!: TelemetryClient;
|
||||
|
||||
export function initTelemetryClient() {
|
||||
if (!telemetryClient) {
|
||||
telemetryClient = new TelemetryClient();
|
||||
}
|
||||
return telemetryClient;
|
||||
}
|
||||
|
||||
export default telemetryClient;
|
|
@ -5,7 +5,7 @@ import NodeCache from "node-cache";
|
|||
import { validateJWT } from "./licenseJwt";
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import moment from "moment";
|
||||
import { setHostMeta } from "@server/setup/setHostMeta";
|
||||
import { setHostMeta } from "@server/lib/hostMeta";
|
||||
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||
|
||||
const keyTypes = ["HOST", "SITES"] as const;
|
||||
|
|
|
@ -3,6 +3,7 @@ import config from "@server/lib/config";
|
|||
import * as winston from "winston";
|
||||
import path from "path";
|
||||
import { APP_PATH } from "./lib/consts";
|
||||
import telemetryClient from "./lib/telemetry";
|
||||
|
||||
const hformat = winston.format.printf(
|
||||
({ level, label, message, timestamp, stack, ...metadata }) => {
|
||||
|
|
|
@ -27,3 +27,4 @@ export * from "./verifyApiKeyAccess";
|
|||
export * from "./verifyDomainAccess";
|
||||
export * from "./verifyClientsEnabled";
|
||||
export * from "./verifyUserIsOrgOwner";
|
||||
export * from "./verifySiteResourceAccess";
|
||||
|
|
62
server/middlewares/verifySiteResourceAccess.ts
Normal file
62
server/middlewares/verifySiteResourceAccess.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { siteResources } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export async function verifySiteResourceAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const siteResourceId = parseInt(req.params.siteResourceId);
|
||||
const siteId = parseInt(req.params.siteId);
|
||||
const orgId = req.params.orgId;
|
||||
|
||||
if (!siteResourceId || !siteId || !orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Missing required parameters"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the site resource exists and belongs to the specified site and org
|
||||
const [siteResource] = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(and(
|
||||
eq(siteResources.siteResourceId, siteResourceId),
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (!siteResource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"Site resource not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Attach the siteResource to the request for use in the next middleware/route
|
||||
// @ts-ignore - Extending Request type
|
||||
req.siteResource = siteResource;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error("Error verifying site resource access:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying site resource access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -15,7 +15,7 @@ export async function createNextServer() {
|
|||
|
||||
const nextServer = express();
|
||||
|
||||
nextServer.all("*", (req, res) => {
|
||||
nextServer.all("/{*splat}", (req, res) => {
|
||||
const parsedUrl = parse(req.url!, true);
|
||||
return handle(req, res, parsedUrl);
|
||||
});
|
||||
|
|
|
@ -10,6 +10,7 @@ export * from "./resetPassword";
|
|||
export * from "./requestPasswordReset";
|
||||
export * from "./setServerAdmin";
|
||||
export * from "./initialSetupComplete";
|
||||
export * from "./validateSetupToken";
|
||||
export * from "./changePassword";
|
||||
export * from "./checkResourceSession";
|
||||
export * from "./securityKey";
|
||||
|
|
|
@ -8,14 +8,15 @@ import logger from "@server/logger";
|
|||
import { hashPassword } from "@server/auth/password";
|
||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
import { response } from "@server/lib";
|
||||
import { db, users } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, users, setupTokens } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import moment from "moment";
|
||||
|
||||
export const bodySchema = z.object({
|
||||
email: z.string().toLowerCase().email(),
|
||||
password: passwordSchema
|
||||
password: passwordSchema,
|
||||
setupToken: z.string().min(1, "Setup token is required")
|
||||
});
|
||||
|
||||
export type SetServerAdminBody = z.infer<typeof bodySchema>;
|
||||
|
@ -39,7 +40,27 @@ export async function setServerAdmin(
|
|||
);
|
||||
}
|
||||
|
||||
const { email, password } = parsedBody.data;
|
||||
const { email, password, setupToken } = parsedBody.data;
|
||||
|
||||
// Validate setup token
|
||||
const [validToken] = await db
|
||||
.select()
|
||||
.from(setupTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(setupTokens.token, setupToken),
|
||||
eq(setupTokens.used, false)
|
||||
)
|
||||
);
|
||||
|
||||
if (!validToken) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid or expired setup token"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
|
@ -58,7 +79,18 @@ export async function setServerAdmin(
|
|||
const passwordHash = await hashPassword(password);
|
||||
const userId = generateId(15);
|
||||
|
||||
await db.insert(users).values({
|
||||
await db.transaction(async (trx) => {
|
||||
// Mark the token as used
|
||||
await trx
|
||||
.update(setupTokens)
|
||||
.set({
|
||||
used: true,
|
||||
dateUsed: moment().toISOString()
|
||||
})
|
||||
.where(eq(setupTokens.tokenId, validToken.tokenId));
|
||||
|
||||
// Create the server admin user
|
||||
await trx.insert(users).values({
|
||||
userId: userId,
|
||||
email: email,
|
||||
type: UserType.Internal,
|
||||
|
@ -68,6 +100,7 @@ export async function setServerAdmin(
|
|||
serverAdmin: true,
|
||||
emailVerified: true
|
||||
});
|
||||
});
|
||||
|
||||
return response<SetServerAdminResponse>(res, {
|
||||
data: null,
|
||||
|
|
84
server/routers/auth/validateSetupToken.ts
Normal file
84
server/routers/auth/validateSetupToken.ts
Normal file
|
@ -0,0 +1,84 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, setupTokens } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const validateSetupTokenSchema = z
|
||||
.object({
|
||||
token: z.string().min(1, "Token is required")
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ValidateSetupTokenResponse = {
|
||||
valid: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export async function validateSetupToken(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedBody = validateSetupTokenSchema.safeParse(req.body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { token } = parsedBody.data;
|
||||
|
||||
// Find the token in the database
|
||||
const [setupToken] = await db
|
||||
.select()
|
||||
.from(setupTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(setupTokens.token, token),
|
||||
eq(setupTokens.used, false)
|
||||
)
|
||||
);
|
||||
|
||||
if (!setupToken) {
|
||||
return response<ValidateSetupTokenResponse>(res, {
|
||||
data: {
|
||||
valid: false,
|
||||
message: "Invalid or expired setup token"
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Token validation completed",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
|
||||
return response<ValidateSetupTokenResponse>(res, {
|
||||
data: {
|
||||
valid: true,
|
||||
message: "Setup token is valid"
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Token validation completed",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to validate setup token"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
39
server/routers/client/targets.ts
Normal file
39
server/routers/client/targets.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { sendToClient } from "../ws";
|
||||
|
||||
export async function addTargets(
|
||||
newtId: string,
|
||||
destinationIp: string,
|
||||
destinationPort: number,
|
||||
protocol: string,
|
||||
port: number | null = null
|
||||
) {
|
||||
const target = `${port ? port + ":" : ""}${
|
||||
destinationIp
|
||||
}:${destinationPort}`;
|
||||
|
||||
await sendToClient(newtId, {
|
||||
type: `newt/wg/${protocol}/add`,
|
||||
data: {
|
||||
targets: [target] // We can only use one target for WireGuard right now
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeTargets(
|
||||
newtId: string,
|
||||
destinationIp: string,
|
||||
destinationPort: number,
|
||||
protocol: string,
|
||||
port: number | null = null
|
||||
) {
|
||||
const target = `${port ? port + ":" : ""}${
|
||||
destinationIp
|
||||
}:${destinationPort}`;
|
||||
|
||||
await sendToClient(newtId, {
|
||||
type: `newt/wg/${protocol}/remove`,
|
||||
data: {
|
||||
targets: [target] // We can only use one target for WireGuard right now
|
||||
}
|
||||
});
|
||||
}
|
|
@ -9,6 +9,7 @@ import * as user from "./user";
|
|||
import * as auth from "./auth";
|
||||
import * as role from "./role";
|
||||
import * as client from "./client";
|
||||
import * as siteResource from "./siteResource";
|
||||
import * as supporterKey from "./supporterKey";
|
||||
import * as accessToken from "./accessToken";
|
||||
import * as idp from "./idp";
|
||||
|
@ -34,7 +35,8 @@ import {
|
|||
verifyDomainAccess,
|
||||
verifyClientsEnabled,
|
||||
verifyUserHasAction,
|
||||
verifyUserIsOrgOwner
|
||||
verifyUserIsOrgOwner,
|
||||
verifySiteResourceAccess
|
||||
} from "@server/middlewares";
|
||||
import { createStore } from "@server/lib/rateLimitStore";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
|
@ -213,9 +215,60 @@ authenticated.get(
|
|||
site.listContainers
|
||||
);
|
||||
|
||||
// Site Resource endpoints
|
||||
authenticated.put(
|
||||
"/org/:orgId/site/:siteId/resource",
|
||||
verifyOrgAccess,
|
||||
verifySiteAccess,
|
||||
verifyUserHasAction(ActionsEnum.createSiteResource),
|
||||
siteResource.createSiteResource
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/site/:siteId/resources",
|
||||
verifyOrgAccess,
|
||||
verifySiteAccess,
|
||||
verifyUserHasAction(ActionsEnum.listSiteResources),
|
||||
siteResource.listSiteResources
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/site-resources",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.listSiteResources),
|
||||
siteResource.listAllSiteResourcesByOrg
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/site/:siteId/resource/:siteResourceId",
|
||||
verifyOrgAccess,
|
||||
verifySiteAccess,
|
||||
verifySiteResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.getSiteResource),
|
||||
siteResource.getSiteResource
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/site/:siteId/resource/:siteResourceId",
|
||||
verifyOrgAccess,
|
||||
verifySiteAccess,
|
||||
verifySiteResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateSiteResource),
|
||||
siteResource.updateSiteResource
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/site/:siteId/resource/:siteResourceId",
|
||||
verifyOrgAccess,
|
||||
verifySiteAccess,
|
||||
verifySiteResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteSiteResource),
|
||||
siteResource.deleteSiteResource
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/resource",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.createResource),
|
||||
resource.createResource
|
||||
);
|
||||
|
@ -397,28 +450,6 @@ authenticated.post(
|
|||
user.addUserRole
|
||||
);
|
||||
|
||||
// authenticated.put(
|
||||
// "/role/:roleId/site",
|
||||
// verifyRoleAccess,
|
||||
// verifyUserInRole,
|
||||
// verifyUserHasAction(ActionsEnum.addRoleSite),
|
||||
// role.addRoleSite
|
||||
// );
|
||||
// authenticated.delete(
|
||||
// "/role/:roleId/site",
|
||||
// verifyRoleAccess,
|
||||
// verifyUserInRole,
|
||||
// verifyUserHasAction(ActionsEnum.removeRoleSite),
|
||||
// role.removeRoleSite
|
||||
// );
|
||||
// authenticated.get(
|
||||
// "/role/:roleId/sites",
|
||||
// verifyRoleAccess,
|
||||
// verifyUserInRole,
|
||||
// verifyUserHasAction(ActionsEnum.listRoleSites),
|
||||
// role.listRoleSites
|
||||
// );
|
||||
|
||||
authenticated.post(
|
||||
"/resource/:resourceId/roles",
|
||||
verifyResourceAccess,
|
||||
|
@ -463,13 +494,6 @@ authenticated.get(
|
|||
resource.getResourceWhitelist
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/transfer`,
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateResource),
|
||||
resource.transferResource
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/access-token`,
|
||||
verifyResourceAccess,
|
||||
|
@ -1033,6 +1057,7 @@ authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback);
|
|||
|
||||
authRouter.put("/set-server-admin", auth.setServerAdmin);
|
||||
authRouter.get("/initial-setup-complete", auth.initialSetupComplete);
|
||||
authRouter.post("/validate-setup-token", auth.validateSetupToken);
|
||||
|
||||
// Security Key routes
|
||||
authRouter.post(
|
||||
|
|
|
@ -341,13 +341,6 @@ authenticated.get(
|
|||
resource.getResourceWhitelist
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/transfer`,
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateResource),
|
||||
resource.transferResource
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/access-token`,
|
||||
verifyApiKeyResourceAccess,
|
||||
|
|
|
@ -207,32 +207,8 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
|||
// Filter out any null values from peers that didn't have an olm
|
||||
const validPeers = peers.filter((peer) => peer !== null);
|
||||
|
||||
// Improved version
|
||||
const allResources = await db.transaction(async (tx) => {
|
||||
// First get all resources for the site
|
||||
const resourcesList = await tx
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
subdomain: resources.subdomain,
|
||||
fullDomain: resources.fullDomain,
|
||||
ssl: resources.ssl,
|
||||
blockAccess: resources.blockAccess,
|
||||
sso: resources.sso,
|
||||
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
||||
http: resources.http,
|
||||
proxyPort: resources.proxyPort,
|
||||
protocol: resources.protocol
|
||||
})
|
||||
.from(resources)
|
||||
.where(
|
||||
and(eq(resources.siteId, siteId), eq(resources.http, false))
|
||||
);
|
||||
|
||||
// Get all enabled targets for these resources in a single query
|
||||
const resourceIds = resourcesList.map((r) => r.resourceId);
|
||||
const allTargets =
|
||||
resourceIds.length > 0
|
||||
? await tx
|
||||
// Get all enabled targets with their resource protocol information
|
||||
const allTargets = await db
|
||||
.select({
|
||||
resourceId: targets.resourceId,
|
||||
targetId: targets.targetId,
|
||||
|
@ -240,47 +216,28 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
|||
method: targets.method,
|
||||
port: targets.port,
|
||||
internalPort: targets.internalPort,
|
||||
enabled: targets.enabled
|
||||
enabled: targets.enabled,
|
||||
protocol: resources.protocol
|
||||
})
|
||||
.from(targets)
|
||||
.where(
|
||||
and(
|
||||
inArray(targets.resourceId, resourceIds),
|
||||
eq(targets.enabled, true)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||
.where(and(eq(targets.siteId, siteId), eq(targets.enabled, true)));
|
||||
|
||||
// Combine the data in JS instead of using SQL for the JSON
|
||||
return resourcesList.map((resource) => ({
|
||||
...resource,
|
||||
targets: allTargets.filter(
|
||||
(target) => target.resourceId === resource.resourceId
|
||||
)
|
||||
}));
|
||||
});
|
||||
const { tcpTargets, udpTargets } = allTargets.reduce(
|
||||
(acc, target) => {
|
||||
// Filter out invalid targets
|
||||
if (!target.internalPort || !target.ip || !target.port) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const { tcpTargets, udpTargets } = allResources.reduce(
|
||||
(acc, resource) => {
|
||||
// Skip resources with no targets
|
||||
if (!resource.targets?.length) return acc;
|
||||
|
||||
// Format valid targets into strings
|
||||
const formattedTargets = resource.targets
|
||||
.filter(
|
||||
(target: Target) =>
|
||||
resource.proxyPort && target?.ip && target?.port
|
||||
)
|
||||
.map(
|
||||
(target: Target) =>
|
||||
`${resource.proxyPort}:${target.ip}:${target.port}`
|
||||
);
|
||||
// Format target into string
|
||||
const formattedTarget = `${target.internalPort}:${target.ip}:${target.port}`;
|
||||
|
||||
// Add to the appropriate protocol array
|
||||
if (resource.protocol === "tcp") {
|
||||
acc.tcpTargets.push(...formattedTargets);
|
||||
if (target.protocol === "tcp") {
|
||||
acc.tcpTargets.push(formattedTarget);
|
||||
} else {
|
||||
acc.udpTargets.push(...formattedTargets);
|
||||
acc.udpTargets.push(formattedTarget);
|
||||
}
|
||||
|
||||
return acc;
|
||||
|
|
|
@ -169,30 +169,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||
.where(eq(newts.newtId, newt.newtId));
|
||||
}
|
||||
|
||||
// Improved version
|
||||
const allResources = await db.transaction(async (tx) => {
|
||||
// First get all resources for the site
|
||||
const resourcesList = await tx
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
subdomain: resources.subdomain,
|
||||
fullDomain: resources.fullDomain,
|
||||
ssl: resources.ssl,
|
||||
blockAccess: resources.blockAccess,
|
||||
sso: resources.sso,
|
||||
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
||||
http: resources.http,
|
||||
proxyPort: resources.proxyPort,
|
||||
protocol: resources.protocol
|
||||
})
|
||||
.from(resources)
|
||||
.where(eq(resources.siteId, siteId));
|
||||
|
||||
// Get all enabled targets for these resources in a single query
|
||||
const resourceIds = resourcesList.map((r) => r.resourceId);
|
||||
const allTargets =
|
||||
resourceIds.length > 0
|
||||
? await tx
|
||||
// Get all enabled targets with their resource protocol information
|
||||
const allTargets = await db
|
||||
.select({
|
||||
resourceId: targets.resourceId,
|
||||
targetId: targets.targetId,
|
||||
|
@ -200,47 +178,28 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||
method: targets.method,
|
||||
port: targets.port,
|
||||
internalPort: targets.internalPort,
|
||||
enabled: targets.enabled
|
||||
enabled: targets.enabled,
|
||||
protocol: resources.protocol
|
||||
})
|
||||
.from(targets)
|
||||
.where(
|
||||
and(
|
||||
inArray(targets.resourceId, resourceIds),
|
||||
eq(targets.enabled, true)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||
.where(and(eq(targets.siteId, siteId), eq(targets.enabled, true)));
|
||||
|
||||
// Combine the data in JS instead of using SQL for the JSON
|
||||
return resourcesList.map((resource) => ({
|
||||
...resource,
|
||||
targets: allTargets.filter(
|
||||
(target) => target.resourceId === resource.resourceId
|
||||
)
|
||||
}));
|
||||
});
|
||||
const { tcpTargets, udpTargets } = allTargets.reduce(
|
||||
(acc, target) => {
|
||||
// Filter out invalid targets
|
||||
if (!target.internalPort || !target.ip || !target.port) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const { tcpTargets, udpTargets } = allResources.reduce(
|
||||
(acc, resource) => {
|
||||
// Skip resources with no targets
|
||||
if (!resource.targets?.length) return acc;
|
||||
|
||||
// Format valid targets into strings
|
||||
const formattedTargets = resource.targets
|
||||
.filter(
|
||||
(target: Target) =>
|
||||
target?.internalPort && target?.ip && target?.port
|
||||
)
|
||||
.map(
|
||||
(target: Target) =>
|
||||
`${target.internalPort}:${target.ip}:${target.port}`
|
||||
);
|
||||
// Format target into string
|
||||
const formattedTarget = `${target.internalPort}:${target.ip}:${target.port}`;
|
||||
|
||||
// Add to the appropriate protocol array
|
||||
if (resource.protocol === "tcp") {
|
||||
acc.tcpTargets.push(...formattedTargets);
|
||||
if (target.protocol === "tcp") {
|
||||
acc.tcpTargets.push(formattedTarget);
|
||||
} else {
|
||||
acc.udpTargets.push(...formattedTargets);
|
||||
acc.udpTargets.push(formattedTarget);
|
||||
}
|
||||
|
||||
return acc;
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { Target } from "@server/db";
|
||||
import { sendToClient } from "../ws";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export function addTargets(
|
||||
export async function addTargets(
|
||||
newtId: string,
|
||||
targets: Target[],
|
||||
protocol: string,
|
||||
|
@ -20,22 +21,9 @@ export function addTargets(
|
|||
targets: payloadTargets
|
||||
}
|
||||
});
|
||||
|
||||
const payloadTargetsResources = targets.map((target) => {
|
||||
return `${port ? port + ":" : ""}${
|
||||
target.ip
|
||||
}:${target.port}`;
|
||||
});
|
||||
|
||||
sendToClient(newtId, {
|
||||
type: `newt/wg/${protocol}/add`,
|
||||
data: {
|
||||
targets: [payloadTargetsResources[0]] // We can only use one target for WireGuard right now
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function removeTargets(
|
||||
export async function removeTargets(
|
||||
newtId: string,
|
||||
targets: Target[],
|
||||
protocol: string,
|
||||
|
@ -48,23 +36,10 @@ export function removeTargets(
|
|||
}:${target.port}`;
|
||||
});
|
||||
|
||||
sendToClient(newtId, {
|
||||
await sendToClient(newtId, {
|
||||
type: `newt/${protocol}/remove`,
|
||||
data: {
|
||||
targets: payloadTargets
|
||||
}
|
||||
});
|
||||
|
||||
const payloadTargetsResources = targets.map((target) => {
|
||||
return `${port ? port + ":" : ""}${
|
||||
target.ip
|
||||
}:${target.port}`;
|
||||
});
|
||||
|
||||
sendToClient(newtId, {
|
||||
type: `newt/wg/${protocol}/remove`,
|
||||
data: {
|
||||
targets: [payloadTargetsResources[0]] // We can only use one target for WireGuard right now
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -22,7 +22,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||
return;
|
||||
}
|
||||
const clientId = olm.clientId;
|
||||
const { publicKey, relay } = message.data;
|
||||
const { publicKey, relay, olmVersion } = message.data;
|
||||
|
||||
logger.debug(
|
||||
`Olm client ID: ${clientId}, Public Key: ${publicKey}, Relay: ${relay}`
|
||||
|
@ -66,15 +66,27 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||
}
|
||||
});
|
||||
|
||||
if (!olmVersion) {
|
||||
// THIS IS FOR BACKWARDS COMPATIBILITY
|
||||
// THE OLDER CLIENTS DID NOT SEND THE VERSION
|
||||
await sendToClient(olm.olmId, {
|
||||
type: "olm/wg/holepunch/all",
|
||||
type: "olm/wg/holepunch",
|
||||
data: {
|
||||
serverPubKey: allExitNodes[0].publicKey,
|
||||
endpoint: allExitNodes[0].endpoint
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (olmVersion) {
|
||||
await db
|
||||
.update(olms)
|
||||
.set({
|
||||
version: olmVersion
|
||||
})
|
||||
.where(eq(olms.olmId, olm.olmId));
|
||||
}
|
||||
|
||||
if (now - (client.lastHolePunch || 0) > 6) {
|
||||
logger.warn("Client last hole punch is too old, skipping all sites");
|
||||
|
|
|
@ -15,7 +15,6 @@ import response from "@server/lib/response";
|
|||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import stoi from "@server/lib/stoi";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { subdomainSchema } from "@server/lib/schemas";
|
||||
|
@ -25,7 +24,6 @@ import { build } from "@server/build";
|
|||
|
||||
const createResourceParamsSchema = z
|
||||
.object({
|
||||
siteId: z.string().transform(stoi).pipe(z.number().int().positive()),
|
||||
orgId: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
@ -34,7 +32,6 @@ const createHttpResourceSchema = z
|
|||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
subdomain: z.string().nullable().optional(),
|
||||
siteId: z.number(),
|
||||
http: z.boolean(),
|
||||
protocol: z.enum(["tcp", "udp"]),
|
||||
domainId: z.string()
|
||||
|
@ -53,11 +50,10 @@ const createHttpResourceSchema = z
|
|||
const createRawResourceSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
siteId: z.number(),
|
||||
http: z.boolean(),
|
||||
protocol: z.enum(["tcp", "udp"]),
|
||||
proxyPort: z.number().int().min(1).max(65535),
|
||||
enableProxy: z.boolean().default(true)
|
||||
// enableProxy: z.boolean().default(true) // always true now
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
|
@ -78,7 +74,7 @@ export type CreateResourceResponse = Resource;
|
|||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/org/{orgId}/site/{siteId}/resource",
|
||||
path: "/org/{orgId}/resource",
|
||||
description: "Create a resource.",
|
||||
tags: [OpenAPITags.Org, OpenAPITags.Resource],
|
||||
request: {
|
||||
|
@ -111,7 +107,7 @@ export async function createResource(
|
|||
);
|
||||
}
|
||||
|
||||
const { siteId, orgId } = parsedParams.data;
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
if (req.user && !req.userOrgRoleId) {
|
||||
return next(
|
||||
|
@ -146,7 +142,7 @@ export async function createResource(
|
|||
if (http) {
|
||||
return await createHttpResource(
|
||||
{ req, res, next },
|
||||
{ siteId, orgId }
|
||||
{ orgId }
|
||||
);
|
||||
} else {
|
||||
if (
|
||||
|
@ -162,7 +158,7 @@ export async function createResource(
|
|||
}
|
||||
return await createRawResource(
|
||||
{ req, res, next },
|
||||
{ siteId, orgId }
|
||||
{ orgId }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
|
@ -180,12 +176,11 @@ async function createHttpResource(
|
|||
next: NextFunction;
|
||||
},
|
||||
meta: {
|
||||
siteId: number;
|
||||
orgId: string;
|
||||
}
|
||||
) {
|
||||
const { req, res, next } = route;
|
||||
const { siteId, orgId } = meta;
|
||||
const { orgId } = meta;
|
||||
|
||||
const parsedBody = createHttpResourceSchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
|
@ -292,7 +287,6 @@ async function createHttpResource(
|
|||
const newResource = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
siteId,
|
||||
fullDomain,
|
||||
domainId,
|
||||
orgId,
|
||||
|
@ -357,12 +351,11 @@ async function createRawResource(
|
|||
next: NextFunction;
|
||||
},
|
||||
meta: {
|
||||
siteId: number;
|
||||
orgId: string;
|
||||
}
|
||||
) {
|
||||
const { req, res, next } = route;
|
||||
const { siteId, orgId } = meta;
|
||||
const { orgId } = meta;
|
||||
|
||||
const parsedBody = createRawResourceSchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
|
@ -374,7 +367,7 @@ async function createRawResource(
|
|||
);
|
||||
}
|
||||
|
||||
const { name, http, protocol, proxyPort, enableProxy } = parsedBody.data;
|
||||
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
|
||||
|
@ -402,13 +395,12 @@ async function createRawResource(
|
|||
const newResource = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
siteId,
|
||||
orgId,
|
||||
name,
|
||||
http,
|
||||
protocol,
|
||||
proxyPort,
|
||||
enableProxy
|
||||
// enableProxy
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
|
|
@ -71,44 +71,44 @@ export async function deleteResource(
|
|||
);
|
||||
}
|
||||
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, deletedResource.siteId!))
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site with ID ${deletedResource.siteId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (site.pubKey) {
|
||||
if (site.type == "wireguard") {
|
||||
await addPeer(site.exitNodeId!, {
|
||||
publicKey: site.pubKey,
|
||||
allowedIps: await getAllowedIps(site.siteId)
|
||||
});
|
||||
} else if (site.type == "newt") {
|
||||
// get the newt on the site by querying the newt table for siteId
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
|
||||
removeTargets(
|
||||
newt.newtId,
|
||||
targetsToBeRemoved,
|
||||
deletedResource.protocol,
|
||||
deletedResource.proxyPort
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// const [site] = await db
|
||||
// .select()
|
||||
// .from(sites)
|
||||
// .where(eq(sites.siteId, deletedResource.siteId!))
|
||||
// .limit(1);
|
||||
//
|
||||
// if (!site) {
|
||||
// return next(
|
||||
// createHttpError(
|
||||
// HttpCode.NOT_FOUND,
|
||||
// `Site with ID ${deletedResource.siteId} not found`
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// if (site.pubKey) {
|
||||
// if (site.type == "wireguard") {
|
||||
// await addPeer(site.exitNodeId!, {
|
||||
// publicKey: site.pubKey,
|
||||
// allowedIps: await getAllowedIps(site.siteId)
|
||||
// });
|
||||
// } else if (site.type == "newt") {
|
||||
// // get the newt on the site by querying the newt table for siteId
|
||||
// const [newt] = await db
|
||||
// .select()
|
||||
// .from(newts)
|
||||
// .where(eq(newts.siteId, site.siteId))
|
||||
// .limit(1);
|
||||
//
|
||||
// removeTargets(
|
||||
// newt.newtId,
|
||||
// targetsToBeRemoved,
|
||||
// deletedResource.protocol,
|
||||
// deletedResource.proxyPort
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
//
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
|
|
|
@ -19,9 +19,7 @@ const getResourceSchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
export type GetResourceResponse = Resource & {
|
||||
siteName: string;
|
||||
};
|
||||
export type GetResourceResponse = Resource;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
|
@ -56,11 +54,9 @@ export async function getResource(
|
|||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.leftJoin(sites, eq(sites.siteId, resources.siteId))
|
||||
.limit(1);
|
||||
|
||||
const resource = resp.resources;
|
||||
const site = resp.sites;
|
||||
const resource = resp;
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
|
@ -73,8 +69,7 @@ export async function getResource(
|
|||
|
||||
return response(res, {
|
||||
data: {
|
||||
...resource,
|
||||
siteName: site?.name
|
||||
...resource
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
|
|
@ -31,6 +31,7 @@ export type GetResourceAuthInfoResponse = {
|
|||
blockAccess: boolean;
|
||||
url: string;
|
||||
whitelist: boolean;
|
||||
skipToIdpId: number | null;
|
||||
};
|
||||
|
||||
export async function getResourceAuthInfo(
|
||||
|
@ -86,7 +87,8 @@ export async function getResourceAuthInfo(
|
|||
sso: resource.sso,
|
||||
blockAccess: resource.blockAccess,
|
||||
url,
|
||||
whitelist: resource.emailWhitelistEnabled
|
||||
whitelist: resource.emailWhitelistEnabled,
|
||||
skipToIdpId: resource.skipToIdpId
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
|
|
@ -6,11 +6,9 @@ import {
|
|||
userResources,
|
||||
roleResources,
|
||||
userOrgs,
|
||||
roles,
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
resourceWhitelist,
|
||||
sites
|
||||
resourceWhitelist
|
||||
} from "@server/db";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
@ -37,12 +35,7 @@ export async function getUserResources(
|
|||
roleId: userOrgs.roleId
|
||||
})
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, userId),
|
||||
eq(userOrgs.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (userOrgResult.length === 0) {
|
||||
|
@ -71,8 +64,8 @@ export async function getUserResources(
|
|||
|
||||
// Combine all accessible resource IDs
|
||||
const accessibleResourceIds = [
|
||||
...directResources.map(r => r.resourceId),
|
||||
...roleResourceResults.map(r => r.resourceId)
|
||||
...directResources.map((r) => r.resourceId),
|
||||
...roleResourceResults.map((r) => r.resourceId)
|
||||
];
|
||||
|
||||
if (accessibleResourceIds.length === 0) {
|
||||
|
@ -95,11 +88,9 @@ export async function getUserResources(
|
|||
enabled: resources.enabled,
|
||||
sso: resources.sso,
|
||||
protocol: resources.protocol,
|
||||
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
||||
siteName: sites.name
|
||||
emailWhitelistEnabled: resources.emailWhitelistEnabled
|
||||
})
|
||||
.from(resources)
|
||||
.leftJoin(sites, eq(sites.siteId, resources.siteId))
|
||||
.where(
|
||||
and(
|
||||
inArray(resources.resourceId, accessibleResourceIds),
|
||||
|
@ -111,28 +102,61 @@ export async function getUserResources(
|
|||
// Check for password, pincode, and whitelist protection for each resource
|
||||
const resourcesWithAuth = await Promise.all(
|
||||
resourcesData.map(async (resource) => {
|
||||
const [passwordCheck, pincodeCheck, whitelistCheck] = await Promise.all([
|
||||
db.select().from(resourcePassword).where(eq(resourcePassword.resourceId, resource.resourceId)).limit(1),
|
||||
db.select().from(resourcePincode).where(eq(resourcePincode.resourceId, resource.resourceId)).limit(1),
|
||||
db.select().from(resourceWhitelist).where(eq(resourceWhitelist.resourceId, resource.resourceId)).limit(1)
|
||||
const [passwordCheck, pincodeCheck, whitelistCheck] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(resourcePassword)
|
||||
.where(
|
||||
eq(
|
||||
resourcePassword.resourceId,
|
||||
resource.resourceId
|
||||
)
|
||||
)
|
||||
.limit(1),
|
||||
db
|
||||
.select()
|
||||
.from(resourcePincode)
|
||||
.where(
|
||||
eq(
|
||||
resourcePincode.resourceId,
|
||||
resource.resourceId
|
||||
)
|
||||
)
|
||||
.limit(1),
|
||||
db
|
||||
.select()
|
||||
.from(resourceWhitelist)
|
||||
.where(
|
||||
eq(
|
||||
resourceWhitelist.resourceId,
|
||||
resource.resourceId
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
]);
|
||||
|
||||
const hasPassword = passwordCheck.length > 0;
|
||||
const hasPincode = pincodeCheck.length > 0;
|
||||
const hasWhitelist = whitelistCheck.length > 0 || resource.emailWhitelistEnabled;
|
||||
const hasWhitelist =
|
||||
whitelistCheck.length > 0 || resource.emailWhitelistEnabled;
|
||||
|
||||
return {
|
||||
resourceId: resource.resourceId,
|
||||
name: resource.name,
|
||||
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
|
||||
enabled: resource.enabled,
|
||||
protected: !!(resource.sso || hasPassword || hasPincode || hasWhitelist),
|
||||
protected: !!(
|
||||
resource.sso ||
|
||||
hasPassword ||
|
||||
hasPincode ||
|
||||
hasWhitelist
|
||||
),
|
||||
protocol: resource.protocol,
|
||||
sso: resource.sso,
|
||||
password: hasPassword,
|
||||
pincode: hasPincode,
|
||||
whitelist: hasWhitelist,
|
||||
siteName: resource.siteName
|
||||
whitelist: hasWhitelist
|
||||
};
|
||||
})
|
||||
);
|
||||
|
@ -144,11 +168,13 @@ export async function getUserResources(
|
|||
message: "User resources retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error fetching user resources:", error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Internal server error")
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Internal server error"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,7 +16,6 @@ export * from "./setResourceWhitelist";
|
|||
export * from "./getResourceWhitelist";
|
||||
export * from "./authWithWhitelist";
|
||||
export * from "./authWithAccessToken";
|
||||
export * from "./transferResource";
|
||||
export * from "./getExchangeToken";
|
||||
export * from "./createResourceRule";
|
||||
export * from "./deleteResourceRule";
|
||||
|
|
|
@ -3,7 +3,6 @@ import { z } from "zod";
|
|||
import { db } from "@server/db";
|
||||
import {
|
||||
resources,
|
||||
sites,
|
||||
userResources,
|
||||
roleResources,
|
||||
resourcePassword,
|
||||
|
@ -20,17 +19,9 @@ import { OpenAPITags, registry } from "@server/openApi";
|
|||
|
||||
const listResourcesParamsSchema = z
|
||||
.object({
|
||||
siteId: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(stoi)
|
||||
.pipe(z.number().int().positive().optional()),
|
||||
orgId: z.string().optional()
|
||||
orgId: z.string()
|
||||
})
|
||||
.strict()
|
||||
.refine((data) => !!data.siteId !== !!data.orgId, {
|
||||
message: "Either siteId or orgId must be provided, but not both"
|
||||
});
|
||||
.strict();
|
||||
|
||||
const listResourcesSchema = z.object({
|
||||
limit: z
|
||||
|
@ -48,55 +39,13 @@ const listResourcesSchema = z.object({
|
|||
.pipe(z.number().int().nonnegative())
|
||||
});
|
||||
|
||||
function queryResources(
|
||||
accessibleResourceIds: number[],
|
||||
siteId?: number,
|
||||
orgId?: string
|
||||
) {
|
||||
if (siteId) {
|
||||
return db
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
name: resources.name,
|
||||
fullDomain: resources.fullDomain,
|
||||
ssl: resources.ssl,
|
||||
siteName: sites.name,
|
||||
siteId: sites.niceId,
|
||||
passwordId: resourcePassword.passwordId,
|
||||
pincodeId: resourcePincode.pincodeId,
|
||||
sso: resources.sso,
|
||||
whitelist: resources.emailWhitelistEnabled,
|
||||
http: resources.http,
|
||||
protocol: resources.protocol,
|
||||
proxyPort: resources.proxyPort,
|
||||
enabled: resources.enabled,
|
||||
domainId: resources.domainId
|
||||
})
|
||||
.from(resources)
|
||||
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
||||
.leftJoin(
|
||||
resourcePassword,
|
||||
eq(resourcePassword.resourceId, resources.resourceId)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePincode,
|
||||
eq(resourcePincode.resourceId, resources.resourceId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(resources.resourceId, accessibleResourceIds),
|
||||
eq(resources.siteId, siteId)
|
||||
)
|
||||
);
|
||||
} else if (orgId) {
|
||||
function queryResources(accessibleResourceIds: number[], orgId: string) {
|
||||
return db
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
name: resources.name,
|
||||
ssl: resources.ssl,
|
||||
fullDomain: resources.fullDomain,
|
||||
siteName: sites.name,
|
||||
siteId: sites.niceId,
|
||||
passwordId: resourcePassword.passwordId,
|
||||
sso: resources.sso,
|
||||
pincodeId: resourcePincode.pincodeId,
|
||||
|
@ -108,7 +57,6 @@ function queryResources(
|
|||
domainId: resources.domainId
|
||||
})
|
||||
.from(resources)
|
||||
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
||||
.leftJoin(
|
||||
resourcePassword,
|
||||
eq(resourcePassword.resourceId, resources.resourceId)
|
||||
|
@ -123,7 +71,6 @@ function queryResources(
|
|||
eq(resources.orgId, orgId)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type ListResourcesResponse = {
|
||||
|
@ -131,20 +78,6 @@ export type ListResourcesResponse = {
|
|||
pagination: { total: number; limit: number; offset: number };
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/site/{siteId}/resources",
|
||||
description: "List resources for a site.",
|
||||
tags: [OpenAPITags.Site, OpenAPITags.Resource],
|
||||
request: {
|
||||
params: z.object({
|
||||
siteId: z.number()
|
||||
}),
|
||||
query: listResourcesSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/resources",
|
||||
|
@ -185,9 +118,11 @@ export async function listResources(
|
|||
)
|
||||
);
|
||||
}
|
||||
const { siteId } = parsedParams.data;
|
||||
|
||||
const orgId = parsedParams.data.orgId || req.userOrg?.orgId || req.apiKeyOrg?.orgId;
|
||||
const orgId =
|
||||
parsedParams.data.orgId ||
|
||||
req.userOrg?.orgId ||
|
||||
req.apiKeyOrg?.orgId;
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
|
@ -222,9 +157,12 @@ export async function listResources(
|
|||
)
|
||||
);
|
||||
} else {
|
||||
accessibleResources = await db.select({
|
||||
accessibleResources = await db
|
||||
.select({
|
||||
resourceId: resources.resourceId
|
||||
}).from(resources).where(eq(resources.orgId, orgId));
|
||||
})
|
||||
.from(resources)
|
||||
.where(eq(resources.orgId, orgId));
|
||||
}
|
||||
|
||||
const accessibleResourceIds = accessibleResources.map(
|
||||
|
@ -236,7 +174,7 @@ export async function listResources(
|
|||
.from(resources)
|
||||
.where(inArray(resources.resourceId, accessibleResourceIds));
|
||||
|
||||
const baseQuery = queryResources(accessibleResourceIds, siteId, orgId);
|
||||
const baseQuery = queryResources(accessibleResourceIds, orgId);
|
||||
|
||||
const resourcesList = await baseQuery!.limit(limit).offset(offset);
|
||||
const totalCountResult = await countQuery;
|
||||
|
|
|
@ -1,214 +0,0 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { newts, resources, sites, targets } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { addPeer } from "../gerbil/peers";
|
||||
import { addTargets, removeTargets } from "../newt/targets";
|
||||
import { getAllowedIps } from "../target/helpers";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const transferResourceParamsSchema = z
|
||||
.object({
|
||||
resourceId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
})
|
||||
.strict();
|
||||
|
||||
const transferResourceBodySchema = z
|
||||
.object({
|
||||
siteId: z.number().int().positive()
|
||||
})
|
||||
.strict();
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/resource/{resourceId}/transfer",
|
||||
description:
|
||||
"Transfer a resource to a different site. This will also transfer the targets associated with the resource.",
|
||||
tags: [OpenAPITags.Resource],
|
||||
request: {
|
||||
params: transferResourceParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: transferResourceBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function transferResource(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = transferResourceParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = transferResourceBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourceId } = parsedParams.data;
|
||||
const { siteId } = parsedBody.data;
|
||||
|
||||
const [oldResource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
if (!oldResource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (oldResource.siteId === siteId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Resource is already assigned to this site`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [newSite] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!newSite) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site with ID ${siteId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [oldSite] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, oldResource.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!oldSite) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site with ID ${oldResource.siteId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [updatedResource] = await db
|
||||
.update(resources)
|
||||
.set({ siteId })
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.returning();
|
||||
|
||||
if (!updatedResource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const resourceTargets = await db
|
||||
.select()
|
||||
.from(targets)
|
||||
.where(eq(targets.resourceId, resourceId));
|
||||
|
||||
if (resourceTargets.length > 0) {
|
||||
////// REMOVE THE TARGETS FROM THE OLD SITE //////
|
||||
if (oldSite.pubKey) {
|
||||
if (oldSite.type == "wireguard") {
|
||||
await addPeer(oldSite.exitNodeId!, {
|
||||
publicKey: oldSite.pubKey,
|
||||
allowedIps: await getAllowedIps(oldSite.siteId)
|
||||
});
|
||||
} else if (oldSite.type == "newt") {
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, oldSite.siteId))
|
||||
.limit(1);
|
||||
|
||||
removeTargets(
|
||||
newt.newtId,
|
||||
resourceTargets,
|
||||
updatedResource.protocol,
|
||||
updatedResource.proxyPort
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
////// ADD THE TARGETS TO THE NEW SITE //////
|
||||
if (newSite.pubKey) {
|
||||
if (newSite.type == "wireguard") {
|
||||
await addPeer(newSite.exitNodeId!, {
|
||||
publicKey: newSite.pubKey,
|
||||
allowedIps: await getAllowedIps(newSite.siteId)
|
||||
});
|
||||
} else if (newSite.type == "newt") {
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, newSite.siteId))
|
||||
.limit(1);
|
||||
|
||||
addTargets(
|
||||
newt.newtId,
|
||||
resourceTargets,
|
||||
updatedResource.protocol,
|
||||
updatedResource.proxyPort
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: updatedResource,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource transferred successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -20,7 +20,6 @@ import { tlsNameSchema } from "@server/lib/schemas";
|
|||
import { subdomainSchema } from "@server/lib/schemas";
|
||||
import { registry } from "@server/openApi";
|
||||
import { OpenAPITags } from "@server/openApi";
|
||||
import { build } from "@server/build";
|
||||
|
||||
const updateResourceParamsSchema = z
|
||||
.object({
|
||||
|
@ -44,7 +43,8 @@ const updateHttpResourceBodySchema = z
|
|||
enabled: z.boolean().optional(),
|
||||
stickySession: z.boolean().optional(),
|
||||
tlsServerName: z.string().nullable().optional(),
|
||||
setHostHeader: z.string().nullable().optional()
|
||||
setHostHeader: z.string().nullable().optional(),
|
||||
skipToIdpId: z.number().int().positive().nullable().optional()
|
||||
})
|
||||
.strict()
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
|
@ -91,8 +91,8 @@ const updateRawResourceBodySchema = z
|
|||
name: z.string().min(1).max(255).optional(),
|
||||
proxyPort: z.number().int().min(1).max(65535).optional(),
|
||||
stickySession: z.boolean().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
enableProxy: z.boolean().optional()
|
||||
enabled: z.boolean().optional()
|
||||
// enableProxy: z.boolean().optional() // always true now
|
||||
})
|
||||
.strict()
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
|
|
|
@ -60,18 +60,18 @@ export async function addRoleSite(
|
|||
})
|
||||
.returning();
|
||||
|
||||
const siteResources = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.siteId, siteId));
|
||||
|
||||
for (const resource of siteResources) {
|
||||
await trx.insert(roleResources).values({
|
||||
roleId,
|
||||
resourceId: resource.resourceId
|
||||
});
|
||||
}
|
||||
|
||||
// const siteResources = await db
|
||||
// .select()
|
||||
// .from(resources)
|
||||
// .where(eq(resources.siteId, siteId));
|
||||
//
|
||||
// for (const resource of siteResources) {
|
||||
// await trx.insert(roleResources).values({
|
||||
// roleId,
|
||||
// resourceId: resource.resourceId
|
||||
// });
|
||||
// }
|
||||
//
|
||||
return response(res, {
|
||||
data: newRoleSite[0],
|
||||
success: true,
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
export * from "./addRoleAction";
|
||||
export * from "../resource/setResourceRoles";
|
||||
export * from "./addRoleSite";
|
||||
export * from "./createRole";
|
||||
export * from "./deleteRole";
|
||||
export * from "./getRole";
|
||||
|
@ -11,5 +10,4 @@ export * from "./listRoles";
|
|||
export * from "./listRoleSites";
|
||||
export * from "./removeRoleAction";
|
||||
export * from "./removeRoleResource";
|
||||
export * from "./removeRoleSite";
|
||||
export * from "./updateRole";
|
|
@ -71,22 +71,22 @@ export async function removeRoleSite(
|
|||
);
|
||||
}
|
||||
|
||||
const siteResources = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.siteId, siteId));
|
||||
|
||||
for (const resource of siteResources) {
|
||||
await trx
|
||||
.delete(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.roleId, roleId),
|
||||
eq(roleResources.resourceId, resource.resourceId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
}
|
||||
// const siteResources = await db
|
||||
// .select()
|
||||
// .from(resources)
|
||||
// .where(eq(resources.siteId, siteId));
|
||||
//
|
||||
// for (const resource of siteResources) {
|
||||
// await trx
|
||||
// .delete(roleResources)
|
||||
// .where(
|
||||
// and(
|
||||
// eq(roleResources.roleId, roleId),
|
||||
// eq(roleResources.resourceId, resource.resourceId)
|
||||
// )
|
||||
// )
|
||||
// .returning();
|
||||
// }
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
|
|
|
@ -145,7 +145,7 @@ export async function createSite(
|
|||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid subnet format. Please provide a valid CIDR notation."
|
||||
"Invalid address format. Please provide a valid IP notation."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
171
server/routers/siteResource/createSiteResource.ts
Normal file
171
server/routers/siteResource/createSiteResource.ts
Normal file
|
@ -0,0 +1,171 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, newts } from "@server/db";
|
||||
import { siteResources, sites, orgs, SiteResource } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { addTargets } from "../client/targets";
|
||||
|
||||
const createSiteResourceParamsSchema = z
|
||||
.object({
|
||||
siteId: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||
orgId: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
||||
const createSiteResourceSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
protocol: z.enum(["tcp", "udp"]),
|
||||
proxyPort: z.number().int().positive(),
|
||||
destinationPort: z.number().int().positive(),
|
||||
destinationIp: z.string().ip(),
|
||||
enabled: z.boolean().default(true)
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type CreateSiteResourceBody = z.infer<typeof createSiteResourceSchema>;
|
||||
export type CreateSiteResourceResponse = SiteResource;
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/org/{orgId}/site/{siteId}/resource",
|
||||
description: "Create a new site resource.",
|
||||
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
||||
request: {
|
||||
params: createSiteResourceParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: createSiteResourceSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function createSiteResource(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = createSiteResourceParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = createSiteResourceSchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { siteId, orgId } = parsedParams.data;
|
||||
const {
|
||||
name,
|
||||
protocol,
|
||||
proxyPort,
|
||||
destinationPort,
|
||||
destinationIp,
|
||||
enabled
|
||||
} = parsedBody.data;
|
||||
|
||||
// Verify the site exists and belongs to the org
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
|
||||
}
|
||||
|
||||
// check if resource with same protocol and proxy port already exists
|
||||
const [existingResource] = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.protocol, protocol),
|
||||
eq(siteResources.proxyPort, proxyPort)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (existingResource && existingResource.siteResourceId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"A resource with the same protocol and proxy port already exists"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Create the site resource
|
||||
const [newSiteResource] = await db
|
||||
.insert(siteResources)
|
||||
.values({
|
||||
siteId,
|
||||
orgId,
|
||||
name,
|
||||
protocol,
|
||||
proxyPort,
|
||||
destinationPort,
|
||||
destinationIp,
|
||||
enabled
|
||||
})
|
||||
.returning();
|
||||
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!newt) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "Newt not found"));
|
||||
}
|
||||
|
||||
await addTargets(newt.newtId, destinationIp, destinationPort, protocol);
|
||||
|
||||
logger.info(
|
||||
`Created site resource ${newSiteResource.siteResourceId} for site ${siteId}`
|
||||
);
|
||||
|
||||
return response(res, {
|
||||
data: newSiteResource,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Site resource created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error creating site resource:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create site resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
124
server/routers/siteResource/deleteSiteResource.ts
Normal file
124
server/routers/siteResource/deleteSiteResource.ts
Normal file
|
@ -0,0 +1,124 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, newts, sites } from "@server/db";
|
||||
import { siteResources } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { removeTargets } from "../client/targets";
|
||||
|
||||
const deleteSiteResourceParamsSchema = z
|
||||
.object({
|
||||
siteResourceId: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||
siteId: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||
orgId: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type DeleteSiteResourceResponse = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}",
|
||||
description: "Delete a site resource.",
|
||||
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
||||
request: {
|
||||
params: deleteSiteResourceParamsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function deleteSiteResource(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = deleteSiteResourceParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { siteResourceId, siteId, orgId } = parsedParams.data;
|
||||
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
|
||||
}
|
||||
|
||||
// Check if site resource exists
|
||||
const [existingSiteResource] = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(and(
|
||||
eq(siteResources.siteResourceId, siteResourceId),
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (!existingSiteResource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"Site resource not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Delete the site resource
|
||||
await db
|
||||
.delete(siteResources)
|
||||
.where(and(
|
||||
eq(siteResources.siteResourceId, siteResourceId),
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
));
|
||||
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!newt) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "Newt not found"));
|
||||
}
|
||||
|
||||
await removeTargets(
|
||||
newt.newtId,
|
||||
existingSiteResource.destinationIp,
|
||||
existingSiteResource.destinationPort,
|
||||
existingSiteResource.protocol
|
||||
);
|
||||
|
||||
logger.info(`Deleted site resource ${siteResourceId} for site ${siteId}`);
|
||||
|
||||
return response(res, {
|
||||
data: { message: "Site resource deleted successfully" },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Site resource deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error deleting site resource:", error);
|
||||
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Failed to delete site resource"));
|
||||
}
|
||||
}
|
83
server/routers/siteResource/getSiteResource.ts
Normal file
83
server/routers/siteResource/getSiteResource.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { siteResources, SiteResource } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const getSiteResourceParamsSchema = z
|
||||
.object({
|
||||
siteResourceId: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||
siteId: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||
orgId: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type GetSiteResourceResponse = SiteResource;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}",
|
||||
description: "Get a specific site resource.",
|
||||
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
||||
request: {
|
||||
params: getSiteResourceParamsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function getSiteResource(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = getSiteResourceParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { siteResourceId, siteId, orgId } = parsedParams.data;
|
||||
|
||||
// Get the site resource
|
||||
const [siteResource] = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(and(
|
||||
eq(siteResources.siteResourceId, siteResourceId),
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (!siteResource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"Site resource not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: siteResource,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Site resource retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error getting site resource:", error);
|
||||
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Failed to get site resource"));
|
||||
}
|
||||
}
|
6
server/routers/siteResource/index.ts
Normal file
6
server/routers/siteResource/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export * from "./createSiteResource";
|
||||
export * from "./deleteSiteResource";
|
||||
export * from "./getSiteResource";
|
||||
export * from "./updateSiteResource";
|
||||
export * from "./listSiteResources";
|
||||
export * from "./listAllSiteResourcesByOrg";
|
111
server/routers/siteResource/listAllSiteResourcesByOrg.ts
Normal file
111
server/routers/siteResource/listAllSiteResourcesByOrg.ts
Normal file
|
@ -0,0 +1,111 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { siteResources, sites, SiteResource } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const listAllSiteResourcesByOrgParamsSchema = z
|
||||
.object({
|
||||
orgId: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
||||
const listAllSiteResourcesByOrgQuerySchema = z.object({
|
||||
limit: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive()),
|
||||
offset: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().nonnegative())
|
||||
});
|
||||
|
||||
export type ListAllSiteResourcesByOrgResponse = {
|
||||
siteResources: (SiteResource & { siteName: string, siteNiceId: string })[];
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/site-resources",
|
||||
description: "List all site resources for an organization.",
|
||||
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
||||
request: {
|
||||
params: listAllSiteResourcesByOrgParamsSchema,
|
||||
query: listAllSiteResourcesByOrgQuerySchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function listAllSiteResourcesByOrg(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = listAllSiteResourcesByOrgParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedQuery = listAllSiteResourcesByOrgQuerySchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
|
||||
// Get all site resources for the org with site names
|
||||
const siteResourcesList = await db
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
siteId: siteResources.siteId,
|
||||
orgId: siteResources.orgId,
|
||||
name: siteResources.name,
|
||||
protocol: siteResources.protocol,
|
||||
proxyPort: siteResources.proxyPort,
|
||||
destinationPort: siteResources.destinationPort,
|
||||
destinationIp: siteResources.destinationIp,
|
||||
enabled: siteResources.enabled,
|
||||
siteName: sites.name,
|
||||
siteNiceId: sites.niceId
|
||||
})
|
||||
.from(siteResources)
|
||||
.innerJoin(sites, eq(siteResources.siteId, sites.siteId))
|
||||
.where(eq(siteResources.orgId, orgId))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return response(res, {
|
||||
data: { siteResources: siteResourcesList },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Site resources retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error listing all site resources by org:", error);
|
||||
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Failed to list site resources"));
|
||||
}
|
||||
}
|
118
server/routers/siteResource/listSiteResources.ts
Normal file
118
server/routers/siteResource/listSiteResources.ts
Normal file
|
@ -0,0 +1,118 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { siteResources, sites, SiteResource } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const listSiteResourcesParamsSchema = z
|
||||
.object({
|
||||
siteId: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||
orgId: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
||||
const listSiteResourcesQuerySchema = z.object({
|
||||
limit: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("100")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive()),
|
||||
offset: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().nonnegative())
|
||||
});
|
||||
|
||||
export type ListSiteResourcesResponse = {
|
||||
siteResources: SiteResource[];
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/site/{siteId}/resources",
|
||||
description: "List site resources for a site.",
|
||||
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
||||
request: {
|
||||
params: listSiteResourcesParamsSchema,
|
||||
query: listSiteResourcesQuerySchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function listSiteResources(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = listSiteResourcesParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedQuery = listSiteResourcesQuerySchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { siteId, orgId } = parsedParams.data;
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
|
||||
// Verify the site exists and belongs to the org
|
||||
const site = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (site.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"Site not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Get site resources
|
||||
const siteResourcesList = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(and(
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return response(res, {
|
||||
data: { siteResources: siteResourcesList },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Site resources retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error listing site resources:", error);
|
||||
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Failed to list site resources"));
|
||||
}
|
||||
}
|
196
server/routers/siteResource/updateSiteResource.ts
Normal file
196
server/routers/siteResource/updateSiteResource.ts
Normal file
|
@ -0,0 +1,196 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, newts, sites } from "@server/db";
|
||||
import { siteResources, SiteResource } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { addTargets } from "../client/targets";
|
||||
|
||||
const updateSiteResourceParamsSchema = z
|
||||
.object({
|
||||
siteResourceId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive()),
|
||||
siteId: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||
orgId: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
||||
const updateSiteResourceSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
protocol: z.enum(["tcp", "udp"]).optional(),
|
||||
proxyPort: z.number().int().positive().optional(),
|
||||
destinationPort: z.number().int().positive().optional(),
|
||||
destinationIp: z.string().ip().optional(),
|
||||
enabled: z.boolean().optional()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type UpdateSiteResourceBody = z.infer<typeof updateSiteResourceSchema>;
|
||||
export type UpdateSiteResourceResponse = SiteResource;
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}",
|
||||
description: "Update a site resource.",
|
||||
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
||||
request: {
|
||||
params: updateSiteResourceParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: updateSiteResourceSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function updateSiteResource(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = updateSiteResourceParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = updateSiteResourceSchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { siteResourceId, siteId, orgId } = parsedParams.data;
|
||||
const updateData = parsedBody.data;
|
||||
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
|
||||
}
|
||||
|
||||
// Check if site resource exists
|
||||
const [existingSiteResource] = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.siteResourceId, siteResourceId),
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existingSiteResource) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Site resource not found")
|
||||
);
|
||||
}
|
||||
|
||||
const protocol = updateData.protocol || existingSiteResource.protocol;
|
||||
const proxyPort =
|
||||
updateData.proxyPort || existingSiteResource.proxyPort;
|
||||
|
||||
// check if resource with same protocol and proxy port already exists
|
||||
const [existingResource] = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.protocol, protocol),
|
||||
eq(siteResources.proxyPort, proxyPort)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (
|
||||
existingResource &&
|
||||
existingResource.siteResourceId !== siteResourceId
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"A resource with the same protocol and proxy port already exists"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Update the site resource
|
||||
const [updatedSiteResource] = await db
|
||||
.update(siteResources)
|
||||
.set(updateData)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.siteResourceId, siteResourceId),
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!newt) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "Newt not found"));
|
||||
}
|
||||
|
||||
await addTargets(
|
||||
newt.newtId,
|
||||
updatedSiteResource.destinationIp,
|
||||
updatedSiteResource.destinationPort,
|
||||
updatedSiteResource.protocol
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Updated site resource ${siteResourceId} for site ${siteId}`
|
||||
);
|
||||
|
||||
return response(res, {
|
||||
data: updatedSiteResource,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Site resource updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error updating site resource:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to update site resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -26,6 +26,7 @@ const createTargetParamsSchema = z
|
|||
|
||||
const createTargetSchema = z
|
||||
.object({
|
||||
siteId: z.number().int().positive(),
|
||||
ip: z.string().refine(isTargetValid),
|
||||
method: z.string().optional().nullable(),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
|
@ -98,17 +99,41 @@ export async function createTarget(
|
|||
);
|
||||
}
|
||||
|
||||
const siteId = targetData.siteId;
|
||||
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, resource.siteId!))
|
||||
.where(eq(sites.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site with ID ${resource.siteId} not found`
|
||||
`Site with ID ${siteId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const existingTargets = await db
|
||||
.select()
|
||||
.from(targets)
|
||||
.where(eq(targets.resourceId, resourceId));
|
||||
|
||||
const existingTarget = existingTargets.find(
|
||||
(target) =>
|
||||
target.ip === targetData.ip &&
|
||||
target.port === targetData.port &&
|
||||
target.method === targetData.method &&
|
||||
target.siteId === targetData.siteId
|
||||
);
|
||||
|
||||
if (existingTarget) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${resourceId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -173,7 +198,12 @@ export async function createTarget(
|
|||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
|
||||
addTargets(newt.newtId, newTarget, resource.protocol, resource.proxyPort);
|
||||
await addTargets(
|
||||
newt.newtId,
|
||||
newTarget,
|
||||
resource.protocol,
|
||||
resource.proxyPort
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -76,38 +76,38 @@ export async function deleteTarget(
|
|||
);
|
||||
}
|
||||
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, resource.siteId!))
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site with ID ${resource.siteId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (site.pubKey) {
|
||||
if (site.type == "wireguard") {
|
||||
await addPeer(site.exitNodeId!, {
|
||||
publicKey: site.pubKey,
|
||||
allowedIps: await getAllowedIps(site.siteId)
|
||||
});
|
||||
} else if (site.type == "newt") {
|
||||
// get the newt on the site by querying the newt table for siteId
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
|
||||
removeTargets(newt.newtId, [deletedTarget], resource.protocol, resource.proxyPort);
|
||||
}
|
||||
}
|
||||
// const [site] = await db
|
||||
// .select()
|
||||
// .from(sites)
|
||||
// .where(eq(sites.siteId, resource.siteId!))
|
||||
// .limit(1);
|
||||
//
|
||||
// if (!site) {
|
||||
// return next(
|
||||
// createHttpError(
|
||||
// HttpCode.NOT_FOUND,
|
||||
// `Site with ID ${resource.siteId} not found`
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// if (site.pubKey) {
|
||||
// if (site.type == "wireguard") {
|
||||
// await addPeer(site.exitNodeId!, {
|
||||
// publicKey: site.pubKey,
|
||||
// allowedIps: await getAllowedIps(site.siteId)
|
||||
// });
|
||||
// } else if (site.type == "newt") {
|
||||
// // get the newt on the site by querying the newt table for siteId
|
||||
// const [newt] = await db
|
||||
// .select()
|
||||
// .from(newts)
|
||||
// .where(eq(newts.siteId, site.siteId))
|
||||
// .limit(1);
|
||||
//
|
||||
// removeTargets(newt.newtId, [deletedTarget], resource.protocol, resource.proxyPort);
|
||||
// }
|
||||
// }
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { db, Target } from "@server/db";
|
||||
import { targets } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
|
@ -16,6 +16,8 @@ const getTargetSchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
type GetTargetResponse = Target;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/target/{targetId}",
|
||||
|
@ -60,7 +62,7 @@ export async function getTarget(
|
|||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
return response<GetTargetResponse>(res, {
|
||||
data: target[0],
|
||||
success: true,
|
||||
error: false,
|
||||
|
|
|
@ -8,29 +8,21 @@ export async function pickPort(siteId: number): Promise<{
|
|||
internalPort: number;
|
||||
targetIps: string[];
|
||||
}> {
|
||||
const resourcesRes = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.siteId, siteId));
|
||||
|
||||
// TODO: is this all inefficient?
|
||||
// Fetch targets for all resources of this site
|
||||
const targetIps: string[] = [];
|
||||
const targetInternalPorts: number[] = [];
|
||||
await Promise.all(
|
||||
resourcesRes.map(async (resource) => {
|
||||
|
||||
const targetsRes = await db
|
||||
.select()
|
||||
.from(targets)
|
||||
.where(eq(targets.resourceId, resource.resourceId));
|
||||
.where(eq(targets.siteId, siteId));
|
||||
|
||||
targetsRes.forEach((target) => {
|
||||
targetIps.push(`${target.ip}/32`);
|
||||
if (target.internalPort) {
|
||||
targetInternalPorts.push(target.internalPort);
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
let internalPort!: number;
|
||||
// pick a port random port from 40000 to 65535 that is not in use
|
||||
|
@ -43,28 +35,20 @@ export async function pickPort(siteId: number): Promise<{
|
|||
break;
|
||||
}
|
||||
}
|
||||
|
||||
currentBannedPorts.push(internalPort);
|
||||
|
||||
return { internalPort, targetIps };
|
||||
}
|
||||
|
||||
export async function getAllowedIps(siteId: number) {
|
||||
// TODO: is this all inefficient?
|
||||
|
||||
const resourcesRes = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.siteId, siteId));
|
||||
|
||||
// Fetch targets for all resources of this site
|
||||
const targetIps = await Promise.all(
|
||||
resourcesRes.map(async (resource) => {
|
||||
const targetsRes = await db
|
||||
.select()
|
||||
.from(targets)
|
||||
.where(eq(targets.resourceId, resource.resourceId));
|
||||
return targetsRes.map((target) => `${target.ip}/32`);
|
||||
})
|
||||
);
|
||||
.where(eq(targets.siteId, siteId));
|
||||
|
||||
const targetIps = targetsRes.map((target) => `${target.ip}/32`);
|
||||
|
||||
return targetIps.flat();
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { db } from "@server/db";
|
||||
import { db, sites } from "@server/db";
|
||||
import { targets } from "@server/db";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
|
@ -42,11 +42,12 @@ function queryTargets(resourceId: number) {
|
|||
method: targets.method,
|
||||
port: targets.port,
|
||||
enabled: targets.enabled,
|
||||
resourceId: targets.resourceId
|
||||
// resourceName: resources.name,
|
||||
resourceId: targets.resourceId,
|
||||
siteId: targets.siteId,
|
||||
siteType: sites.type
|
||||
})
|
||||
.from(targets)
|
||||
// .leftJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||
.leftJoin(sites, eq(sites.siteId, targets.siteId))
|
||||
.where(eq(targets.resourceId, resourceId));
|
||||
|
||||
return baseQuery;
|
||||
|
|
|
@ -22,6 +22,7 @@ const updateTargetParamsSchema = z
|
|||
|
||||
const updateTargetBodySchema = z
|
||||
.object({
|
||||
siteId: z.number().int().positive(),
|
||||
ip: z.string().refine(isTargetValid),
|
||||
method: z.string().min(1).max(10).optional().nullable(),
|
||||
port: z.number().int().min(1).max(65535).optional(),
|
||||
|
@ -77,6 +78,7 @@ export async function updateTarget(
|
|||
}
|
||||
|
||||
const { targetId } = parsedParams.data;
|
||||
const { siteId } = parsedBody.data;
|
||||
|
||||
const [target] = await db
|
||||
.select()
|
||||
|
@ -111,14 +113,42 @@ export async function updateTarget(
|
|||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, resource.siteId!))
|
||||
.where(eq(sites.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site with ID ${resource.siteId} not found`
|
||||
`Site with ID ${siteId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const targetData = {
|
||||
...target,
|
||||
...parsedBody.data
|
||||
};
|
||||
|
||||
const existingTargets = await db
|
||||
.select()
|
||||
.from(targets)
|
||||
.where(eq(targets.resourceId, target.resourceId));
|
||||
|
||||
const foundTarget = existingTargets.find(
|
||||
(target) =>
|
||||
target.targetId !== targetId && // Exclude the current target being updated
|
||||
target.ip === targetData.ip &&
|
||||
target.port === targetData.port &&
|
||||
target.method === targetData.method &&
|
||||
target.siteId === targetData.siteId
|
||||
);
|
||||
|
||||
if (foundTarget) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Target with IP ${targetData.ip}, port ${targetData.port}, and method ${targetData.method} already exists on the same site.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -157,7 +187,12 @@ export async function updateTarget(
|
|||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
|
||||
addTargets(newt.newtId, [updatedTarget], resource.protocol, resource.proxyPort);
|
||||
await addTargets(
|
||||
newt.newtId,
|
||||
[updatedTarget],
|
||||
resource.protocol,
|
||||
resource.proxyPort
|
||||
);
|
||||
}
|
||||
}
|
||||
return response(res, {
|
||||
|
|
|
@ -1,12 +1,22 @@
|
|||
import { Request, Response } from "express";
|
||||
import { db, exitNodes } from "@server/db";
|
||||
import { and, eq, inArray, or, isNull } from "drizzle-orm";
|
||||
import { and, eq, inArray, or, isNull, ne } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import config from "@server/lib/config";
|
||||
import { orgs, resources, sites, Target, targets } from "@server/db";
|
||||
import { build } from "@server/build";
|
||||
|
||||
// Extended Target interface that includes site information
|
||||
interface TargetWithSite extends Target {
|
||||
site: {
|
||||
siteId: number;
|
||||
type: string;
|
||||
subnet: string | null;
|
||||
exitNodeId: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
let currentExitNodeId: number;
|
||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||
const badgerMiddlewareName = "badger";
|
||||
|
@ -83,8 +93,9 @@ export async function traefikConfigProvider(
|
|||
export async function getTraefikConfig(exitNodeId: number): Promise<any> {
|
||||
// Get all resources with related data
|
||||
const allResources = await db.transaction(async (tx) => {
|
||||
// Get the site(s) on this exit node
|
||||
const resourcesWithRelations = await tx
|
||||
// Get resources with their targets and sites in a single optimized query
|
||||
// Start from sites on this exit node, then join to targets and resources
|
||||
const resourcesWithTargetsAndSites = await tx
|
||||
.select({
|
||||
// Resource fields
|
||||
resourceId: resources.resourceId,
|
||||
|
@ -95,64 +106,82 @@ export async function getTraefikConfig(exitNodeId: number): Promise<any> {
|
|||
protocol: resources.protocol,
|
||||
subdomain: resources.subdomain,
|
||||
domainId: resources.domainId,
|
||||
// Site fields
|
||||
site: {
|
||||
siteId: sites.siteId,
|
||||
type: sites.type,
|
||||
subnet: sites.subnet,
|
||||
exitNodeId: sites.exitNodeId
|
||||
},
|
||||
enabled: resources.enabled,
|
||||
stickySession: resources.stickySession,
|
||||
tlsServerName: resources.tlsServerName,
|
||||
setHostHeader: resources.setHostHeader,
|
||||
enableProxy: resources.enableProxy
|
||||
})
|
||||
.from(resources)
|
||||
.innerJoin(sites, eq(sites.siteId, resources.siteId))
|
||||
.where(
|
||||
or(eq(sites.exitNodeId, exitNodeId), isNull(sites.exitNodeId))
|
||||
);
|
||||
|
||||
// Get all resource IDs from the first query
|
||||
const resourceIds = resourcesWithRelations.map((r) => r.resourceId);
|
||||
|
||||
// Second query to get all enabled targets for these resources
|
||||
const allTargets =
|
||||
resourceIds.length > 0
|
||||
? await tx
|
||||
.select({
|
||||
resourceId: targets.resourceId,
|
||||
enableProxy: resources.enableProxy,
|
||||
// Target fields
|
||||
targetId: targets.targetId,
|
||||
targetEnabled: targets.enabled,
|
||||
ip: targets.ip,
|
||||
method: targets.method,
|
||||
port: targets.port,
|
||||
internalPort: targets.internalPort,
|
||||
enabled: targets.enabled
|
||||
// Site fields
|
||||
siteId: sites.siteId,
|
||||
siteType: sites.type,
|
||||
subnet: sites.subnet,
|
||||
exitNodeId: sites.exitNodeId
|
||||
})
|
||||
.from(targets)
|
||||
.from(sites)
|
||||
.innerJoin(targets, eq(targets.siteId, sites.siteId))
|
||||
.innerJoin(resources, eq(resources.resourceId, targets.resourceId))
|
||||
.where(
|
||||
and(
|
||||
inArray(targets.resourceId, resourceIds),
|
||||
eq(targets.enabled, true)
|
||||
eq(targets.enabled, true),
|
||||
eq(resources.enabled, true),
|
||||
or(
|
||||
eq(sites.exitNodeId, currentExitNodeId),
|
||||
isNull(sites.exitNodeId)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
);
|
||||
|
||||
// Create a map for fast target lookup by resourceId
|
||||
const targetsMap = allTargets.reduce((map, target) => {
|
||||
if (!map.has(target.resourceId)) {
|
||||
map.set(target.resourceId, []);
|
||||
// Group by resource and include targets with their unique site data
|
||||
const resourcesMap = new Map();
|
||||
|
||||
resourcesWithTargetsAndSites.forEach((row) => {
|
||||
const resourceId = row.resourceId;
|
||||
|
||||
if (!resourcesMap.has(resourceId)) {
|
||||
resourcesMap.set(resourceId, {
|
||||
resourceId: row.resourceId,
|
||||
fullDomain: row.fullDomain,
|
||||
ssl: row.ssl,
|
||||
http: row.http,
|
||||
proxyPort: row.proxyPort,
|
||||
protocol: row.protocol,
|
||||
subdomain: row.subdomain,
|
||||
domainId: row.domainId,
|
||||
enabled: row.enabled,
|
||||
stickySession: row.stickySession,
|
||||
tlsServerName: row.tlsServerName,
|
||||
setHostHeader: row.setHostHeader,
|
||||
enableProxy: row.enableProxy,
|
||||
targets: []
|
||||
});
|
||||
}
|
||||
map.get(target.resourceId).push(target);
|
||||
return map;
|
||||
}, new Map());
|
||||
|
||||
// Combine the data
|
||||
return resourcesWithRelations.map((resource) => ({
|
||||
...resource,
|
||||
targets: targetsMap.get(resource.resourceId) || []
|
||||
}));
|
||||
// Add target with its associated site data
|
||||
resourcesMap.get(resourceId).targets.push({
|
||||
resourceId: row.resourceId,
|
||||
targetId: row.targetId,
|
||||
ip: row.ip,
|
||||
method: row.method,
|
||||
port: row.port,
|
||||
internalPort: row.internalPort,
|
||||
enabled: row.targetEnabled,
|
||||
site: {
|
||||
siteId: row.siteId,
|
||||
type: row.siteType,
|
||||
subnet: row.subnet,
|
||||
exitNodeId: row.exitNodeId
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(resourcesMap.values());
|
||||
});
|
||||
|
||||
if (!allResources.length) {
|
||||
|
@ -270,7 +299,194 @@ export async function getTraefikConfig(exitNodeId: number): Promise<any> {
|
|||
middlewares: [redirectHttpsMiddlewareName],
|
||||
service: serviceName,
|
||||
rule: `Host(\`${fullDomain}\`)`,
|
||||
<<<<<<< HEAD
|
||||
priority: 100
|
||||
=======
|
||||
priority: 100,
|
||||
...(resource.ssl ? { tls } : {})
|
||||
};
|
||||
|
||||
if (resource.ssl) {
|
||||
config_output.http.routers![routerName + "-redirect"] = {
|
||||
entryPoints: [
|
||||
config.getRawConfig().traefik.http_entrypoint
|
||||
],
|
||||
middlewares: [redirectHttpsMiddlewareName],
|
||||
service: serviceName,
|
||||
rule: `Host(\`${fullDomain}\`)`,
|
||||
priority: 100
|
||||
};
|
||||
}
|
||||
|
||||
config_output.http.services![serviceName] = {
|
||||
loadBalancer: {
|
||||
servers: targets
|
||||
.filter((target: TargetWithSite) => {
|
||||
if (!target.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
) {
|
||||
if (
|
||||
!target.ip ||
|
||||
!target.port ||
|
||||
!target.method
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
} else if (target.site.type === "newt") {
|
||||
if (
|
||||
!target.internalPort ||
|
||||
!target.method ||
|
||||
!target.site.subnet
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((target: TargetWithSite) => {
|
||||
if (
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
) {
|
||||
return {
|
||||
url: `${target.method}://${target.ip}:${target.port}`
|
||||
};
|
||||
} else if (target.site.type === "newt") {
|
||||
const ip = target.site.subnet!.split("/")[0];
|
||||
return {
|
||||
url: `${target.method}://${ip}:${target.internalPort}`
|
||||
};
|
||||
}
|
||||
}),
|
||||
...(resource.stickySession
|
||||
? {
|
||||
sticky: {
|
||||
cookie: {
|
||||
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
|
||||
secure: resource.ssl,
|
||||
httpOnly: true
|
||||
}
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
};
|
||||
|
||||
// Add the serversTransport if TLS server name is provided
|
||||
if (resource.tlsServerName) {
|
||||
if (!config_output.http.serversTransports) {
|
||||
config_output.http.serversTransports = {};
|
||||
}
|
||||
config_output.http.serversTransports![transportName] = {
|
||||
serverName: resource.tlsServerName,
|
||||
//unfortunately the following needs to be set. traefik doesn't merge the default serverTransport settings
|
||||
// if defined in the static config and here. if not set, self-signed certs won't work
|
||||
insecureSkipVerify: true
|
||||
};
|
||||
config_output.http.services![
|
||||
serviceName
|
||||
].loadBalancer.serversTransport = transportName;
|
||||
}
|
||||
|
||||
// Add the host header middleware
|
||||
if (resource.setHostHeader) {
|
||||
if (!config_output.http.middlewares) {
|
||||
config_output.http.middlewares = {};
|
||||
}
|
||||
config_output.http.middlewares[hostHeaderMiddlewareName] = {
|
||||
headers: {
|
||||
customRequestHeaders: {
|
||||
Host: resource.setHostHeader
|
||||
}
|
||||
}
|
||||
};
|
||||
if (!config_output.http.routers![routerName].middlewares) {
|
||||
config_output.http.routers![routerName].middlewares =
|
||||
[];
|
||||
}
|
||||
config_output.http.routers![routerName].middlewares = [
|
||||
...config_output.http.routers![routerName].middlewares,
|
||||
hostHeaderMiddlewareName
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// Non-HTTP (TCP/UDP) configuration
|
||||
if (!resource.enableProxy) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const protocol = resource.protocol.toLowerCase();
|
||||
const port = resource.proxyPort;
|
||||
|
||||
if (!port) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!config_output[protocol]) {
|
||||
config_output[protocol] = {
|
||||
routers: {},
|
||||
services: {}
|
||||
};
|
||||
}
|
||||
|
||||
config_output[protocol].routers[routerName] = {
|
||||
entryPoints: [`${protocol}-${port}`],
|
||||
service: serviceName,
|
||||
...(protocol === "tcp" ? { rule: "HostSNI(`*`)" } : {})
|
||||
};
|
||||
|
||||
config_output[protocol].services[serviceName] = {
|
||||
loadBalancer: {
|
||||
servers: targets
|
||||
.filter((target: TargetWithSite) => {
|
||||
if (!target.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
) {
|
||||
if (!target.ip || !target.port) {
|
||||
return false;
|
||||
}
|
||||
} else if (target.site.type === "newt") {
|
||||
if (!target.internalPort || !target.site.subnet) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((target: TargetWithSite) => {
|
||||
if (
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
) {
|
||||
return {
|
||||
address: `${target.ip}:${target.port}`
|
||||
};
|
||||
} else if (target.site.type === "newt") {
|
||||
const ip = target.site.subnet!.split("/")[0];
|
||||
return {
|
||||
address: `${ip}:${target.internalPort}`
|
||||
};
|
||||
}
|
||||
}),
|
||||
...(resource.stickySession
|
||||
? {
|
||||
sticky: {
|
||||
ipStrategy: {
|
||||
depth: 0,
|
||||
sourcePort: true
|
||||
}
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
>>>>>>> dev
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -43,17 +43,17 @@ export async function addUserSite(
|
|||
})
|
||||
.returning();
|
||||
|
||||
const siteResources = await trx
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.siteId, siteId));
|
||||
|
||||
for (const resource of siteResources) {
|
||||
await trx.insert(userResources).values({
|
||||
userId,
|
||||
resourceId: resource.resourceId
|
||||
});
|
||||
}
|
||||
// const siteResources = await trx
|
||||
// .select()
|
||||
// .from(resources)
|
||||
// .where(eq(resources.siteId, siteId));
|
||||
//
|
||||
// for (const resource of siteResources) {
|
||||
// await trx.insert(userResources).values({
|
||||
// userId,
|
||||
// resourceId: resource.resourceId
|
||||
// });
|
||||
// }
|
||||
|
||||
return response(res, {
|
||||
data: newUserSite[0],
|
||||
|
|
|
@ -189,7 +189,7 @@ export async function inviteUser(
|
|||
)
|
||||
);
|
||||
|
||||
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}`;
|
||||
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`;
|
||||
|
||||
if (doEmail) {
|
||||
await sendEmail(
|
||||
|
@ -241,7 +241,7 @@ export async function inviteUser(
|
|||
});
|
||||
});
|
||||
|
||||
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}`;
|
||||
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`;
|
||||
|
||||
if (doEmail) {
|
||||
await sendEmail(
|
||||
|
|
|
@ -71,22 +71,22 @@ export async function removeUserSite(
|
|||
);
|
||||
}
|
||||
|
||||
const siteResources = await trx
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.siteId, siteId));
|
||||
|
||||
for (const resource of siteResources) {
|
||||
await trx
|
||||
.delete(userResources)
|
||||
.where(
|
||||
and(
|
||||
eq(userResources.userId, userId),
|
||||
eq(userResources.resourceId, resource.resourceId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
}
|
||||
// const siteResources = await trx
|
||||
// .select()
|
||||
// .from(resources)
|
||||
// .where(eq(resources.siteId, siteId));
|
||||
//
|
||||
// for (const resource of siteResources) {
|
||||
// await trx
|
||||
// .delete(userResources)
|
||||
// .where(
|
||||
// and(
|
||||
// eq(userResources.userId, userId),
|
||||
// eq(userResources.resourceId, resource.resourceId)
|
||||
// )
|
||||
// )
|
||||
// .returning();
|
||||
// }
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
|
|
|
@ -23,7 +23,7 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
|||
"olm/ping": handleOlmPingMessage,
|
||||
"newt/socket/status": handleDockerStatusMessage,
|
||||
"newt/socket/containers": handleDockerContainersMessage,
|
||||
"newt/ping/request": handleNewtPingRequestMessage,
|
||||
"newt/ping/request": handleNewtPingRequestMessage
|
||||
};
|
||||
|
||||
startOlmOfflineChecker(); // this is to handle the offline check for olms
|
||||
|
|
73
server/setup/ensureSetupToken.ts
Normal file
73
server/setup/ensureSetupToken.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import { db, setupTokens, users } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { generateRandomString, RandomReader } from "@oslojs/crypto/random";
|
||||
import moment from "moment";
|
||||
import logger from "@server/logger";
|
||||
|
||||
const random: RandomReader = {
|
||||
read(bytes: Uint8Array): void {
|
||||
crypto.getRandomValues(bytes);
|
||||
}
|
||||
};
|
||||
|
||||
function generateToken(): string {
|
||||
// Generate a 32-character alphanumeric token
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
return generateRandomString(random, alphabet, 32);
|
||||
}
|
||||
|
||||
function generateId(length: number): string {
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
return generateRandomString(random, alphabet, length);
|
||||
}
|
||||
|
||||
export async function ensureSetupToken() {
|
||||
try {
|
||||
// Check if a server admin already exists
|
||||
const [existingAdmin] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.serverAdmin, true));
|
||||
|
||||
// If admin exists, no need for setup token
|
||||
if (existingAdmin) {
|
||||
logger.warn("Server admin exists. Setup token generation skipped.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if a setup token already exists
|
||||
const existingTokens = await db
|
||||
.select()
|
||||
.from(setupTokens)
|
||||
.where(eq(setupTokens.used, false));
|
||||
|
||||
// If unused token exists, display it instead of creating a new one
|
||||
if (existingTokens.length > 0) {
|
||||
console.log("=== SETUP TOKEN EXISTS ===");
|
||||
console.log("Token:", existingTokens[0].token);
|
||||
console.log("Use this token on the initial setup page");
|
||||
console.log("================================");
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate a new setup token
|
||||
const token = generateToken();
|
||||
const tokenId = generateId(15);
|
||||
|
||||
await db.insert(setupTokens).values({
|
||||
tokenId: tokenId,
|
||||
token: token,
|
||||
used: false,
|
||||
dateCreated: moment().toISOString(),
|
||||
dateUsed: null
|
||||
});
|
||||
|
||||
console.log("=== SETUP TOKEN GENERATED ===");
|
||||
console.log("Token:", token);
|
||||
console.log("Use this token on the initial setup page");
|
||||
console.log("================================");
|
||||
} catch (error) {
|
||||
console.error("Failed to ensure setup token:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
|
@ -1,9 +1,11 @@
|
|||
import { ensureActions } from "./ensureActions";
|
||||
import { copyInConfig } from "./copyInConfig";
|
||||
import { clearStaleData } from "./clearStaleData";
|
||||
import { ensureSetupToken } from "./ensureSetupToken";
|
||||
|
||||
export async function runSetupFunctions() {
|
||||
await copyInConfig(); // copy in the config to the db as needed
|
||||
await ensureActions(); // make sure all of the actions are in the db and the roles
|
||||
await clearStaleData();
|
||||
await ensureSetupToken(); // ensure setup token exists for initial setup
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import path from "path";
|
|||
import m1 from "./scriptsPg/1.6.0";
|
||||
import m2 from "./scriptsPg/1.7.0";
|
||||
import m3 from "./scriptsPg/1.8.0";
|
||||
import m4 from "./scriptsPg/1.9.0";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
|
@ -16,7 +17,8 @@ import m3 from "./scriptsPg/1.8.0";
|
|||
const migrations = [
|
||||
{ version: "1.6.0", run: m1 },
|
||||
{ version: "1.7.0", run: m2 },
|
||||
{ version: "1.8.0", run: m3 }
|
||||
{ version: "1.8.0", run: m3 },
|
||||
// { version: "1.9.0", run: m4 }
|
||||
// Add new migrations here as they are created
|
||||
] as {
|
||||
version: string;
|
||||
|
|
|
@ -25,6 +25,7 @@ import m20 from "./scriptsSqlite/1.5.0";
|
|||
import m21 from "./scriptsSqlite/1.6.0";
|
||||
import m22 from "./scriptsSqlite/1.7.0";
|
||||
import m23 from "./scriptsSqlite/1.8.0";
|
||||
import m24 from "./scriptsSqlite/1.9.0";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
|
@ -49,6 +50,7 @@ const migrations = [
|
|||
{ version: "1.6.0", run: m21 },
|
||||
{ version: "1.7.0", run: m22 },
|
||||
{ version: "1.8.0", run: m23 },
|
||||
// { version: "1.9.0", run: m24 },
|
||||
// Add new migrations here as they are created
|
||||
] as const;
|
||||
|
||||
|
|
25
server/setup/scriptsPg/1.9.0.ts
Normal file
25
server/setup/scriptsPg/1.9.0.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { db } from "@server/db/pg/driver";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
const version = "1.9.0";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
try {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "setupTokens" (
|
||||
"tokenId" varchar PRIMARY KEY NOT NULL,
|
||||
"token" varchar NOT NULL,
|
||||
"used" boolean DEFAULT false NOT NULL,
|
||||
"dateCreated" varchar NOT NULL,
|
||||
"dateUsed" varchar
|
||||
);
|
||||
`);
|
||||
|
||||
console.log(`Added setupTokens table`);
|
||||
} catch (e) {
|
||||
console.log("Unable to add setupTokens table:", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
35
server/setup/scriptsSqlite/1.9.0.ts
Normal file
35
server/setup/scriptsSqlite/1.9.0.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { APP_PATH } from "@server/lib/consts";
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
|
||||
const version = "1.9.0";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||
const db = new Database(location);
|
||||
|
||||
try {
|
||||
db.pragma("foreign_keys = OFF");
|
||||
|
||||
db.transaction(() => {
|
||||
db.exec(`
|
||||
CREATE TABLE 'setupTokens' (
|
||||
'tokenId' text PRIMARY KEY NOT NULL,
|
||||
'token' text NOT NULL,
|
||||
'used' integer DEFAULT 0 NOT NULL,
|
||||
'dateCreated' text NOT NULL,
|
||||
'dateUsed' text
|
||||
);
|
||||
`);
|
||||
})();
|
||||
|
||||
db.pragma("foreign_keys = ON");
|
||||
|
||||
console.log(`Added setupTokens table`);
|
||||
} catch (e) {
|
||||
console.log("Unable to add setupTokens table:", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
|
@ -1,36 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
createResource?: () => void;
|
||||
}
|
||||
|
||||
export function ResourcesDataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
createResource
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title={t('resources')}
|
||||
searchPlaceholder={t('resourcesSearch')}
|
||||
searchColumn="name"
|
||||
onAdd={createResource}
|
||||
addButtonText={t('resourceAdd')}
|
||||
defaultSort={{
|
||||
id: "name",
|
||||
desc: false
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -1,70 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Server, Lock, Key, Users, X, ArrowRight } from "lucide-react"; // Replace with actual imports
|
||||
import { Card, CardContent } from "@app/components/ui/card";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export const ResourcesSplashCard = () => {
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
|
||||
const key = "resources-splash-dismissed";
|
||||
|
||||
useEffect(() => {
|
||||
const dismissed = localStorage.getItem(key);
|
||||
if (dismissed === "true") {
|
||||
setIsDismissed(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsDismissed(true);
|
||||
localStorage.setItem(key, "true");
|
||||
};
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
if (isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full mx-auto overflow-hidden mb-8 hidden md:block relative">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute top-2 right-2 p-2"
|
||||
aria-label={t('dismiss')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<CardContent className="grid gap-6 p-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Server className="text-blue-500" />
|
||||
{t('resources')}
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
{t('resourcesDescription')}
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li className="flex items-center gap-2">
|
||||
<Lock className="text-green-500 w-4 h-4" />
|
||||
{t('resourcesWireGuardConnect')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Key className="text-yellow-500 w-4 h-4" />
|
||||
{t('resourcesMultipleAuthenticationMethods')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Users className="text-purple-500 w-4 h-4" />
|
||||
{t('resourcesUsersRolesAccess')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourcesSplashCard;
|
|
@ -1,7 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { ResourcesDataTable } from "./ResourcesDataTable";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getPaginationRowModel,
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
@ -10,18 +19,16 @@ import {
|
|||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Copy,
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
MoreHorizontal,
|
||||
Check,
|
||||
ArrowUpRight,
|
||||
ShieldOff,
|
||||
ShieldCheck
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
|
@ -31,17 +38,37 @@ import CopyToClipboard from "@app/components/CopyToClipboard";
|
|||
import { Switch } from "@app/components/ui/switch";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@app/components/ui/table";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger
|
||||
} from "@app/components/ui/tabs";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
||||
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
|
||||
export type ResourceRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
orgId: string;
|
||||
domain: string;
|
||||
site: string;
|
||||
siteId: string;
|
||||
authState: string;
|
||||
http: boolean;
|
||||
protocol: string;
|
||||
|
@ -50,20 +77,147 @@ export type ResourceRow = {
|
|||
domainId?: string;
|
||||
};
|
||||
|
||||
type ResourcesTableProps = {
|
||||
resources: ResourceRow[];
|
||||
export type InternalResourceRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
orgId: string;
|
||||
siteName: string;
|
||||
protocol: string;
|
||||
proxyPort: number | null;
|
||||
siteId: number;
|
||||
siteNiceId: string;
|
||||
destinationIp: string;
|
||||
destinationPort: number;
|
||||
};
|
||||
|
||||
export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
type Site = ListSitesResponse["sites"][0];
|
||||
|
||||
type ResourcesTableProps = {
|
||||
resources: ResourceRow[];
|
||||
internalResources: InternalResourceRow[];
|
||||
orgId: string;
|
||||
defaultView?: "proxy" | "internal";
|
||||
};
|
||||
|
||||
export default function SitesTable({
|
||||
resources,
|
||||
internalResources,
|
||||
orgId,
|
||||
defaultView = "proxy"
|
||||
}: ResourcesTableProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const t = useTranslations();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedResource, setSelectedResource] =
|
||||
useState<ResourceRow | null>();
|
||||
const [selectedInternalResource, setSelectedInternalResource] =
|
||||
useState<InternalResourceRow | null>();
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [editingResource, setEditingResource] =
|
||||
useState<InternalResourceRow | null>();
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
|
||||
const [proxySorting, setProxySorting] = useState<SortingState>([]);
|
||||
const [proxyColumnFilters, setProxyColumnFilters] =
|
||||
useState<ColumnFiltersState>([]);
|
||||
const [proxyGlobalFilter, setProxyGlobalFilter] = useState<any>([]);
|
||||
|
||||
const [internalSorting, setInternalSorting] = useState<SortingState>([]);
|
||||
const [internalColumnFilters, setInternalColumnFilters] =
|
||||
useState<ColumnFiltersState>([]);
|
||||
const [internalGlobalFilter, setInternalGlobalFilter] = useState<any>([]);
|
||||
|
||||
const currentView = searchParams.get("view") || defaultView;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSites = async () => {
|
||||
try {
|
||||
const res = await api.get<AxiosResponse<ListSitesResponse>>(
|
||||
`/org/${orgId}/sites`
|
||||
);
|
||||
setSites(res.data.data.sites);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch sites:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (orgId) {
|
||||
fetchSites();
|
||||
}
|
||||
}, [orgId]);
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (value === "internal") {
|
||||
params.set("view", "internal");
|
||||
} else {
|
||||
params.delete("view");
|
||||
}
|
||||
|
||||
const newUrl = `${window.location.pathname}${params.toString() ? "?" + params.toString() : ""}`;
|
||||
router.replace(newUrl, { scroll: false });
|
||||
};
|
||||
|
||||
const getSearchInput = () => {
|
||||
if (currentView === "internal") {
|
||||
return (
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Input
|
||||
placeholder={t("resourcesSearch")}
|
||||
value={internalGlobalFilter ?? ""}
|
||||
onChange={(e) =>
|
||||
internalTable.setGlobalFilter(
|
||||
String(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full pl-8"
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Input
|
||||
placeholder={t("resourcesSearch")}
|
||||
value={proxyGlobalFilter ?? ""}
|
||||
onChange={(e) =>
|
||||
proxyTable.setGlobalFilter(String(e.target.value))
|
||||
}
|
||||
className="w-full pl-8"
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getActionButton = () => {
|
||||
if (currentView === "internal") {
|
||||
return (
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("resourceAdd")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
router.push(`/${orgId}/settings/resources/create`)
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("resourceAdd")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const deleteResource = (resourceId: number) => {
|
||||
api.delete(`/resource/${resourceId}`)
|
||||
|
@ -81,6 +235,26 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
});
|
||||
};
|
||||
|
||||
const deleteInternalResource = async (
|
||||
resourceId: number,
|
||||
siteId: number
|
||||
) => {
|
||||
try {
|
||||
await api.delete(
|
||||
`/org/${orgId}/site/${siteId}/resource/${resourceId}`
|
||||
);
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
} catch (e) {
|
||||
console.error(t("resourceErrorDelete"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorDelte"),
|
||||
description: formatAxiosError(e, t("v"))
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
async function toggleResourceEnabled(val: boolean, resourceId: number) {
|
||||
const res = await api
|
||||
.post<AxiosResponse<UpdateResourceResponse>>(
|
||||
|
@ -101,7 +275,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
});
|
||||
}
|
||||
|
||||
const columns: ColumnDef<ResourceRow>[] = [
|
||||
const proxyColumns: ColumnDef<ResourceRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
|
@ -118,35 +292,6 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "site",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("site")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<Link
|
||||
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteId}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{resourceRow.site}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "protocol",
|
||||
header: t("protocol"),
|
||||
|
@ -225,10 +370,12 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
<Switch
|
||||
defaultChecked={
|
||||
row.original.http
|
||||
? (!!row.original.domainId && row.original.enabled)
|
||||
? !!row.original.domainId && row.original.enabled
|
||||
: row.original.enabled
|
||||
}
|
||||
disabled={row.original.http ? !row.original.domainId : false}
|
||||
disabled={
|
||||
row.original.http ? !row.original.domainId : false
|
||||
}
|
||||
onCheckedChange={(val) =>
|
||||
toggleResourceEnabled(val, row.original.id)
|
||||
}
|
||||
|
@ -289,6 +436,163 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
}
|
||||
];
|
||||
|
||||
const internalColumns: ColumnDef<InternalResourceRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "siteName",
|
||||
header: t("siteName"),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<Link
|
||||
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteNiceId}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{resourceRow.siteName}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "protocol",
|
||||
header: t("protocol"),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return <span>{resourceRow.protocol.toUpperCase()}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "proxyPort",
|
||||
header: t("proxyPort"),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.proxyPort!.toString()}
|
||||
isLink={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "destination",
|
||||
header: t("resourcesTableDestination"),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
const destination = `${resourceRow.destinationIp}:${resourceRow.destinationPort}`;
|
||||
return <CopyToClipboard text={destination} isLink={false} />;
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedInternalResource(
|
||||
resourceRow
|
||||
);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingResource(resourceRow);
|
||||
setIsEditDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const proxyTable = useReactTable({
|
||||
data: resources,
|
||||
columns: proxyColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setProxySorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setProxyColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onGlobalFilterChange: setProxyGlobalFilter,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 20,
|
||||
pageIndex: 0
|
||||
}
|
||||
},
|
||||
state: {
|
||||
sorting: proxySorting,
|
||||
columnFilters: proxyColumnFilters,
|
||||
globalFilter: proxyGlobalFilter
|
||||
}
|
||||
});
|
||||
|
||||
const internalTable = useReactTable({
|
||||
data: internalResources,
|
||||
columns: internalColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setInternalSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setInternalColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onGlobalFilterChange: setInternalGlobalFilter,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 20,
|
||||
pageIndex: 0
|
||||
}
|
||||
},
|
||||
state: {
|
||||
sorting: internalSorting,
|
||||
columnFilters: internalColumnFilters,
|
||||
globalFilter: internalGlobalFilter
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedResource && (
|
||||
|
@ -320,11 +624,271 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
/>
|
||||
)}
|
||||
|
||||
<ResourcesDataTable
|
||||
columns={columns}
|
||||
data={resources}
|
||||
createResource={() => {
|
||||
router.push(`/${orgId}/settings/resources/create`);
|
||||
{selectedInternalResource && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedInternalResource(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<p className="mb-2">
|
||||
{t("resourceQuestionRemove", {
|
||||
selectedResource:
|
||||
selectedInternalResource?.name ||
|
||||
selectedInternalResource?.id
|
||||
})}
|
||||
</p>
|
||||
|
||||
<p className="mb-2">{t("resourceMessageRemove")}</p>
|
||||
|
||||
<p>{t("resourceMessageConfirm")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("resourceDeleteConfirm")}
|
||||
onConfirm={async () =>
|
||||
deleteInternalResource(
|
||||
selectedInternalResource!.id,
|
||||
selectedInternalResource!.siteId
|
||||
)
|
||||
}
|
||||
string={selectedInternalResource.name}
|
||||
title={t("resourceDelete")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<Card>
|
||||
<Tabs
|
||||
defaultValue={defaultView}
|
||||
className="w-full"
|
||||
onValueChange={handleTabChange}
|
||||
>
|
||||
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-0">
|
||||
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
|
||||
{getSearchInput()}
|
||||
|
||||
{env.flags.enableClients && (
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="proxy">
|
||||
{t("resourcesTableProxyResources")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="internal">
|
||||
{t("resourcesTableClientResources")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:justify-end">
|
||||
{getActionButton()}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TabsContent value="proxy">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{proxyTable
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map(
|
||||
(header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{proxyTable.getRowModel().rows
|
||||
?.length ? (
|
||||
proxyTable
|
||||
.getRowModel()
|
||||
.rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() &&
|
||||
"selected"
|
||||
}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
key={
|
||||
cell.id
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell
|
||||
.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={
|
||||
proxyColumns.length
|
||||
}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t(
|
||||
"resourcesTableNoProxyResourcesFound"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination table={proxyTable} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="internal">
|
||||
<div className="mb-4">
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription>
|
||||
{t(
|
||||
"resourcesTableTheseResourcesForUseWith"
|
||||
)}{" "}
|
||||
<Link
|
||||
href={`/${orgId}/settings/clients`}
|
||||
className="font-medium underline hover:opacity-80 inline-flex items-center"
|
||||
>
|
||||
{t("resourcesTableClients")}
|
||||
<ArrowUpRight className="ml-1 h-3 w-3" />
|
||||
</Link>{" "}
|
||||
{t(
|
||||
"resourcesTableAndOnlyAccessibleInternally"
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{internalTable
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map(
|
||||
(header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{internalTable.getRowModel().rows
|
||||
?.length ? (
|
||||
internalTable
|
||||
.getRowModel()
|
||||
.rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() &&
|
||||
"selected"
|
||||
}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
key={
|
||||
cell.id
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell
|
||||
.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={
|
||||
internalColumns.length
|
||||
}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t(
|
||||
"resourcesTableNoInternalResourcesFound"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination
|
||||
table={internalTable}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</CardContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{editingResource && (
|
||||
<EditInternalResourceDialog
|
||||
open={isEditDialogOpen}
|
||||
setOpen={setIsEditDialogOpen}
|
||||
resource={editingResource}
|
||||
orgId={orgId}
|
||||
onSuccess={() => {
|
||||
router.refresh();
|
||||
setEditingResource(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateInternalResourceDialog
|
||||
open={isCreateDialogOpen}
|
||||
setOpen={setIsCreateDialogOpen}
|
||||
orgId={orgId}
|
||||
sites={sites}
|
||||
onSuccess={() => {
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -10,35 +10,22 @@ import {
|
|||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useDockerSocket } from "@app/hooks/useDockerSocket";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RotateCw } from "lucide-react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { build } from "@server/build";
|
||||
|
||||
type ResourceInfoBoxType = {};
|
||||
|
||||
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
const { resource, authInfo, site } = useResourceContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { resource, authInfo } = useResourceContext();
|
||||
|
||||
const { isEnabled, isAvailable } = useDockerSocket(site!);
|
||||
const t = useTranslations();
|
||||
|
||||
const fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("resourceInfo")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mt-4">
|
||||
<InfoSections cols={4}>
|
||||
<AlertDescription>
|
||||
<InfoSections cols={3}>
|
||||
{resource.http ? (
|
||||
<>
|
||||
<InfoSection>
|
||||
|
@ -71,12 +58,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("site")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{resource.siteName}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{/* {isEnabled && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Socket</InfoSectionTitle>
|
||||
|
@ -117,7 +98,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{build == "oss" && (
|
||||
{/* {build == "oss" && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("externalProxyEnabled")}
|
||||
|
@ -130,7 +111,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
)} */}
|
||||
</>
|
||||
)}
|
||||
<InfoSection>
|
||||
|
|
|
@ -49,6 +49,15 @@ import { UserType } from "@server/types/UserTypes";
|
|||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CheckboxWithLabel } from "@app/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
|
||||
const UsersRolesFormSchema = z.object({
|
||||
roles: z.array(
|
||||
|
@ -110,6 +119,14 @@ export default function ResourceAuthenticationPage() {
|
|||
resource.emailWhitelistEnabled
|
||||
);
|
||||
|
||||
const [autoLoginEnabled, setAutoLoginEnabled] = useState(
|
||||
resource.skipToIdpId !== null && resource.skipToIdpId !== undefined
|
||||
);
|
||||
const [selectedIdpId, setSelectedIdpId] = useState<number | null>(
|
||||
resource.skipToIdpId || null
|
||||
);
|
||||
const [allIdps, setAllIdps] = useState<{ id: number; text: string }[]>([]);
|
||||
|
||||
const [loadingSaveUsersRoles, setLoadingSaveUsersRoles] = useState(false);
|
||||
const [loadingSaveWhitelist, setLoadingSaveWhitelist] = useState(false);
|
||||
|
||||
|
@ -139,7 +156,8 @@ export default function ResourceAuthenticationPage() {
|
|||
resourceRolesResponse,
|
||||
usersResponse,
|
||||
resourceUsersResponse,
|
||||
whitelist
|
||||
whitelist,
|
||||
idpsResponse
|
||||
] = await Promise.all([
|
||||
api.get<AxiosResponse<ListRolesResponse>>(
|
||||
`/org/${org?.org.orgId}/roles`
|
||||
|
@ -155,7 +173,12 @@ export default function ResourceAuthenticationPage() {
|
|||
),
|
||||
api.get<AxiosResponse<GetResourceWhitelistResponse>>(
|
||||
`/resource/${resource.resourceId}/whitelist`
|
||||
)
|
||||
),
|
||||
api.get<
|
||||
AxiosResponse<{
|
||||
idps: { idpId: number; name: string }[];
|
||||
}>
|
||||
>("/idp")
|
||||
]);
|
||||
|
||||
setAllRoles(
|
||||
|
@ -200,6 +223,21 @@ export default function ResourceAuthenticationPage() {
|
|||
}))
|
||||
);
|
||||
|
||||
setAllIdps(
|
||||
idpsResponse.data.data.idps.map((idp) => ({
|
||||
id: idp.idpId,
|
||||
text: idp.name
|
||||
}))
|
||||
);
|
||||
|
||||
if (
|
||||
autoLoginEnabled &&
|
||||
!selectedIdpId &&
|
||||
idpsResponse.data.data.idps.length > 0
|
||||
) {
|
||||
setSelectedIdpId(idpsResponse.data.data.idps[0].idpId);
|
||||
}
|
||||
|
||||
setPageLoading(false);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
@ -260,6 +298,16 @@ export default function ResourceAuthenticationPage() {
|
|||
try {
|
||||
setLoadingSaveUsersRoles(true);
|
||||
|
||||
// Validate that an IDP is selected if auto login is enabled
|
||||
if (autoLoginEnabled && !selectedIdpId) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("error"),
|
||||
description: t("selectIdpRequired")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const jobs = [
|
||||
api.post(`/resource/${resource.resourceId}/roles`, {
|
||||
roleIds: data.roles.map((i) => parseInt(i.id))
|
||||
|
@ -268,14 +316,16 @@ export default function ResourceAuthenticationPage() {
|
|||
userIds: data.users.map((i) => i.id)
|
||||
}),
|
||||
api.post(`/resource/${resource.resourceId}`, {
|
||||
sso: ssoEnabled
|
||||
sso: ssoEnabled,
|
||||
skipToIdpId: autoLoginEnabled ? selectedIdpId : null
|
||||
})
|
||||
];
|
||||
|
||||
await Promise.all(jobs);
|
||||
|
||||
updateResource({
|
||||
sso: ssoEnabled
|
||||
sso: ssoEnabled,
|
||||
skipToIdpId: autoLoginEnabled ? selectedIdpId : null
|
||||
});
|
||||
|
||||
updateAuthInfo({
|
||||
|
@ -542,6 +592,89 @@ export default function ResourceAuthenticationPage() {
|
|||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{ssoEnabled && allIdps.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<div className="space-y-2 mb-3">
|
||||
<CheckboxWithLabel
|
||||
label={t(
|
||||
"autoLoginExternalIdp"
|
||||
)}
|
||||
checked={autoLoginEnabled}
|
||||
onCheckedChange={(
|
||||
checked
|
||||
) => {
|
||||
setAutoLoginEnabled(
|
||||
checked as boolean
|
||||
);
|
||||
if (
|
||||
checked &&
|
||||
allIdps.length > 0
|
||||
) {
|
||||
setSelectedIdpId(
|
||||
allIdps[0].id
|
||||
);
|
||||
} else {
|
||||
setSelectedIdpId(
|
||||
null
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"autoLoginExternalIdpDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{autoLoginEnabled && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{t("selectIdp")}
|
||||
</label>
|
||||
<Select
|
||||
onValueChange={(
|
||||
value
|
||||
) =>
|
||||
setSelectedIdpId(
|
||||
parseInt(value)
|
||||
)
|
||||
}
|
||||
value={
|
||||
selectedIdpId
|
||||
? selectedIdpId.toString()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectIdpPlaceholder"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allIdps.map(
|
||||
(idp) => (
|
||||
<SelectItem
|
||||
key={
|
||||
idp.id
|
||||
}
|
||||
value={idp.id.toString()}
|
||||
>
|
||||
{
|
||||
idp.text
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
|
|
|
@ -14,19 +14,6 @@ import {
|
|||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem
|
||||
} from "@/components/ui/command";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@/components/ui/popover";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { useEffect, useState } from "react";
|
||||
|
@ -45,25 +32,11 @@ import {
|
|||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import CustomDomainInput from "../CustomDomainInput";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { subdomainSchema, tlsNameSchema } 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,
|
||||
updateResourceRule
|
||||
} from "@server/routers/resource";
|
||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
|
@ -81,12 +54,6 @@ import DomainPicker from "@app/components/DomainPicker";
|
|||
import { Globe } from "lucide-react";
|
||||
import { build } from "@server/build";
|
||||
|
||||
const TransferFormSchema = z.object({
|
||||
siteId: z.number()
|
||||
});
|
||||
|
||||
type TransferFormValues = z.infer<typeof TransferFormSchema>;
|
||||
|
||||
export default function GeneralForm() {
|
||||
const [formKey, setFormKey] = useState(0);
|
||||
const params = useParams();
|
||||
|
@ -127,7 +94,7 @@ export default function GeneralForm() {
|
|||
name: z.string().min(1).max(255),
|
||||
domainId: z.string().optional(),
|
||||
proxyPort: z.number().int().min(1).max(65535).optional(),
|
||||
enableProxy: z.boolean().optional()
|
||||
// enableProxy: z.boolean().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
|
@ -156,18 +123,11 @@ export default function GeneralForm() {
|
|||
subdomain: resource.subdomain ? resource.subdomain : undefined,
|
||||
domainId: resource.domainId || undefined,
|
||||
proxyPort: resource.proxyPort || undefined,
|
||||
enableProxy: resource.enableProxy || false
|
||||
// enableProxy: resource.enableProxy || false
|
||||
},
|
||||
mode: "onChange"
|
||||
});
|
||||
|
||||
const transferForm = useForm<TransferFormValues>({
|
||||
resolver: zodResolver(TransferFormSchema),
|
||||
defaultValues: {
|
||||
siteId: resource.siteId ? Number(resource.siteId) : undefined
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSites = async () => {
|
||||
const res = await api.get<AxiosResponse<ListSitesResponse>>(
|
||||
|
@ -221,9 +181,9 @@ export default function GeneralForm() {
|
|||
subdomain: data.subdomain,
|
||||
domainId: data.domainId,
|
||||
proxyPort: data.proxyPort,
|
||||
...(!resource.http && {
|
||||
enableProxy: data.enableProxy
|
||||
})
|
||||
// ...(!resource.http && {
|
||||
// enableProxy: data.enableProxy
|
||||
// })
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
|
@ -251,9 +211,9 @@ export default function GeneralForm() {
|
|||
subdomain: data.subdomain,
|
||||
fullDomain: resource.fullDomain,
|
||||
proxyPort: data.proxyPort,
|
||||
...(!resource.http && {
|
||||
enableProxy: data.enableProxy
|
||||
}),
|
||||
// ...(!resource.http && {
|
||||
// enableProxy: data.enableProxy
|
||||
// })
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
|
@ -261,40 +221,6 @@ export default function GeneralForm() {
|
|||
setSaveLoading(false);
|
||||
}
|
||||
|
||||
async function onTransfer(data: TransferFormValues) {
|
||||
setTransferLoading(true);
|
||||
|
||||
const res = await api
|
||||
.post(`resource/${resource?.resourceId}/transfer`, {
|
||||
siteId: data.siteId
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorTransfer"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("resourceErrorTransferDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
title: t("resourceTransferred"),
|
||||
description: t("resourceTransferredDescription")
|
||||
});
|
||||
router.refresh();
|
||||
|
||||
updateResource({
|
||||
siteName:
|
||||
sites.find((site) => site.siteId === data.siteId)?.name ||
|
||||
""
|
||||
});
|
||||
}
|
||||
setTransferLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
!loadingPage && (
|
||||
<>
|
||||
|
@ -410,7 +336,7 @@ export default function GeneralForm() {
|
|||
)}
|
||||
/>
|
||||
|
||||
{build == "oss" && (
|
||||
{/* {build == "oss" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableProxy"
|
||||
|
@ -444,13 +370,15 @@ export default function GeneralForm() {
|
|||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
)} */}
|
||||
</>
|
||||
)}
|
||||
|
||||
{resource.http && (
|
||||
<div className="space-y-2">
|
||||
<Label>Domain</Label>
|
||||
<Label>
|
||||
{t("resourceDomain")}
|
||||
</Label>
|
||||
<div className="border p-2 rounded-md flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Globe size="14" />
|
||||
|
@ -466,7 +394,9 @@ export default function GeneralForm() {
|
|||
)
|
||||
}
|
||||
>
|
||||
Edit Domain
|
||||
{t(
|
||||
"resourceEditDomain"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -490,140 +420,6 @@ export default function GeneralForm() {
|
|||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceTransfer")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourceTransferDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...transferForm}>
|
||||
<form
|
||||
onSubmit={transferForm.handleSubmit(
|
||||
onTransfer
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="transfer-form"
|
||||
>
|
||||
<FormField
|
||||
control={transferForm.control}
|
||||
name="siteId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("siteDestination")}
|
||||
</FormLabel>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
!field.value &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? sites.find(
|
||||
(
|
||||
site
|
||||
) =>
|
||||
site.siteId ===
|
||||
field.value
|
||||
)
|
||||
?.name
|
||||
: t(
|
||||
"siteSelect"
|
||||
)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"searchSites"
|
||||
)}
|
||||
/>
|
||||
<CommandEmpty>
|
||||
{t(
|
||||
"sitesNotFound"
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sites.map(
|
||||
(
|
||||
site
|
||||
) => (
|
||||
<CommandItem
|
||||
value={`${site.name}:${site.siteId}`}
|
||||
key={
|
||||
site.siteId
|
||||
}
|
||||
onSelect={() => {
|
||||
transferForm.setValue(
|
||||
"siteId",
|
||||
site.siteId
|
||||
);
|
||||
setOpen(
|
||||
false
|
||||
);
|
||||
}}
|
||||
>
|
||||
{
|
||||
site.name
|
||||
}
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
site.siteId ===
|
||||
field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={transferLoading}
|
||||
disabled={transferLoading}
|
||||
form="transfer-form"
|
||||
>
|
||||
{t("resourceTransferSubmit")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
|
||||
<Credenza
|
||||
|
|
|
@ -29,7 +29,6 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
|||
|
||||
let authInfo = null;
|
||||
let resource = null;
|
||||
let site = null;
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<GetResourceResponse>>(
|
||||
`/resource/${params.resourceId}`,
|
||||
|
@ -44,19 +43,6 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
|||
redirect(`/${params.orgId}/settings/resources`);
|
||||
}
|
||||
|
||||
// Fetch site info
|
||||
if (resource.siteId) {
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<GetSiteResponse>>(
|
||||
`/site/${resource.siteId}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
site = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${params.orgId}/settings/resources`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<GetResourceAuthInfoResponse>
|
||||
|
@ -119,7 +105,6 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
|||
|
||||
<OrgProvider org={org}>
|
||||
<ResourceProvider
|
||||
site={site}
|
||||
resource={resource}
|
||||
authInfo={authInfo}
|
||||
>
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
import { useEffect, useState, use } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
@ -34,12 +33,12 @@ import {
|
|||
getPaginationRowModel,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
flexRender
|
||||
flexRender,
|
||||
Row
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
|
@ -51,7 +50,7 @@ import { ArrayElement } from "@server/types/ArrayElement";
|
|||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { GetSiteResponse } from "@server/routers/site";
|
||||
import { GetSiteResponse, ListSitesResponse } from "@server/routers/site";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
|
@ -59,28 +58,48 @@ import {
|
|||
SettingsSectionTitle,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionGrid
|
||||
SettingsSectionForm
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { isTargetValid } from "@server/lib/validators";
|
||||
import { tlsNameSchema } from "@server/lib/schemas";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger
|
||||
} from "@app/components/ui/collapsible";
|
||||
CheckIcon,
|
||||
ChevronsUpDown,
|
||||
Settings,
|
||||
Heart,
|
||||
Check,
|
||||
CircleCheck,
|
||||
CircleX
|
||||
} from "lucide-react";
|
||||
import { ContainersSelector } from "@app/components/ContainersSelector";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
||||
import { Container } from "@server/routers/site";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
|
||||
const addTargetSchema = z.object({
|
||||
ip: z.string().refine(isTargetValid),
|
||||
method: z.string().nullable(),
|
||||
port: z.coerce.number().int().positive()
|
||||
port: z.coerce.number().int().positive(),
|
||||
siteId: z.number().int().positive()
|
||||
});
|
||||
|
||||
const targetsSettingsSchema = z.object({
|
||||
|
@ -91,12 +110,13 @@ type LocalTarget = Omit<
|
|||
ArrayElement<ListTargetsResponse["targets"]> & {
|
||||
new?: boolean;
|
||||
updated?: boolean;
|
||||
siteType: string | null;
|
||||
},
|
||||
"protocol"
|
||||
>;
|
||||
|
||||
export default function ReverseProxyTargets(props: {
|
||||
params: Promise<{ resourceId: number }>;
|
||||
params: Promise<{ resourceId: number; orgId: string }>;
|
||||
}) {
|
||||
const params = use(props.params);
|
||||
const t = useTranslations();
|
||||
|
@ -106,15 +126,48 @@ export default function ReverseProxyTargets(props: {
|
|||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const [targets, setTargets] = useState<LocalTarget[]>([]);
|
||||
const [site, setSite] = useState<GetSiteResponse>();
|
||||
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
|
||||
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
||||
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(new Map());
|
||||
|
||||
const initializeDockerForSite = async (siteId: number) => {
|
||||
if (dockerStates.has(siteId)) {
|
||||
return; // Already initialized
|
||||
}
|
||||
|
||||
const dockerManager = new DockerManager(api, siteId);
|
||||
const dockerState = await dockerManager.initializeDocker();
|
||||
|
||||
setDockerStates(prev => new Map(prev.set(siteId, dockerState)));
|
||||
};
|
||||
|
||||
const refreshContainersForSite = async (siteId: number) => {
|
||||
const dockerManager = new DockerManager(api, siteId);
|
||||
const containers = await dockerManager.fetchContainers();
|
||||
|
||||
setDockerStates(prev => {
|
||||
const newMap = new Map(prev);
|
||||
const existingState = newMap.get(siteId);
|
||||
if (existingState) {
|
||||
newMap.set(siteId, { ...existingState, containers });
|
||||
}
|
||||
return newMap;
|
||||
});
|
||||
};
|
||||
|
||||
const getDockerStateForSite = (siteId: number): DockerState => {
|
||||
return dockerStates.get(siteId) || {
|
||||
isEnabled: false,
|
||||
isAvailable: false,
|
||||
containers: []
|
||||
};
|
||||
};
|
||||
|
||||
const [httpsTlsLoading, setHttpsTlsLoading] = useState(false);
|
||||
const [targetsLoading, setTargetsLoading] = useState(false);
|
||||
const [proxySettingsLoading, setProxySettingsLoading] = useState(false);
|
||||
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const proxySettingsSchema = z.object({
|
||||
|
@ -167,6 +220,14 @@ export default function ReverseProxyTargets(props: {
|
|||
|
||||
const watchedIp = addTargetForm.watch("ip");
|
||||
const watchedPort = addTargetForm.watch("port");
|
||||
const watchedSiteId = addTargetForm.watch("siteId");
|
||||
|
||||
const handleContainerSelect = (hostname: string, port?: number) => {
|
||||
addTargetForm.setValue("ip", hostname);
|
||||
if (port) {
|
||||
addTargetForm.setValue("port", port);
|
||||
}
|
||||
};
|
||||
|
||||
const tlsSettingsForm = useForm<TlsSettingsValues>({
|
||||
resolver: zodResolver(tlsSettingsSchema),
|
||||
|
@ -216,28 +277,64 @@ export default function ReverseProxyTargets(props: {
|
|||
};
|
||||
fetchTargets();
|
||||
|
||||
const fetchSite = async () => {
|
||||
try {
|
||||
const res = await api.get<AxiosResponse<GetSiteResponse>>(
|
||||
`/site/${resource.siteId}`
|
||||
);
|
||||
|
||||
if (res.status === 200) {
|
||||
setSite(res.data.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const fetchSites = async () => {
|
||||
const res = await api
|
||||
.get<
|
||||
AxiosResponse<ListSitesResponse>
|
||||
>(`/org/${params.orgId}/sites`)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("siteErrorFetch"),
|
||||
title: t("sitesErrorFetch"),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
t("siteErrorFetchDescription")
|
||||
e,
|
||||
t("sitesErrorFetchDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res?.status === 200) {
|
||||
setSites(res.data.data.sites);
|
||||
|
||||
// Initialize Docker for newt sites
|
||||
const newtSites = res.data.data.sites.filter(site => site.type === "newt");
|
||||
for (const site of newtSites) {
|
||||
initializeDockerForSite(site.siteId);
|
||||
}
|
||||
|
||||
// If there's only one site, set it as the default in the form
|
||||
if (res.data.data.sites.length) {
|
||||
addTargetForm.setValue(
|
||||
"siteId",
|
||||
res.data.data.sites[0].siteId
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchSite();
|
||||
fetchSites();
|
||||
|
||||
// const fetchSite = async () => {
|
||||
// try {
|
||||
// const res = await api.get<AxiosResponse<GetSiteResponse>>(
|
||||
// `/site/${resource.siteId}`
|
||||
// );
|
||||
//
|
||||
// if (res.status === 200) {
|
||||
// setSite(res.data.data);
|
||||
// }
|
||||
// } catch (err) {
|
||||
// console.error(err);
|
||||
// toast({
|
||||
// variant: "destructive",
|
||||
// title: t("siteErrorFetch"),
|
||||
// description: formatAxiosError(
|
||||
// err,
|
||||
// t("siteErrorFetchDescription")
|
||||
// )
|
||||
// });
|
||||
// }
|
||||
// };
|
||||
// fetchSite();
|
||||
}, []);
|
||||
|
||||
async function addTarget(data: z.infer<typeof addTargetSchema>) {
|
||||
|
@ -246,7 +343,8 @@ export default function ReverseProxyTargets(props: {
|
|||
(target) =>
|
||||
target.ip === data.ip &&
|
||||
target.port === data.port &&
|
||||
target.method === data.method
|
||||
target.method === data.method &&
|
||||
target.siteId === data.siteId
|
||||
);
|
||||
|
||||
if (isDuplicate) {
|
||||
|
@ -258,34 +356,37 @@ export default function ReverseProxyTargets(props: {
|
|||
return;
|
||||
}
|
||||
|
||||
if (site && site.type == "wireguard" && site.subnet) {
|
||||
// make sure that the target IP is within the site subnet
|
||||
const targetIp = data.ip;
|
||||
const subnet = site.subnet;
|
||||
try {
|
||||
if (!isIPInSubnet(targetIp, subnet)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("targetWireGuardErrorInvalidIp"),
|
||||
description: t(
|
||||
"targetWireGuardErrorInvalidIpDescription"
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("targetWireGuardErrorInvalidIp"),
|
||||
description: t("targetWireGuardErrorInvalidIpDescription")
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
// if (site && site.type == "wireguard" && site.subnet) {
|
||||
// // make sure that the target IP is within the site subnet
|
||||
// const targetIp = data.ip;
|
||||
// const subnet = site.subnet;
|
||||
// try {
|
||||
// if (!isIPInSubnet(targetIp, subnet)) {
|
||||
// toast({
|
||||
// variant: "destructive",
|
||||
// title: t("targetWireGuardErrorInvalidIp"),
|
||||
// description: t(
|
||||
// "targetWireGuardErrorInvalidIpDescription"
|
||||
// )
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error(error);
|
||||
// toast({
|
||||
// variant: "destructive",
|
||||
// title: t("targetWireGuardErrorInvalidIp"),
|
||||
// description: t("targetWireGuardErrorInvalidIpDescription")
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
|
||||
const site = sites.find((site) => site.siteId === data.siteId);
|
||||
|
||||
const newTarget: LocalTarget = {
|
||||
...data,
|
||||
siteType: site?.type || null,
|
||||
enabled: true,
|
||||
targetId: new Date().getTime(),
|
||||
new: true,
|
||||
|
@ -311,10 +412,16 @@ export default function ReverseProxyTargets(props: {
|
|||
};
|
||||
|
||||
async function updateTarget(targetId: number, data: Partial<LocalTarget>) {
|
||||
const site = sites.find((site) => site.siteId === data.siteId);
|
||||
setTargets(
|
||||
targets.map((target) =>
|
||||
target.targetId === targetId
|
||||
? { ...target, ...data, updated: true }
|
||||
? {
|
||||
...target,
|
||||
...data,
|
||||
updated: true,
|
||||
siteType: site?.type || null
|
||||
}
|
||||
: target
|
||||
)
|
||||
);
|
||||
|
@ -332,7 +439,8 @@ export default function ReverseProxyTargets(props: {
|
|||
ip: target.ip,
|
||||
port: target.port,
|
||||
method: target.method,
|
||||
enabled: target.enabled
|
||||
enabled: target.enabled,
|
||||
siteId: target.siteId
|
||||
};
|
||||
|
||||
if (target.new) {
|
||||
|
@ -403,6 +511,135 @@ export default function ReverseProxyTargets(props: {
|
|||
}
|
||||
|
||||
const columns: ColumnDef<LocalTarget>[] = [
|
||||
{
|
||||
accessorKey: "siteId",
|
||||
header: t("site"),
|
||||
cell: ({ row }) => {
|
||||
const selectedSite = sites.find(
|
||||
(site) => site.siteId === row.original.siteId
|
||||
);
|
||||
|
||||
const handleContainerSelectForTarget = (
|
||||
hostname: string,
|
||||
port?: number
|
||||
) => {
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
ip: hostname
|
||||
});
|
||||
if (port) {
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
port: port
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"justify-between flex-1",
|
||||
!row.original.siteId &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{row.original.siteId
|
||||
? selectedSite?.name
|
||||
: t("siteSelect")}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("siteSearch")}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t("siteNotFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sites.map((site) => (
|
||||
<CommandItem
|
||||
value={`${site.siteId}:${site.name}:${site.niceId}`}
|
||||
key={site.siteId}
|
||||
onSelect={() => {
|
||||
updateTarget(
|
||||
row.original
|
||||
.targetId,
|
||||
{
|
||||
siteId: site.siteId
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
site.siteId ===
|
||||
row.original
|
||||
.siteId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{site.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{selectedSite && selectedSite.type === "newt" && (() => {
|
||||
const dockerState = getDockerStateForSite(selectedSite.siteId);
|
||||
return (
|
||||
<ContainersSelector
|
||||
site={selectedSite}
|
||||
containers={dockerState.containers}
|
||||
isAvailable={dockerState.isAvailable}
|
||||
onContainerSelect={handleContainerSelectForTarget}
|
||||
onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
...(resource.http
|
||||
? [
|
||||
{
|
||||
accessorKey: "method",
|
||||
header: t("method"),
|
||||
cell: ({ row }: { row: Row<LocalTarget> }) => (
|
||||
<Select
|
||||
defaultValue={row.original.method ?? ""}
|
||||
onValueChange={(value) =>
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
method: value
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{row.original.method}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">http</SelectItem>
|
||||
<SelectItem value="https">https</SelectItem>
|
||||
<SelectItem value="h2c">h2c</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: t("targetAddr"),
|
||||
|
@ -412,6 +649,7 @@ export default function ReverseProxyTargets(props: {
|
|||
className="min-w-[150px]"
|
||||
onBlur={(e) =>
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
ip: e.target.value
|
||||
})
|
||||
}
|
||||
|
@ -428,6 +666,7 @@ export default function ReverseProxyTargets(props: {
|
|||
className="min-w-[100px]"
|
||||
onBlur={(e) =>
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
port: parseInt(e.target.value, 10)
|
||||
})
|
||||
}
|
||||
|
@ -459,7 +698,10 @@ export default function ReverseProxyTargets(props: {
|
|||
<Switch
|
||||
defaultChecked={row.original.enabled}
|
||||
onCheckedChange={(val) =>
|
||||
updateTarget(row.original.targetId, { enabled: val })
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
enabled: val
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
@ -489,33 +731,6 @@ export default function ReverseProxyTargets(props: {
|
|||
}
|
||||
];
|
||||
|
||||
if (resource.http) {
|
||||
const methodCol: ColumnDef<LocalTarget> = {
|
||||
accessorKey: "method",
|
||||
header: t("method"),
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.method ?? ""}
|
||||
onValueChange={(value) =>
|
||||
updateTarget(row.original.targetId, { method: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="min-w-[100px]">
|
||||
{row.original.method}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">http</SelectItem>
|
||||
<SelectItem value="https">https</SelectItem>
|
||||
<SelectItem value="h2c">h2c</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
};
|
||||
|
||||
// add this to the first column
|
||||
columns.unshift(methodCol);
|
||||
}
|
||||
|
||||
const table = useReactTable({
|
||||
data: targets,
|
||||
columns,
|
||||
|
@ -545,54 +760,130 @@ export default function ReverseProxyTargets(props: {
|
|||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...targetsSettingsForm}>
|
||||
<form
|
||||
onSubmit={targetsSettingsForm.handleSubmit(
|
||||
saveAllSettings
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="targets-settings-form"
|
||||
>
|
||||
{targets.length >= 2 && (
|
||||
<FormField
|
||||
control={targetsSettingsForm.control}
|
||||
name="stickySession"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="sticky-toggle"
|
||||
label={t(
|
||||
"targetStickySessions"
|
||||
)}
|
||||
description={t(
|
||||
"targetStickySessionsDescription"
|
||||
)}
|
||||
defaultChecked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={(
|
||||
val
|
||||
) => {
|
||||
field.onChange(val);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
|
||||
<div className="p-4 border rounded-md">
|
||||
<Form {...addTargetForm}>
|
||||
<form
|
||||
onSubmit={addTargetForm.handleSubmit(addTarget)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-start">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 items-start">
|
||||
<FormField
|
||||
control={addTargetForm.control}
|
||||
name="siteId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>
|
||||
{t("site")}
|
||||
</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"justify-between flex-1",
|
||||
!field.value &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? sites.find(
|
||||
(
|
||||
site
|
||||
) =>
|
||||
site.siteId ===
|
||||
field.value
|
||||
)
|
||||
?.name
|
||||
: t(
|
||||
"siteSelect"
|
||||
)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"siteSearch"
|
||||
)}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t(
|
||||
"siteNotFound"
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sites.map(
|
||||
(
|
||||
site
|
||||
) => (
|
||||
<CommandItem
|
||||
value={`${site.siteId}:${site.name}:${site.niceId}`}
|
||||
key={
|
||||
site.siteId
|
||||
}
|
||||
onSelect={() => {
|
||||
addTargetForm.setValue(
|
||||
"siteId",
|
||||
site.siteId
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
site.siteId ===
|
||||
field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{
|
||||
site.name
|
||||
}
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{field.value &&
|
||||
(() => {
|
||||
const selectedSite =
|
||||
sites.find(
|
||||
(site) =>
|
||||
site.siteId ===
|
||||
field.value
|
||||
);
|
||||
return selectedSite &&
|
||||
selectedSite.type ===
|
||||
"newt" ? (() => {
|
||||
const dockerState = getDockerStateForSite(selectedSite.siteId);
|
||||
return (
|
||||
<ContainersSelector
|
||||
site={selectedSite}
|
||||
containers={dockerState.containers}
|
||||
isAvailable={dockerState.isAvailable}
|
||||
onContainerSelect={handleContainerSelect}
|
||||
onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
|
||||
/>
|
||||
);
|
||||
})() : null;
|
||||
})()}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{resource.http && (
|
||||
<FormField
|
||||
control={addTargetForm.control}
|
||||
|
@ -657,26 +948,6 @@ export default function ReverseProxyTargets(props: {
|
|||
<FormControl>
|
||||
<Input id="ip" {...field} />
|
||||
</FormControl>
|
||||
{site && site.type == "newt" && (
|
||||
<ContainersSelector
|
||||
site={site}
|
||||
onContainerSelect={(
|
||||
hostname,
|
||||
port
|
||||
) => {
|
||||
addTargetForm.setValue(
|
||||
"ip",
|
||||
hostname
|
||||
);
|
||||
if (port) {
|
||||
addTargetForm.setValue(
|
||||
"port",
|
||||
port
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
@ -712,33 +983,102 @@ export default function ReverseProxyTargets(props: {
|
|||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{targets.length > 0 ? (
|
||||
<>
|
||||
<h6 className="font-semibold">
|
||||
{t("targetsList")}
|
||||
</h6>
|
||||
<SettingsSectionForm>
|
||||
<Form {...targetsSettingsForm}>
|
||||
<form
|
||||
onSubmit={targetsSettingsForm.handleSubmit(
|
||||
saveAllSettings
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="targets-settings-form"
|
||||
>
|
||||
<FormField
|
||||
control={
|
||||
targetsSettingsForm.control
|
||||
}
|
||||
name="stickySession"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="sticky-toggle"
|
||||
label={t(
|
||||
"targetStickySessions"
|
||||
)}
|
||||
description={t(
|
||||
"targetStickySessionsDescription"
|
||||
)}
|
||||
defaultChecked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={(
|
||||
val
|
||||
) => {
|
||||
field.onChange(
|
||||
val
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
<div className="">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{headerGroup.headers.map(
|
||||
(header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
table
|
||||
.getRowModel()
|
||||
.rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
key={
|
||||
cell.id
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell
|
||||
.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
|
@ -760,6 +1100,15 @@ export default function ReverseProxyTargets(props: {
|
|||
{/* {t('targetNoOneDescription')} */}
|
||||
{/* </TableCaption> */}
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">
|
||||
{t("targetNoOne")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
|
@ -885,7 +1234,7 @@ export default function ReverseProxyTargets(props: {
|
|||
proxySettingsLoading
|
||||
}
|
||||
>
|
||||
{t("saveAllSettings")}
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,26 +1,40 @@
|
|||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import ResourcesTable, { ResourceRow } from "./ResourcesTable";
|
||||
import ResourcesTable, {
|
||||
ResourceRow,
|
||||
InternalResourceRow
|
||||
} from "./ResourcesTable";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { ListResourcesResponse } from "@server/routers/resource";
|
||||
import { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import { GetOrgResponse } from "@server/routers/org";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import ResourcesSplashCard from "./ResourcesSplashCard";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
|
||||
type ResourcesPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
searchParams: Promise<{ view?: string }>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
const t = await getTranslations();
|
||||
|
||||
const env = pullEnv();
|
||||
|
||||
// Default to 'proxy' view, or use the query param if provided
|
||||
let defaultView: "proxy" | "internal" = "proxy";
|
||||
if (env.flags.enableClients) {
|
||||
defaultView = searchParams.view === "internal" ? "internal" : "proxy";
|
||||
}
|
||||
|
||||
let resources: ListResourcesResponse["resources"] = [];
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
|
||||
|
@ -30,6 +44,14 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
|||
resources = res.data.data.resources;
|
||||
} catch (e) {}
|
||||
|
||||
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
||||
try {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<ListAllSiteResourcesByOrgResponse>
|
||||
>(`/org/${params.orgId}/site-resources`, await authCookieHeader());
|
||||
siteResources = res.data.data.siteResources;
|
||||
} catch (e) {}
|
||||
|
||||
let org = null;
|
||||
try {
|
||||
const getOrg = cache(async () =>
|
||||
|
@ -54,8 +76,6 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
|||
name: resource.name,
|
||||
orgId: params.orgId,
|
||||
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
|
||||
site: resource.siteName || t('none'),
|
||||
siteId: resource.siteId || t('unknown'),
|
||||
protocol: resource.protocol,
|
||||
proxyPort: resource.proxyPort,
|
||||
http: resource.http,
|
||||
|
@ -72,17 +92,39 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
|||
};
|
||||
});
|
||||
|
||||
const internalResourceRows: InternalResourceRow[] = siteResources.map(
|
||||
(siteResource) => {
|
||||
return {
|
||||
id: siteResource.siteResourceId,
|
||||
name: siteResource.name,
|
||||
orgId: params.orgId,
|
||||
siteName: siteResource.siteName,
|
||||
protocol: siteResource.protocol,
|
||||
proxyPort: siteResource.proxyPort,
|
||||
siteId: siteResource.siteId,
|
||||
destinationIp: siteResource.destinationIp,
|
||||
destinationPort: siteResource.destinationPort,
|
||||
siteNiceId: siteResource.siteNiceId
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* <ResourcesSplashCard /> */}
|
||||
|
||||
<SettingsSectionTitle
|
||||
title={t('resourceTitle')}
|
||||
description={t('resourceDescription')}
|
||||
title={t("resourceTitle")}
|
||||
description={t("resourceDescription")}
|
||||
/>
|
||||
|
||||
<OrgProvider org={org}>
|
||||
<ResourcesTable resources={resourceRows} orgId={params.orgId} />
|
||||
<ResourcesTable
|
||||
resources={resourceRows}
|
||||
internalResources={internalResourceRows}
|
||||
orgId={params.orgId}
|
||||
defaultView={
|
||||
env.flags.enableClients ? defaultView : "proxy"
|
||||
}
|
||||
/>
|
||||
</OrgProvider>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -98,7 +98,6 @@ export default function CreateShareLinkForm({
|
|||
resourceId: number;
|
||||
name: string;
|
||||
resourceUrl: string;
|
||||
siteName: string | null;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
|
@ -160,8 +159,7 @@ export default function CreateShareLinkForm({
|
|||
.map((r) => ({
|
||||
resourceId: r.resourceId,
|
||||
name: r.name,
|
||||
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`,
|
||||
siteName: r.siteName
|
||||
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
@ -236,8 +234,7 @@ export default function CreateShareLinkForm({
|
|||
resourceName: values.resourceName,
|
||||
title: token.title,
|
||||
createdAt: token.createdAt,
|
||||
expiresAt: token.expiresAt,
|
||||
siteName: resource?.siteName || null
|
||||
expiresAt: token.expiresAt
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -246,7 +243,7 @@ export default function CreateShareLinkForm({
|
|||
|
||||
function getSelectedResourceName(id: number) {
|
||||
const resource = resources.find((r) => r.resourceId === id);
|
||||
return `${resource?.name} ${resource?.siteName ? `(${resource.siteName})` : ""}`;
|
||||
return `${resource?.name}`;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -346,7 +343,7 @@ export default function CreateShareLinkForm({
|
|||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{`${r.name} ${r.siteName ? `(${r.siteName})` : ""}`}
|
||||
{`${r.name}`}
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
|
|
|
@ -42,7 +42,6 @@ export type ShareLinkRow = {
|
|||
title: string | null;
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
siteName: string | null;
|
||||
};
|
||||
|
||||
type ShareLinksTableProps = {
|
||||
|
@ -104,8 +103,7 @@ export default function ShareLinksTable({
|
|||
return (
|
||||
<Link href={`/${orgId}/settings/resources/${r.resourceId}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
{r.resourceName}{" "}
|
||||
{r.siteName ? `(${r.siteName})` : ""}
|
||||
{r.resourceName}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
|
|
@ -33,9 +33,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
|||
|
||||
return (
|
||||
<Alert>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">{t("siteInfo")}</AlertTitle>
|
||||
<AlertDescription className="mt-4">
|
||||
<AlertDescription>
|
||||
<InfoSections cols={env.flags.enableClients ? 3 : 2}>
|
||||
{(site.type == "newt" || site.type == "wireguard") && (
|
||||
<>
|
||||
|
|
|
@ -38,12 +38,14 @@ import { Tag, TagInput } from "@app/components/tags/tag-input";
|
|||
const GeneralFormSchema = z.object({
|
||||
name: z.string().nonempty("Name is required"),
|
||||
dockerSocketEnabled: z.boolean().optional(),
|
||||
remoteSubnets: z.array(
|
||||
remoteSubnets: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
})
|
||||
).optional()
|
||||
)
|
||||
.optional()
|
||||
});
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
|
@ -55,7 +57,9 @@ export default function GeneralPage() {
|
|||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeCidrTagIndex, setActiveCidrTagIndex] = useState<number | null>(null);
|
||||
const [activeCidrTagIndex, setActiveCidrTagIndex] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
@ -66,7 +70,7 @@ export default function GeneralPage() {
|
|||
name: site?.name,
|
||||
dockerSocketEnabled: site?.dockerSocketEnabled ?? false,
|
||||
remoteSubnets: site?.remoteSubnets
|
||||
? site.remoteSubnets.split(',').map((subnet, index) => ({
|
||||
? site.remoteSubnets.split(",").map((subnet, index) => ({
|
||||
id: subnet.trim(),
|
||||
text: subnet.trim()
|
||||
}))
|
||||
|
@ -82,7 +86,10 @@ export default function GeneralPage() {
|
|||
.post(`/site/${site?.siteId}`, {
|
||||
name: data.name,
|
||||
dockerSocketEnabled: data.dockerSocketEnabled,
|
||||
remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || ''
|
||||
remoteSubnets:
|
||||
data.remoteSubnets
|
||||
?.map((subnet) => subnet.text)
|
||||
.join(",") || ""
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
|
@ -98,7 +105,8 @@ export default function GeneralPage() {
|
|||
updateSite({
|
||||
name: data.name,
|
||||
dockerSocketEnabled: data.dockerSocketEnabled,
|
||||
remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || ''
|
||||
remoteSubnets:
|
||||
data.remoteSubnets?.map((subnet) => subnet.text).join(",") || ""
|
||||
});
|
||||
|
||||
toast({
|
||||
|
@ -145,21 +153,37 @@ export default function GeneralPage() {
|
|||
)}
|
||||
/>
|
||||
|
||||
{env.flags.enableClients &&
|
||||
site.type === "newt" ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="remoteSubnets"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("remoteSubnets")}</FormLabel>
|
||||
<FormLabel>
|
||||
{t("remoteSubnets")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeCidrTagIndex}
|
||||
setActiveTagIndex={setActiveCidrTagIndex}
|
||||
placeholder={t("enterCidrRange")}
|
||||
activeTagIndex={
|
||||
activeCidrTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveCidrTagIndex
|
||||
}
|
||||
placeholder={t(
|
||||
"enterCidrRange"
|
||||
)}
|
||||
size="sm"
|
||||
tags={form.getValues().remoteSubnets || []}
|
||||
setTags={(newSubnets) => {
|
||||
tags={
|
||||
form.getValues()
|
||||
.remoteSubnets ||
|
||||
[]
|
||||
}
|
||||
setTags={(
|
||||
newSubnets
|
||||
) => {
|
||||
form.setValue(
|
||||
"remoteSubnets",
|
||||
newSubnets as Tag[]
|
||||
|
@ -167,20 +191,26 @@ export default function GeneralPage() {
|
|||
}}
|
||||
validateTag={(tag) => {
|
||||
// Basic CIDR validation regex
|
||||
const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
|
||||
return cidrRegex.test(tag);
|
||||
const cidrRegex =
|
||||
/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
|
||||
return cidrRegex.test(
|
||||
tag
|
||||
);
|
||||
}}
|
||||
allowDuplicates={false}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("remoteSubnetsDescription")}
|
||||
{t(
|
||||
"remoteSubnetsDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{site && site.type === "newt" && (
|
||||
<FormField
|
||||
|
|
|
@ -43,7 +43,7 @@ import {
|
|||
FaWindows
|
||||
} from "react-icons/fa";
|
||||
import { SiNixos } from "react-icons/si";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { generateKeypair } from "../[niceId]/wireguardConfig";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
|
@ -72,6 +72,7 @@ interface TunnelTypeOption {
|
|||
type Commands = {
|
||||
mac: Record<string, string[]>;
|
||||
linux: Record<string, string[]>;
|
||||
freebsd: Record<string, string[]>;
|
||||
windows: Record<string, string[]>;
|
||||
docker: Record<string, string[]>;
|
||||
podman: Record<string, string[]>;
|
||||
|
@ -107,7 +108,8 @@ export default function Page() {
|
|||
}),
|
||||
method: z.enum(["newt", "wireguard", "local"]),
|
||||
copied: z.boolean(),
|
||||
clientAddress: z.string().optional()
|
||||
clientAddress: z.string().optional(),
|
||||
acceptClients: z.boolean()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
|
@ -170,6 +172,8 @@ export default function Page() {
|
|||
const [wgConfig, setWgConfig] = useState("");
|
||||
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [acceptClients, setAcceptClients] = useState(false);
|
||||
const [newtVersion, setNewtVersion] = useState("latest");
|
||||
|
||||
const [siteDefaults, setSiteDefaults] =
|
||||
useState<PickSiteDefaultsResponse | null>(null);
|
||||
|
@ -199,55 +203,61 @@ PersistentKeepalive = 5`;
|
|||
id: string,
|
||||
secret: string,
|
||||
endpoint: string,
|
||||
version: string
|
||||
version: string,
|
||||
acceptClients: boolean = false
|
||||
) => {
|
||||
const acceptClientsFlag = acceptClients ? " --accept-clients" : "";
|
||||
const acceptClientsEnv = acceptClients
|
||||
? "\n - ACCEPT_CLIENTS=true"
|
||||
: "";
|
||||
|
||||
const commands = {
|
||||
mac: {
|
||||
"Apple Silicon (arm64)": [
|
||||
`curl -L -o newt "https://github.com/fosrl/newt/releases/download/${version}/newt_darwin_arm64" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
"Intel x64 (amd64)": [
|
||||
`curl -L -o newt "https://github.com/fosrl/newt/releases/download/${version}/newt_darwin_amd64" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
All: [
|
||||
`curl -fsSL https://digpangolin.com/get-newt.sh | bash`,
|
||||
`newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
]
|
||||
// "Intel x64 (amd64)": [
|
||||
// `curl -fsSL https://digpangolin.com/get-newt.sh | bash`,
|
||||
// `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
// ]
|
||||
},
|
||||
linux: {
|
||||
amd64: [
|
||||
`wget -O newt "https://github.com/fosrl/newt/releases/download/${version}/newt_linux_amd64" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
arm64: [
|
||||
`wget -O newt "https://github.com/fosrl/newt/releases/download/${version}/newt_linux_arm64" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
arm32: [
|
||||
`wget -O newt "https://github.com/fosrl/newt/releases/download/${version}/newt_linux_arm32" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
arm32v6: [
|
||||
`wget -O newt "https://github.com/fosrl/newt/releases/download/${version}/newt_linux_arm32v6" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
riscv64: [
|
||||
`wget -O newt "https://github.com/fosrl/newt/releases/download/${version}/newt_linux_riscv64" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
All: [
|
||||
`curl -fsSL https://digpangolin.com/get-newt.sh | bash`,
|
||||
`newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
]
|
||||
// arm64: [
|
||||
// `curl -fsSL https://digpangolin.com/get-newt.sh | bash`,
|
||||
// `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
// ],
|
||||
// arm32: [
|
||||
// `curl -fsSL https://digpangolin.com/get-newt.sh | bash`,
|
||||
// `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
// ],
|
||||
// arm32v6: [
|
||||
// `curl -fsSL https://digpangolin.com/get-newt.sh | bash`,
|
||||
// `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
// ],
|
||||
// riscv64: [
|
||||
// `curl -fsSL https://digpangolin.com/get-newt.sh | bash`,
|
||||
// `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
// ]
|
||||
},
|
||||
freebsd: {
|
||||
amd64: [
|
||||
`fetch -o newt "https://github.com/fosrl/newt/releases/download/${version}/newt_freebsd_amd64" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
arm64: [
|
||||
`fetch -o newt "https://github.com/fosrl/newt/releases/download/${version}/newt_freebsd_arm64" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
All: [
|
||||
`curl -fsSL https://digpangolin.com/get-newt.sh | bash`,
|
||||
`newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
]
|
||||
// arm64: [
|
||||
// `curl -fsSL https://digpangolin.com/get-newt.sh | bash`,
|
||||
// `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
// ]
|
||||
},
|
||||
windows: {
|
||||
x64: [
|
||||
`curl -o newt.exe -L "https://github.com/fosrl/newt/releases/download/${version}/newt_windows_amd64.exe"`,
|
||||
`newt.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
`newt.exe --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
]
|
||||
},
|
||||
docker: {
|
||||
|
@ -260,10 +270,10 @@ PersistentKeepalive = 5`;
|
|||
environment:
|
||||
- PANGOLIN_ENDPOINT=${endpoint}
|
||||
- NEWT_ID=${id}
|
||||
- NEWT_SECRET=${secret}`
|
||||
- NEWT_SECRET=${secret}${acceptClientsEnv}`
|
||||
],
|
||||
"Docker Run": [
|
||||
`docker run -dit fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
`docker run -dit fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
]
|
||||
},
|
||||
podman: {
|
||||
|
@ -276,7 +286,7 @@ ContainerName=newt
|
|||
Image=docker.io/fosrl/newt
|
||||
Environment=PANGOLIN_ENDPOINT=${endpoint}
|
||||
Environment=NEWT_ID=${id}
|
||||
Environment=NEWT_SECRET=${secret}
|
||||
Environment=NEWT_SECRET=${secret}${acceptClients ? "\nEnvironment=ACCEPT_CLIENTS=true" : ""}
|
||||
# Secret=newt-secret,type=env,target=NEWT_SECRET
|
||||
|
||||
[Service]
|
||||
|
@ -286,16 +296,16 @@ Restart=always
|
|||
WantedBy=default.target`
|
||||
],
|
||||
"Podman Run": [
|
||||
`podman run -dit docker.io/fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
`podman run -dit docker.io/fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
]
|
||||
},
|
||||
nixos: {
|
||||
x86_64: [
|
||||
`nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
All: [
|
||||
`nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
],
|
||||
aarch64: [
|
||||
`nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
]
|
||||
// aarch64: [
|
||||
// `nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
// ]
|
||||
}
|
||||
};
|
||||
setCommands(commands);
|
||||
|
@ -304,9 +314,11 @@ WantedBy=default.target`
|
|||
const getArchitectures = () => {
|
||||
switch (platform) {
|
||||
case "linux":
|
||||
return ["amd64", "arm64", "arm32", "arm32v6", "riscv64"];
|
||||
// return ["amd64", "arm64", "arm32", "arm32v6", "riscv64"];
|
||||
return ["All"];
|
||||
case "mac":
|
||||
return ["Apple Silicon (arm64)", "Intel x64 (amd64)"];
|
||||
// return ["Apple Silicon (arm64)", "Intel x64 (amd64)"];
|
||||
return ["All"];
|
||||
case "windows":
|
||||
return ["x64"];
|
||||
case "docker":
|
||||
|
@ -314,9 +326,11 @@ WantedBy=default.target`
|
|||
case "podman":
|
||||
return ["Podman Quadlet", "Podman Run"];
|
||||
case "freebsd":
|
||||
return ["amd64", "arm64"];
|
||||
// return ["amd64", "arm64"];
|
||||
return ["All"];
|
||||
case "nixos":
|
||||
return ["x86_64", "aarch64"];
|
||||
// return ["x86_64", "aarch64"];
|
||||
return ["All"];
|
||||
default:
|
||||
return ["x64"];
|
||||
}
|
||||
|
@ -393,7 +407,8 @@ WantedBy=default.target`
|
|||
name: "",
|
||||
copied: false,
|
||||
method: "newt",
|
||||
clientAddress: ""
|
||||
clientAddress: "",
|
||||
acceptClients: false
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -469,7 +484,7 @@ WantedBy=default.target`
|
|||
const load = async () => {
|
||||
setLoadingPage(true);
|
||||
|
||||
let newtVersion = "latest";
|
||||
let currentNewtVersion = "latest";
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
|
@ -484,7 +499,8 @@ WantedBy=default.target`
|
|||
}
|
||||
const data = await response.json();
|
||||
const latestVersion = data.tag_name;
|
||||
newtVersion = latestVersion;
|
||||
currentNewtVersion = latestVersion;
|
||||
setNewtVersion(latestVersion);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
t("newtErrorFetchLatest", {
|
||||
|
@ -530,7 +546,8 @@ WantedBy=default.target`
|
|||
newtId,
|
||||
newtSecret,
|
||||
env.app.dashboardUrl,
|
||||
newtVersion
|
||||
currentNewtVersion,
|
||||
acceptClients
|
||||
);
|
||||
|
||||
hydrateWireGuardConfig(
|
||||
|
@ -556,6 +573,11 @@ WantedBy=default.target`
|
|||
load();
|
||||
}, []);
|
||||
|
||||
// Sync form acceptClients value with local state
|
||||
useEffect(() => {
|
||||
form.setValue("acceptClients", acceptClients);
|
||||
}, [acceptClients, form]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
|
@ -616,7 +638,9 @@ WantedBy=default.target`
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Site Address
|
||||
{t(
|
||||
"siteAddress"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
@ -642,12 +666,9 @@ WantedBy=default.target`
|
|||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
Specify the
|
||||
IP address
|
||||
of the host
|
||||
for clients
|
||||
to connect
|
||||
to.
|
||||
{t(
|
||||
"siteAddressDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
@ -851,6 +872,59 @@ WantedBy=default.target`
|
|||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<p className="font-bold mb-3">
|
||||
{t("siteConfiguration")}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<CheckboxWithLabel
|
||||
id="acceptClients"
|
||||
aria-describedby="acceptClients-desc"
|
||||
checked={acceptClients}
|
||||
onCheckedChange={(
|
||||
checked
|
||||
) => {
|
||||
const value =
|
||||
checked as boolean;
|
||||
setAcceptClients(
|
||||
value
|
||||
);
|
||||
form.setValue(
|
||||
"acceptClients",
|
||||
value
|
||||
);
|
||||
// Re-hydrate commands with new acceptClients value
|
||||
if (
|
||||
newtId &&
|
||||
newtSecret &&
|
||||
newtVersion
|
||||
) {
|
||||
hydrateCommands(
|
||||
newtId,
|
||||
newtSecret,
|
||||
env.app
|
||||
.dashboardUrl,
|
||||
newtVersion,
|
||||
value
|
||||
);
|
||||
}
|
||||
}}
|
||||
label={t(
|
||||
"siteAcceptClientConnections"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
id="acceptClients-desc"
|
||||
className="text-sm text-muted-foreground mb-4"
|
||||
>
|
||||
{t(
|
||||
"siteAcceptClientConnectionsDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<p className="font-bold mb-3">
|
||||
{t("commands")}
|
||||
|
|
|
@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input";
|
|||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
|
@ -31,6 +32,7 @@ import { passwordSchema } from "@server/auth/passwordSchema";
|
|||
|
||||
const formSchema = z
|
||||
.object({
|
||||
setupToken: z.string().min(1, "Setup token is required"),
|
||||
email: z.string().email({ message: "Invalid email address" }),
|
||||
password: passwordSchema,
|
||||
confirmPassword: z.string()
|
||||
|
@ -52,6 +54,7 @@ export default function InitialSetupPage() {
|
|||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
setupToken: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: ""
|
||||
|
@ -63,6 +66,7 @@ export default function InitialSetupPage() {
|
|||
setError(null);
|
||||
try {
|
||||
const res = await api.put("/auth/set-server-admin", {
|
||||
setupToken: values.setupToken,
|
||||
email: values.email,
|
||||
password: values.password
|
||||
});
|
||||
|
@ -102,6 +106,22 @@ export default function InitialSetupPage() {
|
|||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="setupToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("setupToken")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} autoComplete="off" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("setupTokenDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
|
|
100
src/app/auth/resource/[resourceId]/AutoLoginHandler.tsx
Normal file
100
src/app/auth/resource/[resourceId]/AutoLoginHandler.tsx
Normal file
|
@ -0,0 +1,100 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardDescription
|
||||
} from "@app/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type AutoLoginHandlerProps = {
|
||||
resourceId: number;
|
||||
skipToIdpId: number;
|
||||
redirectUrl: string;
|
||||
};
|
||||
|
||||
export default function AutoLoginHandler({
|
||||
resourceId,
|
||||
skipToIdpId,
|
||||
redirectUrl
|
||||
}: AutoLoginHandlerProps) {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function initiateAutoLogin() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<GenerateOidcUrlResponse>
|
||||
>(`/auth/idp/${skipToIdpId}/oidc/generate-url`, {
|
||||
redirectUrl
|
||||
});
|
||||
|
||||
if (res.data.data.redirectUrl) {
|
||||
// Redirect to the IDP for authentication
|
||||
window.location.href = res.data.data.redirectUrl;
|
||||
} else {
|
||||
setError(t("autoLoginErrorNoRedirectUrl"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to generate OIDC URL:", e);
|
||||
setError(formatAxiosError(e, t("autoLoginErrorGeneratingUrl")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
initiateAutoLogin();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("autoLoginTitle")}</CardTitle>
|
||||
<CardDescription>{t("autoLoginDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center space-y-4">
|
||||
{loading && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>{t("autoLoginProcessing")}</span>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && (
|
||||
<div className="flex items-center space-x-2 text-green-600">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<span>{t("autoLoginRedirecting")}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="w-full">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<AlertDescription className="flex flex-col space-y-2">
|
||||
<span>{t("autoLoginError")}</span>
|
||||
<span className="text-xs">{error}</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -15,6 +15,7 @@ import AccessToken from "./AccessToken";
|
|||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { LoginFormIDP } from "@app/components/LoginForm";
|
||||
import { ListIdpsResponse } from "@server/routers/idp";
|
||||
import AutoLoginHandler from "./AutoLoginHandler";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
@ -30,11 +31,13 @@ export default async function ResourceAuthPage(props: {
|
|||
|
||||
const env = pullEnv();
|
||||
|
||||
const authHeader = await authCookieHeader();
|
||||
|
||||
let authInfo: GetResourceAuthInfoResponse | undefined;
|
||||
try {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<GetResourceAuthInfoResponse>
|
||||
>(`/resource/${params.resourceId}/auth`, await authCookieHeader());
|
||||
>(`/resource/${params.resourceId}/auth`, authHeader);
|
||||
|
||||
if (res && res.status === 200) {
|
||||
authInfo = res.data.data;
|
||||
|
@ -62,10 +65,9 @@ export default async function ResourceAuthPage(props: {
|
|||
const redirectPort = new URL(searchParams.redirect).port;
|
||||
const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`;
|
||||
|
||||
|
||||
if (serverResourceHost === redirectHost) {
|
||||
redirectUrl = searchParams.redirect;
|
||||
} else if ( serverResourceHostWithPort === redirectHost ) {
|
||||
} else if (serverResourceHostWithPort === redirectHost) {
|
||||
redirectUrl = searchParams.redirect;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
@ -144,6 +146,19 @@ export default async function ResourceAuthPage(props: {
|
|||
name: idp.name
|
||||
})) as LoginFormIDP[];
|
||||
|
||||
if (authInfo.skipToIdpId && authInfo.skipToIdpId !== null) {
|
||||
const idp = loginIdps.find((idp) => idp.idpId === authInfo.skipToIdpId);
|
||||
if (idp) {
|
||||
return (
|
||||
<AutoLoginHandler
|
||||
resourceId={authInfo.resourceId}
|
||||
skipToIdpId={authInfo.skipToIdpId}
|
||||
redirectUrl={redirectUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{userIsUnauthorized && isSSOOnly ? (
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
|
@ -23,6 +23,7 @@ import {
|
|||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { SignUpResponse } from "@server/routers/auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
|
@ -35,11 +36,46 @@ import { cleanRedirect } from "@app/lib/cleanRedirect";
|
|||
import { useTranslations } from "next-intl";
|
||||
import BrandingLogo from "@app/components/BrandingLogo";
|
||||
import { build } from "@server/build";
|
||||
import { Check, X } from "lucide-react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
// Password strength calculation
|
||||
const calculatePasswordStrength = (password: string) => {
|
||||
const requirements = {
|
||||
length: password.length >= 8,
|
||||
uppercase: /[A-Z]/.test(password),
|
||||
lowercase: /[a-z]/.test(password),
|
||||
number: /[0-9]/.test(password),
|
||||
special: /[~!`@#$%^&*()_\-+={}[\]|\\:;"'<>,.\/?]/.test(password)
|
||||
};
|
||||
|
||||
const score = Object.values(requirements).filter(Boolean).length;
|
||||
let strength: "weak" | "medium" | "strong" = "weak";
|
||||
let color = "bg-red-500";
|
||||
let percentage = 0;
|
||||
|
||||
if (score >= 5) {
|
||||
strength = "strong";
|
||||
color = "bg-green-500";
|
||||
percentage = 100;
|
||||
} else if (score >= 3) {
|
||||
strength = "medium";
|
||||
color = "bg-yellow-500";
|
||||
percentage = 60;
|
||||
} else if (score >= 1) {
|
||||
strength = "weak";
|
||||
color = "bg-red-500";
|
||||
percentage = 30;
|
||||
}
|
||||
|
||||
return { requirements, strength, color, percentage, score };
|
||||
};
|
||||
|
||||
type SignupFormProps = {
|
||||
redirect?: string;
|
||||
inviteId?: string;
|
||||
inviteToken?: string;
|
||||
emailParam?: string;
|
||||
};
|
||||
|
||||
const formSchema = z
|
||||
|
@ -68,29 +104,32 @@ const formSchema = z
|
|||
export default function SignupForm({
|
||||
redirect,
|
||||
inviteId,
|
||||
inviteToken
|
||||
inviteToken,
|
||||
emailParam
|
||||
}: SignupFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const api = createApiClient({ env });
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [termsAgreedAt, setTermsAgreedAt] = useState<string | null>(null);
|
||||
const [passwordValue, setPasswordValue] = useState("");
|
||||
const [confirmPasswordValue, setConfirmPasswordValue] = useState("");
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
email: emailParam || "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
agreeToTerms: false
|
||||
}
|
||||
},
|
||||
mode: "onChange" // Enable real-time validation
|
||||
});
|
||||
|
||||
const t = useTranslations();
|
||||
const passwordStrength = calculatePasswordStrength(passwordValue);
|
||||
const doPasswordsMatch = passwordValue.length > 0 && confirmPasswordValue.length > 0 && passwordValue === confirmPasswordValue;
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
const { email, password } = values;
|
||||
|
@ -172,7 +211,10 @@ export default function SignupForm({
|
|||
<FormItem>
|
||||
<FormLabel>{t("email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input
|
||||
{...field}
|
||||
disabled={!!emailParam}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -183,11 +225,128 @@ export default function SignupForm({
|
|||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>{t("password")}</FormLabel>
|
||||
{passwordStrength.strength === "strong" && (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
setPasswordValue(e.target.value);
|
||||
}}
|
||||
className={cn(
|
||||
passwordStrength.strength === "strong" && "border-green-500 focus-visible:ring-green-500",
|
||||
passwordStrength.strength === "medium" && "border-yellow-500 focus-visible:ring-yellow-500",
|
||||
passwordStrength.strength === "weak" && passwordValue.length > 0 && "border-red-500 focus-visible:ring-red-500"
|
||||
)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
{passwordValue.length > 0 && (
|
||||
<div className="space-y-3 mt-2">
|
||||
{/* Password Strength Meter */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-foreground">{t("passwordStrength")}</span>
|
||||
<span className={cn(
|
||||
"text-sm font-semibold",
|
||||
passwordStrength.strength === "strong" && "text-green-600 dark:text-green-400",
|
||||
passwordStrength.strength === "medium" && "text-yellow-600 dark:text-yellow-400",
|
||||
passwordStrength.strength === "weak" && "text-red-600 dark:text-red-400"
|
||||
)}>
|
||||
{t(`passwordStrength${passwordStrength.strength.charAt(0).toUpperCase() + passwordStrength.strength.slice(1)}`)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={passwordStrength.percentage}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Requirements Checklist */}
|
||||
<div className="bg-muted rounded-lg p-3 space-y-2">
|
||||
<div className="text-sm font-medium text-foreground mb-2">{t("passwordRequirements")}</div>
|
||||
<div className="grid grid-cols-1 gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength.requirements.length ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
passwordStrength.requirements.length ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
{t("passwordRequirementLengthText")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength.requirements.uppercase ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
passwordStrength.requirements.uppercase ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
{t("passwordRequirementUppercaseText")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength.requirements.lowercase ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
passwordStrength.requirements.lowercase ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
{t("passwordRequirementLowercaseText")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength.requirements.number ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
passwordStrength.requirements.number ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
{t("passwordRequirementNumberText")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength.requirements.special ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
passwordStrength.requirements.special ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
{t("passwordRequirementSpecialText")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Only show FormMessage when not showing our custom requirements */}
|
||||
{passwordValue.length === 0 && <FormMessage />}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
@ -196,13 +355,36 @@ export default function SignupForm({
|
|||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("confirmPassword")}
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>{t('confirmPassword')}</FormLabel>
|
||||
{doPasswordsMatch && (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
setConfirmPasswordValue(e.target.value);
|
||||
}}
|
||||
className={cn(
|
||||
doPasswordsMatch && "border-green-500 focus-visible:ring-green-500",
|
||||
confirmPasswordValue.length > 0 && !doPasswordsMatch && "border-red-500 focus-visible:ring-red-500"
|
||||
)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
{confirmPasswordValue.length > 0 && !doPasswordsMatch && (
|
||||
<p className="text-sm text-red-600 mt-1">
|
||||
{t("passwordsDoNotMatch")}
|
||||
</p>
|
||||
)}
|
||||
{/* Only show FormMessage when field is empty */}
|
||||
{confirmPasswordValue.length === 0 && <FormMessage />}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -11,7 +11,10 @@ import { getTranslations } from "next-intl/server";
|
|||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<{ redirect: string | undefined }>;
|
||||
searchParams: Promise<{
|
||||
redirect: string | undefined;
|
||||
email: string | undefined;
|
||||
}>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const getUser = cache(verifySession);
|
||||
|
@ -69,6 +72,7 @@ export default async function Page(props: {
|
|||
redirect={redirectUrl}
|
||||
inviteToken={inviteToken}
|
||||
inviteId={inviteId}
|
||||
emailParam={searchParams.email}
|
||||
/>
|
||||
|
||||
<p className="text-center text-muted-foreground mt-4">
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue