Compare commits

..

69 Commits

Author SHA1 Message Date
miloschwartz
0a537c6830 add org only idp to integration api 2026-01-11 10:47:19 -08:00
miloschwartz
2810632f4a add flag to enable org only idp in ee 2026-01-07 20:40:59 -08:00
Owen Schwartz
2ca400ab16 New translations en-us.json (French) 2025-12-24 16:14:26 -05:00
Owen Schwartz
4183067c77 New translations en-us.json (Norwegian Bokmal) 2025-12-24 16:14:26 -05:00
Owen Schwartz
5eb4691973 New translations en-us.json (Chinese Simplified) 2025-12-24 16:14:26 -05:00
Owen Schwartz
d14dfbf360 New translations en-us.json (Turkish) 2025-12-24 16:14:26 -05:00
Owen Schwartz
493a5ad02a New translations en-us.json (Russian) 2025-12-24 16:14:26 -05:00
Owen Schwartz
481beff028 New translations en-us.json (Portuguese) 2025-12-24 16:14:26 -05:00
Owen Schwartz
f1f7e438b4 New translations en-us.json (Polish) 2025-12-24 16:14:26 -05:00
Owen Schwartz
00f84c9d8e New translations en-us.json (Dutch) 2025-12-24 16:14:26 -05:00
Owen Schwartz
f75b9c6c86 New translations en-us.json (Korean) 2025-12-24 16:14:26 -05:00
Owen Schwartz
31bc6d5773 New translations en-us.json (Italian) 2025-12-24 16:14:26 -05:00
Owen Schwartz
51dc1450d3 New translations en-us.json (German) 2025-12-24 16:14:26 -05:00
Owen Schwartz
fcbea08c87 New translations en-us.json (Czech) 2025-12-24 16:14:26 -05:00
Owen Schwartz
8d60a87aa1 New translations en-us.json (Bulgarian) 2025-12-24 16:14:26 -05:00
Owen Schwartz
956aa64519 New translations en-us.json (Spanish) 2025-12-24 16:14:26 -05:00
Owen Schwartz
fd1cb6ca23 New translations en-us.json (French) 2025-12-24 16:14:26 -05:00
Owen Schwartz
37082ae436 New translations en-us.json (Norwegian Bokmal) 2025-12-24 16:14:26 -05:00
Owen Schwartz
bb47ca3d2e New translations en-us.json (Chinese Simplified) 2025-12-24 16:14:26 -05:00
Owen Schwartz
0dd3c84b24 New translations en-us.json (Turkish) 2025-12-24 16:14:26 -05:00
Owen Schwartz
848fca7e1b New translations en-us.json (Russian) 2025-12-24 16:14:26 -05:00
Owen Schwartz
2500f99722 New translations en-us.json (Portuguese) 2025-12-24 16:14:26 -05:00
Owen Schwartz
c7737c444f New translations en-us.json (Polish) 2025-12-24 16:14:26 -05:00
Owen Schwartz
4d1a7ed69b New translations en-us.json (Dutch) 2025-12-24 16:14:26 -05:00
Owen Schwartz
626d5df67e New translations en-us.json (Korean) 2025-12-24 16:14:26 -05:00
Owen Schwartz
e4c369deec New translations en-us.json (Italian) 2025-12-24 16:14:26 -05:00
Owen Schwartz
307209e73f New translations en-us.json (German) 2025-12-24 16:14:26 -05:00
Owen Schwartz
dc84935ee6 New translations en-us.json (Czech) 2025-12-24 16:14:26 -05:00
Owen Schwartz
998c1f52ca New translations en-us.json (Bulgarian) 2025-12-24 16:14:26 -05:00
Owen Schwartz
2766758c66 New translations en-us.json (Spanish) 2025-12-24 16:14:26 -05:00
Owen Schwartz
258d1d82f3 New translations en-us.json (French) 2025-12-24 16:14:26 -05:00
Owen Schwartz
46aaadb76a New translations en-us.json (Norwegian Bokmal) 2025-12-24 16:14:26 -05:00
Owen Schwartz
ea7a618810 New translations en-us.json (Chinese Simplified) 2025-12-24 16:14:26 -05:00
Owen Schwartz
c0e503b31f New translations en-us.json (Turkish) 2025-12-24 16:14:26 -05:00
Owen Schwartz
55f5a41752 New translations en-us.json (Russian) 2025-12-24 16:14:26 -05:00
Owen Schwartz
b0be82be86 New translations en-us.json (Portuguese) 2025-12-24 16:14:26 -05:00
Owen Schwartz
96a9bdb700 New translations en-us.json (Polish) 2025-12-24 16:14:26 -05:00
Owen Schwartz
74e6d39c24 New translations en-us.json (Dutch) 2025-12-24 16:14:26 -05:00
Owen Schwartz
61dfa00222 New translations en-us.json (Korean) 2025-12-24 16:14:26 -05:00
Owen Schwartz
476281db2b New translations en-us.json (Italian) 2025-12-24 16:14:26 -05:00
Owen Schwartz
f32e31c73d New translations en-us.json (Czech) 2025-12-24 16:14:26 -05:00
Owen Schwartz
ea72279080 New translations en-us.json (Bulgarian) 2025-12-24 16:14:26 -05:00
Owen Schwartz
16ba56af84 New translations en-us.json (Spanish) 2025-12-24 16:14:26 -05:00
Owen Schwartz
f13ddde988 New translations en-us.json (Norwegian Bokmal) 2025-12-24 16:14:26 -05:00
Owen Schwartz
67dc10dfe9 New translations en-us.json (Chinese Simplified) 2025-12-24 16:14:26 -05:00
Owen Schwartz
5fd216adc2 New translations en-us.json (Turkish) 2025-12-24 16:14:26 -05:00
Owen Schwartz
6f0268f6c0 New translations en-us.json (Russian) 2025-12-24 16:14:26 -05:00
Owen Schwartz
2996dfb33a New translations en-us.json (Portuguese) 2025-12-24 16:14:26 -05:00
Owen Schwartz
c92f2cd4ba New translations en-us.json (Polish) 2025-12-24 16:14:26 -05:00
Owen Schwartz
8164d5c1ad New translations en-us.json (Dutch) 2025-12-24 16:14:26 -05:00
Owen Schwartz
d9d8d85f6e New translations en-us.json (Korean) 2025-12-24 16:14:26 -05:00
Owen Schwartz
d49720703f New translations en-us.json (Italian) 2025-12-24 16:14:26 -05:00
Owen Schwartz
2362a9b4dd New translations en-us.json (German) 2025-12-24 16:14:26 -05:00
Owen Schwartz
a8265a5286 New translations en-us.json (Czech) 2025-12-24 16:14:26 -05:00
Owen Schwartz
9ea7431b73 New translations en-us.json (Bulgarian) 2025-12-24 16:14:26 -05:00
Owen Schwartz
37e6f320fe New translations en-us.json (Spanish) 2025-12-24 16:14:26 -05:00
miloschwartz
c0c0d48edf ui enhancements 2025-12-24 16:14:26 -05:00
Owen
284cccbe17 Attempt to fix loginPageOrg undefined error 2025-12-24 16:14:26 -05:00
Owen
81a9a94264 Try to remove deadlocks on client updates 2025-12-24 16:14:26 -05:00
Owen
dccf101554 Allow all in country in blueprints
Fixes #2163
2025-12-24 16:14:26 -05:00
Owen
a01c06bbc7 Respect http status for url & maintenance mode
Fixes #2164
2025-12-24 16:14:26 -05:00
miloschwartz
db43cf1b30 add sticky actions col to org idp table 2025-12-24 16:14:26 -05:00
miloschwartz
2f561b5604 adjustments to mobile header css closes #1930 2025-12-24 16:14:26 -05:00
miloschwartz
5a30f036ff fade mobile footer 2025-12-24 16:14:26 -05:00
miloschwartz
768b9ffd09 fix server admin spacing on mobile sidebar 2025-12-24 16:14:26 -05:00
miloschwartz
8732e50047 add flag to disable product help banners 2025-12-24 16:14:26 -05:00
miloschwartz
d6e0024c96 improved button loading animation 2025-12-24 16:14:26 -05:00
miloschwartz
9759e86921 add stripPortFromHost and reuse everywhere 2025-12-24 16:14:26 -05:00
Owen
ca89c5feca Reorder when the redirect gets in there 2025-12-23 16:02:52 -05:00
72 changed files with 722 additions and 394 deletions

View File

@@ -850,6 +850,7 @@
"orgPolicyConfig": "Конфигуриране на достъп за организация",
"idpUpdatedDescription": "Идентификационният доставчик беше актуализиран успешно",
"redirectUrl": "URL за пренасочване",
"orgIdpRedirectUrls": "URL адреси за пренасочване",
"redirectUrlAbout": "За URL за пренасочване",
"redirectUrlAboutDescription": "Това е URL адресът, към който потребителите ще бъдат пренасочени след удостоверяване. Трябва да конфигурирате този URL адрес в настройките на доставчика на идентичност.",
"pangolinAuth": "Authent - Pangolin",
@@ -1479,7 +1480,7 @@
"IAgreeToThe": "Съгласен съм с",
"termsOfService": "условията за ползване",
"and": "и",
"privacyPolicy": "политиката за поверителност"
"privacyPolicy": "политика за поверителност."
},
"signUpMarketing": {
"keepMeInTheLoop": "Дръж ме в течение с новини, актуализации и нови функции чрез имейл."
@@ -2349,6 +2350,7 @@
"enterConfirmation": "Въведете потвърждение.",
"blueprintViewDetails": "Подробности.",
"defaultIdentityProvider": "По подразбиране доставчик на идентичност.",
"defaultIdentityProviderDescription": "Когато е избран основен доставчик на идентичност, потребителят ще бъде автоматично пренасочен към доставчика за удостоверяване.",
"editInternalResourceDialogNetworkSettings": "Мрежови настройки.",
"editInternalResourceDialogAccessPolicy": "Политика за достъп.",
"editInternalResourceDialogAddRoles": "Добавяне на роли.",

View File

@@ -850,6 +850,7 @@
"orgPolicyConfig": "Konfigurace přístupu pro organizaci",
"idpUpdatedDescription": "Poskytovatel identity byl úspěšně aktualizován",
"redirectUrl": "Přesměrovat URL",
"orgIdpRedirectUrls": "Přesměrovat 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.",
"pangolinAuth": "Auth - Pangolin",
@@ -1479,7 +1480,7 @@
"IAgreeToThe": "Souhlasím s",
"termsOfService": "podmínky služby",
"and": "a",
"privacyPolicy": "zásady ochrany osobních údajů"
"privacyPolicy": "zásady ochrany osobních údajů."
},
"signUpMarketing": {
"keepMeInTheLoop": "Udržujte mě ve smyčce s novinkami, aktualizacemi a novými funkcemi e-mailem."
@@ -2349,6 +2350,7 @@
"enterConfirmation": "Zadejte potvrzení",
"blueprintViewDetails": "Detaily",
"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ě",
"editInternalResourceDialogAccessPolicy": "Přístupová politika",
"editInternalResourceDialogAddRoles": "Přidat role",

View File

@@ -850,6 +850,7 @@
"orgPolicyConfig": "Zugriff für eine Organisation konfigurieren",
"idpUpdatedDescription": "Identitätsanbieter erfolgreich aktualisiert",
"redirectUrl": "Weiterleitungs-URL",
"orgIdpRedirectUrls": "Umleitungs-URLs",
"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.",
"pangolinAuth": "Authentifizierung - Pangolin",
@@ -1479,7 +1480,7 @@
"IAgreeToThe": "Ich stimme den",
"termsOfService": "Nutzungsbedingungen zu",
"and": "und",
"privacyPolicy": "Datenschutzrichtlinie"
"privacyPolicy": "datenschutzrichtlinie."
},
"signUpMarketing": {
"keepMeInTheLoop": "Halten Sie mich auf dem Laufenden mit Neuigkeiten, Updates und neuen Funktionen per E-Mail."
@@ -2349,6 +2350,7 @@
"enterConfirmation": "Bestätigung eingeben",
"blueprintViewDetails": "Details",
"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",
"editInternalResourceDialogAccessPolicy": "Zugriffsrichtlinie",
"editInternalResourceDialogAddRoles": "Rollen hinzufügen",

View File

@@ -1480,7 +1480,7 @@
"IAgreeToThe": "I agree to the",
"termsOfService": "terms of service",
"and": "and",
"privacyPolicy": "privacy policy"
"privacyPolicy": "privacy policy."
},
"signUpMarketing": {
"keepMeInTheLoop": "Keep me in the loop with news, updates, and new features by email."

View File

@@ -850,6 +850,7 @@
"orgPolicyConfig": "Configurar acceso para una organización",
"idpUpdatedDescription": "Proveedor de identidad actualizado correctamente",
"redirectUrl": "URL de redirección",
"orgIdpRedirectUrls": "Redirigir URL",
"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.",
"pangolinAuth": "Autenticación - Pangolin",
@@ -1479,7 +1480,7 @@
"IAgreeToThe": "Estoy de acuerdo con los",
"termsOfService": "términos del servicio",
"and": "y",
"privacyPolicy": "política de privacidad"
"privacyPolicy": "política de privacidad."
},
"signUpMarketing": {
"keepMeInTheLoop": "Mantenerme en el bucle con noticias, actualizaciones y nuevas características por correo electrónico."
@@ -2349,6 +2350,7 @@
"enterConfirmation": "Ingresar confirmación",
"blueprintViewDetails": "Detalles",
"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",
"editInternalResourceDialogAccessPolicy": "Política de acceso",
"editInternalResourceDialogAddRoles": "Agregar roles",

View File

@@ -850,6 +850,7 @@
"orgPolicyConfig": "Configurer l'accès pour une organisation",
"idpUpdatedDescription": "Fournisseur d'identité mis à jour avec succès",
"redirectUrl": "URL de redirection",
"orgIdpRedirectUrls": "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é.",
"pangolinAuth": "Auth - Pangolin",
@@ -1479,7 +1480,7 @@
"IAgreeToThe": "Je suis d'accord avec",
"termsOfService": "les conditions d'utilisation",
"and": "et",
"privacyPolicy": "la politique de confidentialité"
"privacyPolicy": "politique de confidentialité."
},
"signUpMarketing": {
"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",
"blueprintViewDetails": "Détails",
"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",
"editInternalResourceDialogAccessPolicy": "Politique d'accès",
"editInternalResourceDialogAddRoles": "Ajouter des rôles",

View File

@@ -850,6 +850,7 @@
"orgPolicyConfig": "Configura l'accesso per un'organizzazione",
"idpUpdatedDescription": "Provider di identità aggiornato con successo",
"redirectUrl": "URL di Reindirizzamento",
"orgIdpRedirectUrls": "Reindirizza URL",
"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à.",
"pangolinAuth": "Autenticazione - Pangolina",
@@ -1479,7 +1480,7 @@
"IAgreeToThe": "Accetto i",
"termsOfService": "termini di servizio",
"and": "e",
"privacyPolicy": "informativa sulla privacy"
"privacyPolicy": "informativa sulla privacy."
},
"signUpMarketing": {
"keepMeInTheLoop": "Tienimi in loop con notizie, aggiornamenti e nuove funzionalità via e-mail."
@@ -2349,6 +2350,7 @@
"enterConfirmation": "Inserisci conferma",
"blueprintViewDetails": "Dettagli",
"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",
"editInternalResourceDialogAccessPolicy": "Politica di Accesso",
"editInternalResourceDialogAddRoles": "Aggiungi Ruoli",

View File

@@ -850,6 +850,7 @@
"orgPolicyConfig": "조직에 대한 접근을 구성하십시오.",
"idpUpdatedDescription": "아이덴티티 제공자가 성공적으로 업데이트되었습니다",
"redirectUrl": "리디렉션 URL",
"orgIdpRedirectUrls": "리디렉션 URL",
"redirectUrlAbout": "리디렉션 URL에 대한 정보",
"redirectUrlAboutDescription": "사용자가 인증 후 리디렉션될 URL입니다. 이 URL을 신원 제공자 설정에서 구성해야 합니다.",
"pangolinAuth": "인증 - 판골린",
@@ -1479,7 +1480,7 @@
"IAgreeToThe": "동의합니다",
"termsOfService": "서비스 약관",
"and": "및",
"privacyPolicy": "개인 정보 보호 정책"
"privacyPolicy": "개인 정보 보호 정책."
},
"signUpMarketing": {
"keepMeInTheLoop": "이메일을 통해 소식, 업데이트 및 새로운 기능을 받아보세요."
@@ -2349,6 +2350,7 @@
"enterConfirmation": "확인 입력",
"blueprintViewDetails": "세부 정보",
"defaultIdentityProvider": "기본 아이덴티티 공급자",
"defaultIdentityProviderDescription": "기본 ID 공급자가 선택되면, 사용자는 인증을 위해 자동으로 해당 공급자로 리디렉션됩니다.",
"editInternalResourceDialogNetworkSettings": "네트워크 설정",
"editInternalResourceDialogAccessPolicy": "액세스 정책",
"editInternalResourceDialogAddRoles": "역할 추가",

View File

@@ -850,6 +850,7 @@
"orgPolicyConfig": "Konfigurer tilgang for en organisasjon",
"idpUpdatedDescription": "Identitetsleverandør vellykket oppdatert",
"redirectUrl": "Omdirigerings-URL",
"orgIdpRedirectUrls": "Omadressere URL'er",
"redirectUrlAbout": "Om omdirigerings-URL",
"redirectUrlAboutDescription": "Dette er URLen som brukere vil bli omdirigert etter autentisering. Du må konfigurere denne URLen i identitetsleverandørens innstillinger.",
"pangolinAuth": "Autentisering - Pangolin",
@@ -1479,7 +1480,7 @@
"IAgreeToThe": "Jeg godtar",
"termsOfService": "brukervilkårene",
"and": "og",
"privacyPolicy": "personvernerklæringen"
"privacyPolicy": "retningslinjer for personvern"
},
"signUpMarketing": {
"keepMeInTheLoop": "Hold meg i løken med nyheter, oppdateringer og nye funksjoner via e-post."
@@ -2349,6 +2350,7 @@
"enterConfirmation": "Skriv inn bekreftelse",
"blueprintViewDetails": "Detaljer",
"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",
"editInternalResourceDialogAccessPolicy": "Tilgangsregler for tilgang",
"editInternalResourceDialogAddRoles": "Legg til roller",

View File

@@ -850,6 +850,7 @@
"orgPolicyConfig": "Toegang voor een organisatie configureren",
"idpUpdatedDescription": "Identity provider succesvol bijgewerkt",
"redirectUrl": "Omleidings URL",
"orgIdpRedirectUrls": "URL's omleiden",
"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.",
"pangolinAuth": "Authenticatie - Pangolin",
@@ -1479,7 +1480,7 @@
"IAgreeToThe": "Ik ga akkoord met de",
"termsOfService": "servicevoorwaarden",
"and": "en",
"privacyPolicy": "privacybeleid"
"privacyPolicy": "privacy beleid"
},
"signUpMarketing": {
"keepMeInTheLoop": "Houd me op de hoogte met nieuws, updates en nieuwe functies per e-mail."
@@ -2349,6 +2350,7 @@
"enterConfirmation": "Bevestiging invoeren",
"blueprintViewDetails": "Details",
"defaultIdentityProvider": "Standaard Identiteitsprovider",
"defaultIdentityProviderDescription": "Wanneer een standaard identity provider is geselecteerd, zal de gebruiker automatisch worden doorgestuurd naar de provider voor authenticatie.",
"editInternalResourceDialogNetworkSettings": "Netwerkinstellingen",
"editInternalResourceDialogAccessPolicy": "Toegangsbeleid",
"editInternalResourceDialogAddRoles": "Rollen toevoegen",

View File

@@ -850,6 +850,7 @@
"orgPolicyConfig": "Skonfiguruj dostęp dla organizacji",
"idpUpdatedDescription": "Dostawca tożsamości został pomyślnie zaktualizowany",
"redirectUrl": "URL przekierowania",
"orgIdpRedirectUrls": "Przekieruj adresy URL",
"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.",
"pangolinAuth": "Autoryzacja - Pangolin",
@@ -1479,7 +1480,7 @@
"IAgreeToThe": "Zgadzam się z",
"termsOfService": "warunkami usługi",
"and": "oraz",
"privacyPolicy": "polityką prywatności"
"privacyPolicy": "polityka prywatności."
},
"signUpMarketing": {
"keepMeInTheLoop": "Zachowaj mnie w pętli z wiadomościami, aktualizacjami i nowymi funkcjami przez e-mail."
@@ -2349,6 +2350,7 @@
"enterConfirmation": "Wprowadź potwierdzenie",
"blueprintViewDetails": "Szczegóły",
"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",
"editInternalResourceDialogAccessPolicy": "Polityka dostępowa",
"editInternalResourceDialogAddRoles": "Dodaj role",

View File

@@ -850,6 +850,7 @@
"orgPolicyConfig": "Configurar acesso para uma organização",
"idpUpdatedDescription": "Provedor de identidade atualizado com sucesso",
"redirectUrl": "URL de Redirecionamento",
"orgIdpRedirectUrls": "Redirecionar URLs",
"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.",
"pangolinAuth": "Autenticação - Pangolin",
@@ -1479,7 +1480,7 @@
"IAgreeToThe": "Concordo com",
"termsOfService": "os termos de serviço",
"and": "e",
"privacyPolicy": "política de privacidade"
"privacyPolicy": "política de privacidade."
},
"signUpMarketing": {
"keepMeInTheLoop": "Mantenha-me à disposição com notícias, atualizações e novos recursos por e-mail."
@@ -2349,6 +2350,7 @@
"enterConfirmation": "Inserir confirmação",
"blueprintViewDetails": "Detalhes",
"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",
"editInternalResourceDialogAccessPolicy": "Política de Acesso",
"editInternalResourceDialogAddRoles": "Adicionar Funções",

View File

@@ -850,6 +850,7 @@
"orgPolicyConfig": "Настроить доступ для организации",
"idpUpdatedDescription": "Поставщик удостоверений успешно обновлён",
"redirectUrl": "URL редиректа",
"orgIdpRedirectUrls": "Перенаправление URL",
"redirectUrlAbout": "О редиректе URL",
"redirectUrlAboutDescription": "Это URL, на который пользователи будут перенаправлены после аутентификации. Вам нужно настроить этот URL в настройках провайдера.",
"pangolinAuth": "Аутентификация - Pangolin",
@@ -1479,7 +1480,7 @@
"IAgreeToThe": "Я согласен с",
"termsOfService": "условия использования",
"and": "и",
"privacyPolicy": "политика конфиденциальности"
"privacyPolicy": "политика конфиденциальности."
},
"signUpMarketing": {
"keepMeInTheLoop": "Держите меня в цикле с новостями, обновлениями и новыми функциями по электронной почте."
@@ -2349,6 +2350,7 @@
"enterConfirmation": "Введите подтверждение",
"blueprintViewDetails": "Подробности",
"defaultIdentityProvider": "Поставщик удостоверений по умолчанию",
"defaultIdentityProviderDescription": "Когда выбран поставщик идентификации по умолчанию, пользователь будет автоматически перенаправлен на провайдер для аутентификации.",
"editInternalResourceDialogNetworkSettings": "Настройки сети",
"editInternalResourceDialogAccessPolicy": "Политика доступа",
"editInternalResourceDialogAddRoles": "Добавить роли",

View File

@@ -850,6 +850,7 @@
"orgPolicyConfig": "Bir kuruluş için erişimi yapılandırın",
"idpUpdatedDescription": "Kimlik sağlayıcı başarıyla güncellendi",
"redirectUrl": "Yönlendirme URL'si",
"orgIdpRedirectUrls": "Yönlendirme URL'leri",
"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.",
"pangolinAuth": "Yetkilendirme - Pangolin",
@@ -1479,7 +1480,7 @@
"IAgreeToThe": "Kabul ediyorum",
"termsOfService": "hizmet şartları",
"and": "ve",
"privacyPolicy": "gizlilik politikası"
"privacyPolicy": "gizlilik politikası."
},
"signUpMarketing": {
"keepMeInTheLoop": "Bana e-posta yoluyla haberler, güncellemeler ve yeni özellikler hakkında bilgi verin."
@@ -2349,6 +2350,7 @@
"enterConfirmation": "Onayı girin",
"blueprintViewDetails": "Detaylar",
"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ı",
"editInternalResourceDialogAccessPolicy": "Erişim Politikası",
"editInternalResourceDialogAddRoles": "Roller Ekle",

View File

@@ -850,6 +850,7 @@
"orgPolicyConfig": "配置组织访问权限",
"idpUpdatedDescription": "身份提供商更新成功",
"redirectUrl": "重定向网址",
"orgIdpRedirectUrls": "重定向URL",
"redirectUrlAbout": "关于重定向网址",
"redirectUrlAboutDescription": "这是用户在验证后将被重定向到的URL。您需要在身份提供者的设置中配置此URL。",
"pangolinAuth": "认证 - Pangolin",
@@ -1479,7 +1480,7 @@
"IAgreeToThe": "我同意",
"termsOfService": "服务条款",
"and": "和",
"privacyPolicy": "隐私政策"
"privacyPolicy": "隐私政策"
},
"signUpMarketing": {
"keepMeInTheLoop": "通过电子邮件让我在循环中保持新闻、更新和新功能。"
@@ -2349,6 +2350,7 @@
"enterConfirmation": "输入确认",
"blueprintViewDetails": "详细信息",
"defaultIdentityProvider": "默认身份提供商",
"defaultIdentityProviderDescription": "当选择默认身份提供商时,用户将自动重定向到提供商进行身份验证。",
"editInternalResourceDialogNetworkSettings": "网络设置",
"editInternalResourceDialogAccessPolicy": "访问策略",
"editInternalResourceDialogAddRoles": "添加角色",

View File

@@ -111,32 +111,30 @@ export const RuleSchema = z
.refine(
(rule) => {
if (rule.match === "country") {
// Check if it's a valid 2-letter country code
return /^[A-Z]{2}$/.test(rule.value);
// Check if it's a valid 2-letter country code or "ALL"
return /^[A-Z]{2}$/.test(rule.value) || rule.value === "ALL";
}
return true;
},
{
path: ["value"],
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(
(rule) => {
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 isASFormat = asNumberPattern.test(rule.value);
const isNumeric = /^\d+$/.test(rule.value);
return isASFormat || isNumeric;
return asNumberPattern.test(rule.value) || rule.value === "ALL";
}
return true;
},
{
path: ["value"],
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'"
}
);

View File

@@ -84,6 +84,10 @@ export class Config {
?.disable_basic_wireguard_sites
? "true"
: "false";
process.env.FLAGS_DISABLE_PRODUCT_HELP_BANNERS = parsedConfig.flags
?.disable_product_help_banners
? "true"
: "false";
process.env.PRODUCT_UPDATES_NOTIFICATION_ENABLED = parsedConfig.app
.notifications.product_updates

View File

@@ -4,6 +4,7 @@ import { and, eq, isNotNull } from "drizzle-orm";
import config from "@server/lib/config";
import z from "zod";
import logger from "@server/logger";
import semver from "semver";
interface IPRange {
start: bigint;
@@ -683,3 +684,35 @@ export function parsePortRangeString(
return result;
}
export function stripPortFromHost(ip: string, badgerVersion?: string): string {
const isNewerBadger =
badgerVersion &&
semver.valid(badgerVersion) &&
semver.gte(badgerVersion, "1.3.1");
if (isNewerBadger) {
return ip;
}
if (ip.startsWith("[") && ip.includes("]")) {
// if brackets are found, extract the IPv6 address from between the brackets
const ipv6Match = ip.match(/\[(.*?)\]/);
if (ipv6Match) {
return ipv6Match[1];
}
}
// Check if it looks like IPv4 (contains dots and matches IPv4 pattern)
// IPv4 format: x.x.x.x where x is 0-255
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}/;
if (ipv4Pattern.test(ip)) {
const lastColonIndex = ip.lastIndexOf(":");
if (lastColonIndex !== -1) {
return ip.substring(0, lastColonIndex);
}
}
// Return as is
return ip;
}

View File

@@ -330,7 +330,8 @@ export const configSchema = z
enable_integration_api: z.boolean().optional(),
disable_local_sites: z.boolean().optional(),
disable_basic_wireguard_sites: z.boolean().optional(),
disable_config_managed_domains: z.boolean().optional()
disable_config_managed_domains: z.boolean().optional(),
disable_product_help_banners: z.boolean().optional()
})
.optional(),
dns: z

View File

@@ -13,3 +13,4 @@ export * from "./verifyApiKeyIsRoot";
export * from "./verifyApiKeyApiKeyAccess";
export * from "./verifyApiKeyClientAccess";
export * from "./verifyApiKeySiteResourceAccess";
export * from "./verifyApiKeyIdpAccess";

View File

@@ -0,0 +1,88 @@
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { idp, idpOrg, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export async function verifyApiKeyIdpAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const apiKey = req.apiKey;
const idpId = req.params.idpId || req.body.idpId || req.query.idpId;
const orgId = req.params.orgId;
if (!apiKey) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
);
}
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
if (!idpId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid IDP ID")
);
}
if (apiKey.isRoot) {
// Root keys can access any IDP in any org
return next();
}
const [idpRes] = await db
.select()
.from(idp)
.innerJoin(idpOrg, eq(idp.idpId, idpOrg.idpId))
.where(and(eq(idp.idpId, idpId), eq(idpOrg.orgId, orgId)))
.limit(1);
if (!idpRes || !idpRes.idp || !idpRes.idpOrg) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`IdP with ID ${idpId} not found for organization ${orgId}`
)
);
}
if (!req.apiKeyOrg) {
const apiKeyOrgRes = await db
.select()
.from(apiKeyOrg)
.where(
and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, idpRes.idpOrg.orgId)
)
);
req.apiKeyOrg = apiKeyOrgRes[0];
}
if (!req.apiKeyOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to this organization"
)
);
}
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying IDP access"
)
);
}
}

View File

@@ -139,6 +139,10 @@ export class PrivateConfig {
process.env.USE_PANGOLIN_DNS =
this.rawPrivateConfig.flags.use_pangolin_dns.toString();
}
if (this.rawPrivateConfig.flags.use_org_only_idp) {
process.env.USE_ORG_ONLY_IDP =
this.rawPrivateConfig.flags.use_org_only_idp.toString();
}
}
public getRawPrivateConfig() {

View File

@@ -17,6 +17,7 @@ import logger from "@server/logger";
import { and, eq, lt } from "drizzle-orm";
import cache from "@server/lib/cache";
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
import { stripPortFromHost } from "@server/lib/ip";
async function getAccessDays(orgId: string): Promise<number> {
// check cache first
@@ -116,19 +117,7 @@ export async function logAccessAudit(data: {
}
const clientIp = data.requestIp
? (() => {
if (
data.requestIp.startsWith("[") &&
data.requestIp.includes("]")
) {
// if brackets are found, extract the IPv6 address from between the brackets
const ipv6Match = data.requestIp.match(/\[(.*?)\]/);
if (ipv6Match) {
return ipv6Match[1];
}
}
return data.requestIp;
})()
? stripPortFromHost(data.requestIp)
: undefined;
const countryCode = data.requestIp

View File

@@ -83,7 +83,8 @@ export const privateConfigSchema = z.object({
flags: z
.object({
enable_redis: z.boolean().optional().default(false),
use_pangolin_dns: z.boolean().optional().default(false)
use_pangolin_dns: z.boolean().optional().default(false),
use_org_only_idp: z.boolean().optional().default(false)
})
.optional()
.prefault({}),

View File

@@ -358,18 +358,6 @@ export async function getTraefikConfig(
}
}
if (resource.ssl) {
config_output.http.routers![routerName + "-redirect"] = {
entryPoints: [
config.getRawConfig().traefik.http_entrypoint
],
middlewares: [redirectHttpsMiddlewareName],
service: serviceName,
rule: rule,
priority: priority
};
}
let tls = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = fullDomain.split(".");
@@ -435,6 +423,18 @@ export async function getTraefikConfig(
}
}
if (resource.ssl) {
config_output.http.routers![routerName + "-redirect"] = {
entryPoints: [
config.getRawConfig().traefik.http_entrypoint
],
middlewares: [redirectHttpsMiddlewareName],
service: serviceName,
rule: rule,
priority: priority
};
}
const availableServers = targets.filter((target) => {
if (!target.enabled) return false;

View File

@@ -618,6 +618,16 @@ hybridRouter.get(
)
.limit(1);
if (!result) {
return response<LoginPage | null>(res, {
data: null,
success: true,
error: false,
message: "Login page not found",
status: HttpCode.OK
});
}
if (
await checkExitNodeOrg(
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, {
data: result.loginPage,
success: true,

View File

@@ -18,7 +18,8 @@ import * as logs from "#private/routers/auditLogs";
import {
verifyApiKeyHasAction,
verifyApiKeyIsRoot,
verifyApiKeyOrgAccess
verifyApiKeyOrgAccess,
verifyApiKeyIdpAccess
} from "@server/middlewares";
import {
verifyValidSubscription,
@@ -31,6 +32,8 @@ import {
authenticated as a
} from "@server/routers/integration";
import { logActionAudit } from "#private/middlewares";
import config from "#private/lib/config";
import { build } from "@server/build";
export const unauthenticated = ua;
export const authenticated = a;
@@ -88,3 +91,49 @@ authenticated.get(
logActionAudit(ActionsEnum.exportLogs),
logs.exportAccessAuditLogs
);
authenticated.put(
"/org/:orgId/idp/oidc",
verifyValidLicense,
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.createIdp),
logActionAudit(ActionsEnum.createIdp),
orgIdp.createOrgOidcIdp
);
authenticated.post(
"/org/:orgId/idp/:idpId/oidc",
verifyValidLicense,
verifyApiKeyOrgAccess,
verifyApiKeyIdpAccess,
verifyApiKeyHasAction(ActionsEnum.updateIdp),
logActionAudit(ActionsEnum.updateIdp),
orgIdp.updateOrgOidcIdp
);
authenticated.delete(
"/org/:orgId/idp/:idpId",
verifyValidLicense,
verifyApiKeyOrgAccess,
verifyApiKeyIdpAccess,
verifyApiKeyHasAction(ActionsEnum.deleteIdp),
logActionAudit(ActionsEnum.deleteIdp),
orgIdp.deleteOrgIdp
);
authenticated.get(
"/org/:orgId/idp/:idpId",
verifyValidLicense,
verifyApiKeyOrgAccess,
verifyApiKeyIdpAccess,
verifyApiKeyHasAction(ActionsEnum.getIdp),
orgIdp.getOrgIdp
);
authenticated.get(
"/org/:orgId/idp",
verifyValidLicense,
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.listIdps),
orgIdp.listOrgIdps
);

View File

@@ -40,6 +40,11 @@ async function query(orgId: string | undefined, fullDomain: string) {
eq(loginPage.loginPageId, loginPageOrg.loginPageId)
)
.limit(1);
if (!res) {
return null;
}
return {
...res.loginPage,
orgId: res.loginPageOrg.orgId
@@ -65,6 +70,11 @@ async function query(orgId: string | undefined, fullDomain: string) {
)
)
.limit(1);
if (!res) {
return null;
}
return {
...res,
orgId: orgLink.orgId

View File

@@ -48,6 +48,11 @@ async function query(orgId: string) {
)
)
.limit(1);
if (!res) {
return null;
}
return {
...res,
orgId: orgLink.orgs.orgId,

View File

@@ -28,6 +28,7 @@ import { eq, InferInsertModel } from "drizzle-orm";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import { build } from "@server/build";
import config from "@server/private/lib/config";
const paramsSchema = z.strictObject({
orgId: z.string()
@@ -94,8 +95,10 @@ export async function upsertLoginPageBranding(
typeof loginPageBranding
>;
if (build !== "saas") {
// org branding settings are only considered in the saas build
if (
build !== "saas" &&
!config.getRawPrivateConfig().flags.use_org_only_idp
) {
const { orgTitle, orgSubtitle, ...rest } = updateData;
updateData = rest;
}

View File

@@ -46,22 +46,23 @@ const bodySchema = z.strictObject({
roleMapping: z.string().optional()
});
// registry.registerPath({
// method: "put",
// path: "/idp/oidc",
// description: "Create an OIDC IdP.",
// tags: [OpenAPITags.Idp],
// request: {
// body: {
// content: {
// "application/json": {
// schema: bodySchema
// }
// }
// }
// },
// responses: {}
// });
registry.registerPath({
method: "put",
path: "/org/{orgId}/idp/oidc",
description: "Create an OIDC IdP for a specific organization.",
tags: [OpenAPITags.Idp, OpenAPITags.Org],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function createOrgOidcIdp(
req: Request,

View File

@@ -32,9 +32,9 @@ const paramsSchema = z
registry.registerPath({
method: "delete",
path: "/idp/{idpId}",
description: "Delete IDP.",
tags: [OpenAPITags.Idp],
path: "/org/{orgId}/idp/{idpId}",
description: "Delete IDP for a specific organization.",
tags: [OpenAPITags.Idp, OpenAPITags.Org],
request: {
params: paramsSchema
},

View File

@@ -48,16 +48,16 @@ async function query(idpId: number, orgId: string) {
return res;
}
// registry.registerPath({
// method: "get",
// path: "/idp/{idpId}",
// description: "Get an IDP by its IDP ID.",
// tags: [OpenAPITags.Idp],
// request: {
// params: paramsSchema
// },
// responses: {}
// });
registry.registerPath({
method: "get",
path: "/org/:orgId/idp/:idpId",
description: "Get an IDP by its IDP ID for a specific organization.",
tags: [OpenAPITags.Idp, OpenAPITags.Org],
request: {
params: paramsSchema
},
responses: {}
});
export async function getOrgIdp(
req: Request,

View File

@@ -62,16 +62,17 @@ async function query(orgId: string, limit: number, offset: number) {
return res;
}
// registry.registerPath({
// method: "get",
// path: "/idp",
// description: "List all IDP in the system.",
// tags: [OpenAPITags.Idp],
// request: {
// query: querySchema
// },
// responses: {}
// });
registry.registerPath({
method: "get",
path: "/org/{orgId}/idp",
description: "List all IDP for a specific organization.",
tags: [OpenAPITags.Idp, OpenAPITags.Org],
request: {
query: querySchema,
params: paramsSchema
},
responses: {}
});
export async function listOrgIdps(
req: Request,

View File

@@ -53,23 +53,23 @@ export type UpdateOrgIdpResponse = {
idpId: number;
};
// registry.registerPath({
// method: "post",
// path: "/idp/{idpId}/oidc",
// description: "Update an OIDC IdP.",
// tags: [OpenAPITags.Idp],
// request: {
// params: paramsSchema,
// body: {
// content: {
// "application/json": {
// schema: bodySchema
// }
// }
// }
// },
// responses: {}
// });
registry.registerPath({
method: "post",
path: "/org/{orgId}/idp/{idpId}/oidc",
description: "Update an OIDC IdP for a specific organization.",
tags: [OpenAPITags.Idp, OpenAPITags.Org],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function updateOrgOidcIdp(
req: Request,

View File

@@ -10,6 +10,7 @@ import { eq, and, gt } from "drizzle-orm";
import { createSession, generateSessionToken } from "@server/auth/sessions/app";
import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { stripPortFromHost } from "@server/lib/ip";
const paramsSchema = z.object({
code: z.string().min(1, "Code is required")
@@ -27,30 +28,6 @@ export type PollDeviceWebAuthResponse = {
token?: string;
};
// Helper function to extract IP from request (same as in startDeviceWebAuth)
function extractIpFromRequest(req: Request): string | undefined {
const ip = req.ip || req.socket.remoteAddress;
if (!ip) {
return undefined;
}
// Handle IPv6 format [::1] or IPv4 format
if (ip.startsWith("[") && ip.includes("]")) {
const ipv6Match = ip.match(/\[(.*?)\]/);
if (ipv6Match) {
return ipv6Match[1];
}
}
// Handle IPv4 with port (split at last colon)
const lastColonIndex = ip.lastIndexOf(":");
if (lastColonIndex !== -1) {
return ip.substring(0, lastColonIndex);
}
return ip;
}
export async function pollDeviceWebAuth(
req: Request,
res: Response,
@@ -70,7 +47,7 @@ export async function pollDeviceWebAuth(
try {
const { code } = parsedParams.data;
const now = Date.now();
const requestIp = extractIpFromRequest(req);
const requestIp = req.ip ? stripPortFromHost(req.ip) : undefined;
// Hash the code before querying
const hashedCode = hashDeviceCode(code);

View File

@@ -12,6 +12,7 @@ import { TimeSpan } from "oslo";
import { maxmindLookup } from "@server/db/maxmind";
import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { stripPortFromHost } from "@server/lib/ip";
const bodySchema = z
.object({
@@ -39,30 +40,6 @@ function hashDeviceCode(code: string): string {
return encodeHexLowerCase(sha256(new TextEncoder().encode(code)));
}
// Helper function to extract IP from request
function extractIpFromRequest(req: Request): string | undefined {
const ip = req.ip;
if (!ip) {
return undefined;
}
// Handle IPv6 format [::1] or IPv4 format
if (ip.startsWith("[") && ip.includes("]")) {
const ipv6Match = ip.match(/\[(.*?)\]/);
if (ipv6Match) {
return ipv6Match[1];
}
}
// Handle IPv4 with port (split at last colon)
const lastColonIndex = ip.lastIndexOf(":");
if (lastColonIndex !== -1) {
return ip.substring(0, lastColonIndex);
}
return ip;
}
// Helper function to get city from IP (if available)
async function getCityFromIp(ip: string): Promise<string | undefined> {
try {
@@ -112,7 +89,7 @@ export async function startDeviceWebAuth(
const hashedCode = hashDeviceCode(code);
// Extract IP from request
const ip = extractIpFromRequest(req);
const ip = req.ip ? stripPortFromHost(req.ip) : undefined;
// Get city (optional, may return undefined)
const city = ip ? await getCityFromIp(ip) : undefined;

View File

@@ -19,6 +19,7 @@ import {
import { SESSION_COOKIE_EXPIRES as RESOURCE_SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/resource";
import config from "@server/lib/config";
import { response } from "@server/lib/response";
import { stripPortFromHost } from "@server/lib/ip";
const exchangeSessionBodySchema = z.object({
requestToken: z.string(),
@@ -62,26 +63,7 @@ export async function exchangeSession(
cleanHost = cleanHost.slice(0, -1 * matched.length);
}
const clientIp = requestIp
? (() => {
if (requestIp.startsWith("[") && requestIp.includes("]")) {
const ipv6Match = requestIp.match(/\[(.*?)\]/);
if (ipv6Match) {
return ipv6Match[1];
}
}
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}/;
if (ipv4Pattern.test(requestIp)) {
const lastColonIndex = requestIp.lastIndexOf(":");
if (lastColonIndex !== -1) {
return requestIp.substring(0, lastColonIndex);
}
}
return requestIp;
})()
: undefined;
const clientIp = requestIp ? stripPortFromHost(requestIp) : undefined;
const [resource] = await db
.select()

View File

@@ -3,6 +3,7 @@ import logger from "@server/logger";
import { and, eq, lt } from "drizzle-orm";
import cache from "@server/lib/cache";
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
import { stripPortFromHost } from "@server/lib/ip";
/**
@@ -208,26 +209,7 @@ export async function logRequestAudit(
}
const clientIp = body.requestIp
? (() => {
if (
body.requestIp.startsWith("[") &&
body.requestIp.includes("]")
) {
// if brackets are found, extract the IPv6 address from between the brackets
const ipv6Match = body.requestIp.match(/\[(.*?)\]/);
if (ipv6Match) {
return ipv6Match[1];
}
}
// ivp4
// split at last colon
const lastColonIndex = body.requestIp.lastIndexOf(":");
if (lastColonIndex !== -1) {
return body.requestIp.substring(0, lastColonIndex);
}
return body.requestIp;
})()
? stripPortFromHost(body.requestIp)
: undefined;
// Add to buffer instead of writing directly to DB

View File

@@ -21,7 +21,7 @@ import {
resourceSessions
} from "@server/db";
import config from "@server/lib/config";
import { isIpInCidr } from "@server/lib/ip";
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
import { response } from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
@@ -110,37 +110,7 @@ export async function verifyResourceSession(
const clientHeaderAuth = extractBasicAuth(headers);
const clientIp = requestIp
? (() => {
const isNewerBadger =
badgerVersion &&
semver.valid(badgerVersion) &&
semver.gte(badgerVersion, "1.3.1");
if (isNewerBadger) {
return requestIp;
}
if (requestIp.startsWith("[") && requestIp.includes("]")) {
// if brackets are found, extract the IPv6 address from between the brackets
const ipv6Match = requestIp.match(/\[(.*?)\]/);
if (ipv6Match) {
return ipv6Match[1];
}
}
// Check if it looks like IPv4 (contains dots and matches IPv4 pattern)
// IPv4 format: x.x.x.x where x is 0-255
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}/;
if (ipv4Pattern.test(requestIp)) {
const lastColonIndex = requestIp.lastIndexOf(":");
if (lastColonIndex !== -1) {
return requestIp.substring(0, lastColonIndex);
}
}
// Return as is
return requestIp;
})()
? stripPortFromHost(requestIp, badgerVersion)
: undefined;
logger.debug("Client IP:", { clientIp });

View File

@@ -36,7 +36,7 @@ async function query(clientId?: number, niceId?: string, orgId?: string) {
.select()
.from(clients)
.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);
return res;
}

View File

@@ -1,7 +1,7 @@
import { db } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { clients, Newt } from "@server/db";
import { eq } from "drizzle-orm";
import { clients } from "@server/db";
import { eq, sql } from "drizzle-orm";
import logger from "@server/logger";
interface PeerBandwidth {
@@ -10,13 +10,57 @@ interface PeerBandwidth {
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 (
context
) => {
const { message, client, sendToClient } = context;
const { message } = context;
if (!message.data.bandwidthData) {
logger.warn("No bandwidth data provided");
return;
}
const bandwidthData: PeerBandwidth[] = message.data.bandwidthData;
@@ -25,30 +69,40 @@ export const handleReceiveBandwidthMessage: MessageHandler = async (
throw new Error("Invalid bandwidth data");
}
await db.transaction(async (trx) => {
for (const peer of bandwidthData) {
const { publicKey, bytesIn, bytesOut } = peer;
// Sort bandwidth data by publicKey to ensure consistent lock ordering across all instances
// This is critical for preventing deadlocks when multiple instances update the same clients
const sortedBandwidthData = [...bandwidthData].sort((a, b) =>
a.publicKey.localeCompare(b.publicKey)
);
// Find the client by public key
const [client] = await trx
.select()
.from(clients)
.where(eq(clients.pubKey, publicKey))
.limit(1);
const currentTime = new Date().toISOString();
if (!client) {
continue;
}
// Update each client individually with retry logic
// 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
await trx
.update(clients)
.set({
megabytesOut: (client.megabytesIn || 0) + bytesIn,
megabytesIn: (client.megabytesOut || 0) + bytesOut,
lastBandwidthUpdate: new Date().toISOString()
})
.where(eq(clients.clientId, client.clientId));
try {
await withDeadlockRetry(async () => {
// Use atomic SQL increment to avoid SELECT then UPDATE pattern
// This eliminates the need to read the current value first
await db
.update(clients)
.set({
// Note: bytesIn from peer goes to megabytesOut (data sent to client)
// 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
}
});
}
};

View File

@@ -285,7 +285,7 @@ export default function Page() {
<Button
variant="outline"
onClick={() => {
router.push("/admin/idp");
router.push(`/${params.orgId}/settings/idp`);
}}
>
{t("idpSeeAll")}

View File

@@ -0,0 +1,18 @@
import { pullEnv } from "@app/lib/pullEnv";
import { build } from "@server/build";
import { redirect } from "next/navigation";
interface LayoutProps {
children: React.ReactNode;
params: Promise<{}>;
}
export default async function Layout(props: LayoutProps) {
const env = pullEnv();
if (build !== "saas" && !env.flags.useOrgOnlyIdp) {
redirect("/");
}
return props.children;
}

View File

@@ -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 { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import IdpTable, { IdpRow } from "@app/components/private/OrgIdpTable";
import { getTranslations } from "next-intl/server";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { cache } from "react";
import {
GetOrgSubscriptionResponse,
GetOrgTierResponse
} from "@server/routers/billing/types";
import { TierId } from "@server/lib/billing/tiers";
import { build } from "@server/build";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
type OrgIdpPageProps = {
params: Promise<{ orgId: string }>;
@@ -35,21 +28,6 @@ export default async function OrgIdpPage(props: OrgIdpPageProps) {
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 (
<>
<SettingsSectionTitle
@@ -57,13 +35,7 @@ export default async function OrgIdpPage(props: OrgIdpPageProps) {
description={t("idpManageDescription")}
/>
{build === "saas" && !subscribed ? (
<Alert variant="info" className="mb-6">
<AlertDescription>
{t("idpDisabled")} {t("subscriptionRequiredToUse")}
</AlertDescription>
</Alert>
) : null}
<PaidFeaturesAlert />
<IdpTable idps={idps} orgId={params.orgId} />
</>

View File

@@ -82,7 +82,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
<Layout
orgId={params.orgId}
orgs={orgs}
navItems={orgNavSections()}
navItems={orgNavSections(env)}
>
{children}
</Layout>

View File

@@ -36,8 +36,8 @@ import {
import type { ResourceContextType } from "@app/contexts/resourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { orgQueries, resourceQueries } from "@app/lib/queries";
@@ -95,7 +95,7 @@ export default function ResourceAuthenticationPage() {
const router = useRouter();
const t = useTranslations();
const subscription = useSubscriptionStatusContext();
const { isPaidUser } = usePaidStatus();
const queryClient = useQueryClient();
const { data: resourceRoles = [], isLoading: isLoadingResourceRoles } =
@@ -129,7 +129,8 @@ export default function ResourceAuthenticationPage() {
);
const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery(
orgQueries.identityProviders({
orgId: org.org.orgId
orgId: org.org.orgId,
useOrgOnlyIdp: env.flags.useOrgOnlyIdp
})
);
@@ -159,7 +160,7 @@ export default function ResourceAuthenticationPage() {
const allIdps = useMemo(() => {
if (build === "saas") {
if (subscription?.subscribed) {
if (isPaidUser) {
return orgIdps.map((idp) => ({
id: idp.idpId,
text: idp.name

View File

@@ -164,6 +164,10 @@ function MaintenanceSectionForm({
return isEnterpriseNotLicensed || isSaasNotSubscribed;
};
if (!resource.http) {
return null;
}
return (
<SettingsSection>
<SettingsSectionHeader>
@@ -437,9 +441,16 @@ export default function GeneralForm() {
);
const resourceFullDomainName = useMemo(() => {
const url = new URL(resourceFullDomain);
return url.hostname;
}, [resourceFullDomain]);
if (!resource.fullDomain) {
return "";
}
try {
const url = new URL(resourceFullDomain);
return url.hostname;
} catch {
return "";
}
}, [resourceFullDomain, resource.fullDomain]);
const [selectedDomain, setSelectedDomain] = useState<{
domainId: string;

View File

@@ -338,7 +338,7 @@ function ProxyResourceTargetsForm({
<div
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)}
</div>
</Button>

View File

@@ -11,6 +11,7 @@ import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/lib/api/cookies";
import { Layout } from "@app/components/Layout";
import { adminNavSections } from "../navigation";
import { pullEnv } from "@app/lib/pullEnv";
export const dynamic = "force-dynamic";
@@ -27,6 +28,8 @@ export default async function AdminLayout(props: LayoutProps) {
const getUser = cache(verifySession);
const user = await getUser();
const env = pullEnv();
if (!user || !user.serverAdmin) {
redirect(`/`);
}
@@ -48,7 +51,7 @@ export default async function AdminLayout(props: LayoutProps) {
return (
<UserProvider user={user}>
<Layout orgs={orgs} navItems={adminNavSections}>
<Layout orgs={orgs} navItems={adminNavSections(env)}>
{props.children}
</Layout>
</UserProvider>

View File

@@ -70,7 +70,7 @@ export default async function Page(props: {
}
let loginIdps: LoginFormIDP[] = [];
if (build !== "saas") {
if (build === "oss" || !env.flags.useOrgOnlyIdp) {
const idpsRes = await cache(
async () => await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
)();
@@ -121,7 +121,7 @@ export default async function Page(props: {
</p>
)}
{!isInvite && build === "saas" ? (
{!isInvite && (build === "saas" || env.flags.useOrgOnlyIdp) ? (
<div className="text-center text-muted-foreground mt-12 flex flex-col items-center">
<span>{t("needToSignInToOrg")}</span>
<Link

View File

@@ -11,6 +11,7 @@ import {
} from "@server/routers/loginPage/types";
import { redirect } from "next/navigation";
import OrgLoginPage from "@app/components/OrgLoginPage";
import { pullEnv } from "@app/lib/pullEnv";
export const dynamic = "force-dynamic";
@@ -21,7 +22,9 @@ export default async function OrgAuthPage(props: {
const searchParams = await props.searchParams;
const params = await props.params;
if (build !== "saas") {
const env = pullEnv();
if (build !== "saas" && !env.flags.useOrgOnlyIdp) {
const queryString = new URLSearchParams(searchParams as any).toString();
redirect(`/auth/login${queryString ? `?${queryString}` : ""}`);
}
@@ -50,29 +53,25 @@ export default async function OrgAuthPage(props: {
} catch (e) {}
let loginIdps: LoginFormIDP[] = [];
if (build === "saas") {
const idpsRes = await priv.get<AxiosResponse<ListOrgIdpsResponse>>(
`/org/${orgId}/idp`
);
const idpsRes = await priv.get<AxiosResponse<ListOrgIdpsResponse>>(
`/org/${orgId}/idp`
);
loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId,
name: idp.name,
variant: idp.variant
})) as LoginFormIDP[];
}
loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId,
name: idp.name,
variant: idp.variant
})) as LoginFormIDP[];
let branding: LoadLoginPageBrandingResponse | null = null;
if (build === "saas") {
try {
const res = await priv.get<
AxiosResponse<LoadLoginPageBrandingResponse>
>(`/login-page-branding?orgId=${orgId}`);
if (res.status === 200) {
branding = res.data.data;
}
} catch (error) {}
}
try {
const res = await priv.get<
AxiosResponse<LoadLoginPageBrandingResponse>
>(`/login-page-branding?orgId=${orgId}`);
if (res.status === 200) {
branding = res.data.data;
}
} catch (error) {}
return (
<OrgLoginPage

View File

@@ -33,12 +33,12 @@ export default async function OrgAuthPage(props: {
const forceLoginParam = searchParams.forceLogin;
const forceLogin = forceLoginParam === "true";
if (build !== "saas") {
const env = pullEnv();
if (build !== "saas" && !env.flags.useOrgOnlyIdp) {
redirect("/");
}
const env = pullEnv();
const authHeader = await authCookieHeader();
if (searchParams.token) {

View File

@@ -204,7 +204,7 @@ export default async function ResourceAuthPage(props: {
}
let loginIdps: LoginFormIDP[] = [];
if (build === "saas") {
if (build === "saas" || env.flags.useOrgOnlyIdp) {
if (subscribed) {
const idpsRes = await cache(
async () =>

View File

@@ -162,3 +162,32 @@ p {
#nprogress .bar {
background: var(--color-primary) !important;
}
@keyframes dot-pulse {
0%, 80%, 100% {
opacity: 0.3;
transform: scale(0.8);
}
40% {
opacity: 1;
transform: scale(1);
}
}
@layer utilities {
.animate-dot-pulse {
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 */
}
}
}

View File

@@ -22,6 +22,7 @@ import { TopLoader } from "@app/components/Toploader";
import Script from "next/script";
import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
import { TailwindIndicator } from "@app/components/TailwindIndicator";
import { ViewportHeightFix } from "@app/components/ViewportHeightFix";
export const metadata: Metadata = {
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
@@ -77,7 +78,7 @@ export default async function RootLayout({
return (
<html suppressHydrationWarning lang={locale}>
<body className={`${font.className} h-screen overflow-hidden`}>
<body className={`${font.className} h-screen-safe overflow-hidden`}>
<TopLoader />
{build === "saas" && (
<Script
@@ -86,6 +87,7 @@ export default async function RootLayout({
strategy="afterInteractive"
/>
)}
<ViewportHeightFix />
<NextIntlClientProvider>
<ThemeProvider
attribute="class"

View File

@@ -1,4 +1,5 @@
import { SidebarNavItem } from "@app/components/SidebarNav";
import { Env } from "@app/lib/types/env";
import { build } from "@server/build";
import {
Settings,
@@ -39,7 +40,7 @@ export const orgLangingNavItems: SidebarNavItem[] = [
}
];
export const orgNavSections = (): SidebarNavSection[] => [
export const orgNavSections = (env?: Env): SidebarNavSection[] => [
{
heading: "sidebarGeneral",
items: [
@@ -92,8 +93,7 @@ export const orgNavSections = (): SidebarNavSection[] => [
{
title: "sidebarRemoteExitNodes",
href: "/{orgId}/settings/remote-exit-nodes",
icon: <Server className="size-4 flex-none" />,
showEE: true
icon: <Server className="size-4 flex-none" />
}
]
: [])
@@ -123,13 +123,12 @@ export const orgNavSections = (): SidebarNavSection[] => [
href: "/{orgId}/settings/access/roles",
icon: <Users className="size-4 flex-none" />
},
...(build == "saas"
...(build == "saas" || env?.flags.useOrgOnlyIdp
? [
{
title: "sidebarIdentityProviders",
href: "/{orgId}/settings/idp",
icon: <Fingerprint className="size-4 flex-none" />,
showEE: true
icon: <Fingerprint className="size-4 flex-none" />
}
]
: []),
@@ -228,7 +227,7 @@ export const orgNavSections = (): SidebarNavSection[] => [
}
];
export const adminNavSections: SidebarNavSection[] = [
export const adminNavSections = (env?: Env): SidebarNavSection[] => [
{
heading: "sidebarAdmin",
items: [
@@ -242,11 +241,15 @@ export const adminNavSections: SidebarNavSection[] = [
href: "/admin/api-keys",
icon: <KeyRound className="size-4 flex-none" />
},
{
title: "sidebarIdentityProviders",
href: "/admin/idp",
icon: <Fingerprint className="size-4 flex-none" />
},
...(build === "oss" || !env?.flags.useOrgOnlyIdp
? [
{
title: "sidebarIdentityProviders",
href: "/admin/idp",
icon: <Fingerprint className="size-4 flex-none" />
}
]
: []),
...(build == "enterprise"
? [
{

View File

@@ -118,6 +118,7 @@ export default function AuthPageBrandingForm({
const brandingData = form.getValues();
if (!isValid || !isPaidUser) return;
try {
const updateRes = await api.put(
`/org/${orgId}/login-page-branding`,
@@ -289,7 +290,8 @@ export default function AuthPageBrandingForm({
</div>
</div>
{build === "saas" && (
{build === "saas" ||
env.env.flags.useOrgOnlyIdp ? (
<>
<div className="mt-3 mb-6">
<SettingsSectionTitle>
@@ -343,7 +345,7 @@ export default function AuthPageBrandingForm({
/>
</div>
</>
)}
) : null}
<div className="mt-3 mb-6">
<SettingsSectionTitle>

View File

@@ -1,9 +1,10 @@
"use client";
import React, { useState, useEffect, type ReactNode } from "react";
import React, { useState, useEffect, type ReactNode, useEffectEvent } from "react";
import { Card, CardContent } from "@app/components/ui/card";
import { X } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEnvContext } from "@app/hooks/useEnvContext";
type DismissableBannerProps = {
storageKey: string;
@@ -25,6 +26,12 @@ export const DismissableBanner = ({
const [isDismissed, setIsDismissed] = useState(true);
const t = useTranslations();
const { env } = useEnvContext();
if (env.flags.disableProductHelpBanners) {
return null;
}
useEffect(() => {
const dismissedData = localStorage.getItem(storageKey);
if (dismissedData) {

View File

@@ -37,7 +37,7 @@ export async function Layout({
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
return (
<div className="flex h-screen overflow-hidden">
<div className="flex h-screen-safe overflow-hidden">
{/* Desktop Sidebar */}
{showSidebar && (
<LayoutSidebar

View File

@@ -48,7 +48,7 @@ export function LayoutMobileMenu({
const t = useTranslations();
return (
<div className="shrink-0 md:hidden">
<div className="shrink-0 md:hidden sticky top-0 z-50">
<div className="h-16 flex items-center px-2">
<div className="flex items-center gap-4">
{showSidebar && (
@@ -72,7 +72,7 @@ export function LayoutMobileMenu({
<SheetDescription className="sr-only">
{t("navbarDescription")}
</SheetDescription>
<div className="flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto relative">
<div className="px-3">
<OrgSelector
orgId={orgId}
@@ -83,7 +83,7 @@ export function LayoutMobileMenu({
<div className="px-3">
{!isAdminPage &&
user.serverAdmin && (
<div className="pb-3">
<div className="py-2">
<Link
href="/admin"
className={cn(
@@ -113,6 +113,7 @@ export function LayoutMobileMenu({
}
/>
</div>
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
</div>
<div className="px-3 pt-3 pb-3 space-y-4 border-t shrink-0">
<SupporterStatus />

View File

@@ -114,6 +114,16 @@ function getActionsCategories(root: boolean) {
}
};
if (root || build === "saas" || env.flags.useOrgOnlyIdp) {
actionsByCategory["Identity Provider (IDP)"] = {
[t("actionCreateIdp")]: "createIdp",
[t("actionUpdateIdp")]: "updateIdp",
[t("actionDeleteIdp")]: "deleteIdp",
[t("actionListIdps")]: "listIdps",
[t("actionGetIdp")]: "getIdp"
};
}
if (root) {
actionsByCategory["Organization"] = {
[t("actionListOrgs")]: "listOrgs",
@@ -128,24 +138,21 @@ function getActionsCategories(root: boolean) {
...actionsByCategory["Organization"]
};
actionsByCategory["Identity Provider (IDP)"] = {
[t("actionCreateIdp")]: "createIdp",
[t("actionUpdateIdp")]: "updateIdp",
[t("actionDeleteIdp")]: "deleteIdp",
[t("actionListIdps")]: "listIdps",
[t("actionGetIdp")]: "getIdp",
[t("actionCreateIdpOrg")]: "createIdpOrg",
[t("actionDeleteIdpOrg")]: "deleteIdpOrg",
[t("actionListIdpOrgs")]: "listIdpOrgs",
[t("actionUpdateIdpOrg")]: "updateIdpOrg"
};
actionsByCategory["Identity Provider (IDP)"][t("actionCreateIdpOrg")] =
"createIdpOrg";
actionsByCategory["Identity Provider (IDP)"][t("actionDeleteIdpOrg")] =
"deleteIdpOrg";
actionsByCategory["Identity Provider (IDP)"][t("actionListIdpOrgs")] =
"listIdpOrgs";
actionsByCategory["Identity Provider (IDP)"][t("actionUpdateIdpOrg")] =
"updateIdpOrg";
actionsByCategory["User"] = {
[t("actionUpdateUser")]: "updateUser",
[t("actionGetUser")]: "getUser"
};
if (build == "saas") {
if (build === "saas") {
actionsByCategory["SAAS"] = {
["Send Usage Notification Email"]: "sendUsageNotification"
};

View File

@@ -198,7 +198,7 @@ export default function ProxyResourcesTable({
if (!targets || targets.length === 0) {
return (
<div className="flex items-center gap-2">
<div id="LOOK_FOR_ME" className="flex items-center gap-2">
<StatusIcon status="unknown" />
<span className="text-sm">
{t("resourcesTableNoTargets")}

View File

@@ -32,12 +32,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<InfoSections
cols={resource.http && env.flags.usePangolinDns ? 5 : 4}
>
<InfoSection>
<InfoSectionTitle>URL</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard text={fullUrl} isLink={true} />
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
<InfoSectionContent>
@@ -46,6 +40,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSection>
{resource.http ? (
<>
<InfoSection>
<InfoSectionTitle>URL</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard text={fullUrl} isLink={true} />
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("authentication")}

View File

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

View File

@@ -27,6 +27,8 @@ export function IdpDataTable<TData, TValue>({
searchColumn="name"
addButtonText={t("idpAdd")}
onAdd={onAdd}
enableColumnVisibility={true}
stickyRightColumn="actions"
/>
);
}

View File

@@ -118,6 +118,7 @@ export default function IdpTable({ idps, orgId }: Props) {
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3">{t("actions")}</span>,
cell: ({ row }) => {
const siteRow = row.original;

View File

@@ -3,7 +3,6 @@ import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@app/lib/cn";
import { Loader2 } from "lucide-react";
const buttonVariants = cva(
"cursor-pointer inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50",
@@ -74,13 +73,30 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
>
{asChild ? (
props.children
) : loading ? (
<span className="relative inline-flex items-center justify-center">
<span className="inline-flex items-center justify-center opacity-0">
{props.children}
</span>
<span className="absolute inset-0 flex items-center justify-center">
<span className="flex items-center gap-1.5">
<span
className="h-1 w-1 bg-current animate-dot-pulse"
style={{ animationDelay: "0ms" }}
/>
<span
className="h-1 w-1 bg-current animate-dot-pulse"
style={{ animationDelay: "200ms" }}
/>
<span
className="h-1 w-1 bg-current animate-dot-pulse"
style={{ animationDelay: "400ms" }}
/>
</span>
</span>
</span>
) : (
<>
{loading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{props.children}
</>
props.children
)}
</Comp>
);

View File

@@ -14,13 +14,13 @@ const checkboxVariants = cva(
variants: {
variant: {
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:
"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:
"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:
"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: {
@@ -30,8 +30,7 @@ const checkboxVariants = cva(
);
interface CheckboxProps
extends
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>,
extends React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>,
VariantProps<typeof checkboxVariants> {}
const Checkbox = React.forwardRef<
@@ -50,9 +49,8 @@ const Checkbox = React.forwardRef<
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
interface CheckboxWithLabelProps extends React.ComponentPropsWithoutRef<
typeof Checkbox
> {
interface CheckboxWithLabelProps
extends React.ComponentPropsWithoutRef<typeof Checkbox> {
label: string;
}

View File

@@ -59,7 +59,13 @@ export function pullEnv(): Env {
hideSupporterKey:
process.env.HIDE_SUPPORTER_KEY === "true" ? true : false,
usePangolinDns:
process.env.USE_PANGOLIN_DNS === "true" ? true : false
process.env.USE_PANGOLIN_DNS === "true" ? true : false,
disableProductHelpBanners:
process.env.FLAGS_DISABLE_PRODUCT_HELP_BANNERS === "true"
? true
: false,
useOrgOnlyIdp:
process.env.USE_ORG_ONLY_IDP === "true" ? true : false
},
branding: {

View File

@@ -157,7 +157,13 @@ export const orgQueries = {
return res.data.data.domains;
}
}),
identityProviders: ({ orgId }: { orgId: string }) =>
identityProviders: ({
orgId,
useOrgOnlyIdp
}: {
orgId: string;
useOrgOnlyIdp?: boolean;
}) =>
queryOptions({
queryKey: ["ORG", orgId, "IDPS"] as const,
queryFn: async ({ signal, meta }) => {
@@ -165,7 +171,12 @@ export const orgQueries = {
AxiosResponse<{
idps: { idpId: number; name: string }[];
}>
>(build === "saas" ? `/org/${orgId}/idp` : "/idp", { signal });
>(
build === "saas" || useOrgOnlyIdp
? `/org/${orgId}/idp`
: "/idp",
{ signal }
);
return res.data.data.idps;
}
})

View File

@@ -33,6 +33,8 @@ export type Env = {
disableBasicWireguardSites: boolean;
hideSupporterKey: boolean;
usePangolinDns: boolean;
disableProductHelpBanners: boolean;
useOrgOnlyIdp: boolean;
};
branding: {
appName?: string;