mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-27 07:16:40 +00:00
Compare commits
26 Commits
1.15.4-s.5
...
multi-role
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20e547a0f6 | ||
|
|
848d4d91e6 | ||
|
|
06258aa386 | ||
|
|
d7608b1cc8 | ||
|
|
cb86ad4104 | ||
|
|
8cd51df1e1 | ||
|
|
8ef7220766 | ||
|
|
b5333a3686 | ||
|
|
e6e92dbc0f | ||
|
|
01fdd41a10 | ||
|
|
6af06a38ae | ||
|
|
5d9c66d22d | ||
|
|
81f5a4b127 | ||
|
|
da3e68a20b | ||
|
|
8712c1719e | ||
|
|
593c5db0e8 | ||
|
|
b28391feae | ||
|
|
5f8df6d4cd | ||
|
|
c36efe7f14 | ||
|
|
e17ec798d4 | ||
|
|
58ac499f30 | ||
|
|
f07f0092ad | ||
|
|
7c04526088 | ||
|
|
2d7ab68576 | ||
|
|
218a4893b6 | ||
|
|
266bf261aa |
@@ -1572,6 +1572,16 @@
|
||||
"billingFeatureLossWarning": "Уведомление за наличност на функциите",
|
||||
"billingFeatureLossDescription": "Чрез понижението на плана, функциите, недостъпни в новия план, ще бъдат автоматично деактивирани. Някои настройки и конфигурации може да бъдат загубени. Моля, прегледайте ценовата матрица, за да разберете кои функции вече няма да са на разположение.",
|
||||
"billingUsageExceedsLimit": "Текущото използване ({current}) надвишава ограничението ({limit})",
|
||||
"billingPastDueTitle": "Плащането е просрочено",
|
||||
"billingPastDueDescription": "Вашето плащане е просрочено. Моля, актуализирайте метода на плащане, за да продължите да използвате настоящия си план. Ако проблемът не бъде разрешен, абонаментът ви ще бъде прекратен и ще бъдете прехвърлени на безплатния план.",
|
||||
"billingUnpaidTitle": "Абонаментът не е платен",
|
||||
"billingUnpaidDescription": "Вашият абонамент не е платен и сте прехвърлени на безплатния план. Моля, актуализирайте метода на плащане, за да възстановите вашия абонамент.",
|
||||
"billingIncompleteTitle": "Плащането е непълно",
|
||||
"billingIncompleteDescription": "Вашето плащане е непълно. Моля, завършете процеса на плащане, за да активирате вашия абонамент.",
|
||||
"billingIncompleteExpiredTitle": "Плащането е изтекло",
|
||||
"billingIncompleteExpiredDescription": "Вашето плащане никога не е завършено и е изтекло. Прехвърлени сте на безплатния план. Моля, абонирайте се отново, за да възстановите достъпа до платените функции.",
|
||||
"billingManageSubscription": "Управлявайте вашия абонамент",
|
||||
"billingResolvePaymentIssue": "Моля, разрешете проблема с плащането преди да извършите надграждане или понижение",
|
||||
"signUpTerms": {
|
||||
"IAgreeToThe": "Съгласен съм с",
|
||||
"termsOfService": "условията за ползване",
|
||||
|
||||
@@ -336,7 +336,7 @@
|
||||
"userQuestionRemove": "Jste si jisti, že chcete trvale odstranit uživatele ze serveru?",
|
||||
"licenseKey": "Licenční klíč",
|
||||
"valid": "Valid",
|
||||
"numberOfSites": "Počet stránek",
|
||||
"numberOfSites": "Počet lokalit",
|
||||
"licenseKeySearch": "Hledat licenční klíče...",
|
||||
"licenseKeyAdd": "Přidat licenční klíč",
|
||||
"type": "Typ",
|
||||
@@ -1572,6 +1572,16 @@
|
||||
"billingFeatureLossWarning": "Upozornění na dostupnost funkce",
|
||||
"billingFeatureLossDescription": "Po pomenutí budou funkce v novém plánu automaticky zakázány. Některá nastavení a konfigurace mohou být ztraceny. Zkontrolujte cenovou matrici, abyste pochopili, které funkce již nebudou k dispozici.",
|
||||
"billingUsageExceedsLimit": "Aktuální využití ({current}) překračuje limit ({limit})",
|
||||
"billingPastDueTitle": "Poslední splatnost platby",
|
||||
"billingPastDueDescription": "Vaše platba je již splatná. Chcete-li pokračovat v používání aktuálních tarifů, aktualizujte prosím způsob platby. Pokud nebude vyřešeno, Vaše předplatné bude zrušeno a budete vráceno na úroveň zdarma.",
|
||||
"billingUnpaidTitle": "Předplatné nezaplaceno",
|
||||
"billingUnpaidDescription": "Vaše předplatné není zaplaceno a byli jste vráceni do bezplatné úrovně. Aktualizujte prosím svou platební metodu pro obnovení předplatného.",
|
||||
"billingIncompleteTitle": "Platba nedokončena",
|
||||
"billingIncompleteDescription": "Vaše platba je neúplná. Pro aktivaci předplatného prosím dokončete platební proces.",
|
||||
"billingIncompleteExpiredTitle": "Platba vypršela",
|
||||
"billingIncompleteExpiredDescription": "Vaše platba nebyla nikdy dokončena a vypršela. Byli jste vráceni na úroveň zdarma. Prosím, přihlašte se znovu pro obnovení přístupu k placeným funkcím.",
|
||||
"billingManageSubscription": "Spravujte své předplatné",
|
||||
"billingResolvePaymentIssue": "Vyřešte prosím problém s platbou před upgradem nebo upgradem",
|
||||
"signUpTerms": {
|
||||
"IAgreeToThe": "Souhlasím s",
|
||||
"termsOfService": "podmínky služby",
|
||||
@@ -2382,7 +2392,7 @@
|
||||
"terms": "Výrazy",
|
||||
"privacy": "Soukromí",
|
||||
"security": "Zabezpečení",
|
||||
"docs": "Dokumenty",
|
||||
"docs": "Dokumentace",
|
||||
"deviceActivation": "Aktivace zařízení",
|
||||
"deviceCodeInvalidFormat": "Kód musí být 9 znaků (např. A1AJ-N5JD)",
|
||||
"deviceCodeInvalidOrExpired": "Neplatný nebo prošlý kód",
|
||||
|
||||
@@ -1572,6 +1572,16 @@
|
||||
"billingFeatureLossWarning": "Verfügbarkeitshinweis",
|
||||
"billingFeatureLossDescription": "Durch Herabstufung werden Funktionen, die im neuen Paket nicht verfügbar sind, automatisch deaktiviert. Einige Einstellungen und Konfigurationen können verloren gehen. Bitte überprüfen Sie die Preismatrix um zu verstehen, welche Funktionen nicht mehr verfügbar sein werden.",
|
||||
"billingUsageExceedsLimit": "Aktuelle Nutzung ({current}) überschreitet das Limit ({limit})",
|
||||
"billingPastDueTitle": "Zahlung vergangene Fälligkeit",
|
||||
"billingPastDueDescription": "Ihre Zahlung ist abgelaufen. Bitte aktualisieren Sie Ihre Zahlungsmethode, um die aktuellen Funktionen Ihres Pakets weiter zu nutzen. Wenn nicht geklärt, wird Ihr Abonnement abgebrochen und Sie werden auf die kostenlose Stufe zurückgekehrt.",
|
||||
"billingUnpaidTitle": "Unbezahltes Abonnement",
|
||||
"billingUnpaidDescription": "Dein Abonnement ist unbezahlt und du wurdest auf die kostenlose Stufe zurückgekehrt. Bitte aktualisiere deine Zahlungsmethode, um dein Abonnement wiederherzustellen.",
|
||||
"billingIncompleteTitle": "Zahlung unvollständig",
|
||||
"billingIncompleteDescription": "Ihre Zahlung ist unvollständig. Bitte schließen Sie den Zahlungsvorgang ab, um Ihr Abonnement zu aktivieren.",
|
||||
"billingIncompleteExpiredTitle": "Zahlung abgelaufen",
|
||||
"billingIncompleteExpiredDescription": "Deine Zahlung wurde nie abgeschlossen und ist abgelaufen. Du wurdest zur kostenlosen Stufe zurückgekehrt. Bitte melde dich erneut an, um den Zugriff auf kostenpflichtige Funktionen wiederherzustellen.",
|
||||
"billingManageSubscription": "Verwalten Sie Ihr Abonnement",
|
||||
"billingResolvePaymentIssue": "Bitte beheben Sie Ihr Zahlungsproblem vor dem Upgrade oder Herabstufen",
|
||||
"signUpTerms": {
|
||||
"IAgreeToThe": "Ich stimme den",
|
||||
"termsOfService": "Nutzungsbedingungen zu",
|
||||
|
||||
@@ -1572,6 +1572,16 @@
|
||||
"billingFeatureLossWarning": "Feature Availability Notice",
|
||||
"billingFeatureLossDescription": "By downgrading, features not available in the new plan will be automatically disabled. Some settings and configurations may be lost. Please review the pricing matrix to understand which features will no longer be available.",
|
||||
"billingUsageExceedsLimit": "Current usage ({current}) exceeds limit ({limit})",
|
||||
"billingPastDueTitle": "Payment Past Due",
|
||||
"billingPastDueDescription": "Your payment is past due. Please update your payment method to continue using your current plan features. If not resolved, your subscription will be canceled and you'll be reverted to the free tier.",
|
||||
"billingUnpaidTitle": "Subscription Unpaid",
|
||||
"billingUnpaidDescription": "Your subscription is unpaid and you have been reverted to the free tier. Please update your payment method to restore your subscription.",
|
||||
"billingIncompleteTitle": "Payment Incomplete",
|
||||
"billingIncompleteDescription": "Your payment is incomplete. Please complete the payment process to activate your subscription.",
|
||||
"billingIncompleteExpiredTitle": "Payment Expired",
|
||||
"billingIncompleteExpiredDescription": "Your payment was never completed and has expired. You have been reverted to the free tier. Please subscribe again to restore access to paid features.",
|
||||
"billingManageSubscription": "Manage your subscription",
|
||||
"billingResolvePaymentIssue": "Please resolve your payment issue before upgrading or downgrading",
|
||||
"signUpTerms": {
|
||||
"IAgreeToThe": "I agree to the",
|
||||
"termsOfService": "terms of service",
|
||||
|
||||
@@ -1572,6 +1572,16 @@
|
||||
"billingFeatureLossWarning": "Aviso de disponibilidad de funcionalidad",
|
||||
"billingFeatureLossDescription": "Al degradar, las características no disponibles en el nuevo plan se desactivarán automáticamente. Algunas configuraciones y configuraciones pueden perderse. Por favor, revise la matriz de precios para entender qué características ya no estarán disponibles.",
|
||||
"billingUsageExceedsLimit": "El uso actual ({current}) supera el límite ({limit})",
|
||||
"billingPastDueTitle": "Pago vencido",
|
||||
"billingPastDueDescription": "Su pago ha vencido. Por favor, actualice su método de pago para seguir utilizando las características actuales de su plan. Si no se resuelve, tu suscripción se cancelará y serás revertido al nivel gratuito.",
|
||||
"billingUnpaidTitle": "Suscripción no pagada",
|
||||
"billingUnpaidDescription": "Tu suscripción no está pagada y has sido revertido al nivel gratuito. Por favor, actualiza tu método de pago para restaurar tu suscripción.",
|
||||
"billingIncompleteTitle": "Pago incompleto",
|
||||
"billingIncompleteDescription": "Su pago está incompleto. Por favor, complete el proceso de pago para activar su suscripción.",
|
||||
"billingIncompleteExpiredTitle": "Pago expirado",
|
||||
"billingIncompleteExpiredDescription": "Tu pago nunca se completó y ha expirado. Has sido revertido al nivel gratuito. Suscríbete de nuevo para restaurar el acceso a las funciones de pago.",
|
||||
"billingManageSubscription": "Administra tu suscripción",
|
||||
"billingResolvePaymentIssue": "Por favor resuelva su problema de pago antes de actualizar o bajar de calificación",
|
||||
"signUpTerms": {
|
||||
"IAgreeToThe": "Estoy de acuerdo con los",
|
||||
"termsOfService": "términos del servicio",
|
||||
|
||||
@@ -1572,6 +1572,16 @@
|
||||
"billingFeatureLossWarning": "Avis de disponibilité des fonctionnalités",
|
||||
"billingFeatureLossDescription": "En rétrogradant, les fonctionnalités non disponibles dans le nouveau plan seront automatiquement désactivées. Certains paramètres et configurations peuvent être perdus. Veuillez consulter la matrice de prix pour comprendre quelles fonctionnalités ne seront plus disponibles.",
|
||||
"billingUsageExceedsLimit": "L'utilisation actuelle ({current}) dépasse la limite ({limit})",
|
||||
"billingPastDueTitle": "Paiement en retard",
|
||||
"billingPastDueDescription": "Votre paiement est échu. Veuillez mettre à jour votre méthode de paiement pour continuer à utiliser les fonctionnalités de votre plan actuel. Si non résolu, votre abonnement sera annulé et vous serez remis au niveau gratuit.",
|
||||
"billingUnpaidTitle": "Abonnement impayé",
|
||||
"billingUnpaidDescription": "Votre abonnement est impayé et vous avez été reversé au niveau gratuit. Veuillez mettre à jour votre méthode de paiement pour restaurer votre abonnement.",
|
||||
"billingIncompleteTitle": "Paiement incomplet",
|
||||
"billingIncompleteDescription": "Votre paiement est incomplet. Veuillez compléter le processus de paiement pour activer votre abonnement.",
|
||||
"billingIncompleteExpiredTitle": "Paiement expiré",
|
||||
"billingIncompleteExpiredDescription": "Votre paiement n'a jamais été complété et a expiré. Vous avez été restauré au niveau gratuit. Veuillez vous abonner à nouveau pour restaurer l'accès aux fonctionnalités payantes.",
|
||||
"billingManageSubscription": "Gérer votre abonnement",
|
||||
"billingResolvePaymentIssue": "Veuillez résoudre votre problème de paiement avant de procéder à la mise à niveau ou à la rétrogradation",
|
||||
"signUpTerms": {
|
||||
"IAgreeToThe": "Je suis d'accord avec",
|
||||
"termsOfService": "les conditions d'utilisation",
|
||||
|
||||
@@ -1572,6 +1572,16 @@
|
||||
"billingFeatureLossWarning": "Avviso Di Disponibilità Caratteristica",
|
||||
"billingFeatureLossDescription": "Con il downgrading, le funzioni non disponibili nel nuovo piano saranno disattivate automaticamente. Alcune impostazioni e configurazioni potrebbero andare perse. Controlla la matrice dei prezzi per capire quali funzioni non saranno più disponibili.",
|
||||
"billingUsageExceedsLimit": "L'utilizzo corrente ({current}) supera il limite ({limit})",
|
||||
"billingPastDueTitle": "Pagamento Scaduto",
|
||||
"billingPastDueDescription": "Il pagamento è scaduto. Si prega di aggiornare il metodo di pagamento per continuare a utilizzare le funzioni del piano corrente. Se non risolto, il tuo abbonamento verrà annullato e verrai ripristinato al livello gratuito.",
|
||||
"billingUnpaidTitle": "Abbonamento Non Pagato",
|
||||
"billingUnpaidDescription": "Il tuo abbonamento non è pagato e sei stato restituito al livello gratuito. Per favore aggiorna il metodo di pagamento per ripristinare l'abbonamento.",
|
||||
"billingIncompleteTitle": "Pagamento Incompleto",
|
||||
"billingIncompleteDescription": "Il pagamento è incompleto. Si prega di completare il processo di pagamento per attivare il tuo abbonamento.",
|
||||
"billingIncompleteExpiredTitle": "Pagamento Scaduto",
|
||||
"billingIncompleteExpiredDescription": "Il tuo pagamento non è mai stato completato ed è scaduto. Sei stato ripristinato al livello gratuito. Si prega di iscriversi nuovamente per ripristinare l'accesso alle funzionalità a pagamento.",
|
||||
"billingManageSubscription": "Gestisci il tuo abbonamento",
|
||||
"billingResolvePaymentIssue": "Si prega di risolvere il problema di pagamento prima di aggiornare o declassare",
|
||||
"signUpTerms": {
|
||||
"IAgreeToThe": "Accetto i",
|
||||
"termsOfService": "termini di servizio",
|
||||
|
||||
@@ -1572,6 +1572,16 @@
|
||||
"billingFeatureLossWarning": "기능 가용성 알림",
|
||||
"billingFeatureLossDescription": "다운그레이드함으로써 새 계획에서 사용할 수 없는 기능은 자동으로 비활성화됩니다. 일부 설정 및 구성은 손실될 수 있습니다. 어떤 기능들이 더 이상 사용 불가능한지 이해하기 위해 가격표를 검토하세요.",
|
||||
"billingUsageExceedsLimit": "현재 사용량 ({current})이 제한 ({limit})을 초과합니다",
|
||||
"billingPastDueTitle": "연체된 결제",
|
||||
"billingPastDueDescription": "결제가 연체되었습니다. 현재 이용 중인 플랜 기능을 계속 사용하기 위해 결제 수단을 업데이트해 주세요. 해결되지 않으면 구독이 취소되고 무료 요금제로 전환됩니다.",
|
||||
"billingUnpaidTitle": "결제되지 않은 구독",
|
||||
"billingUnpaidDescription": "구독 결제가 완료되지 않아 무료 요금제로 전환되었습니다. 구독을 복원하려면 결제 수단을 업데이트해 주세요.",
|
||||
"billingIncompleteTitle": "불완전한 결제",
|
||||
"billingIncompleteDescription": "결제가 불완전합니다. 구독을 활성화하기 위해 결제 과정을 완료해 주세요.",
|
||||
"billingIncompleteExpiredTitle": "만료된 결제",
|
||||
"billingIncompleteExpiredDescription": "결제가 완료되지 않아 만료되었습니다. 무료 요금제로 전환되었습니다. 유료 기능에 대한 액세스를 복원하려면 다시 구독해 주세요.",
|
||||
"billingManageSubscription": "구독을 관리하십시오",
|
||||
"billingResolvePaymentIssue": "업그레이드 또는 다운그레이드하기 전에 결제 문제를 해결해 주세요.",
|
||||
"signUpTerms": {
|
||||
"IAgreeToThe": "동의합니다",
|
||||
"termsOfService": "서비스 약관",
|
||||
|
||||
@@ -1572,6 +1572,16 @@
|
||||
"billingFeatureLossWarning": "Fremhev tilgjengelig varsel",
|
||||
"billingFeatureLossDescription": "Ved å nedgradere vil funksjoner som ikke er tilgjengelige i den nye planen automatisk bli deaktivert. Noen innstillinger og konfigurasjoner kan gå tapt. Vennligst gjennomgå prismatrisen for å forstå hvilke funksjoner som ikke lenger vil være tilgjengelige.",
|
||||
"billingUsageExceedsLimit": "Gjeldende bruk ({current}) overskrider grensen ({limit})",
|
||||
"billingPastDueTitle": "Betalingen har forfalt",
|
||||
"billingPastDueDescription": "Betalingen er forfalt. Vennligst oppdater betalingsmetoden din for å fortsette å bruke den gjeldende funksjonsplanen din. Hvis du ikke har løst deg, vil abonnementet ditt avbrytes, og du vil bli tilbakestilt til gratistiden.",
|
||||
"billingUnpaidTitle": "Abonnement ubetalt",
|
||||
"billingUnpaidDescription": "Ditt abonnement er ubetalt og du har blitt tilbakestilt til gratis kasse. Vennligst oppdater din betalingsmetode for å gjenopprette abonnementet.",
|
||||
"billingIncompleteTitle": "Betaling ufullstendig",
|
||||
"billingIncompleteDescription": "Betalingen er ufullstendig. Vennligst fullfør betalingsprosessen for å aktivere abonnementet.",
|
||||
"billingIncompleteExpiredTitle": "Betaling utløpt",
|
||||
"billingIncompleteExpiredDescription": "Din betaling ble aldri fullført, og har utløpt. Du har blitt tilbakestilt til gratis dekk. Vennligst abonner på nytt for å gjenopprette tilgangen til betalte funksjoner.",
|
||||
"billingManageSubscription": "Administrere ditt abonnement",
|
||||
"billingResolvePaymentIssue": "Vennligst løs ditt betalingsproblem før du oppgraderer eller nedgraderer betalingen",
|
||||
"signUpTerms": {
|
||||
"IAgreeToThe": "Jeg godtar",
|
||||
"termsOfService": "brukervilkårene",
|
||||
|
||||
@@ -1572,6 +1572,16 @@
|
||||
"billingFeatureLossWarning": "Kennisgeving beschikbaarheid",
|
||||
"billingFeatureLossDescription": "Door downgraden worden functies die niet beschikbaar zijn in het nieuwe abonnement automatisch uitgeschakeld. Sommige instellingen en configuraties kunnen verloren gaan. Raadpleeg de prijsmatrix om te begrijpen welke functies niet langer beschikbaar zijn.",
|
||||
"billingUsageExceedsLimit": "Huidig gebruik ({current}) overschrijdt limiet ({limit})",
|
||||
"billingPastDueTitle": "Vervaldatum betaling",
|
||||
"billingPastDueDescription": "Uw betaling is verlopen. Werk uw betaalmethode bij om uw huidige abonnementsfuncties te blijven gebruiken. Als dit niet is opgelost, zal je abonnement worden geannuleerd en zal je worden teruggezet naar de vrije rang.",
|
||||
"billingUnpaidTitle": "Abonnement Onbetaald",
|
||||
"billingUnpaidDescription": "Uw abonnement is niet betaald en u bent teruggekeerd naar het gratis niveau. Update uw betalingsmethode om uw abonnement te herstellen.",
|
||||
"billingIncompleteTitle": "Betaling onvolledig",
|
||||
"billingIncompleteDescription": "Uw betaling is onvolledig. Voltooi alstublieft het betalingsproces om uw abonnement te activeren.",
|
||||
"billingIncompleteExpiredTitle": "Betaling verlopen",
|
||||
"billingIncompleteExpiredDescription": "Uw betaling is nooit voltooid en verlopen. U bent teruggekeerd naar de gratis niveaus. Abonneer u opnieuw om de toegang tot betaalde functies te herstellen.",
|
||||
"billingManageSubscription": "Beheer uw abonnement",
|
||||
"billingResolvePaymentIssue": "Gelieve uw betalingsprobleem op te lossen voor het upgraden of downgraden",
|
||||
"signUpTerms": {
|
||||
"IAgreeToThe": "Ik ga akkoord met de",
|
||||
"termsOfService": "servicevoorwaarden",
|
||||
|
||||
@@ -1572,6 +1572,16 @@
|
||||
"billingFeatureLossWarning": "Powiadomienie o dostępności funkcji",
|
||||
"billingFeatureLossDescription": "Po obniżeniu wartości funkcje niedostępne w nowym planie zostaną automatycznie wyłączone. Niektóre ustawienia i konfiguracje mogą zostać utracone. Zapoznaj się z matrycą cenową, aby zrozumieć, które funkcje nie będą już dostępne.",
|
||||
"billingUsageExceedsLimit": "Bieżące użycie ({current}) przekracza limit ({limit})",
|
||||
"billingPastDueTitle": "Płatność w przeszłości",
|
||||
"billingPastDueDescription": "Twoja płatność jest zaległa. Zaktualizuj metodę płatności, aby kontynuować korzystanie z funkcji aktualnego planu. Jeśli nie zostanie rozwiązana, Twoja subskrypcja zostanie anulowana i zostaniesz przywrócony do darmowego poziomu.",
|
||||
"billingUnpaidTitle": "Subskrypcja niezapłacona",
|
||||
"billingUnpaidDescription": "Twoja subskrypcja jest niezapłacona i została przywrócona do darmowego poziomu. Zaktualizuj swoją metodę płatności, aby przywrócić subskrypcję.",
|
||||
"billingIncompleteTitle": "Płatność niezakończona",
|
||||
"billingIncompleteDescription": "Twoja płatność jest niekompletna. Ukończ proces płatności, aby aktywować subskrypcję.",
|
||||
"billingIncompleteExpiredTitle": "Płatność wygasła",
|
||||
"billingIncompleteExpiredDescription": "Twoja płatność nigdy nie została zakończona i wygasła. Zostałeś przywrócony do darmowego poziomu. Zapisz się ponownie, aby przywrócić dostęp do płatnych funkcji.",
|
||||
"billingManageSubscription": "Zarządzaj subskrypcją",
|
||||
"billingResolvePaymentIssue": "Rozwiąż problem z płatnościami przed aktualizacją lub obniżeniem oceny",
|
||||
"signUpTerms": {
|
||||
"IAgreeToThe": "Zgadzam się z",
|
||||
"termsOfService": "warunkami usługi",
|
||||
|
||||
@@ -1572,6 +1572,16 @@
|
||||
"billingFeatureLossWarning": "Aviso de disponibilidade de recursos",
|
||||
"billingFeatureLossDescription": "Ao fazer o downgrading, recursos não disponíveis no novo plano serão desativados automaticamente. Algumas configurações e configurações podem ser perdidas. Por favor, revise a matriz de preços para entender quais características não estarão mais disponíveis.",
|
||||
"billingUsageExceedsLimit": "Uso atual ({current}) excede o limite ({limit})",
|
||||
"billingPastDueTitle": "Pagamento passado devido",
|
||||
"billingPastDueDescription": "Seu pagamento está vencido. Por favor, atualize seu método de pagamento para continuar usando os recursos do seu plano atual. Se não for resolvido, sua assinatura será cancelada e você será revertido para o nível gratuito.",
|
||||
"billingUnpaidTitle": "Assinatura não paga",
|
||||
"billingUnpaidDescription": "Sua assinatura não foi paga e você voltou para o nível gratuito. Atualize o seu método de pagamento para restaurar sua assinatura.",
|
||||
"billingIncompleteTitle": "Pagamento Incompleto",
|
||||
"billingIncompleteDescription": "Seu pagamento está incompleto. Por favor, complete o processo de pagamento para ativar sua assinatura.",
|
||||
"billingIncompleteExpiredTitle": "Pagamento expirado",
|
||||
"billingIncompleteExpiredDescription": "Seu pagamento nunca foi concluído e expirou. Você foi revertido para o nível gratuito. Por favor, inscreva-se novamente para restaurar o acesso a recursos pagos.",
|
||||
"billingManageSubscription": "Gerencie sua assinatura",
|
||||
"billingResolvePaymentIssue": "Por favor, resolva seu problema de pagamento antes de atualizar ou rebaixar",
|
||||
"signUpTerms": {
|
||||
"IAgreeToThe": "Concordo com",
|
||||
"termsOfService": "os termos de serviço",
|
||||
|
||||
@@ -1572,6 +1572,16 @@
|
||||
"billingFeatureLossWarning": "Уведомление о доступности функций",
|
||||
"billingFeatureLossDescription": "При переходе на другой тарифный план функции не будут автоматически отключены. Некоторые настройки и конфигурации могут быть потеряны. Пожалуйста, ознакомьтесь с матрицей ценообразования, чтобы понять, какие функции больше не будут доступны.",
|
||||
"billingUsageExceedsLimit": "Текущее использование ({current}) превышает предел ({limit})",
|
||||
"billingPastDueTitle": "Платеж просрочен",
|
||||
"billingPastDueDescription": "Ваш платеж просрочен. Пожалуйста, обновите способ оплаты, чтобы продолжить использовать текущие функции. Если ваша подписка не будет решена, она будет отменена, и вы вернетесь к бесплатному уровню.",
|
||||
"billingUnpaidTitle": "Подписка не оплачена",
|
||||
"billingUnpaidDescription": "Ваша подписка не оплачена, и вы были возвращены к бесплатному уровню. Пожалуйста, обновите способ оплаты, чтобы восстановить вашу подписку.",
|
||||
"billingIncompleteTitle": "Платеж не завершен",
|
||||
"billingIncompleteDescription": "Ваш платеж не завершен. Пожалуйста, завершите процесс оплаты, чтобы активировать вашу подписку.",
|
||||
"billingIncompleteExpiredTitle": "Платеж просрочен",
|
||||
"billingIncompleteExpiredDescription": "Ваш платеж не был завершен и истек. Вы были возвращены к бесплатному уровню. Пожалуйста, подпишитесь снова, чтобы восстановить доступ к платным функциям.",
|
||||
"billingManageSubscription": "Управление подпиской",
|
||||
"billingResolvePaymentIssue": "Пожалуйста, решите проблему оплаты перед обновлением или понижением сорта",
|
||||
"signUpTerms": {
|
||||
"IAgreeToThe": "Я согласен с",
|
||||
"termsOfService": "условия использования",
|
||||
|
||||
@@ -1572,6 +1572,16 @@
|
||||
"billingFeatureLossWarning": "Özellik Kullanılabilirlik Bildirimi",
|
||||
"billingFeatureLossDescription": "Plan düşürüldüğünde, yeni planda mevcut olmayan özellikler otomatik olarak devre dışı bırakılacaktır. Bazı ayarlar ve yapılar kaybolabilir. Hangi özelliklerin artık mevcut olmayacağını anlamak için fiyat tablosunu inceleyiniz.",
|
||||
"billingUsageExceedsLimit": "Mevcut kullanım ({current}) limitleri ({limit}) aşıyor",
|
||||
"billingPastDueTitle": "Ödeme Geçmiş",
|
||||
"billingPastDueDescription": "Ödemenizın vadesi geçti. Mevcut plan özelliklerinizi kullanmaya devam etmek için lütfen ödeme yöntemini güncelleyin. Sorun çözülmezse aboneliğiniz iptal edilecek ve ücretsiz seviyeye dönüleceksiniz.",
|
||||
"billingUnpaidTitle": "Ödenmemiş Abonelik",
|
||||
"billingUnpaidDescription": "Aboneliğiniz ödenmedi ve ücretsiz seviyeye geri döndünüz. Aboneliğinizi geri yüklemek için lütfen ödeme yöntemini güncelleyin.",
|
||||
"billingIncompleteTitle": "Eksik Ödeme",
|
||||
"billingIncompleteDescription": "Ödemeniz eksik. Aboneliğinizi etkinleştirmek için lütfen ödeme sürecini tamamlayın.",
|
||||
"billingIncompleteExpiredTitle": "Ödeme Süresi Doldu",
|
||||
"billingIncompleteExpiredDescription": "Ödemeniz hiç tamamlanmadı ve süresi doldu. Ücretsiz seviyeye geri döndünüz. Ücretli özelliklere erişimi yeniden sağlamak için lütfen yeniden abone olun.",
|
||||
"billingManageSubscription": "Aboneliğinizi Yönetin",
|
||||
"billingResolvePaymentIssue": "Yükseltmeden veya düşürmeden önce ödeme sorunuzu çözün",
|
||||
"signUpTerms": {
|
||||
"IAgreeToThe": "Kabul ediyorum",
|
||||
"termsOfService": "hizmet şartları",
|
||||
|
||||
@@ -1572,6 +1572,16 @@
|
||||
"billingFeatureLossWarning": "功能可用通知",
|
||||
"billingFeatureLossDescription": "如果降级,新计划中不可用的功能将被自动禁用。一些设置和配置可能会丢失。 请查看定价矩阵以了解哪些功能将不再可用。",
|
||||
"billingUsageExceedsLimit": "当前使用量 ({current}) 超出限制 ({limit})",
|
||||
"billingPastDueTitle": "过去到期的付款",
|
||||
"billingPastDueDescription": "您的付款已过期。请更新您的付款方法以继续使用您当前的计划功能。 如果不解决,您的订阅将被取消,您将被恢复到免费等级。",
|
||||
"billingUnpaidTitle": "订阅未付款",
|
||||
"billingUnpaidDescription": "您的订阅未付,您已恢复到免费等级。请更新您的付款方法以恢复您的订阅。",
|
||||
"billingIncompleteTitle": "付款不完成",
|
||||
"billingIncompleteDescription": "您的付款不完整。请完成付款过程以激活您的订阅。",
|
||||
"billingIncompleteExpiredTitle": "付款已过期",
|
||||
"billingIncompleteExpiredDescription": "您的付款尚未完成且已过期。您已恢复到免费级别。请再次订阅以恢复对已支付功能的访问。",
|
||||
"billingManageSubscription": "管理您的订阅",
|
||||
"billingResolvePaymentIssue": "请在升级或降级之前解决您的付款问题",
|
||||
"signUpTerms": {
|
||||
"IAgreeToThe": "我同意",
|
||||
"termsOfService": "服务条款",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Request } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { userActions, roleActions, userOrgs } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { userActions, roleActions } from "@server/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
@@ -52,6 +52,7 @@ export enum ActionsEnum {
|
||||
listRoleResources = "listRoleResources",
|
||||
// listRoleActions = "listRoleActions",
|
||||
addUserRole = "addUserRole",
|
||||
removeUserRole = "removeUserRole",
|
||||
// addUserSite = "addUserSite",
|
||||
// addUserAction = "addUserAction",
|
||||
// removeUserAction = "removeUserAction",
|
||||
@@ -153,29 +154,19 @@ export async function checkUserActionPermission(
|
||||
}
|
||||
|
||||
try {
|
||||
let userOrgRoleId = req.userOrgRoleId;
|
||||
let userOrgRoleIds = req.userOrgRoleIds;
|
||||
|
||||
// If userOrgRoleId is not available on the request, fetch it
|
||||
if (userOrgRoleId === undefined) {
|
||||
const userOrgRole = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, userId),
|
||||
eq(userOrgs.orgId, req.userOrgId!)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (userOrgRole.length === 0) {
|
||||
if (userOrgRoleIds === undefined) {
|
||||
const { getUserOrgRoleIds } = await import(
|
||||
"@server/lib/userOrgRoles"
|
||||
);
|
||||
userOrgRoleIds = await getUserOrgRoleIds(userId, req.userOrgId!);
|
||||
if (userOrgRoleIds.length === 0) {
|
||||
throw createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this organization"
|
||||
);
|
||||
}
|
||||
|
||||
userOrgRoleId = userOrgRole[0].roleId;
|
||||
}
|
||||
|
||||
// Check if the user has direct permission for the action in the current org
|
||||
@@ -186,7 +177,7 @@ export async function checkUserActionPermission(
|
||||
and(
|
||||
eq(userActions.userId, userId),
|
||||
eq(userActions.actionId, actionId),
|
||||
eq(userActions.orgId, req.userOrgId!) // TODO: we cant pass the org id if we are not checking the org
|
||||
eq(userActions.orgId, req.userOrgId!)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
@@ -195,14 +186,14 @@ export async function checkUserActionPermission(
|
||||
return true;
|
||||
}
|
||||
|
||||
// If no direct permission, check role-based permission
|
||||
// If no direct permission, check role-based permission (any of user's roles)
|
||||
const roleActionPermission = await db
|
||||
.select()
|
||||
.from(roleActions)
|
||||
.where(
|
||||
and(
|
||||
eq(roleActions.actionId, actionId),
|
||||
eq(roleActions.roleId, userOrgRoleId!),
|
||||
inArray(roleActions.roleId, userOrgRoleIds),
|
||||
eq(roleActions.orgId, req.userOrgId!)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import { db } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { roleResources, userResources } from "@server/db";
|
||||
|
||||
export async function canUserAccessResource({
|
||||
userId,
|
||||
resourceId,
|
||||
roleId
|
||||
roleIds
|
||||
}: {
|
||||
userId: string;
|
||||
resourceId: number;
|
||||
roleId: number;
|
||||
roleIds: number[];
|
||||
}): Promise<boolean> {
|
||||
const roleResourceAccess = await db
|
||||
.select()
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
eq(roleResources.roleId, roleId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
const roleResourceAccess =
|
||||
roleIds.length > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
inArray(roleResources.roleId, roleIds)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
: [];
|
||||
|
||||
if (roleResourceAccess.length > 0) {
|
||||
return true;
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import { db } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { roleSiteResources, userSiteResources } from "@server/db";
|
||||
|
||||
export async function canUserAccessSiteResource({
|
||||
userId,
|
||||
resourceId,
|
||||
roleId
|
||||
roleIds
|
||||
}: {
|
||||
userId: string;
|
||||
resourceId: number;
|
||||
roleId: number;
|
||||
roleIds: number[];
|
||||
}): Promise<boolean> {
|
||||
const roleResourceAccess = await db
|
||||
.select()
|
||||
.from(roleSiteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleSiteResources.siteResourceId, resourceId),
|
||||
eq(roleSiteResources.roleId, roleId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
const roleResourceAccess =
|
||||
roleIds.length > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(roleSiteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleSiteResources.siteResourceId, resourceId),
|
||||
inArray(roleSiteResources.roleId, roleIds)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
: [];
|
||||
|
||||
if (roleResourceAccess.length > 0) {
|
||||
return true;
|
||||
|
||||
@@ -3,7 +3,14 @@ import {
|
||||
encodeHexLowerCase
|
||||
} from "@oslojs/encoding";
|
||||
import { sha256 } from "@oslojs/crypto/sha2";
|
||||
import { resourceSessions, Session, sessions, User, users } from "@server/db";
|
||||
import {
|
||||
resourceSessions,
|
||||
safeRead,
|
||||
Session,
|
||||
sessions,
|
||||
User,
|
||||
users
|
||||
} from "@server/db";
|
||||
import { db } from "@server/db";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import config from "@server/lib/config";
|
||||
@@ -54,11 +61,15 @@ export async function validateSessionToken(
|
||||
const sessionId = encodeHexLowerCase(
|
||||
sha256(new TextEncoder().encode(token))
|
||||
);
|
||||
const result = await db
|
||||
.select({ user: users, session: sessions })
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(sessions.userId, users.userId))
|
||||
.where(eq(sessions.sessionId, sessionId));
|
||||
|
||||
const result = await safeRead((db) =>
|
||||
db
|
||||
.select({ user: users, session: sessions })
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(sessions.userId, users.userId))
|
||||
.where(eq(sessions.sessionId, sessionId))
|
||||
);
|
||||
|
||||
if (result.length < 1) {
|
||||
return { session: null, user: null };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { encodeHexLowerCase } from "@oslojs/encoding";
|
||||
import { sha256 } from "@oslojs/crypto/sha2";
|
||||
import { resourceSessions, ResourceSession } from "@server/db";
|
||||
import { db } from "@server/db";
|
||||
import { db, safeRead } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
@@ -66,15 +66,17 @@ export async function validateResourceSessionToken(
|
||||
const sessionId = encodeHexLowerCase(
|
||||
sha256(new TextEncoder().encode(token))
|
||||
);
|
||||
const result = await db
|
||||
.select()
|
||||
.from(resourceSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(resourceSessions.sessionId, sessionId),
|
||||
eq(resourceSessions.resourceId, resourceId)
|
||||
const result = await safeRead((db) =>
|
||||
db
|
||||
.select()
|
||||
.from(resourceSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(resourceSessions.sessionId, sessionId),
|
||||
eq(resourceSessions.resourceId, resourceId)
|
||||
)
|
||||
)
|
||||
);
|
||||
);
|
||||
|
||||
if (result.length < 1) {
|
||||
return { resourceSession: null };
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./driver";
|
||||
export * from "./safeRead";
|
||||
export * from "./schema/schema";
|
||||
export * from "./schema/privateSchema";
|
||||
export * from "./migrate";
|
||||
|
||||
24
server/db/pg/safeRead.ts
Normal file
24
server/db/pg/safeRead.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { db, primaryDb } from "./driver";
|
||||
|
||||
/**
|
||||
* Runs a read query with replica fallback for Postgres.
|
||||
* Executes the query against the replica first (when replicas exist).
|
||||
* If the query throws or returns no data (null, undefined, or empty array),
|
||||
* runs the same query against the primary.
|
||||
*/
|
||||
export async function safeRead<T>(
|
||||
query: (d: typeof db | typeof primaryDb) => Promise<T>
|
||||
): Promise<T> {
|
||||
try {
|
||||
const result = await query(db);
|
||||
if (result === undefined || result === null) {
|
||||
return query(primaryDb);
|
||||
}
|
||||
if (Array.isArray(result) && result.length === 0) {
|
||||
return query(primaryDb);
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return query(primaryDb);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
real,
|
||||
serial,
|
||||
text,
|
||||
unique,
|
||||
varchar
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
@@ -332,9 +333,6 @@ export const userOrgs = pgTable("userOrgs", {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId),
|
||||
isOwner: boolean("isOwner").notNull().default(false),
|
||||
autoProvisioned: boolean("autoProvisioned").default(false),
|
||||
pamUsername: varchar("pamUsername") // cleaned username for ssh and such
|
||||
@@ -383,6 +381,22 @@ export const roles = pgTable("roles", {
|
||||
sshUnixGroups: text("sshUnixGroups").default("[]")
|
||||
});
|
||||
|
||||
export const userOrgRoles = pgTable(
|
||||
"userOrgRoles",
|
||||
{
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, { onDelete: "cascade" })
|
||||
},
|
||||
(t) => [unique().on(t.userId, t.orgId, t.roleId)]
|
||||
);
|
||||
|
||||
export const roleActions = pgTable("roleActions", {
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
@@ -1031,6 +1045,7 @@ export type RoleResource = InferSelectModel<typeof roleResources>;
|
||||
export type UserResource = InferSelectModel<typeof userResources>;
|
||||
export type UserInvite = InferSelectModel<typeof userInvites>;
|
||||
export type UserOrg = InferSelectModel<typeof userOrgs>;
|
||||
export type UserOrgRole = InferSelectModel<typeof userOrgRoles>;
|
||||
export type ResourceSession = InferSelectModel<typeof resourceSessions>;
|
||||
export type ResourcePincode = InferSelectModel<typeof resourcePincode>;
|
||||
export type ResourcePassword = InferSelectModel<typeof resourcePassword>;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
resources,
|
||||
roleResources,
|
||||
sessions,
|
||||
userOrgRoles,
|
||||
userOrgs,
|
||||
userResources,
|
||||
users,
|
||||
@@ -104,24 +105,57 @@ export async function getUserSessionWithUser(
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user organization role
|
||||
* Get user organization role (single role; prefer getUserOrgRoleIds + roles for multi-role).
|
||||
* @deprecated Use userOrgRoles table and getUserOrgRoleIds for multi-role support.
|
||||
*/
|
||||
export async function getUserOrgRole(userId: string, orgId: string) {
|
||||
const userOrgRole = await db
|
||||
const userOrg = await db
|
||||
.select({
|
||||
userId: userOrgs.userId,
|
||||
orgId: userOrgs.orgId,
|
||||
roleId: userOrgs.roleId,
|
||||
isOwner: userOrgs.isOwner,
|
||||
autoProvisioned: userOrgs.autoProvisioned,
|
||||
roleName: roles.name
|
||||
autoProvisioned: userOrgs.autoProvisioned
|
||||
})
|
||||
.from(userOrgs)
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
||||
.limit(1);
|
||||
|
||||
return userOrgRole.length > 0 ? userOrgRole[0] : null;
|
||||
if (userOrg.length === 0) return null;
|
||||
|
||||
const [firstRole] = await db
|
||||
.select({
|
||||
roleId: userOrgRoles.roleId,
|
||||
roleName: roles.name
|
||||
})
|
||||
.from(userOrgRoles)
|
||||
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgRoles.userId, userId),
|
||||
eq(userOrgRoles.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return firstRole
|
||||
? {
|
||||
...userOrg[0],
|
||||
roleId: firstRole.roleId,
|
||||
roleName: firstRole.roleName
|
||||
}
|
||||
: { ...userOrg[0], roleId: null, roleName: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role name by role ID (for display).
|
||||
*/
|
||||
export async function getRoleName(roleId: number): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.select({ name: roles.name })
|
||||
.from(roles)
|
||||
.where(eq(roles.roleId, roleId))
|
||||
.limit(1);
|
||||
return row?.name ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./driver";
|
||||
export * from "./safeRead";
|
||||
export * from "./schema/schema";
|
||||
export * from "./schema/privateSchema";
|
||||
export * from "./migrate";
|
||||
|
||||
11
server/db/sqlite/safeRead.ts
Normal file
11
server/db/sqlite/safeRead.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { db } from "./driver";
|
||||
|
||||
/**
|
||||
* Runs a read query. For SQLite there is no replica/primary distinction,
|
||||
* so the query is executed once against the database.
|
||||
*/
|
||||
export async function safeRead<T>(
|
||||
query: (d: typeof db) => Promise<T>
|
||||
): Promise<T> {
|
||||
return query(db);
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import {
|
||||
index,
|
||||
integer,
|
||||
sqliteTable,
|
||||
text,
|
||||
unique
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const domains = sqliteTable("domains", {
|
||||
domainId: text("domainId").primaryKey(),
|
||||
@@ -635,9 +641,6 @@ export const userOrgs = sqliteTable("userOrgs", {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId),
|
||||
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
|
||||
autoProvisioned: integer("autoProvisioned", {
|
||||
mode: "boolean"
|
||||
@@ -692,6 +695,22 @@ export const roles = sqliteTable("roles", {
|
||||
sshUnixGroups: text("sshUnixGroups").default("[]")
|
||||
});
|
||||
|
||||
export const userOrgRoles = sqliteTable(
|
||||
"userOrgRoles",
|
||||
{
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, { onDelete: "cascade" })
|
||||
},
|
||||
(t) => [unique().on(t.userId, t.orgId, t.roleId)]
|
||||
);
|
||||
|
||||
export const roleActions = sqliteTable("roleActions", {
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
@@ -1126,6 +1145,7 @@ export type RoleResource = InferSelectModel<typeof roleResources>;
|
||||
export type UserResource = InferSelectModel<typeof userResources>;
|
||||
export type UserInvite = InferSelectModel<typeof userInvites>;
|
||||
export type UserOrg = InferSelectModel<typeof userOrgs>;
|
||||
export type UserOrgRole = InferSelectModel<typeof userOrgRoles>;
|
||||
export type ResourceSession = InferSelectModel<typeof resourceSessions>;
|
||||
export type ResourcePincode = InferSelectModel<typeof resourcePincode>;
|
||||
export type ResourcePassword = InferSelectModel<typeof resourcePassword>;
|
||||
|
||||
@@ -74,7 +74,7 @@ declare global {
|
||||
session: Session;
|
||||
userOrg?: UserOrg;
|
||||
apiKeyOrg?: ApiKeyOrg;
|
||||
userOrgRoleId?: number;
|
||||
userOrgRoleIds?: number[];
|
||||
userOrgId?: string;
|
||||
userOrgIds?: string[];
|
||||
remoteExitNode?: RemoteExitNode;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
roles,
|
||||
Transaction,
|
||||
userClients,
|
||||
userOrgRoles,
|
||||
userOrgs
|
||||
} from "@server/db";
|
||||
import { getUniqueClientName } from "@server/db/names";
|
||||
@@ -39,20 +40,36 @@ export async function calculateUserClientsForOrgs(
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all user orgs
|
||||
const allUserOrgs = await transaction
|
||||
// Get all user orgs with all roles (for org list and role-based logic)
|
||||
const userOrgRoleRows = await transaction
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.innerJoin(roles, eq(roles.roleId, userOrgs.roleId))
|
||||
.innerJoin(
|
||||
userOrgRoles,
|
||||
and(
|
||||
eq(userOrgs.userId, userOrgRoles.userId),
|
||||
eq(userOrgs.orgId, userOrgRoles.orgId)
|
||||
)
|
||||
)
|
||||
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
||||
.where(eq(userOrgs.userId, userId));
|
||||
|
||||
const userOrgIds = allUserOrgs.map(({ userOrgs: uo }) => uo.orgId);
|
||||
const userOrgIds = [...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))];
|
||||
const orgIdToRoleRows = new Map<
|
||||
string,
|
||||
(typeof userOrgRoleRows)[0][]
|
||||
>();
|
||||
for (const r of userOrgRoleRows) {
|
||||
const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? [];
|
||||
list.push(r);
|
||||
orgIdToRoleRows.set(r.userOrgs.orgId, list);
|
||||
}
|
||||
|
||||
// For each OLM, ensure there's a client in each org the user is in
|
||||
for (const olm of userOlms) {
|
||||
for (const userRoleOrg of allUserOrgs) {
|
||||
const { userOrgs: userOrg, roles: role } = userRoleOrg;
|
||||
const orgId = userOrg.orgId;
|
||||
for (const orgId of orgIdToRoleRows.keys()) {
|
||||
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
|
||||
const userOrg = roleRowsForOrg[0].userOrgs;
|
||||
|
||||
const [org] = await transaction
|
||||
.select()
|
||||
@@ -196,7 +213,7 @@ export async function calculateUserClientsForOrgs(
|
||||
const requireApproval =
|
||||
build !== "oss" &&
|
||||
isOrgLicensed &&
|
||||
role.requireDeviceApproval;
|
||||
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval);
|
||||
|
||||
const newClientData: InferInsertModel<typeof clients> = {
|
||||
userId,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
siteResources,
|
||||
sites,
|
||||
Transaction,
|
||||
userOrgRoles,
|
||||
userOrgs,
|
||||
userSiteResources
|
||||
} from "@server/db";
|
||||
@@ -77,10 +78,10 @@ export async function getClientSiteResourceAccess(
|
||||
// get all of the users in these roles
|
||||
const userIdsFromRoles = await trx
|
||||
.select({
|
||||
userId: userOrgs.userId
|
||||
userId: userOrgRoles.userId
|
||||
})
|
||||
.from(userOrgs)
|
||||
.where(inArray(userOrgs.roleId, roleIds))
|
||||
.from(userOrgRoles)
|
||||
.where(inArray(userOrgRoles.roleId, roleIds))
|
||||
.then((rows) => rows.map((row) => row.userId));
|
||||
|
||||
const newAllUserIds = Array.from(
|
||||
@@ -811,12 +812,12 @@ export async function rebuildClientAssociationsFromClient(
|
||||
|
||||
// Role-based access
|
||||
const roleIds = await trx
|
||||
.select({ roleId: userOrgs.roleId })
|
||||
.from(userOrgs)
|
||||
.select({ roleId: userOrgRoles.roleId })
|
||||
.from(userOrgRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, client.userId),
|
||||
eq(userOrgs.orgId, client.orgId)
|
||||
eq(userOrgRoles.userId, client.userId),
|
||||
eq(userOrgRoles.orgId, client.orgId)
|
||||
)
|
||||
) // this needs to be locked onto this org or else cross-org access could happen
|
||||
.then((rows) => rows.map((row) => row.roleId));
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
siteResources,
|
||||
sites,
|
||||
Transaction,
|
||||
UserOrg,
|
||||
userOrgRoles,
|
||||
userOrgs,
|
||||
userResources,
|
||||
userSiteResources,
|
||||
@@ -19,9 +19,15 @@ import { FeatureId } from "@server/lib/billing";
|
||||
export async function assignUserToOrg(
|
||||
org: Org,
|
||||
values: typeof userOrgs.$inferInsert,
|
||||
roleId: number,
|
||||
trx: Transaction | typeof db = db
|
||||
) {
|
||||
const [userOrg] = await trx.insert(userOrgs).values(values).returning();
|
||||
await trx.insert(userOrgRoles).values({
|
||||
userId: userOrg.userId,
|
||||
orgId: userOrg.orgId,
|
||||
roleId
|
||||
});
|
||||
|
||||
// calculate if the user is in any other of the orgs before we count it as an add to the billing org
|
||||
if (org.billingOrgId) {
|
||||
@@ -58,6 +64,14 @@ export async function removeUserFromOrg(
|
||||
userId: string,
|
||||
trx: Transaction | typeof db = db
|
||||
) {
|
||||
await trx
|
||||
.delete(userOrgRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgRoles.userId, userId),
|
||||
eq(userOrgRoles.orgId, org.orgId)
|
||||
)
|
||||
);
|
||||
await trx
|
||||
.delete(userOrgs)
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId)));
|
||||
|
||||
22
server/lib/userOrgRoles.ts
Normal file
22
server/lib/userOrgRoles.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { db, userOrgRoles } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Get all role IDs a user has in an organization.
|
||||
* Returns empty array if the user has no roles in the org (callers must treat as no access).
|
||||
*/
|
||||
export async function getUserOrgRoleIds(
|
||||
userId: string,
|
||||
orgId: string
|
||||
): Promise<number[]> {
|
||||
const rows = await db
|
||||
.select({ roleId: userOrgRoles.roleId })
|
||||
.from(userOrgRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgRoles.userId, userId),
|
||||
eq(userOrgRoles.orgId, orgId)
|
||||
)
|
||||
);
|
||||
return rows.map((r) => r.roleId);
|
||||
}
|
||||
@@ -21,8 +21,7 @@ export async function getUserOrgs(
|
||||
try {
|
||||
const userOrganizations = await db
|
||||
.select({
|
||||
orgId: userOrgs.orgId,
|
||||
roleId: userOrgs.roleId
|
||||
orgId: userOrgs.orgId
|
||||
})
|
||||
.from(userOrgs)
|
||||
.where(eq(userOrgs.userId, userId));
|
||||
|
||||
@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
|
||||
export async function verifyAccessTokenAccess(
|
||||
req: Request,
|
||||
@@ -93,7 +94,10 @@ export async function verifyAccessTokenAccess(
|
||||
)
|
||||
);
|
||||
} else {
|
||||
req.userOrgRoleId = req.userOrg.roleId;
|
||||
req.userOrgRoleIds = await getUserOrgRoleIds(
|
||||
req.userOrg.userId,
|
||||
resource[0].orgId!
|
||||
);
|
||||
req.userOrgId = resource[0].orgId!;
|
||||
}
|
||||
|
||||
@@ -118,7 +122,7 @@ export async function verifyAccessTokenAccess(
|
||||
const resourceAllowed = await canUserAccessResource({
|
||||
userId,
|
||||
resourceId,
|
||||
roleId: req.userOrgRoleId!
|
||||
roleIds: req.userOrgRoleIds ?? []
|
||||
});
|
||||
|
||||
if (!resourceAllowed) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { roles, userOrgs } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
|
||||
export async function verifyAdmin(
|
||||
req: Request,
|
||||
@@ -62,13 +63,29 @@ export async function verifyAdmin(
|
||||
}
|
||||
}
|
||||
|
||||
const userRole = await db
|
||||
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId!);
|
||||
|
||||
if (req.userOrgRoleIds.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have Admin access"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const userAdminRoles = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(eq(roles.roleId, req.userOrg.roleId))
|
||||
.where(
|
||||
and(
|
||||
inArray(roles.roleId, req.userOrgRoleIds),
|
||||
eq(roles.isAdmin, true)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (userRole.length === 0 || !userRole[0].isAdmin) {
|
||||
if (userAdminRoles.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { userOrgs, apiKeys, apiKeyOrg } from "@server/db";
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
|
||||
export async function verifyApiKeyAccess(
|
||||
req: Request,
|
||||
@@ -103,8 +104,10 @@ export async function verifyApiKeyAccess(
|
||||
}
|
||||
}
|
||||
|
||||
const userOrgRoleId = req.userOrg.roleId;
|
||||
req.userOrgRoleId = userOrgRoleId;
|
||||
req.userOrgRoleIds = await getUserOrgRoleIds(
|
||||
req.userOrg.userId,
|
||||
orgId
|
||||
);
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { Client, db } from "@server/db";
|
||||
import { userOrgs, clients, roleClients, userClients } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import logger from "@server/logger";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
|
||||
export async function verifyClientAccess(
|
||||
req: Request,
|
||||
@@ -113,21 +114,30 @@ export async function verifyClientAccess(
|
||||
}
|
||||
}
|
||||
|
||||
const userOrgRoleId = req.userOrg.roleId;
|
||||
req.userOrgRoleId = userOrgRoleId;
|
||||
req.userOrgRoleIds = await getUserOrgRoleIds(
|
||||
req.userOrg.userId,
|
||||
client.orgId
|
||||
);
|
||||
req.userOrgId = client.orgId;
|
||||
|
||||
// Check role-based site access first
|
||||
const [roleClientAccess] = await db
|
||||
.select()
|
||||
.from(roleClients)
|
||||
.where(
|
||||
and(
|
||||
eq(roleClients.clientId, client.clientId),
|
||||
eq(roleClients.roleId, userOrgRoleId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
// Check role-based client access (any of user's roles)
|
||||
const roleClientAccessList =
|
||||
(req.userOrgRoleIds?.length ?? 0) > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(roleClients)
|
||||
.where(
|
||||
and(
|
||||
eq(roleClients.clientId, client.clientId),
|
||||
inArray(
|
||||
roleClients.roleId,
|
||||
req.userOrgRoleIds!
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
: [];
|
||||
const [roleClientAccess] = roleClientAccessList;
|
||||
|
||||
if (roleClientAccess) {
|
||||
// User has access to the site through their role
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db, domains, orgDomains } from "@server/db";
|
||||
import { userOrgs, apiKeyOrg } from "@server/db";
|
||||
import { userOrgs } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
|
||||
export async function verifyDomainAccess(
|
||||
req: Request,
|
||||
@@ -63,7 +64,7 @@ export async function verifyDomainAccess(
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, userId),
|
||||
eq(userOrgs.orgId, apiKeyOrg.orgId)
|
||||
eq(userOrgs.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
@@ -97,8 +98,7 @@ export async function verifyDomainAccess(
|
||||
}
|
||||
}
|
||||
|
||||
const userOrgRoleId = req.userOrg.roleId;
|
||||
req.userOrgRoleId = userOrgRoleId;
|
||||
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db, orgs } from "@server/db";
|
||||
import { db } from "@server/db";
|
||||
import { userOrgs } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
|
||||
export async function verifyOrgAccess(
|
||||
req: Request,
|
||||
@@ -64,8 +65,8 @@ export async function verifyOrgAccess(
|
||||
}
|
||||
}
|
||||
|
||||
// User has access, attach the user's role to the request for potential future use
|
||||
req.userOrgRoleId = req.userOrg.roleId;
|
||||
// User has access, attach the user's role(s) to the request for potential future use
|
||||
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
|
||||
req.userOrgId = orgId;
|
||||
|
||||
return next();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db, Resource } from "@server/db";
|
||||
import { resources, userOrgs, userResources, roleResources } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
|
||||
export async function verifyResourceAccess(
|
||||
req: Request,
|
||||
@@ -107,20 +108,28 @@ export async function verifyResourceAccess(
|
||||
}
|
||||
}
|
||||
|
||||
const userOrgRoleId = req.userOrg.roleId;
|
||||
req.userOrgRoleId = userOrgRoleId;
|
||||
req.userOrgRoleIds = await getUserOrgRoleIds(
|
||||
req.userOrg.userId,
|
||||
resource.orgId
|
||||
);
|
||||
req.userOrgId = resource.orgId;
|
||||
|
||||
const roleResourceAccess = await db
|
||||
.select()
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resource.resourceId),
|
||||
eq(roleResources.roleId, userOrgRoleId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
const roleResourceAccess =
|
||||
(req.userOrgRoleIds?.length ?? 0) > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resource.resourceId),
|
||||
inArray(
|
||||
roleResources.roleId,
|
||||
req.userOrgRoleIds!
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
: [];
|
||||
|
||||
if (roleResourceAccess.length > 0) {
|
||||
return next();
|
||||
|
||||
@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import logger from "@server/logger";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
|
||||
export async function verifyRoleAccess(
|
||||
req: Request,
|
||||
@@ -99,7 +100,6 @@ export async function verifyRoleAccess(
|
||||
}
|
||||
|
||||
if (!req.userOrg) {
|
||||
// get the userORg
|
||||
const userOrg = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
@@ -109,7 +109,7 @@ export async function verifyRoleAccess(
|
||||
.limit(1);
|
||||
|
||||
req.userOrg = userOrg[0];
|
||||
req.userOrgRoleId = userOrg[0].roleId;
|
||||
req.userOrgRoleIds = await getUserOrgRoleIds(userId, orgId!);
|
||||
}
|
||||
|
||||
if (!req.userOrg) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { sites, Site, userOrgs, userSites, roleSites, roles } from "@server/db";
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import { and, eq, inArray, or } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
|
||||
export async function verifySiteAccess(
|
||||
req: Request,
|
||||
@@ -112,21 +113,29 @@ export async function verifySiteAccess(
|
||||
}
|
||||
}
|
||||
|
||||
const userOrgRoleId = req.userOrg.roleId;
|
||||
req.userOrgRoleId = userOrgRoleId;
|
||||
req.userOrgRoleIds = await getUserOrgRoleIds(
|
||||
req.userOrg.userId,
|
||||
site.orgId
|
||||
);
|
||||
req.userOrgId = site.orgId;
|
||||
|
||||
// Check role-based site access first
|
||||
const roleSiteAccess = await db
|
||||
.select()
|
||||
.from(roleSites)
|
||||
.where(
|
||||
and(
|
||||
eq(roleSites.siteId, site.siteId),
|
||||
eq(roleSites.roleId, userOrgRoleId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
// Check role-based site access first (any of user's roles)
|
||||
const roleSiteAccess =
|
||||
(req.userOrgRoleIds?.length ?? 0) > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(roleSites)
|
||||
.where(
|
||||
and(
|
||||
eq(roleSites.siteId, site.siteId),
|
||||
inArray(
|
||||
roleSites.roleId,
|
||||
req.userOrgRoleIds!
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
: [];
|
||||
|
||||
if (roleSiteAccess.length > 0) {
|
||||
// User's role has access to the site
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db, roleSiteResources, userOrgs, userSiteResources } from "@server/db";
|
||||
import { siteResources } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import logger from "@server/logger";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
|
||||
export async function verifySiteResourceAccess(
|
||||
req: Request,
|
||||
@@ -109,23 +110,34 @@ export async function verifySiteResourceAccess(
|
||||
}
|
||||
}
|
||||
|
||||
const userOrgRoleId = req.userOrg.roleId;
|
||||
req.userOrgRoleId = userOrgRoleId;
|
||||
req.userOrgRoleIds = await getUserOrgRoleIds(
|
||||
req.userOrg.userId,
|
||||
siteResource.orgId
|
||||
);
|
||||
req.userOrgId = siteResource.orgId;
|
||||
|
||||
// Attach the siteResource to the request for use in the next middleware/route
|
||||
req.siteResource = siteResource;
|
||||
|
||||
const roleResourceAccess = await db
|
||||
.select()
|
||||
.from(roleSiteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleSiteResources.siteResourceId, siteResourceIdNum),
|
||||
eq(roleSiteResources.roleId, userOrgRoleId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
const roleResourceAccess =
|
||||
(req.userOrgRoleIds?.length ?? 0) > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(roleSiteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
roleSiteResources.siteResourceId,
|
||||
siteResourceIdNum
|
||||
),
|
||||
inArray(
|
||||
roleSiteResources.roleId,
|
||||
req.userOrgRoleIds!
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
: [];
|
||||
|
||||
if (roleResourceAccess.length > 0) {
|
||||
return next();
|
||||
|
||||
@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { canUserAccessResource } from "../auth/canUserAccessResource";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
|
||||
export async function verifyTargetAccess(
|
||||
req: Request,
|
||||
@@ -99,7 +100,10 @@ export async function verifyTargetAccess(
|
||||
)
|
||||
);
|
||||
} else {
|
||||
req.userOrgRoleId = req.userOrg.roleId;
|
||||
req.userOrgRoleIds = await getUserOrgRoleIds(
|
||||
req.userOrg.userId,
|
||||
resource[0].orgId!
|
||||
);
|
||||
req.userOrgId = resource[0].orgId!;
|
||||
}
|
||||
|
||||
@@ -126,7 +130,7 @@ export async function verifyTargetAccess(
|
||||
const resourceAllowed = await canUserAccessResource({
|
||||
userId,
|
||||
resourceId,
|
||||
roleId: req.userOrgRoleId!
|
||||
roleIds: req.userOrgRoleIds ?? []
|
||||
});
|
||||
|
||||
if (!resourceAllowed) {
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function verifyUserInRole(
|
||||
const roleId = parseInt(
|
||||
req.params.roleId || req.body.roleId || req.query.roleId
|
||||
);
|
||||
const userRoleId = req.userOrgRoleId;
|
||||
const userOrgRoleIds = req.userOrgRoleIds ?? [];
|
||||
|
||||
if (isNaN(roleId)) {
|
||||
return next(
|
||||
@@ -20,7 +20,7 @@ export async function verifyUserInRole(
|
||||
);
|
||||
}
|
||||
|
||||
if (!userRoleId) {
|
||||
if (userOrgRoleIds.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
@@ -29,7 +29,7 @@ export async function verifyUserInRole(
|
||||
);
|
||||
}
|
||||
|
||||
if (userRoleId !== roleId) {
|
||||
if (!userOrgRoleIds.includes(roleId)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { userOrgs, db, idp, idpOrg } from "@server/db";
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
|
||||
export async function verifyIdpAccess(
|
||||
req: Request,
|
||||
@@ -84,8 +85,10 @@ export async function verifyIdpAccess(
|
||||
);
|
||||
}
|
||||
|
||||
const userOrgRoleId = req.userOrg.roleId;
|
||||
req.userOrgRoleId = userOrgRoleId;
|
||||
req.userOrgRoleIds = await getUserOrgRoleIds(
|
||||
req.userOrg.userId,
|
||||
idpRes.idpOrg.orgId
|
||||
);
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
|
||||
@@ -12,11 +12,12 @@
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db, exitNodeOrgs, exitNodes, remoteExitNodes } from "@server/db";
|
||||
import { sites, userOrgs, userSites, roleSites, roles } from "@server/db";
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import { db, exitNodeOrgs, remoteExitNodes } from "@server/db";
|
||||
import { userOrgs } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
|
||||
export async function verifyRemoteExitNodeAccess(
|
||||
req: Request,
|
||||
@@ -103,8 +104,10 @@ export async function verifyRemoteExitNodeAccess(
|
||||
);
|
||||
}
|
||||
|
||||
const userOrgRoleId = req.userOrg.roleId;
|
||||
req.userOrgRoleId = userOrgRoleId;
|
||||
req.userOrgRoleIds = await getUserOrgRoleIds(
|
||||
req.userOrg.userId,
|
||||
exitNodeOrg.orgId
|
||||
);
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { userOrgs, users, roles, orgs } from "@server/db";
|
||||
import { userOrgs, userOrgRoles, users, roles, orgs } from "@server/db";
|
||||
import { eq, and, or } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -95,7 +95,14 @@ async function getOrgAdmins(orgId: string) {
|
||||
})
|
||||
.from(userOrgs)
|
||||
.innerJoin(users, eq(userOrgs.userId, users.userId))
|
||||
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
||||
.leftJoin(
|
||||
userOrgRoles,
|
||||
and(
|
||||
eq(userOrgs.userId, userOrgRoles.userId),
|
||||
eq(userOrgs.orgId, userOrgRoles.orgId)
|
||||
)
|
||||
)
|
||||
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
@@ -103,8 +110,11 @@ async function getOrgAdmins(orgId: string) {
|
||||
)
|
||||
);
|
||||
|
||||
// Filter to only include users with verified emails
|
||||
const orgAdmins = admins.filter(
|
||||
// Dedupe by userId (user may have multiple roles)
|
||||
const byUserId = new Map(
|
||||
admins.map((a) => [a.userId, a])
|
||||
);
|
||||
const orgAdmins = Array.from(byUserId.values()).filter(
|
||||
(admin) => admin.email && admin.email.length > 0
|
||||
);
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export async function createRemoteExitNode(
|
||||
|
||||
const { remoteExitNodeId, secret } = parsedBody.data;
|
||||
|
||||
if (req.user && !req.userOrgRoleId) {
|
||||
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { eq, or, and } from "drizzle-orm";
|
||||
import { and, eq, inArray, or } from "drizzle-orm";
|
||||
import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource";
|
||||
import { signPublicKey, getOrgCAKeys } from "#private/lib/sshCA";
|
||||
import config from "@server/lib/config";
|
||||
@@ -122,7 +122,7 @@ export async function signSshKey(
|
||||
resource: resourceQueryString
|
||||
} = parsedBody.data;
|
||||
const userId = req.user?.userId;
|
||||
const roleId = req.userOrgRoleId!;
|
||||
const roleIds = req.userOrgRoleIds ?? [];
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
@@ -130,6 +130,15 @@ export async function signSshKey(
|
||||
);
|
||||
}
|
||||
|
||||
if (roleIds.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User has no role in organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [userOrg] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
@@ -310,11 +319,11 @@ export async function signSshKey(
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the user has access to the resource
|
||||
// Check if the user has access to the resource (any of their roles)
|
||||
const hasAccess = await canUserAccessSiteResource({
|
||||
userId: userId,
|
||||
resourceId: resource.siteResourceId,
|
||||
roleId: roleId
|
||||
roleIds
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
@@ -326,28 +335,39 @@ export async function signSshKey(
|
||||
);
|
||||
}
|
||||
|
||||
const [roleRow] = await db
|
||||
const roleRows = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(eq(roles.roleId, roleId))
|
||||
.limit(1);
|
||||
.where(inArray(roles.roleId, roleIds));
|
||||
|
||||
let parsedSudoCommands: string[] = [];
|
||||
let parsedGroups: string[] = [];
|
||||
try {
|
||||
parsedSudoCommands = JSON.parse(roleRow?.sshSudoCommands ?? "[]");
|
||||
if (!Array.isArray(parsedSudoCommands)) parsedSudoCommands = [];
|
||||
} catch {
|
||||
parsedSudoCommands = [];
|
||||
const parsedSudoCommands: string[] = [];
|
||||
const parsedGroupsSet = new Set<string>();
|
||||
let homedir: boolean | null = null;
|
||||
const sudoModeOrder = { none: 0, commands: 1, all: 2 };
|
||||
let sudoMode: "none" | "commands" | "all" = "none";
|
||||
for (const roleRow of roleRows) {
|
||||
try {
|
||||
const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]");
|
||||
if (Array.isArray(cmds)) parsedSudoCommands.push(...cmds);
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
try {
|
||||
const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
|
||||
if (Array.isArray(grps)) grps.forEach((g: string) => parsedGroupsSet.add(g));
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
if (roleRow?.sshCreateHomeDir === true) homedir = true;
|
||||
const m = roleRow?.sshSudoMode ?? "none";
|
||||
if (sudoModeOrder[m as keyof typeof sudoModeOrder] > sudoModeOrder[sudoMode]) {
|
||||
sudoMode = m as "none" | "commands" | "all";
|
||||
}
|
||||
}
|
||||
try {
|
||||
parsedGroups = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
|
||||
if (!Array.isArray(parsedGroups)) parsedGroups = [];
|
||||
} catch {
|
||||
parsedGroups = [];
|
||||
const parsedGroups = Array.from(parsedGroupsSet);
|
||||
if (homedir === null && roleRows.length > 0) {
|
||||
homedir = roleRows[0].sshCreateHomeDir ?? null;
|
||||
}
|
||||
const homedir = roleRow?.sshCreateHomeDir ?? null;
|
||||
const sudoMode = roleRow?.sshSudoMode ?? "none";
|
||||
|
||||
// get the site
|
||||
const [newt] = await db
|
||||
|
||||
@@ -208,7 +208,7 @@ export async function listAccessTokens(
|
||||
.where(
|
||||
or(
|
||||
eq(userResources.userId, req.user!.userId),
|
||||
eq(roleResources.roleId, req.userOrgRoleId!)
|
||||
inArray(roleResources.roleId, req.userOrgRoleIds!)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -3,12 +3,13 @@ import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToke
|
||||
import {
|
||||
getResourceByDomain,
|
||||
getResourceRules,
|
||||
getRoleName,
|
||||
getRoleResourceAccess,
|
||||
getUserOrgRole,
|
||||
getUserResourceAccess,
|
||||
getOrgLoginPage,
|
||||
getUserSessionWithUser
|
||||
} from "@server/db/queries/verifySessionQueries";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
import {
|
||||
LoginPage,
|
||||
Org,
|
||||
@@ -916,9 +917,9 @@ async function isUserAllowedToAccessResource(
|
||||
return null;
|
||||
}
|
||||
|
||||
const userOrgRole = await getUserOrgRole(user.userId, resource.orgId);
|
||||
const userOrgRoleIds = await getUserOrgRoleIds(user.userId, resource.orgId);
|
||||
|
||||
if (!userOrgRole) {
|
||||
if (!userOrgRoleIds.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -934,17 +935,23 @@ async function isUserAllowedToAccessResource(
|
||||
return null;
|
||||
}
|
||||
|
||||
const roleResourceAccess = await getRoleResourceAccess(
|
||||
resource.resourceId,
|
||||
userOrgRole.roleId
|
||||
);
|
||||
|
||||
if (roleResourceAccess) {
|
||||
const roleNames: string[] = [];
|
||||
for (const roleId of userOrgRoleIds) {
|
||||
const roleResourceAccess = await getRoleResourceAccess(
|
||||
resource.resourceId,
|
||||
roleId
|
||||
);
|
||||
if (roleResourceAccess) {
|
||||
const roleName = await getRoleName(roleId);
|
||||
if (roleName) roleNames.push(roleName);
|
||||
}
|
||||
}
|
||||
if (roleNames.length > 0) {
|
||||
return {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: userOrgRole.roleName
|
||||
role: roleNames.join(", ")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -954,11 +961,15 @@ async function isUserAllowedToAccessResource(
|
||||
);
|
||||
|
||||
if (userResourceAccess) {
|
||||
const names = await Promise.all(
|
||||
userOrgRoleIds.map((id) => getRoleName(id))
|
||||
);
|
||||
const role = names.filter(Boolean).join(", ") || "";
|
||||
return {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: userOrgRole.roleName
|
||||
role
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ export async function createClient(
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
if (req.user && !req.userOrgRoleId) {
|
||||
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||
);
|
||||
@@ -234,7 +234,7 @@ export async function createClient(
|
||||
clientId: newClient.clientId
|
||||
});
|
||||
|
||||
if (req.user && req.userOrgRoleId != adminRole.roleId) {
|
||||
if (req.user && !req.userOrgRoleIds?.includes(adminRole.roleId)) {
|
||||
// make sure the user can access the client
|
||||
trx.insert(userClients).values({
|
||||
userId: req.user.userId,
|
||||
|
||||
@@ -297,7 +297,7 @@ export async function listClients(
|
||||
.where(
|
||||
or(
|
||||
eq(userClients.userId, req.user!.userId),
|
||||
eq(roleClients.roleId, req.userOrgRoleId!)
|
||||
inArray(roleClients.roleId, req.userOrgRoleIds!)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -316,7 +316,7 @@ export async function listUserDevices(
|
||||
.where(
|
||||
or(
|
||||
eq(userClients.userId, req.user!.userId),
|
||||
eq(roleClients.roleId, req.userOrgRoleId!)
|
||||
inArray(roleClients.roleId, req.userOrgRoleIds!)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -654,6 +654,16 @@ authenticated.post(
|
||||
user.addUserRole
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/role/:roleId/remove/:userId",
|
||||
verifyRoleAccess,
|
||||
verifyUserAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.removeUserRole),
|
||||
logActionAudit(ActionsEnum.removeUserRole),
|
||||
user.removeUserRole
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/resource/:resourceId/roles",
|
||||
verifyResourceAccess,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
orgs,
|
||||
Role,
|
||||
roles,
|
||||
userOrgRoles,
|
||||
userOrgs,
|
||||
users
|
||||
} from "@server/db";
|
||||
@@ -570,32 +571,28 @@ export async function validateOidcCallback(
|
||||
}
|
||||
}
|
||||
|
||||
// Update roles for existing auto-provisioned orgs where the role has changed
|
||||
const orgsToUpdate = autoProvisionedOrgs.filter(
|
||||
(currentOrg) => {
|
||||
const newOrg = userOrgInfo.find(
|
||||
(newOrg) => newOrg.orgId === currentOrg.orgId
|
||||
);
|
||||
return newOrg && newOrg.roleId !== currentOrg.roleId;
|
||||
}
|
||||
);
|
||||
|
||||
if (orgsToUpdate.length > 0) {
|
||||
for (const org of orgsToUpdate) {
|
||||
const newRole = userOrgInfo.find(
|
||||
(newOrg) => newOrg.orgId === org.orgId
|
||||
);
|
||||
if (newRole) {
|
||||
await trx
|
||||
.update(userOrgs)
|
||||
.set({ roleId: newRole.roleId })
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, userId!),
|
||||
eq(userOrgs.orgId, org.orgId)
|
||||
)
|
||||
);
|
||||
}
|
||||
// Ensure IDP-provided role exists for existing auto-provisioned orgs (add only; never delete other roles)
|
||||
const userRolesInOrgs = await trx
|
||||
.select()
|
||||
.from(userOrgRoles)
|
||||
.where(eq(userOrgRoles.userId, userId!));
|
||||
for (const currentOrg of autoProvisionedOrgs) {
|
||||
const newRole = userOrgInfo.find(
|
||||
(newOrg) => newOrg.orgId === currentOrg.orgId
|
||||
);
|
||||
if (!newRole) continue;
|
||||
const currentRolesInOrg = userRolesInOrgs.filter(
|
||||
(r) => r.orgId === currentOrg.orgId
|
||||
);
|
||||
const hasIdpRole = currentRolesInOrg.some(
|
||||
(r) => r.roleId === newRole.roleId
|
||||
);
|
||||
if (!hasIdpRole) {
|
||||
await trx.insert(userOrgRoles).values({
|
||||
userId: userId!,
|
||||
orgId: currentOrg.orgId,
|
||||
roleId: newRole.roleId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,9 +616,9 @@ export async function validateOidcCallback(
|
||||
{
|
||||
orgId: org.orgId,
|
||||
userId: userId!,
|
||||
roleId: org.roleId,
|
||||
autoProvisioned: true,
|
||||
},
|
||||
org.roleId,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
@@ -532,6 +532,16 @@ authenticated.post(
|
||||
user.addUserRole
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/role/:roleId/remove/:userId",
|
||||
verifyApiKeyRoleAccess,
|
||||
verifyApiKeyUserAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.removeUserRole),
|
||||
logActionAudit(ActionsEnum.removeUserRole),
|
||||
user.removeUserRole
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/resource/:resourceId/roles",
|
||||
verifyApiKeyResourceAccess,
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function createNewt(
|
||||
|
||||
const { newtId, secret } = parsedBody.data;
|
||||
|
||||
if (req.user && !req.userOrgRoleId) {
|
||||
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||
);
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function createNewt(
|
||||
|
||||
const { newtId, secret } = parsedBody.data;
|
||||
|
||||
if (req.user && !req.userOrgRoleId) {
|
||||
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, idp, idpOidcConfig } from "@server/db";
|
||||
import { roles, userOrgs, users } from "@server/db";
|
||||
import { roles, userOrgRoles, userOrgs, users } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -14,7 +14,7 @@ import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { CheckOrgAccessPolicyResult } from "@server/lib/checkOrgAccessPolicy";
|
||||
|
||||
async function queryUser(orgId: string, userId: string) {
|
||||
const [user] = await db
|
||||
const [userRow] = await db
|
||||
.select({
|
||||
orgId: userOrgs.orgId,
|
||||
userId: users.userId,
|
||||
@@ -22,10 +22,7 @@ async function queryUser(orgId: string, userId: string) {
|
||||
username: users.username,
|
||||
name: users.name,
|
||||
type: users.type,
|
||||
roleId: userOrgs.roleId,
|
||||
roleName: roles.name,
|
||||
isOwner: userOrgs.isOwner,
|
||||
isAdmin: roles.isAdmin,
|
||||
twoFactorEnabled: users.twoFactorEnabled,
|
||||
autoProvisioned: userOrgs.autoProvisioned,
|
||||
idpId: users.idpId,
|
||||
@@ -35,13 +32,40 @@ async function queryUser(orgId: string, userId: string) {
|
||||
idpAutoProvision: idp.autoProvision
|
||||
})
|
||||
.from(userOrgs)
|
||||
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
||||
.leftJoin(users, eq(userOrgs.userId, users.userId))
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
.leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId))
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||
.limit(1);
|
||||
return user;
|
||||
|
||||
if (!userRow) return undefined;
|
||||
|
||||
const roleRows = await db
|
||||
.select({
|
||||
roleId: userOrgRoles.roleId,
|
||||
roleName: roles.name,
|
||||
isAdmin: roles.isAdmin
|
||||
})
|
||||
.from(userOrgRoles)
|
||||
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgRoles.userId, userId),
|
||||
eq(userOrgRoles.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
const isAdmin = roleRows.some((r) => r.isAdmin);
|
||||
|
||||
return {
|
||||
...userRow,
|
||||
isAdmin,
|
||||
roleIds: roleRows.map((r) => r.roleId),
|
||||
roles: roleRows.map((r) => ({
|
||||
roleId: r.roleId,
|
||||
name: r.roleName ?? ""
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export type CheckOrgUserAccessResponse = CheckOrgAccessPolicyResult;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
orgs,
|
||||
roleActions,
|
||||
roles,
|
||||
userOrgRoles,
|
||||
userOrgs,
|
||||
users,
|
||||
actions
|
||||
@@ -312,9 +313,13 @@ export async function createOrg(
|
||||
await trx.insert(userOrgs).values({
|
||||
userId: req.user!.userId,
|
||||
orgId: newOrg[0].orgId,
|
||||
roleId: roleId,
|
||||
isOwner: true
|
||||
});
|
||||
await trx.insert(userOrgRoles).values({
|
||||
userId: req.user!.userId,
|
||||
orgId: newOrg[0].orgId,
|
||||
roleId
|
||||
});
|
||||
ownerUserId = req.user!.userId;
|
||||
} else {
|
||||
// if org created by root api key, set the server admin as the owner
|
||||
@@ -332,9 +337,13 @@ export async function createOrg(
|
||||
await trx.insert(userOrgs).values({
|
||||
userId: serverAdmin.userId,
|
||||
orgId: newOrg[0].orgId,
|
||||
roleId: roleId,
|
||||
isOwner: true
|
||||
});
|
||||
await trx.insert(userOrgRoles).values({
|
||||
userId: serverAdmin.userId,
|
||||
orgId: newOrg[0].orgId,
|
||||
roleId
|
||||
});
|
||||
ownerUserId = serverAdmin.userId;
|
||||
}
|
||||
|
||||
|
||||
@@ -117,20 +117,26 @@ export async function getOrgOverview(
|
||||
.from(userOrgs)
|
||||
.where(eq(userOrgs.orgId, orgId));
|
||||
|
||||
const [role] = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(eq(roles.roleId, req.userOrg.roleId));
|
||||
const roleIds = req.userOrgRoleIds ?? [];
|
||||
const roleRows =
|
||||
roleIds.length > 0
|
||||
? await db
|
||||
.select({ name: roles.name, isAdmin: roles.isAdmin })
|
||||
.from(roles)
|
||||
.where(inArray(roles.roleId, roleIds))
|
||||
: [];
|
||||
const userRoleName = roleRows.map((r) => r.name ?? "").join(", ") ?? "";
|
||||
const isAdmin = roleRows.some((r) => r.isAdmin === true);
|
||||
|
||||
return response<GetOrgOverviewResponse>(res, {
|
||||
data: {
|
||||
orgName: org[0].name,
|
||||
orgId: org[0].orgId,
|
||||
userRoleName: role.name,
|
||||
userRoleName,
|
||||
numSites,
|
||||
numUsers,
|
||||
numResources,
|
||||
isAdmin: role.isAdmin || false,
|
||||
isAdmin,
|
||||
isOwner: req.userOrg?.isOwner || false
|
||||
},
|
||||
success: true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, roles } from "@server/db";
|
||||
import { Org, orgs, userOrgs } from "@server/db";
|
||||
import { Org, orgs, userOrgRoles, userOrgs } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -82,10 +82,7 @@ export async function listUserOrgs(
|
||||
const { userId } = parsedParams.data;
|
||||
|
||||
const userOrganizations = await db
|
||||
.select({
|
||||
orgId: userOrgs.orgId,
|
||||
roleId: userOrgs.roleId
|
||||
})
|
||||
.select({ orgId: userOrgs.orgId })
|
||||
.from(userOrgs)
|
||||
.where(eq(userOrgs.userId, userId));
|
||||
|
||||
@@ -116,10 +113,27 @@ export async function listUserOrgs(
|
||||
userOrgs,
|
||||
and(eq(userOrgs.orgId, orgs.orgId), eq(userOrgs.userId, userId))
|
||||
)
|
||||
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const roleRows = await db
|
||||
.select({
|
||||
orgId: userOrgRoles.orgId,
|
||||
isAdmin: roles.isAdmin
|
||||
})
|
||||
.from(userOrgRoles)
|
||||
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgRoles.userId, userId),
|
||||
inArray(userOrgRoles.orgId, userOrgIds)
|
||||
)
|
||||
);
|
||||
|
||||
const orgHasAdmin = new Set(
|
||||
roleRows.filter((r) => r.isAdmin).map((r) => r.orgId)
|
||||
);
|
||||
|
||||
const totalCountResult = await db
|
||||
.select({ count: sql<number>`cast(count(*) as integer)` })
|
||||
.from(orgs)
|
||||
@@ -133,8 +147,8 @@ export async function listUserOrgs(
|
||||
if (val.userOrgs && val.userOrgs.isOwner) {
|
||||
res.isOwner = val.userOrgs.isOwner;
|
||||
}
|
||||
if (val.roles && val.roles.isAdmin) {
|
||||
res.isAdmin = val.roles.isAdmin;
|
||||
if (val.orgs && orgHasAdmin.has(val.orgs.orgId)) {
|
||||
res.isAdmin = true;
|
||||
}
|
||||
if (val.userOrgs?.isOwner && val.orgs?.isBillingOrg) {
|
||||
res.isPrimaryOrg = val.orgs.isBillingOrg;
|
||||
|
||||
@@ -112,7 +112,7 @@ export async function createResource(
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
if (req.user && !req.userOrgRoleId) {
|
||||
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||
);
|
||||
@@ -278,7 +278,7 @@ async function createHttpResource(
|
||||
resourceId: newResource[0].resourceId
|
||||
});
|
||||
|
||||
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
|
||||
if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
|
||||
// make sure the user can access the resource
|
||||
await trx.insert(userResources).values({
|
||||
userId: req.user?.userId!,
|
||||
@@ -371,7 +371,7 @@ async function createRawResource(
|
||||
resourceId: newResource[0].resourceId
|
||||
});
|
||||
|
||||
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
|
||||
if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
|
||||
// make sure the user can access the resource
|
||||
await trx.insert(userResources).values({
|
||||
userId: req.user?.userId!,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
resources,
|
||||
userResources,
|
||||
roleResources,
|
||||
userOrgRoles,
|
||||
userOrgs,
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
@@ -32,22 +33,29 @@ export async function getUserResources(
|
||||
);
|
||||
}
|
||||
|
||||
// First get the user's role in the organization
|
||||
const userOrgResult = await db
|
||||
.select({
|
||||
roleId: userOrgs.roleId
|
||||
})
|
||||
// Check user is in organization and get their role IDs
|
||||
const [userOrg] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (userOrgResult.length === 0) {
|
||||
if (!userOrg) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User not in organization")
|
||||
);
|
||||
}
|
||||
|
||||
const userRoleId = userOrgResult[0].roleId;
|
||||
const userRoleIds = await db
|
||||
.select({ roleId: userOrgRoles.roleId })
|
||||
.from(userOrgRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgRoles.userId, userId),
|
||||
eq(userOrgRoles.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows.map((r) => r.roleId));
|
||||
|
||||
// Get resources accessible through direct assignment or role assignment
|
||||
const directResourcesQuery = db
|
||||
@@ -55,20 +63,28 @@ export async function getUserResources(
|
||||
.from(userResources)
|
||||
.where(eq(userResources.userId, userId));
|
||||
|
||||
const roleResourcesQuery = db
|
||||
.select({ resourceId: roleResources.resourceId })
|
||||
.from(roleResources)
|
||||
.where(eq(roleResources.roleId, userRoleId));
|
||||
const roleResourcesQuery =
|
||||
userRoleIds.length > 0
|
||||
? db
|
||||
.select({ resourceId: roleResources.resourceId })
|
||||
.from(roleResources)
|
||||
.where(inArray(roleResources.roleId, userRoleIds))
|
||||
: Promise.resolve([]);
|
||||
|
||||
const directSiteResourcesQuery = db
|
||||
.select({ siteResourceId: userSiteResources.siteResourceId })
|
||||
.from(userSiteResources)
|
||||
.where(eq(userSiteResources.userId, userId));
|
||||
|
||||
const roleSiteResourcesQuery = db
|
||||
.select({ siteResourceId: roleSiteResources.siteResourceId })
|
||||
.from(roleSiteResources)
|
||||
.where(eq(roleSiteResources.roleId, userRoleId));
|
||||
const roleSiteResourcesQuery =
|
||||
userRoleIds.length > 0
|
||||
? db
|
||||
.select({
|
||||
siteResourceId: roleSiteResources.siteResourceId
|
||||
})
|
||||
.from(roleSiteResources)
|
||||
.where(inArray(roleSiteResources.roleId, userRoleIds))
|
||||
: Promise.resolve([]);
|
||||
|
||||
const [directResources, roleResourceResults, directSiteResourceResults, roleSiteResourceResults] = await Promise.all([
|
||||
directResourcesQuery,
|
||||
|
||||
@@ -276,7 +276,7 @@ export async function listResources(
|
||||
.where(
|
||||
or(
|
||||
eq(userResources.userId, req.user!.userId),
|
||||
eq(roleResources.roleId, req.userOrgRoleId!)
|
||||
inArray(roleResources.roleId, req.userOrgRoleIds!)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { roles, userOrgs } from "@server/db";
|
||||
import { roles, userOrgRoles } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -114,11 +114,11 @@ export async function deleteRole(
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
// move all users from the userOrgs table with roleId to newRoleId
|
||||
// move all users from userOrgRoles with roleId to newRoleId
|
||||
await trx
|
||||
.update(userOrgs)
|
||||
.update(userOrgRoles)
|
||||
.set({ roleId: newRoleId })
|
||||
.where(eq(userOrgs.roleId, roleId));
|
||||
.where(eq(userOrgRoles.roleId, roleId));
|
||||
|
||||
// delete the old role
|
||||
await trx.delete(roles).where(eq(roles.roleId, roleId));
|
||||
|
||||
@@ -111,7 +111,7 @@ export async function createSite(
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
if (req.user && !req.userOrgRoleId) {
|
||||
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||
);
|
||||
@@ -399,7 +399,7 @@ export async function createSite(
|
||||
siteId: newSite.siteId
|
||||
});
|
||||
|
||||
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
|
||||
if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
|
||||
// make sure the user can access the site
|
||||
trx.insert(userSites).values({
|
||||
userId: req.user?.userId!,
|
||||
|
||||
@@ -235,7 +235,7 @@ export async function listSites(
|
||||
.where(
|
||||
or(
|
||||
eq(userSites.userId, req.user!.userId),
|
||||
eq(roleSites.roleId, req.userOrgRoleId!)
|
||||
inArray(roleSites.roleId, req.userOrgRoleIds!)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -165,9 +165,9 @@ export async function acceptInvite(
|
||||
org,
|
||||
{
|
||||
userId: existingUser[0].userId,
|
||||
orgId: existingInvite.orgId,
|
||||
roleId: existingInvite.roleId
|
||||
orgId: existingInvite.orgId
|
||||
},
|
||||
existingInvite.roleId,
|
||||
trx
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { clients, db, UserOrg } from "@server/db";
|
||||
import { userOrgs, roles } from "@server/db";
|
||||
import { clients, db } from "@server/db";
|
||||
import { userOrgRoles, userOrgs, roles } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -111,20 +111,23 @@ export async function addUserRole(
|
||||
);
|
||||
}
|
||||
|
||||
let newUserRole: UserOrg | null = null;
|
||||
let newUserRole: { userId: string; orgId: string; roleId: number } | null =
|
||||
null;
|
||||
await db.transaction(async (trx) => {
|
||||
[newUserRole] = await trx
|
||||
.update(userOrgs)
|
||||
.set({ roleId })
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, userId),
|
||||
eq(userOrgs.orgId, role.orgId)
|
||||
)
|
||||
)
|
||||
const inserted = await trx
|
||||
.insert(userOrgRoles)
|
||||
.values({
|
||||
userId,
|
||||
orgId: role.orgId,
|
||||
roleId
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
// get the client associated with this user in this org
|
||||
if (inserted.length > 0) {
|
||||
newUserRole = inserted[0];
|
||||
}
|
||||
|
||||
const orgClients = await trx
|
||||
.select()
|
||||
.from(clients)
|
||||
@@ -133,17 +136,15 @@ export async function addUserRole(
|
||||
eq(clients.userId, userId),
|
||||
eq(clients.orgId, role.orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
);
|
||||
|
||||
for (const orgClient of orgClients) {
|
||||
// we just changed the user's role, so we need to rebuild client associations and what they have access to
|
||||
await rebuildClientAssociationsFromClient(orgClient, trx);
|
||||
}
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: newUserRole,
|
||||
data: newUserRole ?? { userId, orgId: role.orgId, roleId },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Role added to user successfully",
|
||||
|
||||
@@ -221,12 +221,16 @@ export async function createOrgUser(
|
||||
);
|
||||
}
|
||||
|
||||
await assignUserToOrg(org, {
|
||||
orgId,
|
||||
userId: existingUser.userId,
|
||||
roleId: role.roleId,
|
||||
autoProvisioned: false
|
||||
}, trx);
|
||||
await assignUserToOrg(
|
||||
org,
|
||||
{
|
||||
orgId,
|
||||
userId: existingUser.userId,
|
||||
autoProvisioned: false,
|
||||
},
|
||||
role.roleId,
|
||||
trx
|
||||
);
|
||||
} else {
|
||||
userId = generateId(15);
|
||||
|
||||
@@ -244,12 +248,16 @@ export async function createOrgUser(
|
||||
})
|
||||
.returning();
|
||||
|
||||
await assignUserToOrg(org, {
|
||||
orgId,
|
||||
userId: newUser.userId,
|
||||
roleId: role.roleId,
|
||||
autoProvisioned: false
|
||||
}, trx);
|
||||
await assignUserToOrg(
|
||||
org,
|
||||
{
|
||||
orgId,
|
||||
userId: newUser.userId,
|
||||
autoProvisioned: false,
|
||||
},
|
||||
role.roleId,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
await calculateUserClientsForOrgs(userId, trx);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, idp, idpOidcConfig } from "@server/db";
|
||||
import { roles, userOrgs, users } from "@server/db";
|
||||
import { roles, userOrgRoles, userOrgs, users } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -12,7 +12,7 @@ import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
async function queryUser(orgId: string, userId: string) {
|
||||
const [user] = await db
|
||||
const [userRow] = await db
|
||||
.select({
|
||||
orgId: userOrgs.orgId,
|
||||
userId: users.userId,
|
||||
@@ -20,10 +20,7 @@ async function queryUser(orgId: string, userId: string) {
|
||||
username: users.username,
|
||||
name: users.name,
|
||||
type: users.type,
|
||||
roleId: userOrgs.roleId,
|
||||
roleName: roles.name,
|
||||
isOwner: userOrgs.isOwner,
|
||||
isAdmin: roles.isAdmin,
|
||||
twoFactorEnabled: users.twoFactorEnabled,
|
||||
autoProvisioned: userOrgs.autoProvisioned,
|
||||
idpId: users.idpId,
|
||||
@@ -33,13 +30,40 @@ async function queryUser(orgId: string, userId: string) {
|
||||
idpAutoProvision: idp.autoProvision
|
||||
})
|
||||
.from(userOrgs)
|
||||
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
||||
.leftJoin(users, eq(userOrgs.userId, users.userId))
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
.leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId))
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||
.limit(1);
|
||||
return user;
|
||||
|
||||
if (!userRow) return undefined;
|
||||
|
||||
const roleRows = await db
|
||||
.select({
|
||||
roleId: userOrgRoles.roleId,
|
||||
roleName: roles.name,
|
||||
isAdmin: roles.isAdmin
|
||||
})
|
||||
.from(userOrgRoles)
|
||||
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgRoles.userId, userId),
|
||||
eq(userOrgRoles.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
const isAdmin = roleRows.some((r) => r.isAdmin);
|
||||
|
||||
return {
|
||||
...userRow,
|
||||
isAdmin,
|
||||
roleIds: roleRows.map((r) => r.roleId),
|
||||
roles: roleRows.map((r) => ({
|
||||
roleId: r.roleId,
|
||||
name: r.roleName ?? ""
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export type GetOrgUserResponse = NonNullable<
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from "./getUser";
|
||||
export * from "./removeUserOrg";
|
||||
export * from "./listUsers";
|
||||
export * from "./addUserRole";
|
||||
export * from "./removeUserRole";
|
||||
export * from "./inviteUser";
|
||||
export * from "./acceptInvite";
|
||||
export * from "./getOrgUser";
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, idpOidcConfig } from "@server/db";
|
||||
import { idp, roles, userOrgs, users } from "@server/db";
|
||||
import { idp, roles, userOrgRoles, userOrgs, users } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { and, sql } from "drizzle-orm";
|
||||
import { sql } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
@@ -31,7 +31,7 @@ const listUsersSchema = z.strictObject({
|
||||
});
|
||||
|
||||
async function queryUsers(orgId: string, limit: number, offset: number) {
|
||||
return await db
|
||||
const rows = await db
|
||||
.select({
|
||||
id: users.userId,
|
||||
email: users.email,
|
||||
@@ -41,8 +41,6 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
|
||||
username: users.username,
|
||||
name: users.name,
|
||||
type: users.type,
|
||||
roleId: userOrgs.roleId,
|
||||
roleName: roles.name,
|
||||
isOwner: userOrgs.isOwner,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId,
|
||||
@@ -52,12 +50,39 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
|
||||
.where(eq(userOrgs.orgId, orgId))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const roleRows = await db
|
||||
.select({
|
||||
userId: userOrgRoles.userId,
|
||||
roleId: userOrgRoles.roleId,
|
||||
roleName: roles.name
|
||||
})
|
||||
.from(userOrgRoles)
|
||||
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
||||
.where(eq(userOrgRoles.orgId, orgId));
|
||||
|
||||
const rolesByUser = new Map<
|
||||
string,
|
||||
{ roleId: number; roleName: string }[]
|
||||
>();
|
||||
for (const r of roleRows) {
|
||||
const list = rolesByUser.get(r.userId) ?? [];
|
||||
list.push({ roleId: r.roleId, roleName: r.roleName ?? "" });
|
||||
rolesByUser.set(r.userId, list);
|
||||
}
|
||||
|
||||
return rows.map((row) => {
|
||||
const userRoles = rolesByUser.get(row.id) ?? [];
|
||||
return {
|
||||
...row,
|
||||
roles: userRoles
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type ListUsersResponse = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db, Olm, olms, orgs, userOrgs } from "@server/db";
|
||||
import { db, Olm, olms, orgs, userOrgRoles, userOrgs } from "@server/db";
|
||||
import { idp, users } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
@@ -84,16 +84,31 @@ export async function myDevice(
|
||||
.from(olms)
|
||||
.where(and(eq(olms.userId, userId), eq(olms.olmId, olmId)));
|
||||
|
||||
const userOrganizations = await db
|
||||
const userOrgRows = await db
|
||||
.select({
|
||||
orgId: userOrgs.orgId,
|
||||
orgName: orgs.name,
|
||||
roleId: userOrgs.roleId
|
||||
orgName: orgs.name
|
||||
})
|
||||
.from(userOrgs)
|
||||
.where(eq(userOrgs.userId, userId))
|
||||
.innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId));
|
||||
|
||||
const roleRows = await db
|
||||
.select({
|
||||
orgId: userOrgRoles.orgId,
|
||||
roleId: userOrgRoles.roleId
|
||||
})
|
||||
.from(userOrgRoles)
|
||||
.where(eq(userOrgRoles.userId, userId));
|
||||
|
||||
const roleByOrg = new Map(
|
||||
roleRows.map((r) => [r.orgId, r.roleId])
|
||||
);
|
||||
const userOrganizations = userOrgRows.map((row) => ({
|
||||
...row,
|
||||
roleId: roleByOrg.get(row.orgId) ?? 0
|
||||
}));
|
||||
|
||||
return response<MyDeviceResponse>(res, {
|
||||
data: {
|
||||
user,
|
||||
|
||||
157
server/routers/user/removeUserRole.ts
Normal file
157
server/routers/user/removeUserRole.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { userOrgRoles, userOrgs, roles, clients } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import stoi from "@server/lib/stoi";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
|
||||
|
||||
const removeUserRoleParamsSchema = z.strictObject({
|
||||
userId: z.string(),
|
||||
roleId: z.string().transform(stoi).pipe(z.number())
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/role/{roleId}/remove/{userId}",
|
||||
description: "Remove a role from a user. User must have at least one role left in the org.",
|
||||
tags: [OpenAPITags.Role, OpenAPITags.User],
|
||||
request: {
|
||||
params: removeUserRoleParamsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function removeUserRole(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = removeUserRoleParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { userId, roleId } = parsedParams.data;
|
||||
|
||||
if (req.user && !req.userOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"You do not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [role] = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(eq(roles.roleId, roleId))
|
||||
.limit(1);
|
||||
|
||||
if (!role) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID")
|
||||
);
|
||||
}
|
||||
|
||||
const [existingUser] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existingUser) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"User not found or does not belong to the specified organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (existingUser.isOwner) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Cannot change the roles of the owner of the organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const remainingRoles = await db
|
||||
.select({ roleId: userOrgRoles.roleId })
|
||||
.from(userOrgRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgRoles.userId, userId),
|
||||
eq(userOrgRoles.orgId, role.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (remainingRoles.length <= 1) {
|
||||
const hasThisRole = remainingRoles.some((r) => r.roleId === roleId);
|
||||
if (hasThisRole) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User must have at least one role in the organization. Remove the last role is not allowed."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(userOrgRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgRoles.userId, userId),
|
||||
eq(userOrgRoles.orgId, role.orgId),
|
||||
eq(userOrgRoles.roleId, roleId)
|
||||
)
|
||||
);
|
||||
|
||||
const orgClients = await trx
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(
|
||||
and(
|
||||
eq(clients.userId, userId),
|
||||
eq(clients.orgId, role.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
for (const orgClient of orgClients) {
|
||||
await rebuildClientAssociationsFromClient(orgClient, trx);
|
||||
}
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: { userId, orgId: role.orgId, roleId },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Role removed from user successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export default async function migration() {
|
||||
// all roles set hoemdir to true
|
||||
|
||||
// generate ca certs for all orgs?
|
||||
// set authDaemonMode to "site" for all orgs
|
||||
// set authDaemonMode to "site" for all site-resources
|
||||
|
||||
try {
|
||||
db.transaction(() => {})();
|
||||
|
||||
@@ -5,5 +5,5 @@ import { Session } from "@server/db";
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user: User;
|
||||
session: Session;
|
||||
userOrgRoleId?: number;
|
||||
userOrgRoleIds?: number[];
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { InviteUserResponse } from "@server/routers/user";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -44,6 +42,9 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import IdpTypeBadge from "@app/components/IdpTypeBadge";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
|
||||
type UserRole = { roleId: number; name: string };
|
||||
|
||||
export default function AccessControlsPage() {
|
||||
const { orgUser: user } = userOrgUserContext();
|
||||
@@ -54,12 +55,12 @@ export default function AccessControlsPage() {
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
||||
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const formSchema = z.object({
|
||||
username: z.string(),
|
||||
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }),
|
||||
autoProvisioned: z.boolean()
|
||||
});
|
||||
|
||||
@@ -67,11 +68,17 @@ export default function AccessControlsPage() {
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
username: user.username!,
|
||||
roleId: user.roleId?.toString(),
|
||||
autoProvisioned: user.autoProvisioned || false
|
||||
}
|
||||
});
|
||||
|
||||
const currentRoleIds = user.roleIds ?? [];
|
||||
const currentRoles: UserRole[] = user.roles ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
setUserRoles(currentRoles);
|
||||
}, [user.userId, currentRoleIds.join(",")]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchRoles() {
|
||||
const res = await api
|
||||
@@ -94,32 +101,20 @@ export default function AccessControlsPage() {
|
||||
}
|
||||
|
||||
fetchRoles();
|
||||
|
||||
form.setValue("roleId", user.roleId.toString());
|
||||
form.setValue("autoProvisioned", user.autoProvisioned || false);
|
||||
}, []);
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
async function handleAddRole(roleId: number) {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Execute both API calls simultaneously
|
||||
const [roleRes, userRes] = await Promise.all([
|
||||
api.post<AxiosResponse<InviteUserResponse>>(
|
||||
`/role/${values.roleId}/add/${user.userId}`
|
||||
),
|
||||
api.post(`/org/${orgId}/user/${user.userId}`, {
|
||||
autoProvisioned: values.autoProvisioned
|
||||
})
|
||||
]);
|
||||
|
||||
if (roleRes.status === 200 && userRes.status === 200) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t("userSaved"),
|
||||
description: t("userSavedDescription")
|
||||
});
|
||||
}
|
||||
await api.post(`/role/${roleId}/add/${user.userId}`);
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t("userSaved"),
|
||||
description: t("userSavedDescription")
|
||||
});
|
||||
const role = roles.find((r) => r.roleId === roleId);
|
||||
if (role) setUserRoles((prev) => [...prev, role]);
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
@@ -130,10 +125,61 @@ export default function AccessControlsPage() {
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function handleRemoveRole(roleId: number) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.delete(`/role/${roleId}/remove/${user.userId}`);
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t("userSaved"),
|
||||
description: t("userSavedDescription")
|
||||
});
|
||||
setUserRoles((prev) => prev.filter((r) => r.roleId !== roleId));
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("accessRoleErrorAdd"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("accessRoleErrorAddDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post(`/org/${orgId}/user/${user.userId}`, {
|
||||
autoProvisioned: values.autoProvisioned
|
||||
});
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t("userSaved"),
|
||||
description: t("userSavedDescription")
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("accessRoleErrorAdd"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("accessRoleErrorAddDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const availableRolesToAdd = roles.filter(
|
||||
(r) => !userRoles.some((ur) => ur.roleId === r.roleId)
|
||||
);
|
||||
const canRemoveRole = userRoles.length > 1;
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
@@ -154,7 +200,6 @@ export default function AccessControlsPage() {
|
||||
className="space-y-4"
|
||||
id="access-controls-form"
|
||||
>
|
||||
{/* IDP Type Display */}
|
||||
{user.type !== UserType.Internal &&
|
||||
user.idpType && (
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
@@ -171,49 +216,72 @@ export default function AccessControlsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("role")}</FormLabel>
|
||||
<FormItem>
|
||||
<FormLabel>{t("role")}</FormLabel>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{userRoles.map((r) => (
|
||||
<Badge
|
||||
key={r.roleId}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{r.name}
|
||||
{canRemoveRole && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleRemoveRole(
|
||||
r.roleId
|
||||
)
|
||||
}
|
||||
disabled={loading}
|
||||
className="ml-1 rounded hover:bg-muted"
|
||||
aria-label={`Remove ${r.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
{availableRolesToAdd.length > 0 && (
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
// If auto provision is enabled, set it to false when role changes
|
||||
if (user.idpAutoProvision) {
|
||||
form.setValue(
|
||||
"autoProvisioned",
|
||||
false
|
||||
);
|
||||
}
|
||||
handleAddRole(
|
||||
parseInt(value, 10)
|
||||
);
|
||||
}}
|
||||
value={field.value}
|
||||
disabled={loading}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"accessRoleSelect"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"accessRoleSelect"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem
|
||||
key={role.roleId}
|
||||
value={role.roleId.toString()}
|
||||
>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{availableRolesToAdd.map(
|
||||
(role) => (
|
||||
<SelectItem
|
||||
key={
|
||||
role.roleId
|
||||
}
|
||||
value={role.roleId.toString()}
|
||||
>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
</div>
|
||||
{userRoles.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("accessRoleSelectPlease")}
|
||||
</p>
|
||||
)}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
{user.idpAutoProvision && (
|
||||
<FormField
|
||||
@@ -231,7 +299,9 @@ export default function AccessControlsPage() {
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>
|
||||
{t("autoProvisioned")}
|
||||
{t(
|
||||
"autoProvisioned"
|
||||
)}
|
||||
</FormLabel>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
|
||||
@@ -88,7 +88,9 @@ export default async function UsersPage(props: UsersPageProps) {
|
||||
status: t("userConfirmed"),
|
||||
role: user.isOwner
|
||||
? t("accessRoleOwner")
|
||||
: user.roleName || t("accessRoleMember"),
|
||||
: user.roles?.length
|
||||
? user.roles.map((r) => r.roleName).join(", ")
|
||||
: t("accessRoleMember"),
|
||||
isOwner: user.isOwner || false
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Geist, Inter, Manrope, Open_Sans } from "next/font/google";
|
||||
import { ThemeProvider } from "@app/providers/ThemeProvider";
|
||||
import EnvProvider from "@app/providers/EnvProvider";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
@@ -24,6 +23,7 @@ import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
|
||||
import { TailwindIndicator } from "@app/components/TailwindIndicator";
|
||||
import { ViewportHeightFix } from "@app/components/ViewportHeightFix";
|
||||
import StoreInternalRedirect from "@app/components/StoreInternalRedirect";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
|
||||
@@ -32,10 +32,12 @@ export const metadata: Metadata = {
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const font = Inter({
|
||||
const inter = Inter({
|
||||
subsets: ["latin"]
|
||||
});
|
||||
|
||||
const fontClassName = inter.className;
|
||||
|
||||
export default async function RootLayout({
|
||||
children
|
||||
}: Readonly<{
|
||||
@@ -79,7 +81,7 @@ export default async function RootLayout({
|
||||
|
||||
return (
|
||||
<html suppressHydrationWarning lang={locale}>
|
||||
<body className={`${font.className} h-screen-safe overflow-hidden`}>
|
||||
<body className={`${fontClassName} h-screen-safe overflow-hidden`}>
|
||||
<StoreInternalRedirect />
|
||||
<TopLoader />
|
||||
{/* build === "saas" && (
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<InfoSections cols={4}>
|
||||
<InfoSections cols={3}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("name")}</InfoSectionTitle>
|
||||
<InfoSectionContent>{client.name}</InfoSectionContent>
|
||||
@@ -55,12 +55,6 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("address")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{client.subnet.split("/")[0]}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -5,13 +5,11 @@ import { SidebarNav } from "@app/components/SidebarNav";
|
||||
import { OrgSelector } from "@app/components/OrgSelector";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import SupporterStatus from "@app/components/SupporterStatus";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ExternalLink, Menu, Server } from "lucide-react";
|
||||
import { ArrowRight, Menu, Server } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import ProfileIcon from "@app/components/ProfileIcon";
|
||||
import ThemeSwitcher from "@app/components/ThemeSwitcher";
|
||||
@@ -44,7 +42,6 @@ export function LayoutMobileMenu({
|
||||
const pathname = usePathname();
|
||||
const isAdminPage = pathname?.startsWith("/admin");
|
||||
const { user } = useUserContext();
|
||||
const { env } = useEnvContext();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
@@ -83,7 +80,7 @@ export function LayoutMobileMenu({
|
||||
<div className="px-3 pt-3">
|
||||
{!isAdminPage &&
|
||||
user.serverAdmin && (
|
||||
<div className="py-2">
|
||||
<div className="mb-1">
|
||||
<Link
|
||||
href="/admin"
|
||||
className={cn(
|
||||
@@ -98,11 +95,12 @@ export function LayoutMobileMenu({
|
||||
<span className="flex-shrink-0 mr-2">
|
||||
<Server className="h-4 w-4" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="flex-1">
|
||||
{t(
|
||||
"serverAdmin"
|
||||
)}
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 ml-auto opacity-70" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
@@ -115,22 +113,6 @@ 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 />
|
||||
{env?.app?.version && (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
<Link
|
||||
href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1"
|
||||
>
|
||||
v{env.app.version}
|
||||
<ExternalLink size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
@@ -146,6 +146,46 @@ export function LayoutSidebar({
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto relative">
|
||||
<div className="px-2 pt-3">
|
||||
{!isAdminPage && user.serverAdmin && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isSidebarCollapsed ? "mb-4" : "mb-1"
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href="/admin"
|
||||
className={cn(
|
||||
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/80 dark:hover:bg-secondary/50 rounded-md",
|
||||
isSidebarCollapsed
|
||||
? "px-2 py-2 justify-center"
|
||||
: "px-3 py-1.5"
|
||||
)}
|
||||
title={
|
||||
isSidebarCollapsed
|
||||
? t("serverAdmin")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
!isSidebarCollapsed && "mr-2"
|
||||
)}
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
</span>
|
||||
{!isSidebarCollapsed && (
|
||||
<>
|
||||
<span className="flex-1">
|
||||
{t("serverAdmin")}
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 ml-auto opacity-70" />
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<SidebarNav
|
||||
sections={navItems}
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
@@ -156,40 +196,6 @@ export function LayoutSidebar({
|
||||
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
|
||||
</div>
|
||||
|
||||
{!isAdminPage && user.serverAdmin && (
|
||||
<div className="shrink-0 px-2 pb-2">
|
||||
<Link
|
||||
href="/admin"
|
||||
className={cn(
|
||||
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/80 dark:hover:bg-secondary/50 rounded-md",
|
||||
isSidebarCollapsed
|
||||
? "px-2 py-2 justify-center"
|
||||
: "px-3 py-1.5"
|
||||
)}
|
||||
title={
|
||||
isSidebarCollapsed ? t("serverAdmin") : undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
!isSidebarCollapsed && "mr-2"
|
||||
)}
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
</span>
|
||||
{!isSidebarCollapsed && (
|
||||
<>
|
||||
<span className="flex-1">
|
||||
{t("serverAdmin")}
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 ml-auto opacity-70" />
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSidebarCollapsed && (
|
||||
<div className="shrink-0 flex justify-center py-2">
|
||||
<TooltipProvider>
|
||||
@@ -218,7 +224,7 @@ export function LayoutSidebar({
|
||||
|
||||
<div className="w-full border-t border-border mb-3" />
|
||||
|
||||
<div className="p-4 pt-0 mt-0 flex flex-col shrink-0">
|
||||
<div className="p-4 pt-1 flex flex-col shrink-0">
|
||||
{canShowProductUpdates && (
|
||||
<div className="mb-3 empty:mb-0">
|
||||
<ProductUpdates isCollapsed={isSidebarCollapsed} />
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<InfoSections cols={4}>
|
||||
<InfoSections cols={3}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
||||
<InfoSectionContent>{site.niceId}</InfoSectionContent>
|
||||
@@ -68,15 +68,6 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
{getConnectionTypeString(site.type)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
|
||||
{site.type == "newt" && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Address</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{site.address?.split("/")[0]}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
Reference in New Issue
Block a user