From 9d636ca7772ef1189da8e1f1779c9fc4e554f677 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 20 Jul 2026 18:40:15 +0800 Subject: [PATCH] feat(remote-connect): account-password mobile pairing and resilient device list Pairing (account mode): - QR advertises auth=account with username prefill when the desktop is logged in; mobile submits account username+password through the ECDH room channel (password is never persisted or logged). - Desktop verifies credentials verify-only: challenge + local master key unwrap compared against the current session, without calling /api/auth/login, so the desktop token is never disturbed. The trusted mobile identity binds the canonical account user id. - Account credentials are never downgraded to password-less pairing when the verifier is unavailable (e.g. desktop signed out after the QR was generated); real failure causes are kept in desktop logs while the mobile gets a unified rejection message. - Login/logout/account switch refresh or clear the pairing context and the URL-bound trusted identity. Device list: - RelayHttpClient refreshes the delegated identity from the paired desktop on HTTP 401 and retries once; the terminal 'authorization expired' state is removed from the devices page. Verification: - cargo test -p bitfun-core --lib remote_connect::tests (5 passed) - cargo test -p bitfun-services-integrations --features remote-connect --test remote_connect_contracts (51 passed) - cargo check -p bitfun-desktop - pnpm --dir src/mobile-web run type-check --- ...count-pairing-and-device-refresh-design.md | 51 ++++ .../desktop/src/api/remote_connect_api.rs | 61 ++++ .../core/src/service/remote_connect/mod.rs | 263 +++++++++++++++++- .../src/remote_connect/account.rs | 60 +++- .../src/remote_connect/pairing.rs | 22 ++ .../src/remote_connect/qr_generator.rs | 53 +++- .../tests/remote_connect_contracts.rs | 70 +++-- src/mobile-web/src/i18n/messages.ts | 27 +- src/mobile-web/src/pages/DevicesPage.tsx | 53 ++-- src/mobile-web/src/pages/PairingPage.tsx | 101 ++++++- .../src/services/RelayHttpClient.ts | 147 +++++++--- 11 files changed, 768 insertions(+), 140 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-20-mobile-web-account-pairing-and-device-refresh-design.md diff --git a/docs/superpowers/specs/2026-07-20-mobile-web-account-pairing-and-device-refresh-design.md b/docs/superpowers/specs/2026-07-20-mobile-web-account-pairing-and-device-refresh-design.md new file mode 100644 index 0000000000..5e722fedab --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-mobile-web-account-pairing-and-device-refresh-design.md @@ -0,0 +1,51 @@ +# Mobile-Web 账号配对门槛与设备列表跟随设计 + +Date: 2026-07-20 +Scope: `src/mobile-web`, `src/crates/services/services-integrations` (pairing/QR/account), `src/crates/assembly/core` (remote_connect), `src/apps/desktop` (account verifier wiring) + +## 背景 + +1. Mobile-web 设备页在桌面换号后展示「账号授权已过期」死胡同:委派 token 失效后未向配对桌面重新拉取身份。 +2. 扫码配对的「用户 ID」原用于 URL 防盗用;桌面已登录 BitFun 账号时,应改为校验与桌面相同的账号密码,且必须是被扫码桌面当前账号。 + +## 已确认产品规则 + +| 场景 | Mobile 表单 | 校验 | +|---|---|---| +| 桌面未登录账号 | 仅用户 ID(现状) | `TrustedMobileIdentity`(install_id + user_id) | +| 桌面已登录账号 | 用户名 + 密码 | 与桌面登录同源(challenge + unwrap master_key);`master_key` 必须等于当前桌面会话;密码不落盘;用户名可预填(含首次) | + +设备列表:与桌面登录后列表一致;401 时透明刷新委派身份并重试,不再展示授权过期终态。 + +## 方案 + +### A. 配对(账号模式) + +1. **QR URL**:桌面已登录时追加 `auth=account&user=`(username 来自 credential hint,非机密)。未登录不带这些参数。 +2. **Mobile**:解析到 `auth=account` 时显示密码框;用户名优先 URL,其次本地缓存;**禁用无密码自动重连**;密码仅经 ECDH 加密房间通道提交。 +3. **`PairingResponse`**:新增可选 `password`;`user_id` 在账号模式下承载提交的 username。 +4. **Desktop 校验(verify-only)**: + - 注册 `account_pairing_verifier(username, password) -> Result`。 + - 实现:`AccountClient` challenge + unwrap master_key,与当前 `AccountSession.master_key` 比较;一致则返回 `session.user_id`。 + - **不调用** `/api/auth/login`,避免刷新/污染桌面 token。 + - 账号不一致或密码错误 → 统一拒绝文案(不泄露哪一项失败)。 +5. **信任绑定**:校验成功后用 **canonical `user_id`**(非明文 username)写入 `TrustedMobileIdentity`,保证重连时 username 提交仍能对齐。 + +### B. 设备列表跟随 + +1. `RelayHttpClient`:`clearDelegatedIdentity()`;`requestDelegatedIdentity({ force })`;`listDevices` / `sendDeviceRpc` 遇 HTTP 401 时清身份 → 强制重拉 → **重试一次**。 +2. `DevicesPage`:移除 `tokenExpired` 终态;失败走可重试错误或 `noDelegatedIdentity`。 +3. 清理或降级 `devices.tokenExpired` i18n(无引用则删)。 + +## 错误处理 + +- 账号模式缺密码 / 校验失败:配对失败 + 既有失败次数锁定。 +- 桌面在出码后登出:verifier 不可用 → 拒绝账号模式配对(提示桌面重新登录并刷新二维码)。 +- 委派刷新后桌面仍未登录:设备页 `noDelegatedIdentity` + 重试。 + +## 验证 + +- `cargo test -p bitfun-services-integrations --features remote-connect --test remote_connect_contracts`(不带 `--features remote-connect` 时整个测试文件被 cfg 门掉,0 测试静默通过) +- `cargo check -p bitfun-desktop` +- `pnpm --dir src/mobile-web run type-check` +- 手工:未登录扫码;已登录扫码(预填用户名+密码);错密码;换号后设备列表刷新无过期页 diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index 5756420e6e..69be7a1cae 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -393,6 +393,13 @@ async fn register_delegated_identity_providers() { }) }) .await; + + // Account-mode mobile pairing: QR prefill + password verification. + register_account_pairing_context(service).await; + + // Login/restore may switch accounts; drop any prior URL-bound mobile + // identity so the next pair can bind to the current account user id. + service.clear_trusted_mobile_identity().await; } // Global provider for IM bots. @@ -418,6 +425,51 @@ async fn register_delegated_identity_providers() { }); } +/// Wire QR account prefill + verify-only password check for mobile pairing. +async fn register_account_pairing_context(service: &RemoteConnectService) { + // Always enable account mode when logged in; username prefill is best-effort. + let username = load_credential_hint() + .map(|hint| hint.username) + .unwrap_or_default(); + service.set_account_pairing_username(Some(username)).await; + + let session_arc = get_account_session().clone(); + let relay_url_arc = get_account_relay_url().clone(); + service + .set_account_pairing_verifier(move |username, password| { + let session_arc = session_arc.clone(); + let relay_url_arc = relay_url_arc.clone(); + async move { + let session = session_arc + .read() + .await + .clone() + .ok_or_else(|| "Desktop is not logged into a BitFun account".to_string())?; + let relay_url = relay_url_arc + .read() + .await + .clone() + .ok_or_else(|| "Desktop is not logged into a BitFun account".to_string())?; + AccountClient::new() + .verify_password_for_master_key( + &relay_url, + &username, + &password, + &session.master_key, + ) + .await + .map_err(|e| { + // Keep the real cause in desktop logs (network vs bad + // credentials); the mobile only gets the unified message. + log::warn!("Account pairing verification failed: {e}"); + "Invalid username or password".to_string() + })?; + Ok(session.user_id) + } + }) + .await; +} + pub fn init_on_startup() { tokio::spawn(async { // Restore persisted account session (if any) before anything else @@ -982,6 +1034,13 @@ pub async fn remote_connect_start( let holder = get_service_holder(); let guard = holder.read().await; let service = guard.as_ref().ok_or("service not initialized")?; + // Refresh account pairing context so a newly logged-in session is reflected + // in the QR (`auth=account&user=...`) before the room is created. + if get_account_session().read().await.is_some() { + register_account_pairing_context(service).await; + } else { + service.clear_account_pairing_context().await; + } service .start(method) .await @@ -1343,6 +1402,8 @@ pub async fn account_logout() -> Result<(), String> { // Disconnect device routing before clearing the session. if let Some(service) = get_service_holder().read().await.as_ref() { service.stop_device_connection().await; + service.clear_account_pairing_context().await; + service.clear_trusted_mobile_identity().await; } // Revoke the token on the relay (best-effort — don't block on failure) if let Ok((session, relay_url)) = read_account_context().await { diff --git a/src/crates/assembly/core/src/service/remote_connect/mod.rs b/src/crates/assembly/core/src/service/remote_connect/mod.rs index 4b5ceddd58..8cbcb66df3 100644 --- a/src/crates/assembly/core/src/service/remote_connect/mod.rs +++ b/src/crates/assembly/core/src/service/remote_connect/mod.rs @@ -171,6 +171,11 @@ pub struct RemoteConnectService { /// login. Resolved on demand when a paired client sends /// `get_delegated_identity` over the room channel. delegated_identity_fn: Arc>>, + /// Non-secret username embedded in the QR when the desktop is logged in. + account_pairing_username: Arc>>, + /// When set, pairing requires BitFun account username+password and the + /// verifier returns the canonical account `user_id` on success. + account_pairing_verifier: Arc>>, } /// Provider returning `(token, master_key, relay_url)` for the paired client. @@ -181,6 +186,18 @@ type DelegatedIdentityFn = Arc< + Sync, >; +/// Verifies mobile-submitted account credentials. Returns the canonical +/// account `user_id` when the credentials match the logged-in desktop account. +type AccountPairingVerifierFn = Arc< + dyn Fn( + String, + String, + ) -> std::pin::Pin< + Box> + Send + Sync>, + > + Send + + Sync, +>; + impl RemoteConnectService { pub fn new( config: RemoteConnectConfig, @@ -210,6 +227,8 @@ impl RemoteConnectService { device_relay_client: Arc::new(RwLock::new(None)), online_devices: Arc::new(RwLock::new(Vec::new())), delegated_identity_fn: Arc::new(RwLock::new(None)), + account_pairing_username: Arc::new(RwLock::new(None)), + account_pairing_verifier: Arc::new(RwLock::new(None)), }) } @@ -226,26 +245,53 @@ impl RemoteConnectService { *self.delegated_identity_fn.write().await = Some(Arc::new(move || Box::pin(f()))); } + /// Enable account-password pairing in the QR. + /// `None` disables account mode; `Some(username)` enables it (username may + /// be empty when only `auth=account` should be advertised without prefill). + pub async fn set_account_pairing_username(&self, username: Option) { + *self.account_pairing_username.write().await = username.map(|u| u.trim().to_string()); + } + + /// Register account password verification for mobile pairing. + pub async fn set_account_pairing_verifier(&self, f: F) + where + F: Fn(String, String) -> Fut + Send + Sync + 'static, + Fut: std::future::Future> + Send + Sync + 'static, + { + *self.account_pairing_verifier.write().await = Some(Arc::new(move |username, password| { + Box::pin(f(username, password)) + })); + } + + /// Clear account pairing context (username + verifier) on logout. + pub async fn clear_account_pairing_context(&self) { + *self.account_pairing_username.write().await = None; + *self.account_pairing_verifier.write().await = None; + } + + /// Drop the URL-bound mobile identity. Call when the desktop account + /// changes so a later pair can bind to the new account user id. + pub async fn clear_trusted_mobile_identity(&self) { + *self.trusted_mobile_identity.write().await = None; + } + pub fn device_identity(&self) -> &DeviceIdentity { &self.device_identity } async fn validate_mobile_identity( trusted_mobile_identity: &Arc>>, - response: &pairing::PairingResponse, + mobile_install_id: &str, + user_id: &str, ) -> std::result::Result { - let mobile_install_id = response - .mobile_install_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "Missing mobile installation ID".to_string())?; - let user_id = response - .user_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "Missing user ID".to_string())?; + let mobile_install_id = mobile_install_id.trim(); + let user_id = user_id.trim(); + if mobile_install_id.is_empty() { + return Err("Missing mobile installation ID".to_string()); + } + if user_id.is_empty() { + return Err("Missing user ID".to_string()); + } let submitted = TrustedMobileIdentity { mobile_install_id: mobile_install_id.to_string(), @@ -268,6 +314,53 @@ impl RemoteConnectService { } } + /// When the desktop is logged in, require and verify account credentials. + /// Returns the canonical account `user_id` to bind as the trusted identity. + async fn resolve_pairing_user_id( + account_pairing_verifier: &Arc>>, + response: &pairing::PairingResponse, + ) -> std::result::Result { + let verifier = account_pairing_verifier.read().await.clone(); + let Some(verify) = verifier else { + // The mobile submitted account credentials (QR advertised + // auth=account) but the desktop logged out after generating the + // code. Never downgrade to password-less pairing. + if response + .password + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + { + return Err( + "Desktop signed out of the BitFun account; sign in again and refresh the QR code" + .to_string(), + ); + } + let user_id = response + .user_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "Missing user ID".to_string())?; + return Ok(user_id.to_string()); + }; + + let username = response + .user_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "Missing username".to_string())?; + let password = response + .password + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "Missing password".to_string())?; + + verify(username.to_string(), password.to_string()).await + } + async fn persist_mobile_identity( trusted_mobile_identity: &Arc>>, identity: TrustedMobileIdentity, @@ -539,7 +632,13 @@ impl RemoteConnectService { }; let client_language = crate::service::config::get_app_language_code().await; - let qr_url = QrGenerator::build_url(&qr_payload, &web_app_url, &client_language); + let account_username = self.account_pairing_username.read().await.clone(); + let qr_url = QrGenerator::build_url( + &qr_payload, + &web_app_url, + &client_language, + account_username.as_deref(), + ); let qr_svg = QrGenerator::generate_svg_from_url(&qr_url)?; let qr_data = QrGenerator::generate_png_base64_from_url(&qr_url)?; @@ -551,6 +650,7 @@ impl RemoteConnectService { let server_arc = self.remote_server.clone(); let trusted_mobile_identity_arc = self.trusted_mobile_identity.clone(); let delegated_identity_fn_arc = self.delegated_identity_fn.clone(); + let account_pairing_verifier_arc = self.account_pairing_verifier.clone(); let local_device_id = self.device_identity.device_id.clone(); tokio::spawn(async move { while let Some(event) = event_rx.recv().await { @@ -652,10 +752,34 @@ impl RemoteConnectService { if let Ok(response) = serde_json::from_str::(&json) { + let canonical_user_id = match RemoteConnectService::resolve_pairing_user_id( + &account_pairing_verifier_arc, + &response, + ) + .await + { + Ok(user_id) => user_id, + Err(message) => { + drop(p); + RemoteConnectService::send_pairing_error_response( + &relay_arc, + &correlation_id, + &shared_secret, + message, + ) + .await; + continue; + } + }; + let mobile_install_id = response + .mobile_install_id + .clone() + .unwrap_or_default(); let submitted_identity = match RemoteConnectService::validate_mobile_identity( &trusted_mobile_identity_arc, - &response, + &mobile_install_id, + &canonical_user_id, ) .await { @@ -1368,3 +1492,112 @@ impl RemoteConnectService { self.pairing.read().await.shared_secret().copied() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn pairing_response(user_id: Option<&str>, password: Option<&str>) -> pairing::PairingResponse { + pairing::PairingResponse { + challenge_echo: "echo".to_string(), + device_id: "mobile-1".to_string(), + device_name: "Phone".to_string(), + mobile_install_id: Some("install-1".to_string()), + user_id: user_id.map(str::to_string), + password: password.map(str::to_string), + } + } + + fn no_verifier() -> Arc>> { + Arc::new(RwLock::new(None)) + } + + fn verifier_returning( + canonical_user_id: &'static str, + ) -> Arc>> { + Arc::new(RwLock::new(Some( + Arc::new(move |_username: String, _password: String| { + Box::pin(async move { Ok(canonical_user_id.to_string()) }) + as std::pin::Pin< + Box> + Send + Sync>, + > + }) as AccountPairingVerifierFn, + ))) + } + + #[tokio::test] + async fn account_mode_requires_password() { + let result = RemoteConnectService::resolve_pairing_user_id( + &verifier_returning("user-123"), + &pairing_response(Some("alice"), None), + ) + .await; + assert_eq!(result.unwrap_err(), "Missing password"); + } + + #[tokio::test] + async fn account_mode_requires_username() { + let result = RemoteConnectService::resolve_pairing_user_id( + &verifier_returning("user-123"), + &pairing_response(None, Some("secret")), + ) + .await; + assert_eq!(result.unwrap_err(), "Missing username"); + } + + #[tokio::test] + async fn account_credentials_are_never_downgraded_when_verifier_is_gone() { + // QR advertised auth=account, but the desktop signed out before the + // scan finished: reject instead of falling back to password-less pairing. + let result = RemoteConnectService::resolve_pairing_user_id( + &no_verifier(), + &pairing_response(Some("alice"), Some("secret")), + ) + .await; + assert!(result.unwrap_err().contains("signed out")); + } + + #[tokio::test] + async fn legacy_mode_without_verifier_uses_plain_user_id() { + let result = RemoteConnectService::resolve_pairing_user_id( + &no_verifier(), + &pairing_response(Some("local-user"), None), + ) + .await; + assert_eq!(result.unwrap(), "local-user"); + } + + #[tokio::test] + async fn verifier_result_binds_canonical_user_id() { + let canonical = RemoteConnectService::resolve_pairing_user_id( + &verifier_returning("canonical-user-123"), + &pairing_response(Some("alice"), Some("secret")), + ) + .await + .expect("verification should succeed"); + assert_eq!(canonical, "canonical-user-123"); + + let trusted = Arc::new(RwLock::new(None)); + let identity = + RemoteConnectService::validate_mobile_identity(&trusted, "install-1", &canonical) + .await + .expect("first pairing binds the identity"); + assert_eq!(identity.user_id, "canonical-user-123"); + RemoteConnectService::persist_mobile_identity(&trusted, identity).await; + + // Reconnect with the same canonical id still matches. + assert!( + RemoteConnectService::validate_mobile_identity(&trusted, "install-1", &canonical) + .await + .is_ok() + ); + // A different account user id is rejected against the bound identity. + assert!(RemoteConnectService::validate_mobile_identity( + &trusted, + "install-1", + "other-user" + ) + .await + .is_err()); + } +} diff --git a/src/crates/services/services-integrations/src/remote_connect/account.rs b/src/crates/services/services-integrations/src/remote_connect/account.rs index ed92937d09..b70a159e05 100644 --- a/src/crates/services/services-integrations/src/remote_connect/account.rs +++ b/src/crates/services/services-integrations/src/remote_connect/account.rs @@ -241,17 +241,14 @@ impl AccountClient { } } - /// Log in to an existing account. Fetches the KDF challenge, derives the KEK - /// locally, unwraps the master key (GCM failure ⇒ wrong password), then - /// verifies the password hash with the relay to obtain a token. - pub async fn login( + /// Fetch the login challenge and unwrap the master key locally. + /// Does not call `/api/auth/login` and does not mint a token. + async fn unwrap_master_key_for_credentials( &self, relay_url: &str, username: &str, password: &str, - device: &DeviceIdentity, - ) -> Result { - // 1. Challenge: fetch salts + wrapped master key. + ) -> Result<([u8; MASTER_KEY_LEN], ChallengeResponse)> { let challenge_req = serde_json::json!({ "username": username }); let resp = self .http @@ -267,19 +264,56 @@ impl AccountClient { let salt = BASE64 .decode(&challenge.salt) .map_err(|e| anyhow!("b64 decode salt: {e}"))?; - let kdf_salt = BASE64 - .decode(&challenge.kdf_salt) - .map_err(|e| anyhow!("b64 decode kdf_salt: {e}"))?; let params: KdfParams = serde_json::from_str(&challenge.argon2_params) .map_err(|e| anyhow!("parse argon2_params: {e}"))?; - // 2. Derive KEK and unwrap the master key. A failure here means the - // password is wrong (the GCM tag won't verify). + // GCM tag failure means the password is wrong. let kek = derive_kek(password, &salt, ¶ms)?; let master_key = unwrap_master_key(&kek, &challenge.wrapped_master_key) .map_err(|_| anyhow!("invalid username or password"))?; + Ok((master_key, challenge)) + } + + /// Verify username/password against an existing session without minting a + /// new relay token. Uses the same challenge + KEK unwrap path as `login`. + /// Succeeds only when the unwrapped master key matches `expected_master_key`. + pub async fn verify_password_for_master_key( + &self, + relay_url: &str, + username: &str, + password: &str, + expected_master_key: &[u8; MASTER_KEY_LEN], + ) -> Result<()> { + let (master_key, _) = self + .unwrap_master_key_for_credentials(relay_url, username, password) + .await?; + if master_key != *expected_master_key { + return Err(anyhow!("invalid username or password")); + } + Ok(()) + } + + /// Log in to an existing account. Fetches the KDF challenge, derives the KEK + /// locally, unwraps the master key (GCM failure ⇒ wrong password), then + /// verifies the password hash with the relay to obtain a token. + pub async fn login( + &self, + relay_url: &str, + username: &str, + password: &str, + device: &DeviceIdentity, + ) -> Result { + let (master_key, challenge) = self + .unwrap_master_key_for_credentials(relay_url, username, password) + .await?; + + let kdf_salt = BASE64 + .decode(&challenge.kdf_salt) + .map_err(|e| anyhow!("b64 decode kdf_salt: {e}"))?; + let params: KdfParams = serde_json::from_str(&challenge.argon2_params) + .map_err(|e| anyhow!("parse argon2_params: {e}"))?; - // 3. Derive the server-verifiable hash and submit it. + // Derive the server-verifiable hash and submit it. let password_hash = derive_password_hash(password, &kdf_salt, ¶ms)?; let login_req = serde_json::json!({ "username": username, diff --git a/src/crates/services/services-integrations/src/remote_connect/pairing.rs b/src/crates/services/services-integrations/src/remote_connect/pairing.rs index cb061b9f62..69c7887d66 100644 --- a/src/crates/services/services-integrations/src/remote_connect/pairing.rs +++ b/src/crates/services/services-integrations/src/remote_connect/pairing.rs @@ -52,8 +52,12 @@ pub struct PairingResponse { pub device_name: String, #[serde(default)] pub mobile_install_id: Option, + /// Local pairing user id, or BitFun account username when account auth is required. #[serde(default)] pub user_id: Option, + /// BitFun account password when the paired desktop is logged in. Never log this field. + #[serde(default)] + pub password: Option, } /// Manages the pairing state machine. @@ -168,6 +172,23 @@ impl PairingProtocol { device_identity: &DeviceIdentity, mobile_install_id: Option, user_id: Option, + ) -> PairingResponse { + Self::answer_challenge_with_password( + challenge, + device_identity, + mobile_install_id, + user_id, + None, + ) + } + + /// Mobile side: pairing response that may include an account password. + pub fn answer_challenge_with_password( + challenge: &PairingChallenge, + device_identity: &DeviceIdentity, + mobile_install_id: Option, + user_id: Option, + password: Option, ) -> PairingResponse { PairingResponse { challenge_echo: challenge.challenge.clone(), @@ -175,6 +196,7 @@ impl PairingProtocol { device_name: device_identity.device_name.clone(), mobile_install_id, user_id, + password, } } diff --git a/src/crates/services/services-integrations/src/remote_connect/qr_generator.rs b/src/crates/services/services-integrations/src/remote_connect/qr_generator.rs index 4746936791..9eae6900b9 100644 --- a/src/crates/services/services-integrations/src/remote_connect/qr_generator.rs +++ b/src/crates/services/services-integrations/src/remote_connect/qr_generator.rs @@ -12,12 +12,19 @@ impl QrGenerator { /// Build the URL that the QR code points to. /// `web_app_url` = where the mobile web app is hosted. /// `payload.url` = the relay server that the mobile WebSocket should connect to. - pub fn build_url(payload: &QrPayload, web_app_url: &str, language: &str) -> String { + /// When `account_username` is set, the URL requests account password pairing + /// (`auth=account&user=...`) so mobile can prefill the logged-in username. + pub fn build_url( + payload: &QrPayload, + web_app_url: &str, + language: &str, + account_username: Option<&str>, + ) -> String { let relay_ws = payload .url .replace("https://", "wss://") .replace("http://", "ws://"); - format!( + let mut url = format!( "{web_app}/#/pair?room={room}&did={did}&pk={pk}&dn={dn}&relay={relay}&v={v}&lang={lang}", web_app = web_app_url.trim_end_matches('/'), room = urlencoding::encode(&payload.room_id), @@ -27,7 +34,18 @@ impl QrGenerator { relay = urlencoding::encode(&relay_ws), v = payload.version, lang = urlencoding::encode(language), - ) + ); + // `Some(_)` enables account-password pairing even when username prefill + // is unavailable (e.g. restored session without a credential hint). + if let Some(username) = account_username { + url.push_str("&auth=account"); + let trimmed = username.trim(); + if !trimmed.is_empty() { + url.push_str("&user="); + url.push_str(&urlencoding::encode(trimmed)); + } + } + url } /// Generate a QR code as a base64-encoded PNG from a pre-built URL. @@ -76,7 +94,34 @@ mod tests { version: 1, }; - let url = QrGenerator::build_url(&payload, "https://mobile.example.com", "en-US"); + let url = QrGenerator::build_url(&payload, "https://mobile.example.com", "en-US", None); assert!(url.contains("lang=en-US")); + assert!(!url.contains("auth=account")); + } + + #[test] + fn build_url_includes_account_auth_when_username_provided() { + let payload = QrPayload { + room_id: "room_123".to_string(), + url: "https://relay.example.com".to_string(), + device_id: "device_123".to_string(), + device_name: "BitFun Desktop".to_string(), + public_key: "public_key_value".to_string(), + version: 1, + }; + + let url = QrGenerator::build_url( + &payload, + "https://mobile.example.com", + "zh-CN", + Some("alice"), + ); + assert!(url.contains("auth=account")); + assert!(url.contains("user=alice")); + + let auth_only = + QrGenerator::build_url(&payload, "https://mobile.example.com", "zh-CN", Some("")); + assert!(auth_only.contains("auth=account")); + assert!(!auth_only.contains("user=")); } } diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index 0bf97046dc..efe27ef67b 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -27,22 +27,22 @@ use bitfun_services_integrations::remote_connect::{ resolve_remote_execution_image_contexts, resolve_remote_file_chunk_range, resolve_remote_workspace_path, should_send_remote_model_catalog, submit_remote_dialog, ActiveTurnSnapshot, ChatImageAttachment, ChatMessage, ChatMessageItem, DeviceIdentity, - ImageAttachment, KeyPair, PairingProtocol, PairingState, QrGenerator, QrPayload, RelayMessage, - RemoteAssistantWorkspaceFacts, RemoteCancelDecision, RemoteCancelRuntimeHost, - RemoteCancelTaskRequest, RemoteChatHistoryRound, RemoteChatHistoryTextItem, - RemoteChatHistoryThinkingItem, RemoteChatHistoryToolCall, RemoteChatHistoryToolItem, - RemoteChatHistoryTurn, RemoteCommand, RemoteCommandRuntimeHost, RemoteConnectSubmissionSource, - RemoteDefaultModelsConfig, RemoteDialogQueuePriority, RemoteDialogResolvedSubmission, - RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, RemoteDialogSubmissionPolicy, - RemoteDialogSubmissionRequest, RemoteDialogSubmitOutcome, RemoteDialogWorkspaceBinding, - RemoteImageContext, RemoteImageContextAdapter, RemoteModelCapabilityFact, RemoteModelCatalog, - RemoteModelCatalogFacts, RemoteModelConfig, RemoteModelFacts, RemoteReasoningModeFact, - RemoteRecentWorkspaceFacts, RemoteResponse, RemoteSessionMetadata, RemoteSessionStateTracker, - RemoteSessionTrackerHost, RemoteSessionTrackerRegistry, RemoteSessionWorkspaceIdentity, - RemoteTerminalPrewarmRequest, RemoteToolStatus, RemoteWorkspaceFacts, RemoteWorkspaceFileChunk, - RemoteWorkspaceFileContent, RemoteWorkspaceFileInfo, RemoteWorkspaceFileRuntimeHost, - RemoteWorkspaceKind, RemoteWorkspaceUpdate, TrackerEvent, REMOTE_FILE_MAX_CHUNK_BYTES, - REMOTE_FILE_MAX_READ_BYTES, + ImageAttachment, KeyPair, PairingChallenge, PairingProtocol, PairingResponse, PairingState, + QrGenerator, QrPayload, RelayMessage, RemoteAssistantWorkspaceFacts, RemoteCancelDecision, + RemoteCancelRuntimeHost, RemoteCancelTaskRequest, RemoteChatHistoryRound, + RemoteChatHistoryTextItem, RemoteChatHistoryThinkingItem, RemoteChatHistoryToolCall, + RemoteChatHistoryToolItem, RemoteChatHistoryTurn, RemoteCommand, RemoteCommandRuntimeHost, + RemoteConnectSubmissionSource, RemoteDefaultModelsConfig, RemoteDialogQueuePriority, + RemoteDialogResolvedSubmission, RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, + RemoteDialogSubmissionPolicy, RemoteDialogSubmissionRequest, RemoteDialogSubmitOutcome, + RemoteDialogWorkspaceBinding, RemoteImageContext, RemoteImageContextAdapter, + RemoteModelCapabilityFact, RemoteModelCatalog, RemoteModelCatalogFacts, RemoteModelConfig, + RemoteModelFacts, RemoteReasoningModeFact, RemoteRecentWorkspaceFacts, RemoteResponse, + RemoteSessionMetadata, RemoteSessionStateTracker, RemoteSessionTrackerHost, + RemoteSessionTrackerRegistry, RemoteSessionWorkspaceIdentity, RemoteTerminalPrewarmRequest, + RemoteToolStatus, RemoteWorkspaceFacts, RemoteWorkspaceFileChunk, RemoteWorkspaceFileContent, + RemoteWorkspaceFileInfo, RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind, + RemoteWorkspaceUpdate, TrackerEvent, REMOTE_FILE_MAX_CHUNK_BYTES, REMOTE_FILE_MAX_READ_BYTES, }; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -95,10 +95,46 @@ fn remote_connect_qr_and_relay_primitives_live_in_services_owner() { version: 1, }; - let url = QrGenerator::build_url(&payload, "https://mobile.example.com/", "zh-CN"); + let url = QrGenerator::build_url(&payload, "https://mobile.example.com/", "zh-CN", None); assert!(url.starts_with("https://mobile.example.com/#/pair?")); assert!(url.contains("relay=wss%3A%2F%2Frelay.example.com%2Fsocket")); assert!(url.contains("lang=zh-CN")); + assert!(!url.contains("auth=account")); + + let account_url = QrGenerator::build_url( + &payload, + "https://mobile.example.com/", + "zh-CN", + Some("alice"), + ); + assert!(account_url.contains("auth=account")); + assert!(account_url.contains("user=alice")); + + let auth_only_url = + QrGenerator::build_url(&payload, "https://mobile.example.com/", "zh-CN", Some("")); + assert!(auth_only_url.contains("auth=account")); + assert!(!auth_only_url.contains("user=")); + + let with_password = PairingProtocol::answer_challenge_with_password( + &PairingChallenge { + challenge: "abc".to_string(), + timestamp: 1, + }, + &DeviceIdentity { + device_id: "m1".to_string(), + device_name: "Phone".to_string(), + mac_address: "00:00:00:00:00:00".to_string(), + }, + Some("install-1".to_string()), + Some("alice".to_string()), + Some("secret".to_string()), + ); + let json = serde_json::to_value(&with_password).expect("serialize pairing response"); + assert_eq!(json["user_id"], "alice"); + assert_eq!(json["password"], "secret"); + let parsed: PairingResponse = + serde_json::from_value(json).expect("deserialize pairing response"); + assert_eq!(parsed.password.as_deref(), Some("secret")); let message = RelayMessage::CreateRoom { room_id: Some(payload.room_id), diff --git a/src/mobile-web/src/i18n/messages.ts b/src/mobile-web/src/i18n/messages.ts index aeaed6cc73..223ab40337 100644 --- a/src/mobile-web/src/i18n/messages.ts +++ b/src/mobile-web/src/i18n/messages.ts @@ -33,16 +33,24 @@ export const messages: Record = { }, pairing: { enterUserIdToContinue: 'Enter your user ID to continue', + enterAccountToContinue: 'Enter your BitFun account to continue', connectingAndPairing: 'Connecting and pairing...', pairedLoadingSessions: 'Paired! Loading sessions...', connectionError: 'Connection error', invalidQrCode: 'Invalid QR code: missing room or public key', userIdRequired: 'User ID is required', + usernameRequired: 'Username is required', + passwordRequired: 'Password is required', tooManyAttempts: 'Too many failed attempts. Try again in {seconds}s.', pairingFailed: 'Pairing failed', fieldLabel: 'User ID', + usernameLabel: 'Username', + passwordLabel: 'Password', placeholder: 'Enter a user ID', + usernamePlaceholder: 'Enter your username', + passwordPlaceholder: 'Enter your password', note: 'The first successful connection binds this URL to your user ID for the current remote session.', + accountNote: 'Use the same BitFun account currently logged in on the scanned desktop. Your password is not saved on this phone.', connecting: 'Connecting...', retryIn: 'Retry in {seconds}s', continue: 'Continue', @@ -165,7 +173,6 @@ export const messages: Record = { pairedDesktop: 'Paired', switchFailed: 'Failed to switch device', controllingDevice: 'Controlling {name}', - tokenExpired: 'Account authorization expired. Re-scan the QR code or ask the desktop user to log in again.', }, tools: { ls: 'LS', @@ -202,16 +209,24 @@ export const messages: Record = { }, pairing: { enterUserIdToContinue: '请输入你的用户 ID 继续', + enterAccountToContinue: '请输入你的 BitFun 账号继续', connectingAndPairing: '正在连接并配对...', pairedLoadingSessions: '配对成功,正在加载会话...', connectionError: '连接异常', invalidQrCode: '二维码无效:缺少 room 或 public key', userIdRequired: '用户 ID 不能为空', + usernameRequired: '用户名不能为空', + passwordRequired: '密码不能为空', tooManyAttempts: '失败次数过多,请在 {seconds} 秒后重试。', pairingFailed: '配对失败', fieldLabel: '用户 ID', + usernameLabel: '用户名', + passwordLabel: '密码', placeholder: '请输入用户 ID', + usernamePlaceholder: '请输入用户名', + passwordPlaceholder: '请输入密码', note: '首次成功连接后,本次远程会话会把该 URL 绑定到你的用户 ID。', + accountNote: '请使用被扫码桌面端当前登录的 BitFun 账号。密码不会保存在本机。', connecting: '连接中...', retryIn: '{seconds} 秒后重试', continue: '继续', @@ -334,7 +349,6 @@ export const messages: Record = { pairedDesktop: '已配对', switchFailed: '切换设备失败', controllingDevice: '正在控制 {name}', - tokenExpired: '账号授权已过期,请重新扫码或让桌面端用户重新登录。', }, tools: { ls: '列表', @@ -371,16 +385,24 @@ export const messages: Record = { }, pairing: { enterUserIdToContinue: '請輸入你的用戶 ID 繼續', + enterAccountToContinue: '請輸入你的 BitFun 帳號繼續', connectingAndPairing: '正在連接並配對...', pairedLoadingSessions: '配對成功,正在加載會話...', connectionError: '連接異常', invalidQrCode: '二維碼無效:缺少 room 或 public key', userIdRequired: '用戶 ID 不能為空', + usernameRequired: '用戶名不能為空', + passwordRequired: '密碼不能為空', tooManyAttempts: '失敗次數過多,請在 {seconds} 秒後重試。', pairingFailed: '配對失敗', fieldLabel: '用戶 ID', + usernameLabel: '用戶名', + passwordLabel: '密碼', placeholder: '請輸入用戶 ID', + usernamePlaceholder: '請輸入用戶名', + passwordPlaceholder: '請輸入密碼', note: '首次成功連接後,本次遠程會話會把該 URL 綁定到你的用戶 ID。', + accountNote: '請使用被掃碼桌面端目前登入的 BitFun 帳號。密碼不會保存在本機。', connecting: '連接中...', retryIn: '{seconds} 秒後重試', continue: '繼續', @@ -503,7 +525,6 @@ export const messages: Record = { pairedDesktop: '已配對', switchFailed: '切換設備失敗', controllingDevice: '正在控制 {name}', - tokenExpired: '帳號授權已過期,請重新掃碼或讓桌面端用戶重新登入。', }, tools: { ls: '列表', diff --git a/src/mobile-web/src/pages/DevicesPage.tsx b/src/mobile-web/src/pages/DevicesPage.tsx index c593cf81af..e93ecdf812 100644 --- a/src/mobile-web/src/pages/DevicesPage.tsx +++ b/src/mobile-web/src/pages/DevicesPage.tsx @@ -23,12 +23,6 @@ interface Props { onBack: () => void; } -/** Check whether an error is an HTTP 401 (token expired/unauthorized). */ -function isTokenExpiredError(e: unknown): boolean { - const msg = String((e as { message?: string })?.message || ''); - return msg.includes('HTTP 401') || msg.includes('Unauthorized'); -} - const BackIcon = () => ( @@ -70,7 +64,6 @@ const DevicesPage: React.FC = ({ client, onBack }) => { const [loading, setLoading] = useState(false); const [switchingId, setSwitchingId] = useState(null); const [error, setError] = useState(null); - const [tokenExpired, setTokenExpired] = useState(false); const mountedRef = useRef(true); useEffect(() => { @@ -86,30 +79,28 @@ const DevicesPage: React.FC = ({ client, onBack }) => { const list = await client.listDevices(); if (!mountedRef.current) return; setDevices(list); - setTokenExpired(false); setError(null); + setIdentityReady(true); } catch (e: unknown) { if (!mountedRef.current) return; - if (isTokenExpiredError(e)) { - setTokenExpired(true); + const message = String((e as { message?: string })?.message || e); + if (message.includes('No delegated identity')) { + setIdentityReady(false); + setDevices([]); } else { - setError(String((e as { message?: string })?.message || e)); + setError(message); } } }, [client]); // Acquire the delegated identity lazily: the desktop may have logged into - // its account after this mobile session was paired. - const ensureIdentity = useCallback(async () => { - if (client.hasDelegatedIdentity) { - setIdentityReady(true); - setIdentityChecking(false); - return true; - } + // its account after this mobile session was paired. Force-refresh so a + // desktop account switch is reflected without re-scanning. + const ensureIdentity = useCallback(async (force = false) => { setIdentityChecking(true); let granted = false; try { - granted = await client.requestDelegatedIdentity(); + granted = await client.requestDelegatedIdentity({ force: force || !client.hasDelegatedIdentity }); } catch { granted = false; } @@ -123,7 +114,7 @@ const DevicesPage: React.FC = ({ client, onBack }) => { useEffect(() => { let timer: ReturnType | undefined; const init = async () => { - const granted = await ensureIdentity(); + const granted = await ensureIdentity(false); if (!granted || !mountedRef.current) return; setLoading(true); await refreshDevices(); @@ -138,7 +129,8 @@ const DevicesPage: React.FC = ({ client, onBack }) => { const handleManualRefresh = useCallback(async () => { if (loading || switchingId) return; - const granted = await ensureIdentity(); + // Force refresh so desktop account switches are picked up immediately. + const granted = await ensureIdentity(true); if (!granted) return; setLoading(true); await refreshDevices(); @@ -170,10 +162,12 @@ const DevicesPage: React.FC = ({ client, onBack }) => { onBack(); } catch (e: unknown) { if (!mountedRef.current) return; - if (isTokenExpiredError(e)) { - setTokenExpired(true); + const message = String((e as { message?: string })?.message || e); + if (message.includes('No delegated identity')) { + setIdentityReady(false); + setDevices([]); } else { - setError(String((e as { message?: string })?.message || e) || t('devices.switchFailed')); + setError(message || t('devices.switchFailed')); } } finally { if (mountedRef.current) setSwitchingId(null); @@ -202,15 +196,6 @@ const DevicesPage: React.FC = ({ client, onBack }) => { ); } - if (tokenExpired) { - return ( -
- -

{t('devices.tokenExpired')}

-
- ); - } - if (loading && devices.length === 0) { return (
@@ -300,7 +285,7 @@ const DevicesPage: React.FC = ({ client, onBack }) => {
- {error && !tokenExpired &&
{error}
} + {error &&
{error}
}
{renderBody()} diff --git a/src/mobile-web/src/pages/PairingPage.tsx b/src/mobile-web/src/pages/PairingPage.tsx index 9a022e61a5..d68ea347d4 100644 --- a/src/mobile-web/src/pages/PairingPage.tsx +++ b/src/mobile-web/src/pages/PairingPage.tsx @@ -30,7 +30,10 @@ const USER_ID_LOCKOUT_MS = 60_000; function isProtectedUserIdError(message: string): boolean { return message.includes('This remote URL is already protected') - || message.includes('This mobile device must continue using the previously confirmed user ID'); + || message.includes('This mobile device must continue using the previously confirmed user ID') + || message.includes('Invalid username or password') + || message.includes('Missing password') + || message.includes('Missing username'); } function generateInstallId(): string { @@ -48,12 +51,20 @@ function getOrCreateInstallId(): string { return created; } -function resolveRelayBaseUrl(): { room: string | null; pk: string | null; httpBaseUrl: string } { +function resolvePairingTarget(): { + room: string | null; + pk: string | null; + httpBaseUrl: string; + accountAuth: boolean; + accountUsername: string | null; +} { const hash = window.location.hash; const params = new URLSearchParams(hash.replace(/^#\/pair\?/, '')); const room = params.get('room'); const pk = params.get('pk'); const relayParam = params.get('relay'); + const accountAuth = params.get('auth') === 'account'; + const accountUsername = params.get('user')?.trim() || null; if (relayParam) { return { @@ -64,6 +75,8 @@ function resolveRelayBaseUrl(): { room: string | null; pk: string | null; httpBa .replace(/^ws:\/\//, 'http://') .replace(/\/ws\/?$/, '') .replace(/\/$/, ''), + accountAuth, + accountUsername, }; } @@ -75,6 +88,8 @@ function resolveRelayBaseUrl(): { room: string | null; pk: string | null; httpBa room, pk, httpBaseUrl: origin + pathname, + accountAuth, + accountUsername, }; } @@ -89,6 +104,7 @@ const PairingPage: React.FC = ({ onPaired }) => { setAuthenticatedUserId, } = useMobileStore(); const [userId, setUserId] = useState(''); + const [password, setPassword] = useState(''); const [mobileInstallId, setMobileInstallId] = useState(''); const [submitting, setSubmitting] = useState(false); const [failureCount, setFailureCount] = useState(0); @@ -98,7 +114,8 @@ const PairingPage: React.FC = ({ onPaired }) => { const failureCountRef = useRef(0); const lockUntilRef = useRef(null); - const pairingTarget = useMemo(() => resolveRelayBaseUrl(), []); + const pairingTarget = useMemo(() => resolvePairingTarget(), []); + const requiresAccountAuth = pairingTarget.accountAuth; const isLocked = !!lockUntil && lockUntil > now; const remainingLockSeconds = isLocked ? Math.max(1, Math.ceil((lockUntil - now) / 1000)) @@ -106,9 +123,11 @@ const PairingPage: React.FC = ({ onPaired }) => { const attemptPair = useCallback(async ( providedUserId: string, + providedPassword: string, options?: { autoReconnect?: boolean; installId?: string }, ) => { const userIdValue = providedUserId.trim(); + const passwordValue = providedPassword.trim(); const autoReconnect = options?.autoReconnect === true; const currentInstallId = options?.installId || mobileInstallId || getOrCreateInstallId(); const activeLockUntil = lockUntilRef.current; @@ -122,7 +141,12 @@ const PairingPage: React.FC = ({ onPaired }) => { return; } if (!userIdValue) { - setError(t('pairing.userIdRequired')); + setError(requiresAccountAuth ? t('pairing.usernameRequired') : t('pairing.userIdRequired')); + setConnectionStatus('error'); + return; + } + if (requiresAccountAuth && !passwordValue) { + setError(t('pairing.passwordRequired')); setConnectionStatus('error'); return; } @@ -143,6 +167,7 @@ const PairingPage: React.FC = ({ onPaired }) => { const initialSync = await client.pair(pairingTarget.pk, { userId: userIdValue, mobileInstallId: currentInstallId, + password: requiresAccountAuth ? passwordValue : undefined, }); setConnectionStatus('paired'); localStorage.setItem(MOBILE_USER_ID_KEY, userIdValue); @@ -150,6 +175,7 @@ const PairingPage: React.FC = ({ onPaired }) => { localStorage.removeItem(MOBILE_LOCK_UNTIL_KEY); setFailureCount(0); setLockUntil(null); + setPassword(''); setAuthenticatedUserId(initialSync.authenticated_user_id ?? userIdValue); const sessionMgr = new RemoteSessionManager(client); @@ -243,10 +269,22 @@ const PairingPage: React.FC = ({ onPaired }) => { } finally { setSubmitting(false); } - }, [mobileInstallId, pairingTarget.httpBaseUrl, pairingTarget.pk, pairingTarget.room, setAuthenticatedUserId, setConnectionStatus, setError, t]); + }, [ + mobileInstallId, + pairingTarget.httpBaseUrl, + pairingTarget.pk, + pairingTarget.room, + requiresAccountAuth, + setAuthenticatedUserId, + setConnectionStatus, + setError, + t, + ]); useEffect(() => { const savedUserId = localStorage.getItem(MOBILE_USER_ID_KEY)?.trim() ?? ''; + const qrUsername = pairingTarget.accountUsername?.trim() ?? ''; + const prefilledUserId = qrUsername || savedUserId; const currentInstallId = getOrCreateInstallId(); const persistedFailureCount = Number(localStorage.getItem(MOBILE_FAILURE_COUNT_KEY) || '0'); const persistedLockUntil = Number(localStorage.getItem(MOBILE_LOCK_UNTIL_KEY) || '0'); @@ -255,8 +293,13 @@ const PairingPage: React.FC = ({ onPaired }) => { localStorage.removeItem(MOBILE_LOCK_UNTIL_KEY); localStorage.removeItem(MOBILE_FAILURE_COUNT_KEY); } - const shouldAutoReconnect = !!savedUserId && !!currentInstallId && !!pairingTarget.room && !!pairingTarget.pk; - setUserId(savedUserId); + // Account mode always needs a password — never auto-reconnect without it. + const shouldAutoReconnect = !requiresAccountAuth + && !!savedUserId + && !!currentInstallId + && !!pairingTarget.room + && !!pairingTarget.pk; + setUserId(prefilledUserId); setMobileInstallId(currentInstallId); setFailureCount(normalizedLockUntil ? persistedFailureCount : 0); setLockUntil(normalizedLockUntil); @@ -264,9 +307,17 @@ const PairingPage: React.FC = ({ onPaired }) => { setError(null); if (shouldAutoReconnect && !autoReconnectAttemptedRef.current) { autoReconnectAttemptedRef.current = true; - void attemptPair(savedUserId, { autoReconnect: true, installId: currentInstallId }); + void attemptPair(savedUserId, '', { autoReconnect: true, installId: currentInstallId }); } - }, [attemptPair, pairingTarget.pk, pairingTarget.room, setConnectionStatus, setError]); + }, [ + attemptPair, + pairingTarget.accountUsername, + pairingTarget.pk, + pairingTarget.room, + requiresAccountAuth, + setConnectionStatus, + setError, + ]); useEffect(() => { failureCountRef.current = failureCount; @@ -297,11 +348,13 @@ const PairingPage: React.FC = ({ onPaired }) => { const handleConnect = async () => { autoReconnectAttemptedRef.current = true; - await attemptPair(userId, { autoReconnect: false }); + await attemptPair(userId, password, { autoReconnect: false }); }; const stateLabels: Record = { - idle: t('pairing.enterUserIdToContinue'), + idle: requiresAccountAuth + ? t('pairing.enterAccountToContinue') + : t('pairing.enterUserIdToContinue'), pairing: t('pairing.connectingAndPairing'), paired: t('pairing.pairedLoadingSessions'), error: t('pairing.connectionError'), @@ -335,21 +388,41 @@ const PairingPage: React.FC = ({ onPaired }) => { {showForm && (
+ {requiresAccountAuth && ( + + )}

- {t('pairing.note')} + {requiresAccountAuth ? t('pairing.accountNote') : t('pairing.note')}