mirror of
https://github.com/fosrl/pangolin.git
synced 2026-01-29 14:20:44 +00:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd79e77576 | ||
|
|
1f1c20d637 | ||
|
|
e87b3b1b54 | ||
|
|
a6f7b65625 | ||
|
|
722fa47132 | ||
|
|
f83e290b4c | ||
|
|
11b4047283 | ||
|
|
69b2032a86 | ||
|
|
636298569f | ||
|
|
ed8a282d35 | ||
|
|
3bd5e850e0 | ||
|
|
070f1f9159 | ||
|
|
195644cca5 | ||
|
|
8092c86ecd | ||
|
|
28f33702da | ||
|
|
570632b8be | ||
|
|
f2881e1b31 | ||
|
|
dad35e37ef | ||
|
|
39afabd60e | ||
|
|
dc7e14a34b | ||
|
|
1dca71a779 | ||
|
|
e9494efa8e | ||
|
|
8159a0f13d | ||
|
|
ee9101e738 | ||
|
|
b670e6e3dc | ||
|
|
5e5754fa62 | ||
|
|
5fcf76066f | ||
|
|
601645fa72 | ||
|
|
12765ad675 | ||
|
|
ad3383d23d | ||
|
|
7d5961cf50 | ||
|
|
864aa052f1 | ||
|
|
be16196058 | ||
|
|
8a62f12e8b | ||
|
|
78f464f6ca | ||
|
|
f37eda4739 | ||
|
|
4e106e9e5a | ||
|
|
ccf8e5e6f4 | ||
|
|
9455adf61f | ||
|
|
970ab9818a | ||
|
|
7848cf7141 | ||
|
|
8e5aa9c195 | ||
|
|
a03e9ba7dd | ||
|
|
9e646ba385 | ||
|
|
d9a4f20fe6 | ||
|
|
e659f0e75d | ||
|
|
8891d6239f | ||
|
|
e3bd3fb985 | ||
|
|
54764dfacd | ||
|
|
b156b5ff2d | ||
|
|
d8e547c9a0 | ||
|
|
a0b93377a4 | ||
|
|
e8a6efd079 | ||
|
|
18bb6caf8f | ||
|
|
bc335d15c0 | ||
|
|
fb1481c69c | ||
|
|
9557f755a5 |
@@ -13,6 +13,8 @@ managed:
|
||||
app:
|
||||
dashboard_url: "https://{{.DashboardDomain}}"
|
||||
log_level: "info"
|
||||
telemetry:
|
||||
anonymous_usage: true
|
||||
|
||||
domains:
|
||||
domain1:
|
||||
|
||||
@@ -1059,6 +1059,7 @@
|
||||
"actionGetSiteResource": "Get Site Resource",
|
||||
"actionListSiteResources": "List Site Resources",
|
||||
"actionUpdateSiteResource": "Update Site Resource",
|
||||
"actionListInvitations": "List Invitations",
|
||||
"noneSelected": "None selected",
|
||||
"orgNotFound2": "No organizations found.",
|
||||
"searchProgress": "Search...",
|
||||
@@ -1457,5 +1458,43 @@
|
||||
"autoLoginRedirecting": "Redirecting to login...",
|
||||
"autoLoginError": "Auto Login Error",
|
||||
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
|
||||
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL."
|
||||
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.",
|
||||
"managedSelfHosted": {
|
||||
"title": "Managed Self-Hosted",
|
||||
"description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles",
|
||||
"introTitle": "Managed Self-Hosted Pangolin",
|
||||
"introDescription": "is a deployment option designed for people who want simplicity and extra reliability while still keeping their data private and self-hosted.",
|
||||
"introDetail": "With this option, you still run your own Pangolin node — your tunnels, SSL termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Simpler operations",
|
||||
"description": "No need to run your own mail server or set up complex alerting. You'll get health checks and downtime alerts out of the box."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Automatic updates",
|
||||
"description": "The cloud dashboard evolves quickly, so you get new features and bug fixes without having to manually pull new containers every time."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Less maintenance",
|
||||
"description": "No database migrations, backups, or extra infrastructure to manage. We handle that in the cloud."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Cloud failover",
|
||||
"description": "If your node goes down, your tunnels can temporarily fail over to our cloud points of presence until you bring it back online."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "High availability (PoPs)",
|
||||
"description": "You can also attach multiple nodes to your account for redundancy and better performance."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Future enhancements",
|
||||
"description": "We're planning to add more analytics, alerting, and management tools to make your deployment even more robust."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Learn more about the Managed Self-Hosted option in our",
|
||||
"documentation": "documentation"
|
||||
},
|
||||
"convertButton": "Convert This Node to Managed Self-Hosted"
|
||||
},
|
||||
"internationaldomaindetected": "International Domain Detected",
|
||||
"willbestoredas": "Will be stored as:"
|
||||
}
|
||||
|
||||
@@ -1059,6 +1059,7 @@
|
||||
"actionGetSiteResource": "Get Site Resource",
|
||||
"actionListSiteResources": "List Site Resources",
|
||||
"actionUpdateSiteResource": "Update Site Resource",
|
||||
"actionListInvitations": "List Invitations",
|
||||
"noneSelected": "None selected",
|
||||
"orgNotFound2": "No organizations found.",
|
||||
"searchProgress": "Search...",
|
||||
@@ -1457,5 +1458,43 @@
|
||||
"autoLoginRedirecting": "Redirecting to login...",
|
||||
"autoLoginError": "Auto Login Error",
|
||||
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
|
||||
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL."
|
||||
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.",
|
||||
"managedSelfHosted": {
|
||||
"title": "Managed Self-Hosted",
|
||||
"description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles",
|
||||
"introTitle": "Managed Self-Hosted Pangolin",
|
||||
"introDescription": "is a deployment option designed for people who want simplicity and extra reliability while still keeping their data private and self-hosted.",
|
||||
"introDetail": "With this option, you still run your own Pangolin node — your tunnels, SSL termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Simpler operations",
|
||||
"description": "No need to run your own mail server or set up complex alerting. You'll get health checks and downtime alerts out of the box."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Automatic updates",
|
||||
"description": "The cloud dashboard evolves quickly, so you get new features and bug fixes without having to manually pull new containers every time."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Less maintenance",
|
||||
"description": "No database migrations, backups, or extra infrastructure to manage. We handle that in the cloud."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Cloud failover",
|
||||
"description": "If your node goes down, your tunnels can temporarily fail over to our cloud points of presence until you bring it back online."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "High availability (PoPs)",
|
||||
"description": "You can also attach multiple nodes to your account for redundancy and better performance."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Future enhancements",
|
||||
"description": "We're planning to add more analytics, alerting, and management tools to make your deployment even more robust."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Learn more about the Managed Self-Hosted option in our",
|
||||
"documentation": "documentation"
|
||||
},
|
||||
"convertButton": "Convert This Node to Managed Self-Hosted"
|
||||
},
|
||||
"internationaldomaindetected": "International Domain Detected",
|
||||
"willbestoredas": "Will be stored as:"
|
||||
}
|
||||
|
||||
@@ -1059,6 +1059,7 @@
|
||||
"actionGetSiteResource": "Site-Ressource abrufen",
|
||||
"actionListSiteResources": "Site-Ressourcen auflisten",
|
||||
"actionUpdateSiteResource": "Site-Ressource aktualisieren",
|
||||
"actionListInvitations": "Einladungen auflisten",
|
||||
"noneSelected": "Keine ausgewählt",
|
||||
"orgNotFound2": "Keine Organisationen gefunden.",
|
||||
"searchProgress": "Suche...",
|
||||
@@ -1457,5 +1458,43 @@
|
||||
"autoLoginRedirecting": "Weiterleitung zur Anmeldung...",
|
||||
"autoLoginError": "Fehler bei der automatischen Anmeldung",
|
||||
"autoLoginErrorNoRedirectUrl": "Keine Weiterleitungs-URL vom Identitätsanbieter erhalten.",
|
||||
"autoLoginErrorGeneratingUrl": "Fehler beim Generieren der Authentifizierungs-URL."
|
||||
"autoLoginErrorGeneratingUrl": "Fehler beim Generieren der Authentifizierungs-URL.",
|
||||
"managedSelfHosted": {
|
||||
"title": "Verwaltetes Selbsthosted",
|
||||
"description": "Zuverlässiger und wartungsarmer Pangolin Server mit zusätzlichen Glocken und Pfeifen",
|
||||
"introTitle": "Verwalteter selbstgehosteter Pangolin",
|
||||
"introDescription": "ist eine Deployment-Option, die für Personen konzipiert wurde, die Einfachheit und zusätzliche Zuverlässigkeit wünschen, während sie ihre Daten privat und selbstgehostet halten.",
|
||||
"introDetail": "Mit dieser Option haben Sie immer noch Ihren eigenen Pangolin-Knoten – Ihre Tunnel, SSL-Terminierung und Traffic bleiben auf Ihrem Server. Der Unterschied besteht darin, dass Verwaltung und Überwachung über unser Cloud-Dashboard abgewickelt werden, das eine Reihe von Vorteilen freischaltet:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Einfachere Operationen",
|
||||
"description": "Sie brauchen keinen eigenen Mail-Server auszuführen oder komplexe Warnungen einzurichten. Sie erhalten Gesundheitschecks und Ausfallwarnungen aus dem Box."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Automatische Updates",
|
||||
"description": "Das Cloud-Dashboard entwickelt sich schnell, so dass Sie neue Funktionen und Fehlerbehebungen erhalten, ohne jedes Mal neue Container manuell ziehen zu müssen."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Weniger Wartung",
|
||||
"description": "Keine Datenbankmigrationen, Sicherungen oder zusätzliche Infrastruktur zum Verwalten. Wir kümmern uns um das in der Cloud."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Cloud-Ausfall",
|
||||
"description": "Wenn Ihr Knoten runtergeht, können Ihre Tunnel vorübergehend an unsere Cloud-Punkte scheitern, bis Sie ihn wieder online bringen."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "Hohe Verfügbarkeit (PoPs)",
|
||||
"description": "Sie können auch mehrere Knoten an Ihr Konto anhängen, um Redundanz und bessere Leistung zu erzielen."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Zukünftige Verbesserungen",
|
||||
"description": "Wir planen weitere Analyse-, Alarm- und Management-Tools hinzuzufügen, um Ihren Einsatz noch robuster zu machen."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Erfahren Sie mehr über die Managed Self-Hosted Option in unserer",
|
||||
"documentation": "dokumentation"
|
||||
},
|
||||
"convertButton": "Diesen Knoten in Managed Self-Hosted umwandeln"
|
||||
},
|
||||
"internationaldomaindetected": "Internationale Domain erkannt",
|
||||
"willbestoredas": "Wird gespeichert als:"
|
||||
}
|
||||
|
||||
@@ -1059,6 +1059,7 @@
|
||||
"actionGetSiteResource": "Get Site Resource",
|
||||
"actionListSiteResources": "List Site Resources",
|
||||
"actionUpdateSiteResource": "Update Site Resource",
|
||||
"actionListInvitations": "List Invitations",
|
||||
"noneSelected": "None selected",
|
||||
"orgNotFound2": "No organizations found.",
|
||||
"searchProgress": "Search...",
|
||||
@@ -1457,5 +1458,43 @@
|
||||
"autoLoginRedirecting": "Redirecting to login...",
|
||||
"autoLoginError": "Auto Login Error",
|
||||
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
|
||||
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL."
|
||||
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.",
|
||||
"managedSelfHosted": {
|
||||
"title": "Managed Self-Hosted",
|
||||
"description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles",
|
||||
"introTitle": "Managed Self-Hosted Pangolin",
|
||||
"introDescription": "is a deployment option designed for people who want simplicity and extra reliability while still keeping their data private and self-hosted.",
|
||||
"introDetail": "With this option, you still run your own Pangolin node — your tunnels, SSL termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Simpler operations",
|
||||
"description": "No need to run your own mail server or set up complex alerting. You'll get health checks and downtime alerts out of the box."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Automatic updates",
|
||||
"description": "The cloud dashboard evolves quickly, so you get new features and bug fixes without having to manually pull new containers every time."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Less maintenance",
|
||||
"description": "No database migrations, backups, or extra infrastructure to manage. We handle that in the cloud."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Cloud failover",
|
||||
"description": "If your node goes down, your tunnels can temporarily fail over to our cloud points of presence until you bring it back online."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "High availability (PoPs)",
|
||||
"description": "You can also attach multiple nodes to your account for redundancy and better performance."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Future enhancements",
|
||||
"description": "We're planning to add more analytics, alerting, and management tools to make your deployment even more robust."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Learn more about the Managed Self-Hosted option in our",
|
||||
"documentation": "documentation"
|
||||
},
|
||||
"convertButton": "Convert This Node to Managed Self-Hosted"
|
||||
},
|
||||
"internationaldomaindetected": "International Domain Detected",
|
||||
"willbestoredas": "Will be stored as:"
|
||||
}
|
||||
|
||||
@@ -1059,6 +1059,7 @@
|
||||
"actionGetSiteResource": "Obtener recurso del sitio",
|
||||
"actionListSiteResources": "Listar recursos del sitio",
|
||||
"actionUpdateSiteResource": "Actualizar recurso del sitio",
|
||||
"actionListInvitations": "Listar invitaciones",
|
||||
"noneSelected": "Ninguno seleccionado",
|
||||
"orgNotFound2": "No se encontraron organizaciones.",
|
||||
"searchProgress": "Buscar...",
|
||||
@@ -1457,5 +1458,43 @@
|
||||
"autoLoginRedirecting": "Redirigiendo al inicio de sesión...",
|
||||
"autoLoginError": "Error de inicio de sesión automático",
|
||||
"autoLoginErrorNoRedirectUrl": "No se recibió URL de redirección del proveedor de identidad.",
|
||||
"autoLoginErrorGeneratingUrl": "Error al generar URL de autenticación."
|
||||
"autoLoginErrorGeneratingUrl": "Error al generar URL de autenticación.",
|
||||
"managedSelfHosted": {
|
||||
"title": "Autogestionado",
|
||||
"description": "Servidor Pangolin autoalojado más fiable y de bajo mantenimiento con campanas y silbidos extra",
|
||||
"introTitle": "Pangolin autogestionado",
|
||||
"introDescription": "es una opción de despliegue diseñada para personas que quieren simplicidad y fiabilidad extra mientras mantienen sus datos privados y autoalojados.",
|
||||
"introDetail": "Con esta opción, todavía ejecuta su propio nodo Pangolin, sus túneles, terminación SSL y tráfico permanecen en su servidor. La diferencia es que la gestión y el control se gestionan a través de nuestro panel de control en la nube, que desbloquea una serie de ventajas:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Operaciones simples",
|
||||
"description": "No necesitas ejecutar tu propio servidor de correo o configurar alertas complejas. Recibirás cheques de salud y alertas de tiempo de inactividad."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Actualizaciones automáticas",
|
||||
"description": "El tablero de la nube evolucionará rápidamente, por lo que obtendrá nuevas características y correcciones de errores sin tener que extraer manualmente nuevos contenedores cada vez."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Menos mantenimiento",
|
||||
"description": "No hay migraciones de base de datos, copias de seguridad o infraestructura extra para administrar. Lo manejamos en la nube."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Fallo en la nube",
|
||||
"description": "Si tu nodo cae, tus túneles pueden fallar temporalmente a nuestros puntos de presencia en la nube hasta que lo vuelvas a conectar."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "Alta disponibilidad (PoPs)",
|
||||
"description": "También puede adjuntar múltiples nodos a su cuenta para redundancia y mejor rendimiento."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Mejoras futuras",
|
||||
"description": "Estamos planeando añadir más herramientas analíticas, alertas y de administración para hacer su despliegue aún más robusto."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Aprenda más acerca de la opción de autoalojamiento administrado en nuestra",
|
||||
"documentation": "documentación"
|
||||
},
|
||||
"convertButton": "Convierte este nodo a autoalojado administrado"
|
||||
},
|
||||
"internationaldomaindetected": "Dominio Internacional detectado",
|
||||
"willbestoredas": "Se almacenará como:"
|
||||
}
|
||||
|
||||
@@ -1059,6 +1059,7 @@
|
||||
"actionGetSiteResource": "Obtenir une ressource de site",
|
||||
"actionListSiteResources": "Lister les ressources de site",
|
||||
"actionUpdateSiteResource": "Mettre à jour une ressource de site",
|
||||
"actionListInvitations": "Lister les invitations",
|
||||
"noneSelected": "Aucune sélection",
|
||||
"orgNotFound2": "Aucune organisation trouvée.",
|
||||
"searchProgress": "Rechercher...",
|
||||
@@ -1457,5 +1458,43 @@
|
||||
"autoLoginRedirecting": "Redirection vers la connexion...",
|
||||
"autoLoginError": "Erreur de connexion automatique",
|
||||
"autoLoginErrorNoRedirectUrl": "Aucune URL de redirection reçue du fournisseur d'identité.",
|
||||
"autoLoginErrorGeneratingUrl": "Échec de la génération de l'URL d'authentification."
|
||||
"autoLoginErrorGeneratingUrl": "Échec de la génération de l'URL d'authentification.",
|
||||
"managedSelfHosted": {
|
||||
"title": "Gestion autonome",
|
||||
"description": "Serveur Pangolin auto-hébergé avec des cloches et des sifflets supplémentaires",
|
||||
"introTitle": "Pangolin auto-hébergé géré",
|
||||
"introDescription": "est une option de déploiement conçue pour les personnes qui veulent de la simplicité et de la fiabilité tout en gardant leurs données privées et auto-hébergées.",
|
||||
"introDetail": "Avec cette option, vous exécutez toujours votre propre nœud Pangolin — vos tunnels, la terminaison SSL et le trafic restent sur votre serveur. La différence est que la gestion et la surveillance sont gérées via notre tableau de bord du cloud, qui déverrouille un certain nombre d'avantages :",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Opérations plus simples",
|
||||
"description": "Pas besoin de faire tourner votre propre serveur de messagerie ou de configurer des alertes complexes. Vous obtiendrez des contrôles de santé et des alertes de temps d'arrêt par la suite."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Mises à jour automatiques",
|
||||
"description": "Le tableau de bord du cloud évolue rapidement, de sorte que vous obtenez de nouvelles fonctionnalités et des corrections de bugs sans avoir à extraire manuellement de nouveaux conteneurs à chaque fois."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Moins de maintenance",
|
||||
"description": "Aucune migration de base de données, sauvegarde ou infrastructure supplémentaire à gérer. Nous gérons cela dans le cloud."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Basculement du Cloud",
|
||||
"description": "Si votre nœud descend, vos tunnels peuvent temporairement échouer jusqu'à ce que vous le rapatriez en ligne."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "Haute disponibilité (PoPs)",
|
||||
"description": "Vous pouvez également attacher plusieurs nœuds à votre compte pour une redondance et de meilleures performances."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Améliorations futures",
|
||||
"description": "Nous prévoyons d'ajouter plus d'outils d'analyse, d'alerte et de gestion pour rendre votre déploiement encore plus robuste."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "En savoir plus sur l'option Auto-Hébergement géré dans notre",
|
||||
"documentation": "documentation"
|
||||
},
|
||||
"convertButton": "Convertir ce noeud en auto-hébergé géré"
|
||||
},
|
||||
"internationaldomaindetected": "Domaine international détecté",
|
||||
"willbestoredas": "Sera stocké comme :"
|
||||
}
|
||||
|
||||
@@ -1059,6 +1059,7 @@
|
||||
"actionGetSiteResource": "Ottieni Risorsa del Sito",
|
||||
"actionListSiteResources": "Elenca Risorse del Sito",
|
||||
"actionUpdateSiteResource": "Aggiorna Risorsa del Sito",
|
||||
"actionListInvitations": "Elenco Inviti",
|
||||
"noneSelected": "Nessuna selezione",
|
||||
"orgNotFound2": "Nessuna organizzazione trovata.",
|
||||
"searchProgress": "Ricerca...",
|
||||
@@ -1457,5 +1458,43 @@
|
||||
"autoLoginRedirecting": "Reindirizzamento al login...",
|
||||
"autoLoginError": "Errore di Accesso Automatico",
|
||||
"autoLoginErrorNoRedirectUrl": "Nessun URL di reindirizzamento ricevuto dal provider di identità.",
|
||||
"autoLoginErrorGeneratingUrl": "Impossibile generare l'URL di autenticazione."
|
||||
"autoLoginErrorGeneratingUrl": "Impossibile generare l'URL di autenticazione.",
|
||||
"managedSelfHosted": {
|
||||
"title": "Gestito Auto-Ospitato",
|
||||
"description": "Server Pangolin self-hosted più affidabile e a bassa manutenzione con campanelli e fischietti extra",
|
||||
"introTitle": "Managed Self-Hosted Pangolin",
|
||||
"introDescription": "è un'opzione di distribuzione progettata per le persone che vogliono la semplicità e l'affidabilità extra mantenendo i loro dati privati e self-hosted.",
|
||||
"introDetail": "Con questa opzione, esegui ancora il tuo nodo Pangolin — i tunnel, la terminazione SSL e il traffico rimangono tutti sul tuo server. La differenza è che la gestione e il monitoraggio sono gestiti attraverso il nostro cruscotto cloud, che sblocca una serie di vantaggi:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Operazioni più semplici",
|
||||
"description": "Non è necessario eseguire il proprio server di posta o impostare un avviso complesso. Otterrai controlli di salute e avvisi di inattività fuori dalla casella."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Aggiornamenti automatici",
|
||||
"description": "Il cruscotto cloud si evolve rapidamente, in modo da ottenere nuove funzionalità e correzioni di bug senza dover tirare manualmente nuovi contenitori ogni volta."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Meno manutenzione",
|
||||
"description": "Nessuna migrazione di database, backup o infrastruttura extra da gestire. Gestiamo questo problema nel cloud."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "failover del cloud",
|
||||
"description": "Se il tuo nodo scende, i tuoi tunnel possono temporaneamente fallire nei nostri punti di presenza cloud fino a quando non lo riporti online."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "Alta disponibilità (PoPs)",
|
||||
"description": "Puoi anche allegare più nodi al tuo account per ridondanza e prestazioni migliori."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Miglioramenti futuri",
|
||||
"description": "Stiamo pianificando di aggiungere più strumenti di analisi, allerta e gestione per rendere la tua distribuzione ancora più robusta."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Scopri di più sull'opzione Managed Self-Hosted nella nostra",
|
||||
"documentation": "documentazione"
|
||||
},
|
||||
"convertButton": "Converti questo nodo in auto-ospitato gestito"
|
||||
},
|
||||
"internationaldomaindetected": "Dominio Internazionale Rilevato",
|
||||
"willbestoredas": "Verrà conservato come:"
|
||||
}
|
||||
|
||||
@@ -1059,6 +1059,7 @@
|
||||
"actionGetSiteResource": "사이트 리소스 가져오기",
|
||||
"actionListSiteResources": "사이트 리소스 목록",
|
||||
"actionUpdateSiteResource": "사이트 리소스 업데이트",
|
||||
"actionListInvitations": "초대 목록",
|
||||
"noneSelected": "선택된 항목 없음",
|
||||
"orgNotFound2": "조직이 없습니다.",
|
||||
"searchProgress": "검색...",
|
||||
@@ -1457,5 +1458,43 @@
|
||||
"autoLoginRedirecting": "로그인으로 리디렉션 중...",
|
||||
"autoLoginError": "자동 로그인 오류",
|
||||
"autoLoginErrorNoRedirectUrl": "ID 공급자로부터 리디렉션 URL을 받지 못했습니다.",
|
||||
"autoLoginErrorGeneratingUrl": "인증 URL 생성 실패."
|
||||
"autoLoginErrorGeneratingUrl": "인증 URL 생성 실패.",
|
||||
"managedSelfHosted": {
|
||||
"title": "관리 자체 호스팅",
|
||||
"description": "더 신뢰할 수 있고 낮은 유지보수의 자체 호스팅 팡골린 서버, 추가 기능 포함",
|
||||
"introTitle": "관리 자체 호스팅 팡골린",
|
||||
"introDescription": "는 자신의 데이터를 프라이빗하고 자체 호스팅을 유지하면서 더 간단하고 추가적인 신뢰성을 원하는 사람들을 위한 배포 옵션입니다.",
|
||||
"introDetail": "이 옵션을 사용하면 여전히 자신의 팡골린 노드를 운영하고 - 터널, SSL 종료 및 트래픽 모두 서버에 유지됩니다. 차이점은 관리 및 모니터링이 클라우드 대시보드를 통해 처리되어 여러 혜택을 제공합니다.",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "더 간단한 운영",
|
||||
"description": "자체 메일 서버를 운영하거나 복잡한 경고를 설정할 필요가 없습니다. 기본적으로 상태 점검 및 다운타임 경고를 받을 수 있습니다."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "자동 업데이트",
|
||||
"description": "클라우드 대시보드는 빠르게 발전하므로 새로운 기능과 버그 수정 사항을 수동으로 새로운 컨테이너를 가져오지 않고도 받을 수 있습니다."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "유지보수 감소",
|
||||
"description": "데이터베이스 마이그레이션, 백업 또는 추가 인프라를 관리할 필요가 없습니다. 저희가 클라우드에서 처리합니다."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "클라우드 장애 조치",
|
||||
"description": "노드가 다운되면 터널이 클라우드의 프레즌스 포인트로 임시 전환되어 노드를 다시 온라인으로 가져올 때까지 유지됩니다."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "고가용성 (PoPs)",
|
||||
"description": "계정에 여러 노드를 연결하여 이중성과 성능을 향상시킬 수 있습니다."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "향후 개선",
|
||||
"description": "배포를 더욱 견고하게 만들기 위해 더 많은 분석, 경고, 및 관리 도구를 추가할 계획입니다."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "관리 자체 호스팅 옵션에 대해 더 알아보세요",
|
||||
"documentation": "문서"
|
||||
},
|
||||
"convertButton": "이 노드를 관리 자체 호스팅으로 변환"
|
||||
},
|
||||
"internationaldomaindetected": "국제 도메인 감지됨",
|
||||
"willbestoredas": "다음으로 저장됩니다:"
|
||||
}
|
||||
|
||||
@@ -1059,6 +1059,7 @@
|
||||
"actionGetSiteResource": "Hent Stedsressurs",
|
||||
"actionListSiteResources": "List opp Stedsressurser",
|
||||
"actionUpdateSiteResource": "Oppdater Stedsressurs",
|
||||
"actionListInvitations": "Liste invitasjoner",
|
||||
"noneSelected": "Ingen valgt",
|
||||
"orgNotFound2": "Ingen organisasjoner funnet.",
|
||||
"searchProgress": "Søker...",
|
||||
@@ -1457,5 +1458,43 @@
|
||||
"autoLoginRedirecting": "Omdirigerer til innlogging...",
|
||||
"autoLoginError": "Feil ved automatisk innlogging",
|
||||
"autoLoginErrorNoRedirectUrl": "Ingen omdirigerings-URL mottatt fra identitetsleverandøren.",
|
||||
"autoLoginErrorGeneratingUrl": "Kunne ikke generere autentiserings-URL."
|
||||
"autoLoginErrorGeneratingUrl": "Kunne ikke generere autentiserings-URL.",
|
||||
"managedSelfHosted": {
|
||||
"title": "Administrert selv-hostet",
|
||||
"description": "Sikre og lavvedlikeholdsservere, selvbetjente Pangolin med ekstra klokker, og understell",
|
||||
"introTitle": "Administrert Self-Hosted Pangolin",
|
||||
"introDescription": "er et alternativ for bruk utviklet for personer som ønsker enkel og ekstra pålitelighet mens de fortsatt holder sine data privat og selvdrevne.",
|
||||
"introDetail": "Med dette valget kjører du fortsatt din egen Pangolin-node - tunneler, SSL-terminering og trafikken ligger på serveren din. Forskjellen er at behandling og overvåking håndteres gjennom vårt skydashbord, som låser opp en rekke fordeler:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Enklere operasjoner",
|
||||
"description": "Ingen grunn til å kjøre din egen e-postserver eller sette opp kompleks varsling. Du vil få helsesjekk og nedetid varsler ut av boksen."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Automatiske oppdateringer",
|
||||
"description": "Cloud dashbordet utvikler seg raskt, så du får nye funksjoner og feilrettinger uten at du trenger å trekke nye beholdere manuelt hver gang."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Mindre vedlikehold",
|
||||
"description": "Ingen databasestyrer, sikkerhetskopier eller ekstra infrastruktur for å forvalte. Vi håndterer det i skyen."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Sky feilslått",
|
||||
"description": "Hvis EK-gruppen din går ned, kan tunnlene midlertidig mislykkes i å nå våre sky-punkter til du tar den tilbake på nett."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "Høy tilgjengelighet (PoPs)",
|
||||
"description": "Du kan også legge ved flere noder til kontoen din for redundans og bedre ytelse."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Fremtidige forbedringer",
|
||||
"description": "Vi planlegger å legge inn mer analyser, varsle og styringsverktøy for å gjøre din distribusjon enda mer robust."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Lær mer om Managed Self-Hosted alternativet i vår",
|
||||
"documentation": "dokumentasjon"
|
||||
},
|
||||
"convertButton": "Konverter denne noden til manuelt bruk"
|
||||
},
|
||||
"internationaldomaindetected": "Internasjonalt domene oppdaget",
|
||||
"willbestoredas": "Vil bli lagret som:"
|
||||
}
|
||||
|
||||
@@ -1059,6 +1059,7 @@
|
||||
"actionGetSiteResource": "Bron van site ophalen",
|
||||
"actionListSiteResources": "Bronnen van site weergeven",
|
||||
"actionUpdateSiteResource": "Document bijwerken van site",
|
||||
"actionListInvitations": "Toon uitnodigingen",
|
||||
"noneSelected": "Niet geselecteerd",
|
||||
"orgNotFound2": "Geen organisaties gevonden.",
|
||||
"searchProgress": "Zoeken...",
|
||||
@@ -1457,5 +1458,43 @@
|
||||
"autoLoginRedirecting": "Redirecting naar inloggen...",
|
||||
"autoLoginError": "Auto Login Fout",
|
||||
"autoLoginErrorNoRedirectUrl": "Geen redirect URL ontvangen van de identity provider.",
|
||||
"autoLoginErrorGeneratingUrl": "Genereren van authenticatie-URL mislukt."
|
||||
"autoLoginErrorGeneratingUrl": "Genereren van authenticatie-URL mislukt.",
|
||||
"managedSelfHosted": {
|
||||
"title": "Beheerde Self-Hosted",
|
||||
"description": "betrouwbaardere en slecht onderhouden Pangolin server met extra klokken en klokkenluiders",
|
||||
"introTitle": "Beheerde zelfgehoste pangolin",
|
||||
"introDescription": "is een implementatieoptie ontworpen voor mensen die eenvoud en extra betrouwbaarheid willen, terwijl hun gegevens privé en zelf georganiseerd blijven.",
|
||||
"introDetail": "Met deze optie beheert u nog steeds uw eigen Pangolin node - uw tunnels, SSL-verbinding en verkeer alles op uw server. Het verschil is dat beheer en monitoring worden behandeld via onze cloud dashboard, wat een aantal voordelen oplevert:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Simpler operaties",
|
||||
"description": "Je hoeft geen eigen mailserver te draaien of complexe waarschuwingen in te stellen. Je krijgt gezondheidscontroles en downtime meldingen uit de box."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Automatische updates",
|
||||
"description": "Het cloud dashboard evolueert snel, zodat u nieuwe functies en bug fixes krijgt zonder elke keer handmatig nieuwe containers te moeten trekken."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Minder onderhoud",
|
||||
"description": "Geen database migratie, back-ups of extra infrastructuur om te beheren. Dat behandelen we in de cloud."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Cloud fout",
|
||||
"description": "Als uw node omlaag gaat, kunnen uw tunnels tijdelijk niet meer naar onze aanwezigheidspunten gaan totdat u hem weer online brengt."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "Hoge beschikbaarheid (PoPs)",
|
||||
"description": "U kunt ook meerdere nodes koppelen aan uw account voor ontslag en betere prestaties."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Toekomstige verbeteringen",
|
||||
"description": "We zijn van plan om meer analytica, waarschuwing en beheerhulpmiddelen toe te voegen om uw implementatie nog steviger te maken."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Meer informatie over de optie voor zelf-verzorging in onze",
|
||||
"documentation": "documentatie"
|
||||
},
|
||||
"convertButton": "Converteer deze node naar Beheerde Zelf-Hosted"
|
||||
},
|
||||
"internationaldomaindetected": "Internationaal Domein Gedetecteerd",
|
||||
"willbestoredas": "Zal worden opgeslagen als:"
|
||||
}
|
||||
|
||||
@@ -1059,6 +1059,7 @@
|
||||
"actionGetSiteResource": "Pobierz zasób strony",
|
||||
"actionListSiteResources": "Lista zasobów strony",
|
||||
"actionUpdateSiteResource": "Aktualizuj zasób strony",
|
||||
"actionListInvitations": "Lista zaproszeń",
|
||||
"noneSelected": "Nie wybrano",
|
||||
"orgNotFound2": "Nie znaleziono organizacji.",
|
||||
"searchProgress": "Szukaj...",
|
||||
@@ -1457,5 +1458,43 @@
|
||||
"autoLoginRedirecting": "Przekierowanie do logowania...",
|
||||
"autoLoginError": "Błąd automatycznego logowania",
|
||||
"autoLoginErrorNoRedirectUrl": "Nie otrzymano URL przekierowania od dostawcy tożsamości.",
|
||||
"autoLoginErrorGeneratingUrl": "Nie udało się wygenerować URL uwierzytelniania."
|
||||
"autoLoginErrorGeneratingUrl": "Nie udało się wygenerować URL uwierzytelniania.",
|
||||
"managedSelfHosted": {
|
||||
"title": "Zarządzane Samodzielnie-Hostingowane",
|
||||
"description": "Większa niezawodność i niska konserwacja serwera Pangolin z dodatkowymi dzwonkami i sygnałami",
|
||||
"introTitle": "Zarządzany samowystarczalny Pangolin",
|
||||
"introDescription": "jest opcją wdrażania zaprojektowaną dla osób, które chcą prostoty i dodatkowej niezawodności, przy jednoczesnym utrzymaniu swoich danych prywatnych i samodzielnych.",
|
||||
"introDetail": "Z tą opcją nadal obsługujesz swój własny węzeł Pangolin — tunele, zakończenie SSL i ruch na Twoim serwerze. Różnica polega na tym, że zarządzanie i monitorowanie odbywa się za pomocą naszej tablicy rozdzielczej, która odblokowuje szereg korzyści:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Uproszczone operacje",
|
||||
"description": "Nie ma potrzeby uruchamiania własnego serwera pocztowego lub ustawiania skomplikowanych powiadomień. Będziesz mieć kontrolę zdrowia i powiadomienia o przestoju."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Automatyczne aktualizacje",
|
||||
"description": "Panel chmury rozwija się szybko, więc otrzymujesz nowe funkcje i poprawki błędów bez konieczności ręcznego ciągnięcia nowych kontenerów za każdym razem."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Mniej konserwacji",
|
||||
"description": "Brak migracji bazy danych, kopii zapasowych lub dodatkowej infrastruktury do zarządzania. Obsługujemy to w chmurze."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Przegrywanie w chmurze",
|
||||
"description": "Jeśli Twój węzeł zostanie wyłączony, tunele mogą tymczasowo zawieść do naszych punktów w chmurze, dopóki nie przyniesiesz go z powrotem do trybu online."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "Wysoka dostępność (PoPs)",
|
||||
"description": "Możesz również dołączyć wiele węzłów do swojego konta w celu nadmiarowości i lepszej wydajności."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Przyszłe ulepszenia",
|
||||
"description": "Planujemy dodać więcej narzędzi analitycznych, ostrzegawczych i zarządzania, aby zwiększyć odporność wdrożenia."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Dowiedz się więcej o opcji zarządzania samodzielnym hostingiem w naszym",
|
||||
"documentation": "dokumentacja"
|
||||
},
|
||||
"convertButton": "Konwertuj ten węzeł do zarządzanego samodzielnie"
|
||||
},
|
||||
"internationaldomaindetected": "Wykryto międzynarodową domenę",
|
||||
"willbestoredas": "Będą przechowywane jako:"
|
||||
}
|
||||
|
||||
@@ -1059,6 +1059,7 @@
|
||||
"actionGetSiteResource": "Obter Recurso do Site",
|
||||
"actionListSiteResources": "Listar Recursos do Site",
|
||||
"actionUpdateSiteResource": "Atualizar Recurso do Site",
|
||||
"actionListInvitations": "Listar Convites",
|
||||
"noneSelected": "Nenhum selecionado",
|
||||
"orgNotFound2": "Nenhuma organização encontrada.",
|
||||
"searchProgress": "Pesquisar...",
|
||||
@@ -1457,5 +1458,43 @@
|
||||
"autoLoginRedirecting": "Redirecionando para login...",
|
||||
"autoLoginError": "Erro de Login Automático",
|
||||
"autoLoginErrorNoRedirectUrl": "Nenhum URL de redirecionamento recebido do provedor de identidade.",
|
||||
"autoLoginErrorGeneratingUrl": "Falha ao gerar URL de autenticação."
|
||||
"autoLoginErrorGeneratingUrl": "Falha ao gerar URL de autenticação.",
|
||||
"managedSelfHosted": {
|
||||
"title": "Gerenciado Auto-Hospedado",
|
||||
"description": "Servidor Pangolin auto-hospedado mais confiável e com baixa manutenção com sinos extras e assobiamentos",
|
||||
"introTitle": "Pangolin Auto-Hospedado Gerenciado",
|
||||
"introDescription": "é uma opção de implantação projetada para pessoas que querem simplicidade e confiança adicional, mantendo os seus dados privados e auto-hospedados.",
|
||||
"introDetail": "Com esta opção, você ainda roda seu próprio nó Pangolin — seus túneis, terminação SSL e tráfego todos permanecem no seu servidor. A diferença é que a gestão e a monitorização são geridos através do nosso painel de nuvem, que desbloqueia vários benefícios:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Operações simples",
|
||||
"description": "Não é necessário executar o seu próprio servidor de e-mail ou configurar um alerta complexo. Você receberá fora de caixa verificações de saúde e alertas de tempo de inatividade."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Atualizações automáticas",
|
||||
"description": "O painel em nuvem evolui rapidamente, para que você obtenha novos recursos e correções de bugs sem ter de puxar manualmente novos contêineres toda vez."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Menos manutenção",
|
||||
"description": "Sem migrações, backups ou infraestrutura extra para gerenciar. Lidamos com isso na nuvem."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Falha na nuvem",
|
||||
"description": "Se o seu nó descer, seus túneis podem falhar temporariamente nos nossos pontos de presença na nuvem até que você o traga online."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "Alta disponibilidade (Ppos)",
|
||||
"description": "Você também pode anexar vários nós à sua conta para um melhor desempenho."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Aprimoramentos futuros",
|
||||
"description": "Estamos planejando adicionar mais análises, alertas e ferramentas de gerenciamento para tornar sua implantação ainda mais robusta."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Saiba mais sobre a opção Hospedagem Auto-Gerenciada no nosso",
|
||||
"documentation": "documentação"
|
||||
},
|
||||
"convertButton": "Converter este nó para Auto-Hospedado Gerenciado"
|
||||
},
|
||||
"internationaldomaindetected": "Domínio Internacional Detectado",
|
||||
"willbestoredas": "Será armazenado como:"
|
||||
}
|
||||
|
||||
@@ -1059,6 +1059,7 @@
|
||||
"actionGetSiteResource": "Получить ресурс сайта",
|
||||
"actionListSiteResources": "Список ресурсов сайта",
|
||||
"actionUpdateSiteResource": "Обновить ресурс сайта",
|
||||
"actionListInvitations": "Список приглашений",
|
||||
"noneSelected": "Ничего не выбрано",
|
||||
"orgNotFound2": "Организации не найдены.",
|
||||
"searchProgress": "Поиск...",
|
||||
@@ -1457,5 +1458,43 @@
|
||||
"autoLoginRedirecting": "Перенаправление к входу...",
|
||||
"autoLoginError": "Ошибка автоматического входа",
|
||||
"autoLoginErrorNoRedirectUrl": "URL-адрес перенаправления не получен от провайдера удостоверения.",
|
||||
"autoLoginErrorGeneratingUrl": "Не удалось сгенерировать URL-адрес аутентификации."
|
||||
"autoLoginErrorGeneratingUrl": "Не удалось сгенерировать URL-адрес аутентификации.",
|
||||
"managedSelfHosted": {
|
||||
"title": "Управляемый с самовывоза",
|
||||
"description": "Более надежный и низко обслуживаемый сервер Pangolin с дополнительными колокольнями и свистками",
|
||||
"introTitle": "Управляемый Само-Хост Панголина",
|
||||
"introDescription": "- это вариант развертывания, предназначенный для людей, которые хотят простоты и надёжности, сохраняя при этом свои данные конфиденциальными и самостоятельными.",
|
||||
"introDetail": "С помощью этой опции вы по-прежнему используете узел Pangolin — туннели, SSL, и весь остающийся на вашем сервере. Разница заключается в том, что управление и мониторинг осуществляются через нашу панель инструментов из облака, которая открывает ряд преимуществ:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Более простые операции",
|
||||
"description": "Не нужно запускать свой собственный почтовый сервер или настроить комплексное оповещение. Вы будете получать проверки состояния здоровья и оповещения о неисправностях из коробки."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Автоматическое обновление",
|
||||
"description": "Панель управления в облаке развивается быстро, так что вы получаете новые функции и исправления ошибок, без необходимости каждый раз получать новые контейнеры."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Меньше обслуживания",
|
||||
"description": "Нет миграции баз данных, резервных копий или дополнительной инфраструктуры для управления. Мы обрабатываем это в облаке."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Облачное срабатывание",
|
||||
"description": "Если ваш узел исчезнет, ваши туннели могут временно прерваться до наших облачных точек присутствия, пока вы не вернете его в сети."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "Высокая доступность (PoP)",
|
||||
"description": "Вы также можете прикрепить несколько узлов к вашему аккаунту для избыточности и лучшей производительности."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Будущие улучшения",
|
||||
"description": "Мы планируем добавить дополнительные инструменты аналитики, оповещения и управления, чтобы сделать установку еще более надежной."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Узнайте больше о опции Managed Self-Hosted в нашей",
|
||||
"documentation": "документация"
|
||||
},
|
||||
"convertButton": "Конвертировать этот узел в управляемый себе-хост"
|
||||
},
|
||||
"internationaldomaindetected": "Обнаружен международный домен",
|
||||
"willbestoredas": "Будет храниться как:"
|
||||
}
|
||||
|
||||
@@ -1059,6 +1059,7 @@
|
||||
"actionGetSiteResource": "Site Kaynağını Al",
|
||||
"actionListSiteResources": "Site Kaynaklarını Listele",
|
||||
"actionUpdateSiteResource": "Site Kaynağını Güncelle",
|
||||
"actionListInvitations": "Davetiyeleri Listele",
|
||||
"noneSelected": "Hiçbiri seçili değil",
|
||||
"orgNotFound2": "Hiçbir organizasyon bulunamadı.",
|
||||
"searchProgress": "Ara...",
|
||||
@@ -1457,5 +1458,43 @@
|
||||
"autoLoginRedirecting": "Girişe yönlendiriliyorsunuz...",
|
||||
"autoLoginError": "Otomatik Giriş Hatası",
|
||||
"autoLoginErrorNoRedirectUrl": "Kimlik sağlayıcıdan yönlendirme URL'si alınamadı.",
|
||||
"autoLoginErrorGeneratingUrl": "Kimlik doğrulama URL'si oluşturulamadı."
|
||||
"autoLoginErrorGeneratingUrl": "Kimlik doğrulama URL'si oluşturulamadı.",
|
||||
"managedSelfHosted": {
|
||||
"title": "Yönetilen Self-Hosted",
|
||||
"description": "Daha güvenilir ve düşük bakım gerektiren, ekstra özelliklere sahip kendi kendine barındırabileceğiniz Pangolin sunucusu",
|
||||
"introTitle": "Yönetilen Kendi Kendine Barındırılan Pangolin",
|
||||
"introDescription": "Bu, basitlik ve ekstra güvenilirlik arayan, ancak verilerini gizli tutmak ve kendi sunucularında barındırmak isteyen kişiler için tasarlanmış bir dağıtım seçeneğidir.",
|
||||
"introDetail": "Bu seçenekle, kendi Pangolin düğümünüzü çalıştırmaya devam edersiniz — tünelleriniz, SSL bitişiniz ve trafiğiniz tamamen sunucunuzda kalır. Fark, yönetim ve izlemeyi bulut panomuz üzerinden gerçekleştiririz, bu da bir dizi avantaj sağlar:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "Daha basit işlemler",
|
||||
"description": "Kendi e-posta sunucunuzu çalıştırmanıza veya karmaşık uyarılar kurmanıza gerek yok. Sağlık kontrolleri ve kesinti uyarılarını kutudan çıktığı gibi alırsınız."
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "Otomatik güncellemeler",
|
||||
"description": "Bulut panosu hızla gelişir, böylece her seferinde yeni konteynerler manuel olarak çekmeden yeni özellikler ve hata düzeltmeleri alırsınız."
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "Daha az bakım",
|
||||
"description": "Veritabanı geçişleri, yedeklemeler veya ekstra altyapı yönetimi yok. Biz bunu bulutta hallederiz."
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "Bulut yedekleme",
|
||||
"description": "Düğümünüz kapandığında, tünelleriniz geçici olarak bulut bağlantı noktalarımıza geçebilir, böylece tekrar çevrimiçi hale getirene kadar tünelleriniz kesintiye uğramaz."
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "Yüksek kullanılabilirlik (Bağlantı Noktaları)",
|
||||
"description": "Yedeklilik ve daha iyi performans için hesabınıza birden fazla düğüm bağlayabilirsiniz."
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "Gelecek iyileştirmeler",
|
||||
"description": "Dağıtımınızı daha sağlam hale getirmek amacıyla daha fazla analiz, uyarı ve yönetim aracı eklemeyi planlıyoruz."
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "Yönetilen Kendi Kendine Barındırılan seçeneği hakkında daha fazla bilgi edinin",
|
||||
"documentation": "dokümantasyon"
|
||||
},
|
||||
"convertButton": "Bu Düğümü Yönetilen Kendi Kendine Barındırma Dönüştürün"
|
||||
},
|
||||
"internationaldomaindetected": "Uluslararası Alan Adı Tespit Edildi",
|
||||
"willbestoredas": "Şu şekilde depolanacak:"
|
||||
}
|
||||
|
||||
@@ -1059,6 +1059,7 @@
|
||||
"actionGetSiteResource": "获取站点资源",
|
||||
"actionListSiteResources": "列出站点资源",
|
||||
"actionUpdateSiteResource": "更新站点资源",
|
||||
"actionListInvitations": "邀请列表",
|
||||
"noneSelected": "未选择",
|
||||
"orgNotFound2": "未找到组织。",
|
||||
"searchProgress": "搜索中...",
|
||||
@@ -1457,5 +1458,43 @@
|
||||
"autoLoginRedirecting": "重定向到登录...",
|
||||
"autoLoginError": "自动登录错误",
|
||||
"autoLoginErrorNoRedirectUrl": "未从身份提供商收到重定向URL。",
|
||||
"autoLoginErrorGeneratingUrl": "生成身份验证URL失败。"
|
||||
"autoLoginErrorGeneratingUrl": "生成身份验证URL失败。",
|
||||
"managedSelfHosted": {
|
||||
"title": "托管自托管",
|
||||
"description": "更可靠和低维护自我托管的 Pangolin 服务器,带有额外的铃声和告密器",
|
||||
"introTitle": "托管自托管的潘戈林公司",
|
||||
"introDescription": "这是一种部署选择,为那些希望简洁和额外可靠的人设计,同时仍然保持他们的数据的私密性和自我托管性。",
|
||||
"introDetail": "通过此选项,您仍然运行您自己的 Pangolin 节点 — — 您的隧道、SSL 终止,并且流量在您的服务器上保持所有状态。 不同之处在于,管理和监测是通过我们的云层仪表板进行的,该仪表板开启了一些好处:",
|
||||
"benefitSimplerOperations": {
|
||||
"title": "简单的操作",
|
||||
"description": "无需运行您自己的邮件服务器或设置复杂的警报。您将从方框中获得健康检查和下限提醒。"
|
||||
},
|
||||
"benefitAutomaticUpdates": {
|
||||
"title": "自动更新",
|
||||
"description": "云仪表盘快速演化,所以您可以获得新的功能和错误修复,而不必每次手动拉取新的容器。"
|
||||
},
|
||||
"benefitLessMaintenance": {
|
||||
"title": "减少维护时间",
|
||||
"description": "没有要管理的数据库迁移、备份或额外的基础设施。我们在云端处理这个问题。"
|
||||
},
|
||||
"benefitCloudFailover": {
|
||||
"title": "云失败",
|
||||
"description": "如果您的节点被关闭,您的隧道可能暂时无法连接到我们的云端,直到您将其重新连接上线。"
|
||||
},
|
||||
"benefitHighAvailability": {
|
||||
"title": "高可用率(PoPs)",
|
||||
"description": "您还可以将多个节点添加到您的帐户中以获取冗余和更好的性能。"
|
||||
},
|
||||
"benefitFutureEnhancements": {
|
||||
"title": "将来的改进",
|
||||
"description": "我们正在计划添加更多的分析、警报和管理工具,使你的部署更加有力。"
|
||||
},
|
||||
"docsAlert": {
|
||||
"text": "在我们中更多地了解管理下的自托管选项",
|
||||
"documentation": "文档"
|
||||
},
|
||||
"convertButton": "将此节点转换为管理自托管的"
|
||||
},
|
||||
"internationaldomaindetected": "检测到国际域",
|
||||
"willbestoredas": "储存为:"
|
||||
}
|
||||
|
||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -84,6 +84,7 @@
|
||||
"react-icons": "^5.5.0",
|
||||
"rebuild": "0.1.2",
|
||||
"semver": "^7.7.2",
|
||||
"source-map-support": "0.5.21",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tailwind-merge": "3.3.1",
|
||||
"tw-animate-css": "^1.3.7",
|
||||
@@ -112,8 +113,8 @@
|
||||
"@types/node": "^24",
|
||||
"@types/nodemailer": "6.4.17",
|
||||
"@types/pg": "8.15.5",
|
||||
"@types/react": "19.1.11",
|
||||
"@types/react-dom": "19.1.8",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"@types/ws": "8.18.1",
|
||||
@@ -5026,9 +5027,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.11.tgz",
|
||||
"integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==",
|
||||
"version": "19.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5036,9 +5037,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.8.tgz",
|
||||
"integrity": "sha512-xG7xaBMJCpcK0RpN8jDbAACQo54ycO6h4dSSmgv8+fu6ZIAdANkx/WsawASUjVXYfy+J9AbUpRMNNEsXCDfDBQ==",
|
||||
"version": "19.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz",
|
||||
"integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
@@ -6199,7 +6200,6 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
@@ -15590,7 +15590,6 @@
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -15609,7 +15608,6 @@
|
||||
"version": "0.5.21",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"react-icons": "^5.5.0",
|
||||
"rebuild": "0.1.2",
|
||||
"semver": "^7.7.2",
|
||||
"source-map-support": "0.5.21",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tailwind-merge": "3.3.1",
|
||||
"tw-animate-css": "^1.3.7",
|
||||
@@ -129,8 +130,8 @@
|
||||
"@types/node": "^24",
|
||||
"@types/nodemailer": "6.4.17",
|
||||
"@types/pg": "8.15.5",
|
||||
"@types/react": "19.1.11",
|
||||
"@types/react-dom": "19.1.8",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"@types/ws": "8.18.1",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#! /usr/bin/env node
|
||||
import "./extendZod.ts";
|
||||
import 'source-map-support/register.js'
|
||||
|
||||
import { runSetupFunctions } from "./setup";
|
||||
import { createApiServer } from "./apiServer";
|
||||
|
||||
@@ -179,6 +179,7 @@ export const configSchema = z
|
||||
.default("/var/dynamic/router_config.yml"),
|
||||
static_domains: z.array(z.string()).optional().default([]),
|
||||
site_types: z.array(z.string()).optional().default(["newt", "wireguard", "local"]),
|
||||
allow_raw_resources: z.boolean().optional().default(true),
|
||||
file_mode: z.boolean().optional().default(false)
|
||||
})
|
||||
.optional()
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function getValidCertificatesForDomainsHybrid(domains: Set<string>)
|
||||
Array<{
|
||||
id: number;
|
||||
domain: string;
|
||||
wildcard: boolean | null;
|
||||
certFile: string | null;
|
||||
keyFile: string | null;
|
||||
expiresAt: Date | null;
|
||||
@@ -68,6 +69,7 @@ export async function getValidCertificatesForDomains(domains: Set<string>): Prom
|
||||
Array<{
|
||||
id: number;
|
||||
domain: string;
|
||||
wildcard: boolean | null;
|
||||
certFile: string | null;
|
||||
keyFile: string | null;
|
||||
expiresAt: Date | null;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
export const subdomainSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/,
|
||||
/^(?!:\/\/)([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/,
|
||||
"Invalid subdomain format"
|
||||
)
|
||||
.min(1, "Subdomain must be at least 1 character long")
|
||||
@@ -12,7 +12,8 @@ export const subdomainSchema = z
|
||||
export const tlsNameSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$|^$/,
|
||||
/^(?!:\/\/)([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$|^$/,
|
||||
"Invalid subdomain format"
|
||||
)
|
||||
.transform((val) => val.toLowerCase());
|
||||
.transform((val) => val.toLowerCase());
|
||||
|
||||
|
||||
235
server/lib/traefikConfig.test.ts
Normal file
235
server/lib/traefikConfig.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { assertEquals } from "@test/assert";
|
||||
import { isDomainCoveredByWildcard } from "./traefikConfig";
|
||||
|
||||
function runTests() {
|
||||
console.log('Running wildcard domain coverage tests...');
|
||||
|
||||
// Test case 1: Basic wildcard certificate at example.com
|
||||
const basicWildcardCerts = new Map([
|
||||
['example.com', { exists: true, wildcard: true }]
|
||||
]);
|
||||
|
||||
// Should match first-level subdomains
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('level1.example.com', basicWildcardCerts),
|
||||
true,
|
||||
'Wildcard cert at example.com should match level1.example.com'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('api.example.com', basicWildcardCerts),
|
||||
true,
|
||||
'Wildcard cert at example.com should match api.example.com'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('www.example.com', basicWildcardCerts),
|
||||
true,
|
||||
'Wildcard cert at example.com should match www.example.com'
|
||||
);
|
||||
|
||||
// Should match the root domain (exact match)
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('example.com', basicWildcardCerts),
|
||||
true,
|
||||
'Wildcard cert at example.com should match example.com itself'
|
||||
);
|
||||
|
||||
// Should NOT match second-level subdomains
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('level2.level1.example.com', basicWildcardCerts),
|
||||
false,
|
||||
'Wildcard cert at example.com should NOT match level2.level1.example.com'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('deep.nested.subdomain.example.com', basicWildcardCerts),
|
||||
false,
|
||||
'Wildcard cert at example.com should NOT match deep.nested.subdomain.example.com'
|
||||
);
|
||||
|
||||
// Should NOT match different domains
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('test.otherdomain.com', basicWildcardCerts),
|
||||
false,
|
||||
'Wildcard cert at example.com should NOT match test.otherdomain.com'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('notexample.com', basicWildcardCerts),
|
||||
false,
|
||||
'Wildcard cert at example.com should NOT match notexample.com'
|
||||
);
|
||||
|
||||
// Test case 2: Multiple wildcard certificates
|
||||
const multipleWildcardCerts = new Map([
|
||||
['example.com', { exists: true, wildcard: true }],
|
||||
['test.org', { exists: true, wildcard: true }],
|
||||
['api.service.net', { exists: true, wildcard: true }]
|
||||
]);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('app.example.com', multipleWildcardCerts),
|
||||
true,
|
||||
'Should match subdomain of first wildcard cert'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('staging.test.org', multipleWildcardCerts),
|
||||
true,
|
||||
'Should match subdomain of second wildcard cert'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('v1.api.service.net', multipleWildcardCerts),
|
||||
true,
|
||||
'Should match subdomain of third wildcard cert'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('deep.nested.api.service.net', multipleWildcardCerts),
|
||||
false,
|
||||
'Should NOT match multi-level subdomain of third wildcard cert'
|
||||
);
|
||||
|
||||
// Test exact domain matches for multiple certs
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('example.com', multipleWildcardCerts),
|
||||
true,
|
||||
'Should match exact domain of first wildcard cert'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('test.org', multipleWildcardCerts),
|
||||
true,
|
||||
'Should match exact domain of second wildcard cert'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('api.service.net', multipleWildcardCerts),
|
||||
true,
|
||||
'Should match exact domain of third wildcard cert'
|
||||
);
|
||||
|
||||
// Test case 3: Non-wildcard certificates (should not match anything)
|
||||
const nonWildcardCerts = new Map([
|
||||
['example.com', { exists: true, wildcard: false }],
|
||||
['specific.domain.com', { exists: true, wildcard: false }]
|
||||
]);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('sub.example.com', nonWildcardCerts),
|
||||
false,
|
||||
'Non-wildcard cert should not match subdomains'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('example.com', nonWildcardCerts),
|
||||
false,
|
||||
'Non-wildcard cert should not match even exact domain via this function'
|
||||
);
|
||||
|
||||
// Test case 4: Non-existent certificates (should not match)
|
||||
const nonExistentCerts = new Map([
|
||||
['example.com', { exists: false, wildcard: true }],
|
||||
['missing.com', { exists: false, wildcard: true }]
|
||||
]);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('sub.example.com', nonExistentCerts),
|
||||
false,
|
||||
'Non-existent wildcard cert should not match'
|
||||
);
|
||||
|
||||
// Test case 5: Edge cases with special domain names
|
||||
const specialDomainCerts = new Map([
|
||||
['localhost', { exists: true, wildcard: true }],
|
||||
['127-0-0-1.nip.io', { exists: true, wildcard: true }],
|
||||
['xn--e1afmkfd.xn--p1ai', { exists: true, wildcard: true }] // IDN domain
|
||||
]);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('app.localhost', specialDomainCerts),
|
||||
true,
|
||||
'Should match subdomain of localhost wildcard'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('test.127-0-0-1.nip.io', specialDomainCerts),
|
||||
true,
|
||||
'Should match subdomain of nip.io wildcard'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('sub.xn--e1afmkfd.xn--p1ai', specialDomainCerts),
|
||||
true,
|
||||
'Should match subdomain of IDN wildcard'
|
||||
);
|
||||
|
||||
// Test case 6: Empty input and edge cases
|
||||
const emptyCerts = new Map();
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('any.domain.com', emptyCerts),
|
||||
false,
|
||||
'Empty certificate map should not match any domain'
|
||||
);
|
||||
|
||||
// Test case 7: Domains with single character components
|
||||
const singleCharCerts = new Map([
|
||||
['a.com', { exists: true, wildcard: true }],
|
||||
['x.y.z', { exists: true, wildcard: true }]
|
||||
]);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('b.a.com', singleCharCerts),
|
||||
true,
|
||||
'Should match single character subdomain'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('w.x.y.z', singleCharCerts),
|
||||
true,
|
||||
'Should match single character subdomain of multi-part domain'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('v.w.x.y.z', singleCharCerts),
|
||||
false,
|
||||
'Should NOT match multi-level subdomain of single char domain'
|
||||
);
|
||||
|
||||
// Test case 8: Domains with numbers and hyphens
|
||||
const numericCerts = new Map([
|
||||
['api-v2.service-1.com', { exists: true, wildcard: true }],
|
||||
['123.456.net', { exists: true, wildcard: true }]
|
||||
]);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('staging.api-v2.service-1.com', numericCerts),
|
||||
true,
|
||||
'Should match subdomain with hyphens and numbers'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('test.123.456.net', numericCerts),
|
||||
true,
|
||||
'Should match subdomain with numeric components'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('deep.staging.api-v2.service-1.com', numericCerts),
|
||||
false,
|
||||
'Should NOT match multi-level subdomain with hyphens and numbers'
|
||||
);
|
||||
|
||||
console.log('All wildcard domain coverage tests passed!');
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
try {
|
||||
runTests();
|
||||
} catch (error) {
|
||||
console.error('Test failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export class TraefikConfigManager {
|
||||
exists: boolean;
|
||||
lastModified: Date | null;
|
||||
expiresAt: Date | null;
|
||||
wildcard: boolean | null;
|
||||
}
|
||||
>();
|
||||
|
||||
@@ -115,6 +116,7 @@ export class TraefikConfigManager {
|
||||
exists: boolean;
|
||||
lastModified: Date | null;
|
||||
expiresAt: Date | null;
|
||||
wildcard: boolean;
|
||||
}
|
||||
>
|
||||
> {
|
||||
@@ -136,13 +138,16 @@ export class TraefikConfigManager {
|
||||
const certPath = path.join(domainDir, "cert.pem");
|
||||
const keyPath = path.join(domainDir, "key.pem");
|
||||
const lastUpdatePath = path.join(domainDir, ".last_update");
|
||||
const wildcardPath = path.join(domainDir, ".wildcard");
|
||||
|
||||
const certExists = await this.fileExists(certPath);
|
||||
const keyExists = await this.fileExists(keyPath);
|
||||
const lastUpdateExists = await this.fileExists(lastUpdatePath);
|
||||
const wildcardExists = await this.fileExists(wildcardPath);
|
||||
|
||||
let lastModified: Date | null = null;
|
||||
const expiresAt: Date | null = null;
|
||||
let wildcard = false;
|
||||
|
||||
if (lastUpdateExists) {
|
||||
try {
|
||||
@@ -161,10 +166,26 @@ export class TraefikConfigManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a wildcard certificate
|
||||
if (wildcardExists) {
|
||||
try {
|
||||
const wildcardContent = fs
|
||||
.readFileSync(wildcardPath, "utf8")
|
||||
.trim();
|
||||
wildcard = wildcardContent === "true";
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Could not read wildcard file for ${domain}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
state.set(domain, {
|
||||
exists: certExists && keyExists,
|
||||
lastModified,
|
||||
expiresAt
|
||||
expiresAt,
|
||||
wildcard
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -192,19 +213,36 @@ export class TraefikConfigManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fetch if domains have changed
|
||||
// Filter out domains covered by wildcard certificates
|
||||
const domainsNeedingCerts = new Set<string>();
|
||||
for (const domain of currentDomains) {
|
||||
if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
|
||||
domainsNeedingCerts.add(domain);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch if domains needing certificates have changed
|
||||
const lastDomainsNeedingCerts = new Set<string>();
|
||||
for (const domain of this.lastKnownDomains) {
|
||||
if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
|
||||
lastDomainsNeedingCerts.add(domain);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
this.lastKnownDomains.size !== currentDomains.size ||
|
||||
!Array.from(this.lastKnownDomains).every((domain) =>
|
||||
currentDomains.has(domain)
|
||||
domainsNeedingCerts.size !== lastDomainsNeedingCerts.size ||
|
||||
!Array.from(domainsNeedingCerts).every((domain) =>
|
||||
lastDomainsNeedingCerts.has(domain)
|
||||
)
|
||||
) {
|
||||
logger.info("Fetching certificates due to domain changes");
|
||||
logger.info(
|
||||
"Fetching certificates due to domain changes (after wildcard filtering)"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if any local certificates are missing or appear to be outdated
|
||||
for (const domain of currentDomains) {
|
||||
for (const domain of domainsNeedingCerts) {
|
||||
const localState = this.lastLocalCertificateState.get(domain);
|
||||
if (!localState || !localState.exists) {
|
||||
logger.info(
|
||||
@@ -273,6 +311,7 @@ export class TraefikConfigManager {
|
||||
let validCertificates: Array<{
|
||||
id: number;
|
||||
domain: string;
|
||||
wildcard: boolean | null;
|
||||
certFile: string | null;
|
||||
keyFile: string | null;
|
||||
expiresAt: Date | null;
|
||||
@@ -280,23 +319,50 @@ export class TraefikConfigManager {
|
||||
}> = [];
|
||||
|
||||
if (this.shouldFetchCertificates(domains)) {
|
||||
// Get valid certificates for active domains
|
||||
if (config.isManagedMode()) {
|
||||
validCertificates =
|
||||
await getValidCertificatesForDomainsHybrid(domains);
|
||||
} else {
|
||||
validCertificates =
|
||||
await getValidCertificatesForDomains(domains);
|
||||
// Filter out domains that are already covered by wildcard certificates
|
||||
const domainsToFetch = new Set<string>();
|
||||
for (const domain of domains) {
|
||||
if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
|
||||
domainsToFetch.add(domain);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Domain ${domain} is covered by existing wildcard certificate, skipping fetch`
|
||||
);
|
||||
}
|
||||
}
|
||||
this.lastCertificateFetch = new Date();
|
||||
this.lastKnownDomains = new Set(domains);
|
||||
|
||||
logger.info(
|
||||
`Fetched ${validCertificates.length} certificates from remote`
|
||||
);
|
||||
if (domainsToFetch.size > 0) {
|
||||
// Get valid certificates for domains not covered by wildcards
|
||||
if (config.isManagedMode()) {
|
||||
validCertificates =
|
||||
await getValidCertificatesForDomainsHybrid(
|
||||
domainsToFetch
|
||||
);
|
||||
} else {
|
||||
validCertificates =
|
||||
await getValidCertificatesForDomains(
|
||||
domainsToFetch
|
||||
);
|
||||
}
|
||||
this.lastCertificateFetch = new Date();
|
||||
this.lastKnownDomains = new Set(domains);
|
||||
|
||||
// Download and decrypt new certificates
|
||||
await this.processValidCertificates(validCertificates);
|
||||
logger.info(
|
||||
`Fetched ${validCertificates.length} certificates from remote (${domains.size - domainsToFetch.size} domains covered by wildcards)`
|
||||
);
|
||||
|
||||
// Download and decrypt new certificates
|
||||
await this.processValidCertificates(validCertificates);
|
||||
} else {
|
||||
logger.info(
|
||||
"All domains are covered by existing wildcard certificates, no fetch needed"
|
||||
);
|
||||
this.lastCertificateFetch = new Date();
|
||||
this.lastKnownDomains = new Set(domains);
|
||||
}
|
||||
|
||||
// Always ensure all existing certificates (including wildcards) are in the config
|
||||
await this.updateDynamicConfigFromLocalCerts(domains);
|
||||
} else {
|
||||
const timeSinceLastFetch = this.lastCertificateFetch
|
||||
? Math.round(
|
||||
@@ -544,7 +610,11 @@ export class TraefikConfigManager {
|
||||
// Clear existing certificates and rebuild from local state
|
||||
dynamicConfig.tls.certificates = [];
|
||||
|
||||
// Keep track of certificates we've already added to avoid duplicates
|
||||
const addedCertPaths = new Set<string>();
|
||||
|
||||
for (const domain of domains) {
|
||||
// First, try to find an exact match certificate
|
||||
const localState = this.lastLocalCertificateState.get(domain);
|
||||
if (localState && localState.exists) {
|
||||
const domainDir = path.join(
|
||||
@@ -554,11 +624,47 @@ export class TraefikConfigManager {
|
||||
const certPath = path.join(domainDir, "cert.pem");
|
||||
const keyPath = path.join(domainDir, "key.pem");
|
||||
|
||||
const certEntry = {
|
||||
certFile: certPath,
|
||||
keyFile: keyPath
|
||||
};
|
||||
dynamicConfig.tls.certificates.push(certEntry);
|
||||
if (!addedCertPaths.has(certPath)) {
|
||||
const certEntry = {
|
||||
certFile: certPath,
|
||||
keyFile: keyPath
|
||||
};
|
||||
dynamicConfig.tls.certificates.push(certEntry);
|
||||
addedCertPaths.add(certPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// If no exact match, check for wildcard certificates that cover this domain
|
||||
for (const [certDomain, certState] of this.lastLocalCertificateState) {
|
||||
if (certState.exists && certState.wildcard) {
|
||||
// Check if this wildcard certificate covers the domain
|
||||
if (domain.endsWith("." + certDomain)) {
|
||||
// Verify it's only one level deep (wildcard only covers one level)
|
||||
const prefix = domain.substring(
|
||||
0,
|
||||
domain.length - ("." + certDomain).length
|
||||
);
|
||||
if (!prefix.includes(".")) {
|
||||
const domainDir = path.join(
|
||||
config.getRawConfig().traefik.certificates_path,
|
||||
certDomain
|
||||
);
|
||||
const certPath = path.join(domainDir, "cert.pem");
|
||||
const keyPath = path.join(domainDir, "key.pem");
|
||||
|
||||
if (!addedCertPaths.has(certPath)) {
|
||||
const certEntry = {
|
||||
certFile: certPath,
|
||||
keyFile: keyPath
|
||||
};
|
||||
dynamicConfig.tls.certificates.push(certEntry);
|
||||
addedCertPaths.add(certPath);
|
||||
}
|
||||
break; // Found a wildcard that covers this domain
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,6 +683,7 @@ export class TraefikConfigManager {
|
||||
validCertificates: Array<{
|
||||
id: number;
|
||||
domain: string;
|
||||
wildcard: boolean | null;
|
||||
certFile: string | null;
|
||||
keyFile: string | null;
|
||||
expiresAt: Date | null;
|
||||
@@ -651,15 +758,24 @@ export class TraefikConfigManager {
|
||||
"utf8"
|
||||
);
|
||||
|
||||
// Check if this is a wildcard certificate and store it
|
||||
const wildcardPath = path.join(domainDir, ".wildcard");
|
||||
fs.writeFileSync(
|
||||
wildcardPath,
|
||||
cert.wildcard ? "true" : "false",
|
||||
"utf8"
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Certificate updated for domain: ${cert.domain}`
|
||||
`Certificate updated for domain: ${cert.domain}${cert.wildcard ? " (wildcard)" : ""}`
|
||||
);
|
||||
|
||||
// Update local state tracking
|
||||
this.lastLocalCertificateState.set(cert.domain, {
|
||||
exists: true,
|
||||
lastModified: new Date(),
|
||||
expiresAt: cert.expiresAt
|
||||
expiresAt: cert.expiresAt,
|
||||
wildcard: cert.wildcard
|
||||
});
|
||||
}
|
||||
|
||||
@@ -810,14 +926,8 @@ export class TraefikConfigManager {
|
||||
this.lastLocalCertificateState.delete(dirName);
|
||||
|
||||
// Remove from dynamic config
|
||||
const certFilePath = path.join(
|
||||
domainDir,
|
||||
"cert.pem"
|
||||
);
|
||||
const keyFilePath = path.join(
|
||||
domainDir,
|
||||
"key.pem"
|
||||
);
|
||||
const certFilePath = path.join(domainDir, "cert.pem");
|
||||
const keyFilePath = path.join(domainDir, "key.pem");
|
||||
const before = dynamicConfig.tls.certificates.length;
|
||||
dynamicConfig.tls.certificates =
|
||||
dynamicConfig.tls.certificates.filter(
|
||||
@@ -894,14 +1004,58 @@ export class TraefikConfigManager {
|
||||
monitorInterval: number;
|
||||
lastCertificateFetch: Date | null;
|
||||
localCertificateCount: number;
|
||||
wildcardCertificates: string[];
|
||||
domainsCoveredByWildcards: string[];
|
||||
} {
|
||||
const wildcardCertificates: string[] = [];
|
||||
const domainsCoveredByWildcards: string[] = [];
|
||||
|
||||
// Find wildcard certificates
|
||||
for (const [domain, state] of this.lastLocalCertificateState) {
|
||||
if (state.exists && state.wildcard) {
|
||||
wildcardCertificates.push(domain);
|
||||
}
|
||||
}
|
||||
|
||||
// Find domains covered by wildcards
|
||||
for (const domain of this.activeDomains) {
|
||||
if (isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
|
||||
domainsCoveredByWildcards.push(domain);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isRunning: this.isRunning,
|
||||
activeDomains: Array.from(this.activeDomains),
|
||||
monitorInterval:
|
||||
config.getRawConfig().traefik.monitor_interval || 5000,
|
||||
lastCertificateFetch: this.lastCertificateFetch,
|
||||
localCertificateCount: this.lastLocalCertificateState.size
|
||||
localCertificateCount: this.lastLocalCertificateState.size,
|
||||
wildcardCertificates,
|
||||
domainsCoveredByWildcards
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain is covered by existing wildcard certificates
|
||||
*/
|
||||
export function isDomainCoveredByWildcard(domain: string, lastLocalCertificateState: Map<string, { exists: boolean; wildcard: boolean | null }>): boolean {
|
||||
for (const [certDomain, state] of lastLocalCertificateState) {
|
||||
if (state.exists && state.wildcard) {
|
||||
// If stored as example.com but is wildcard, check subdomains
|
||||
if (domain.endsWith("." + certDomain)) {
|
||||
// Check that it's only one level deep (wildcard only covers one level)
|
||||
const prefix = domain.substring(
|
||||
0,
|
||||
domain.length - ("." + certDomain).length
|
||||
);
|
||||
// If prefix contains a dot, it's more than one level deep
|
||||
if (!prefix.includes(".")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function verifyRoleAccess(
|
||||
);
|
||||
}
|
||||
|
||||
const { roleIds } = req.body;
|
||||
const roleIds = req.body?.roleIds;
|
||||
const allRoleIds = roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]);
|
||||
|
||||
if (allRoleIds.length === 0) {
|
||||
|
||||
@@ -78,12 +78,16 @@ authenticated.post(
|
||||
verifyUserHasAction(ActionsEnum.updateOrg),
|
||||
org.updateOrg
|
||||
);
|
||||
authenticated.delete(
|
||||
"/org/:orgId",
|
||||
verifyOrgAccess,
|
||||
verifyUserIsOrgOwner,
|
||||
org.deleteOrg
|
||||
);
|
||||
|
||||
if (build !== "saas") {
|
||||
authenticated.delete(
|
||||
"/org/:orgId",
|
||||
verifyOrgAccess,
|
||||
verifyUserIsOrgOwner,
|
||||
verifyUserHasAction(ActionsEnum.deleteOrg),
|
||||
org.deleteOrg
|
||||
);
|
||||
}
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/site",
|
||||
|
||||
@@ -221,6 +221,13 @@ authenticated.get(
|
||||
domain.listDomains
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/invitations",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listInvitations),
|
||||
user.listInvitations
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/create-invite",
|
||||
verifyApiKeyOrgAccess,
|
||||
|
||||
@@ -49,19 +49,7 @@ export async function deleteOrg(
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
// Check if the user has permission to list sites
|
||||
const hasPermission = await checkUserActionPermission(
|
||||
ActionsEnum.deleteOrg,
|
||||
req
|
||||
);
|
||||
if (!hasPermission) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have permission to perform this action"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { db, resources } from "@server/db";
|
||||
import { apiKeys, roleResources, roles } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -74,13 +74,18 @@ export async function setResourceRoles(
|
||||
|
||||
const { resourceId } = parsedParams.data;
|
||||
|
||||
const orgId = req.userOrg?.orgId || req.apiKeyOrg?.orgId;
|
||||
// get the resource
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
if (!orgId) {
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Organization not found"
|
||||
"Resource not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -92,7 +97,7 @@ export async function setResourceRoles(
|
||||
.where(
|
||||
and(
|
||||
eq(roles.name, "Admin"),
|
||||
eq(roles.orgId, orgId)
|
||||
eq(roles.orgId, resource.orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
@@ -272,7 +272,7 @@ export async function createSite(
|
||||
type,
|
||||
dockerSocketEnabled: false,
|
||||
online: true,
|
||||
subnet: "0.0.0.0/0"
|
||||
subnet: "0.0.0.0/32"
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response } from "express";
|
||||
import { db, exitNodes } from "@server/db";
|
||||
import { and, eq, inArray, or, isNull, ne } from "drizzle-orm";
|
||||
import { and, eq, inArray, or, isNull, ne, isNotNull } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import config from "@server/lib/config";
|
||||
@@ -149,7 +149,10 @@ export async function getTraefikConfig(
|
||||
eq(sites.exitNodeId, exitNodeId),
|
||||
isNull(sites.exitNodeId)
|
||||
),
|
||||
inArray(sites.type, siteTypes)
|
||||
inArray(sites.type, siteTypes),
|
||||
config.getRawConfig().traefik.allow_raw_resources
|
||||
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
|
||||
: eq(resources.http, true),
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -58,18 +58,23 @@ export async function addUserRole(
|
||||
);
|
||||
}
|
||||
|
||||
const orgId = req.userOrg?.orgId || req.apiKeyOrg?.orgId;
|
||||
// get the role
|
||||
const [role] = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(eq(roles.roleId, roleId))
|
||||
.limit(1);
|
||||
|
||||
if (!orgId) {
|
||||
if (!role) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID")
|
||||
);
|
||||
}
|
||||
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (existingUser.length === 0) {
|
||||
@@ -93,7 +98,7 @@ export async function addUserRole(
|
||||
const roleExists = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.roleId, roleId), eq(roles.orgId, orgId)))
|
||||
.where(and(eq(roles.roleId, roleId), eq(roles.orgId, role.orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (roleExists.length === 0) {
|
||||
@@ -108,7 +113,7 @@ export async function addUserRole(
|
||||
const newUserRole = await db
|
||||
.update(userOrgs)
|
||||
.set({ roleId })
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId)))
|
||||
.returning();
|
||||
|
||||
return response(res, {
|
||||
|
||||
@@ -22,6 +22,7 @@ export function InvitationsDataTable<TData, TValue>({
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="invitations-table"
|
||||
title={t('invite')}
|
||||
searchPlaceholder={t('inviteSearch')}
|
||||
searchColumn="email"
|
||||
|
||||
@@ -24,6 +24,7 @@ export function RolesDataTable<TData, TValue>({
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="roles-table"
|
||||
title={t('roles')}
|
||||
searchPlaceholder={t('accessRolesSearch')}
|
||||
searchColumn="name"
|
||||
|
||||
@@ -24,6 +24,7 @@ export function UsersDataTable<TData, TValue>({
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="users-table"
|
||||
title={t('users')}
|
||||
searchPlaceholder={t('accessUsersSearch')}
|
||||
searchColumn="email"
|
||||
|
||||
@@ -22,6 +22,7 @@ export function OrgApiKeysDataTable<TData, TValue>({
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="Org-apikeys-table"
|
||||
title={t('apiKeys')}
|
||||
searchPlaceholder={t('searchApiKeys')}
|
||||
searchColumn="name"
|
||||
|
||||
@@ -20,6 +20,7 @@ export function ClientsDataTable<TData, TValue>({
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="clients-table"
|
||||
title="Clients"
|
||||
searchPlaceholder="Search clients..."
|
||||
searchColumn="name"
|
||||
|
||||
@@ -7,12 +7,13 @@ import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
FormMessage,
|
||||
FormDescription
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
@@ -33,7 +34,7 @@ import { CreateDomainResponse } from "@server/routers/domain/createOrgDomain";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon, AlertTriangle } from "lucide-react";
|
||||
import { InfoIcon, AlertTriangle, Globe } from "lucide-react";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import {
|
||||
InfoSection,
|
||||
@@ -43,9 +44,58 @@ import {
|
||||
} from "@app/components/InfoSection";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { build } from "@server/build";
|
||||
import { toASCII, toUnicode } from 'punycode';
|
||||
|
||||
|
||||
// Helper functions for Unicode domain handling
|
||||
function toPunycode(domain: string): string {
|
||||
try {
|
||||
const parts = toASCII(domain);
|
||||
return parts;
|
||||
} catch (error) {
|
||||
return domain.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function fromPunycode(domain: string): string {
|
||||
try {
|
||||
const parts = toUnicode(domain);
|
||||
return parts;
|
||||
} catch (error) {
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
|
||||
function isValidDomainFormat(domain: string): boolean {
|
||||
const unicodeRegex = /^(?!:\/\/)([^\s.]+\.)*[^\s.]+$/;
|
||||
|
||||
if (!unicodeRegex.test(domain)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parts = domain.split('.');
|
||||
for (const part of parts) {
|
||||
if (part.length === 0 || part.startsWith('-') || part.endsWith('-')) {
|
||||
return false;
|
||||
}
|
||||
if (part.length > 63) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (domain.length > 253) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
baseDomain: z.string().min(1, "Domain is required"),
|
||||
baseDomain: z
|
||||
.string()
|
||||
.min(1, "Domain is required")
|
||||
.refine((val) => isValidDomainFormat(val), "Invalid domain format")
|
||||
.transform((val) => toPunycode(val)),
|
||||
type: z.enum(["ns", "cname", "wildcard"])
|
||||
});
|
||||
|
||||
@@ -109,8 +159,14 @@ export default function CreateDomainForm({
|
||||
}
|
||||
}
|
||||
|
||||
const domainType = form.watch("type");
|
||||
const baseDomain = form.watch("baseDomain");
|
||||
const domainInputValue = form.watch("baseDomain") || "";
|
||||
|
||||
const punycodePreview = useMemo(() => {
|
||||
if (!domainInputValue) return "";
|
||||
const punycode = toPunycode(domainInputValue);
|
||||
return punycode !== domainInputValue.toLowerCase() ? punycode : "";
|
||||
}, [domainInputValue]);
|
||||
|
||||
let domainOptions: any = [];
|
||||
if (build == "enterprise" || build == "saas") {
|
||||
@@ -182,10 +238,23 @@ export default function CreateDomainForm({
|
||||
<FormLabel>{t("domain")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="example.com"
|
||||
placeholder="example.com, café.com, 日本.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
{punycodePreview && (
|
||||
<FormDescription className="flex items-center gap-2 text-xs">
|
||||
<Alert>
|
||||
<Globe className="h-4 w-4" />
|
||||
<AlertTitle>{t("internationaldomaindetected")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div className="mt-2 space-y-1">
|
||||
<p>{t("willbestoredas")} <code className="font-mono px-1 py-0.5 rounded">{punycodePreview}</code></p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -206,66 +275,73 @@ export default function CreateDomainForm({
|
||||
|
||||
<div className="space-y-4">
|
||||
{createdDomain.nsRecords &&
|
||||
createdDomain.nsRecords.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-medium mb-3">
|
||||
{t("createDomainNsRecords")}
|
||||
</h3>
|
||||
<InfoSections cols={1}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("createDomainRecord")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"createDomainType"
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
NS
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"createDomainName"
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
{baseDomain}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"createDomainValue"
|
||||
)}
|
||||
</span>
|
||||
{createdDomain.nsRecords.map(
|
||||
(
|
||||
nsRecord,
|
||||
index
|
||||
) => (
|
||||
<div
|
||||
className="flex justify-between items-center"
|
||||
key={index}
|
||||
>
|
||||
<CopyToClipboard
|
||||
text={
|
||||
nsRecord
|
||||
}
|
||||
/>
|
||||
createdDomain.nsRecords.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-medium mb-3">
|
||||
{t("createDomainNsRecords")}
|
||||
</h3>
|
||||
<InfoSections cols={1}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("createDomainRecord")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"createDomainType"
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
NS
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"createDomainName"
|
||||
)}
|
||||
</span>
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-mono block">
|
||||
{fromPunycode(baseDomain)}
|
||||
</span>
|
||||
{fromPunycode(baseDomain) !== baseDomain && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
({baseDomain})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"createDomainValue"
|
||||
)}
|
||||
</span>
|
||||
{createdDomain.nsRecords.map(
|
||||
(
|
||||
nsRecord,
|
||||
index
|
||||
) => (
|
||||
<div
|
||||
className="flex justify-between items-center"
|
||||
key={index}
|
||||
>
|
||||
<CopyToClipboard
|
||||
text={
|
||||
nsRecord
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createdDomain.cnameRecords &&
|
||||
createdDomain.cnameRecords.length > 0 && (
|
||||
@@ -307,11 +383,16 @@ export default function CreateDomainForm({
|
||||
"createDomainName"
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
{
|
||||
cnameRecord.baseDomain
|
||||
}
|
||||
</span>
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-mono block">
|
||||
{fromPunycode(cnameRecord.baseDomain)}
|
||||
</span>
|
||||
{fromPunycode(cnameRecord.baseDomain) !== cnameRecord.baseDomain && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
({cnameRecord.baseDomain})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
@@ -374,11 +455,16 @@ export default function CreateDomainForm({
|
||||
"createDomainName"
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
{
|
||||
aRecord.baseDomain
|
||||
}
|
||||
</span>
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-mono block">
|
||||
{fromPunycode(aRecord.baseDomain)}
|
||||
</span>
|
||||
{fromPunycode(aRecord.baseDomain) !== aRecord.baseDomain && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
({aRecord.baseDomain})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
@@ -390,7 +476,7 @@ export default function CreateDomainForm({
|
||||
{
|
||||
aRecord.value
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</InfoSectionContent>
|
||||
@@ -440,11 +526,16 @@ export default function CreateDomainForm({
|
||||
"createDomainName"
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
{
|
||||
txtRecord.baseDomain
|
||||
}
|
||||
</span>
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-mono block">
|
||||
{fromPunycode(txtRecord.baseDomain)}
|
||||
</span>
|
||||
{fromPunycode(txtRecord.baseDomain) !== txtRecord.baseDomain && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
({txtRecord.baseDomain})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
@@ -513,4 +604,4 @@ export default function CreateDomainForm({
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export function DomainsDataTable<TData, TValue>({
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="domains-table"
|
||||
title={t("domains")}
|
||||
searchPlaceholder={t("domainsSearch")}
|
||||
searchColumn="baseDomain"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { GetOrgResponse } from "@server/routers/org";
|
||||
import { redirect } from "next/navigation";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import { ListDomainsResponse } from "@server/routers/domain";
|
||||
import { toUnicode } from 'punycode';
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
@@ -22,7 +23,13 @@ export default async function DomainsPage(props: Props) {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<ListDomainsResponse>
|
||||
>(`/org/${params.orgId}/domains`, await authCookieHeader());
|
||||
domains = res.data.data.domains as DomainRow[];
|
||||
|
||||
const rawDomains = res.data.data.domains as DomainRow[];
|
||||
|
||||
domains = rawDomains.map((domain) => ({
|
||||
...domain,
|
||||
baseDomain: toUnicode(domain.baseDomain),
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
@@ -99,6 +99,43 @@ type ResourcesTableProps = {
|
||||
defaultView?: "proxy" | "internal";
|
||||
};
|
||||
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
PAGE_SIZE: 'datatable-page-size',
|
||||
getTablePageSize: (tableId?: string) =>
|
||||
tableId ? `datatable-${tableId}-page-size` : STORAGE_KEYS.PAGE_SIZE
|
||||
};
|
||||
|
||||
const getStoredPageSize = (tableId?: string, defaultSize = 20): number => {
|
||||
if (typeof window === 'undefined') return defaultSize;
|
||||
|
||||
try {
|
||||
const key = STORAGE_KEYS.getTablePageSize(tableId);
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
const parsed = parseInt(stored, 10);
|
||||
if (parsed > 0 && parsed <= 1000) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to read page size from localStorage:', error);
|
||||
}
|
||||
return defaultSize;
|
||||
};
|
||||
|
||||
const setStoredPageSize = (pageSize: number, tableId?: string): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const key = STORAGE_KEYS.getTablePageSize(tableId);
|
||||
localStorage.setItem(key, pageSize.toString());
|
||||
} catch (error) {
|
||||
console.warn('Failed to save page size to localStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export default function ResourcesTable({
|
||||
resources,
|
||||
internalResources,
|
||||
@@ -113,6 +150,13 @@ export default function ResourcesTable({
|
||||
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const [proxyPageSize, setProxyPageSize] = useState<number>(() =>
|
||||
getStoredPageSize('proxy-resources', 20)
|
||||
);
|
||||
const [internalPageSize, setInternalPageSize] = useState<number>(() =>
|
||||
getStoredPageSize('internal-resources', 20)
|
||||
);
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedResource, setSelectedResource] =
|
||||
useState<ResourceRow | null>();
|
||||
@@ -559,7 +603,7 @@ export default function ResourcesTable({
|
||||
onGlobalFilterChange: setProxyGlobalFilter,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 20,
|
||||
pageSize: proxyPageSize,
|
||||
pageIndex: 0
|
||||
}
|
||||
},
|
||||
@@ -582,7 +626,7 @@ export default function ResourcesTable({
|
||||
onGlobalFilterChange: setInternalGlobalFilter,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 20,
|
||||
pageSize: internalPageSize,
|
||||
pageIndex: 0
|
||||
}
|
||||
},
|
||||
@@ -593,6 +637,16 @@ export default function ResourcesTable({
|
||||
}
|
||||
});
|
||||
|
||||
const handleProxyPageSizeChange = (newPageSize: number) => {
|
||||
setProxyPageSize(newPageSize);
|
||||
setStoredPageSize(newPageSize, 'proxy-resources');
|
||||
};
|
||||
|
||||
const handleInternalPageSizeChange = (newPageSize: number) => {
|
||||
setInternalPageSize(newPageSize);
|
||||
setStoredPageSize(newPageSize, 'internal-resources');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedResource && (
|
||||
@@ -761,7 +815,10 @@ export default function ResourcesTable({
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination table={proxyTable} />
|
||||
<DataTablePagination
|
||||
table={proxyTable}
|
||||
onPageSizeChange={handleProxyPageSizeChange}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="internal">
|
||||
@@ -861,6 +918,7 @@ export default function ResourcesTable({
|
||||
<div className="mt-4">
|
||||
<DataTablePagination
|
||||
table={internalTable}
|
||||
onPageSizeChange={handleInternalPageSizeChange}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
import { toUnicode } from "punycode";
|
||||
|
||||
interface DomainOption {
|
||||
baseDomain: string;
|
||||
@@ -91,7 +92,7 @@ export default function CustomDomainInput({
|
||||
key={option.domainId}
|
||||
value={option.domainId}
|
||||
>
|
||||
.{option.baseDomain}
|
||||
.{toUnicode(option.baseDomain)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -12,15 +12,19 @@ import {
|
||||
} from "@app/components/InfoSection";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
import { toUnicode } from 'punycode';
|
||||
|
||||
type ResourceInfoBoxType = {};
|
||||
|
||||
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
export default function ResourceInfoBox({ }: ResourceInfoBoxType) {
|
||||
const { resource, authInfo } = useResourceContext();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||
|
||||
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
@@ -34,9 +38,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{authInfo.password ||
|
||||
authInfo.pincode ||
|
||||
authInfo.sso ||
|
||||
authInfo.whitelist ? (
|
||||
authInfo.pincode ||
|
||||
authInfo.sso ||
|
||||
authInfo.whitelist ? (
|
||||
<div className="flex items-start space-x-2 text-green-500">
|
||||
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
||||
<span>{t("protected")}</span>
|
||||
|
||||
@@ -53,6 +53,9 @@ import {
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import { Globe } from "lucide-react";
|
||||
import { build } from "@server/build";
|
||||
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
||||
import { DomainRow } from "../../../domains/DomainsTable";
|
||||
import { toASCII, toUnicode } from "punycode";
|
||||
|
||||
export default function GeneralForm() {
|
||||
const [formKey, setFormKey] = useState(0);
|
||||
@@ -79,12 +82,13 @@ export default function GeneralForm() {
|
||||
|
||||
const [loadingPage, setLoadingPage] = useState(true);
|
||||
const [resourceFullDomain, setResourceFullDomain] = useState(
|
||||
`${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
|
||||
`${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`
|
||||
);
|
||||
const [selectedDomain, setSelectedDomain] = useState<{
|
||||
domainId: string;
|
||||
subdomain?: string;
|
||||
fullDomain: string;
|
||||
baseDomain: string;
|
||||
} | null>(null);
|
||||
|
||||
const GeneralFormSchema = z
|
||||
@@ -153,7 +157,11 @@ export default function GeneralForm() {
|
||||
});
|
||||
|
||||
if (res?.status === 200) {
|
||||
const domains = res.data.data.domains;
|
||||
const rawDomains = res.data.data.domains as DomainRow[];
|
||||
const domains = rawDomains.map((domain) => ({
|
||||
...domain,
|
||||
baseDomain: toUnicode(domain.baseDomain),
|
||||
}));
|
||||
setBaseDomains(domains);
|
||||
setFormKey((key) => key + 1);
|
||||
}
|
||||
@@ -178,7 +186,7 @@ export default function GeneralForm() {
|
||||
{
|
||||
enabled: data.enabled,
|
||||
name: data.name,
|
||||
subdomain: data.subdomain,
|
||||
subdomain: data.subdomain ? toASCII(data.subdomain) : undefined,
|
||||
domainId: data.domainId,
|
||||
proxyPort: data.proxyPort,
|
||||
// ...(!resource.http && {
|
||||
@@ -317,10 +325,10 @@ export default function GeneralForm() {
|
||||
.target
|
||||
.value
|
||||
? parseInt(
|
||||
e
|
||||
.target
|
||||
.value
|
||||
)
|
||||
e
|
||||
.target
|
||||
.value
|
||||
)
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
@@ -441,7 +449,8 @@ export default function GeneralForm() {
|
||||
const selected = {
|
||||
domainId: res.domainId,
|
||||
subdomain: res.subdomain,
|
||||
fullDomain: res.fullDomain
|
||||
fullDomain: res.fullDomain,
|
||||
baseDomain: res.baseDomain
|
||||
};
|
||||
setSelectedDomain(selected);
|
||||
}}
|
||||
@@ -454,18 +463,23 @@ export default function GeneralForm() {
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (selectedDomain) {
|
||||
setResourceFullDomain(
|
||||
selectedDomain.fullDomain
|
||||
);
|
||||
form.setValue(
|
||||
"domainId",
|
||||
selectedDomain.domainId
|
||||
);
|
||||
form.setValue(
|
||||
"subdomain",
|
||||
selectedDomain.subdomain
|
||||
);
|
||||
const sanitizedSubdomain = selectedDomain.subdomain
|
||||
? finalizeSubdomainSanitize(selectedDomain.subdomain)
|
||||
: "";
|
||||
|
||||
const sanitizedFullDomain = sanitizedSubdomain
|
||||
? `${sanitizedSubdomain}.${selectedDomain.baseDomain}`
|
||||
: selectedDomain.baseDomain;
|
||||
|
||||
setResourceFullDomain(sanitizedFullDomain);
|
||||
form.setValue("domainId", selectedDomain.domainId);
|
||||
form.setValue("subdomain", sanitizedSubdomain);
|
||||
|
||||
setEditDomainOpen(false);
|
||||
|
||||
toast({
|
||||
description: `Final domain: ${sanitizedFullDomain}`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -94,6 +94,7 @@ import {
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
||||
|
||||
const addTargetSchema = z.object({
|
||||
ip: z.string().refine(isTargetValid),
|
||||
@@ -417,11 +418,11 @@ export default function ReverseProxyTargets(props: {
|
||||
targets.map((target) =>
|
||||
target.targetId === targetId
|
||||
? {
|
||||
...target,
|
||||
...data,
|
||||
updated: true,
|
||||
siteType: site?.type || null
|
||||
}
|
||||
...target,
|
||||
...data,
|
||||
updated: true,
|
||||
siteType: site?.type || null
|
||||
}
|
||||
: target
|
||||
)
|
||||
);
|
||||
@@ -545,7 +546,7 @@ export default function ReverseProxyTargets(props: {
|
||||
className={cn(
|
||||
"justify-between flex-1",
|
||||
!row.original.siteId &&
|
||||
"text-muted-foreground"
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{row.original.siteId
|
||||
@@ -614,31 +615,31 @@ export default function ReverseProxyTargets(props: {
|
||||
},
|
||||
...(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: "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",
|
||||
@@ -647,12 +648,33 @@ export default function ReverseProxyTargets(props: {
|
||||
<Input
|
||||
defaultValue={row.original.ip}
|
||||
className="min-w-[150px]"
|
||||
onBlur={(e) =>
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
ip: e.target.value
|
||||
})
|
||||
}
|
||||
onBlur={(e) => {
|
||||
const input = e.target.value.trim();
|
||||
const hasProtocol = /^(https?|h2c):\/\//.test(input);
|
||||
const hasPort = /:\d+(?:\/|$)/.test(input);
|
||||
|
||||
if (hasProtocol || hasPort) {
|
||||
const parsed = parseHostTarget(input);
|
||||
if (parsed) {
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
method: hasProtocol ? parsed.protocol : row.original.method,
|
||||
ip: parsed.host,
|
||||
port: hasPort ? parsed.port : row.original.port
|
||||
});
|
||||
} else {
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
ip: input
|
||||
});
|
||||
}
|
||||
} else {
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
ip: input
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -785,21 +807,21 @@ export default function ReverseProxyTargets(props: {
|
||||
className={cn(
|
||||
"justify-between flex-1",
|
||||
!field.value &&
|
||||
"text-muted-foreground"
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? sites.find(
|
||||
(
|
||||
site
|
||||
) =>
|
||||
site.siteId ===
|
||||
field.value
|
||||
)
|
||||
?.name
|
||||
(
|
||||
site
|
||||
) =>
|
||||
site.siteId ===
|
||||
field.value
|
||||
)
|
||||
?.name
|
||||
: t(
|
||||
"siteSelect"
|
||||
)}
|
||||
"siteSelect"
|
||||
)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
@@ -865,18 +887,18 @@ export default function ReverseProxyTargets(props: {
|
||||
);
|
||||
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;
|
||||
"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 />
|
||||
@@ -942,11 +964,32 @@ export default function ReverseProxyTargets(props: {
|
||||
name="ip"
|
||||
render={({ field }) => (
|
||||
<FormItem className="relative">
|
||||
<FormLabel>
|
||||
{t("targetAddr")}
|
||||
</FormLabel>
|
||||
<FormLabel>{t("targetAddr")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="ip" {...field} />
|
||||
<Input
|
||||
id="ip"
|
||||
{...field}
|
||||
onBlur={(e) => {
|
||||
const input = e.target.value.trim();
|
||||
const hasProtocol = /^(https?|h2c):\/\//.test(input);
|
||||
const hasPort = /:\d+(?:\/|$)/.test(input);
|
||||
|
||||
if (hasProtocol || hasPort) {
|
||||
const parsed = parseHostTarget(input);
|
||||
if (parsed) {
|
||||
if (hasProtocol || !addTargetForm.getValues("method")) {
|
||||
addTargetForm.setValue("method", parsed.protocol);
|
||||
}
|
||||
addTargetForm.setValue("ip", parsed.host);
|
||||
if (hasPort || !addTargetForm.getValues("port")) {
|
||||
addTargetForm.setValue("port", parsed.port);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
field.onBlur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -1048,12 +1091,12 @@ export default function ReverseProxyTargets(props: {
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -88,6 +88,9 @@ import { ArrayElement } from "@server/types/ArrayElement";
|
||||
import { isTargetValid } from "@server/lib/validators";
|
||||
import { ListTargetsResponse } from "@server/routers/target";
|
||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
||||
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
||||
import { toASCII, toUnicode } from 'punycode';
|
||||
import { DomainRow } from "../../domains/DomainsTable";
|
||||
|
||||
const baseResourceFormSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
@@ -164,12 +167,12 @@ export default function Page() {
|
||||
...(!env.flags.allowRawResources
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "raw" as ResourceType,
|
||||
title: t("resourceRaw"),
|
||||
description: t("resourceRawDescription")
|
||||
}
|
||||
])
|
||||
{
|
||||
id: "raw" as ResourceType,
|
||||
title: t("resourceRaw"),
|
||||
description: t("resourceRawDescription")
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
const baseForm = useForm<BaseResourceFormValues>({
|
||||
@@ -301,11 +304,11 @@ export default function Page() {
|
||||
targets.map((target) =>
|
||||
target.targetId === targetId
|
||||
? {
|
||||
...target,
|
||||
...data,
|
||||
updated: true,
|
||||
siteType: site?.type || null
|
||||
}
|
||||
...target,
|
||||
...data,
|
||||
updated: true,
|
||||
siteType: site?.type || null
|
||||
}
|
||||
: target
|
||||
)
|
||||
);
|
||||
@@ -326,7 +329,7 @@ export default function Page() {
|
||||
if (isHttp) {
|
||||
const httpData = httpForm.getValues();
|
||||
Object.assign(payload, {
|
||||
subdomain: httpData.subdomain,
|
||||
subdomain: httpData.subdomain ? toASCII(httpData.subdomain) : undefined,
|
||||
domainId: httpData.domainId,
|
||||
protocol: "tcp"
|
||||
});
|
||||
@@ -468,7 +471,11 @@ export default function Page() {
|
||||
});
|
||||
|
||||
if (res?.status === 200) {
|
||||
const domains = res.data.data.domains;
|
||||
const rawDomains = res.data.data.domains as DomainRow[];
|
||||
const domains = rawDomains.map((domain) => ({
|
||||
...domain,
|
||||
baseDomain: toUnicode(domain.baseDomain),
|
||||
}));
|
||||
setBaseDomains(domains);
|
||||
// if (domains.length) {
|
||||
// httpForm.setValue("domainId", domains[0].domainId);
|
||||
@@ -520,7 +527,7 @@ export default function Page() {
|
||||
className={cn(
|
||||
"justify-between flex-1",
|
||||
!row.original.siteId &&
|
||||
"text-muted-foreground"
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{row.original.siteId
|
||||
@@ -589,31 +596,31 @@ export default function Page() {
|
||||
},
|
||||
...(baseForm.watch("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: "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",
|
||||
@@ -622,12 +629,23 @@ export default function Page() {
|
||||
<Input
|
||||
defaultValue={row.original.ip}
|
||||
className="min-w-[150px]"
|
||||
onBlur={(e) =>
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
ip: e.target.value
|
||||
})
|
||||
}
|
||||
onBlur={(e) => {
|
||||
const parsed = parseHostTarget(e.target.value);
|
||||
|
||||
if (parsed) {
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
method: parsed.protocol,
|
||||
ip: parsed.host,
|
||||
port: parsed.port ? Number(parsed.port) : undefined,
|
||||
});
|
||||
} else {
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
ip: e.target.value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -909,10 +927,10 @@ export default function Page() {
|
||||
.target
|
||||
.value
|
||||
? parseInt(
|
||||
e
|
||||
.target
|
||||
.value
|
||||
)
|
||||
e
|
||||
.target
|
||||
.value
|
||||
)
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
@@ -1015,21 +1033,21 @@ export default function Page() {
|
||||
className={cn(
|
||||
"justify-between flex-1",
|
||||
!field.value &&
|
||||
"text-muted-foreground"
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? sites.find(
|
||||
(
|
||||
site
|
||||
) =>
|
||||
site.siteId ===
|
||||
field.value
|
||||
)
|
||||
?.name
|
||||
(
|
||||
site
|
||||
) =>
|
||||
site.siteId ===
|
||||
field.value
|
||||
)
|
||||
?.name
|
||||
: t(
|
||||
"siteSelect"
|
||||
)}
|
||||
"siteSelect"
|
||||
)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
@@ -1097,18 +1115,18 @@ export default function Page() {
|
||||
);
|
||||
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;
|
||||
"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 />
|
||||
@@ -1176,21 +1194,25 @@ export default function Page() {
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={
|
||||
addTargetForm.control
|
||||
}
|
||||
control={addTargetForm.control}
|
||||
name="ip"
|
||||
render={({ field }) => (
|
||||
<FormItem className="relative">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"targetAddr"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormLabel>{t("targetAddr")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="ip"
|
||||
{...field}
|
||||
onBlur={(e) => {
|
||||
const parsed = parseHostTarget(e.target.value);
|
||||
if (parsed) {
|
||||
addTargetForm.setValue("method", parsed.protocol);
|
||||
addTargetForm.setValue("ip", parsed.host);
|
||||
addTargetForm.setValue("port", parsed.port);
|
||||
} else {
|
||||
field.onBlur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -1270,12 +1292,12 @@ export default function Page() {
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { GetOrgResponse } from "@server/routers/org";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { toUnicode } from "punycode";
|
||||
|
||||
type ResourcesPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
@@ -75,7 +76,9 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||
id: resource.resourceId,
|
||||
name: resource.name,
|
||||
orgId: params.orgId,
|
||||
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
|
||||
|
||||
|
||||
domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`,
|
||||
protocol: resource.protocol,
|
||||
proxyPort: resource.proxyPort,
|
||||
http: resource.http,
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
} from "@app/components/ui/collapsible";
|
||||
import AccessTokenSection from "./AccessTokenUsage";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toUnicode } from 'punycode';
|
||||
|
||||
type FormProps = {
|
||||
open: boolean;
|
||||
@@ -159,7 +160,7 @@ export default function CreateShareLinkForm({
|
||||
.map((r) => ({
|
||||
resourceId: r.resourceId,
|
||||
name: r.name,
|
||||
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`
|
||||
resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export function ShareLinksDataTable<TData, TValue>({
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="shareLinks-table"
|
||||
title={t('shareLinks')}
|
||||
searchPlaceholder={t('shareSearch')}
|
||||
searchColumn="name"
|
||||
|
||||
@@ -26,6 +26,7 @@ export function SitesDataTable<TData, TValue>({
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="sites-table"
|
||||
title={t('sites')}
|
||||
searchPlaceholder={t('searchSitesProgress')}
|
||||
searchColumn="name"
|
||||
|
||||
@@ -47,6 +47,7 @@ export function ApiKeysDataTable<TData, TValue>({
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="apiKeys-table"
|
||||
title={t('apiKeys')}
|
||||
searchPlaceholder={t('searchApiKeys')}
|
||||
searchColumn="name"
|
||||
|
||||
@@ -21,6 +21,7 @@ export function IdpDataTable<TData, TValue>({
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="idp-table"
|
||||
title={t('idp')}
|
||||
searchPlaceholder={t('idpSearch')}
|
||||
searchColumn="name"
|
||||
|
||||
@@ -22,6 +22,7 @@ export function PolicyDataTable<TData, TValue>({
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="orgPolicies-table"
|
||||
title={t('orgPolicies')}
|
||||
searchPlaceholder={t('orgPoliciesSearch')}
|
||||
searchColumn="orgId"
|
||||
|
||||
@@ -136,6 +136,7 @@ export function LicenseKeysDataTable({
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={licenseKeys}
|
||||
persistPageSize="licenseKeys-table"
|
||||
title={t('licenseKeys')}
|
||||
searchPlaceholder={t('licenseKeySearch')}
|
||||
searchColumn="licenseKey"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
@@ -18,30 +20,27 @@ import {
|
||||
ExternalLink
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function ManagedPage() {
|
||||
const t = useTranslations();
|
||||
|
||||
export default async function ManagedPage() {
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Managed Self-Hosted"
|
||||
description="More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles"
|
||||
title={t("managedSelfHosted.title")}
|
||||
description={t("managedSelfHosted.description")}
|
||||
/>
|
||||
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionBody>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
<strong>Managed Self-Hosted Pangolin</strong> is a
|
||||
deployment option designed for people who want
|
||||
simplicity and extra reliability while still keeping
|
||||
their data private and self-hosted.
|
||||
<strong>{t("managedSelfHosted.introTitle")}</strong>{" "}
|
||||
{t("managedSelfHosted.introDescription")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
With this option, you still run your own Pangolin
|
||||
node — your tunnels, SSL termination, and traffic
|
||||
all stay on your server. The difference is that
|
||||
management and monitoring are handled through our
|
||||
cloud dashboard, which unlocks a number of benefits:
|
||||
{t("managedSelfHosted.introDetail")}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 py-4">
|
||||
@@ -50,13 +49,14 @@ export default async function ManagedPage() {
|
||||
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
Simpler operations
|
||||
{t(
|
||||
"managedSelfHosted.benefitSimplerOperations.title"
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No need to run your own mail server
|
||||
or set up complex alerting. You'll
|
||||
get health checks and downtime
|
||||
alerts out of the box.
|
||||
{t(
|
||||
"managedSelfHosted.benefitSimplerOperations.description"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,13 +65,14 @@ export default async function ManagedPage() {
|
||||
<RefreshCw className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
Automatic updates
|
||||
{t(
|
||||
"managedSelfHosted.benefitAutomaticUpdates.title"
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The cloud dashboard evolves quickly,
|
||||
so you get new features and bug
|
||||
fixes without having to manually
|
||||
pull new containers every time.
|
||||
{t(
|
||||
"managedSelfHosted.benefitAutomaticUpdates.description"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,12 +81,14 @@ export default async function ManagedPage() {
|
||||
<Wrench className="w-5 h-5 text-orange-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
Less maintenance
|
||||
{t(
|
||||
"managedSelfHosted.benefitLessMaintenance.title"
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No database migrations, backups, or
|
||||
extra infrastructure to manage. We
|
||||
handle that in the cloud.
|
||||
{t(
|
||||
"managedSelfHosted.benefitLessMaintenance.description"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,13 +99,14 @@ export default async function ManagedPage() {
|
||||
<Activity className="w-5 h-5 text-purple-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
Cloud failover
|
||||
{t(
|
||||
"managedSelfHosted.benefitCloudFailover.title"
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
If your node goes down, your tunnels
|
||||
can temporarily fail over to our
|
||||
cloud points of presence until you
|
||||
bring it back online.
|
||||
{t(
|
||||
"managedSelfHosted.benefitCloudFailover.description"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,12 +114,14 @@ export default async function ManagedPage() {
|
||||
<Shield className="w-5 h-5 text-indigo-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
High availability (PoPs)
|
||||
{t(
|
||||
"managedSelfHosted.benefitHighAvailability.title"
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You can also attach multiple nodes
|
||||
to your account for redundancy and
|
||||
better performance.
|
||||
{t(
|
||||
"managedSelfHosted.benefitHighAvailability.description"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,13 +130,14 @@ export default async function ManagedPage() {
|
||||
<Zap className="w-5 h-5 text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
Future enhancements
|
||||
{t(
|
||||
"managedSelfHosted.benefitFutureEnhancements.title"
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We're planning to add more
|
||||
analytics, alerting, and management
|
||||
tools to make your deployment even
|
||||
more robust.
|
||||
{t(
|
||||
"managedSelfHosted.benefitFutureEnhancements.description"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,15 +148,14 @@ export default async function ManagedPage() {
|
||||
variant="neutral"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
Read the docs to learn more about the Managed
|
||||
Self-Hosted option in our{" "}
|
||||
{t("managedSelfHosted.docsAlert.text")}{" "}
|
||||
<Link
|
||||
href="https://docs.digpangolin.com/self-host/advanced/convert-to-managed"
|
||||
href="https://docs.digpangolin.com/manage/managed"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline text-primary flex items-center gap-1"
|
||||
>
|
||||
documentation
|
||||
{t("managedSelfHosted.docsAlert.documentation")}
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Link>
|
||||
.
|
||||
@@ -157,13 +163,13 @@ export default async function ManagedPage() {
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Link
|
||||
href="https://docs.digpangolin.com/self-host/advanced/convert-to-managed"
|
||||
href="https://docs.digpangolin.com/self-host/convert-managed"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline text-primary flex items-center gap-1"
|
||||
>
|
||||
<Button>
|
||||
Convert This Node to Managed Self-Hosted
|
||||
{t("managedSelfHosted.convertButton")}
|
||||
</Button>
|
||||
</Link>
|
||||
</SettingsSectionFooter>
|
||||
|
||||
@@ -22,6 +22,7 @@ export function UsersDataTable<TData, TValue>({
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="userServer-table"
|
||||
title={t('userServer')}
|
||||
searchPlaceholder={t('userSearch')}
|
||||
searchColumn="email"
|
||||
|
||||
@@ -48,6 +48,7 @@ export default async function InvitePage(props: {
|
||||
)
|
||||
.catch((e) => {
|
||||
error = formatAxiosError(e);
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
@@ -55,13 +56,13 @@ export default async function InvitePage(props: {
|
||||
}
|
||||
|
||||
function cardType() {
|
||||
if (error.includes(t('inviteErrorWrongUser'))) {
|
||||
if (error.includes("Invite is not for this user")) {
|
||||
return "wrong_user";
|
||||
} else if (
|
||||
error.includes(t('inviteErrorUserNotExists'))
|
||||
error.includes("User does not exist. Please create an account first.")
|
||||
) {
|
||||
return "user_does_not_exist";
|
||||
} else if (error.includes(t('inviteErrorLoginRequired'))) {
|
||||
} else if (error.includes("You must be logged in to accept an invite")) {
|
||||
return "not_logged_in";
|
||||
} else {
|
||||
return "rejected";
|
||||
|
||||
@@ -18,28 +18,38 @@ import { useTranslations } from "next-intl";
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>;
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table
|
||||
table,
|
||||
onPageSizeChange
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const t = useTranslations();
|
||||
|
||||
const handlePageSizeChange = (value: string) => {
|
||||
const newPageSize = Number(value);
|
||||
table.setPageSize(newPageSize);
|
||||
|
||||
// Call the callback if provided (for persistence)
|
||||
if (onPageSizeChange) {
|
||||
onPageSizeChange(newPageSize);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between text-muted-foreground">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
onValueChange={handlePageSizeChange}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue
|
||||
placeholder={table.getState().pagination.pageSize}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
<SelectContent side="bottom">
|
||||
{[10, 20, 30, 40, 50, 100].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
|
||||
@@ -37,6 +37,13 @@ import { cn } from "@/lib/cn";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
sanitizeInputRaw,
|
||||
finalizeSubdomainSanitize,
|
||||
validateByDomainType,
|
||||
isValidSubdomainStructure
|
||||
} from "@/lib/subdomain-utils";
|
||||
import { toUnicode } from "punycode";
|
||||
|
||||
type OrganizationDomain = {
|
||||
domainId: string;
|
||||
@@ -120,6 +127,7 @@ export default function DomainPicker2({
|
||||
)
|
||||
.map((domain) => ({
|
||||
...domain,
|
||||
baseDomain: toUnicode(domain.baseDomain),
|
||||
type: domain.type as "ns" | "cname" | "wildcard"
|
||||
}));
|
||||
setOrganizationDomains(domains);
|
||||
@@ -255,108 +263,64 @@ export default function DomainPicker2({
|
||||
|
||||
const dropdownOptions = generateDropdownOptions();
|
||||
|
||||
const validateSubdomain = (
|
||||
subdomain: string,
|
||||
baseDomain: DomainOption
|
||||
): boolean => {
|
||||
if (!baseDomain) return false;
|
||||
const finalizeSubdomain = (sub: string, base: DomainOption): string => {
|
||||
const sanitized = finalizeSubdomainSanitize(sub);
|
||||
|
||||
if (baseDomain.type === "provided-search") {
|
||||
return /^[a-zA-Z0-9-]+$/.test(subdomain);
|
||||
if (!sanitized) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid subdomain",
|
||||
description: `The input "${sub}" was removed because it's not valid.`,
|
||||
});
|
||||
return "";
|
||||
}
|
||||
|
||||
if (baseDomain.type === "organization") {
|
||||
if (baseDomain.domainType === "cname") {
|
||||
return subdomain === "";
|
||||
} else if (baseDomain.domainType === "ns") {
|
||||
return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/.test(subdomain);
|
||||
} else if (baseDomain.domainType === "wildcard") {
|
||||
return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/.test(subdomain);
|
||||
}
|
||||
const ok = validateByDomainType(sanitized, {
|
||||
type: base.type === "provided-search" ? "provided-search" : "organization",
|
||||
domainType: base.domainType
|
||||
});
|
||||
|
||||
if (!ok) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid subdomain",
|
||||
description: `"${sub}" could not be made valid for ${base.domain}.`,
|
||||
});
|
||||
return "";
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Handle base domain selection
|
||||
const handleBaseDomainSelect = (option: DomainOption) => {
|
||||
setSelectedBaseDomain(option);
|
||||
setOpen(false);
|
||||
|
||||
if (option.domainType === "cname") {
|
||||
setSubdomainInput("");
|
||||
}
|
||||
|
||||
if (option.type === "provided-search") {
|
||||
setUserInput("");
|
||||
setAvailableOptions([]);
|
||||
setSelectedProvidedDomain(null);
|
||||
onDomainChange?.({
|
||||
domainId: option.domainId!,
|
||||
type: "organization",
|
||||
subdomain: undefined,
|
||||
fullDomain: option.domain,
|
||||
baseDomain: option.domain
|
||||
if (sub !== sanitized) {
|
||||
toast({
|
||||
title: "Subdomain sanitized",
|
||||
description: `"${sub}" was corrected to "${sanitized}"`,
|
||||
});
|
||||
}
|
||||
|
||||
if (option.type === "organization") {
|
||||
if (option.domainType === "cname") {
|
||||
onDomainChange?.({
|
||||
domainId: option.domainId!,
|
||||
type: "organization",
|
||||
subdomain: undefined,
|
||||
fullDomain: option.domain,
|
||||
baseDomain: option.domain
|
||||
});
|
||||
} else {
|
||||
onDomainChange?.({
|
||||
domainId: option.domainId!,
|
||||
type: "organization",
|
||||
subdomain: undefined,
|
||||
fullDomain: option.domain,
|
||||
baseDomain: option.domain
|
||||
});
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
const handleSubdomainChange = (value: string) => {
|
||||
const validInput = value.replace(/[^a-zA-Z0-9.-]/g, "");
|
||||
setSubdomainInput(validInput);
|
||||
|
||||
const raw = sanitizeInputRaw(value);
|
||||
setSubdomainInput(raw);
|
||||
setSelectedProvidedDomain(null);
|
||||
|
||||
if (selectedBaseDomain && selectedBaseDomain.type === "organization") {
|
||||
const isValid = validateSubdomain(validInput, selectedBaseDomain);
|
||||
if (isValid) {
|
||||
const fullDomain = validInput
|
||||
? `${validInput}.${selectedBaseDomain.domain}`
|
||||
: selectedBaseDomain.domain;
|
||||
onDomainChange?.({
|
||||
domainId: selectedBaseDomain.domainId!,
|
||||
type: "organization",
|
||||
subdomain: validInput || undefined,
|
||||
fullDomain: fullDomain,
|
||||
baseDomain: selectedBaseDomain.domain
|
||||
});
|
||||
} else if (validInput === "") {
|
||||
onDomainChange?.({
|
||||
domainId: selectedBaseDomain.domainId!,
|
||||
type: "organization",
|
||||
subdomain: undefined,
|
||||
fullDomain: selectedBaseDomain.domain,
|
||||
baseDomain: selectedBaseDomain.domain
|
||||
});
|
||||
}
|
||||
if (selectedBaseDomain?.type === "organization") {
|
||||
const fullDomain = raw
|
||||
? `${raw}.${selectedBaseDomain.domain}`
|
||||
: selectedBaseDomain.domain;
|
||||
|
||||
onDomainChange?.({
|
||||
domainId: selectedBaseDomain.domainId!,
|
||||
type: "organization",
|
||||
subdomain: raw || undefined,
|
||||
fullDomain,
|
||||
baseDomain: selectedBaseDomain.domain
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleProvidedDomainInputChange = (value: string) => {
|
||||
const validInput = value.replace(/[^a-zA-Z0-9.-]/g, "");
|
||||
setUserInput(validInput);
|
||||
|
||||
// Clear selected domain when user types
|
||||
setUserInput(value);
|
||||
if (selectedProvidedDomain) {
|
||||
setSelectedProvidedDomain(null);
|
||||
onDomainChange?.({
|
||||
@@ -369,6 +333,43 @@ export default function DomainPicker2({
|
||||
}
|
||||
};
|
||||
|
||||
const handleBaseDomainSelect = (option: DomainOption) => {
|
||||
let sub = subdomainInput;
|
||||
|
||||
if (sub && sub.trim() !== "") {
|
||||
sub = finalizeSubdomain(sub, option) || "";
|
||||
setSubdomainInput(sub);
|
||||
} else {
|
||||
sub = "";
|
||||
setSubdomainInput("");
|
||||
}
|
||||
|
||||
if (option.type === "provided-search") {
|
||||
setUserInput("");
|
||||
setAvailableOptions([]);
|
||||
setSelectedProvidedDomain(null);
|
||||
}
|
||||
|
||||
setSelectedBaseDomain(option);
|
||||
setOpen(false);
|
||||
|
||||
if (option.domainType === "cname") {
|
||||
sub = "";
|
||||
setSubdomainInput("");
|
||||
}
|
||||
|
||||
const fullDomain = sub ? `${sub}.${option.domain}` : option.domain;
|
||||
|
||||
onDomainChange?.({
|
||||
domainId: option.domainId || "",
|
||||
domainNamespaceId: option.domainNamespaceId,
|
||||
type: option.type === "provided-search" ? "provided" : "organization",
|
||||
subdomain: sub || undefined,
|
||||
fullDomain,
|
||||
baseDomain: option.domain
|
||||
});
|
||||
};
|
||||
|
||||
const handleProvidedDomainSelect = (option: AvailableOption) => {
|
||||
setSelectedProvidedDomain(option);
|
||||
|
||||
@@ -380,15 +381,19 @@ export default function DomainPicker2({
|
||||
domainId: option.domainId,
|
||||
domainNamespaceId: option.domainNamespaceId,
|
||||
type: "provided",
|
||||
subdomain: subdomain,
|
||||
subdomain,
|
||||
fullDomain: option.fullDomain,
|
||||
baseDomain: baseDomain
|
||||
baseDomain
|
||||
});
|
||||
};
|
||||
|
||||
const isSubdomainValid = selectedBaseDomain
|
||||
? validateSubdomain(subdomainInput, selectedBaseDomain)
|
||||
const isSubdomainValid = selectedBaseDomain && subdomainInput
|
||||
? validateByDomainType(subdomainInput, {
|
||||
type: selectedBaseDomain.type === "provided-search" ? "provided-search" : "organization",
|
||||
domainType: selectedBaseDomain.domainType
|
||||
})
|
||||
: true;
|
||||
|
||||
const showSubdomainInput =
|
||||
selectedBaseDomain &&
|
||||
selectedBaseDomain.type === "organization" &&
|
||||
@@ -396,7 +401,7 @@ export default function DomainPicker2({
|
||||
const showProvidedDomainSearch =
|
||||
selectedBaseDomain?.type === "provided-search";
|
||||
|
||||
const sortedAvailableOptions = availableOptions.sort((a, b) => {
|
||||
const sortedAvailableOptions = [...availableOptions].sort((a, b) => {
|
||||
const comparison = a.fullDomain.localeCompare(b.fullDomain);
|
||||
return sortOrder === "asc" ? comparison : -comparison;
|
||||
});
|
||||
@@ -408,6 +413,7 @@ export default function DomainPicker2({
|
||||
const hasMoreProvided =
|
||||
sortedAvailableOptions.length > providedDomainsShown;
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
@@ -426,16 +432,16 @@ export default function DomainPicker2({
|
||||
showProvidedDomainSearch
|
||||
? ""
|
||||
: showSubdomainInput
|
||||
? ""
|
||||
: t("domainPickerNotAvailableForCname")
|
||||
? ""
|
||||
: t("domainPickerNotAvailableForCname")
|
||||
}
|
||||
disabled={
|
||||
!showSubdomainInput && !showProvidedDomainSearch
|
||||
}
|
||||
className={cn(
|
||||
!isSubdomainValid &&
|
||||
subdomainInput &&
|
||||
"border-red-500"
|
||||
subdomainInput &&
|
||||
"border-red-500 focus:border-red-500"
|
||||
)}
|
||||
onChange={(e) => {
|
||||
if (showProvidedDomainSearch) {
|
||||
@@ -445,6 +451,11 @@ export default function DomainPicker2({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{showSubdomainInput && subdomainInput && !isValidSubdomainStructure(subdomainInput) && (
|
||||
<p className="text-sm text-red-500">
|
||||
This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.
|
||||
</p>
|
||||
)}
|
||||
{showSubdomainInput && !subdomainInput && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("domainPickerEnterSubdomainOrLeaveBlank")}
|
||||
@@ -470,7 +481,7 @@ export default function DomainPicker2({
|
||||
{selectedBaseDomain ? (
|
||||
<div className="flex items-center space-x-2 min-w-0 flex-1">
|
||||
{selectedBaseDomain.type ===
|
||||
"organization" ? null : (
|
||||
"organization" ? null : (
|
||||
<Zap className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
@@ -564,67 +575,67 @@ export default function DomainPicker2({
|
||||
</CommandGroup>
|
||||
{(build === "saas" ||
|
||||
build === "enterprise") && (
|
||||
<CommandSeparator className="my-2" />
|
||||
)}
|
||||
<CommandSeparator className="my-2" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(build === "saas" ||
|
||||
build === "enterprise") && (
|
||||
<CommandGroup
|
||||
heading={
|
||||
build === "enterprise"
|
||||
? t(
|
||||
"domainPickerProvidedDomains"
|
||||
)
|
||||
: t("domainPickerFreeDomains")
|
||||
}
|
||||
className="py-2"
|
||||
>
|
||||
<CommandList>
|
||||
<CommandItem
|
||||
key="provided-search"
|
||||
onSelect={() =>
|
||||
handleBaseDomainSelect({
|
||||
id: "provided-search",
|
||||
domain:
|
||||
build ===
|
||||
"enterprise"
|
||||
<CommandGroup
|
||||
heading={
|
||||
build === "enterprise"
|
||||
? t(
|
||||
"domainPickerProvidedDomains"
|
||||
)
|
||||
: t("domainPickerFreeDomains")
|
||||
}
|
||||
className="py-2"
|
||||
>
|
||||
<CommandList>
|
||||
<CommandItem
|
||||
key="provided-search"
|
||||
onSelect={() =>
|
||||
handleBaseDomainSelect({
|
||||
id: "provided-search",
|
||||
domain:
|
||||
build ===
|
||||
"enterprise"
|
||||
? "Provided Domain"
|
||||
: "Free Provided Domain",
|
||||
type: "provided-search"
|
||||
})
|
||||
}
|
||||
className="mx-2 rounded-md"
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
|
||||
<Zap className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="font-medium truncate">
|
||||
{build === "enterprise"
|
||||
? "Provided Domain"
|
||||
: "Free Provided Domain",
|
||||
type: "provided-search"
|
||||
})
|
||||
}
|
||||
className="mx-2 rounded-md"
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
|
||||
<Zap className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="font-medium truncate">
|
||||
{build === "enterprise"
|
||||
? "Provided Domain"
|
||||
: "Free Provided Domain"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"domainPickerSearchForAvailableDomains"
|
||||
: "Free Provided Domain"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"domainPickerSearchForAvailableDomains"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-4 w-4 text-primary",
|
||||
selectedBaseDomain?.id ===
|
||||
"provided-search"
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-4 w-4 text-primary",
|
||||
selectedBaseDomain?.id ===
|
||||
"provided-search"
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
</CommandList>
|
||||
</CommandGroup>
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
</CommandList>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -680,7 +691,7 @@ export default function DomainPicker2({
|
||||
htmlFor={option.domainNamespaceId}
|
||||
data-state={
|
||||
selectedProvidedDomain?.domainNamespaceId ===
|
||||
option.domainNamespaceId
|
||||
option.domainNamespaceId
|
||||
? "checked"
|
||||
: "unchecked"
|
||||
}
|
||||
@@ -760,4 +771,4 @@ function debounce<T extends (...args: any[]) => any>(
|
||||
func(...args);
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { cn } from "@app/lib/cn";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import SupporterStatus from "@app/components/SupporterStatus";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Menu, Server } from "lucide-react";
|
||||
import { ExternalLink, Menu, Server } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
@@ -117,7 +117,15 @@ export function LayoutMobileMenu({
|
||||
<SupporterStatus />
|
||||
{env?.app?.version && (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
v{env.app.version}
|
||||
<Link
|
||||
href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1"
|
||||
>
|
||||
v{env.app.version}
|
||||
<ExternalLink size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cn } from "@app/lib/cn";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import SupporterStatus from "@app/components/SupporterStatus";
|
||||
import { ExternalLink, Server, BookOpenText, Zap } from "lucide-react";
|
||||
import { FaDiscord, FaGithub } from "react-icons/fa";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
@@ -151,7 +152,7 @@ export function LayoutSidebar({
|
||||
{!isUnlocked()
|
||||
? t("communityEdition")
|
||||
: t("commercialEdition")}
|
||||
<ExternalLink size={12} />
|
||||
<FaGithub size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground ">
|
||||
@@ -165,9 +166,28 @@ export function LayoutSidebar({
|
||||
<BookOpenText size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
<Link
|
||||
href="https://discord.gg/HCJR8Xhme4"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1"
|
||||
>
|
||||
Discord
|
||||
<FaDiscord size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
{env?.app?.version && (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
v{env.app.version}
|
||||
<Link
|
||||
href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1"
|
||||
>
|
||||
v{env.app.version}
|
||||
<ExternalLink size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ function getActionsCategories(root: boolean) {
|
||||
[t('actionUpdateOrg')]: "updateOrg",
|
||||
[t('actionGetOrgUser')]: "getOrgUser",
|
||||
[t('actionInviteUser')]: "inviteUser",
|
||||
[t('actionListInvitations')]: "listInvitations",
|
||||
[t('actionRemoveUser')]: "removeUser",
|
||||
[t('actionListUsers')]: "listUsers",
|
||||
[t('actionListOrgDomains')]: "listOrgDomains"
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
TableRow
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||
import { Plus, Search, RefreshCw } from "lucide-react";
|
||||
@@ -32,7 +32,42 @@ import {
|
||||
} from "@app/components/ui/card";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo } from "react";
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
PAGE_SIZE: 'datatable-page-size',
|
||||
getTablePageSize: (tableId?: string) =>
|
||||
tableId ? `${tableId}-size` : STORAGE_KEYS.PAGE_SIZE
|
||||
};
|
||||
|
||||
const getStoredPageSize = (tableId?: string, defaultSize = 20): number => {
|
||||
if (typeof window === 'undefined') return defaultSize;
|
||||
|
||||
try {
|
||||
const key = STORAGE_KEYS.getTablePageSize(tableId);
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
const parsed = parseInt(stored, 10);
|
||||
// Validate that it's a reasonable page size
|
||||
if (parsed > 0 && parsed <= 1000) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to read page size from localStorage:', error);
|
||||
}
|
||||
return defaultSize;
|
||||
};
|
||||
|
||||
const setStoredPageSize = (pageSize: number, tableId?: string): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const key = STORAGE_KEYS.getTablePageSize(tableId);
|
||||
localStorage.setItem(key, pageSize.toString());
|
||||
} catch (error) {
|
||||
console.warn('Failed to save page size to localStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
type TabFilter = {
|
||||
id: string;
|
||||
@@ -56,6 +91,8 @@ type DataTableProps<TData, TValue> = {
|
||||
};
|
||||
tabs?: TabFilter[];
|
||||
defaultTab?: string;
|
||||
persistPageSize?: boolean | string;
|
||||
defaultPageSize?: number;
|
||||
};
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
@@ -70,8 +107,23 @@ export function DataTable<TData, TValue>({
|
||||
searchColumn = "name",
|
||||
defaultSort,
|
||||
tabs,
|
||||
defaultTab
|
||||
defaultTab,
|
||||
persistPageSize = false,
|
||||
defaultPageSize = 20
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const t = useTranslations();
|
||||
|
||||
// Determine table identifier for storage
|
||||
const tableId = typeof persistPageSize === 'string' ? persistPageSize : undefined;
|
||||
|
||||
// Initialize page size from storage or default
|
||||
const [pageSize, setPageSize] = useState<number>(() => {
|
||||
if (persistPageSize) {
|
||||
return getStoredPageSize(tableId, defaultPageSize);
|
||||
}
|
||||
return defaultPageSize;
|
||||
});
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>(
|
||||
defaultSort ? [defaultSort] : []
|
||||
);
|
||||
@@ -80,7 +132,6 @@ export function DataTable<TData, TValue>({
|
||||
const [activeTab, setActiveTab] = useState<string>(
|
||||
defaultTab || tabs?.[0]?.id || ""
|
||||
);
|
||||
const t = useTranslations();
|
||||
|
||||
// Apply tab filter to data
|
||||
const filteredData = useMemo(() => {
|
||||
@@ -108,7 +159,7 @@ export function DataTable<TData, TValue>({
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 20,
|
||||
pageSize: pageSize,
|
||||
pageIndex: 0
|
||||
}
|
||||
},
|
||||
@@ -119,12 +170,35 @@ export function DataTable<TData, TValue>({
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const currentPageSize = table.getState().pagination.pageSize;
|
||||
if (currentPageSize !== pageSize) {
|
||||
table.setPageSize(pageSize);
|
||||
|
||||
// Persist to localStorage if enabled
|
||||
if (persistPageSize) {
|
||||
setStoredPageSize(pageSize, tableId);
|
||||
}
|
||||
}
|
||||
}, [pageSize, table, persistPageSize, tableId]);
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
// Reset to first page when changing tabs
|
||||
table.setPageIndex(0);
|
||||
};
|
||||
|
||||
// Enhanced pagination component that updates our local state
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
table.setPageSize(newPageSize);
|
||||
|
||||
// Persist immediately when changed
|
||||
if (persistPageSize) {
|
||||
setStoredPageSize(newPageSize, tableId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<Card>
|
||||
@@ -235,7 +309,10 @@ export function DataTable<TData, TValue>({
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination table={table} />
|
||||
<DataTablePagination
|
||||
table={table}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
29
src/lib/parseHostTarget.ts
Normal file
29
src/lib/parseHostTarget.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
|
||||
export function parseHostTarget(input: string) {
|
||||
try {
|
||||
const normalized = input.match(/^(https?|h2c):\/\//) ? input : `http://${input}`;
|
||||
const url = new URL(normalized);
|
||||
|
||||
const protocol = url.protocol.replace(":", ""); // http | https | h2c
|
||||
const host = url.hostname;
|
||||
|
||||
let defaultPort: number;
|
||||
switch (protocol) {
|
||||
case "https":
|
||||
defaultPort = 443;
|
||||
break;
|
||||
case "h2c":
|
||||
defaultPort = 80;
|
||||
break;
|
||||
default: // http
|
||||
defaultPort = 80;
|
||||
break;
|
||||
}
|
||||
|
||||
const port = url.port ? parseInt(url.port, 10) : defaultPort;
|
||||
|
||||
return { protocol, host, port };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
63
src/lib/subdomain-utils.ts
Normal file
63
src/lib/subdomain-utils.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
|
||||
export type DomainType = "organization" | "provided" | "provided-search";
|
||||
|
||||
export const SINGLE_LABEL_RE = /^[\p{L}\p{N}-]+$/u; // provided-search (no dots)
|
||||
export const MULTI_LABEL_RE = /^[\p{L}\p{N}-]+(\.[\p{L}\p{N}-]+)*$/u; // ns/wildcard
|
||||
export const SINGLE_LABEL_STRICT_RE = /^[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?$/u; // start/end alnum
|
||||
|
||||
|
||||
export function sanitizeInputRaw(input: string): string {
|
||||
if (!input) return "";
|
||||
return input
|
||||
.toLowerCase()
|
||||
.normalize("NFC") // normalize Unicode
|
||||
.replace(/[^\p{L}\p{N}.-]/gu, ""); // allow Unicode letters, numbers, dot, hyphen
|
||||
}
|
||||
|
||||
export function finalizeSubdomainSanitize(input: string): string {
|
||||
if (!input) return "";
|
||||
return input
|
||||
.toLowerCase()
|
||||
.normalize("NFC")
|
||||
.replace(/[^\p{L}\p{N}.-]/gu, "") // allow Unicode
|
||||
.replace(/\.{2,}/g, ".") // collapse multiple dots
|
||||
.replace(/^-+|-+$/g, "") // strip leading/trailing hyphens
|
||||
.replace(/^\.+|\.+$/g, "") // strip leading/trailing dots
|
||||
.replace(/(\.-)|(-\.)/g, "."); // fix illegal dot-hyphen combos
|
||||
}
|
||||
|
||||
|
||||
export function validateByDomainType(subdomain: string, domainType: { type: "provided-search" | "organization"; domainType?: "ns" | "cname" | "wildcard" } ): boolean {
|
||||
if (!domainType) return false;
|
||||
|
||||
if (domainType.type === "provided-search") {
|
||||
return SINGLE_LABEL_RE.test(subdomain);
|
||||
}
|
||||
|
||||
if (domainType.type === "organization") {
|
||||
if (domainType.domainType === "cname") {
|
||||
return subdomain === "";
|
||||
} else if (domainType.domainType === "ns" || domainType.domainType === "wildcard") {
|
||||
if (subdomain === "") return true;
|
||||
if (!MULTI_LABEL_RE.test(subdomain)) return false;
|
||||
const labels = subdomain.split(".");
|
||||
return labels.every(l => l.length >= 1 && l.length <= 63 && SINGLE_LABEL_RE.test(l));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const isValidSubdomainStructure = (input: string): boolean => {
|
||||
const regex = /^(?!-)([\p{L}\p{N}-]{1,63})(?<!-)$/u;
|
||||
|
||||
if (!input) return false;
|
||||
if (input.includes("..")) return false;
|
||||
|
||||
return input.split(".").every(label => regex.test(label));
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user