mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-14 23:00:26 +02:00
Compare commits
66 Commits
18579c0647
..
1.14.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ca400ab16 | |||
| 4183067c77 | |||
| 5eb4691973 | |||
| d14dfbf360 | |||
| 493a5ad02a | |||
| 481beff028 | |||
| f1f7e438b4 | |||
| 00f84c9d8e | |||
| f75b9c6c86 | |||
| 31bc6d5773 | |||
| 51dc1450d3 | |||
| fcbea08c87 | |||
| 8d60a87aa1 | |||
| 956aa64519 | |||
| fd1cb6ca23 | |||
| 37082ae436 | |||
| bb47ca3d2e | |||
| 0dd3c84b24 | |||
| 848fca7e1b | |||
| 2500f99722 | |||
| c7737c444f | |||
| 4d1a7ed69b | |||
| 626d5df67e | |||
| e4c369deec | |||
| 307209e73f | |||
| dc84935ee6 | |||
| 998c1f52ca | |||
| 2766758c66 | |||
| 258d1d82f3 | |||
| 46aaadb76a | |||
| ea7a618810 | |||
| c0e503b31f | |||
| 55f5a41752 | |||
| b0be82be86 | |||
| 96a9bdb700 | |||
| 74e6d39c24 | |||
| 61dfa00222 | |||
| 476281db2b | |||
| f32e31c73d | |||
| ea72279080 | |||
| 16ba56af84 | |||
| f13ddde988 | |||
| 67dc10dfe9 | |||
| 5fd216adc2 | |||
| 6f0268f6c0 | |||
| 2996dfb33a | |||
| c92f2cd4ba | |||
| 8164d5c1ad | |||
| d9d8d85f6e | |||
| d49720703f | |||
| 2362a9b4dd | |||
| a8265a5286 | |||
| 9ea7431b73 | |||
| 37e6f320fe | |||
| c0c0d48edf | |||
| 284cccbe17 | |||
| 81a9a94264 | |||
| dccf101554 | |||
| a01c06bbc7 | |||
| db43cf1b30 | |||
| 2f561b5604 | |||
| 5a30f036ff | |||
| 768b9ffd09 | |||
| 8732e50047 | |||
| d6e0024c96 | |||
| 9759e86921 |
+3
-1
@@ -850,6 +850,7 @@
|
|||||||
"orgPolicyConfig": "Конфигуриране на достъп за организация",
|
"orgPolicyConfig": "Конфигуриране на достъп за организация",
|
||||||
"idpUpdatedDescription": "Идентификационният доставчик беше актуализиран успешно",
|
"idpUpdatedDescription": "Идентификационният доставчик беше актуализиран успешно",
|
||||||
"redirectUrl": "URL за пренасочване",
|
"redirectUrl": "URL за пренасочване",
|
||||||
|
"orgIdpRedirectUrls": "URL адреси за пренасочване",
|
||||||
"redirectUrlAbout": "За URL за пренасочване",
|
"redirectUrlAbout": "За URL за пренасочване",
|
||||||
"redirectUrlAboutDescription": "Това е URL адресът, към който потребителите ще бъдат пренасочени след удостоверяване. Трябва да конфигурирате този URL адрес в настройките на доставчика на идентичност.",
|
"redirectUrlAboutDescription": "Това е URL адресът, към който потребителите ще бъдат пренасочени след удостоверяване. Трябва да конфигурирате този URL адрес в настройките на доставчика на идентичност.",
|
||||||
"pangolinAuth": "Authent - Pangolin",
|
"pangolinAuth": "Authent - Pangolin",
|
||||||
@@ -1479,7 +1480,7 @@
|
|||||||
"IAgreeToThe": "Съгласен съм с",
|
"IAgreeToThe": "Съгласен съм с",
|
||||||
"termsOfService": "условията за ползване",
|
"termsOfService": "условията за ползване",
|
||||||
"and": "и",
|
"and": "и",
|
||||||
"privacyPolicy": "политиката за поверителност"
|
"privacyPolicy": "политика за поверителност."
|
||||||
},
|
},
|
||||||
"signUpMarketing": {
|
"signUpMarketing": {
|
||||||
"keepMeInTheLoop": "Дръж ме в течение с новини, актуализации и нови функции чрез имейл."
|
"keepMeInTheLoop": "Дръж ме в течение с новини, актуализации и нови функции чрез имейл."
|
||||||
@@ -2349,6 +2350,7 @@
|
|||||||
"enterConfirmation": "Въведете потвърждение.",
|
"enterConfirmation": "Въведете потвърждение.",
|
||||||
"blueprintViewDetails": "Подробности.",
|
"blueprintViewDetails": "Подробности.",
|
||||||
"defaultIdentityProvider": "По подразбиране доставчик на идентичност.",
|
"defaultIdentityProvider": "По подразбиране доставчик на идентичност.",
|
||||||
|
"defaultIdentityProviderDescription": "Когато е избран основен доставчик на идентичност, потребителят ще бъде автоматично пренасочен към доставчика за удостоверяване.",
|
||||||
"editInternalResourceDialogNetworkSettings": "Мрежови настройки.",
|
"editInternalResourceDialogNetworkSettings": "Мрежови настройки.",
|
||||||
"editInternalResourceDialogAccessPolicy": "Политика за достъп.",
|
"editInternalResourceDialogAccessPolicy": "Политика за достъп.",
|
||||||
"editInternalResourceDialogAddRoles": "Добавяне на роли.",
|
"editInternalResourceDialogAddRoles": "Добавяне на роли.",
|
||||||
|
|||||||
+3
-1
@@ -850,6 +850,7 @@
|
|||||||
"orgPolicyConfig": "Konfigurace přístupu pro organizaci",
|
"orgPolicyConfig": "Konfigurace přístupu pro organizaci",
|
||||||
"idpUpdatedDescription": "Poskytovatel identity byl úspěšně aktualizován",
|
"idpUpdatedDescription": "Poskytovatel identity byl úspěšně aktualizován",
|
||||||
"redirectUrl": "Přesměrovat URL",
|
"redirectUrl": "Přesměrovat URL",
|
||||||
|
"orgIdpRedirectUrls": "Přesměrovat URL",
|
||||||
"redirectUrlAbout": "O přesměrování URL",
|
"redirectUrlAbout": "O přesměrování URL",
|
||||||
"redirectUrlAboutDescription": "Toto je URL, na kterou budou uživatelé po ověření přesměrováni. Tuto URL je třeba nastavit v nastavení poskytovatele identity.",
|
"redirectUrlAboutDescription": "Toto je URL, na kterou budou uživatelé po ověření přesměrováni. Tuto URL je třeba nastavit v nastavení poskytovatele identity.",
|
||||||
"pangolinAuth": "Auth - Pangolin",
|
"pangolinAuth": "Auth - Pangolin",
|
||||||
@@ -1479,7 +1480,7 @@
|
|||||||
"IAgreeToThe": "Souhlasím s",
|
"IAgreeToThe": "Souhlasím s",
|
||||||
"termsOfService": "podmínky služby",
|
"termsOfService": "podmínky služby",
|
||||||
"and": "a",
|
"and": "a",
|
||||||
"privacyPolicy": "zásady ochrany osobních údajů"
|
"privacyPolicy": "zásady ochrany osobních údajů."
|
||||||
},
|
},
|
||||||
"signUpMarketing": {
|
"signUpMarketing": {
|
||||||
"keepMeInTheLoop": "Udržujte mě ve smyčce s novinkami, aktualizacemi a novými funkcemi e-mailem."
|
"keepMeInTheLoop": "Udržujte mě ve smyčce s novinkami, aktualizacemi a novými funkcemi e-mailem."
|
||||||
@@ -2349,6 +2350,7 @@
|
|||||||
"enterConfirmation": "Zadejte potvrzení",
|
"enterConfirmation": "Zadejte potvrzení",
|
||||||
"blueprintViewDetails": "Detaily",
|
"blueprintViewDetails": "Detaily",
|
||||||
"defaultIdentityProvider": "Výchozí poskytovatel identity",
|
"defaultIdentityProvider": "Výchozí poskytovatel identity",
|
||||||
|
"defaultIdentityProviderDescription": "Pokud je vybrán výchozí poskytovatel identity, uživatel bude automaticky přesměrován na poskytovatele pro ověření.",
|
||||||
"editInternalResourceDialogNetworkSettings": "Nastavení sítě",
|
"editInternalResourceDialogNetworkSettings": "Nastavení sítě",
|
||||||
"editInternalResourceDialogAccessPolicy": "Přístupová politika",
|
"editInternalResourceDialogAccessPolicy": "Přístupová politika",
|
||||||
"editInternalResourceDialogAddRoles": "Přidat role",
|
"editInternalResourceDialogAddRoles": "Přidat role",
|
||||||
|
|||||||
+3
-1
@@ -850,6 +850,7 @@
|
|||||||
"orgPolicyConfig": "Zugriff für eine Organisation konfigurieren",
|
"orgPolicyConfig": "Zugriff für eine Organisation konfigurieren",
|
||||||
"idpUpdatedDescription": "Identitätsanbieter erfolgreich aktualisiert",
|
"idpUpdatedDescription": "Identitätsanbieter erfolgreich aktualisiert",
|
||||||
"redirectUrl": "Weiterleitungs-URL",
|
"redirectUrl": "Weiterleitungs-URL",
|
||||||
|
"orgIdpRedirectUrls": "Umleitungs-URLs",
|
||||||
"redirectUrlAbout": "Über die Weiterleitungs-URL",
|
"redirectUrlAbout": "Über die Weiterleitungs-URL",
|
||||||
"redirectUrlAboutDescription": "Dies ist die URL, zu der Benutzer nach der Authentifizierung umgeleitet werden. Sie müssen diese URL in den Einstellungen des Identity Providers konfigurieren.",
|
"redirectUrlAboutDescription": "Dies ist die URL, zu der Benutzer nach der Authentifizierung umgeleitet werden. Sie müssen diese URL in den Einstellungen des Identity Providers konfigurieren.",
|
||||||
"pangolinAuth": "Authentifizierung - Pangolin",
|
"pangolinAuth": "Authentifizierung - Pangolin",
|
||||||
@@ -1479,7 +1480,7 @@
|
|||||||
"IAgreeToThe": "Ich stimme den",
|
"IAgreeToThe": "Ich stimme den",
|
||||||
"termsOfService": "Nutzungsbedingungen zu",
|
"termsOfService": "Nutzungsbedingungen zu",
|
||||||
"and": "und",
|
"and": "und",
|
||||||
"privacyPolicy": "Datenschutzrichtlinie"
|
"privacyPolicy": "datenschutzrichtlinie."
|
||||||
},
|
},
|
||||||
"signUpMarketing": {
|
"signUpMarketing": {
|
||||||
"keepMeInTheLoop": "Halten Sie mich auf dem Laufenden mit Neuigkeiten, Updates und neuen Funktionen per E-Mail."
|
"keepMeInTheLoop": "Halten Sie mich auf dem Laufenden mit Neuigkeiten, Updates und neuen Funktionen per E-Mail."
|
||||||
@@ -2349,6 +2350,7 @@
|
|||||||
"enterConfirmation": "Bestätigung eingeben",
|
"enterConfirmation": "Bestätigung eingeben",
|
||||||
"blueprintViewDetails": "Details",
|
"blueprintViewDetails": "Details",
|
||||||
"defaultIdentityProvider": "Standard Identitätsanbieter",
|
"defaultIdentityProvider": "Standard Identitätsanbieter",
|
||||||
|
"defaultIdentityProviderDescription": "Wenn ein Standard-Identity Provider ausgewählt ist, wird der Benutzer zur Authentifizierung automatisch an den Anbieter weitergeleitet.",
|
||||||
"editInternalResourceDialogNetworkSettings": "Netzwerkeinstellungen",
|
"editInternalResourceDialogNetworkSettings": "Netzwerkeinstellungen",
|
||||||
"editInternalResourceDialogAccessPolicy": "Zugriffsrichtlinie",
|
"editInternalResourceDialogAccessPolicy": "Zugriffsrichtlinie",
|
||||||
"editInternalResourceDialogAddRoles": "Rollen hinzufügen",
|
"editInternalResourceDialogAddRoles": "Rollen hinzufügen",
|
||||||
|
|||||||
+1
-1
@@ -1480,7 +1480,7 @@
|
|||||||
"IAgreeToThe": "I agree to the",
|
"IAgreeToThe": "I agree to the",
|
||||||
"termsOfService": "terms of service",
|
"termsOfService": "terms of service",
|
||||||
"and": "and",
|
"and": "and",
|
||||||
"privacyPolicy": "privacy policy"
|
"privacyPolicy": "privacy policy."
|
||||||
},
|
},
|
||||||
"signUpMarketing": {
|
"signUpMarketing": {
|
||||||
"keepMeInTheLoop": "Keep me in the loop with news, updates, and new features by email."
|
"keepMeInTheLoop": "Keep me in the loop with news, updates, and new features by email."
|
||||||
|
|||||||
+3
-1
@@ -850,6 +850,7 @@
|
|||||||
"orgPolicyConfig": "Configurar acceso para una organización",
|
"orgPolicyConfig": "Configurar acceso para una organización",
|
||||||
"idpUpdatedDescription": "Proveedor de identidad actualizado correctamente",
|
"idpUpdatedDescription": "Proveedor de identidad actualizado correctamente",
|
||||||
"redirectUrl": "URL de redirección",
|
"redirectUrl": "URL de redirección",
|
||||||
|
"orgIdpRedirectUrls": "Redirigir URL",
|
||||||
"redirectUrlAbout": "Acerca de la URL de redirección",
|
"redirectUrlAbout": "Acerca de la URL de redirección",
|
||||||
"redirectUrlAboutDescription": "Esta es la URL a la que los usuarios serán redireccionados después de la autenticación. Necesitas configurar esta URL en la configuración del proveedor de identidad.",
|
"redirectUrlAboutDescription": "Esta es la URL a la que los usuarios serán redireccionados después de la autenticación. Necesitas configurar esta URL en la configuración del proveedor de identidad.",
|
||||||
"pangolinAuth": "Autenticación - Pangolin",
|
"pangolinAuth": "Autenticación - Pangolin",
|
||||||
@@ -1479,7 +1480,7 @@
|
|||||||
"IAgreeToThe": "Estoy de acuerdo con los",
|
"IAgreeToThe": "Estoy de acuerdo con los",
|
||||||
"termsOfService": "términos del servicio",
|
"termsOfService": "términos del servicio",
|
||||||
"and": "y",
|
"and": "y",
|
||||||
"privacyPolicy": "política de privacidad"
|
"privacyPolicy": "política de privacidad."
|
||||||
},
|
},
|
||||||
"signUpMarketing": {
|
"signUpMarketing": {
|
||||||
"keepMeInTheLoop": "Mantenerme en el bucle con noticias, actualizaciones y nuevas características por correo electrónico."
|
"keepMeInTheLoop": "Mantenerme en el bucle con noticias, actualizaciones y nuevas características por correo electrónico."
|
||||||
@@ -2349,6 +2350,7 @@
|
|||||||
"enterConfirmation": "Ingresar confirmación",
|
"enterConfirmation": "Ingresar confirmación",
|
||||||
"blueprintViewDetails": "Detalles",
|
"blueprintViewDetails": "Detalles",
|
||||||
"defaultIdentityProvider": "Proveedor de identidad predeterminado",
|
"defaultIdentityProvider": "Proveedor de identidad predeterminado",
|
||||||
|
"defaultIdentityProviderDescription": "Cuando se selecciona un proveedor de identidad por defecto, el usuario será redirigido automáticamente al proveedor de autenticación.",
|
||||||
"editInternalResourceDialogNetworkSettings": "Configuración de red",
|
"editInternalResourceDialogNetworkSettings": "Configuración de red",
|
||||||
"editInternalResourceDialogAccessPolicy": "Política de acceso",
|
"editInternalResourceDialogAccessPolicy": "Política de acceso",
|
||||||
"editInternalResourceDialogAddRoles": "Agregar roles",
|
"editInternalResourceDialogAddRoles": "Agregar roles",
|
||||||
|
|||||||
+3
-1
@@ -850,6 +850,7 @@
|
|||||||
"orgPolicyConfig": "Configurer l'accès pour une organisation",
|
"orgPolicyConfig": "Configurer l'accès pour une organisation",
|
||||||
"idpUpdatedDescription": "Fournisseur d'identité mis à jour avec succès",
|
"idpUpdatedDescription": "Fournisseur d'identité mis à jour avec succès",
|
||||||
"redirectUrl": "URL de redirection",
|
"redirectUrl": "URL de redirection",
|
||||||
|
"orgIdpRedirectUrls": "URL de redirection",
|
||||||
"redirectUrlAbout": "À propos de l'URL de redirection",
|
"redirectUrlAbout": "À propos de l'URL de redirection",
|
||||||
"redirectUrlAboutDescription": "C'est l'URL vers laquelle les utilisateurs seront redirigés après l'authentification. Vous devez configurer cette URL dans les paramètres du fournisseur d'identité.",
|
"redirectUrlAboutDescription": "C'est l'URL vers laquelle les utilisateurs seront redirigés après l'authentification. Vous devez configurer cette URL dans les paramètres du fournisseur d'identité.",
|
||||||
"pangolinAuth": "Auth - Pangolin",
|
"pangolinAuth": "Auth - Pangolin",
|
||||||
@@ -1479,7 +1480,7 @@
|
|||||||
"IAgreeToThe": "Je suis d'accord avec",
|
"IAgreeToThe": "Je suis d'accord avec",
|
||||||
"termsOfService": "les conditions d'utilisation",
|
"termsOfService": "les conditions d'utilisation",
|
||||||
"and": "et",
|
"and": "et",
|
||||||
"privacyPolicy": "la politique de confidentialité"
|
"privacyPolicy": "politique de confidentialité."
|
||||||
},
|
},
|
||||||
"signUpMarketing": {
|
"signUpMarketing": {
|
||||||
"keepMeInTheLoop": "Gardez-moi dans la boucle avec des nouvelles, des mises à jour et de nouvelles fonctionnalités par courriel."
|
"keepMeInTheLoop": "Gardez-moi dans la boucle avec des nouvelles, des mises à jour et de nouvelles fonctionnalités par courriel."
|
||||||
@@ -2349,6 +2350,7 @@
|
|||||||
"enterConfirmation": "Entrez la confirmation",
|
"enterConfirmation": "Entrez la confirmation",
|
||||||
"blueprintViewDetails": "Détails",
|
"blueprintViewDetails": "Détails",
|
||||||
"defaultIdentityProvider": "Fournisseur d'identité par défaut",
|
"defaultIdentityProvider": "Fournisseur d'identité par défaut",
|
||||||
|
"defaultIdentityProviderDescription": "Lorsqu'un fournisseur d'identité par défaut est sélectionné, l'utilisateur sera automatiquement redirigé vers le fournisseur pour authentification.",
|
||||||
"editInternalResourceDialogNetworkSettings": "Paramètres réseau",
|
"editInternalResourceDialogNetworkSettings": "Paramètres réseau",
|
||||||
"editInternalResourceDialogAccessPolicy": "Politique d'accès",
|
"editInternalResourceDialogAccessPolicy": "Politique d'accès",
|
||||||
"editInternalResourceDialogAddRoles": "Ajouter des rôles",
|
"editInternalResourceDialogAddRoles": "Ajouter des rôles",
|
||||||
|
|||||||
+3
-1
@@ -850,6 +850,7 @@
|
|||||||
"orgPolicyConfig": "Configura l'accesso per un'organizzazione",
|
"orgPolicyConfig": "Configura l'accesso per un'organizzazione",
|
||||||
"idpUpdatedDescription": "Provider di identità aggiornato con successo",
|
"idpUpdatedDescription": "Provider di identità aggiornato con successo",
|
||||||
"redirectUrl": "URL di Reindirizzamento",
|
"redirectUrl": "URL di Reindirizzamento",
|
||||||
|
"orgIdpRedirectUrls": "Reindirizza URL",
|
||||||
"redirectUrlAbout": "Informazioni sull'URL di Reindirizzamento",
|
"redirectUrlAbout": "Informazioni sull'URL di Reindirizzamento",
|
||||||
"redirectUrlAboutDescription": "Questo è l'URL a cui gli utenti saranno reindirizzati dopo l'autenticazione. È necessario configurare questo URL nelle impostazioni del provider di identità.",
|
"redirectUrlAboutDescription": "Questo è l'URL a cui gli utenti saranno reindirizzati dopo l'autenticazione. È necessario configurare questo URL nelle impostazioni del provider di identità.",
|
||||||
"pangolinAuth": "Autenticazione - Pangolina",
|
"pangolinAuth": "Autenticazione - Pangolina",
|
||||||
@@ -1479,7 +1480,7 @@
|
|||||||
"IAgreeToThe": "Accetto i",
|
"IAgreeToThe": "Accetto i",
|
||||||
"termsOfService": "termini di servizio",
|
"termsOfService": "termini di servizio",
|
||||||
"and": "e",
|
"and": "e",
|
||||||
"privacyPolicy": "informativa sulla privacy"
|
"privacyPolicy": "informativa sulla privacy."
|
||||||
},
|
},
|
||||||
"signUpMarketing": {
|
"signUpMarketing": {
|
||||||
"keepMeInTheLoop": "Tienimi in loop con notizie, aggiornamenti e nuove funzionalità via e-mail."
|
"keepMeInTheLoop": "Tienimi in loop con notizie, aggiornamenti e nuove funzionalità via e-mail."
|
||||||
@@ -2349,6 +2350,7 @@
|
|||||||
"enterConfirmation": "Inserisci conferma",
|
"enterConfirmation": "Inserisci conferma",
|
||||||
"blueprintViewDetails": "Dettagli",
|
"blueprintViewDetails": "Dettagli",
|
||||||
"defaultIdentityProvider": "Provider di Identità Predefinito",
|
"defaultIdentityProvider": "Provider di Identità Predefinito",
|
||||||
|
"defaultIdentityProviderDescription": "Quando viene selezionato un provider di identità predefinito, l'utente verrà automaticamente reindirizzato al provider per l'autenticazione.",
|
||||||
"editInternalResourceDialogNetworkSettings": "Impostazioni di Rete",
|
"editInternalResourceDialogNetworkSettings": "Impostazioni di Rete",
|
||||||
"editInternalResourceDialogAccessPolicy": "Politica di Accesso",
|
"editInternalResourceDialogAccessPolicy": "Politica di Accesso",
|
||||||
"editInternalResourceDialogAddRoles": "Aggiungi Ruoli",
|
"editInternalResourceDialogAddRoles": "Aggiungi Ruoli",
|
||||||
|
|||||||
+3
-1
@@ -850,6 +850,7 @@
|
|||||||
"orgPolicyConfig": "조직에 대한 접근을 구성하십시오.",
|
"orgPolicyConfig": "조직에 대한 접근을 구성하십시오.",
|
||||||
"idpUpdatedDescription": "아이덴티티 제공자가 성공적으로 업데이트되었습니다",
|
"idpUpdatedDescription": "아이덴티티 제공자가 성공적으로 업데이트되었습니다",
|
||||||
"redirectUrl": "리디렉션 URL",
|
"redirectUrl": "리디렉션 URL",
|
||||||
|
"orgIdpRedirectUrls": "리디렉션 URL",
|
||||||
"redirectUrlAbout": "리디렉션 URL에 대한 정보",
|
"redirectUrlAbout": "리디렉션 URL에 대한 정보",
|
||||||
"redirectUrlAboutDescription": "사용자가 인증 후 리디렉션될 URL입니다. 이 URL을 신원 제공자 설정에서 구성해야 합니다.",
|
"redirectUrlAboutDescription": "사용자가 인증 후 리디렉션될 URL입니다. 이 URL을 신원 제공자 설정에서 구성해야 합니다.",
|
||||||
"pangolinAuth": "인증 - 판골린",
|
"pangolinAuth": "인증 - 판골린",
|
||||||
@@ -1479,7 +1480,7 @@
|
|||||||
"IAgreeToThe": "동의합니다",
|
"IAgreeToThe": "동의합니다",
|
||||||
"termsOfService": "서비스 약관",
|
"termsOfService": "서비스 약관",
|
||||||
"and": "및",
|
"and": "및",
|
||||||
"privacyPolicy": "개인 정보 보호 정책"
|
"privacyPolicy": "개인 정보 보호 정책."
|
||||||
},
|
},
|
||||||
"signUpMarketing": {
|
"signUpMarketing": {
|
||||||
"keepMeInTheLoop": "이메일을 통해 소식, 업데이트 및 새로운 기능을 받아보세요."
|
"keepMeInTheLoop": "이메일을 통해 소식, 업데이트 및 새로운 기능을 받아보세요."
|
||||||
@@ -2349,6 +2350,7 @@
|
|||||||
"enterConfirmation": "확인 입력",
|
"enterConfirmation": "확인 입력",
|
||||||
"blueprintViewDetails": "세부 정보",
|
"blueprintViewDetails": "세부 정보",
|
||||||
"defaultIdentityProvider": "기본 아이덴티티 공급자",
|
"defaultIdentityProvider": "기본 아이덴티티 공급자",
|
||||||
|
"defaultIdentityProviderDescription": "기본 ID 공급자가 선택되면, 사용자는 인증을 위해 자동으로 해당 공급자로 리디렉션됩니다.",
|
||||||
"editInternalResourceDialogNetworkSettings": "네트워크 설정",
|
"editInternalResourceDialogNetworkSettings": "네트워크 설정",
|
||||||
"editInternalResourceDialogAccessPolicy": "액세스 정책",
|
"editInternalResourceDialogAccessPolicy": "액세스 정책",
|
||||||
"editInternalResourceDialogAddRoles": "역할 추가",
|
"editInternalResourceDialogAddRoles": "역할 추가",
|
||||||
|
|||||||
+3
-1
@@ -850,6 +850,7 @@
|
|||||||
"orgPolicyConfig": "Konfigurer tilgang for en organisasjon",
|
"orgPolicyConfig": "Konfigurer tilgang for en organisasjon",
|
||||||
"idpUpdatedDescription": "Identitetsleverandør vellykket oppdatert",
|
"idpUpdatedDescription": "Identitetsleverandør vellykket oppdatert",
|
||||||
"redirectUrl": "Omdirigerings-URL",
|
"redirectUrl": "Omdirigerings-URL",
|
||||||
|
"orgIdpRedirectUrls": "Omadressere URL'er",
|
||||||
"redirectUrlAbout": "Om omdirigerings-URL",
|
"redirectUrlAbout": "Om omdirigerings-URL",
|
||||||
"redirectUrlAboutDescription": "Dette er URLen som brukere vil bli omdirigert etter autentisering. Du må konfigurere denne URLen i identitetsleverandørens innstillinger.",
|
"redirectUrlAboutDescription": "Dette er URLen som brukere vil bli omdirigert etter autentisering. Du må konfigurere denne URLen i identitetsleverandørens innstillinger.",
|
||||||
"pangolinAuth": "Autentisering - Pangolin",
|
"pangolinAuth": "Autentisering - Pangolin",
|
||||||
@@ -1479,7 +1480,7 @@
|
|||||||
"IAgreeToThe": "Jeg godtar",
|
"IAgreeToThe": "Jeg godtar",
|
||||||
"termsOfService": "brukervilkårene",
|
"termsOfService": "brukervilkårene",
|
||||||
"and": "og",
|
"and": "og",
|
||||||
"privacyPolicy": "personvernerklæringen"
|
"privacyPolicy": "retningslinjer for personvern"
|
||||||
},
|
},
|
||||||
"signUpMarketing": {
|
"signUpMarketing": {
|
||||||
"keepMeInTheLoop": "Hold meg i løken med nyheter, oppdateringer og nye funksjoner via e-post."
|
"keepMeInTheLoop": "Hold meg i løken med nyheter, oppdateringer og nye funksjoner via e-post."
|
||||||
@@ -2349,6 +2350,7 @@
|
|||||||
"enterConfirmation": "Skriv inn bekreftelse",
|
"enterConfirmation": "Skriv inn bekreftelse",
|
||||||
"blueprintViewDetails": "Detaljer",
|
"blueprintViewDetails": "Detaljer",
|
||||||
"defaultIdentityProvider": "Standard identitetsleverandør",
|
"defaultIdentityProvider": "Standard identitetsleverandør",
|
||||||
|
"defaultIdentityProviderDescription": "Når en standard identitetsleverandør er valgt, vil brukeren automatisk bli omdirigert til leverandøren for autentisering.",
|
||||||
"editInternalResourceDialogNetworkSettings": "Nettverksinnstillinger",
|
"editInternalResourceDialogNetworkSettings": "Nettverksinnstillinger",
|
||||||
"editInternalResourceDialogAccessPolicy": "Tilgangsregler for tilgang",
|
"editInternalResourceDialogAccessPolicy": "Tilgangsregler for tilgang",
|
||||||
"editInternalResourceDialogAddRoles": "Legg til roller",
|
"editInternalResourceDialogAddRoles": "Legg til roller",
|
||||||
|
|||||||
+3
-1
@@ -850,6 +850,7 @@
|
|||||||
"orgPolicyConfig": "Toegang voor een organisatie configureren",
|
"orgPolicyConfig": "Toegang voor een organisatie configureren",
|
||||||
"idpUpdatedDescription": "Identity provider succesvol bijgewerkt",
|
"idpUpdatedDescription": "Identity provider succesvol bijgewerkt",
|
||||||
"redirectUrl": "Omleidings URL",
|
"redirectUrl": "Omleidings URL",
|
||||||
|
"orgIdpRedirectUrls": "URL's omleiden",
|
||||||
"redirectUrlAbout": "Over omleidings-URL",
|
"redirectUrlAbout": "Over omleidings-URL",
|
||||||
"redirectUrlAboutDescription": "Dit is de URL waarnaar gebruikers worden doorverwezen na verificatie. U moet deze URL configureren in de instellingen van de identiteitsprovider.",
|
"redirectUrlAboutDescription": "Dit is de URL waarnaar gebruikers worden doorverwezen na verificatie. U moet deze URL configureren in de instellingen van de identiteitsprovider.",
|
||||||
"pangolinAuth": "Authenticatie - Pangolin",
|
"pangolinAuth": "Authenticatie - Pangolin",
|
||||||
@@ -1479,7 +1480,7 @@
|
|||||||
"IAgreeToThe": "Ik ga akkoord met de",
|
"IAgreeToThe": "Ik ga akkoord met de",
|
||||||
"termsOfService": "servicevoorwaarden",
|
"termsOfService": "servicevoorwaarden",
|
||||||
"and": "en",
|
"and": "en",
|
||||||
"privacyPolicy": "privacybeleid"
|
"privacyPolicy": "privacy beleid"
|
||||||
},
|
},
|
||||||
"signUpMarketing": {
|
"signUpMarketing": {
|
||||||
"keepMeInTheLoop": "Houd me op de hoogte met nieuws, updates en nieuwe functies per e-mail."
|
"keepMeInTheLoop": "Houd me op de hoogte met nieuws, updates en nieuwe functies per e-mail."
|
||||||
@@ -2349,6 +2350,7 @@
|
|||||||
"enterConfirmation": "Bevestiging invoeren",
|
"enterConfirmation": "Bevestiging invoeren",
|
||||||
"blueprintViewDetails": "Details",
|
"blueprintViewDetails": "Details",
|
||||||
"defaultIdentityProvider": "Standaard Identiteitsprovider",
|
"defaultIdentityProvider": "Standaard Identiteitsprovider",
|
||||||
|
"defaultIdentityProviderDescription": "Wanneer een standaard identity provider is geselecteerd, zal de gebruiker automatisch worden doorgestuurd naar de provider voor authenticatie.",
|
||||||
"editInternalResourceDialogNetworkSettings": "Netwerkinstellingen",
|
"editInternalResourceDialogNetworkSettings": "Netwerkinstellingen",
|
||||||
"editInternalResourceDialogAccessPolicy": "Toegangsbeleid",
|
"editInternalResourceDialogAccessPolicy": "Toegangsbeleid",
|
||||||
"editInternalResourceDialogAddRoles": "Rollen toevoegen",
|
"editInternalResourceDialogAddRoles": "Rollen toevoegen",
|
||||||
|
|||||||
+3
-1
@@ -850,6 +850,7 @@
|
|||||||
"orgPolicyConfig": "Skonfiguruj dostęp dla organizacji",
|
"orgPolicyConfig": "Skonfiguruj dostęp dla organizacji",
|
||||||
"idpUpdatedDescription": "Dostawca tożsamości został pomyślnie zaktualizowany",
|
"idpUpdatedDescription": "Dostawca tożsamości został pomyślnie zaktualizowany",
|
||||||
"redirectUrl": "URL przekierowania",
|
"redirectUrl": "URL przekierowania",
|
||||||
|
"orgIdpRedirectUrls": "Przekieruj adresy URL",
|
||||||
"redirectUrlAbout": "O URL przekierowania",
|
"redirectUrlAbout": "O URL przekierowania",
|
||||||
"redirectUrlAboutDescription": "Jest to adres URL, na który użytkownicy zostaną przekierowani po uwierzytelnieniu. Musisz skonfigurować ten adres URL w ustawieniach dostawcy tożsamości.",
|
"redirectUrlAboutDescription": "Jest to adres URL, na który użytkownicy zostaną przekierowani po uwierzytelnieniu. Musisz skonfigurować ten adres URL w ustawieniach dostawcy tożsamości.",
|
||||||
"pangolinAuth": "Autoryzacja - Pangolin",
|
"pangolinAuth": "Autoryzacja - Pangolin",
|
||||||
@@ -1479,7 +1480,7 @@
|
|||||||
"IAgreeToThe": "Zgadzam się z",
|
"IAgreeToThe": "Zgadzam się z",
|
||||||
"termsOfService": "warunkami usługi",
|
"termsOfService": "warunkami usługi",
|
||||||
"and": "oraz",
|
"and": "oraz",
|
||||||
"privacyPolicy": "polityką prywatności"
|
"privacyPolicy": "polityka prywatności."
|
||||||
},
|
},
|
||||||
"signUpMarketing": {
|
"signUpMarketing": {
|
||||||
"keepMeInTheLoop": "Zachowaj mnie w pętli z wiadomościami, aktualizacjami i nowymi funkcjami przez e-mail."
|
"keepMeInTheLoop": "Zachowaj mnie w pętli z wiadomościami, aktualizacjami i nowymi funkcjami przez e-mail."
|
||||||
@@ -2349,6 +2350,7 @@
|
|||||||
"enterConfirmation": "Wprowadź potwierdzenie",
|
"enterConfirmation": "Wprowadź potwierdzenie",
|
||||||
"blueprintViewDetails": "Szczegóły",
|
"blueprintViewDetails": "Szczegóły",
|
||||||
"defaultIdentityProvider": "Domyślny dostawca tożsamości",
|
"defaultIdentityProvider": "Domyślny dostawca tożsamości",
|
||||||
|
"defaultIdentityProviderDescription": "Gdy zostanie wybrany domyślny dostawca tożsamości, użytkownik zostanie automatycznie przekierowany do dostawcy w celu uwierzytelnienia.",
|
||||||
"editInternalResourceDialogNetworkSettings": "Ustawienia sieci",
|
"editInternalResourceDialogNetworkSettings": "Ustawienia sieci",
|
||||||
"editInternalResourceDialogAccessPolicy": "Polityka dostępowa",
|
"editInternalResourceDialogAccessPolicy": "Polityka dostępowa",
|
||||||
"editInternalResourceDialogAddRoles": "Dodaj role",
|
"editInternalResourceDialogAddRoles": "Dodaj role",
|
||||||
|
|||||||
+3
-1
@@ -850,6 +850,7 @@
|
|||||||
"orgPolicyConfig": "Configurar acesso para uma organização",
|
"orgPolicyConfig": "Configurar acesso para uma organização",
|
||||||
"idpUpdatedDescription": "Provedor de identidade atualizado com sucesso",
|
"idpUpdatedDescription": "Provedor de identidade atualizado com sucesso",
|
||||||
"redirectUrl": "URL de Redirecionamento",
|
"redirectUrl": "URL de Redirecionamento",
|
||||||
|
"orgIdpRedirectUrls": "Redirecionar URLs",
|
||||||
"redirectUrlAbout": "Sobre o URL de Redirecionamento",
|
"redirectUrlAbout": "Sobre o URL de Redirecionamento",
|
||||||
"redirectUrlAboutDescription": "Essa é a URL para a qual os usuários serão redirecionados após a autenticação. Você precisa configurar esta URL nas configurações do provedor de identidade.",
|
"redirectUrlAboutDescription": "Essa é a URL para a qual os usuários serão redirecionados após a autenticação. Você precisa configurar esta URL nas configurações do provedor de identidade.",
|
||||||
"pangolinAuth": "Autenticação - Pangolin",
|
"pangolinAuth": "Autenticação - Pangolin",
|
||||||
@@ -1479,7 +1480,7 @@
|
|||||||
"IAgreeToThe": "Concordo com",
|
"IAgreeToThe": "Concordo com",
|
||||||
"termsOfService": "os termos de serviço",
|
"termsOfService": "os termos de serviço",
|
||||||
"and": "e",
|
"and": "e",
|
||||||
"privacyPolicy": "política de privacidade"
|
"privacyPolicy": "política de privacidade."
|
||||||
},
|
},
|
||||||
"signUpMarketing": {
|
"signUpMarketing": {
|
||||||
"keepMeInTheLoop": "Mantenha-me à disposição com notícias, atualizações e novos recursos por e-mail."
|
"keepMeInTheLoop": "Mantenha-me à disposição com notícias, atualizações e novos recursos por e-mail."
|
||||||
@@ -2349,6 +2350,7 @@
|
|||||||
"enterConfirmation": "Inserir confirmação",
|
"enterConfirmation": "Inserir confirmação",
|
||||||
"blueprintViewDetails": "Detalhes",
|
"blueprintViewDetails": "Detalhes",
|
||||||
"defaultIdentityProvider": "Provedor de Identidade Padrão",
|
"defaultIdentityProvider": "Provedor de Identidade Padrão",
|
||||||
|
"defaultIdentityProviderDescription": "Quando um provedor de identidade padrão for selecionado, o usuário será automaticamente redirecionado para o provedor de autenticação.",
|
||||||
"editInternalResourceDialogNetworkSettings": "Configurações de Rede",
|
"editInternalResourceDialogNetworkSettings": "Configurações de Rede",
|
||||||
"editInternalResourceDialogAccessPolicy": "Política de Acesso",
|
"editInternalResourceDialogAccessPolicy": "Política de Acesso",
|
||||||
"editInternalResourceDialogAddRoles": "Adicionar Funções",
|
"editInternalResourceDialogAddRoles": "Adicionar Funções",
|
||||||
|
|||||||
+3
-1
@@ -850,6 +850,7 @@
|
|||||||
"orgPolicyConfig": "Настроить доступ для организации",
|
"orgPolicyConfig": "Настроить доступ для организации",
|
||||||
"idpUpdatedDescription": "Поставщик удостоверений успешно обновлён",
|
"idpUpdatedDescription": "Поставщик удостоверений успешно обновлён",
|
||||||
"redirectUrl": "URL редиректа",
|
"redirectUrl": "URL редиректа",
|
||||||
|
"orgIdpRedirectUrls": "Перенаправление URL",
|
||||||
"redirectUrlAbout": "О редиректе URL",
|
"redirectUrlAbout": "О редиректе URL",
|
||||||
"redirectUrlAboutDescription": "Это URL, на который пользователи будут перенаправлены после аутентификации. Вам нужно настроить этот URL в настройках провайдера.",
|
"redirectUrlAboutDescription": "Это URL, на который пользователи будут перенаправлены после аутентификации. Вам нужно настроить этот URL в настройках провайдера.",
|
||||||
"pangolinAuth": "Аутентификация - Pangolin",
|
"pangolinAuth": "Аутентификация - Pangolin",
|
||||||
@@ -1479,7 +1480,7 @@
|
|||||||
"IAgreeToThe": "Я согласен с",
|
"IAgreeToThe": "Я согласен с",
|
||||||
"termsOfService": "условия использования",
|
"termsOfService": "условия использования",
|
||||||
"and": "и",
|
"and": "и",
|
||||||
"privacyPolicy": "политика конфиденциальности"
|
"privacyPolicy": "политика конфиденциальности."
|
||||||
},
|
},
|
||||||
"signUpMarketing": {
|
"signUpMarketing": {
|
||||||
"keepMeInTheLoop": "Держите меня в цикле с новостями, обновлениями и новыми функциями по электронной почте."
|
"keepMeInTheLoop": "Держите меня в цикле с новостями, обновлениями и новыми функциями по электронной почте."
|
||||||
@@ -2349,6 +2350,7 @@
|
|||||||
"enterConfirmation": "Введите подтверждение",
|
"enterConfirmation": "Введите подтверждение",
|
||||||
"blueprintViewDetails": "Подробности",
|
"blueprintViewDetails": "Подробности",
|
||||||
"defaultIdentityProvider": "Поставщик удостоверений по умолчанию",
|
"defaultIdentityProvider": "Поставщик удостоверений по умолчанию",
|
||||||
|
"defaultIdentityProviderDescription": "Когда выбран поставщик идентификации по умолчанию, пользователь будет автоматически перенаправлен на провайдер для аутентификации.",
|
||||||
"editInternalResourceDialogNetworkSettings": "Настройки сети",
|
"editInternalResourceDialogNetworkSettings": "Настройки сети",
|
||||||
"editInternalResourceDialogAccessPolicy": "Политика доступа",
|
"editInternalResourceDialogAccessPolicy": "Политика доступа",
|
||||||
"editInternalResourceDialogAddRoles": "Добавить роли",
|
"editInternalResourceDialogAddRoles": "Добавить роли",
|
||||||
|
|||||||
+3
-1
@@ -850,6 +850,7 @@
|
|||||||
"orgPolicyConfig": "Bir kuruluş için erişimi yapılandırın",
|
"orgPolicyConfig": "Bir kuruluş için erişimi yapılandırın",
|
||||||
"idpUpdatedDescription": "Kimlik sağlayıcı başarıyla güncellendi",
|
"idpUpdatedDescription": "Kimlik sağlayıcı başarıyla güncellendi",
|
||||||
"redirectUrl": "Yönlendirme URL'si",
|
"redirectUrl": "Yönlendirme URL'si",
|
||||||
|
"orgIdpRedirectUrls": "Yönlendirme URL'leri",
|
||||||
"redirectUrlAbout": "Yönlendirme URL'si Hakkında",
|
"redirectUrlAbout": "Yönlendirme URL'si Hakkında",
|
||||||
"redirectUrlAboutDescription": "Bu, kimlik doğrulamasından sonra kullanıcıların yönlendirileceği URL'dir. Bu URL'yi kimlik sağlayıcınızın ayarlarında yapılandırmanız gerekir.",
|
"redirectUrlAboutDescription": "Bu, kimlik doğrulamasından sonra kullanıcıların yönlendirileceği URL'dir. Bu URL'yi kimlik sağlayıcınızın ayarlarında yapılandırmanız gerekir.",
|
||||||
"pangolinAuth": "Yetkilendirme - Pangolin",
|
"pangolinAuth": "Yetkilendirme - Pangolin",
|
||||||
@@ -1479,7 +1480,7 @@
|
|||||||
"IAgreeToThe": "Kabul ediyorum",
|
"IAgreeToThe": "Kabul ediyorum",
|
||||||
"termsOfService": "hizmet şartları",
|
"termsOfService": "hizmet şartları",
|
||||||
"and": "ve",
|
"and": "ve",
|
||||||
"privacyPolicy": "gizlilik politikası"
|
"privacyPolicy": "gizlilik politikası."
|
||||||
},
|
},
|
||||||
"signUpMarketing": {
|
"signUpMarketing": {
|
||||||
"keepMeInTheLoop": "Bana e-posta yoluyla haberler, güncellemeler ve yeni özellikler hakkında bilgi verin."
|
"keepMeInTheLoop": "Bana e-posta yoluyla haberler, güncellemeler ve yeni özellikler hakkında bilgi verin."
|
||||||
@@ -2349,6 +2350,7 @@
|
|||||||
"enterConfirmation": "Onayı girin",
|
"enterConfirmation": "Onayı girin",
|
||||||
"blueprintViewDetails": "Detaylar",
|
"blueprintViewDetails": "Detaylar",
|
||||||
"defaultIdentityProvider": "Varsayılan Kimlik Sağlayıcı",
|
"defaultIdentityProvider": "Varsayılan Kimlik Sağlayıcı",
|
||||||
|
"defaultIdentityProviderDescription": "Varsayılan bir kimlik sağlayıcı seçildiğinde, kullanıcı kimlik doğrulaması için otomatik olarak sağlayıcıya yönlendirilecektir.",
|
||||||
"editInternalResourceDialogNetworkSettings": "Ağ Ayarları",
|
"editInternalResourceDialogNetworkSettings": "Ağ Ayarları",
|
||||||
"editInternalResourceDialogAccessPolicy": "Erişim Politikası",
|
"editInternalResourceDialogAccessPolicy": "Erişim Politikası",
|
||||||
"editInternalResourceDialogAddRoles": "Roller Ekle",
|
"editInternalResourceDialogAddRoles": "Roller Ekle",
|
||||||
|
|||||||
+3
-1
@@ -850,6 +850,7 @@
|
|||||||
"orgPolicyConfig": "配置组织访问权限",
|
"orgPolicyConfig": "配置组织访问权限",
|
||||||
"idpUpdatedDescription": "身份提供商更新成功",
|
"idpUpdatedDescription": "身份提供商更新成功",
|
||||||
"redirectUrl": "重定向网址",
|
"redirectUrl": "重定向网址",
|
||||||
|
"orgIdpRedirectUrls": "重定向URL",
|
||||||
"redirectUrlAbout": "关于重定向网址",
|
"redirectUrlAbout": "关于重定向网址",
|
||||||
"redirectUrlAboutDescription": "这是用户在验证后将被重定向到的URL。您需要在身份提供者的设置中配置此URL。",
|
"redirectUrlAboutDescription": "这是用户在验证后将被重定向到的URL。您需要在身份提供者的设置中配置此URL。",
|
||||||
"pangolinAuth": "认证 - Pangolin",
|
"pangolinAuth": "认证 - Pangolin",
|
||||||
@@ -1479,7 +1480,7 @@
|
|||||||
"IAgreeToThe": "我同意",
|
"IAgreeToThe": "我同意",
|
||||||
"termsOfService": "服务条款",
|
"termsOfService": "服务条款",
|
||||||
"and": "和",
|
"and": "和",
|
||||||
"privacyPolicy": "隐私政策"
|
"privacyPolicy": "隐私政策。"
|
||||||
},
|
},
|
||||||
"signUpMarketing": {
|
"signUpMarketing": {
|
||||||
"keepMeInTheLoop": "通过电子邮件让我在循环中保持新闻、更新和新功能。"
|
"keepMeInTheLoop": "通过电子邮件让我在循环中保持新闻、更新和新功能。"
|
||||||
@@ -2349,6 +2350,7 @@
|
|||||||
"enterConfirmation": "输入确认",
|
"enterConfirmation": "输入确认",
|
||||||
"blueprintViewDetails": "详细信息",
|
"blueprintViewDetails": "详细信息",
|
||||||
"defaultIdentityProvider": "默认身份提供商",
|
"defaultIdentityProvider": "默认身份提供商",
|
||||||
|
"defaultIdentityProviderDescription": "当选择默认身份提供商时,用户将自动重定向到提供商进行身份验证。",
|
||||||
"editInternalResourceDialogNetworkSettings": "网络设置",
|
"editInternalResourceDialogNetworkSettings": "网络设置",
|
||||||
"editInternalResourceDialogAccessPolicy": "访问策略",
|
"editInternalResourceDialogAccessPolicy": "访问策略",
|
||||||
"editInternalResourceDialogAddRoles": "添加角色",
|
"editInternalResourceDialogAddRoles": "添加角色",
|
||||||
|
|||||||
@@ -111,32 +111,30 @@ export const RuleSchema = z
|
|||||||
.refine(
|
.refine(
|
||||||
(rule) => {
|
(rule) => {
|
||||||
if (rule.match === "country") {
|
if (rule.match === "country") {
|
||||||
// Check if it's a valid 2-letter country code
|
// Check if it's a valid 2-letter country code or "ALL"
|
||||||
return /^[A-Z]{2}$/.test(rule.value);
|
return /^[A-Z]{2}$/.test(rule.value) || rule.value === "ALL";
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ["value"],
|
path: ["value"],
|
||||||
message:
|
message:
|
||||||
"Value must be a 2-letter country code when match is 'country'"
|
"Value must be a 2-letter country code or 'ALL' when match is 'country'"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(rule) => {
|
(rule) => {
|
||||||
if (rule.match === "asn") {
|
if (rule.match === "asn") {
|
||||||
// Check if it's either AS<number> format or just a number
|
// Check if it's either AS<number> format or "ALL"
|
||||||
const asNumberPattern = /^AS\d+$/i;
|
const asNumberPattern = /^AS\d+$/i;
|
||||||
const isASFormat = asNumberPattern.test(rule.value);
|
return asNumberPattern.test(rule.value) || rule.value === "ALL";
|
||||||
const isNumeric = /^\d+$/.test(rule.value);
|
|
||||||
return isASFormat || isNumeric;
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ["value"],
|
path: ["value"],
|
||||||
message:
|
message:
|
||||||
"Value must be either 'AS<number>' format or a number when match is 'asn'"
|
"Value must be 'AS<number>' format or 'ALL' when match is 'asn'"
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -573,20 +573,6 @@ class RedisManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async incr(key: string): Promise<number> {
|
|
||||||
if (!this.isRedisEnabled() || !this.writeClient) return 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await this.executeWithRetry(
|
|
||||||
() => this.writeClient!.incr(key),
|
|
||||||
"Redis INCR"
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Redis INCR error:", error);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async sadd(key: string, member: string): Promise<boolean> {
|
public async sadd(key: string, member: string): Promise<boolean> {
|
||||||
if (!this.isRedisEnabled() || !this.writeClient) return false;
|
if (!this.isRedisEnabled() || !this.writeClient) return false;
|
||||||
|
|
||||||
|
|||||||
@@ -618,6 +618,16 @@ hybridRouter.get(
|
|||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return response<LoginPage | null>(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Login page not found",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
await checkExitNodeOrg(
|
await checkExitNodeOrg(
|
||||||
remoteExitNode.exitNodeId,
|
remoteExitNode.exitNodeId,
|
||||||
@@ -633,16 +643,6 @@ hybridRouter.get(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return response<LoginPage | null>(res, {
|
|
||||||
data: null,
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Login page not found",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return response<LoginPage>(res, {
|
return response<LoginPage>(res, {
|
||||||
data: result.loginPage,
|
data: result.loginPage,
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ async function query(orgId: string | undefined, fullDomain: string) {
|
|||||||
eq(loginPage.loginPageId, loginPageOrg.loginPageId)
|
eq(loginPage.loginPageId, loginPageOrg.loginPageId)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...res.loginPage,
|
...res.loginPage,
|
||||||
orgId: res.loginPageOrg.orgId
|
orgId: res.loginPageOrg.orgId
|
||||||
@@ -65,6 +70,11 @@ async function query(orgId: string | undefined, fullDomain: string) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...res,
|
...res,
|
||||||
orgId: orgLink.orgId
|
orgId: orgLink.orgId
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ async function query(orgId: string) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...res,
|
...res,
|
||||||
orgId: orgLink.orgs.orgId,
|
orgId: orgLink.orgs.orgId,
|
||||||
|
|||||||
+16
-110
@@ -43,8 +43,7 @@ import {
|
|||||||
WSMessage,
|
WSMessage,
|
||||||
TokenPayload,
|
TokenPayload,
|
||||||
WebSocketRequest,
|
WebSocketRequest,
|
||||||
RedisMessage,
|
RedisMessage
|
||||||
SendMessageOptions
|
|
||||||
} from "@server/routers/ws";
|
} from "@server/routers/ws";
|
||||||
import { validateSessionToken } from "@server/auth/sessions/app";
|
import { validateSessionToken } from "@server/auth/sessions/app";
|
||||||
|
|
||||||
@@ -173,9 +172,6 @@ const REDIS_CHANNEL = "websocket_messages";
|
|||||||
// Client tracking map (local to this node)
|
// Client tracking map (local to this node)
|
||||||
const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
|
const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
|
||||||
|
|
||||||
// Config version tracking map (local to this node, resets on server restart)
|
|
||||||
const clientConfigVersions: Map<string, number> = new Map();
|
|
||||||
|
|
||||||
// Recovery tracking
|
// Recovery tracking
|
||||||
let isRedisRecoveryInProgress = false;
|
let isRedisRecoveryInProgress = false;
|
||||||
|
|
||||||
@@ -186,7 +182,6 @@ const getClientMapKey = (clientId: string) => clientId;
|
|||||||
const getConnectionsKey = (clientId: string) => `ws:connections:${clientId}`;
|
const getConnectionsKey = (clientId: string) => `ws:connections:${clientId}`;
|
||||||
const getNodeConnectionsKey = (nodeId: string, clientId: string) =>
|
const getNodeConnectionsKey = (nodeId: string, clientId: string) =>
|
||||||
`ws:node:${nodeId}:${clientId}`;
|
`ws:node:${nodeId}:${clientId}`;
|
||||||
const getConfigVersionKey = (clientId: string) => `ws:configVersion:${clientId}`;
|
|
||||||
|
|
||||||
// Initialize Redis subscription for cross-node messaging
|
// Initialize Redis subscription for cross-node messaging
|
||||||
const initializeRedisSubscription = async (): Promise<void> => {
|
const initializeRedisSubscription = async (): Promise<void> => {
|
||||||
@@ -382,76 +377,17 @@ const removeClient = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to get the current config version for a client
|
|
||||||
const getClientConfigVersion = async (clientId: string): Promise<number> => {
|
|
||||||
// Try Redis first if available
|
|
||||||
if (redisManager.isRedisEnabled()) {
|
|
||||||
try {
|
|
||||||
const redisVersion = await redisManager.get(getConfigVersionKey(clientId));
|
|
||||||
if (redisVersion !== null) {
|
|
||||||
const version = parseInt(redisVersion, 10);
|
|
||||||
// Sync local cache with Redis
|
|
||||||
clientConfigVersions.set(clientId, version);
|
|
||||||
return version;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Failed to get config version from Redis:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to local cache
|
|
||||||
return clientConfigVersions.get(clientId) || 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to increment and get the new config version for a client
|
|
||||||
const incrementClientConfigVersion = async (clientId: string): Promise<number> => {
|
|
||||||
let newVersion: number;
|
|
||||||
|
|
||||||
if (redisManager.isRedisEnabled()) {
|
|
||||||
try {
|
|
||||||
// Use Redis INCR for atomic increment across nodes
|
|
||||||
newVersion = await redisManager.incr(getConfigVersionKey(clientId));
|
|
||||||
// Sync local cache
|
|
||||||
clientConfigVersions.set(clientId, newVersion);
|
|
||||||
return newVersion;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Failed to increment config version in Redis:", error);
|
|
||||||
// Fall through to local increment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local increment
|
|
||||||
const currentVersion = clientConfigVersions.get(clientId) || 0;
|
|
||||||
newVersion = currentVersion + 1;
|
|
||||||
clientConfigVersions.set(clientId, newVersion);
|
|
||||||
return newVersion;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Local message sending (within this node)
|
// Local message sending (within this node)
|
||||||
const sendToClientLocal = async (
|
const sendToClientLocal = async (
|
||||||
clientId: string,
|
clientId: string,
|
||||||
message: WSMessage,
|
message: WSMessage
|
||||||
options: SendMessageOptions = {}
|
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const mapKey = getClientMapKey(clientId);
|
const mapKey = getClientMapKey(clientId);
|
||||||
const clients = connectedClients.get(mapKey);
|
const clients = connectedClients.get(mapKey);
|
||||||
if (!clients || clients.length === 0) {
|
if (!clients || clients.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const messageString = JSON.stringify(message);
|
||||||
// Handle config version
|
|
||||||
let configVersion = await getClientConfigVersion(clientId);
|
|
||||||
if (options.incrementConfigVersion) {
|
|
||||||
configVersion = await incrementClientConfigVersion(clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add config version to message
|
|
||||||
const messageWithVersion = {
|
|
||||||
...message,
|
|
||||||
configVersion
|
|
||||||
};
|
|
||||||
|
|
||||||
const messageString = JSON.stringify(messageWithVersion);
|
|
||||||
clients.forEach((client) => {
|
clients.forEach((client) => {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
client.send(messageString);
|
client.send(messageString);
|
||||||
@@ -459,69 +395,43 @@ const sendToClientLocal = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`sendToClient: Message type ${message.type} sent to clientId ${clientId} (configVersion: ${configVersion})`
|
`sendToClient: Message type ${message.type} sent to clientId ${clientId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const broadcastToAllExceptLocal = async (
|
const broadcastToAllExceptLocal = async (
|
||||||
message: WSMessage,
|
message: WSMessage,
|
||||||
excludeClientId?: string,
|
excludeClientId?: string
|
||||||
options: SendMessageOptions = {}
|
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
for (const [mapKey, clients] of connectedClients.entries()) {
|
connectedClients.forEach((clients, mapKey) => {
|
||||||
const [type, id] = mapKey.split(":");
|
const [type, id] = mapKey.split(":");
|
||||||
const clientId = mapKey; // mapKey is the clientId
|
if (!(excludeClientId && id === excludeClientId)) {
|
||||||
if (!(excludeClientId && clientId === excludeClientId)) {
|
|
||||||
// Handle config version per client
|
|
||||||
let configVersion = await getClientConfigVersion(clientId);
|
|
||||||
if (options.incrementConfigVersion) {
|
|
||||||
configVersion = await incrementClientConfigVersion(clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add config version to message
|
|
||||||
const messageWithVersion = {
|
|
||||||
...message,
|
|
||||||
configVersion
|
|
||||||
};
|
|
||||||
|
|
||||||
clients.forEach((client) => {
|
clients.forEach((client) => {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
client.send(JSON.stringify(messageWithVersion));
|
client.send(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cross-node message sending (via Redis)
|
// Cross-node message sending (via Redis)
|
||||||
const sendToClient = async (
|
const sendToClient = async (
|
||||||
clientId: string,
|
clientId: string,
|
||||||
message: WSMessage,
|
message: WSMessage
|
||||||
options: SendMessageOptions = {}
|
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
// Try to send locally first
|
// Try to send locally first
|
||||||
const localSent = await sendToClientLocal(clientId, message, options);
|
const localSent = await sendToClientLocal(clientId, message);
|
||||||
|
|
||||||
// Only send via Redis if the client is not connected locally and Redis is enabled
|
// Only send via Redis if the client is not connected locally and Redis is enabled
|
||||||
if (!localSent && redisManager.isRedisEnabled()) {
|
if (!localSent && redisManager.isRedisEnabled()) {
|
||||||
try {
|
try {
|
||||||
// If we need to increment config version, do it before sending via Redis
|
|
||||||
// so remote nodes send the correct version
|
|
||||||
let configVersion = await getClientConfigVersion(clientId);
|
|
||||||
if (options.incrementConfigVersion) {
|
|
||||||
configVersion = await incrementClientConfigVersion(clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const redisMessage: RedisMessage = {
|
const redisMessage: RedisMessage = {
|
||||||
type: "direct",
|
type: "direct",
|
||||||
targetClientId: clientId,
|
targetClientId: clientId,
|
||||||
message: {
|
message,
|
||||||
...message,
|
|
||||||
configVersion
|
|
||||||
},
|
|
||||||
fromNodeId: NODE_ID
|
fromNodeId: NODE_ID
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -548,22 +458,19 @@ const sendToClient = async (
|
|||||||
|
|
||||||
const broadcastToAllExcept = async (
|
const broadcastToAllExcept = async (
|
||||||
message: WSMessage,
|
message: WSMessage,
|
||||||
excludeClientId?: string,
|
excludeClientId?: string
|
||||||
options: SendMessageOptions = {}
|
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
// Broadcast locally
|
// Broadcast locally
|
||||||
await broadcastToAllExceptLocal(message, excludeClientId, options);
|
await broadcastToAllExceptLocal(message, excludeClientId);
|
||||||
|
|
||||||
// If Redis is enabled, also broadcast via Redis pub/sub to other nodes
|
// If Redis is enabled, also broadcast via Redis pub/sub to other nodes
|
||||||
// Note: For broadcasts, we include the options so remote nodes can handle versioning
|
|
||||||
if (redisManager.isRedisEnabled()) {
|
if (redisManager.isRedisEnabled()) {
|
||||||
try {
|
try {
|
||||||
const redisMessage: RedisMessage = {
|
const redisMessage: RedisMessage = {
|
||||||
type: "broadcast",
|
type: "broadcast",
|
||||||
excludeClientId,
|
excludeClientId,
|
||||||
message,
|
message,
|
||||||
fromNodeId: NODE_ID,
|
fromNodeId: NODE_ID
|
||||||
options
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await redisManager.publish(
|
await redisManager.publish(
|
||||||
@@ -1029,6 +936,5 @@ export {
|
|||||||
getActiveNodes,
|
getActiveNodes,
|
||||||
disconnectClient,
|
disconnectClient,
|
||||||
NODE_ID,
|
NODE_ID,
|
||||||
cleanup,
|
cleanup
|
||||||
getClientConfigVersion
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ async function query(clientId?: number, niceId?: string, orgId?: string) {
|
|||||||
.select()
|
.select()
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.where(and(eq(clients.niceId, niceId), eq(clients.orgId, orgId)))
|
.where(and(eq(clients.niceId, niceId), eq(clients.orgId, orgId)))
|
||||||
.leftJoin(olms, eq(olms.clientId, olms.clientId))
|
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import {
|
|||||||
ExitNode,
|
ExitNode,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
siteResources,
|
siteResources,
|
||||||
clientSiteResourcesAssociationsCache,
|
clientSiteResourcesAssociationsCache
|
||||||
Site
|
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { clients, clientSitesAssociationsCache, Newt, sites } from "@server/db";
|
import { clients, clientSitesAssociationsCache, Newt, sites } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -131,38 +130,6 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { peers, targets } = await buildClientConfigurationForNewtClient(
|
|
||||||
site,
|
|
||||||
exitNode
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build the configuration response
|
|
||||||
const configResponse = {
|
|
||||||
ipAddress: site.address,
|
|
||||||
peers,
|
|
||||||
targets
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.debug("Sending config: ", configResponse);
|
|
||||||
|
|
||||||
return {
|
|
||||||
message: {
|
|
||||||
type: "newt/wg/receive-config",
|
|
||||||
data: {
|
|
||||||
...configResponse
|
|
||||||
}
|
|
||||||
},
|
|
||||||
broadcast: false,
|
|
||||||
excludeSender: false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function buildClientConfigurationForNewtClient(
|
|
||||||
site: Site,
|
|
||||||
exitNode?: ExitNode
|
|
||||||
) {
|
|
||||||
const siteId = site.siteId;
|
|
||||||
|
|
||||||
// Get all clients connected to this site
|
// Get all clients connected to this site
|
||||||
const clientsRes = await db
|
const clientsRes = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -311,8 +278,22 @@ export async function buildClientConfigurationForNewtClient(
|
|||||||
targetsToSend.push(...resourceTargets);
|
targetsToSend.push(...resourceTargets);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
// Build the configuration response
|
||||||
|
const configResponse = {
|
||||||
|
ipAddress: site.address,
|
||||||
peers: validPeers,
|
peers: validPeers,
|
||||||
targets: targetsToSend
|
targets: targetsToSend
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
logger.debug("Sending config: ", configResponse);
|
||||||
|
return {
|
||||||
|
message: {
|
||||||
|
type: "newt/wg/receive-config",
|
||||||
|
data: {
|
||||||
|
...configResponse
|
||||||
|
}
|
||||||
|
},
|
||||||
|
broadcast: false,
|
||||||
|
excludeSender: false
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
import { db } from "@server/db";
|
|
||||||
import { disconnectClient } from "#dynamic/routers/ws";
|
|
||||||
import { getClientConfigVersion, MessageHandler } from "@server/routers/ws";
|
|
||||||
import { clients, Newt } from "@server/db";
|
|
||||||
import { eq, lt, isNull, and, or } from "drizzle-orm";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { validateSessionToken } from "@server/auth/sessions/app";
|
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
|
||||||
import { sendTerminateClient } from "../client/terminate";
|
|
||||||
import { encodeHexLowerCase } from "@oslojs/encoding";
|
|
||||||
import { sha256 } from "@oslojs/crypto/sha2";
|
|
||||||
|
|
||||||
// Track if the offline checker interval is running
|
|
||||||
// let offlineCheckerInterval: NodeJS.Timeout | null = null;
|
|
||||||
// const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds
|
|
||||||
// const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts the background interval that checks for clients that haven't pinged recently
|
|
||||||
* and marks them as offline
|
|
||||||
*/
|
|
||||||
// export const startNewtOfflineChecker = (): void => {
|
|
||||||
// if (offlineCheckerInterval) {
|
|
||||||
// return; // Already running
|
|
||||||
// }
|
|
||||||
|
|
||||||
// offlineCheckerInterval = setInterval(async () => {
|
|
||||||
// try {
|
|
||||||
// const twoMinutesAgo = Math.floor(
|
|
||||||
// (Date.now() - OFFLINE_THRESHOLD_MS) / 1000
|
|
||||||
// );
|
|
||||||
|
|
||||||
// // TODO: WE NEED TO MAKE SURE THIS WORKS WITH DISTRIBUTED NODES ALL DOING THE SAME THING
|
|
||||||
|
|
||||||
// // Find clients that haven't pinged in the last 2 minutes and mark them as offline
|
|
||||||
// const offlineClients = await db
|
|
||||||
// .update(clients)
|
|
||||||
// .set({ online: false })
|
|
||||||
// .where(
|
|
||||||
// and(
|
|
||||||
// eq(clients.online, true),
|
|
||||||
// or(
|
|
||||||
// lt(clients.lastPing, twoMinutesAgo),
|
|
||||||
// isNull(clients.lastPing)
|
|
||||||
// )
|
|
||||||
// )
|
|
||||||
// )
|
|
||||||
// .returning();
|
|
||||||
|
|
||||||
// for (const offlineClient of offlineClients) {
|
|
||||||
// logger.info(
|
|
||||||
// `Kicking offline newt client ${offlineClient.clientId} due to inactivity`
|
|
||||||
// );
|
|
||||||
|
|
||||||
// if (!offlineClient.newtId) {
|
|
||||||
// logger.warn(
|
|
||||||
// `Offline client ${offlineClient.clientId} has no newtId, cannot disconnect`
|
|
||||||
// );
|
|
||||||
// continue;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Send a disconnect message to the client if connected
|
|
||||||
// try {
|
|
||||||
// await sendTerminateClient(
|
|
||||||
// offlineClient.clientId,
|
|
||||||
// offlineClient.newtId
|
|
||||||
// ); // terminate first
|
|
||||||
// // wait a moment to ensure the message is sent
|
|
||||||
// await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
// await disconnectClient(offlineClient.newtId);
|
|
||||||
// } catch (error) {
|
|
||||||
// logger.error(
|
|
||||||
// `Error sending disconnect to offline newt ${offlineClient.clientId}`,
|
|
||||||
// { error }
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// } catch (error) {
|
|
||||||
// logger.error("Error in offline checker interval", { error });
|
|
||||||
// }
|
|
||||||
// }, OFFLINE_CHECK_INTERVAL);
|
|
||||||
|
|
||||||
// logger.debug("Started offline checker interval");
|
|
||||||
// };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stops the background interval that checks for offline clients
|
|
||||||
*/
|
|
||||||
// export const stopNewtOfflineChecker = (): void => {
|
|
||||||
// if (offlineCheckerInterval) {
|
|
||||||
// clearInterval(offlineCheckerInterval);
|
|
||||||
// offlineCheckerInterval = null;
|
|
||||||
// logger.info("Stopped offline checker interval");
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles ping messages from clients and responds with pong
|
|
||||||
*/
|
|
||||||
export const handleNewtPingMessage: MessageHandler = async (context) => {
|
|
||||||
const { message, client: c, sendToClient } = context;
|
|
||||||
const newt = c as Newt;
|
|
||||||
|
|
||||||
if (!newt) {
|
|
||||||
logger.warn("Newt not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// get the version
|
|
||||||
const configVersion = await getClientConfigVersion(newt.newtId);
|
|
||||||
|
|
||||||
if (message.configVersion && configVersion != message.configVersion) {
|
|
||||||
logger.warn(`Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})`);
|
|
||||||
|
|
||||||
// TODO: sync the client
|
|
||||||
}
|
|
||||||
|
|
||||||
// try {
|
|
||||||
// // Update the client's last ping timestamp
|
|
||||||
// await db
|
|
||||||
// .update(clients)
|
|
||||||
// .set({
|
|
||||||
// lastPing: Math.floor(Date.now() / 1000),
|
|
||||||
// online: true
|
|
||||||
// })
|
|
||||||
// .where(eq(clients.clientId, newt.clientId));
|
|
||||||
// } catch (error) {
|
|
||||||
// logger.error("Error handling ping message", { error });
|
|
||||||
// }
|
|
||||||
|
|
||||||
return {
|
|
||||||
message: {
|
|
||||||
type: "pong",
|
|
||||||
data: {
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
broadcast: false,
|
|
||||||
excludeSender: false
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -233,35 +233,6 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
.where(eq(newts.newtId, newt.newtId));
|
.where(eq(newts.newtId, newt.newtId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tcpTargets, udpTargets, validHealthCheckTargets } =
|
|
||||||
await buildTargetConfigurationForNewtClient(siteId);
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
message: {
|
|
||||||
type: "newt/wg/connect",
|
|
||||||
data: {
|
|
||||||
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
|
|
||||||
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
|
||||||
publicKey: exitNode.publicKey,
|
|
||||||
serverIP: exitNode.address.split("/")[0],
|
|
||||||
tunnelIP: siteSubnet.split("/")[0],
|
|
||||||
targets: {
|
|
||||||
udp: udpTargets,
|
|
||||||
tcp: tcpTargets
|
|
||||||
},
|
|
||||||
healthCheckTargets: validHealthCheckTargets
|
|
||||||
}
|
|
||||||
},
|
|
||||||
broadcast: false, // Send to all clients
|
|
||||||
excludeSender: false // Include sender in broadcast
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
|
||||||
// Get all enabled targets with their resource protocol information
|
// Get all enabled targets with their resource protocol information
|
||||||
const allTargets = await db
|
const allTargets = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -366,12 +337,30 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
|||||||
(target) => target !== null
|
(target) => target !== null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validHealthCheckTargets,
|
message: {
|
||||||
tcpTargets,
|
type: "newt/wg/connect",
|
||||||
udpTargets
|
data: {
|
||||||
|
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
|
||||||
|
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
||||||
|
publicKey: exitNode.publicKey,
|
||||||
|
serverIP: exitNode.address.split("/")[0],
|
||||||
|
tunnelIP: siteSubnet.split("/")[0],
|
||||||
|
targets: {
|
||||||
|
udp: udpTargets,
|
||||||
|
tcp: tcpTargets
|
||||||
|
},
|
||||||
|
healthCheckTargets: validHealthCheckTargets
|
||||||
|
}
|
||||||
|
},
|
||||||
|
broadcast: false, // Send to all clients
|
||||||
|
excludeSender: false // Include sender in broadcast
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
async function getUniqueSubnetForSite(
|
async function getUniqueSubnetForSite(
|
||||||
exitNode: ExitNode,
|
exitNode: ExitNode,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import { clients, Newt } from "@server/db";
|
import { clients } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
interface PeerBandwidth {
|
interface PeerBandwidth {
|
||||||
@@ -10,13 +10,57 @@ interface PeerBandwidth {
|
|||||||
bytesOut: number;
|
bytesOut: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retry configuration for deadlock handling
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const BASE_DELAY_MS = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error is a deadlock error
|
||||||
|
*/
|
||||||
|
function isDeadlockError(error: any): boolean {
|
||||||
|
return (
|
||||||
|
error?.code === "40P01" ||
|
||||||
|
error?.cause?.code === "40P01" ||
|
||||||
|
(error?.message && error.message.includes("deadlock"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a function with retry logic for deadlock handling
|
||||||
|
*/
|
||||||
|
async function withDeadlockRetry<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
context: string
|
||||||
|
): Promise<T> {
|
||||||
|
let attempt = 0;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (isDeadlockError(error) && attempt < MAX_RETRIES) {
|
||||||
|
attempt++;
|
||||||
|
const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS;
|
||||||
|
const jitter = Math.random() * baseDelay;
|
||||||
|
const delay = baseDelay + jitter;
|
||||||
|
logger.warn(
|
||||||
|
`Deadlock detected in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const handleReceiveBandwidthMessage: MessageHandler = async (
|
export const handleReceiveBandwidthMessage: MessageHandler = async (
|
||||||
context
|
context
|
||||||
) => {
|
) => {
|
||||||
const { message, client, sendToClient } = context;
|
const { message } = context;
|
||||||
|
|
||||||
if (!message.data.bandwidthData) {
|
if (!message.data.bandwidthData) {
|
||||||
logger.warn("No bandwidth data provided");
|
logger.warn("No bandwidth data provided");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bandwidthData: PeerBandwidth[] = message.data.bandwidthData;
|
const bandwidthData: PeerBandwidth[] = message.data.bandwidthData;
|
||||||
@@ -25,30 +69,40 @@ export const handleReceiveBandwidthMessage: MessageHandler = async (
|
|||||||
throw new Error("Invalid bandwidth data");
|
throw new Error("Invalid bandwidth data");
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
// Sort bandwidth data by publicKey to ensure consistent lock ordering across all instances
|
||||||
for (const peer of bandwidthData) {
|
// This is critical for preventing deadlocks when multiple instances update the same clients
|
||||||
const { publicKey, bytesIn, bytesOut } = peer;
|
const sortedBandwidthData = [...bandwidthData].sort((a, b) =>
|
||||||
|
a.publicKey.localeCompare(b.publicKey)
|
||||||
|
);
|
||||||
|
|
||||||
// Find the client by public key
|
const currentTime = new Date().toISOString();
|
||||||
const [client] = await trx
|
|
||||||
.select()
|
|
||||||
.from(clients)
|
|
||||||
.where(eq(clients.pubKey, publicKey))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!client) {
|
// Update each client individually with retry logic
|
||||||
continue;
|
// This reduces transaction scope and allows retries per-client
|
||||||
}
|
for (const peer of sortedBandwidthData) {
|
||||||
|
const { publicKey, bytesIn, bytesOut } = peer;
|
||||||
|
|
||||||
// Update the client's bandwidth usage
|
try {
|
||||||
await trx
|
await withDeadlockRetry(async () => {
|
||||||
.update(clients)
|
// Use atomic SQL increment to avoid SELECT then UPDATE pattern
|
||||||
.set({
|
// This eliminates the need to read the current value first
|
||||||
megabytesOut: (client.megabytesIn || 0) + bytesIn,
|
await db
|
||||||
megabytesIn: (client.megabytesOut || 0) + bytesOut,
|
.update(clients)
|
||||||
lastBandwidthUpdate: new Date().toISOString()
|
.set({
|
||||||
})
|
// Note: bytesIn from peer goes to megabytesOut (data sent to client)
|
||||||
.where(eq(clients.clientId, client.clientId));
|
// and bytesOut from peer goes to megabytesIn (data received from client)
|
||||||
|
megabytesOut: sql`COALESCE(${clients.megabytesOut}, 0) + ${bytesIn}`,
|
||||||
|
megabytesIn: sql`COALESCE(${clients.megabytesIn}, 0) + ${bytesOut}`,
|
||||||
|
lastBandwidthUpdate: currentTime
|
||||||
|
})
|
||||||
|
.where(eq(clients.pubKey, publicKey));
|
||||||
|
}, `update client bandwidth ${publicKey}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to update bandwidth for client ${publicKey}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
// Continue with other clients even if one fails
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,4 +6,3 @@ export * from "./handleGetConfigMessage";
|
|||||||
export * from "./handleSocketMessages";
|
export * from "./handleSocketMessages";
|
||||||
export * from "./handleNewtPingRequestMessage";
|
export * from "./handleNewtPingRequestMessage";
|
||||||
export * from "./handleApplyBlueprintMessage";
|
export * from "./handleApplyBlueprintMessage";
|
||||||
export * from "./handleNewtPingMessage";
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { disconnectClient } from "#dynamic/routers/ws";
|
import { disconnectClient } from "#dynamic/routers/ws";
|
||||||
import { getClientConfigVersion, MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import { clients, Olm } from "@server/db";
|
import { clients, Olm } from "@server/db";
|
||||||
import { eq, lt, isNull, and, or } from "drizzle-orm";
|
import { eq, lt, isNull, and, or } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
@@ -108,15 +108,6 @@ export const handleOlmPingMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// get the version
|
|
||||||
const configVersion = await getClientConfigVersion(olm.olmId);
|
|
||||||
|
|
||||||
if (message.configVersion && configVersion != message.configVersion) {
|
|
||||||
logger.warn(`Olm ping with outdated config version: ${message.configVersion} (current: ${configVersion})`);
|
|
||||||
|
|
||||||
// TODO: sync the client
|
|
||||||
}
|
|
||||||
|
|
||||||
if (olm.userId) {
|
if (olm.userId) {
|
||||||
// we need to check a user token to make sure its still valid
|
// we need to check a user token to make sure its still valid
|
||||||
const { session: userSession, user } =
|
const { session: userSession, user } =
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
Client,
|
|
||||||
clientSiteResourcesAssociationsCache,
|
clientSiteResourcesAssociationsCache,
|
||||||
db,
|
db,
|
||||||
orgs,
|
orgs,
|
||||||
@@ -14,7 +13,7 @@ import {
|
|||||||
olms,
|
olms,
|
||||||
sites
|
sites
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { and, count, eq, inArray, isNull } from "drizzle-orm";
|
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||||
import { addPeer, deletePeer } from "../newt/peers";
|
import { addPeer, deletePeer } from "../newt/peers";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { generateAliasConfig } from "@server/lib/ip";
|
import { generateAliasConfig } from "@server/lib/ip";
|
||||||
@@ -145,64 +144,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all sites data
|
|
||||||
const sitesCountResult = await db
|
|
||||||
.select({ count: count() })
|
|
||||||
.from(sites)
|
|
||||||
.innerJoin(
|
|
||||||
clientSitesAssociationsCache,
|
|
||||||
eq(sites.siteId, clientSitesAssociationsCache.siteId)
|
|
||||||
)
|
|
||||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
|
||||||
|
|
||||||
// Extract the count value from the result array
|
|
||||||
const sitesCount = sitesCountResult.length > 0 ? sitesCountResult[0].count : 0;
|
|
||||||
|
|
||||||
// Prepare an array to store site configurations
|
|
||||||
logger.debug(
|
|
||||||
`Found ${sitesCount} sites for client ${client.clientId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// this prevents us from accepting a register from an olm that has not hole punched yet.
|
|
||||||
// the olm will pump the register so we can keep checking
|
|
||||||
// TODO: I still think there is a better way to do this rather than locking it out here but ???
|
|
||||||
if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) {
|
|
||||||
logger.warn(
|
|
||||||
"Client last hole punch is too old and we have sites to send; skipping this register"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const siteConfigurations = await buildSiteConfigurationForOlmClient(client, publicKey, relay);
|
|
||||||
|
|
||||||
// REMOVED THIS SO IT CREATES THE INTERFACE AND JUST WAITS FOR THE SITES
|
|
||||||
// if (siteConfigurations.length === 0) {
|
|
||||||
// logger.warn("No valid site configurations found");
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Return connect message with all site configurations
|
|
||||||
return {
|
|
||||||
message: {
|
|
||||||
type: "olm/wg/connect",
|
|
||||||
data: {
|
|
||||||
sites: siteConfigurations,
|
|
||||||
tunnelIP: client.subnet,
|
|
||||||
utilitySubnet: org.utilitySubnet
|
|
||||||
}
|
|
||||||
},
|
|
||||||
broadcast: false,
|
|
||||||
excludeSender: false
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function buildSiteConfigurationForOlmClient(
|
|
||||||
client: Client,
|
|
||||||
publicKey: string,
|
|
||||||
relay: boolean
|
|
||||||
) {
|
|
||||||
const siteConfigurations = [];
|
|
||||||
|
|
||||||
// Get all sites data
|
// Get all sites data
|
||||||
const sitesData = await db
|
const sitesData = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -213,6 +154,22 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
)
|
)
|
||||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
||||||
|
|
||||||
|
// Prepare an array to store site configurations
|
||||||
|
const siteConfigurations = [];
|
||||||
|
logger.debug(
|
||||||
|
`Found ${sitesData.length} sites for client ${client.clientId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// this prevents us from accepting a register from an olm that has not hole punched yet.
|
||||||
|
// the olm will pump the register so we can keep checking
|
||||||
|
// TODO: I still think there is a better way to do this rather than locking it out here but ???
|
||||||
|
if (now - (client.lastHolePunch || 0) > 5 && sitesData.length > 0) {
|
||||||
|
logger.warn(
|
||||||
|
"Client last hole punch is too old and we have sites to send; skipping this register"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Process each site
|
// Process each site
|
||||||
for (const {
|
for (const {
|
||||||
sites: site,
|
sites: site,
|
||||||
@@ -332,5 +289,23 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return siteConfigurations;
|
// REMOVED THIS SO IT CREATES THE INTERFACE AND JUST WAITS FOR THE SITES
|
||||||
}
|
// if (siteConfigurations.length === 0) {
|
||||||
|
// logger.warn("No valid site configurations found");
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Return connect message with all site configurations
|
||||||
|
return {
|
||||||
|
message: {
|
||||||
|
type: "olm/wg/connect",
|
||||||
|
data: {
|
||||||
|
sites: siteConfigurations,
|
||||||
|
tunnelIP: client.subnet,
|
||||||
|
utilitySubnet: org.utilitySubnet
|
||||||
|
}
|
||||||
|
},
|
||||||
|
broadcast: false,
|
||||||
|
excludeSender: false
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import {
|
|||||||
handleDockerStatusMessage,
|
handleDockerStatusMessage,
|
||||||
handleDockerContainersMessage,
|
handleDockerContainersMessage,
|
||||||
handleNewtPingRequestMessage,
|
handleNewtPingRequestMessage,
|
||||||
handleApplyBlueprintMessage,
|
handleApplyBlueprintMessage
|
||||||
handleNewtPingMessage
|
|
||||||
} from "../newt";
|
} from "../newt";
|
||||||
import {
|
import {
|
||||||
handleOlmRegisterMessage,
|
handleOlmRegisterMessage,
|
||||||
@@ -25,7 +24,6 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
|||||||
"olm/wg/relay": handleOlmRelayMessage,
|
"olm/wg/relay": handleOlmRelayMessage,
|
||||||
"olm/wg/unrelay": handleOlmUnRelayMessage,
|
"olm/wg/unrelay": handleOlmUnRelayMessage,
|
||||||
"olm/ping": handleOlmPingMessage,
|
"olm/ping": handleOlmPingMessage,
|
||||||
"newt/ping": handleNewtPingMessage,
|
|
||||||
"newt/wg/register": handleNewtRegisterMessage,
|
"newt/wg/register": handleNewtRegisterMessage,
|
||||||
"newt/wg/get-config": handleGetConfigMessage,
|
"newt/wg/get-config": handleGetConfigMessage,
|
||||||
"newt/receive-bandwidth": handleReceiveBandwidthMessage,
|
"newt/receive-bandwidth": handleReceiveBandwidthMessage,
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export interface AuthenticatedWebSocket extends WebSocket {
|
|||||||
connectionId?: string;
|
connectionId?: string;
|
||||||
isFullyConnected?: boolean;
|
isFullyConnected?: boolean;
|
||||||
pendingMessages?: Buffer[];
|
pendingMessages?: Buffer[];
|
||||||
configVersion?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenPayload {
|
export interface TokenPayload {
|
||||||
@@ -37,7 +36,6 @@ export interface TokenPayload {
|
|||||||
export interface WSMessage {
|
export interface WSMessage {
|
||||||
type: string;
|
type: string;
|
||||||
data: any;
|
data: any;
|
||||||
configVersion?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HandlerResponse {
|
export interface HandlerResponse {
|
||||||
@@ -52,15 +50,10 @@ export interface HandlerContext {
|
|||||||
senderWs: WebSocket;
|
senderWs: WebSocket;
|
||||||
client: Newt | Olm | RemoteExitNode | undefined;
|
client: Newt | Olm | RemoteExitNode | undefined;
|
||||||
clientType: ClientType;
|
clientType: ClientType;
|
||||||
sendToClient: (
|
sendToClient: (clientId: string, message: WSMessage) => Promise<boolean>;
|
||||||
clientId: string,
|
|
||||||
message: WSMessage,
|
|
||||||
options?: SendMessageOptions
|
|
||||||
) => Promise<boolean>;
|
|
||||||
broadcastToAllExcept: (
|
broadcastToAllExcept: (
|
||||||
message: WSMessage,
|
message: WSMessage,
|
||||||
excludeClientId?: string,
|
excludeClientId?: string
|
||||||
options?: SendMessageOptions
|
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
connectedClients: Map<string, WebSocket[]>;
|
connectedClients: Map<string, WebSocket[]>;
|
||||||
}
|
}
|
||||||
@@ -69,11 +62,6 @@ export type MessageHandler = (
|
|||||||
context: HandlerContext
|
context: HandlerContext
|
||||||
) => Promise<HandlerResponse | void>;
|
) => Promise<HandlerResponse | void>;
|
||||||
|
|
||||||
// Options for sending messages with config version tracking
|
|
||||||
export interface SendMessageOptions {
|
|
||||||
incrementConfigVersion?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redis message type for cross-node communication
|
// Redis message type for cross-node communication
|
||||||
export interface RedisMessage {
|
export interface RedisMessage {
|
||||||
type: "direct" | "broadcast";
|
type: "direct" | "broadcast";
|
||||||
@@ -81,5 +69,4 @@ export interface RedisMessage {
|
|||||||
excludeClientId?: string;
|
excludeClientId?: string;
|
||||||
message: WSMessage;
|
message: WSMessage;
|
||||||
fromNodeId: string;
|
fromNodeId: string;
|
||||||
options?: SendMessageOptions;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-59
@@ -15,8 +15,7 @@ import {
|
|||||||
TokenPayload,
|
TokenPayload,
|
||||||
WebSocketRequest,
|
WebSocketRequest,
|
||||||
WSMessage,
|
WSMessage,
|
||||||
AuthenticatedWebSocket,
|
AuthenticatedWebSocket
|
||||||
SendMessageOptions
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { validateSessionToken } from "@server/auth/sessions/app";
|
import { validateSessionToken } from "@server/auth/sessions/app";
|
||||||
|
|
||||||
@@ -35,8 +34,6 @@ const NODE_ID = uuidv4();
|
|||||||
|
|
||||||
// Client tracking map (local to this node)
|
// Client tracking map (local to this node)
|
||||||
const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
|
const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
|
||||||
// Config version tracking map (clientId -> version)
|
|
||||||
const clientConfigVersions: Map<string, number> = new Map();
|
|
||||||
// Helper to get map key
|
// Helper to get map key
|
||||||
const getClientMapKey = (clientId: string) => clientId;
|
const getClientMapKey = (clientId: string) => clientId;
|
||||||
|
|
||||||
@@ -87,34 +84,14 @@ const removeClient = async (
|
|||||||
// Local message sending (within this node)
|
// Local message sending (within this node)
|
||||||
const sendToClientLocal = async (
|
const sendToClientLocal = async (
|
||||||
clientId: string,
|
clientId: string,
|
||||||
message: WSMessage,
|
message: WSMessage
|
||||||
options: SendMessageOptions = {}
|
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const mapKey = getClientMapKey(clientId);
|
const mapKey = getClientMapKey(clientId);
|
||||||
const clients = connectedClients.get(mapKey);
|
const clients = connectedClients.get(mapKey);
|
||||||
if (!clients || clients.length === 0) {
|
if (!clients || clients.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const messageString = JSON.stringify(message);
|
||||||
// Increment config version if requested
|
|
||||||
if (options.incrementConfigVersion) {
|
|
||||||
const currentVersion = clientConfigVersions.get(clientId) || 0;
|
|
||||||
const newVersion = currentVersion + 1;
|
|
||||||
clientConfigVersions.set(clientId, newVersion);
|
|
||||||
// Update version on all client connections
|
|
||||||
clients.forEach((client) => {
|
|
||||||
client.configVersion = newVersion;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include config version in message
|
|
||||||
const configVersion = clientConfigVersions.get(clientId) || 0;
|
|
||||||
const messageWithVersion = {
|
|
||||||
...message,
|
|
||||||
configVersion
|
|
||||||
};
|
|
||||||
|
|
||||||
const messageString = JSON.stringify(messageWithVersion);
|
|
||||||
clients.forEach((client) => {
|
clients.forEach((client) => {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
client.send(messageString);
|
client.send(messageString);
|
||||||
@@ -125,31 +102,14 @@ const sendToClientLocal = async (
|
|||||||
|
|
||||||
const broadcastToAllExceptLocal = async (
|
const broadcastToAllExceptLocal = async (
|
||||||
message: WSMessage,
|
message: WSMessage,
|
||||||
excludeClientId?: string,
|
excludeClientId?: string
|
||||||
options: SendMessageOptions = {}
|
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
connectedClients.forEach((clients, mapKey) => {
|
connectedClients.forEach((clients, mapKey) => {
|
||||||
const [type, id] = mapKey.split(":");
|
const [type, id] = mapKey.split(":");
|
||||||
const clientId = mapKey; // mapKey is the clientId
|
if (!(excludeClientId && id === excludeClientId)) {
|
||||||
if (!(excludeClientId && clientId === excludeClientId)) {
|
|
||||||
// Handle config version per client
|
|
||||||
if (options.incrementConfigVersion) {
|
|
||||||
const currentVersion = clientConfigVersions.get(clientId) || 0;
|
|
||||||
const newVersion = currentVersion + 1;
|
|
||||||
clientConfigVersions.set(clientId, newVersion);
|
|
||||||
clients.forEach((client) => {
|
|
||||||
client.configVersion = newVersion;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Include config version in message for this client
|
|
||||||
const configVersion = clientConfigVersions.get(clientId) || 0;
|
|
||||||
const messageWithVersion = {
|
|
||||||
...message,
|
|
||||||
configVersion
|
|
||||||
};
|
|
||||||
clients.forEach((client) => {
|
clients.forEach((client) => {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
client.send(JSON.stringify(messageWithVersion));
|
client.send(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -159,11 +119,10 @@ const broadcastToAllExceptLocal = async (
|
|||||||
// Cross-node message sending
|
// Cross-node message sending
|
||||||
const sendToClient = async (
|
const sendToClient = async (
|
||||||
clientId: string,
|
clientId: string,
|
||||||
message: WSMessage,
|
message: WSMessage
|
||||||
options: SendMessageOptions = {}
|
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
// Try to send locally first
|
// Try to send locally first
|
||||||
const localSent = await sendToClientLocal(clientId, message, options);
|
const localSent = await sendToClientLocal(clientId, message);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`sendToClient: Message type ${message.type} sent to clientId ${clientId}`
|
`sendToClient: Message type ${message.type} sent to clientId ${clientId}`
|
||||||
@@ -174,11 +133,10 @@ const sendToClient = async (
|
|||||||
|
|
||||||
const broadcastToAllExcept = async (
|
const broadcastToAllExcept = async (
|
||||||
message: WSMessage,
|
message: WSMessage,
|
||||||
excludeClientId?: string,
|
excludeClientId?: string
|
||||||
options: SendMessageOptions = {}
|
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
// Broadcast locally
|
// Broadcast locally
|
||||||
await broadcastToAllExceptLocal(message, excludeClientId, options);
|
await broadcastToAllExceptLocal(message, excludeClientId);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if a client has active connections across all nodes
|
// Check if a client has active connections across all nodes
|
||||||
@@ -188,11 +146,6 @@ const hasActiveConnections = async (clientId: string): Promise<boolean> => {
|
|||||||
return !!(clients && clients.length > 0);
|
return !!(clients && clients.length > 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get the current config version for a client
|
|
||||||
const getClientConfigVersion = async (clientId: string): Promise<number> => {
|
|
||||||
return clientConfigVersions.get(clientId) || 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get all active nodes for a client
|
// Get all active nodes for a client
|
||||||
const getActiveNodes = async (
|
const getActiveNodes = async (
|
||||||
clientType: ClientType,
|
clientType: ClientType,
|
||||||
@@ -481,6 +434,5 @@ export {
|
|||||||
getActiveNodes,
|
getActiveNodes,
|
||||||
disconnectClient,
|
disconnectClient,
|
||||||
NODE_ID,
|
NODE_ID,
|
||||||
cleanup,
|
cleanup
|
||||||
getClientConfigVersion
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ export default function Page() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push("/admin/idp");
|
router.push(`/${params.orgId}/settings/idp`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("idpSeeAll")}
|
{t("idpSeeAll")}
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import { internal, priv } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import IdpTable, { IdpRow } from "@app/components/private/OrgIdpTable";
|
import IdpTable, { IdpRow } from "@app/components/private/OrgIdpTable";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
import { cache } from "react";
|
|
||||||
import {
|
|
||||||
GetOrgSubscriptionResponse,
|
|
||||||
GetOrgTierResponse
|
|
||||||
} from "@server/routers/billing/types";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { build } from "@server/build";
|
|
||||||
|
|
||||||
type OrgIdpPageProps = {
|
type OrgIdpPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
@@ -35,21 +28,6 @@ export default async function OrgIdpPage(props: OrgIdpPageProps) {
|
|||||||
|
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
|
||||||
let subscriptionStatus: GetOrgTierResponse | null = null;
|
|
||||||
try {
|
|
||||||
const getSubscription = cache(() =>
|
|
||||||
priv.get<AxiosResponse<GetOrgTierResponse>>(
|
|
||||||
`/org/${params.orgId}/billing/tier`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const subRes = await getSubscription();
|
|
||||||
subscriptionStatus = subRes.data.data;
|
|
||||||
} catch {}
|
|
||||||
const subscribed =
|
|
||||||
build === "enterprise"
|
|
||||||
? true
|
|
||||||
: subscriptionStatus?.tier === TierId.STANDARD;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsSectionTitle
|
<SettingsSectionTitle
|
||||||
@@ -57,13 +35,7 @@ export default async function OrgIdpPage(props: OrgIdpPageProps) {
|
|||||||
description={t("idpManageDescription")}
|
description={t("idpManageDescription")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{build === "saas" && !subscribed ? (
|
<PaidFeaturesAlert />
|
||||||
<Alert variant="info" className="mb-6">
|
|
||||||
<AlertDescription>
|
|
||||||
{t("idpDisabled")} {t("subscriptionRequiredToUse")}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<IdpTable idps={idps} orgId={params.orgId} />
|
<IdpTable idps={idps} orgId={params.orgId} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -164,6 +164,10 @@ function MaintenanceSectionForm({
|
|||||||
return isEnterpriseNotLicensed || isSaasNotSubscribed;
|
return isEnterpriseNotLicensed || isSaasNotSubscribed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!resource.http) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
@@ -437,9 +441,16 @@ export default function GeneralForm() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const resourceFullDomainName = useMemo(() => {
|
const resourceFullDomainName = useMemo(() => {
|
||||||
const url = new URL(resourceFullDomain);
|
if (!resource.fullDomain) {
|
||||||
return url.hostname;
|
return "";
|
||||||
}, [resourceFullDomain]);
|
}
|
||||||
|
try {
|
||||||
|
const url = new URL(resourceFullDomain);
|
||||||
|
return url.hostname;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}, [resourceFullDomain, resource.fullDomain]);
|
||||||
|
|
||||||
const [selectedDomain, setSelectedDomain] = useState<{
|
const [selectedDomain, setSelectedDomain] = useState<{
|
||||||
domainId: string;
|
domainId: string;
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ function ProxyResourceTargetsForm({
|
|||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 ${status === "healthy" ? "text-green-500" : status === "unhealthy" ? "text-destructive" : ""}`}
|
className={`flex items-center gap-2 ${status === "healthy" ? "text-green-500" : status === "unhealthy" ? "text-destructive" : ""}`}
|
||||||
>
|
>
|
||||||
<Settings className="h-3 w-3" />
|
<Settings className="h-4 w-4 text-foreground" />
|
||||||
{getStatusText(status)}
|
{getStatusText(status)}
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -178,4 +178,16 @@ p {
|
|||||||
.animate-dot-pulse {
|
.animate-dot-pulse {
|
||||||
animation: dot-pulse 1.4s ease-in-out infinite;
|
animation: dot-pulse 1.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Use JavaScript-set viewport height for mobile to handle keyboard properly */
|
||||||
|
.h-screen-safe {
|
||||||
|
height: 100vh; /* Default for desktop and fallback */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only apply custom viewport height on mobile */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.h-screen-safe {
|
||||||
|
height: var(--vh, 100vh); /* Use CSS variable set by ViewportHeightFix on mobile */
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -22,6 +22,7 @@ import { TopLoader } from "@app/components/Toploader";
|
|||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
|
import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
|
||||||
import { TailwindIndicator } from "@app/components/TailwindIndicator";
|
import { TailwindIndicator } from "@app/components/TailwindIndicator";
|
||||||
|
import { ViewportHeightFix } from "@app/components/ViewportHeightFix";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
|
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
|
||||||
@@ -77,7 +78,7 @@ export default async function RootLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<html suppressHydrationWarning lang={locale}>
|
<html suppressHydrationWarning lang={locale}>
|
||||||
<body className={`${font.className} h-screen overflow-hidden`}>
|
<body className={`${font.className} h-screen-safe overflow-hidden`}>
|
||||||
<TopLoader />
|
<TopLoader />
|
||||||
{build === "saas" && (
|
{build === "saas" && (
|
||||||
<Script
|
<Script
|
||||||
@@ -86,6 +87,7 @@ export default async function RootLayout({
|
|||||||
strategy="afterInteractive"
|
strategy="afterInteractive"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ViewportHeightFix />
|
||||||
<NextIntlClientProvider>
|
<NextIntlClientProvider>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export async function Layout({
|
|||||||
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
|
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden">
|
<div className="flex h-screen-safe overflow-hidden">
|
||||||
{/* Desktop Sidebar */}
|
{/* Desktop Sidebar */}
|
||||||
{showSidebar && (
|
{showSidebar && (
|
||||||
<LayoutSidebar
|
<LayoutSidebar
|
||||||
@@ -75,7 +75,7 @@ export async function Layout({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"container mx-auto max-w-12xl mb-12",
|
"container mx-auto max-w-12xl mb-12",
|
||||||
showHeader && "pt-16 md:pt-16" // Add top padding on mobile and desktop to account for fixed header
|
showHeader && "md:pt-16" // Add top padding only on desktop to account for fixed header
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function LayoutMobileMenu({
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0 md:hidden fixed top-0 left-0 right-0 z-50 bg-card border-b border-border">
|
<div className="shrink-0 md:hidden sticky top-0 z-50">
|
||||||
<div className="h-16 flex items-center px-2">
|
<div className="h-16 flex items-center px-2">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{showSidebar && (
|
{showSidebar && (
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ export default function ProxyResourcesTable({
|
|||||||
|
|
||||||
if (!targets || targets.length === 0) {
|
if (!targets || targets.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div id="LOOK_FOR_ME" className="flex items-center gap-2">
|
||||||
<StatusIcon status="unknown" />
|
<StatusIcon status="unknown" />
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{t("resourcesTableNoTargets")}
|
{t("resourcesTableNoTargets")}
|
||||||
|
|||||||
@@ -32,12 +32,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
<InfoSections
|
<InfoSections
|
||||||
cols={resource.http && env.flags.usePangolinDns ? 5 : 4}
|
cols={resource.http && env.flags.usePangolinDns ? 5 : 4}
|
||||||
>
|
>
|
||||||
<InfoSection>
|
|
||||||
<InfoSectionTitle>URL</InfoSectionTitle>
|
|
||||||
<InfoSectionContent>
|
|
||||||
<CopyToClipboard text={fullUrl} isLink={true} />
|
|
||||||
</InfoSectionContent>
|
|
||||||
</InfoSection>
|
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
@@ -46,6 +40,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
</InfoSection>
|
</InfoSection>
|
||||||
{resource.http ? (
|
{resource.http ? (
|
||||||
<>
|
<>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>URL</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
<CopyToClipboard text={fullUrl} isLink={true} />
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>
|
<InfoSectionTitle>
|
||||||
{t("authentication")}
|
{t("authentication")}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixes mobile viewport height issues when keyboard opens/closes
|
||||||
|
* by setting a CSS variable with a stable viewport height
|
||||||
|
* Only applies on mobile devices (< 768px, matching Tailwind's md breakpoint)
|
||||||
|
*/
|
||||||
|
export function ViewportHeightFix() {
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if we're on mobile (md breakpoint is typically 768px)
|
||||||
|
const isMobile = () => window.innerWidth < 768;
|
||||||
|
|
||||||
|
// On desktop, don't set --vh at all, let CSS use 100vh directly
|
||||||
|
if (!isMobile()) {
|
||||||
|
// Remove --vh if it was set, so CSS falls back to 100vh
|
||||||
|
document.documentElement.style.removeProperty("--vh");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile-specific logic
|
||||||
|
let maxHeight = window.innerHeight;
|
||||||
|
let resizeTimer: NodeJS.Timeout;
|
||||||
|
|
||||||
|
// Set the viewport height as a CSS variable
|
||||||
|
const setViewportHeight = (height: number) => {
|
||||||
|
document.documentElement.style.setProperty("--vh", `${height}px`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial value
|
||||||
|
setViewportHeight(maxHeight);
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
// If we switched to desktop, remove --vh and stop
|
||||||
|
if (!isMobile()) {
|
||||||
|
document.documentElement.style.removeProperty("--vh");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(() => {
|
||||||
|
const currentHeight = window.innerHeight;
|
||||||
|
|
||||||
|
// Track the maximum height we've seen (when keyboard is closed)
|
||||||
|
if (currentHeight > maxHeight) {
|
||||||
|
maxHeight = currentHeight;
|
||||||
|
setViewportHeight(maxHeight);
|
||||||
|
}
|
||||||
|
// If current height is close to max, update max (keyboard closed)
|
||||||
|
else if (currentHeight >= maxHeight * 0.9) {
|
||||||
|
maxHeight = currentHeight;
|
||||||
|
setViewportHeight(maxHeight);
|
||||||
|
}
|
||||||
|
// Otherwise, keep using the max height (keyboard is open)
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOrientationChange = () => {
|
||||||
|
// Reset on orientation change
|
||||||
|
setTimeout(() => {
|
||||||
|
maxHeight = window.innerHeight;
|
||||||
|
setViewportHeight(maxHeight);
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
window.addEventListener("orientationchange", handleOrientationChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
window.removeEventListener("orientationchange", handleOrientationChange);
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -73,35 +73,30 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
>
|
>
|
||||||
{asChild ? (
|
{asChild ? (
|
||||||
props.children
|
props.children
|
||||||
) : (
|
) : loading ? (
|
||||||
<span className="relative inline-flex items-center justify-center">
|
<span className="relative inline-flex items-center justify-center">
|
||||||
<span
|
<span className="inline-flex items-center justify-center opacity-0">
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center justify-center",
|
|
||||||
loading && "opacity-0"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{props.children}
|
{props.children}
|
||||||
</span>
|
</span>
|
||||||
{loading && (
|
<span className="absolute inset-0 flex items-center justify-center">
|
||||||
<span className="absolute inset-0 flex items-center justify-center">
|
<span className="flex items-center gap-1.5">
|
||||||
<span className="flex items-center gap-1">
|
<span
|
||||||
<span
|
className="h-1 w-1 bg-current animate-dot-pulse"
|
||||||
className="h-1 w-1 bg-current animate-dot-pulse"
|
style={{ animationDelay: "0ms" }}
|
||||||
style={{ animationDelay: "0ms" }}
|
/>
|
||||||
/>
|
<span
|
||||||
<span
|
className="h-1 w-1 bg-current animate-dot-pulse"
|
||||||
className="h-1 w-1 bg-current animate-dot-pulse"
|
style={{ animationDelay: "200ms" }}
|
||||||
style={{ animationDelay: "200ms" }}
|
/>
|
||||||
/>
|
<span
|
||||||
<span
|
className="h-1 w-1 bg-current animate-dot-pulse"
|
||||||
className="h-1 w-1 bg-current animate-dot-pulse"
|
style={{ animationDelay: "400ms" }}
|
||||||
style={{ animationDelay: "400ms" }}
|
/>
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
props.children
|
||||||
)}
|
)}
|
||||||
</Comp>
|
</Comp>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ const checkboxVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
outlinePrimary:
|
outlinePrimary:
|
||||||
"border rounded-[5px] border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
"border rounded-[5px] border-input data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
outline:
|
outline:
|
||||||
"border rounded-[5px] border-input data-[state=checked]:bg-muted data-[state=checked]:text-accent-foreground",
|
"border rounded-[5px] border-input data-[state=checked]:border-primary data-[state=checked]:bg-muted data-[state=checked]:text-accent-foreground",
|
||||||
outlinePrimarySquare:
|
outlinePrimarySquare:
|
||||||
"border rounded-[5px] border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
"border rounded-[5px] border-input data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
outlineSquare:
|
outlineSquare:
|
||||||
"border rounded-[5px] border-input data-[state=checked]:bg-muted data-[state=checked]:text-accent-foreground"
|
"border rounded-[5px] border-input data-[state=checked]:border-primary data-[state=checked]:bg-muted data-[state=checked]:text-accent-foreground"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -30,8 +30,7 @@ const checkboxVariants = cva(
|
|||||||
);
|
);
|
||||||
|
|
||||||
interface CheckboxProps
|
interface CheckboxProps
|
||||||
extends
|
extends React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>,
|
|
||||||
VariantProps<typeof checkboxVariants> {}
|
VariantProps<typeof checkboxVariants> {}
|
||||||
|
|
||||||
const Checkbox = React.forwardRef<
|
const Checkbox = React.forwardRef<
|
||||||
@@ -50,9 +49,8 @@ const Checkbox = React.forwardRef<
|
|||||||
));
|
));
|
||||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||||
|
|
||||||
interface CheckboxWithLabelProps extends React.ComponentPropsWithoutRef<
|
interface CheckboxWithLabelProps
|
||||||
typeof Checkbox
|
extends React.ComponentPropsWithoutRef<typeof Checkbox> {
|
||||||
> {
|
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user