diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index ee72ddaa..9e26d9da 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,12 +195,17 @@ 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 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 @@ -320,6 +327,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 +377,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' @@ -376,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/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..1aa2842d --- /dev/null +++ b/src-tauri/src/account.rs @@ -0,0 +1,1021 @@ +//! 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); +const KEYCHAIN_UNAVAILABLE: &str = + "This computer's keychain is unavailable. Unlock it, then try again."; + +#[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 { + /// 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> { + let entry = Self::entry()?; + match entry.get_password() { + Ok(value) => Ok(Some(value)), + Err(keyring::Error::NoEntry) => Ok(None), + Err( + error @ (keyring::Error::PlatformFailure(_) | keyring::Error::NoStorageAccess(_)), + ) => Err(error.to_string()), + Err(_) => { + let _ = entry.delete_credential(); + Ok(None) + } + } + } + + 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, + /// 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. +#[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, + /// 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. +#[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 account = Self { + config, + transport, + store, + 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; + } + } + + /// `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; + if retry_keychain { + self.load(&mut data); + } + 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) + } + + 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(), + 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. 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; + let _ = self.store.delete(); + } + + 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.lock(true).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()); + } + 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.lock(false).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.lock(false).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.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 { + 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.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))?; + // 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 + .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.lock(false).await; + self.forget(&mut data); + 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.lock(false).await; + self.forget(&mut data); + 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::atomic::{AtomicBool, Ordering}; + 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_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> { + 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.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(()) + } + } + + 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, + keychain_unavailable: false, + } + ); + 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::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(); + 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.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); + // 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); + } + + #[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 store = Arc::new(MemoryStore::default()); + let (account, transport) = signed_in(store.clone()).await; + store.fail_delete.store(true, Ordering::SeqCst); + transport.respond(200, json!({})); + 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] + 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_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(); + // 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; + } + 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 b68ec204..2806889f 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; @@ -824,6 +825,67 @@ 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 result = account.request_code(&email).await; + // Emitted on failure too: a rejected session signs out mid-action. + account::emit(&app, &account.current_view().await); + result +} + +#[tauri::command] +async fn verify_sign_in_code( + app: AppHandle, + account: State<'_, account::Account>, + code: String, +) -> Result { + let result = account.verify_code(&code).await; + // Emitted on failure too: a rejected session signs out mid-action. + account::emit(&app, &account.current_view().await); + result +} + +#[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 result = account.sign_out().await; + // Emitted on failure too: a rejected session signs out mid-action. + account::emit(&app, &account.current_view().await); + result +} + +#[tauri::command] +async fn delete_account( + app: AppHandle, + account: State<'_, account::Account>, +) -> Result { + let result = account.delete_account().await; + // Emitted on failure too: a rejected session signs out mid-action. + account::emit(&app, &account.current_view().await); + result +} + fn publish_update(app: &AppHandle, model: &AppModel, update: UpdateView) -> AppState { model .shared @@ -1520,6 +1582,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()); @@ -1650,7 +1713,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..63e726a4 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, })); +// 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) + : 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..3b1eadcd --- /dev/null +++ b/src/settings/AccountSection.test.tsx @@ -0,0 +1,197 @@ +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, keychainUnavailable: false }; +const pending: AccountView = { ...signedOut, pendingEmail: "me@example.com" }; +const signedIn: AccountView = { ...signedOut, signedIn: true, email: "me@example.com" }; + +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).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"); + 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"); + await waitFor(() => expect(screen.getByRole("button", { name: "Sign out" })).toHaveFocus()); + 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(); + await waitFor(() => expect(screen.getByLabelText("Email")).toHaveFocus()); + }); + + 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(); + await waitFor(() => expect(screen.getByLabelText("Email")).toHaveFocus()); + }); + + 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(); + await waitFor(() => expect(screen.getByRole("button", { name: "Keep account" })).toHaveFocus()); + fireEvent.click(screen.getByRole("button", { name: "Keep account" })); + 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(); + 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("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("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"); + 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 () => { + 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..85970c75 --- /dev/null +++ b/src/settings/AccountSection.tsx @@ -0,0 +1,173 @@ +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."; + +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); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + 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; + void api.account().then((view) => { if (active) setAccount(view); }); + const unlisten = api.onAccount((view) => setAccount(view)); + 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(); + setFocusNext(null); + }); + + // 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 { + const view = await action(); + setAccount(view); + setFocusNext(next(view)); + return true; + } catch (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(); + if (!email.trim()) return; + void run(() => api.requestSignInCode(email), () => "code").then((ok) => { if (ok) setCode(""); }); + }; + const verifyCode = (event: FormEvent) => { + event.preventDefault(); + if (!code.trim()) return; + void run(() => api.verifySignInCode(code), () => "signOut").then((ok) => { if (ok) setCode(""); }); + }; + + if (!account) return

Loading…

; + + // 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}.

+ {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..95cbbc9b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -80,3 +80,11 @@ export type SwitchProfile = { builtIn: boolean; bindings: SwitchBinding[]; }; + +export type AccountView = { + available: boolean; + signedIn: boolean; + email: string | null; + pendingEmail: string | null; + keychainUnavailable: boolean; +}; diff --git a/src/ui/app.css b/src/ui/app.css index 7bca89db..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; } @@ -669,3 +671,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); }