feat: implement RdClient login functionality and enhance authentication flow

- Added a dedicated login page for the Remote Desktop client, allowing operators to sign in when their session expires.
- Implemented safe return URL handling for the login process.
- Updated authentication middleware to support RdClient-specific routes and permissions.
- Enhanced localization support for the new login feature across multiple languages.
- Improved user experience by redirecting to the appropriate pages based on authentication status.
This commit is contained in:
UNITRONIX
2026-06-14 12:51:30 +02:00
parent e3278ea6cb
commit bb3da6b801
35 changed files with 1374 additions and 43 deletions
+1
View File
@@ -10,6 +10,7 @@
### Added
- **RdClient dashboard (web):** **Tools → Remote Desktop** opens `/remote` in a new browser tab — standalone operator device list with branding, search/filters, and **Connect** (same `/remote/:id` viewer as Devices). Devices → Web Remote unchanged.
- **RdClient login (web):** `/remote/login` — dedicated operator sign-in when the panel session expires (redirects to `/remote` or viewer instead of admin `/login`); optional **Remember me** stores encrypted credentials in IndexedDB (Web Crypto AES-GCM), separate from the peer password vault planned for Phase B.
### Changed
- _(none yet)_
+19
View File
@@ -3819,5 +3819,24 @@
"open_panel": "فتح لوحة التحكم",
"devices_count": "{count} أجهزة",
"retry": "إعادة المحاولة"
},
"rdclient_login": {
"title": "عميل سطح المكتب البعيد",
"subtitle": "سجّل الدخول للاتصال بأجهزتك",
"username_placeholder": "اسم المستخدم",
"password_placeholder": "كلمة المرور",
"remember_me": "تذكرني على هذا الجهاز",
"sign_in": "تسجيل الدخول",
"totp_title": "المصادقة الثنائية",
"totp_subtitle": "أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة",
"verify": "تحقق",
"back_to_login": "العودة إلى تسجيل الدخول",
"open_console_login": "فتح تسجيل دخول وحدة التحكم",
"session_expired": "انتهت الجلسة. يرجى تسجيل الدخول مرة أخرى.",
"fill_all_fields": "يرجى ملء جميع الحقول",
"invalid_credentials": "بيانات اعتماد غير صالحة",
"network_error": "خطأ في الشبكة",
"enter_6_digits": "أدخل جميع الأرقام الستة",
"invalid_code": "رمز غير صالح"
}
}
+19
View File
@@ -3812,5 +3812,24 @@
"open_panel": "Otevřít konzoli",
"devices_count": "{count} zařízení",
"retry": "Zkusit znovu"
},
"rdclient_login": {
"title": "Klient vzdálené plochy",
"subtitle": "Přihlaste se pro připojení k vašim zařízením",
"username_placeholder": "Uživatelské jméno",
"password_placeholder": "Heslo",
"remember_me": "Zapamatovat si mě na tomto zařízení",
"sign_in": "Přihlásit se",
"totp_title": "Dvoufaktorové ověření",
"totp_subtitle": "Zadejte 6místný kód z vaší autentizační aplikace",
"verify": "Ověřit",
"back_to_login": "Zpět k přihlášení",
"open_console_login": "Otevřít přihlášení konzole",
"session_expired": "Relace vypršela. Přihlaste se znovu.",
"fill_all_fields": "Vyplňte všechna pole",
"invalid_credentials": "Neplatné přihlašovací údaje",
"network_error": "Chyba sítě",
"enter_6_digits": "Zadejte všech 6 číslic",
"invalid_code": "Neplatný kód"
}
}
+19
View File
@@ -3818,5 +3818,24 @@
"open_panel": "Åbn konsol",
"devices_count": "{count} enheder",
"retry": "Prøv igen"
},
"rdclient_login": {
"title": "Remote Desktop-klient",
"subtitle": "Log ind for at oprette forbindelse til dine enheder",
"username_placeholder": "Brugernavn",
"password_placeholder": "Adgangskode",
"remember_me": "Husk mig på denne enhed",
"sign_in": "Log ind",
"totp_title": "To-faktor-godkendelse",
"totp_subtitle": "Indtast den 6-cifrede kode fra din godkendelsesapp",
"verify": "Bekræft",
"back_to_login": "Tilbage til login",
"open_console_login": "Åbn konsol-login",
"session_expired": "Session udløbet. Log ind igen.",
"fill_all_fields": "Udfyld alle felter",
"invalid_credentials": "Ugyldige loginoplysninger",
"network_error": "Netværksfejl",
"enter_6_digits": "Indtast alle 6 cifre",
"invalid_code": "Ugyldig kode"
}
}
+19
View File
@@ -3812,5 +3812,24 @@
"open_panel": "Konsole öffnen",
"devices_count": "{count} Geräte",
"retry": "Erneut versuchen"
},
"rdclient_login": {
"title": "Remote-Desktop-Client",
"subtitle": "Melden Sie sich an, um eine Verbindung zu Ihren Geräten herzustellen",
"username_placeholder": "Benutzername",
"password_placeholder": "Passwort",
"remember_me": "Auf diesem Gerät merken",
"sign_in": "Anmelden",
"totp_title": "Zwei-Faktor-Authentifizierung",
"totp_subtitle": "Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein",
"verify": "Bestätigen",
"back_to_login": "Zurück zur Anmeldung",
"open_console_login": "Konsolen-Anmeldung öffnen",
"session_expired": "Sitzung abgelaufen. Bitte erneut anmelden.",
"fill_all_fields": "Bitte alle Felder ausfüllen",
"invalid_credentials": "Ungültige Anmeldedaten",
"network_error": "Netzwerkfehler",
"enter_6_digits": "Alle 6 Ziffern eingeben",
"invalid_code": "Ungültiger Code"
}
}
+19
View File
@@ -3812,5 +3812,24 @@
"save": "Save notification settings",
"saved": "Notification settings saved"
}
},
"rdclient_login": {
"title": "Remote Desktop Client",
"subtitle": "Sign in to connect to your devices",
"username_placeholder": "Username",
"password_placeholder": "Password",
"remember_me": "Remember me on this device",
"sign_in": "Sign in",
"totp_title": "Two-factor authentication",
"totp_subtitle": "Enter the 6-digit code from your authenticator app",
"verify": "Verify",
"back_to_login": "Back to sign in",
"open_console_login": "Open console login",
"session_expired": "Session expired. Please sign in again.",
"fill_all_fields": "Please fill in all fields",
"invalid_credentials": "Invalid credentials",
"network_error": "Network error",
"enter_6_digits": "Enter all 6 digits",
"invalid_code": "Invalid code"
}
}
+19
View File
@@ -3812,5 +3812,24 @@
"open_panel": "Abrir consola",
"devices_count": "{count} dispositivos",
"retry": "Reintentar"
},
"rdclient_login": {
"title": "Cliente de escritorio remoto",
"subtitle": "Inicie sesión para conectarse a sus dispositivos",
"username_placeholder": "Usuario",
"password_placeholder": "Contraseña",
"remember_me": "Recordarme en este dispositivo",
"sign_in": "Iniciar sesión",
"totp_title": "Autenticación de dos factores",
"totp_subtitle": "Introduzca el código de 6 dígitos de su aplicación de autenticación",
"verify": "Verificar",
"back_to_login": "Volver al inicio de sesión",
"open_console_login": "Abrir inicio de sesión de consola",
"session_expired": "Sesión expirada. Inicie sesión de nuevo.",
"fill_all_fields": "Complete todos los campos",
"invalid_credentials": "Credenciales inválidas",
"network_error": "Error de red",
"enter_6_digits": "Introduzca los 6 dígitos",
"invalid_code": "Código inválido"
}
}
+19
View File
@@ -3818,5 +3818,24 @@
"open_panel": "Avaa konsoli",
"devices_count": "{count} laitetta",
"retry": "Yritä uudelleen"
},
"rdclient_login": {
"title": "Etätyöpöytäasiakas",
"subtitle": "Kirjaudu sisään yhdistääksesi laitteisiisi",
"username_placeholder": "Käyttäjätunnus",
"password_placeholder": "Salasana",
"remember_me": "Muista minut tällä laitteella",
"sign_in": "Kirjaudu sisään",
"totp_title": "Kaksivaiheinen tunnistautuminen",
"totp_subtitle": "Syötä 6-numeroinen koodi tunnistussovelluksestasi",
"verify": "Vahvista",
"back_to_login": "Takaisin kirjautumiseen",
"open_console_login": "Avaa konsolin kirjautuminen",
"session_expired": "Istunto vanhentui. Kirjaudu uudelleen.",
"fill_all_fields": "Täytä kaikki kentät",
"invalid_credentials": "Virheelliset tunnistetiedot",
"network_error": "Verkkovirhe",
"enter_6_digits": "Syötä kaikki 6 numeroa",
"invalid_code": "Virheellinen koodi"
}
}
+19
View File
@@ -3816,5 +3816,24 @@
"open_panel": "Ouvrir la console",
"devices_count": "{count} appareils",
"retry": "Réessayer"
},
"rdclient_login": {
"title": "Client Bureau à distance",
"subtitle": "Connectez-vous pour accéder à vos appareils",
"username_placeholder": "Nom d'utilisateur",
"password_placeholder": "Mot de passe",
"remember_me": "Se souvenir de moi sur cet appareil",
"sign_in": "Se connecter",
"totp_title": "Authentification à deux facteurs",
"totp_subtitle": "Entrez le code à 6 chiffres de votre application d'authentification",
"verify": "Vérifier",
"back_to_login": "Retour à la connexion",
"open_console_login": "Ouvrir la connexion console",
"session_expired": "Session expirée. Veuillez vous reconnecter.",
"fill_all_fields": "Veuillez remplir tous les champs",
"invalid_credentials": "Identifiants invalides",
"network_error": "Erreur réseau",
"enter_6_digits": "Entrez les 6 chiffres",
"invalid_code": "Code invalide"
}
}
+19
View File
@@ -3818,5 +3818,24 @@
"open_panel": "कंसोल खोलें",
"devices_count": "{count} डिवाइस",
"retry": "पुनः प्रयास"
},
"rdclient_login": {
"title": "रिमोट डेस्कटॉप क्लाइंट",
"subtitle": "अपने उपकरणों से कनेक्ट करने के लिए साइन इन करें",
"username_placeholder": "उपयोगकर्ता नाम",
"password_placeholder": "पासवर्ड",
"remember_me": "इस उपकरण पर मुझे याद रखें",
"sign_in": "साइन इन",
"totp_title": "दो-कारक प्रमाणीकरण",
"totp_subtitle": "अपने प्रमाणीकरण ऐप से 6-अंकीय कोड दर्ज करें",
"verify": "सत्यापित करें",
"back_to_login": "साइन इन पर वापस",
"open_console_login": "कंसोल लॉगिन खोलें",
"session_expired": "सत्र समाप्त हो गया। कृपया फिर से साइन इन करें।",
"fill_all_fields": "कृपया सभी फ़ील्ड भरें",
"invalid_credentials": "अमान्य क्रेडेंशियल",
"network_error": "नेटवर्क त्रुटि",
"enter_6_digits": "सभी 6 अंक दर्ज करें",
"invalid_code": "अमान्य कोड"
}
}
+19
View File
@@ -3818,5 +3818,24 @@
"open_panel": "Konzol megnyitása",
"devices_count": "{count} eszköz",
"retry": "Újra"
},
"rdclient_login": {
"title": "Távoli asztal kliens",
"subtitle": "Jelentkezzen be az eszközeihez való csatlakozáshoz",
"username_placeholder": "Felhasználónév",
"password_placeholder": "Jelszó",
"remember_me": "Emlékezzen rám ezen az eszközön",
"sign_in": "Bejelentkezés",
"totp_title": "Kétlépcsős hitelesítés",
"totp_subtitle": "Adja meg a 6 számjegyű kódot a hitelesítő alkalmazásból",
"verify": "Ellenőrzés",
"back_to_login": "Vissza a bejelentkezéshez",
"open_console_login": "Konzol bejelentkezés megnyitása",
"session_expired": "A munkamenet lejárt. Jelentkezzen be újra.",
"fill_all_fields": "Töltse ki az összes mezőt",
"invalid_credentials": "Érvénytelen hitelesítő adatok",
"network_error": "Hálózati hiba",
"enter_6_digits": "Adja meg mind a 6 számjegyet",
"invalid_code": "Érvénytelen kód"
}
}
+19
View File
@@ -3818,5 +3818,24 @@
"open_panel": "Buka konsol",
"devices_count": "{count} perangkat",
"retry": "Coba lagi"
},
"rdclient_login": {
"title": "Klien Desktop Jarak Jauh",
"subtitle": "Masuk untuk terhubung ke perangkat Anda",
"username_placeholder": "Nama pengguna",
"password_placeholder": "Kata sandi",
"remember_me": "Ingat saya di perangkat ini",
"sign_in": "Masuk",
"totp_title": "Autentikasi dua faktor",
"totp_subtitle": "Masukkan kode 6 digit dari aplikasi autentikator Anda",
"verify": "Verifikasi",
"back_to_login": "Kembali ke masuk",
"open_console_login": "Buka login konsol",
"session_expired": "Sesi berakhir. Silakan masuk lagi.",
"fill_all_fields": "Harap isi semua bidang",
"invalid_credentials": "Kredensial tidak valid",
"network_error": "Kesalahan jaringan",
"enter_6_digits": "Masukkan semua 6 digit",
"invalid_code": "Kode tidak valid"
}
}
+19
View File
@@ -3812,5 +3812,24 @@
"open_panel": "Apri console",
"devices_count": "{count} dispositivi",
"retry": "Riprova"
},
"rdclient_login": {
"title": "Client desktop remoto",
"subtitle": "Accedi per connetterti ai tuoi dispositivi",
"username_placeholder": "Nome utente",
"password_placeholder": "Password",
"remember_me": "Ricordami su questo dispositivo",
"sign_in": "Accedi",
"totp_title": "Autenticazione a due fattori",
"totp_subtitle": "Inserisci il codice a 6 cifre dalla tua app di autenticazione",
"verify": "Verifica",
"back_to_login": "Torna all'accesso",
"open_console_login": "Apri accesso console",
"session_expired": "Sessione scaduta. Accedi di nuovo.",
"fill_all_fields": "Compila tutti i campi",
"invalid_credentials": "Credenziali non valide",
"network_error": "Errore di rete",
"enter_6_digits": "Inserisci tutte le 6 cifre",
"invalid_code": "Codice non valido"
}
}
+19
View File
@@ -3812,5 +3812,24 @@
"open_panel": "コンソールを開く",
"devices_count": "{count} 台",
"retry": "再試行"
},
"rdclient_login": {
"title": "リモートデスクトップクライアント",
"subtitle": "デバイスに接続するにはサインインしてください",
"username_placeholder": "ユーザー名",
"password_placeholder": "パスワード",
"remember_me": "このデバイスでログイン情報を保存",
"sign_in": "サインイン",
"totp_title": "二要素認証",
"totp_subtitle": "認証アプリの6桁のコードを入力してください",
"verify": "確認",
"back_to_login": "サインインに戻る",
"open_console_login": "コンソールログインを開く",
"session_expired": "セッションの有効期限が切れました。再度サインインしてください。",
"fill_all_fields": "すべての項目を入力してください",
"invalid_credentials": "認証情報が無効です",
"network_error": "ネットワークエラー",
"enter_6_digits": "6桁すべてを入力してください",
"invalid_code": "無効なコード"
}
}
+19
View File
@@ -3812,5 +3812,24 @@
"open_panel": "콘솔 열기",
"devices_count": "{count}개 장치",
"retry": "다시 시도"
},
"rdclient_login": {
"title": "원격 데스크톱 클라이언트",
"subtitle": "기기에 연결하려면 로그인하세요",
"username_placeholder": "사용자 이름",
"password_placeholder": "비밀번호",
"remember_me": "이 기기에서 로그인 정보 저장",
"sign_in": "로그인",
"totp_title": "2단계 인증",
"totp_subtitle": "인증 앱의 6자리 코드를 입력하세요",
"verify": "확인",
"back_to_login": "로그인으로 돌아가기",
"open_console_login": "콘솔 로그인 열기",
"session_expired": "세션이 만료되었습니다. 다시 로그인하세요.",
"fill_all_fields": "모든 항목을 입력하세요",
"invalid_credentials": "잘못된 자격 증명",
"network_error": "네트워크 오류",
"enter_6_digits": "6자리 모두 입력하세요",
"invalid_code": "잘못된 코드"
}
}
+19
View File
@@ -3818,5 +3818,24 @@
"open_panel": "Åpne konsoll",
"devices_count": "{count} enheter",
"retry": "Prøv igjen"
},
"rdclient_login": {
"title": "Remote Desktop-klient",
"subtitle": "Logg inn for å koble til enhetene dine",
"username_placeholder": "Brukernavn",
"password_placeholder": "Passord",
"remember_me": "Husk meg på denne enheten",
"sign_in": "Logg inn",
"totp_title": "Tofaktorautentisering",
"totp_subtitle": "Skriv inn 6-sifret kode fra autentiseringsappen",
"verify": "Bekreft",
"back_to_login": "Tilbake til innlogging",
"open_console_login": "Åpne konsollinnlogging",
"session_expired": "Økten har utløpt. Logg inn på nytt.",
"fill_all_fields": "Fyll ut alle felt",
"invalid_credentials": "Ugyldige påloggingsdetaljer",
"network_error": "Nettverksfeil",
"enter_6_digits": "Skriv inn alle 6 sifre",
"invalid_code": "Ugyldig kode"
}
}
+19
View File
@@ -3812,5 +3812,24 @@
"open_panel": "Console openen",
"devices_count": "{count} apparaten",
"retry": "Opnieuw"
},
"rdclient_login": {
"title": "Remote Desktop-client",
"subtitle": "Meld u aan om verbinding te maken met uw apparaten",
"username_placeholder": "Gebruikersnaam",
"password_placeholder": "Wachtwoord",
"remember_me": "Onthoud mij op dit apparaat",
"sign_in": "Aanmelden",
"totp_title": "Tweefactorauthenticatie",
"totp_subtitle": "Voer de 6-cijferige code uit uw authenticator-app in",
"verify": "Verifiëren",
"back_to_login": "Terug naar aanmelden",
"open_console_login": "Console-aanmelding openen",
"session_expired": "Sessie verlopen. Meld u opnieuw aan.",
"fill_all_fields": "Vul alle velden in",
"invalid_credentials": "Ongeldige inloggegevens",
"network_error": "Netwerkfout",
"enter_6_digits": "Voer alle 6 cijfers in",
"invalid_code": "Ongeldige code"
}
}
+19
View File
@@ -3812,5 +3812,24 @@
"open_panel": "Otwórz konsolę",
"devices_count": "{count} urządzeń",
"retry": "Ponów"
},
"rdclient_login": {
"title": "Klient pulpitu zdalnego",
"subtitle": "Zaloguj się, aby połączyć się ze swoimi urządzeniami",
"username_placeholder": "Nazwa użytkownika",
"password_placeholder": "Hasło",
"remember_me": "Zapamiętaj mnie na tym urządzeniu",
"sign_in": "Zaloguj się",
"totp_title": "Uwierzytelnianie dwuskładnikowe",
"totp_subtitle": "Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej",
"verify": "Weryfikuj",
"back_to_login": "Wróć do logowania",
"open_console_login": "Otwórz logowanie do konsoli",
"session_expired": "Sesja wygasła. Zaloguj się ponownie.",
"fill_all_fields": "Wypełnij wszystkie pola",
"invalid_credentials": "Nieprawidłowe dane logowania",
"network_error": "Błąd sieci",
"enter_6_digits": "Wprowadź wszystkie 6 cyfr",
"invalid_code": "Nieprawidłowy kod"
}
}
+19
View File
@@ -3812,5 +3812,24 @@
"open_panel": "Abrir consola",
"devices_count": "{count} dispositivos",
"retry": "Tentar novamente"
},
"rdclient_login": {
"title": "Cliente de ambiente de trabalho remoto",
"subtitle": "Inicie sessão para ligar aos seus dispositivos",
"username_placeholder": "Nome de utilizador",
"password_placeholder": "Palavra-passe",
"remember_me": "Lembrar-me neste dispositivo",
"sign_in": "Iniciar sessão",
"totp_title": "Autenticação de dois fatores",
"totp_subtitle": "Introduza o código de 6 dígitos da sua aplicação de autenticação",
"verify": "Verificar",
"back_to_login": "Voltar ao início de sessão",
"open_console_login": "Abrir início de sessão da consola",
"session_expired": "Sessão expirada. Inicie sessão novamente.",
"fill_all_fields": "Preencha todos os campos",
"invalid_credentials": "Credenciais inválidas",
"network_error": "Erro de rede",
"enter_6_digits": "Introduza os 6 dígitos",
"invalid_code": "Código inválido"
}
}
+19
View File
@@ -3818,5 +3818,24 @@
"open_panel": "Deschide consola",
"devices_count": "{count} dispozitive",
"retry": "Reîncearcă"
},
"rdclient_login": {
"title": "Client desktop la distanță",
"subtitle": "Autentificați-vă pentru a vă conecta la dispozitive",
"username_placeholder": "Nume utilizator",
"password_placeholder": "Parolă",
"remember_me": "Ține-mă minte pe acest dispozitiv",
"sign_in": "Autentificare",
"totp_title": "Autentificare cu doi factori",
"totp_subtitle": "Introduceți codul de 6 cifre din aplicația de autentificare",
"verify": "Verifică",
"back_to_login": "Înapoi la autentificare",
"open_console_login": "Deschide autentificarea consolă",
"session_expired": "Sesiunea a expirat. Autentificați-vă din nou.",
"fill_all_fields": "Completați toate câmpurile",
"invalid_credentials": "Date de autentificare invalide",
"network_error": "Eroare de rețea",
"enter_6_digits": "Introduceți toate cele 6 cifre",
"invalid_code": "Cod invalid"
}
}
+19
View File
@@ -3818,5 +3818,24 @@
"open_panel": "Öppna konsol",
"devices_count": "{count} enheter",
"retry": "Försök igen"
},
"rdclient_login": {
"title": "Remote Desktop-klient",
"subtitle": "Logga in för att ansluta till dina enheter",
"username_placeholder": "Användarnamn",
"password_placeholder": "Lösenord",
"remember_me": "Kom ihåg mig på den här enheten",
"sign_in": "Logga in",
"totp_title": "Tvåfaktorsautentisering",
"totp_subtitle": "Ange den 6-siffriga koden från din autentiseringsapp",
"verify": "Verifiera",
"back_to_login": "Tillbaka till inloggning",
"open_console_login": "Öppna konsolinloggning",
"session_expired": "Sessionen har gått ut. Logga in igen.",
"fill_all_fields": "Fyll i alla fält",
"invalid_credentials": "Ogiltiga inloggningsuppgifter",
"network_error": "Nätverksfel",
"enter_6_digits": "Ange alla 6 siffror",
"invalid_code": "Ogiltig kod"
}
}
+19
View File
@@ -3818,5 +3818,24 @@
"open_panel": "เปิดคอนโซล",
"devices_count": "{count} อุปกรณ์",
"retry": "ลองอีกครั้ง"
},
"rdclient_login": {
"title": "ไคลเอนต์เดสก์ท็อประยะไกล",
"subtitle": "ลงชื่อเข้าใช้เพื่อเชื่อมต่อกับอุปกรณ์ของคุณ",
"username_placeholder": "ชื่อผู้ใช้",
"password_placeholder": "รหัสผ่าน",
"remember_me": "จดจำฉันบนอุปกรณ์นี้",
"sign_in": "ลงชื่อเข้าใช้",
"totp_title": "การยืนยันตัวตนสองขั้นตอน",
"totp_subtitle": "ป้อนรหัส 6 หลักจากแอปยืนยันตัวตนของคุณ",
"verify": "ยืนยัน",
"back_to_login": "กลับไปลงชื่อเข้าใช้",
"open_console_login": "เปิดการเข้าสู่ระบบคอนโซล",
"session_expired": "เซสชันหมดอายุ กรุณาลงชื่อเข้าใช้อีกครั้ง",
"fill_all_fields": "กรุณากรอกข้อมูลให้ครบทุกช่อง",
"invalid_credentials": "ข้อมูลรับรองไม่ถูกต้อง",
"network_error": "ข้อผิดพลาดเครือข่าย",
"enter_6_digits": "ป้อนตัวเลขครบ 6 หลัก",
"invalid_code": "รหัสไม่ถูกต้อง"
}
}
+19
View File
@@ -3818,5 +3818,24 @@
"open_panel": "Konsolu aç",
"devices_count": "{count} cihaz",
"retry": "Yeniden dene"
},
"rdclient_login": {
"title": "Uzak Masaüstü İstemcisi",
"subtitle": "Cihazlarınıza bağlanmak için oturum açın",
"username_placeholder": "Kullanıcı adı",
"password_placeholder": "Parola",
"remember_me": "Bu cihazda beni hatırla",
"sign_in": "Oturum aç",
"totp_title": "İki faktörlü kimlik doğrulama",
"totp_subtitle": "Kimlik doğrulama uygulamanızdaki 6 haneli kodu girin",
"verify": "Doğrula",
"back_to_login": "Oturum açmaya dön",
"open_console_login": "Konsol oturum açmayı aç",
"session_expired": "Oturum süresi doldu. Lütfen tekrar oturum açın.",
"fill_all_fields": "Lütfen tüm alanları doldurun",
"invalid_credentials": "Geçersiz kimlik bilgileri",
"network_error": "Ağ hatası",
"enter_6_digits": "6 hanenin tamamını girin",
"invalid_code": "Geçersiz kod"
}
}
+19
View File
@@ -3818,5 +3818,24 @@
"open_panel": "Відкрити консоль",
"devices_count": "{count} пристроїв",
"retry": "Повторити"
},
"rdclient_login": {
"title": "Клієнт віддаленого робочого столу",
"subtitle": "Увійдіть, щоб підключитися до ваших пристроїв",
"username_placeholder": "Ім'я користувача",
"password_placeholder": "Пароль",
"remember_me": "Запам'ятати мене на цьому пристрої",
"sign_in": "Увійти",
"totp_title": "Двофакторна автентифікація",
"totp_subtitle": "Введіть 6-значний код з вашого додатку автентифікації",
"verify": "Підтвердити",
"back_to_login": "Назад до входу",
"open_console_login": "Відкрити вхід у консоль",
"session_expired": "Сесію закінчено. Увійдіть знову.",
"fill_all_fields": "Заповніть усі поля",
"invalid_credentials": "Невірні облікові дані",
"network_error": "Помилка мережі",
"enter_6_digits": "Введіть усі 6 цифр",
"invalid_code": "Невірний код"
}
}
+19
View File
@@ -3818,5 +3818,24 @@
"open_panel": "Mở bảng điều khiển",
"devices_count": "{count} thiết bị",
"retry": "Thử lại"
},
"rdclient_login": {
"title": "Máy khách Desktop từ xa",
"subtitle": "Đăng nhập để kết nối với thiết bị của bạn",
"username_placeholder": "Tên người dùng",
"password_placeholder": "Mật khẩu",
"remember_me": "Ghi nhớ tôi trên thiết bị này",
"sign_in": "Đăng nhập",
"totp_title": "Xác thực hai yếu tố",
"totp_subtitle": "Nhập mã 6 chữ số từ ứng dụng xác thực của bạn",
"verify": "Xác minh",
"back_to_login": "Quay lại đăng nhập",
"open_console_login": "Mở đăng nhập bảng điều khiển",
"session_expired": "Phiên đã hết hạn. Vui lòng đăng nhập lại.",
"fill_all_fields": "Vui lòng điền đầy đủ các trường",
"invalid_credentials": "Thông tin đăng nhập không hợp lệ",
"network_error": "Lỗi mạng",
"enter_6_digits": "Nhập đủ 6 chữ số",
"invalid_code": "Mã không hợp lệ"
}
}
+19
View File
@@ -3816,5 +3816,24 @@
"open_panel": "開啟主控台",
"devices_count": "{count} 台裝置",
"retry": "重試"
},
"rdclient_login": {
"title": "遠端桌面用戶端",
"subtitle": "登入以連線至您的裝置",
"username_placeholder": "使用者名稱",
"password_placeholder": "密碼",
"remember_me": "在此裝置上記住我",
"sign_in": "登入",
"totp_title": "雙因素驗證",
"totp_subtitle": "輸入驗證器應用程式中的6位數代碼",
"verify": "驗證",
"back_to_login": "返回登入",
"open_console_login": "開啟主控台登入",
"session_expired": "工作階段已過期,請重新登入。",
"fill_all_fields": "請填寫所有欄位",
"invalid_credentials": "憑證無效",
"network_error": "網路錯誤",
"enter_6_digits": "請輸入全部6位數字",
"invalid_code": "代碼無效"
}
}
+19
View File
@@ -3812,5 +3812,24 @@
"open_panel": "打开控制台",
"devices_count": "{count} 台设备",
"retry": "重试"
},
"rdclient_login": {
"title": "远程桌面客户端",
"subtitle": "登录以连接您的设备",
"username_placeholder": "用户名",
"password_placeholder": "密码",
"remember_me": "在此设备上记住我",
"sign_in": "登录",
"totp_title": "双因素认证",
"totp_subtitle": "输入身份验证应用中的6位代码",
"verify": "验证",
"back_to_login": "返回登录",
"open_console_login": "打开控制台登录",
"session_expired": "会话已过期,请重新登录。",
"fill_all_fields": "请填写所有字段",
"invalid_credentials": "凭据无效",
"network_error": "网络错误",
"enter_6_digits": "请输入全部6位数字",
"invalid_code": "代码无效"
}
}
+78
View File
@@ -91,6 +91,81 @@ function roleHasPermission(role, permission) {
return perms.has(permission);
}
/**
* Safe relative return URL for RdClient pages only (/remote).
*/
function isSafeRdClientReturnUrl(u) {
if (typeof u !== 'string' || u.length === 0) return false;
if (/[\r\n\x00]/.test(u)) return false;
if (!u.startsWith('/remote')) return false;
if (u.startsWith('//') || u.startsWith('/\\')) return false;
if (u.startsWith('/remote/login')) return false;
return true;
}
function rdClientLoginRedirect(req) {
const requested = req.originalUrl || req.url || '/remote';
const returnParam = isSafeRdClientReturnUrl(requested)
? requested
: (isSafeRdClientReturnUrl(req.query.return) ? req.query.return : '/remote');
return `/remote/login?return=${encodeURIComponent(returnParam)}`;
}
/**
* RdClient auth redirects to /remote/login instead of panel /login.
* @param {string} [permission] - optional permission (e.g. device.connect)
*/
function requireRdClientAuth(permission) {
return function (req, res, next) {
if (!req.session || !req.session.userId) {
if (req.path.startsWith('/api/')) {
return res.status(401).json({ success: false, error: 'Unauthorized. Please log in.' });
}
return res.redirect(rdClientLoginRedirect(req));
}
if (req.session.user && req.session.user.role === 'pro') {
req.session.destroy();
if (req.path.startsWith('/api/')) {
return res.status(403).json({
success: false,
error: 'Pro accounts can only access the RustDesk desktop client API'
});
}
return res.redirect(rdClientLoginRedirect(req));
}
if (permission) {
const role = req.session.user && req.session.user.role;
if (!roleHasPermission(role, permission)) {
if (req.path.startsWith('/api/')) {
return res.status(403).json({ success: false, error: `Permission denied: ${permission}` });
}
return res.status(403).render('errors/403', {
title: 'Forbidden',
message: 'You do not have permission to access this resource'
});
}
}
res.locals.user = req.session.user;
res.locals.emergencyMode = !!req.session.emergencyMode;
next();
};
}
/**
* RdClient login page redirect away if already authenticated with device.connect.
*/
function rdClientGuestOnly(req, res, next) {
const role = req.session && req.session.user && req.session.user.role;
if (req.session && req.session.userId && role !== 'pro' && roleHasPermission(role, 'device.connect')) {
const dest = isSafeRdClientReturnUrl(req.query.return) ? req.query.return : '/remote';
return res.redirect(dest);
}
next();
}
/**
* Require authentication middleware
* Redirects to login page for HTML requests, returns 401 for API requests
@@ -247,6 +322,9 @@ module.exports = {
requireRole,
requireAdmin,
requirePermission,
requireRdClientAuth,
rdClientGuestOnly,
isSafeRdClientReturnUrl,
optionalAuth,
guestOnly,
roleHasPermission,
+103
View File
@@ -0,0 +1,103 @@
/**
* RdClient operator login extends desktop-login styles
*/
.rdclient-login-page .dl-login-layer {
display: none;
}
.rdclient-login-page .dl-lock-screen {
display: none;
}
.rdclient-login-shell {
position: relative;
z-index: 2;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 16px 32px;
}
.rdclient-login-wallpaper {
position: fixed;
inset: 0;
z-index: 0;
background: linear-gradient(135deg, #0d1117 0%, #161b22 40%, #1a1a2e 70%, #0f3460 100%);
}
.rdclient-login-brand {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 24px;
color: var(--text-primary, #e6edf3);
font-size: 1.125rem;
font-weight: 600;
}
.rdclient-login-brand .material-icons {
font-size: 28px;
color: var(--accent-blue, #388bfd);
}
.rdclient-login-card-wrap {
width: 100%;
max-width: 420px;
}
.rdclient-login-heading {
text-align: center;
margin-bottom: 8px;
}
.rdclient-login-heading h1 {
margin: 0 0 6px;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #e6edf3);
}
.rdclient-login-heading p {
margin: 0 0 16px;
font-size: 0.8125rem;
color: var(--text-secondary, #8b949e);
}
.rdclient-remember {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 0 12px;
font-size: 0.8125rem;
color: var(--text-secondary, #8b949e);
cursor: pointer;
user-select: none;
}
.rdclient-remember input {
width: 16px;
height: 16px;
accent-color: var(--accent-blue, #388bfd);
}
.rdclient-login-footer {
margin-top: 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
font-size: 0.75rem;
color: var(--text-secondary, #8b949e);
}
.rdclient-login-console-link {
color: var(--accent-blue, #388bfd);
text-decoration: none;
}
.rdclient-login-console-link:hover {
text-decoration: underline;
}
+261
View File
@@ -0,0 +1,261 @@
/**
* BetterDesk RdClient operator login (/remote/login)
*/
(function () {
'use strict';
var DEFAULT_GRADIENT = 'linear-gradient(135deg, #0d1117 0%, #161b22 40%, #1a1a2e 70%, #0f3460 100%)';
function t(key, fallback) {
if (typeof _ === 'function') {
var v = _(key);
if (v && v !== key) return v;
}
return fallback || key;
}
function returnUrl() {
var u = (window.BetterDesk && window.BetterDesk.returnUrl) || '/remote';
if (typeof u !== 'string' || !u.startsWith('/remote')) return '/remote';
return u;
}
function redirectAfterLogin() {
window.location.href = returnUrl();
}
function showError(el, textEl, msg) {
if (el) el.classList.remove('hidden');
if (textEl) textEl.textContent = msg;
}
function hideError(el) {
if (el) el.classList.add('hidden');
}
function preloadWallpaper() {
var el = document.getElementById('rdclient-wallpaper');
if (!el) return;
el.style.background = DEFAULT_GRADIENT;
}
async function prefillCredentials() {
var store = window.RdClientSecureStore;
if (!store) return;
var usernameInput = document.getElementById('rdclient-username');
var passwordInput = document.getElementById('rdclient-password');
var rememberInput = document.getElementById('rdclient-remember');
if (!usernameInput || !passwordInput) return;
try {
var creds = await store.loadCredentials();
if (creds.username) usernameInput.value = creds.username;
if (creds.password) {
passwordInput.value = creds.password;
if (rememberInput) rememberInput.checked = true;
} else if (rememberInput) {
rememberInput.checked = await store.hasStoredPassword();
}
} catch (_) { /* ignore */ }
}
function checkSessionExpired() {
if (!(window.BetterDesk && window.BetterDesk.sessionExpired)) return;
showError(
document.getElementById('rdclient-error'),
document.getElementById('rdclient-error-text'),
t('rdclient_login.session_expired', 'Session expired. Please sign in again.')
);
}
function bindLoginForm() {
var form = document.getElementById('rdclient-login-form');
var submitBtn = document.getElementById('rdclient-submit-btn');
var errorEl = document.getElementById('rdclient-error');
var errorText = document.getElementById('rdclient-error-text');
var passwordToggle = document.getElementById('rdclient-password-toggle');
var passwordInput = document.getElementById('rdclient-password');
var csrfToken = (window.BetterDesk && window.BetterDesk.csrfToken) || '';
if (passwordToggle && passwordInput) {
passwordToggle.addEventListener('click', function () {
var isPassword = passwordInput.type === 'password';
passwordInput.type = isPassword ? 'text' : 'password';
passwordToggle.querySelector('.material-icons').textContent =
isPassword ? 'visibility_off' : 'visibility';
});
}
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
var username = (document.getElementById('rdclient-username').value || '').trim();
var password = (document.getElementById('rdclient-password').value || '');
var remember = document.getElementById('rdclient-remember');
var rememberChecked = remember ? remember.checked : false;
if (!username || !password) {
showError(errorEl, errorText, t('rdclient_login.fill_all_fields', 'Please fill in all fields'));
return;
}
submitBtn.classList.add('loading');
submitBtn.disabled = true;
hideError(errorEl);
fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({ username: username, password: password })
})
.then(function (r) { return r.json().then(function (data) { return { data: data }; }); })
.then(function (result) {
submitBtn.classList.remove('loading');
submitBtn.disabled = false;
if (result.data.success && result.data.totpRequired) {
showTotpForm();
return;
}
if (result.data.success) {
var store = window.RdClientSecureStore;
if (store) {
store.saveCredentials(username, password, rememberChecked)
.finally(redirectAfterLogin);
} else {
redirectAfterLogin();
}
return;
}
showError(errorEl, errorText, result.data.error || t('rdclient_login.invalid_credentials', 'Invalid credentials'));
})
.catch(function () {
submitBtn.classList.remove('loading');
submitBtn.disabled = false;
showError(errorEl, errorText, t('rdclient_login.network_error', 'Network error'));
});
});
}
function showTotpForm() {
document.getElementById('rdclient-login-form').classList.add('hidden');
var totpForm = document.getElementById('rdclient-totp-form');
if (totpForm) {
totpForm.classList.remove('hidden');
var first = totpForm.querySelector('.dl-totp-digit[data-idx="0"]');
if (first) setTimeout(function () { first.focus(); }, 100);
}
}
function hideTotpForm() {
document.getElementById('rdclient-login-form').classList.remove('hidden');
document.getElementById('rdclient-totp-form').classList.add('hidden');
document.querySelectorAll('#rdclient-totp-form .dl-totp-digit').forEach(function (d) { d.value = ''; });
}
function getTotpCode() {
var code = '';
document.querySelectorAll('#rdclient-totp-form .dl-totp-digit').forEach(function (d) { code += d.value; });
return code;
}
function bindTotpForm() {
var form = document.getElementById('rdclient-totp-form');
var submitBtn = document.getElementById('rdclient-totp-submit');
var errorEl = document.getElementById('rdclient-totp-error');
var errorText = document.getElementById('rdclient-totp-error-text');
var backLink = document.getElementById('rdclient-totp-back-link');
var csrfToken = (window.BetterDesk && window.BetterDesk.csrfToken) || '';
if (!form) return;
var digits = form.querySelectorAll('.dl-totp-digit');
digits.forEach(function (digit, idx) {
digit.addEventListener('input', function () {
var val = digit.value.replace(/\D/g, '');
digit.value = val.substring(0, 1);
if (val && idx < 5) digits[idx + 1].focus();
if (getTotpCode().length === 6) submitTotp();
});
digit.addEventListener('keydown', function (e) {
if (e.key === 'Backspace' && !digit.value && idx > 0) {
digits[idx - 1].focus();
digits[idx - 1].value = '';
}
});
});
form.addEventListener('submit', function (e) {
e.preventDefault();
submitTotp();
});
function submitTotp() {
var code = getTotpCode();
if (code.length !== 6) {
showError(errorEl, errorText, t('rdclient_login.enter_6_digits', 'Enter all 6 digits'));
return;
}
submitBtn.classList.add('loading');
submitBtn.disabled = true;
hideError(errorEl);
fetch('/api/auth/totp/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({ code: code })
})
.then(function (r) { return r.json(); })
.then(function (data) {
submitBtn.classList.remove('loading');
submitBtn.disabled = false;
if (data.success) {
redirectAfterLogin();
return;
}
showError(errorEl, errorText, data.error || t('rdclient_login.invalid_code', 'Invalid code'));
document.querySelectorAll('#rdclient-totp-form .dl-totp-digit').forEach(function (d) { d.value = ''; });
if (digits[0]) digits[0].focus();
})
.catch(function () {
submitBtn.classList.remove('loading');
submitBtn.disabled = false;
showError(errorEl, errorText, t('rdclient_login.network_error', 'Network error'));
});
}
if (backLink) {
backLink.addEventListener('click', function (e) {
e.preventDefault();
hideTotpForm();
hideError(errorEl);
});
}
}
function init() {
preloadWallpaper();
prefillCredentials();
checkSessionExpired();
bindLoginForm();
bindTotpForm();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+213
View File
@@ -0,0 +1,213 @@
/**
* BetterDesk RdClient encrypted credential storage (browser).
* Passwords are stored in IndexedDB with AES-GCM (Web Crypto); never plaintext in localStorage.
*/
var RdClientSecureStore = (function () {
'use strict';
var DB_NAME = 'betterdesk-rdclient';
var DB_VERSION = 1;
var STORE = 'vault';
var KEY_ID = 'device-key';
var CRED_ID = 'credentials';
var USERNAME_KEY = 'bd_rdclient_last_user';
function openDb() {
return new Promise(function (resolve, reject) {
if (!window.indexedDB) {
reject(new Error('IndexedDB unavailable'));
return;
}
var req = indexedDB.open(DB_NAME, DB_VERSION);
req.onerror = function () { reject(req.error || new Error('IndexedDB open failed')); };
req.onupgradeneeded = function (ev) {
var db = ev.target.result;
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE);
}
};
req.onsuccess = function () { resolve(req.result); };
});
}
function idbGet(db, key) {
return new Promise(function (resolve, reject) {
var tx = db.transaction(STORE, 'readonly');
var req = tx.objectStore(STORE).get(key);
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () { reject(req.error); };
});
}
function idbPut(db, key, value) {
return new Promise(function (resolve, reject) {
var tx = db.transaction(STORE, 'readwrite');
var req = tx.objectStore(STORE).put(value, key);
req.onsuccess = function () { resolve(); };
req.onerror = function () { reject(req.error); };
});
}
function idbDelete(db, key) {
return new Promise(function (resolve, reject) {
var tx = db.transaction(STORE, 'readwrite');
var req = tx.objectStore(STORE).delete(key);
req.onsuccess = function () { resolve(); };
req.onerror = function () { reject(req.error); };
});
}
function bufToB64(buf) {
var bytes = new Uint8Array(buf);
var bin = '';
for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
function b64ToBuf(b64) {
var bin = atob(b64);
var bytes = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes.buffer;
}
async function getOrCreateDeviceKey(db) {
var existing = await idbGet(db, KEY_ID);
if (existing && existing.key) {
return crypto.subtle.importKey(
'raw',
b64ToBuf(existing.key),
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
var raw = crypto.getRandomValues(new Uint8Array(32));
await idbPut(db, KEY_ID, { key: bufToB64(raw.buffer) });
return crypto.subtle.importKey(
'raw',
raw,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
async function encryptPassword(key, password) {
var iv = crypto.getRandomValues(new Uint8Array(12));
var enc = new TextEncoder().encode(password);
var cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, key, enc);
return { iv: bufToB64(iv.buffer), data: bufToB64(cipher) };
}
async function decryptPassword(key, payload) {
if (!payload || !payload.iv || !payload.data) return '';
var iv = new Uint8Array(b64ToBuf(payload.iv));
var data = b64ToBuf(payload.data);
var plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, key, data);
return new TextDecoder().decode(plain);
}
function saveLastUsername(username) {
try {
if (username) localStorage.setItem(USERNAME_KEY, username);
else localStorage.removeItem(USERNAME_KEY);
} catch (_) { /* quota / private mode */ }
}
function loadLastUsername() {
try {
return localStorage.getItem(USERNAME_KEY) || '';
} catch (_) {
return '';
}
}
async function saveCredentials(username, password, rememberPassword) {
saveLastUsername(username);
if (!rememberPassword || !password) {
await clearStoredPassword();
return;
}
if (!window.crypto || !window.crypto.subtle) return;
var db = await openDb();
try {
var key = await getOrCreateDeviceKey(db);
var encrypted = await encryptPassword(key, password);
await idbPut(db, CRED_ID, {
username: username,
password: encrypted,
rememberPassword: true,
updatedAt: Date.now()
});
} finally {
db.close();
}
}
async function clearStoredPassword() {
if (!window.indexedDB) return;
var db = await openDb();
try {
await idbDelete(db, CRED_ID);
} finally {
db.close();
}
}
async function clearCredentials() {
saveLastUsername('');
await clearStoredPassword();
}
async function loadCredentials() {
var username = loadLastUsername();
var result = { username: username, password: '', rememberPassword: false };
if (!window.crypto || !window.crypto.subtle || !window.indexedDB) {
return result;
}
var db = await openDb();
try {
var record = await idbGet(db, CRED_ID);
if (!record || !record.rememberPassword || !record.password) {
return result;
}
var key = await getOrCreateDeviceKey(db);
var password = await decryptPassword(key, record.password);
result.username = record.username || username;
result.password = password;
result.rememberPassword = true;
return result;
} catch (_) {
return result;
} finally {
db.close();
}
}
async function hasStoredPassword() {
if (!window.indexedDB) return false;
var db = await openDb();
try {
var record = await idbGet(db, CRED_ID);
return !!(record && record.rememberPassword && record.password);
} catch (_) {
return false;
} finally {
db.close();
}
}
return {
saveCredentials: saveCredentials,
loadCredentials: loadCredentials,
clearCredentials: clearCredentials,
clearStoredPassword: clearStoredPassword,
hasStoredPassword: hasStoredPassword,
loadLastUsername: loadLastUsername
};
})();
if (typeof window !== 'undefined') {
window.RdClientSecureStore = RdClientSecureStore;
}
+7 -2
View File
@@ -252,8 +252,13 @@ const Utils = {
return data;
} catch (error) {
if (error.status === 401) {
// Redirect to login on auth error
window.location.href = '/login';
var path = window.location.pathname || '';
if (path.startsWith('/remote') && !path.startsWith('/remote/login')) {
window.location.href = '/remote/login?return=' +
encodeURIComponent(path + (window.location.search || '')) + '&expired=1';
} else {
window.location.href = '/login';
}
}
throw error;
}
+22 -40
View File
@@ -8,7 +8,7 @@ const router = express.Router();
const fs = require('fs');
const db = require('../services/database');
const config = require('../config/config');
const { requireAuth, requirePermission } = require('../middleware/auth');
const { requireRdClientAuth, rdClientGuestOnly, isSafeRdClientReturnUrl } = require('../middleware/auth');
// Lazy-loaded relay helper — avoid circular require at module load time
function getRemoteRelay() {
@@ -25,10 +25,24 @@ try {
console.warn('Warning: Could not read server public key:', err.message);
}
/**
* GET /remote/login - RdClient operator login (when panel session expired)
*/
router.get('/remote/login', rdClientGuestOnly, (req, res) => {
const returnUrl = isSafeRdClientReturnUrl(req.query.return) ? req.query.return : '/remote';
const sessionExpired = req.query.expired === '1' || req.query.expired === 'true';
res.render('rdclient-login', {
title: req.t('rdclient_login.title'),
activePage: 'remote',
returnUrl,
sessionExpired,
});
});
/**
* GET /remote - RdClient operator dashboard (device list + connect)
*/
router.get('/remote', requireAuth, requirePermission('device.connect'), (req, res) => {
router.get('/remote', requireRdClientAuth('device.connect'), (req, res) => {
res.render('remote-dashboard', {
title: req.t('remote_dashboard.title'),
activePage: 'remote',
@@ -37,25 +51,11 @@ router.get('/remote', requireAuth, requirePermission('device.connect'), (req, re
/**
* GET /remote/:deviceId - Unified remote desktop viewer (single entry point).
*
* Phase 2.1 of the unification plan: this route is now the only canonical
* URL for browser-based remote desktop. The transport (RustDesk relay vs.
* CDAP WebSocket) is auto-detected on the server by probing the Go server
* for `device_type` and `cdap_connected`. The decision is then passed to
* the appropriate template.
*
* Query overrides:
* ?transport=cdap force CDAP transport (skip auto-probe)
* ?transport=rd force RustDesk transport
*
* Until the unified `remote.ejs` shell lands (PR 2.2 / 2.3) we still render
* the existing two templates underneath. Operators get a single URL and
* shareable links work regardless of which transport is active.
*/
router.get('/remote/:deviceId', requireAuth, async (req, res) => {
router.get('/remote/:deviceId', requireRdClientAuth('device.connect'), async (req, res) => {
const deviceId = req.params.deviceId;
if (!deviceId || !/^[A-Za-z0-9_-]{3,64}$/.test(deviceId)) {
if (!deviceId || deviceId === 'login' || !/^[A-Za-z0-9_-]{3,64}$/.test(deviceId)) {
return res.redirect('/devices');
}
@@ -66,8 +66,6 @@ router.get('/remote/:deviceId', requireAuth, async (req, res) => {
// Database lookup failure is non-blocking - viewer can still work
}
// Probe Go server for authoritative transport hint. Local panel DB
// does not carry `device_type` or `cdap_connected`.
let isOsAgent = false;
let isCdapConnected = false;
let goPeer = null;
@@ -80,7 +78,6 @@ router.get('/remote/:deviceId', requireAuth, async (req, res) => {
}
} catch { /* non-fatal: degrade to standard viewer */ }
// Resolve transport: explicit query param wins, then auto-detect.
const forced = String(req.query.transport || '').toLowerCase();
let transport;
if (forced === 'cdap' || forced === 'rd') {
@@ -91,8 +88,6 @@ router.get('/remote/:deviceId', requireAuth, async (req, res) => {
transport = 'rd';
}
// Capability hints exposed to the browser so the unified UI can light
// up the right toolbar buttons.
const capabilities = {
transport,
os_agent: isOsAgent,
@@ -100,11 +95,6 @@ router.get('/remote/:deviceId', requireAuth, async (req, res) => {
device_type: goPeer && goPeer.device_type ? String(goPeer.device_type) : '',
};
// PR 2.2/2.3 unification: a single canonical web client (`remote.ejs`)
// serves both transports. The browser branches on
// `window.__capabilities.transport`. The legacy `remote-cdap` template
// is no longer rendered; its inline widget remains usable from
// device-detail panels via `cdap-desktop.js` directly.
res.render('remote', {
title: `${req.t('remote.title')} - ${deviceId}`,
activePage: 'remote',
@@ -118,12 +108,8 @@ router.get('/remote/:deviceId', requireAuth, async (req, res) => {
/**
* GET /remote-cdap/:deviceId - Legacy alias, redirects to unified entry.
*
* Kept for backwards compatibility with existing bookmarks, deep links, and
* the `devices.js` "Connect" button. New code should link to
* `/remote/:deviceId` directly.
*/
router.get('/remote-cdap/:deviceId', requireAuth, (req, res) => {
router.get('/remote-cdap/:deviceId', requireRdClientAuth('device.connect'), (req, res) => {
const deviceId = req.params.deviceId;
if (deviceId && /^[A-Za-z0-9_-]{3,64}$/.test(deviceId)) {
return res.redirect(`/remote/${encodeURIComponent(deviceId)}?transport=cdap`);
@@ -133,12 +119,8 @@ router.get('/remote-cdap/:deviceId', requireAuth, (req, res) => {
/**
* GET /remote-desktop/:deviceId - Legacy route, redirects to unified /remote/:deviceId
*
* Previously served a separate JPEG stream viewer. The web remote client has
* been unified: `/remote/:deviceId` is now the only canonical entry point for
* browser-based remote desktop.
*/
router.get('/remote-desktop/:deviceId', requireAuth, (req, res) => {
router.get('/remote-desktop/:deviceId', requireRdClientAuth('device.connect'), (req, res) => {
const deviceId = req.params.deviceId;
if (deviceId && /^[A-Za-z0-9_-]{3,64}$/.test(deviceId)) {
return res.redirect(`/remote/${encodeURIComponent(deviceId)}`);
@@ -149,7 +131,7 @@ router.get('/remote-desktop/:deviceId', requireAuth, (req, res) => {
/**
* GET /api/remote/sessions - List active native remote sessions
*/
router.get('/api/remote/sessions', requireAuth, (req, res) => {
router.get('/api/remote/sessions', requireRdClientAuth(), (req, res) => {
const relay = getRemoteRelay();
if (!relay) return res.json({ sessions: [] });
const sessions = relay.getActiveSessions();
@@ -159,7 +141,7 @@ router.get('/api/remote/sessions', requireAuth, (req, res) => {
/**
* GET /api/remote/session/:deviceId - Get state of a single native remote session
*/
router.get('/api/remote/session/:deviceId', requireAuth, (req, res) => {
router.get('/api/remote/session/:deviceId', requireRdClientAuth(), (req, res) => {
const relay = getRemoteRelay();
if (!relay) return res.status(404).json({ error: 'Remote relay not available' });
const state = relay.getSessionState(req.params.deviceId);
+71 -1
View File
@@ -5,7 +5,7 @@
const request = require('supertest');
const { createTestApp } = require('./helpers');
const { requireAuth, requireRole, guestOnly } = require('../middleware/auth');
const { requireAuth, requireRole, guestOnly, requireRdClientAuth, rdClientGuestOnly, isSafeRdClientReturnUrl } = require('../middleware/auth');
describe('Auth Middleware', () => {
describe('requireAuth', () => {
@@ -141,4 +141,74 @@ describe('Auth Middleware', () => {
expect(res.headers.location).toBe('/');
});
});
describe('requireRdClientAuth', () => {
it('should redirect unauthenticated HTML requests to /remote/login', async () => {
const app = createTestApp();
app.get('/remote', requireRdClientAuth('device.connect'), (_req, res) => {
res.send('OK');
});
const res = await request(app).get('/remote');
expect(res.status).toBe(302);
expect(res.headers.location).toBe('/remote/login?return=%2Fremote');
});
it('should return 401 for unauthenticated API requests', async () => {
const app = createTestApp();
app.get('/api/remote/sessions', requireRdClientAuth(), (_req, res) => {
res.json({ sessions: [] });
});
const res = await request(app).get('/api/remote/sessions');
expect(res.status).toBe(401);
});
it('should pass through for authenticated operator with permission', async () => {
const app = createTestApp();
app.use((req, _res, next) => {
req.session.userId = 2;
req.session.user = { id: 2, username: 'operator1', role: 'operator' };
next();
});
app.get('/remote', requireRdClientAuth('device.connect'), (_req, res) => {
res.send('OK');
});
const res = await request(app).get('/remote');
expect(res.status).toBe(200);
});
});
describe('isSafeRdClientReturnUrl', () => {
it('should accept /remote paths only', () => {
expect(isSafeRdClientReturnUrl('/remote')).toBe(true);
expect(isSafeRdClientReturnUrl('/remote/abc123')).toBe(true);
expect(isSafeRdClientReturnUrl('/login')).toBe(false);
expect(isSafeRdClientReturnUrl('/remote/login')).toBe(false);
expect(isSafeRdClientReturnUrl('//evil.com/remote')).toBe(false);
});
});
describe('rdClientGuestOnly', () => {
it('should redirect authenticated operator to return URL', async () => {
const app = createTestApp();
app.use((req, _res, next) => {
req.session.userId = 2;
req.session.user = { id: 2, username: 'operator1', role: 'operator' };
next();
});
app.get('/remote/login', rdClientGuestOnly, (_req, res) => {
res.send('login');
});
const res = await request(app).get('/remote/login?return=%2Fremote%2Fdev1');
expect(res.status).toBe(302);
expect(res.headers.location).toBe('/remote/dev1');
});
});
});
+124
View File
@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="<%= lang || 'en' %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title><%= _('rdclient_login.title') %> - <%= appName %></title>
<link rel="icon" type="image/svg+xml" href="/branding/favicon.svg">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="/css/theme.css?v=<%= cacheVersion %>">
<link rel="stylesheet" href="/css/branding.css?v=<%= typeof brandingRevision !== 'undefined' ? brandingRevision : cacheVersion %>">
<link rel="stylesheet" href="/css/desktop-login.css?v=<%= cacheVersion %>">
<link rel="stylesheet" href="/css/rdclient-login.css?v=<%= cacheVersion %>">
</head>
<body class="desktop-login-page rdclient-login-page">
<div class="dl-wallpaper rdclient-login-wallpaper" id="rdclient-wallpaper">
<div class="dl-wallpaper-overlay"></div>
</div>
<div class="rdclient-login-shell">
<div class="rdclient-login-brand" id="rdclient-login-brand">
<span class="material-icons">connected_tv</span>
<span><%= appName %></span>
</div>
<div class="dl-card-wrapper rdclient-login-card-wrap">
<div class="dl-card" id="rdclient-login-card">
<div class="rdclient-login-heading">
<h1><%= _('rdclient_login.title') %></h1>
<p><%= _('rdclient_login.subtitle') %></p>
</div>
<div class="dl-avatar" id="rdclient-avatar">
<span class="material-icons">person</span>
</div>
<form class="dl-form" id="rdclient-login-form">
<div class="dl-field">
<input type="text" id="rdclient-username" class="dl-input"
placeholder="<%= _('rdclient_login.username_placeholder') %>"
autocomplete="username" required autofocus>
</div>
<div class="dl-field">
<div class="dl-password-wrapper">
<input type="password" id="rdclient-password" class="dl-input"
placeholder="<%= _('rdclient_login.password_placeholder') %>"
autocomplete="current-password" required>
<button type="button" class="dl-password-toggle" id="rdclient-password-toggle">
<span class="material-icons">visibility</span>
</button>
</div>
</div>
<label class="rdclient-remember">
<input type="checkbox" id="rdclient-remember" checked>
<span><%= _('rdclient_login.remember_me') %></span>
</label>
<div class="dl-error hidden" id="rdclient-error">
<span class="material-icons">error</span>
<span id="rdclient-error-text"></span>
</div>
<button type="submit" class="dl-submit" id="rdclient-submit-btn">
<span class="dl-submit-text"><%= _('rdclient_login.sign_in') %></span>
<span class="dl-submit-arrow"><span class="material-icons">arrow_forward</span></span>
</button>
</form>
<form class="dl-form hidden" id="rdclient-totp-form">
<div class="dl-totp-header">
<span class="material-icons dl-totp-icon">security</span>
<div class="dl-totp-title"><%= _('rdclient_login.totp_title') %></div>
<div class="dl-totp-subtitle"><%= _('rdclient_login.totp_subtitle') %></div>
</div>
<div class="dl-totp-inputs" id="rdclient-totp-inputs">
<input type="text" class="dl-totp-digit" maxlength="1" inputmode="numeric" pattern="[0-9]" data-idx="0">
<input type="text" class="dl-totp-digit" maxlength="1" inputmode="numeric" pattern="[0-9]" data-idx="1">
<input type="text" class="dl-totp-digit" maxlength="1" inputmode="numeric" pattern="[0-9]" data-idx="2">
<span class="dl-totp-separator"></span>
<input type="text" class="dl-totp-digit" maxlength="1" inputmode="numeric" pattern="[0-9]" data-idx="3">
<input type="text" class="dl-totp-digit" maxlength="1" inputmode="numeric" pattern="[0-9]" data-idx="4">
<input type="text" class="dl-totp-digit" maxlength="1" inputmode="numeric" pattern="[0-9]" data-idx="5">
</div>
<div class="dl-error hidden" id="rdclient-totp-error">
<span class="material-icons">error</span>
<span id="rdclient-totp-error-text"></span>
</div>
<button type="submit" class="dl-submit" id="rdclient-totp-submit">
<span class="dl-submit-text"><%= _('rdclient_login.verify') %></span>
<span class="dl-submit-arrow"><span class="material-icons">verified_user</span></span>
</button>
<div class="dl-totp-back">
<a href="#" id="rdclient-totp-back-link">
<span class="material-icons" style="font-size:16px">arrow_back</span>
<%= _('rdclient_login.back_to_login') %>
</a>
</div>
</form>
</div>
</div>
<div class="rdclient-login-footer">
<a href="/login" class="rdclient-login-console-link"><%= _('rdclient_login.open_console_login') %></a>
<span><%= appName %> v<%= appVersion %></span>
</div>
</div>
<script nonce="<%= cspNonce %>">
window.BetterDesk = {
csrfToken: '<%= csrfToken || "" %>',
lang: '<%= lang || "en" %>',
translations: <%- JSON.stringify(translations || {}) %>,
branding: <%- JSON.stringify(branding || {}) %>,
returnUrl: <%- JSON.stringify(typeof returnUrl !== 'undefined' ? returnUrl : '/remote') %>,
sessionExpired: <%= sessionExpired ? 'true' : 'false' %>
};
</script>
<script src="/js/i18n-client.js?v=<%= cacheVersion %>"></script>
<script src="/js/rdclientSecureStore.js?v=<%= cacheVersion %>"></script>
<script src="/js/rdclient-login.js?v=<%= cacheVersion %>"></script>
</body>
</html>