Merge branch 'dev' into hybrid

This commit is contained in:
Owen 2025-08-16 12:04:16 -07:00
commit 7bf98c0c40
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
119 changed files with 9999 additions and 3106 deletions

View file

@ -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

View file

@ -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);

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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."
}

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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": "리소스 생성",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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": "Создать ресурс",

View file

@ -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",

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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": {

View file

@ -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",

View file

@ -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>;

View file

@ -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>;

View file

@ -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();

View file

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

View file

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

View file

@ -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;

View file

@ -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 }) => {

View file

@ -27,3 +27,4 @@ export * from "./verifyApiKeyAccess";
export * from "./verifyDomainAccess";
export * from "./verifyClientsEnabled";
export * from "./verifyUserIsOrgOwner";
export * from "./verifySiteResourceAccess";

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

View file

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

View file

@ -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";

View file

@ -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,

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

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

View file

@ -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(

View file

@ -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,

View file

@ -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;

View file

@ -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;

View file

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

View file

@ -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");

View file

@ -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();

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

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

View file

@ -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";

View file

@ -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;

View file

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

View file

@ -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, {

View file

@ -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,

View file

@ -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";

View file

@ -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, {

View file

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

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

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

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

View file

@ -0,0 +1,6 @@
export * from "./createSiteResource";
export * from "./deleteSiteResource";
export * from "./getSiteResource";
export * from "./updateSiteResource";
export * from "./listSiteResources";
export * from "./listAllSiteResourcesByOrg";

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

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

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

View file

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

View file

@ -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,

View file

@ -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,

View file

@ -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();
}

View file

@ -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;

View file

@ -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, {

View file

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

View file

@ -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],

View file

@ -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(

View file

@ -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, {

View file

@ -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

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

View file

@ -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
}

View file

@ -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;

View file

@ -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;

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

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

View file

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

View file

@ -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;

View file

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

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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}
>

View file

@ -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

View file

@ -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>
</>
);

View file

@ -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>
)
)}

View file

@ -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>

View file

@ -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") && (
<>

View file

@ -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

View file

@ -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")}

View file

@ -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"

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

View file

@ -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 ? (

View file

@ -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>
)}
/>

View file

@ -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