From 40164af2b5b42549638b8c0b516d53889e3e5c42 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 5 Oct 2026 13:01:23 +0100 Subject: [PATCH 1/5] Add Switchify account sign-in with an emailed code Sign in with the same Supabase account as the Android app. Rust calls the auth REST API (request code, verify, refresh, sign out) so the webview CSP stays closed. The refresh token is kept in the OS keychain under com.enaboapps.switchify.pc.account; access tokens stay in memory and no token, code or email is logged or sent to the webview beyond the address shown to the user. A rejected refresh token signs out; outages do not. Settings gains an Account tab to request a code, sign in, sign out and delete the account through the shared delete_user_account RPC, with a confirmation step. Release builds now require SWITCHIFY_SUPABASE_URL and SWITCHIFY_SUPABASE_PUBLISHABLE_KEY; builds without them show accounts as unavailable. Refs #984 Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/release-tauri.yml | 9 +- src-tauri/build.rs | 6 + src-tauri/capabilities/main.json | 8 +- .../autogenerated/cancel_sign_in.toml | 11 + .../autogenerated/delete_account.toml | 11 + .../autogenerated/get_account.toml | 11 + .../autogenerated/request_sign_in_code.toml | 11 + .../permissions/autogenerated/sign_out.toml | 11 + .../autogenerated/verify_sign_in_code.toml | 11 + src-tauri/src/account.rs | 873 ++++++++++++++++++ src-tauri/src/lib.rs | 67 +- src/api.ts | 18 +- src/settings/AccountSection.test.tsx | 101 ++ src/settings/AccountSection.tsx | 110 +++ src/settings/SettingsView.tsx | 5 +- src/settings/settings.test.tsx | 6 +- src/types.ts | 7 + 17 files changed, 1268 insertions(+), 8 deletions(-) create mode 100644 src-tauri/permissions/autogenerated/cancel_sign_in.toml create mode 100644 src-tauri/permissions/autogenerated/delete_account.toml create mode 100644 src-tauri/permissions/autogenerated/get_account.toml create mode 100644 src-tauri/permissions/autogenerated/request_sign_in_code.toml create mode 100644 src-tauri/permissions/autogenerated/sign_out.toml create mode 100644 src-tauri/permissions/autogenerated/verify_sign_in_code.toml create mode 100644 src-tauri/src/account.rs create mode 100644 src/settings/AccountSection.test.tsx create mode 100644 src/settings/AccountSection.tsx diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index ee72ddaa..1ade5f4a 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -141,6 +141,8 @@ jobs: APPLE_SIGNING_IDENTITY: ${{ vars.APPLE_SIGNING_IDENTITY }} APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} SWITCHIFY_TELEMETRY_ENDPOINT: ${{ vars.TIMBERLOGS_ENDPOINT }} + SWITCHIFY_SUPABASE_URL: ${{ vars.SWITCHIFY_SUPABASE_URL }} + SWITCHIFY_SUPABASE_PUBLISHABLE_KEY: ${{ vars.SWITCHIFY_SUPABASE_PUBLISHABLE_KEY }} steps: - name: Checkout release tag uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -193,7 +195,8 @@ jobs: fi for required_name in APPLE_API_ISSUER APPLE_API_KEY TIMBERLOGS_API_KEY \ SWITCHIFY_TELEMETRY_ENDPOINT TAURI_SIGNING_PRIVATE_KEY \ - TAURI_SIGNING_PRIVATE_KEY_PASSWORD SWITCHIFY_UPDATER_PUBLIC_KEY; do + TAURI_SIGNING_PRIVATE_KEY_PASSWORD SWITCHIFY_UPDATER_PUBLIC_KEY \ + SWITCHIFY_SUPABASE_URL SWITCHIFY_SUPABASE_PUBLISHABLE_KEY; do if [[ -z "${!required_name:-}" ]]; then echo "Missing macOS release configuration: $required_name" >&2 exit 1 @@ -320,6 +323,8 @@ jobs: SWITCHIFY_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} SWITCHIFY_CERTUM_CERT_THUMBPRINT: ${{ vars.CERTUM_CERT_THUMBPRINT }} SWITCHIFY_TELEMETRY_ENDPOINT: ${{ vars.TIMBERLOGS_ENDPOINT }} + SWITCHIFY_SUPABASE_URL: ${{ vars.SWITCHIFY_SUPABASE_URL }} + SWITCHIFY_SUPABASE_PUBLISHABLE_KEY: ${{ vars.SWITCHIFY_SUPABASE_PUBLISHABLE_KEY }} steps: - name: Checkout release tag uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -368,6 +373,8 @@ jobs: 'SWITCHIFY_UPDATER_PUBLIC_KEY', 'SWITCHIFY_CERTUM_CERT_THUMBPRINT', 'SWITCHIFY_TELEMETRY_ENDPOINT', + 'SWITCHIFY_SUPABASE_URL', + 'SWITCHIFY_SUPABASE_PUBLISHABLE_KEY', 'TIMBERLOGS_API_KEY', 'TAURI_SIGNING_PRIVATE_KEY', 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD' diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 3930f6b8..bc479eba 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -40,6 +40,12 @@ fn main() { "cancel_update_download", "install_update", "export_diagnostics", + "get_account", + "request_sign_in_code", + "verify_sign_in_code", + "cancel_sign_in", + "sign_out", + "delete_account", ]); let mut attributes = tauri_build::Attributes::new().app_manifest(app_manifest); if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index 4e332b11..757cc310 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -39,6 +39,12 @@ "allow-download-update", "allow-cancel-update-download", "allow-install-update", - "allow-export-diagnostics" + "allow-export-diagnostics", + "allow-get-account", + "allow-request-sign-in-code", + "allow-verify-sign-in-code", + "allow-cancel-sign-in", + "allow-sign-out", + "allow-delete-account" ] } diff --git a/src-tauri/permissions/autogenerated/cancel_sign_in.toml b/src-tauri/permissions/autogenerated/cancel_sign_in.toml new file mode 100644 index 00000000..816367b8 --- /dev/null +++ b/src-tauri/permissions/autogenerated/cancel_sign_in.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-cancel-sign-in" +description = "Enables the cancel_sign_in command without any pre-configured scope." +commands.allow = ["cancel_sign_in"] + +[[permission]] +identifier = "deny-cancel-sign-in" +description = "Denies the cancel_sign_in command without any pre-configured scope." +commands.deny = ["cancel_sign_in"] diff --git a/src-tauri/permissions/autogenerated/delete_account.toml b/src-tauri/permissions/autogenerated/delete_account.toml new file mode 100644 index 00000000..6553a93d --- /dev/null +++ b/src-tauri/permissions/autogenerated/delete_account.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-delete-account" +description = "Enables the delete_account command without any pre-configured scope." +commands.allow = ["delete_account"] + +[[permission]] +identifier = "deny-delete-account" +description = "Denies the delete_account command without any pre-configured scope." +commands.deny = ["delete_account"] diff --git a/src-tauri/permissions/autogenerated/get_account.toml b/src-tauri/permissions/autogenerated/get_account.toml new file mode 100644 index 00000000..9a80b395 --- /dev/null +++ b/src-tauri/permissions/autogenerated/get_account.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-get-account" +description = "Enables the get_account command without any pre-configured scope." +commands.allow = ["get_account"] + +[[permission]] +identifier = "deny-get-account" +description = "Denies the get_account command without any pre-configured scope." +commands.deny = ["get_account"] diff --git a/src-tauri/permissions/autogenerated/request_sign_in_code.toml b/src-tauri/permissions/autogenerated/request_sign_in_code.toml new file mode 100644 index 00000000..37a0013c --- /dev/null +++ b/src-tauri/permissions/autogenerated/request_sign_in_code.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-request-sign-in-code" +description = "Enables the request_sign_in_code command without any pre-configured scope." +commands.allow = ["request_sign_in_code"] + +[[permission]] +identifier = "deny-request-sign-in-code" +description = "Denies the request_sign_in_code command without any pre-configured scope." +commands.deny = ["request_sign_in_code"] diff --git a/src-tauri/permissions/autogenerated/sign_out.toml b/src-tauri/permissions/autogenerated/sign_out.toml new file mode 100644 index 00000000..f4d3d0c6 --- /dev/null +++ b/src-tauri/permissions/autogenerated/sign_out.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-sign-out" +description = "Enables the sign_out command without any pre-configured scope." +commands.allow = ["sign_out"] + +[[permission]] +identifier = "deny-sign-out" +description = "Denies the sign_out command without any pre-configured scope." +commands.deny = ["sign_out"] diff --git a/src-tauri/permissions/autogenerated/verify_sign_in_code.toml b/src-tauri/permissions/autogenerated/verify_sign_in_code.toml new file mode 100644 index 00000000..e6efcc6c --- /dev/null +++ b/src-tauri/permissions/autogenerated/verify_sign_in_code.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-verify-sign-in-code" +description = "Enables the verify_sign_in_code command without any pre-configured scope." +commands.allow = ["verify_sign_in_code"] + +[[permission]] +identifier = "deny-verify-sign-in-code" +description = "Denies the verify_sign_in_code command without any pre-configured scope." +commands.deny = ["verify_sign_in_code"] diff --git a/src-tauri/src/account.rs b/src-tauri/src/account.rs new file mode 100644 index 00000000..c9eb6fa3 --- /dev/null +++ b/src-tauri/src/account.rs @@ -0,0 +1,873 @@ +//! Switchify account sign-in with an emailed code, shared with the Android +//! app's Supabase project. Requests go from Rust so the webview's content +//! security policy stays closed. The refresh token lives in the OS keychain; +//! access tokens stay in memory. Neither, nor the code, is ever logged or +//! sent to the webview. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Emitter}; + +const KEYRING_SERVICE: &str = "com.enaboapps.switchify.pc.account"; +const KEYRING_USER: &str = "session"; +pub const ACCOUNT_EVENT: &str = "account-changed"; +/// Refresh this long before the access token expires. +const REFRESH_MARGIN: Duration = Duration::from_secs(60); + +#[derive(Clone)] +struct Config { + url: String, + key: String, +} + +impl Config { + fn from_build() -> Option { + Self::new( + option_env!("SWITCHIFY_SUPABASE_URL").unwrap_or_default(), + option_env!("SWITCHIFY_SUPABASE_PUBLISHABLE_KEY").unwrap_or_default(), + ) + } + + fn new(url: &str, key: &str) -> Option { + let url = url.trim().trim_end_matches('/').to_owned(); + let key = key.trim().to_owned(); + (url.starts_with("https://") && !key.is_empty()).then_some(Self { url, key }) + } +} + +struct Response { + status: u16, + body: Value, +} + +type ResponseFuture = Pin> + Send>>; + +trait Transport: Send + Sync { + /// POSTs JSON with the publishable key. `bearer` is a user access token. + fn post( + &self, + config: &Config, + path: &str, + bearer: Option<&str>, + body: Value, + ) -> ResponseFuture; +} + +struct HttpTransport { + client: reqwest::Client, +} + +impl Default for HttpTransport { + fn default() -> Self { + Self { + client: reqwest::Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .unwrap_or_default(), + } + } +} + +impl Transport for HttpTransport { + fn post( + &self, + config: &Config, + path: &str, + bearer: Option<&str>, + body: Value, + ) -> ResponseFuture { + let mut request = self + .client + .post(format!("{}{path}", config.url)) + .header("apikey", &config.key) + .json(&body); + // Publishable keys are not JWTs, so only user tokens go in Authorization. + if let Some(token) = bearer { + request = request.bearer_auth(token); + } + Box::pin(async move { + // reqwest errors can include the URL but never headers or bodies. + let response = request + .send() + .await + .map_err(|_| "Could not reach Switchify. Check your connection.".to_string())?; + let status = response.status().as_u16(); + let body = response.json::().await.unwrap_or(Value::Null); + Ok(Response { status, body }) + }) + } +} + +trait SessionStore: Send + Sync { + fn load(&self) -> Result, String>; + fn save(&self, value: &str) -> Result<(), String>; + fn delete(&self) -> Result<(), String>; +} + +struct KeyringSessionStore; + +impl KeyringSessionStore { + fn entry() -> Result { + keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).map_err(|error| error.to_string()) + } +} + +impl SessionStore for KeyringSessionStore { + fn load(&self) -> Result, String> { + match Self::entry()?.get_password() { + Ok(value) => Ok(Some(value)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(error) => Err(error.to_string()), + } + } + + fn save(&self, value: &str) -> Result<(), String> { + Self::entry()? + .set_password(value) + .map_err(|error| error.to_string()) + } + + fn delete(&self) -> Result<(), String> { + match Self::entry()?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(error) => Err(error.to_string()), + } + } +} + +/// What survives a restart. Kept in the keychain as one JSON entry. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StoredSession { + user_id: String, + email: String, + refresh_token: String, +} + +struct Session { + stored: StoredSession, + access: Option<(String, Instant)>, +} + +#[derive(Default)] +struct Data { + session: Option, + pending_email: Option, +} + +/// Sanitized state for the webview. Never includes tokens. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountView { + pub available: bool, + pub signed_in: bool, + pub email: Option, + pub pending_email: Option, +} + +/// A usable access token for the signed-in user. +#[derive(Clone)] +pub struct Authorized { + // Read by the sync engine (#986). + #[allow(dead_code)] + pub user_id: String, + pub access_token: String, +} + +pub struct Account { + config: Option, + transport: Arc, + store: Arc, + // Async so a refresh holds the lock: refresh tokens rotate, and two + // concurrent refreshes would revoke the session. + data: tokio::sync::Mutex, +} + +impl Account { + pub fn install() -> Self { + Self::new( + Config::from_build(), + Arc::new(HttpTransport::default()), + Arc::new(KeyringSessionStore), + ) + } + + fn new( + config: Option, + transport: Arc, + store: Arc, + ) -> Self { + let session = config + .as_ref() + .and_then(|_| store.load().ok().flatten()) + .and_then(|value| serde_json::from_str::(&value).ok()) + .map(|stored| Session { + stored, + access: None, + }); + Self { + config, + transport, + store, + data: tokio::sync::Mutex::new(Data { + session, + pending_email: None, + }), + } + } + + pub async fn view(&self) -> AccountView { + let data = self.data.lock().await; + self.view_of(&data) + } + + fn view_of(&self, data: &Data) -> AccountView { + AccountView { + available: self.config.is_some(), + signed_in: data.session.is_some(), + email: data.session.as_ref().map(|s| s.stored.email.clone()), + pending_email: data.pending_email.clone(), + } + } + + fn config(&self) -> Result<&Config, String> { + self.config + .as_ref() + .ok_or_else(|| "Accounts are unavailable in this build.".to_string()) + } + + pub async fn request_code(&self, email: &str) -> Result { + let config = self.config()?; + let email = normalize_email(email)?; + let mut data = self.data.lock().await; + if data.session.is_some() { + return Err("Sign out before signing in with another email.".into()); + } + let response = self + .transport + .post( + config, + "/auth/v1/otp", + None, + json!({ "email": email, "create_user": true }), + ) + .await?; + match response.status { + 200..=299 => { + data.pending_email = Some(email); + Ok(self.view_of(&data)) + } + 429 => Err("Too many codes requested. Wait a minute, then try again.".into()), + _ => Err("Could not send a sign-in code. Try again.".into()), + } + } + + pub async fn verify_code(&self, code: &str) -> Result { + let config = self.config()?; + let code: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + if !(6..=10).contains(&code.len()) || !code.chars().all(|c| c.is_ascii_digit()) { + return Err("Enter the code from the email.".into()); + } + let mut data = self.data.lock().await; + let email = data + .pending_email + .clone() + .ok_or("Request a sign-in code first.")?; + let response = self + .transport + .post( + config, + "/auth/v1/verify", + None, + json!({ "type": "email", "email": email, "token": code }), + ) + .await?; + match response.status { + 200..=299 => {} + 429 => return Err("Too many attempts. Wait a minute, then try again.".into()), + 400..=499 => return Err("That code is wrong or has expired.".into()), + _ => return Err("Could not sign in. Try again.".into()), + } + let session = parse_session(&response.body, None)?; + self.persist(&session.stored)?; + data.session = Some(session); + data.pending_email = None; + Ok(self.view_of(&data)) + } + + pub async fn cancel_code(&self) -> AccountView { + let mut data = self.data.lock().await; + data.pending_email = None; + self.view_of(&data) + } + + /// Returns a fresh access token, refreshing it if needed. A rejected + /// refresh token signs this install out. + pub async fn authorize(&self) -> Result { + let config = self.config()?; + let mut data = self.data.lock().await; + let session = data.session.as_ref().ok_or("Sign in first.")?; + if let Some((token, expires)) = &session.access { + if Instant::now() + REFRESH_MARGIN < *expires { + return Ok(Authorized { + user_id: session.stored.user_id.clone(), + access_token: token.clone(), + }); + } + } + let response = self + .transport + .post( + config, + "/auth/v1/token?grant_type=refresh_token", + None, + json!({ "refresh_token": session.stored.refresh_token }), + ) + .await?; + match response.status { + 200..=299 => {} + 400 | 401 | 403 => { + self.store.delete()?; + data.session = None; + return Err("You were signed out. Sign in again.".into()); + } + _ => return Err("Could not reach Switchify. Try again later.".into()), + } + let previous = session.stored.clone(); + let refreshed = parse_session(&response.body, Some(&previous))?; + self.persist(&refreshed.stored)?; + let authorized = Authorized { + user_id: refreshed.stored.user_id.clone(), + access_token: refreshed + .access + .as_ref() + .map(|(token, _)| token.clone()) + .unwrap_or_default(), + }; + data.session = Some(refreshed); + Ok(authorized) + } + + /// Ends the session on this install. The server call is best effort; + /// local credentials are always removed. + pub async fn sign_out(&self) -> Result { + let config = self.config()?.clone(); + let token = self.authorize().await.ok().map(|a| a.access_token); + if let Some(token) = token { + let _ = self + .transport + .post( + &config, + "/auth/v1/logout?scope=local", + Some(&token), + json!({}), + ) + .await; + } + let mut data = self.data.lock().await; + self.store.delete()?; + data.session = None; + data.pending_email = None; + Ok(self.view_of(&data)) + } + + /// Permanently deletes the account and its synced data on every device, + /// the same way the Android app does. + pub async fn delete_account(&self) -> Result { + let config = self.config()?.clone(); + let authorized = self.authorize().await?; + let response = self + .transport + .post( + &config, + "/rest/v1/rpc/delete_user_account", + Some(&authorized.access_token), + json!({}), + ) + .await?; + if !(200..=299).contains(&response.status) { + return Err("Could not delete the account. Try again.".into()); + } + let mut data = self.data.lock().await; + self.store.delete()?; + data.session = None; + data.pending_email = None; + Ok(self.view_of(&data)) + } + + fn persist(&self, stored: &StoredSession) -> Result<(), String> { + let value = serde_json::to_string(stored).map_err(|error| error.to_string())?; + self.store + .save(&value) + .map_err(|_| "Could not save the sign-in to this computer's keychain.".to_string()) + } +} + +fn normalize_email(email: &str) -> Result { + let email = email.trim(); + let valid = email.len() <= 254 + && !email.chars().any(char::is_whitespace) + && email.split_once('@').is_some_and(|(local, domain)| { + !local.is_empty() + && !domain.contains('@') + && domain.contains('.') + && !domain.starts_with('.') + && !domain.ends_with('.') + }); + if valid { + Ok(email.to_owned()) + } else { + Err("Enter a valid email address.".into()) + } +} + +/// Reads a GoTrue session response. A refresh response may omit the user; +/// then the previous identity is kept. +fn parse_session(body: &Value, previous: Option<&StoredSession>) -> Result { + let invalid = || "Switchify sent an unexpected response. Try again.".to_string(); + let text = |value: &Value| value.as_str().filter(|s| !s.is_empty()).map(str::to_owned); + let access_token = text(&body["access_token"]).ok_or_else(invalid)?; + let refresh_token = text(&body["refresh_token"]).ok_or_else(invalid)?; + let expires_in = body["expires_in"].as_u64().ok_or_else(invalid)?; + let user_id = text(&body["user"]["id"]) + .or_else(|| previous.map(|p| p.user_id.clone())) + .ok_or_else(invalid)?; + let email = text(&body["user"]["email"]) + .or_else(|| previous.map(|p| p.email.clone())) + .ok_or_else(invalid)?; + if previous.is_some_and(|p| p.user_id != user_id) { + return Err(invalid()); + } + Ok(Session { + stored: StoredSession { + user_id, + email, + refresh_token, + }, + access: Some(( + access_token, + Instant::now() + Duration::from_secs(expires_in), + )), + }) +} + +pub fn emit(app: &AppHandle, view: &AccountView) { + let _ = app.emit(ACCOUNT_EVENT, view); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::VecDeque; + use std::sync::Mutex; + + #[derive(Default)] + struct FakeTransport { + requests: Mutex, Value)>>, + responses: Mutex>>, + } + + impl FakeTransport { + fn respond(&self, status: u16, body: Value) { + self.responses + .lock() + .unwrap() + .push_back(Ok(Response { status, body })); + } + fn fail(&self) { + self.responses + .lock() + .unwrap() + .push_back(Err("offline".into())); + } + fn requests(&self) -> Vec<(String, Option, Value)> { + self.requests.lock().unwrap().clone() + } + } + + impl Transport for FakeTransport { + fn post( + &self, + _config: &Config, + path: &str, + bearer: Option<&str>, + body: Value, + ) -> ResponseFuture { + self.requests + .lock() + .unwrap() + .push((path.into(), bearer.map(str::to_owned), body)); + let response = self + .responses + .lock() + .unwrap() + .pop_front() + .expect("unexpected request"); + Box::pin(async move { response }) + } + } + + #[derive(Default)] + struct MemoryStore { + value: Mutex>, + fail_save: bool, + } + + impl SessionStore for MemoryStore { + fn load(&self) -> Result, String> { + Ok(self.value.lock().unwrap().clone()) + } + fn save(&self, value: &str) -> Result<(), String> { + if self.fail_save { + return Err("locked".into()); + } + *self.value.lock().unwrap() = Some(value.into()); + Ok(()) + } + fn delete(&self) -> Result<(), String> { + *self.value.lock().unwrap() = None; + Ok(()) + } + } + + fn config() -> Option { + Config::new("https://example.supabase.co/", "publishable") + } + + fn account(store: Arc) -> (Account, Arc) { + let transport = Arc::new(FakeTransport::default()); + (Account::new(config(), transport.clone(), store), transport) + } + + fn session_body(access: &str, refresh: &str, expires_in: u64) -> Value { + json!({ + "access_token": access, + "refresh_token": refresh, + "expires_in": expires_in, + "user": { "id": "user-1", "email": "me@example.com" } + }) + } + + async fn signed_in(store: Arc) -> (Account, Arc) { + let (account, transport) = account(store); + transport.respond(200, json!({})); + account.request_code("me@example.com").await.unwrap(); + transport.respond(200, session_body("access-1", "refresh-1", 3600)); + account.verify_code("123456").await.unwrap(); + (account, transport) + } + + #[test] + fn config_requires_https_and_a_key() { + assert!(Config::new("http://example.supabase.co", "key").is_none()); + assert!(Config::new("https://example.supabase.co", " ").is_none()); + assert_eq!( + Config::new(" https://example.supabase.co/ ", "key") + .unwrap() + .url, + "https://example.supabase.co" + ); + } + + #[test] + fn keyring_identity_is_stable() { + assert_eq!(KEYRING_SERVICE, "com.enaboapps.switchify.pc.account"); + assert_eq!(KEYRING_USER, "session"); + } + + #[tokio::test] + async fn unavailable_without_build_config() { + let account = Account::new( + None, + Arc::new(FakeTransport::default()), + Arc::new(MemoryStore::default()), + ); + assert!(!account.view().await.available); + assert!(account.request_code("me@example.com").await.is_err()); + } + + #[tokio::test] + async fn rejects_invalid_email_and_code_without_requests() { + let (account, transport) = account(Arc::default()); + for email in [ + "", + "me", + "me@", + "@example.com", + "me@example", + "m e@example.com", + ] { + assert!(account.request_code(email).await.is_err(), "{email}"); + } + assert!( + account.verify_code("123456").await.is_err(), + "no pending email" + ); + assert!(transport.requests().is_empty()); + transport.respond(200, json!({})); + account.request_code("me@example.com").await.unwrap(); + for code in ["12345", "abcdef", "12345678901"] { + assert!(account.verify_code(code).await.is_err(), "{code}"); + } + assert_eq!(transport.requests().len(), 1); + } + + #[tokio::test] + async fn sign_in_with_code_stores_only_the_refresh_session() { + let store = Arc::new(MemoryStore::default()); + let (account, transport) = account(store.clone()); + transport.respond(200, json!({})); + let view = account.request_code(" me@example.com ").await.unwrap(); + assert_eq!(view.pending_email.as_deref(), Some("me@example.com")); + transport.respond(200, session_body("access-1", "refresh-1", 3600)); + let view = account.verify_code("123 456").await.unwrap(); + assert_eq!( + view, + AccountView { + available: true, + signed_in: true, + email: Some("me@example.com".into()), + pending_email: None, + } + ); + let requests = transport.requests(); + assert_eq!(requests[0].0, "/auth/v1/otp"); + assert_eq!( + requests[0].2, + json!({"email": "me@example.com", "create_user": true}) + ); + assert_eq!(requests[1].0, "/auth/v1/verify"); + assert_eq!( + requests[1].2, + json!({"type": "email", "email": "me@example.com", "token": "123456"}) + ); + let stored = store.value.lock().unwrap().clone().unwrap(); + assert!(stored.contains("refresh-1") && !stored.contains("access-1")); + let view_json = serde_json::to_string(&view).unwrap(); + assert!(!view_json.contains("refresh") && !view_json.contains("access-1")); + } + + #[tokio::test] + async fn wrong_code_keeps_the_pending_email() { + let (account, transport) = account(Arc::default()); + transport.respond(200, json!({})); + account.request_code("me@example.com").await.unwrap(); + transport.respond(403, json!({"msg": "Token has expired or is invalid"})); + assert_eq!( + account.verify_code("000000").await.unwrap_err(), + "That code is wrong or has expired." + ); + let view = account.view().await; + assert!(!view.signed_in); + assert_eq!(view.pending_email.as_deref(), Some("me@example.com")); + } + + #[tokio::test] + async fn rate_limits_and_network_errors_are_reported() { + let (account, transport) = account(Arc::default()); + transport.respond(429, json!({})); + assert!(account + .request_code("me@example.com") + .await + .unwrap_err() + .contains("Wait a minute")); + transport.fail(); + assert!(account.request_code("me@example.com").await.is_err()); + assert!(account.view().await.pending_email.is_none()); + } + + #[tokio::test] + async fn keychain_failure_does_not_sign_in() { + let store = Arc::new(MemoryStore { + fail_save: true, + ..MemoryStore::default() + }); + let (account, transport) = account(store); + transport.respond(200, json!({})); + account.request_code("me@example.com").await.unwrap(); + transport.respond(200, session_body("a", "r", 3600)); + assert!(account.verify_code("123456").await.is_err()); + assert!(!account.view().await.signed_in); + } + + #[tokio::test] + async fn session_survives_restart_and_refreshes() { + let store = Arc::new(MemoryStore::default()); + signed_in(store.clone()).await; + let (restarted, transport) = account(store.clone()); + assert!(restarted.view().await.signed_in); + transport.respond( + 200, + json!({"access_token": "access-2", "refresh_token": "refresh-2", "expires_in": 3600}), + ); + let authorized = restarted.authorize().await.unwrap(); + assert_eq!(authorized.access_token, "access-2"); + assert_eq!(authorized.user_id, "user-1"); + let requests = transport.requests(); + assert_eq!(requests[0].0, "/auth/v1/token?grant_type=refresh_token"); + assert_eq!(requests[0].2, json!({"refresh_token": "refresh-1"})); + assert!(store + .value + .lock() + .unwrap() + .clone() + .unwrap() + .contains("refresh-2")); + // Cached until close to expiry: no second request. + restarted.authorize().await.unwrap(); + assert_eq!(transport.requests().len(), 1); + } + + #[tokio::test] + async fn expiring_token_is_refreshed() { + let (account, transport) = account(Arc::default()); + transport.respond(200, json!({})); + account.request_code("me@example.com").await.unwrap(); + transport.respond(200, session_body("access-1", "refresh-1", 30)); + account.verify_code("123456").await.unwrap(); + transport.respond(200, session_body("access-2", "refresh-2", 3600)); + assert_eq!(account.authorize().await.unwrap().access_token, "access-2"); + } + + #[tokio::test] + async fn rejected_refresh_signs_out_but_outage_does_not() { + let store = Arc::new(MemoryStore::default()); + signed_in(store.clone()).await; + let (account, transport) = account(store.clone()); + transport.fail(); + assert!(account.authorize().await.is_err()); + transport.respond(503, json!({})); + assert!(account.authorize().await.is_err()); + assert!(account.view().await.signed_in, "outages keep the session"); + transport.respond(400, json!({"error": "invalid_grant"})); + assert!(account.authorize().await.is_err()); + assert!(!account.view().await.signed_in); + assert!(store.value.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn refresh_for_a_different_user_is_rejected() { + let store = Arc::new(MemoryStore::default()); + signed_in(store.clone()).await; + let (account, transport) = account(store); + let mut body = session_body("a", "r", 3600); + body["user"]["id"] = json!("someone-else"); + transport.respond(200, body); + assert!(account.authorize().await.is_err()); + } + + #[tokio::test] + async fn sign_out_clears_credentials_even_offline() { + let store = Arc::new(MemoryStore::default()); + let (account, transport) = signed_in(store.clone()).await; + transport.fail(); + let view = account.sign_out().await.unwrap(); + assert!(!view.signed_in && view.email.is_none()); + assert!(store.value.lock().unwrap().is_none()); + let logout = transport.requests().pop().unwrap(); + assert_eq!(logout.0, "/auth/v1/logout?scope=local"); + assert_eq!(logout.1.as_deref(), Some("access-1")); + } + + #[tokio::test] + async fn delete_account_calls_the_shared_rpc_then_signs_out() { + let store = Arc::new(MemoryStore::default()); + let (account, transport) = signed_in(store.clone()).await; + transport.respond(500, json!({})); + assert!(account.delete_account().await.is_err()); + assert!( + account.view().await.signed_in, + "failed delete keeps the session" + ); + transport.respond(204, Value::Null); + assert!(!account.delete_account().await.unwrap().signed_in); + assert!(store.value.lock().unwrap().is_none()); + let rpc = transport.requests().pop().unwrap(); + assert_eq!(rpc.0, "/rest/v1/rpc/delete_user_account"); + assert_eq!(rpc.1.as_deref(), Some("access-1")); + } + + #[tokio::test] + async fn signed_in_install_cannot_request_another_code() { + let (account, transport) = signed_in(Arc::default()).await; + assert!(account.request_code("other@example.com").await.is_err()); + assert_eq!(transport.requests().len(), 2); + } + + /// End-to-end against a local `supabase start` stack (switchify-supabase), + /// reading the code from its Mailpit inbox. Run with: + /// SWITCHIFY_LOCAL_SUPABASE_URL=http://127.0.0.1:54321 + /// SWITCHIFY_LOCAL_SUPABASE_KEY= + /// cargo test account::tests::local_stack -- --ignored + #[tokio::test] + #[ignore = "needs a local Supabase stack"] + async fn local_stack_sign_in_refresh_sign_out_and_delete() { + let url = std::env::var("SWITCHIFY_LOCAL_SUPABASE_URL").unwrap(); + let key = std::env::var("SWITCHIFY_LOCAL_SUPABASE_KEY").unwrap(); + let mailpit = std::env::var("SWITCHIFY_LOCAL_MAILPIT_URL") + .unwrap_or_else(|_| "http://127.0.0.1:54324".into()); + // Local stacks are plain HTTP, which Config::new rightly refuses. + let config = Config { url, key }; + let store = Arc::new(MemoryStore::default()); + let account = Account::new( + Some(config.clone()), + Arc::new(HttpTransport::default()), + store.clone(), + ); + let email = format!("it-{}@example.test", uuid::Uuid::new_v4()); + account.request_code(&email).await.unwrap(); + + let client = reqwest::Client::new(); + let mut code = None; + for _ in 0..20 { + let search: Value = client + .get(format!("{mailpit}/api/v1/search?query=to:{email}")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + if let Some(id) = search["messages"][0]["ID"].as_str() { + let message: Value = client + .get(format!("{mailpit}/api/v1/message/{id}")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let text = message["Text"].as_str().unwrap_or_default(); + code = text + .split(|c: char| !c.is_ascii_digit()) + .find(|part| part.len() == 6) + .map(str::to_owned); + break; + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + let view = account + .verify_code(&code.expect("no code email")) + .await + .unwrap(); + assert!(view.signed_in); + assert_eq!(view.email.as_deref(), Some(email.as_str())); + + // A restart refreshes from the stored refresh token. + let restarted = Account::new( + Some(config.clone()), + Arc::new(HttpTransport::default()), + store.clone(), + ); + let authorized = restarted.authorize().await.unwrap(); + assert!(!authorized.user_id.is_empty()); + + // The access token works for the shared delete RPC. + assert!(!restarted.delete_account().await.unwrap().signed_in); + assert!(store.value.lock().unwrap().is_none()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9829ecb1..5b80c04a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod account; mod ble_lifecycle; mod diagnostics; mod display_navigation; @@ -813,6 +814,63 @@ fn delete_switch_profile( Ok(list_switch_profiles(model)) } +#[tauri::command] +async fn get_account(account: State<'_, account::Account>) -> Result { + Ok(account.view().await) +} + +#[tauri::command] +async fn request_sign_in_code( + app: AppHandle, + account: State<'_, account::Account>, + email: String, +) -> Result { + let view = account.request_code(&email).await?; + account::emit(&app, &view); + Ok(view) +} + +#[tauri::command] +async fn verify_sign_in_code( + app: AppHandle, + account: State<'_, account::Account>, + code: String, +) -> Result { + let view = account.verify_code(&code).await?; + account::emit(&app, &view); + Ok(view) +} + +#[tauri::command] +async fn cancel_sign_in( + app: AppHandle, + account: State<'_, account::Account>, +) -> Result { + let view = account.cancel_code().await; + account::emit(&app, &view); + Ok(view) +} + +#[tauri::command] +async fn sign_out( + app: AppHandle, + account: State<'_, account::Account>, +) -> Result { + let view = account.sign_out().await?; + account::emit(&app, &view); + Ok(view) +} + +#[tauri::command] +async fn delete_account( + app: AppHandle, + account: State<'_, account::Account>, +) -> Result { + let view = account.delete_account().await?; + account::emit(&app, &view); + Ok(view) +} + fn publish_update(app: &AppHandle, model: &AppModel, update: UpdateView) -> AppState { model .shared @@ -1509,6 +1567,7 @@ pub fn run() { .manage(UpdateManager::::default()) .manage(PendingProfileExit::default()) .manage(PendingNavigation::default()) + .manage(account::Account::install()) .setup(move |app| { app.manage(switch_practice::Controller::default()); switch_runtime::install(app.handle()); @@ -1639,7 +1698,13 @@ pub fn run() { download_update, cancel_update_download, install_update, - export_diagnostics + export_diagnostics, + get_account, + request_sign_in_code, + verify_sign_in_code, + cancel_sign_in, + sign_out, + delete_account ]) .run(tauri::generate_context!()) .expect("error while running Switchify PC"); diff --git a/src/api.ts b/src/api.ts index 6b86b929..44e11052 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,6 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; -import type { AppSettings, AppState, SwitchProfile } from "./types"; +import type { AccountView, AppSettings, AppState, SwitchProfile } from "./types"; export type ProfileExitAction = "hide" | "quit"; export type NavigationTarget = "home" | "settings" | "profiles"; @@ -39,6 +39,12 @@ const emptyBindings = () => Array.from({ length: 8 }, (_, index) => ({ type: "none" as const, })); +// Browser previews have no backend, so the account is shown as unavailable. +const browserAccount: AccountView = { available: false, signedIn: false, email: null, pendingEmail: null }; +const accountCall = (command: string, args?: Record) => "__TAURI_INTERNALS__" in window + ? invoke(command, args) + : Promise.resolve(structuredClone(browserAccount)); + let browserProfiles: SwitchProfile[] = [{ id: "builtin.keyboard", version: 1, @@ -108,6 +114,16 @@ export const api = { cancelUpdateDownload: () => call("cancel_update_download"), installUpdate: () => call("install_update"), exportDiagnostics: () => call("export_diagnostics"), + account: () => accountCall("get_account"), + requestSignInCode: (email: string) => accountCall("request_sign_in_code", { email }), + verifySignInCode: (code: string) => accountCall("verify_sign_in_code", { code }), + cancelSignIn: () => accountCall("cancel_sign_in"), + signOut: () => accountCall("sign_out"), + deleteAccount: () => accountCall("delete_account"), + onAccount: async (handler: (account: AccountView) => void): Promise => { + if (!("__TAURI_INTERNALS__" in window)) return () => undefined; + return listen("account-changed", (event) => handler(event.payload)); + }, onState: async (handler: (state: AppState) => void): Promise => { if (!("__TAURI_INTERNALS__" in window)) return () => undefined; return listen("app-state-changed", (event) => handler(event.payload)); diff --git a/src/settings/AccountSection.test.tsx b/src/settings/AccountSection.test.tsx new file mode 100644 index 00000000..9134d68a --- /dev/null +++ b/src/settings/AccountSection.test.tsx @@ -0,0 +1,101 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { api } from "../api"; +import type { AccountView } from "../types"; +import { AccountSection } from "./AccountSection"; + +const signedOut: AccountView = { available: true, signedIn: false, email: null, pendingEmail: null }; +const pending: AccountView = { ...signedOut, pendingEmail: "me@example.com" }; +const signedIn: AccountView = { available: true, signedIn: true, email: "me@example.com", pendingEmail: null }; + +function start(view: AccountView) { + vi.spyOn(api, "account").mockResolvedValue(view); + vi.spyOn(api, "onAccount").mockResolvedValue(() => undefined); + render(); +} + +describe("AccountSection", () => { + afterEach(() => vi.restoreAllMocks()); + + it("says when accounts are unavailable in this build", async () => { + start({ ...signedOut, available: false }); + expect(await screen.findByText("Accounts are unavailable in this build.")).toBeInTheDocument(); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + }); + + it("requests a code, then signs in with it", async () => { + start(signedOut); + const request = vi.spyOn(api, "requestSignInCode").mockResolvedValue(pending); + const verify = vi.spyOn(api, "verifySignInCode").mockResolvedValue(signedIn); + const send = await screen.findByRole("button", { name: "Email me a code" }); + expect(send).toBeDisabled(); + fireEvent.change(screen.getByLabelText("Email"), { target: { value: "me@example.com" } }); + fireEvent.click(send); + expect(request).toHaveBeenCalledWith("me@example.com"); + const codeField = await screen.findByLabelText("Code from the email"); + await waitFor(() => expect(codeField).toHaveFocus()); + expect(screen.getByRole("status")).toHaveTextContent("We sent a code to me@example.com"); + fireEvent.change(codeField, { target: { value: "123456" } }); + fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + expect(verify).toHaveBeenCalledWith("123456"); + expect(await screen.findByRole("button", { name: "Sign out" })).toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent("Signed in as me@example.com."); + }); + + it("shows a wrong-code error and keeps the code form", async () => { + start(pending); + vi.spyOn(api, "verifySignInCode").mockRejectedValue("That code is wrong or has expired."); + fireEvent.change(await screen.findByLabelText("Code from the email"), { target: { value: "000000" } }); + fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + expect(await screen.findByRole("alert")).toHaveTextContent("That code is wrong or has expired."); + expect(screen.getByLabelText("Code from the email")).toHaveAttribute("aria-invalid", "true"); + }); + + it("can go back to choose a different email", async () => { + start(pending); + const cancel = vi.spyOn(api, "cancelSignIn").mockResolvedValue(signedOut); + fireEvent.click(await screen.findByRole("button", { name: "Use a different email" })); + expect(cancel).toHaveBeenCalled(); + expect(await screen.findByLabelText("Email")).toBeInTheDocument(); + }); + + it("signs out", async () => { + start(signedIn); + const signOut = vi.spyOn(api, "signOut").mockResolvedValue(signedOut); + fireEvent.click(await screen.findByRole("button", { name: "Sign out" })); + expect(signOut).toHaveBeenCalled(); + expect(await screen.findByLabelText("Email")).toBeInTheDocument(); + }); + + it("asks for confirmation before deleting the account", async () => { + start(signedIn); + const remove = vi.spyOn(api, "deleteAccount").mockResolvedValue(signedOut); + fireEvent.click(await screen.findByRole("button", { name: "Delete account…" })); + expect(remove).not.toHaveBeenCalled(); + expect(screen.getByText(/removes it and its saved settings from every device/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Keep account" })); + expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Delete account…" })); + fireEvent.click(screen.getByRole("button", { name: "Delete permanently" })); + expect(remove).toHaveBeenCalled(); + expect(await screen.findByLabelText("Email")).toBeInTheDocument(); + }); + + it("follows account changes from the backend", async () => { + let receive: (view: AccountView) => void = () => undefined; + vi.spyOn(api, "account").mockResolvedValue(signedOut); + vi.spyOn(api, "onAccount").mockImplementation(async (handler) => { receive = handler; return () => undefined; }); + render(); + await screen.findByLabelText("Email"); + receive(signedIn); + expect(await screen.findByRole("button", { name: "Sign out" })).toBeInTheDocument(); + }); + + it("is reachable from the Settings tabs", async () => { + const { App } = await import("../App"); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + fireEvent.click(screen.getByRole("tab", { name: "Account" })); + expect(await screen.findByRole("heading", { name: "Account" })).toBeInTheDocument(); + }); +}); diff --git a/src/settings/AccountSection.tsx b/src/settings/AccountSection.tsx new file mode 100644 index 00000000..14e8bff6 --- /dev/null +++ b/src/settings/AccountSection.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { api } from "../api"; +import { Button, Input } from "../ui/controls"; +import type { AccountView } from "../types"; +import { SettingGroup } from "./controls"; + +const description = "Sign in with the same account as Switchify on Android. We email you a code; there is no password."; + +export function AccountSection() { + const [account, setAccount] = useState(null); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const codeRef = useRef(null); + + useEffect(() => { + let active = true; + void api.account().then((view) => { if (active) setAccount(view); }); + const unlisten = api.onAccount((view) => setAccount(view)); + return () => { active = false; void unlisten.then((stop) => stop()); }; + }, []); + + useEffect(() => { + if (account?.pendingEmail) codeRef.current?.focus(); + }, [account?.pendingEmail]); + + const run = async (action: () => Promise) => { + setBusy(true); + setError(null); + try { + setAccount(await action()); + return true; + } catch (failure) { + setError(failure instanceof Error ? failure.message : String(failure)); + return false; + } finally { + setBusy(false); + } + }; + + const requestCode = (event: FormEvent) => { + event.preventDefault(); + void run(() => api.requestSignInCode(email)).then((ok) => { if (ok) setCode(""); }); + }; + const verifyCode = (event: FormEvent) => { + event.preventDefault(); + void run(() => api.verifySignInCode(code)).then((ok) => { if (ok) setCode(""); }); + }; + + if (!account) return

Loading…

; + + const errorText = error && {error}; + + if (!account.available) { + return

Accounts are unavailable in this build.

; + } + + if (account.signedIn) { + return +

Signed in as {account.email}.

+ {confirmDelete + ?
+

Deleting your account removes it and its saved settings from every device, including Switchify on Android. This cannot be undone.

+
+ + +
+
+ :
+ + +
} + {errorText} +
; + } + + if (account.pendingEmail) { + return +
+

We sent a code to {account.pendingEmail}. It can take a minute to arrive.

+ + {errorText} +
+ + +
+
+
; + } + + return +
+ + {errorText} +
+ +
+
+
; +} diff --git a/src/settings/SettingsView.tsx b/src/settings/SettingsView.tsx index f990d425..5a064121 100644 --- a/src/settings/SettingsView.tsx +++ b/src/settings/SettingsView.tsx @@ -5,10 +5,11 @@ import { GeneralSection } from "./GeneralSection"; import { InputSection } from "./InputSection"; import { CursorSection } from "./CursorSection"; import { PrivacySection } from "./PrivacySection"; +import { AccountSection } from "./AccountSection"; import { UpdatesSection, type UpdateAction } from "./UpdatesSection"; -type SettingsTabId = "general" | "input" | "cursor" | "privacy" | "updates"; +type SettingsTabId = "general" | "input" | "cursor" | "account" | "privacy" | "updates"; export function SettingsView({ state, settings, onChange, chooseTelemetry, updateAction, cancelUpdate, busy, focusUpdates, onUpdatesFocused, updateAttention, onUpdatesShown }: { state: AppState; settings: AppSettings; onChange: (next: AppSettings) => void; chooseTelemetry: (enabled: boolean) => void; updateAction: (action: UpdateAction) => void; cancelUpdate: () => void; busy: boolean; focusUpdates: boolean; onUpdatesFocused: () => void; updateAttention: string | null; onUpdatesShown: (shown: boolean) => void }) { const updatesRef = useRef(null); @@ -28,6 +29,7 @@ export function SettingsView({ state, settings, onChange, chooseTelemetry, updat { id: "general" as const, label: "General" }, { id: "input" as const, label: "Input" }, ...(state.capabilities.cursorOverlay ? [{ id: "cursor" as const, label: "Cursor appearance" }] : []), + { id: "account" as const, label: "Account" }, { id: "privacy" as const, label: "Privacy" }, // On the Updates tab the panel itself shows the reason, so no marker there. { id: "updates" as const, label: "Updates", attention: updateAttention && active !== "updates" ? updateAttention : undefined }, @@ -56,6 +58,7 @@ export function SettingsView({ state, settings, onChange, chooseTelemetry, updat {active === "general" && } {active === "input" && } {active === "cursor" && } + {active === "account" && } {active === "privacy" && } {active === "updates" && } diff --git a/src/settings/settings.test.tsx b/src/settings/settings.test.tsx index 06b3e16a..e8cdb682 100644 --- a/src/settings/settings.test.tsx +++ b/src/settings/settings.test.tsx @@ -440,7 +440,7 @@ describe("Switchify PC settings", () => { const tablist = screen.getByRole("tablist", { name: "Settings sections" }); expect(within(tablist).getAllByRole("tab").map((tab) => tab.textContent)) - .toEqual(["General", "Input", "Cursor appearance", "Privacy", "Updates"]); + .toEqual(["General", "Input", "Cursor appearance", "Account", "Privacy", "Updates"]); expect(screen.getByRole("tab", { name: "General" })).toHaveAttribute("aria-selected", "true"); expect(screen.getByRole("tabpanel")).toHaveAccessibleName("General"); }); @@ -489,7 +489,7 @@ describe("Switchify PC settings", () => { expect(screen.queryByRole("tab", { name: "Cursor appearance" })).not.toBeInTheDocument(); expect(screen.getAllByRole("tab").map((tab) => tab.textContent)) - .toEqual(["General", "Input", "Privacy", "Updates"]); + .toEqual(["General", "Input", "Account", "Privacy", "Updates"]); selectTab("Input"); expect(screen.queryByRole("checkbox", { name: "Show cursor overlay" })).not.toBeInTheDocument(); }); @@ -558,7 +558,7 @@ describe("Switchify PC settings", () => { const general = screen.getByRole("tab", { name: "General" }); expect(general).toHaveAttribute("aria-controls", "settings-panel-general"); expect(document.getElementById("settings-panel-general")).toBeInTheDocument(); - for (const name of ["Input", "Cursor appearance", "Privacy", "Updates"]) { + for (const name of ["Input", "Cursor appearance", "Account", "Privacy", "Updates"]) { expect(screen.getByRole("tab", { name })).not.toHaveAttribute("aria-controls"); } // The panel is not a tab stop of its own; its controls are. diff --git a/src/types.ts b/src/types.ts index b8737533..84b8f8b9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -80,3 +80,10 @@ export type SwitchProfile = { builtIn: boolean; bindings: SwitchBinding[]; }; + +export type AccountView = { + available: boolean; + signedIn: boolean; + email: string | null; + pendingEmail: string | null; +}; From eb4f9fefa21a520df777ae5c6005912b011b91a8 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 5 Oct 2026 13:09:24 +0100 Subject: [PATCH 2/5] Harden account sessions and keep focus in the Account tab Address review on account sign-in: - Keep a server-rotated refresh token in memory even if the keychain write fails, so the revoked token is never reused. - Clear the in-memory session before deleting from the keychain, so a keychain failure never leaves a deleted or signed-out account showing. - Retry a keychain that could not be read at startup (e.g. locked at login) and tell the user, instead of silently appearing signed out. - Account tab: controls stay enabled while busy so focus is never dropped, focus moves to the next control after each step, opening the tab no longer steals focus, and repeated errors are announced again. - Release jobs require an https Supabase URL. - Tests cover rotated-token save failure, locked and corrupt keychain entries, keychain delete failure and the unchanged store on a mismatched refresh. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/release-tauri.yml | 7 + src-tauri/src/account.rs | 190 +++++++++++++++++++++------ src/api.ts | 2 +- src/settings/AccountSection.test.tsx | 46 +++++-- src/settings/AccountSection.tsx | 77 +++++++---- src/types.ts | 1 + 6 files changed, 252 insertions(+), 71 deletions(-) diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 1ade5f4a..9e26d9da 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -202,6 +202,10 @@ jobs: exit 1 fi done + if [[ "$SWITCHIFY_SUPABASE_URL" != https://* ]]; then + echo 'SWITCHIFY_SUPABASE_URL must use https://, or accounts ship disabled.' >&2 + exit 1 + fi - name: Install Developer ID certificate shell: bash @@ -383,6 +387,9 @@ jobs: throw "Missing Windows release configuration: $name" } } + if (-not $env:SWITCHIFY_SUPABASE_URL.StartsWith('https://')) { + throw 'SWITCHIFY_SUPABASE_URL must use https://, or accounts ship disabled.' + } - name: Render updater configuration run: node scripts/render-updater-config.mjs src-tauri/tauri.release.windows.generated.json --windows diff --git a/src-tauri/src/account.rs b/src-tauri/src/account.rs index c9eb6fa3..9f4c5963 100644 --- a/src-tauri/src/account.rs +++ b/src-tauri/src/account.rs @@ -17,6 +17,8 @@ const KEYRING_USER: &str = "session"; pub const ACCOUNT_EVENT: &str = "account-changed"; /// Refresh this long before the access token expires. const REFRESH_MARGIN: Duration = Duration::from_secs(60); +const KEYCHAIN_UNAVAILABLE: &str = + "This computer's keychain is unavailable. Unlock it, then try again."; #[derive(Clone)] struct Config { @@ -157,6 +159,9 @@ struct Session { struct Data { session: Option, pending_email: Option, + /// False while the keychain could not be read (e.g. locked at login); + /// reading is retried on the next account call. + loaded: bool, } /// Sanitized state for the webview. Never includes tokens. @@ -167,6 +172,8 @@ pub struct AccountView { pub signed_in: bool, pub email: Option, pub pending_email: Option, + /// The saved sign-in could not be read from the keychain yet. + pub keychain_unavailable: bool, } /// A usable access token for the signed-in user. @@ -201,27 +208,42 @@ impl Account { transport: Arc, store: Arc, ) -> Self { - let session = config - .as_ref() - .and_then(|_| store.load().ok().flatten()) - .and_then(|value| serde_json::from_str::(&value).ok()) - .map(|stored| Session { - stored, - access: None, - }); - Self { + let account = Self { config, transport, store, - data: tokio::sync::Mutex::new(Data { - session, - pending_email: None, - }), + data: tokio::sync::Mutex::new(Data::default()), + }; + account.load(account.data.try_lock().as_deref_mut().expect("new lock")); + account + } + + /// Reads the saved session once. An unreadable keychain is retried later; + /// an absent or corrupt entry counts as signed out (the next sign-in + /// overwrites it). + fn load(&self, data: &mut Data) { + if data.loaded || self.config.is_none() { + return; + } + if let Ok(value) = self.store.load() { + data.session = value + .and_then(|value| serde_json::from_str::(&value).ok()) + .map(|stored| Session { + stored, + access: None, + }); + data.loaded = true; } } + async fn lock(&self) -> tokio::sync::MutexGuard<'_, Data> { + let mut data = self.data.lock().await; + self.load(&mut data); + data + } + pub async fn view(&self) -> AccountView { - let data = self.data.lock().await; + let data = self.lock().await; self.view_of(&data) } @@ -231,9 +253,19 @@ impl Account { signed_in: data.session.is_some(), email: data.session.as_ref().map(|s| s.stored.email.clone()), pending_email: data.pending_email.clone(), + keychain_unavailable: self.config.is_some() && !data.loaded, } } + /// Forgets the session on this install. Memory is cleared first so the + /// app never shows a session that is gone. A keychain that cannot delete + /// leaves a refresh token that the server has revoked or will reject. + fn forget(&self, data: &mut Data) { + data.session = None; + data.pending_email = None; + let _ = self.store.delete(); + } + fn config(&self) -> Result<&Config, String> { self.config .as_ref() @@ -243,7 +275,10 @@ impl Account { pub async fn request_code(&self, email: &str) -> Result { let config = self.config()?; let email = normalize_email(email)?; - let mut data = self.data.lock().await; + let mut data = self.lock().await; + if !data.loaded { + return Err(KEYCHAIN_UNAVAILABLE.into()); + } if data.session.is_some() { return Err("Sign out before signing in with another email.".into()); } @@ -272,7 +307,7 @@ impl Account { if !(6..=10).contains(&code.len()) || !code.chars().all(|c| c.is_ascii_digit()) { return Err("Enter the code from the email.".into()); } - let mut data = self.data.lock().await; + let mut data = self.lock().await; let email = data .pending_email .clone() @@ -300,7 +335,7 @@ impl Account { } pub async fn cancel_code(&self) -> AccountView { - let mut data = self.data.lock().await; + let mut data = self.lock().await; data.pending_email = None; self.view_of(&data) } @@ -309,7 +344,7 @@ impl Account { /// refresh token signs this install out. pub async fn authorize(&self) -> Result { let config = self.config()?; - let mut data = self.data.lock().await; + let mut data = self.lock().await; let session = data.session.as_ref().ok_or("Sign in first.")?; if let Some((token, expires)) = &session.access { if Instant::now() + REFRESH_MARGIN < *expires { @@ -331,15 +366,18 @@ impl Account { match response.status { 200..=299 => {} 400 | 401 | 403 => { - self.store.delete()?; - data.session = None; + self.forget(&mut data); return Err("You were signed out. Sign in again.".into()); } _ => return Err("Could not reach Switchify. Try again later.".into()), } let previous = session.stored.clone(); let refreshed = parse_session(&response.body, Some(&previous))?; - self.persist(&refreshed.stored)?; + // The server has already rotated the refresh token, so the new one + // must be kept in memory even if the keychain write fails; reusing the + // old one later would revoke the session. Only a restart before the + // next successful save falls back to the old token. + let _ = self.persist(&refreshed.stored); let authorized = Authorized { user_id: refreshed.stored.user_id.clone(), access_token: refreshed @@ -368,10 +406,8 @@ impl Account { ) .await; } - let mut data = self.data.lock().await; - self.store.delete()?; - data.session = None; - data.pending_email = None; + let mut data = self.lock().await; + self.forget(&mut data); Ok(self.view_of(&data)) } @@ -392,10 +428,8 @@ impl Account { if !(200..=299).contains(&response.status) { return Err("Could not delete the account. Try again.".into()); } - let mut data = self.data.lock().await; - self.store.delete()?; - data.session = None; - data.pending_email = None; + let mut data = self.lock().await; + self.forget(&mut data); Ok(self.view_of(&data)) } @@ -463,6 +497,7 @@ pub fn emit(app: &AppHandle, view: &AccountView) { mod tests { use super::*; use std::collections::VecDeque; + use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; #[derive(Default)] @@ -514,21 +549,35 @@ mod tests { #[derive(Default)] struct MemoryStore { value: Mutex>, - fail_save: bool, + fail_load: AtomicBool, + fail_save: AtomicBool, + fail_delete: AtomicBool, + } + + impl MemoryStore { + fn stored(&self) -> Option { + self.value.lock().unwrap().clone() + } } impl SessionStore for MemoryStore { fn load(&self) -> Result, String> { - Ok(self.value.lock().unwrap().clone()) + if self.fail_load.load(Ordering::SeqCst) { + return Err("locked".into()); + } + Ok(self.stored()) } fn save(&self, value: &str) -> Result<(), String> { - if self.fail_save { + if self.fail_save.load(Ordering::SeqCst) { return Err("locked".into()); } *self.value.lock().unwrap() = Some(value.into()); Ok(()) } fn delete(&self) -> Result<(), String> { + if self.fail_delete.load(Ordering::SeqCst) { + return Err("locked".into()); + } *self.value.lock().unwrap() = None; Ok(()) } @@ -632,6 +681,7 @@ mod tests { signed_in: true, email: Some("me@example.com".into()), pending_email: None, + keychain_unavailable: false, } ); let requests = transport.requests(); @@ -682,10 +732,8 @@ mod tests { #[tokio::test] async fn keychain_failure_does_not_sign_in() { - let store = Arc::new(MemoryStore { - fail_save: true, - ..MemoryStore::default() - }); + let store = Arc::new(MemoryStore::default()); + store.fail_save.store(true, Ordering::SeqCst); let (account, transport) = account(store); transport.respond(200, json!({})); account.request_code("me@example.com").await.unwrap(); @@ -753,11 +801,77 @@ mod tests { async fn refresh_for_a_different_user_is_rejected() { let store = Arc::new(MemoryStore::default()); signed_in(store.clone()).await; - let (account, transport) = account(store); + let (account, transport) = account(store.clone()); + let saved = store.stored(); let mut body = session_body("a", "r", 3600); body["user"]["id"] = json!("someone-else"); transport.respond(200, body); assert!(account.authorize().await.is_err()); + assert_eq!(store.stored(), saved, "mismatched session is not saved"); + assert_eq!( + account.view().await.email.as_deref(), + Some("me@example.com") + ); + } + + #[tokio::test] + async fn rotated_refresh_token_is_kept_when_the_keychain_save_fails() { + let store = Arc::new(MemoryStore::default()); + let (account, transport) = signed_in(store.clone()).await; + let saved = store.stored(); + store.fail_save.store(true, Ordering::SeqCst); + // Force a refresh by expiring the cached access token. + account.data.lock().await.session.as_mut().unwrap().access = None; + transport.respond(200, session_body("access-2", "refresh-2", 3600)); + assert_eq!(account.authorize().await.unwrap().access_token, "access-2"); + assert_eq!(store.stored(), saved, "keychain keeps the last saved copy"); + // The next refresh must use the rotated token, not the revoked one. + account.data.lock().await.session.as_mut().unwrap().access = None; + transport.respond(200, session_body("access-3", "refresh-3", 3600)); + account.authorize().await.unwrap(); + assert_eq!( + transport.requests().last().unwrap().2, + json!({"refresh_token": "refresh-2"}) + ); + } + + #[tokio::test] + async fn locked_keychain_at_startup_is_retried() { + let store = Arc::new(MemoryStore::default()); + signed_in(store.clone()).await; + store.fail_load.store(true, Ordering::SeqCst); + let (account, transport) = account(store.clone()); + let view = account.view().await; + assert!(!view.signed_in && view.keychain_unavailable); + assert!(account.request_code("me@example.com").await.is_err()); + assert!( + transport.requests().is_empty(), + "no sign-in over a hidden session" + ); + store.fail_load.store(false, Ordering::SeqCst); + let view = account.view().await; + assert!(view.signed_in && !view.keychain_unavailable); + } + + #[tokio::test] + async fn corrupt_keychain_entry_counts_as_signed_out() { + let store = Arc::new(MemoryStore::default()); + *store.value.lock().unwrap() = Some("not json".into()); + let (account, _) = account(store); + let view = account.view().await; + assert!(!view.signed_in && !view.keychain_unavailable); + } + + #[tokio::test] + async fn keychain_delete_failure_still_signs_out_and_deletes() { + let store = Arc::new(MemoryStore::default()); + let (account, transport) = signed_in(store.clone()).await; + store.fail_delete.store(true, Ordering::SeqCst); + transport.respond(204, Value::Null); + assert!(!account.delete_account().await.unwrap().signed_in); + let (account, transport) = signed_in(Arc::new(MemoryStore::default())).await; + transport.respond(200, json!({})); + assert!(!account.sign_out().await.unwrap().signed_in); } #[tokio::test] @@ -805,7 +919,7 @@ mod tests { /// cargo test account::tests::local_stack -- --ignored #[tokio::test] #[ignore = "needs a local Supabase stack"] - async fn local_stack_sign_in_refresh_sign_out_and_delete() { + async fn local_stack_sign_in_refresh_and_delete() { let url = std::env::var("SWITCHIFY_LOCAL_SUPABASE_URL").unwrap(); let key = std::env::var("SWITCHIFY_LOCAL_SUPABASE_KEY").unwrap(); let mailpit = std::env::var("SWITCHIFY_LOCAL_MAILPIT_URL") diff --git a/src/api.ts b/src/api.ts index 44e11052..73a698f7 100644 --- a/src/api.ts +++ b/src/api.ts @@ -40,7 +40,7 @@ const emptyBindings = () => Array.from({ length: 8 }, (_, index) => ({ })); // Browser previews have no backend, so the account is shown as unavailable. -const browserAccount: AccountView = { available: false, signedIn: false, email: null, pendingEmail: null }; +const browserAccount: AccountView = { available: false, signedIn: false, email: null, pendingEmail: null, keychainUnavailable: false }; const accountCall = (command: string, args?: Record) => "__TAURI_INTERNALS__" in window ? invoke(command, args) : Promise.resolve(structuredClone(browserAccount)); diff --git a/src/settings/AccountSection.test.tsx b/src/settings/AccountSection.test.tsx index 9134d68a..9e70484f 100644 --- a/src/settings/AccountSection.test.tsx +++ b/src/settings/AccountSection.test.tsx @@ -4,9 +4,9 @@ import { api } from "../api"; import type { AccountView } from "../types"; import { AccountSection } from "./AccountSection"; -const signedOut: AccountView = { available: true, signedIn: false, email: null, pendingEmail: null }; +const signedOut: AccountView = { available: true, signedIn: false, email: null, pendingEmail: null, keychainUnavailable: false }; const pending: AccountView = { ...signedOut, pendingEmail: "me@example.com" }; -const signedIn: AccountView = { available: true, signedIn: true, email: "me@example.com", pendingEmail: null }; +const signedIn: AccountView = { ...signedOut, signedIn: true, email: "me@example.com" }; function start(view: AccountView) { vi.spyOn(api, "account").mockResolvedValue(view); @@ -28,7 +28,9 @@ describe("AccountSection", () => { const request = vi.spyOn(api, "requestSignInCode").mockResolvedValue(pending); const verify = vi.spyOn(api, "verifySignInCode").mockResolvedValue(signedIn); const send = await screen.findByRole("button", { name: "Email me a code" }); - expect(send).toBeDisabled(); + expect(send).toHaveAttribute("aria-disabled", "true"); + fireEvent.click(send); + expect(request).not.toHaveBeenCalled(); fireEvent.change(screen.getByLabelText("Email"), { target: { value: "me@example.com" } }); fireEvent.click(send); expect(request).toHaveBeenCalledWith("me@example.com"); @@ -38,7 +40,7 @@ describe("AccountSection", () => { fireEvent.change(codeField, { target: { value: "123456" } }); fireEvent.click(screen.getByRole("button", { name: "Sign in" })); expect(verify).toHaveBeenCalledWith("123456"); - expect(await screen.findByRole("button", { name: "Sign out" })).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole("button", { name: "Sign out" })).toHaveFocus()); expect(screen.getByRole("status")).toHaveTextContent("Signed in as me@example.com."); }); @@ -56,7 +58,7 @@ describe("AccountSection", () => { const cancel = vi.spyOn(api, "cancelSignIn").mockResolvedValue(signedOut); fireEvent.click(await screen.findByRole("button", { name: "Use a different email" })); expect(cancel).toHaveBeenCalled(); - expect(await screen.findByLabelText("Email")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByLabelText("Email")).toHaveFocus()); }); it("signs out", async () => { @@ -64,7 +66,7 @@ describe("AccountSection", () => { const signOut = vi.spyOn(api, "signOut").mockResolvedValue(signedOut); fireEvent.click(await screen.findByRole("button", { name: "Sign out" })); expect(signOut).toHaveBeenCalled(); - expect(await screen.findByLabelText("Email")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByLabelText("Email")).toHaveFocus()); }); it("asks for confirmation before deleting the account", async () => { @@ -73,12 +75,40 @@ describe("AccountSection", () => { fireEvent.click(await screen.findByRole("button", { name: "Delete account…" })); expect(remove).not.toHaveBeenCalled(); expect(screen.getByText(/removes it and its saved settings from every device/)).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole("button", { name: "Keep account" })).toHaveFocus()); fireEvent.click(screen.getByRole("button", { name: "Keep account" })); - expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole("button", { name: "Delete account…" })).toHaveFocus()); fireEvent.click(screen.getByRole("button", { name: "Delete account…" })); fireEvent.click(screen.getByRole("button", { name: "Delete permanently" })); expect(remove).toHaveBeenCalled(); - expect(await screen.findByLabelText("Email")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByLabelText("Email")).toHaveFocus()); + }); + + it("keeps focus on the field when a request fails, and repeats the same error", async () => { + start(signedOut); + vi.spyOn(api, "requestSignInCode").mockRejectedValue("Could not send a sign-in code. Try again."); + const field = await screen.findByLabelText("Email"); + fireEvent.change(field, { target: { value: "me@example.com" } }); + field.focus(); + fireEvent.submit(field.closest("form")!); + const first = await screen.findByRole("alert"); + expect(field).toHaveFocus(); + expect(field).not.toBeDisabled(); + fireEvent.submit(field.closest("form")!); + await waitFor(() => expect(screen.getByRole("alert")).not.toBe(first)); + expect(screen.getByRole("alert")).toHaveTextContent("Could not send a sign-in code."); + }); + + it("does not move focus when the tab opens with a pending code", async () => { + start(pending); + const field = await screen.findByLabelText("Code from the email"); + expect(field).not.toHaveFocus(); + }); + + it("explains a locked keychain instead of offering sign-in", async () => { + start({ ...signedOut, keychainUnavailable: true }); + expect(await screen.findByText(/keychain is locked or unavailable/)).toBeInTheDocument(); + expect(screen.queryByLabelText("Email")).not.toBeInTheDocument(); }); it("follows account changes from the backend", async () => { diff --git a/src/settings/AccountSection.tsx b/src/settings/AccountSection.tsx index 14e8bff6..1985a9ee 100644 --- a/src/settings/AccountSection.tsx +++ b/src/settings/AccountSection.tsx @@ -6,14 +6,29 @@ import { SettingGroup } from "./controls"; const description = "Sign in with the same account as Switchify on Android. We email you a code; there is no password."; +type FocusTarget = "email" | "code" | "signOut" | "deleteAccount" | "keepAccount"; + export function AccountSection() { const [account, setAccount] = useState(null); const [email, setEmail] = useState(""); const [code, setCode] = useState(""); - const [error, setError] = useState(null); + const [error, setError] = useState<{ text: string; id: number } | null>(null); const [busy, setBusy] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); + // Set only by the user's own actions, so opening the tab never moves focus. + const [focusNext, setFocusNext] = useState(null); + const emailRef = useRef(null); const codeRef = useRef(null); + const signOutRef = useRef(null); + const deleteRef = useRef(null); + const keepRef = useRef(null); + const targets: Record HTMLElement | null> = { + email: () => emailRef.current, + code: () => codeRef.current, + signOut: () => signOutRef.current, + deleteAccount: () => deleteRef.current, + keepAccount: () => keepRef.current, + }; useEffect(() => { let active = true; @@ -23,40 +38,56 @@ export function AccountSection() { }, []); useEffect(() => { - if (account?.pendingEmail) codeRef.current?.focus(); - }, [account?.pendingEmail]); + if (!focusNext) return; + targets[focusNext]()?.focus(); + setFocusNext(null); + }); - const run = async (action: () => Promise) => { + // Controls stay enabled while busy so focus is never dropped to the page; + // repeated presses are ignored instead. + const run = async (action: () => Promise, next: (view: AccountView) => FocusTarget | null) => { + if (busy) return false; setBusy(true); setError(null); try { - setAccount(await action()); + const view = await action(); + setAccount(view); + setFocusNext(next(view)); return true; } catch (failure) { - setError(failure instanceof Error ? failure.message : String(failure)); + setError({ text: failure instanceof Error ? failure.message : String(failure), id: Date.now() }); return false; } finally { setBusy(false); } }; + const afterSignOut = (view: AccountView): FocusTarget | null => view.signedIn ? null : "email"; const requestCode = (event: FormEvent) => { event.preventDefault(); - void run(() => api.requestSignInCode(email)).then((ok) => { if (ok) setCode(""); }); + if (!email.trim()) return; + void run(() => api.requestSignInCode(email), () => "code").then((ok) => { if (ok) setCode(""); }); }; const verifyCode = (event: FormEvent) => { event.preventDefault(); - void run(() => api.verifySignInCode(code)).then((ok) => { if (ok) setCode(""); }); + if (!code.trim()) return; + void run(() => api.verifySignInCode(code), () => "signOut").then((ok) => { if (ok) setCode(""); }); }; if (!account) return

Loading…

; - const errorText = error && {error}; + // Keyed by occurrence so the same message is announced again. + const errorText = error && {error.text}; + const errorProps = { "aria-invalid": Boolean(error), "aria-describedby": error ? "account-error" : undefined }; if (!account.available) { return

Accounts are unavailable in this build.

; } + if (account.keychainUnavailable) { + return

Your saved sign-in can't be read because this computer's keychain is locked or unavailable. Unlock it, then reopen this tab.

; + } + if (account.signedIn) { return

Signed in as {account.email}.

@@ -64,13 +95,13 @@ export function AccountSection() { ?

Deleting your account removes it and its saved settings from every device, including Switchify on Android. This cannot be undone.

- - + +
:
- - + +
} {errorText}
; @@ -78,32 +109,30 @@ export function AccountSection() { if (account.pendingEmail) { return -
+

We sent a code to {account.pendingEmail}. It can take a minute to arrive.

{errorText}
- - + +
; } return -
+ {errorText}
- +
; diff --git a/src/types.ts b/src/types.ts index 84b8f8b9..95cbbc9b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -86,4 +86,5 @@ export type AccountView = { signedIn: boolean; email: string | null; pendingEmail: string | null; + keychainUnavailable: boolean; }; From 3cfeac1296881d51673098c6d8847e9459151c3f Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 5 Oct 2026 13:16:54 +0100 Subject: [PATCH 3/5] Keep the Account tab accurate after failures and locked keychains Address re-review on account sign-in: - Account commands emit the current account state even when they fail, so a session rejected mid-action never leaves a stale signed-in view. - Only user actions re-read an unreadable keychain; background calls do not, so they cannot trigger keychain prompts. - Undecodable or ambiguous keychain entries are removed and treated as signed out instead of blocking sign-in forever. - Buttons marked aria-disabled look disabled. - Correct the comment on a failed keychain delete, and make the local stack test read the code after "enter the code:" (the magic-link token can contain digit runs). - Tests cover background no-retry, sign-out with a failing keychain delete, and repeat presses while a request is pending. Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/account.rs | 65 ++++++++++++++++++++-------- src-tauri/src/lib.rs | 28 +++++++----- src/settings/AccountSection.test.tsx | 19 ++++++++ src/ui/app.css | 5 +++ 4 files changed, 86 insertions(+), 31 deletions(-) diff --git a/src-tauri/src/account.rs b/src-tauri/src/account.rs index 9f4c5963..4a7c522f 100644 --- a/src-tauri/src/account.rs +++ b/src-tauri/src/account.rs @@ -119,11 +119,21 @@ impl KeyringSessionStore { } impl SessionStore for KeyringSessionStore { + /// Only an unreachable keychain is an error (retried later). An entry + /// that cannot be decoded or is ambiguous is removed and treated as + /// signed out, so it can never lock the user out of signing in. fn load(&self) -> Result, String> { - match Self::entry()?.get_password() { + let entry = Self::entry()?; + match entry.get_password() { Ok(value) => Ok(Some(value)), Err(keyring::Error::NoEntry) => Ok(None), - Err(error) => Err(error.to_string()), + Err( + error @ (keyring::Error::PlatformFailure(_) | keyring::Error::NoStorageAccess(_)), + ) => Err(error.to_string()), + Err(_) => { + let _ = entry.delete_credential(); + Ok(None) + } } } @@ -236,14 +246,18 @@ impl Account { } } - async fn lock(&self) -> tokio::sync::MutexGuard<'_, Data> { + /// `retry_keychain` re-reads a keychain that was unreadable. Only user + /// actions retry, so background calls never trigger keychain prompts. + async fn lock(&self, retry_keychain: bool) -> tokio::sync::MutexGuard<'_, Data> { let mut data = self.data.lock().await; - self.load(&mut data); + if retry_keychain { + self.load(&mut data); + } data } pub async fn view(&self) -> AccountView { - let data = self.lock().await; + let data = self.lock(true).await; self.view_of(&data) } @@ -258,8 +272,10 @@ impl Account { } /// Forgets the session on this install. Memory is cleared first so the - /// app never shows a session that is gone. A keychain that cannot delete - /// leaves a refresh token that the server has revoked or will reject. + /// app never shows a session that is gone. If the keychain also cannot + /// delete, the refresh token stays behind: it is dead after a server + /// sign-out or account deletion, but if that call failed too (offline) + /// the next launch can restore the session. Both failing is rare. fn forget(&self, data: &mut Data) { data.session = None; data.pending_email = None; @@ -275,7 +291,7 @@ impl Account { pub async fn request_code(&self, email: &str) -> Result { let config = self.config()?; let email = normalize_email(email)?; - let mut data = self.lock().await; + let mut data = self.lock(true).await; if !data.loaded { return Err(KEYCHAIN_UNAVAILABLE.into()); } @@ -307,7 +323,7 @@ impl Account { if !(6..=10).contains(&code.len()) || !code.chars().all(|c| c.is_ascii_digit()) { return Err("Enter the code from the email.".into()); } - let mut data = self.lock().await; + let mut data = self.lock(false).await; let email = data .pending_email .clone() @@ -335,7 +351,7 @@ impl Account { } pub async fn cancel_code(&self) -> AccountView { - let mut data = self.lock().await; + let mut data = self.lock(false).await; data.pending_email = None; self.view_of(&data) } @@ -344,7 +360,7 @@ impl Account { /// refresh token signs this install out. pub async fn authorize(&self) -> Result { let config = self.config()?; - let mut data = self.lock().await; + let mut data = self.lock(false).await; let session = data.session.as_ref().ok_or("Sign in first.")?; if let Some((token, expires)) = &session.access { if Instant::now() + REFRESH_MARGIN < *expires { @@ -406,7 +422,7 @@ impl Account { ) .await; } - let mut data = self.lock().await; + let mut data = self.lock(false).await; self.forget(&mut data); Ok(self.view_of(&data)) } @@ -428,7 +444,7 @@ impl Account { if !(200..=299).contains(&response.status) { return Err("Could not delete the account. Try again.".into()); } - let mut data = self.lock().await; + let mut data = self.lock(false).await; self.forget(&mut data); Ok(self.view_of(&data)) } @@ -849,6 +865,10 @@ mod tests { "no sign-in over a hidden session" ); store.fail_load.store(false, Ordering::SeqCst); + // Background calls never re-read the keychain (no prompts)... + assert!(account.authorize().await.is_err()); + assert!(transport.requests().is_empty()); + // ...but opening the Account tab does. let view = account.view().await; assert!(view.signed_in && !view.keychain_unavailable); } @@ -869,9 +889,13 @@ mod tests { store.fail_delete.store(true, Ordering::SeqCst); transport.respond(204, Value::Null); assert!(!account.delete_account().await.unwrap().signed_in); - let (account, transport) = signed_in(Arc::new(MemoryStore::default())).await; + let store = Arc::new(MemoryStore::default()); + let (account, transport) = signed_in(store.clone()).await; + store.fail_delete.store(true, Ordering::SeqCst); transport.respond(200, json!({})); - assert!(!account.sign_out().await.unwrap().signed_in); + let view = account.sign_out().await.unwrap(); + assert!(!view.signed_in && view.email.is_none()); + assert!(account.authorize().await.is_err(), "memory session is gone"); } #[tokio::test] @@ -956,10 +980,13 @@ mod tests { .await .unwrap(); let text = message["Text"].as_str().unwrap_or_default(); - code = text - .split(|c: char| !c.is_ascii_digit()) - .find(|part| part.len() == 6) - .map(str::to_owned); + // The magic-link URL's token can contain digit runs too, so + // read only what follows "enter the code:". + code = text.rsplit_once("code:").map(|(_, rest)| { + rest.chars() + .filter(char::is_ascii_digit) + .collect::() + }); break; } tokio::time::sleep(Duration::from_millis(250)).await; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5b80c04a..cc6ed18d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -825,9 +825,10 @@ async fn request_sign_in_code( account: State<'_, account::Account>, email: String, ) -> Result { - let view = account.request_code(&email).await?; - account::emit(&app, &view); - Ok(view) + let result = account.request_code(&email).await; + // Emitted on failure too: a rejected session signs out mid-action. + account::emit(&app, &account.view().await); + result } #[tauri::command] @@ -836,9 +837,10 @@ async fn verify_sign_in_code( account: State<'_, account::Account>, code: String, ) -> Result { - let view = account.verify_code(&code).await?; - account::emit(&app, &view); - Ok(view) + let result = account.verify_code(&code).await; + // Emitted on failure too: a rejected session signs out mid-action. + account::emit(&app, &account.view().await); + result } #[tauri::command] @@ -856,9 +858,10 @@ async fn sign_out( app: AppHandle, account: State<'_, account::Account>, ) -> Result { - let view = account.sign_out().await?; - account::emit(&app, &view); - Ok(view) + let result = account.sign_out().await; + // Emitted on failure too: a rejected session signs out mid-action. + account::emit(&app, &account.view().await); + result } #[tauri::command] @@ -866,9 +869,10 @@ async fn delete_account( app: AppHandle, account: State<'_, account::Account>, ) -> Result { - let view = account.delete_account().await?; - account::emit(&app, &view); - Ok(view) + let result = account.delete_account().await; + // Emitted on failure too: a rejected session signs out mid-action. + account::emit(&app, &account.view().await); + result } fn publish_update(app: &AppHandle, model: &AppModel, update: UpdateView) -> AppState { diff --git a/src/settings/AccountSection.test.tsx b/src/settings/AccountSection.test.tsx index 9e70484f..4c19ee7a 100644 --- a/src/settings/AccountSection.test.tsx +++ b/src/settings/AccountSection.test.tsx @@ -99,6 +99,25 @@ describe("AccountSection", () => { expect(screen.getByRole("alert")).toHaveTextContent("Could not send a sign-in code."); }); + it("ignores repeat presses and keeps focus while a request is running", async () => { + start(signedOut); + let finish: (view: AccountView) => void = () => undefined; + const request = vi.spyOn(api, "requestSignInCode").mockImplementation(() => new Promise((resolve) => { finish = resolve; })); + const field = await screen.findByLabelText("Email"); + fireEvent.change(field, { target: { value: "me@example.com" } }); + field.focus(); + const send = screen.getByRole("button", { name: "Email me a code" }); + fireEvent.click(send); + await waitFor(() => expect(send).toHaveAttribute("aria-disabled", "true")); + expect(field).toHaveFocus(); + expect(field).toHaveAttribute("readonly"); + fireEvent.click(send); + fireEvent.submit(field.closest("form")!); + expect(request).toHaveBeenCalledTimes(1); + finish(pending); + await waitFor(() => expect(screen.getByLabelText("Code from the email")).toHaveFocus()); + }); + it("does not move focus when the tab opens with a pending code", async () => { start(pending); const field = await screen.findByLabelText("Code from the email"); diff --git a/src/ui/app.css b/src/ui/app.css index 7bca89db..766f54e7 100644 --- a/src/ui/app.css +++ b/src/ui/app.css @@ -669,3 +669,8 @@ fieldset:disabled :is(.color-options, .scanner-colours) label:not(:has(input:che } } + +/* Buttons that stay focusable while unavailable (aria-disabled) look the same + as disabled ones. */ +.btn[aria-disabled="true"] { cursor: default; } +.btn[aria-disabled="true"]:not(fieldset:disabled *) { border-color: var(--border); color: var(--ink-disabled); background: var(--surface-disabled); } From 361fed240afd8f43dbfd698a3dbb32adc2e7a51c Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 5 Oct 2026 13:22:00 +0100 Subject: [PATCH 4/5] Reset the Account tab cleanly when the backend changes screens Address final review on account sign-in: - A screen change pushed by the backend (e.g. a session rejected during delete) clears any pending delete confirmation and code, so the next sign-in never opens one click from deletion. - If that change removes the focused control, focus moves to the new screen's first control or the keychain notice; focus elsewhere is kept. - Post-action events report state without re-reading the keychain, so a locked keychain prompts at most once per action. - aria-disabled buttons show as GrayText in forced colours. Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/account.rs | 7 +++++ src-tauri/src/lib.rs | 8 ++--- src/settings/AccountSection.test.tsx | 47 ++++++++++++++++++++++++++++ src/settings/AccountSection.tsx | 36 ++++++++++++++++++++- src/ui/app.css | 2 ++ 5 files changed, 95 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/account.rs b/src-tauri/src/account.rs index 4a7c522f..1aa2842d 100644 --- a/src-tauri/src/account.rs +++ b/src-tauri/src/account.rs @@ -256,6 +256,13 @@ impl Account { data } + /// The view without re-reading the keychain, for reporting state after an + /// action (a second read could prompt again on a locked keychain). + pub async fn current_view(&self) -> AccountView { + let data = self.lock(false).await; + self.view_of(&data) + } + pub async fn view(&self) -> AccountView { let data = self.lock(true).await; self.view_of(&data) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cc6ed18d..6c6f0653 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -827,7 +827,7 @@ async fn request_sign_in_code( ) -> Result { let result = account.request_code(&email).await; // Emitted on failure too: a rejected session signs out mid-action. - account::emit(&app, &account.view().await); + account::emit(&app, &account.current_view().await); result } @@ -839,7 +839,7 @@ async fn verify_sign_in_code( ) -> Result { let result = account.verify_code(&code).await; // Emitted on failure too: a rejected session signs out mid-action. - account::emit(&app, &account.view().await); + account::emit(&app, &account.current_view().await); result } @@ -860,7 +860,7 @@ async fn sign_out( ) -> Result { let result = account.sign_out().await; // Emitted on failure too: a rejected session signs out mid-action. - account::emit(&app, &account.view().await); + account::emit(&app, &account.current_view().await); result } @@ -871,7 +871,7 @@ async fn delete_account( ) -> Result { let result = account.delete_account().await; // Emitted on failure too: a rejected session signs out mid-action. - account::emit(&app, &account.view().await); + account::emit(&app, &account.current_view().await); result } diff --git a/src/settings/AccountSection.test.tsx b/src/settings/AccountSection.test.tsx index 4c19ee7a..3b1eadcd 100644 --- a/src/settings/AccountSection.test.tsx +++ b/src/settings/AccountSection.test.tsx @@ -118,6 +118,53 @@ describe("AccountSection", () => { await waitFor(() => expect(screen.getByLabelText("Code from the email")).toHaveFocus()); }); + it("never carries a delete confirmation into the next sign-in after a rejected session", async () => { + let receive: (view: AccountView) => void = () => undefined; + vi.spyOn(api, "account").mockResolvedValue(signedIn); + vi.spyOn(api, "onAccount").mockImplementation(async (handler) => { receive = handler; return () => undefined; }); + // The backend signs out (and emits) before the command fails. + vi.spyOn(api, "deleteAccount").mockImplementation(async () => { + receive(signedOut); + throw "You were signed out. Sign in again."; + }); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Delete account…" })); + fireEvent.click(screen.getByRole("button", { name: "Delete permanently" })); + expect(await screen.findByRole("alert")).toHaveTextContent("You were signed out."); + await waitFor(() => expect(screen.getByLabelText("Email")).toHaveFocus()); + receive(signedIn); + expect(await screen.findByRole("button", { name: "Sign out" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Delete permanently" })).not.toBeInTheDocument(); + }); + + it("focuses the keychain notice when a request finds the keychain locked", async () => { + let receive: (view: AccountView) => void = () => undefined; + vi.spyOn(api, "account").mockResolvedValue(signedOut); + vi.spyOn(api, "onAccount").mockImplementation(async (handler) => { receive = handler; return () => undefined; }); + vi.spyOn(api, "requestSignInCode").mockImplementation(async () => { + receive({ ...signedOut, keychainUnavailable: true }); + throw "This computer's keychain is unavailable. Unlock it, then try again."; + }); + render(); + const field = await screen.findByLabelText("Email"); + fireEvent.change(field, { target: { value: "me@example.com" } }); + fireEvent.click(screen.getByRole("button", { name: "Email me a code" })); + await waitFor(() => expect(screen.getByText(/keychain is locked or unavailable/)).toHaveFocus()); + }); + + it("leaves focus alone when the backend changes screens while focus is elsewhere", async () => { + let receive: (view: AccountView) => void = () => undefined; + vi.spyOn(api, "account").mockResolvedValue(signedOut); + vi.spyOn(api, "onAccount").mockImplementation(async (handler) => { receive = handler; return () => undefined; }); + render(<>); + await screen.findByLabelText("Email"); + const elsewhere = screen.getByRole("button", { name: "Elsewhere" }); + elsewhere.focus(); + receive(signedIn); + await screen.findByRole("button", { name: "Sign out" }); + expect(elsewhere).toHaveFocus(); + }); + it("does not move focus when the tab opens with a pending code", async () => { start(pending); const field = await screen.findByLabelText("Code from the email"); diff --git a/src/settings/AccountSection.tsx b/src/settings/AccountSection.tsx index 1985a9ee..85970c75 100644 --- a/src/settings/AccountSection.tsx +++ b/src/settings/AccountSection.tsx @@ -7,6 +7,15 @@ import { SettingGroup } from "./controls"; const description = "Sign in with the same account as Switchify on Android. We email you a code; there is no password."; type FocusTarget = "email" | "code" | "signOut" | "deleteAccount" | "keepAccount"; +type Screen = "loading" | "unavailable" | "keychain" | "signedIn" | "pending" | "signedOut"; + +function screenOf(account: AccountView | null): Screen { + if (!account) return "loading"; + if (!account.available) return "unavailable"; + if (account.keychainUnavailable) return "keychain"; + if (account.signedIn) return "signedIn"; + return account.pendingEmail ? "pending" : "signedOut"; +} export function AccountSection() { const [account, setAccount] = useState(null); @@ -37,6 +46,31 @@ export function AccountSection() { return () => { active = false; void unlisten.then((stop) => stop()); }; }, []); + // The backend can change screens on its own, e.g. a session rejected in the + // middle of an action. Never carry a delete confirmation or a code into + // another screen, and if the focused control vanished, focus the new + // screen instead of leaving focus on the page. Focus that is still on a + // control (such as the tabs) is left alone. + const keychainRef = useRef(null); + const screen = screenOf(account); + const previousScreen = useRef(screen); + useEffect(() => { + const previous = previousScreen.current; + previousScreen.current = screen; + if (previous === screen) return; + if (screen !== "signedIn") setConfirmDelete(false); + if (screen !== "pending") setCode(""); + const active = document.activeElement; + if (previous === "loading" || (active && active !== document.body)) return; + const first: Partial HTMLElement | null>> = { + signedOut: () => emailRef.current, + pending: () => codeRef.current, + signedIn: () => signOutRef.current, + keychain: () => keychainRef.current, + }; + first[screen]?.()?.focus(); + }, [screen]); + useEffect(() => { if (!focusNext) return; targets[focusNext]()?.focus(); @@ -85,7 +119,7 @@ export function AccountSection() { } if (account.keychainUnavailable) { - return

Your saved sign-in can't be read because this computer's keychain is locked or unavailable. Unlock it, then reopen this tab.

; + return

Your saved sign-in can't be read because this computer's keychain is locked or unavailable. Unlock it, then reopen this tab.

; } if (account.signedIn) { diff --git a/src/ui/app.css b/src/ui/app.css index 766f54e7..99daa132 100644 --- a/src/ui/app.css +++ b/src/ui/app.css @@ -623,6 +623,8 @@ fieldset:disabled :is(.color-options, .scanner-colours) label:not(:has(input:che .toggle-row[data-disabled="true"] input:checked + .toggle::after { background: Canvas; } /* Opting out of forced colours would also keep the page's focus blue. */ .toggle-row input:focus-visible + .toggle { outline-color: CanvasText; } + /* Native :disabled is greyed by the system; aria-disabled is not. */ + .btn[aria-disabled="true"] { color: GrayText; border-color: GrayText; } .color-swatch, .scanner-sample { forced-color-adjust: none; } /* A white or yellow swatch would vanish on a light Canvas without an edge. */ .color-swatch, .color-swatch.white { border: 2px solid CanvasText; } From 1ed87e54d883b1a24b46bc8c9f896c1e9f1692f2 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 5 Oct 2026 13:28:09 +0100 Subject: [PATCH 5/5] Reword browser-build comment to avoid the retired product name Co-Authored-By: Claude Opus 5.5 (1M context) --- src/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api.ts b/src/api.ts index 73a698f7..63e726a4 100644 --- a/src/api.ts +++ b/src/api.ts @@ -39,7 +39,7 @@ const emptyBindings = () => Array.from({ length: 8 }, (_, index) => ({ type: "none" as const, })); -// Browser previews have no backend, so the account is shown as unavailable. +// Plain browser builds have no backend, so the account is shown as unavailable. const browserAccount: AccountView = { available: false, signedIn: false, email: null, pendingEmail: null, keychainUnavailable: false }; const accountCall = (command: string, args?: Record) => "__TAURI_INTERNALS__" in window ? invoke(command, args)