diff --git a/Cargo.lock b/Cargo.lock index a01e6c0..fc2bb35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2250,6 +2250,7 @@ dependencies = [ "ely_browser_core", "ely_design_system", "ely_domain", + "ely_sync_client", "gpui", "gpui-component", "gpui-component-assets", diff --git a/cloudflare/src/better_auth.ts b/cloudflare/src/better_auth.ts index 1a75f10..71545f0 100644 --- a/cloudflare/src/better_auth.ts +++ b/cloudflare/src/better_auth.ts @@ -7,7 +7,7 @@ import { jsonResponse } from "./responses.js"; const APP_NAME = "ELY Browser"; const AUTH_BASE_PATH = "/api/auth"; const AUTH_CALLBACK_URL = "ely://auth/callback"; -const EMAIL_OTP_FROM_ADDRESS = "auth@elydora.com"; +const EMAIL_OTP_FROM_ADDRESS = "browser@elydora.com"; const EMAIL_OTP_EXPIRES_IN_SECONDS = 300; type BetterAuthDatabase = NonNullable; diff --git a/cloudflare/wrangler.toml b/cloudflare/wrangler.toml index 0d4c887..375b97c 100644 --- a/cloudflare/wrangler.toml +++ b/cloudflare/wrangler.toml @@ -19,7 +19,7 @@ id = "ba8f06dd5cc34bc4bc0f8a305c570cfb" [[send_email]] name = "SEND_EMAIL" -allowed_sender_addresses = ["auth@elydora.com"] +allowed_sender_addresses = ["browser@elydora.com"] [vars] ELY_ENVIRONMENT = "production" diff --git a/crates/ely_app/Cargo.toml b/crates/ely_app/Cargo.toml index a7d3b1a..7e764a3 100644 --- a/crates/ely_app/Cargo.toml +++ b/crates/ely_app/Cargo.toml @@ -15,6 +15,7 @@ ed25519-dalek.workspace = true ely_browser_core = { path = "../ely_browser_core" } ely_design_system = { path = "../ely_design_system" } ely_domain = { path = "../ely_domain" } +ely_sync_client = { path = "../ely_sync_client" } gpui.workspace = true gpui-component.workspace = true gpui-component-assets.workspace = true diff --git a/crates/ely_app/src/shell/auth.rs b/crates/ely_app/src/shell/auth.rs new file mode 100644 index 0000000..0d7fe93 --- /dev/null +++ b/crates/ely_app/src/shell/auth.rs @@ -0,0 +1,267 @@ +//! Email + OTP sign-in flow plumbing for the shell. +//! +//! The HTTP work runs on a dedicated `ely-sync-auth` thread so the +//! GPUI render loop never blocks on the network — same invariant the +//! Servo IPC worker enforces. Results flow back to the shell through +//! `SyncStateUpdate` messages drained by `tick_external_web_surfaces`, +//! so the existing 8 ms tick is the single point that reconciles +//! background-task state with `BrowserCore`. + +use std::sync::mpsc::Sender; + +use ely_browser_core::SyncEngine; +use ely_sync_client::{ApiClientConfig, BearerToken, send_email_otp, verify_email_otp}; +use gpui::Context; + +use crate::services::servo_profile_data::{default_profile_data_root, profile_data_dir}; + +use super::{ElyShell, ShellState, SyncStateUpdate}; + +/// Where the user is in the email OTP form. Tracked on `ElyShell` so +/// the Sync settings page can pick the right widget cluster (only the +/// email row, OTP row + email row, signed-in account chip, etc.) on +/// every render without re-deriving it from disk. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) enum AuthFlowPhase { + /// No sign-in attempt in progress. The form shows just the email + /// field and a "Send code" button. + #[default] + Idle, + /// `send_email_otp` is in flight. UI disables the form so the + /// user can't resend before the worker confirms acceptance. + SendingCode { email: String }, + /// The worker accepted the request and Cloudflare's `SEND_EMAIL` + /// binding handed the message to the recipient's MTA. UI now + /// reveals the OTP field. + AwaitingOtp { email: String }, + /// `verify_email_otp` is in flight. UI shows a transient + /// "Verifying…" state. + Verifying { email: String }, + /// Last attempt failed. UI surfaces the message inline so the + /// user knows what to retry. + Error { email: String, message: String }, +} + +impl AuthFlowPhase { + pub(crate) fn email(&self) -> Option<&str> { + match self { + Self::Idle => None, + Self::SendingCode { email } + | Self::AwaitingOtp { email } + | Self::Verifying { email } + | Self::Error { email, .. } => Some(email), + } + } + + pub(crate) fn error_message(&self) -> Option<&str> { + match self { + Self::Error { message, .. } => Some(message.as_str()), + _ => None, + } + } + + pub(crate) fn is_busy(&self) -> bool { + matches!(self, Self::SendingCode { .. } | Self::Verifying { .. }) + } +} + +impl ElyShell { + /// Hand the typed email to the worker thread that calls + /// `send_email_otp`. The thread reports success / failure back + /// through the shared `SyncStateUpdate` channel, which the next + /// shell tick reconciles into the `auth_flow_phase`. + pub(crate) fn submit_email_otp_request(&mut self, cx: &mut Context) { + let email = self.read_auth_email_input(cx); + let Some(email) = normalize_email(&email) else { + self.auth_flow_phase = AuthFlowPhase::Error { + email: String::new(), + message: "Enter a valid email to receive a code.".to_string(), + }; + return; + }; + self.auth_flow_phase = AuthFlowPhase::SendingCode { email: email.clone() }; + let tx = self.sync_inbox_tx.clone(); + spawn_send_otp(email, tx); + } + + /// Hand the typed OTP to the worker thread that calls + /// `verify_email_otp`, persists the bearer token, and triggers + /// the first snapshot upload on success. + pub(crate) fn submit_email_otp_verify(&mut self, cx: &mut Context) { + let email = match self.auth_flow_phase.clone() { + AuthFlowPhase::AwaitingOtp { email } + | AuthFlowPhase::Error { email, .. } + | AuthFlowPhase::Verifying { email } => email, + _ => return, + }; + let otp = self.read_auth_otp_input(cx); + let normalized_otp = otp.trim().replace(['-', ' '], ""); + if normalized_otp.is_empty() { + self.auth_flow_phase = + AuthFlowPhase::Error { email, message: "Enter the code you received.".to_string() }; + return; + } + let active_profile_id = match active_profile_id_for(&self.state) { + Some(id) => id, + None => return, + }; + let Some(profile_root) = default_profile_data_root() else { + self.auth_flow_phase = AuthFlowPhase::Error { + email, + message: "Profile data root is unavailable on this machine.".to_string(), + }; + return; + }; + let profile_dir = profile_data_dir(&profile_root, &active_profile_id); + self.auth_flow_phase = AuthFlowPhase::Verifying { email: email.clone() }; + let tx = self.sync_inbox_tx.clone(); + spawn_verify_otp(email, normalized_otp, profile_dir, tx); + } + + /// Drop the persisted bearer token and reset the local form. + /// The bearer file is removed synchronously — there is no network + /// call to make, the token is the only artefact we own. + pub(crate) fn submit_sign_out(&mut self, _cx: &mut Context) { + self.auth_flow_phase = AuthFlowPhase::Idle; + let active_profile_id = match active_profile_id_for(&self.state) { + Some(id) => id, + None => return, + }; + let Some(profile_root) = default_profile_data_root() else { + return; + }; + let profile_dir = profile_data_dir(&profile_root, &active_profile_id); + match SyncEngine::for_profile_dir(&profile_dir, "ELY", super::sync_platform_label()) { + Ok(mut engine) => { + let _ = engine.install_bearer(""); + } + Err(error) => { + tracing::warn!(target: "ely::sync", error = %error, "sign-out failed to load engine"); + } + } + if let ShellState::Ready(core) = &mut self.state { + core.set_sync_connection_state(ely_domain::SyncConnectionState::SignedOut); + } + } + + fn read_auth_email_input(&self, cx: &Context) -> String { + self.auth_email_input.read(cx).value().to_string() + } + + fn read_auth_otp_input(&self, cx: &Context) -> String { + self.auth_otp_input.read(cx).value().to_string() + } +} + +fn normalize_email(raw: &str) -> Option { + let trimmed = raw.trim(); + if !trimmed.contains('@') || trimmed.starts_with('@') || trimmed.ends_with('@') { + return None; + } + Some(trimmed.to_lowercase()) +} + +fn active_profile_id_for(state: &ShellState) -> Option { + let ShellState::Ready(core) = state else { + return None; + }; + core.snapshot().ok().map(|snapshot| snapshot.active_profile_id.clone()) +} + +fn spawn_send_otp(email: String, tx: Sender) { + std::thread::Builder::new() + .name("ely-sync-auth-send".to_string()) + .spawn(move || { + let config = ApiClientConfig::production(); + match send_email_otp(&config, &email) { + Ok(()) => { + let _ = tx.send(SyncStateUpdate::AuthOtpSent { email }); + } + Err(error) => { + let _ = + tx.send(SyncStateUpdate::AuthError { email, message: error.to_string() }); + } + } + }) + .map(|_| ()) + .unwrap_or_else(|error| { + tracing::warn!(target: "ely::sync", error = %error, "spawn ely-sync-auth-send failed"); + }); +} + +fn spawn_verify_otp( + email: String, + otp: String, + profile_dir: std::path::PathBuf, + tx: Sender, +) { + std::thread::Builder::new() + .name("ely-sync-auth-verify".to_string()) + .spawn(move || { + let config = ApiClientConfig::production(); + let token: BearerToken = match verify_email_otp(&config, &email, &otp) { + Ok(token) => token, + Err(error) => { + let _ = tx.send(SyncStateUpdate::AuthError { + email, + message: error.to_string(), + }); + return; + } + }; + let mut engine = + match SyncEngine::for_profile_dir(&profile_dir, "ELY", super::sync_platform_label()) + { + Ok(engine) => engine, + Err(error) => { + let _ = tx.send(SyncStateUpdate::AuthError { + email, + message: error.to_string(), + }); + return; + } + }; + if let Err(error) = engine.install_bearer(token.as_str()) { + let _ = tx.send(SyncStateUpdate::AuthError { email, message: error.to_string() }); + return; + } + let _ = tx.send(SyncStateUpdate::AuthSucceeded { email }); + }) + .map(|_| ()) + .unwrap_or_else(|error| { + tracing::warn!(target: "ely::sync", error = %error, "spawn ely-sync-auth-verify failed"); + }); +} + +#[cfg(test)] +mod tests { + use super::{AuthFlowPhase, normalize_email}; + + #[test] + fn normalize_lowercases_and_trims() { + assert_eq!(normalize_email(" User@Example.COM "), Some("user@example.com".to_string())); + } + + #[test] + fn normalize_rejects_obviously_broken() { + assert_eq!(normalize_email("noatsign"), None); + assert_eq!(normalize_email("@no-local-part"), None); + assert_eq!(normalize_email("missing-domain@"), None); + assert_eq!(normalize_email(""), None); + } + + #[test] + fn auth_phase_helpers() { + let phase = AuthFlowPhase::Verifying { email: "you@there".to_string() }; + assert_eq!(phase.email(), Some("you@there")); + assert!(phase.is_busy()); + assert_eq!(phase.error_message(), None); + + let phase = AuthFlowPhase::Error { + email: "you@there".to_string(), + message: "rate limited".to_string(), + }; + assert_eq!(phase.error_message(), Some("rate limited")); + assert!(!phase.is_busy()); + } +} diff --git a/crates/ely_app/src/shell/internal_pages/sync.rs b/crates/ely_app/src/shell/internal_pages/sync.rs index aac985f..056c92c 100644 --- a/crates/ely_app/src/shell/internal_pages/sync.rs +++ b/crates/ely_app/src/shell/internal_pages/sync.rs @@ -7,7 +7,9 @@ use gpui::{ AnyElement, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, SharedString, StatefulInteractiveElement, Styled, div, prelude::FluentBuilder, px, rgb, rgba, }; -use gpui_component::{IconName, scroll::ScrollableElement}; +use gpui_component::{IconName, input::Input, scroll::ScrollableElement}; + +use crate::shell::auth::AuthFlowPhase; use crate::brand::SYNC_SERVICE_NAME; @@ -27,14 +29,18 @@ impl ElyShell { .grid() .grid_cols(2) .gap(px(32.0)) - .child(render_left_column(snapshot, cx)) + .child(render_left_column(self, snapshot, cx)) .child(render_right_column(snapshot, cx)), ), ) } } -fn render_left_column(snapshot: &BrowserSnapshot, cx: &mut Context) -> AnyElement { +fn render_left_column( + shell: &mut ElyShell, + snapshot: &BrowserSnapshot, + cx: &mut Context, +) -> AnyElement { div() .flex() .flex_col() @@ -43,6 +49,7 @@ fn render_left_column(snapshot: &BrowserSnapshot, cx: &mut Context) -> .child(render_status_pill(snapshot)) .child(render_serif_headline()) .child(render_intro_paragraph()) + .child(render_account_card(shell, snapshot, cx)) .child(render_metrics_card(snapshot, cx)) .into_any_element() } @@ -118,6 +125,229 @@ fn render_metrics_card(snapshot: &BrowserSnapshot, cx: &mut Context) - .into_any_element() } +fn render_account_card( + shell: &ElyShell, + snapshot: &BrowserSnapshot, + cx: &mut Context, +) -> AnyElement { + let card = div() + .max_w(px(380.0)) + .p(px(20.0)) + .rounded(px(16.0)) + .bg(rgba(card_bg())) + .flex() + .flex_col() + .gap(px(14.0)); + + match snapshot.sync_status.connection() { + SyncConnectionState::SignedOut => card + .child(render_account_heading("Sign in")) + .child(render_account_subtitle("We'll email a 6-digit code from browser@elydora.com.")) + .children(account_form(shell, cx)) + .into_any_element(), + SyncConnectionState::SignedIn + | SyncConnectionState::AwaitingDeviceApproval + | SyncConnectionState::SyncReady { .. } + | SyncConnectionState::SyncError { .. } => card + .child(render_account_heading("Account")) + .child(render_signed_in_chip()) + .child(render_sign_out_button(cx)) + .into_any_element(), + } +} + +fn account_form(shell: &ElyShell, cx: &mut Context) -> Vec { + let mut elements: Vec = Vec::new(); + + let phase = shell.auth_flow_phase.clone(); + let prefill_email = phase.email().map(str::to_string); + + elements.push(render_account_label("Email")); + elements.push(render_input(&shell.auth_email_input, prefill_email.as_deref())); + + match &phase { + AuthFlowPhase::Idle | AuthFlowPhase::Error { .. } => { + elements.push(render_primary_button( + "send-otp", + "Send code", + false, + cx, + |shell, cx| { + shell.submit_email_otp_request(cx); + }, + )); + } + AuthFlowPhase::SendingCode { .. } => { + elements.push(render_primary_button("send-otp", "Sending…", true, cx, |_, _| {})); + } + AuthFlowPhase::AwaitingOtp { .. } | AuthFlowPhase::Verifying { .. } => { + elements.push(render_account_label("Code")); + elements.push(render_input(&shell.auth_otp_input, None)); + elements.push(render_dual_button_row( + phase.is_busy(), + cx, + |shell, cx| shell.submit_email_otp_verify(cx), + |shell, cx| shell.submit_email_otp_request(cx), + )); + } + } + + if let Some(message) = phase.error_message() { + elements.push(render_inline_error(message)); + } + + elements +} + +fn render_account_heading(label: &str) -> AnyElement { + div() + .text_size(px(13.0)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(colors::ink())) + .child(label.to_string()) + .into_any_element() +} + +fn render_account_subtitle(text: &str) -> AnyElement { + div() + .text_size(px(12.0)) + .text_color(rgb(colors::ink_3())) + .child(text.to_string()) + .into_any_element() +} + +fn render_account_label(label: &'static str) -> AnyElement { + div() + .text_size(px(10.5)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(colors::ink_4())) + .child(label) + .into_any_element() +} + +fn render_input( + state: &gpui::Entity, + hint: Option<&str>, +) -> AnyElement { + let mut wrapper = div() + .px(px(10.0)) + .py(px(8.0)) + .rounded(px(8.0)) + .bg(rgba(button_bg())) + .child(Input::new(state).appearance(false).cleanable(false)); + if let Some(hint) = hint { + wrapper = wrapper.child( + div().text_size(px(10.0)).text_color(rgb(colors::ink_4())).child(hint.to_string()), + ); + } + wrapper.into_any_element() +} + +fn render_primary_button( + id: &'static str, + label: &'static str, + disabled: bool, + cx: &mut Context, + handler: F, +) -> AnyElement +where + F: Fn(&mut ElyShell, &mut Context) + 'static, +{ + div() + .id(SharedString::from(id)) + .px(px(14.0)) + .py(px(8.0)) + .rounded(px(8.0)) + .bg(rgba(colors::accent())) + .text_size(px(12.5)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(0xfff5e6)) + .when(!disabled, |el| { + el.cursor_pointer() + .hover(|style| style.opacity(0.92)) + .active(|style| style.opacity(0.78)) + .on_click(cx.listener(move |shell, _, _, cx| handler(shell, cx))) + }) + .when(disabled, |el| el.opacity(0.6)) + .child(label) + .into_any_element() +} + +fn render_dual_button_row( + disabled: bool, + cx: &mut Context, + primary: P, + secondary: S, +) -> AnyElement +where + P: Fn(&mut ElyShell, &mut Context) + 'static, + S: Fn(&mut ElyShell, &mut Context) + 'static, +{ + div() + .flex() + .gap(px(8.0)) + .child(render_primary_button( + "verify-otp", + if disabled { "Verifying…" } else { "Verify" }, + disabled, + cx, + primary, + )) + .child( + div() + .id(SharedString::from("resend-otp")) + .px(px(12.0)) + .py(px(8.0)) + .rounded(px(8.0)) + .bg(rgba(button_bg())) + .text_size(px(12.0)) + .text_color(rgb(colors::ink_2())) + .when(!disabled, |el| { + el.cursor_pointer() + .hover(|style| style.bg(rgba(button_bg_hover()))) + .active(|style| style.opacity(0.85)) + .on_click(cx.listener(move |shell, _, _, cx| secondary(shell, cx))) + }) + .when(disabled, |el| el.opacity(0.6)) + .child("Resend code"), + ) + .into_any_element() +} + +fn render_inline_error(message: &str) -> AnyElement { + div() + .text_size(px(11.5)) + .text_color(rgb(colors::error())) + .child(message.to_string()) + .into_any_element() +} + +fn render_signed_in_chip() -> AnyElement { + div() + .text_size(px(13.0)) + .text_color(rgb(colors::ink_2())) + .child("Signed in. New sessions on this device share the same encrypted snapshot.") + .into_any_element() +} + +fn render_sign_out_button(cx: &mut Context) -> AnyElement { + div() + .id(SharedString::from("sign-out")) + .px(px(12.0)) + .py(px(7.0)) + .rounded(px(8.0)) + .bg(rgba(button_bg())) + .text_size(px(12.0)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(colors::ink_2())) + .cursor_pointer() + .hover(|style| style.bg(rgba(button_bg_hover()))) + .active(|style| style.opacity(0.85)) + .on_click(cx.listener(|shell, _, _, cx| shell.submit_sign_out(cx))) + .child("Sign out") + .into_any_element() +} + fn render_metric(label: &'static str, value: usize, color: u32) -> AnyElement { div() .flex() @@ -151,7 +381,7 @@ fn render_reset_button(cx: &mut Context) -> AnyElement { .cursor_pointer() .hover(|style| style.opacity(0.92)) .active(|style| style.opacity(0.78)) - .on_click(cx.listener(|shell, _, _, cx| shell.trigger_cloud_sync_upload(cx))) + .on_click(cx.listener(|shell, _, _, _| shell.trigger_cloud_sync_upload())) .child("Sync now"), ) .child( diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 61b28b9..e1fdb22 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -1,4 +1,5 @@ mod archive_labels; +mod auth; mod bookmark_files; mod bookmarks; pub(crate) mod chrome; @@ -105,21 +106,41 @@ pub struct ElyShell { /// `tick_external_web_surfaces`. sync_inbox_rx: std::sync::mpsc::Receiver, pub(crate) sync_inbox_tx: std::sync::mpsc::Sender, + pub(crate) auth_email_input: Entity, + pub(crate) auth_otp_input: Entity, + pub(crate) auth_flow_phase: auth::AuthFlowPhase, _command_subscription: Subscription, _translucency_subscription: Subscription, } -/// Messages the off-thread sync worker pushes back to the shell so the -/// `SyncConnectionState` on `BrowserCore` reflects the live engine -/// without the UI thread ever touching the network. `SignedIn` is the -/// initial-probe state set synchronously on shell startup and does not -/// flow through this channel. +/// Messages the off-thread sync workers push back to the shell so +/// `SyncConnectionState` on `BrowserCore` and the in-flight auth +/// form reflect live state without the UI thread ever touching the +/// network. `SignedIn` is the initial-probe state set synchronously +/// on shell startup and does not flow through this channel. #[derive(Clone, Debug)] pub(crate) enum SyncStateUpdate { SignedOut, AwaitingDeviceApproval, SyncReady { last_synced_at_secs: u64 }, SyncError { message: String }, + AuthOtpSent { email: String }, + AuthSucceeded { email: String }, + AuthError { email: String, message: String }, +} + +/// Stable label for the current OS used by the device registration +/// payload. Defined once here so every off-thread call site agrees. +pub(crate) const fn sync_platform_label() -> &'static str { + if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "linux") { + "linux" + } else { + "other" + } } impl ElyShell { @@ -140,6 +161,9 @@ impl ElyShell { cx.new(|cx| InputState::new(window, cx).placeholder("Search ELY or type a command…")); let plugin_search_input = cx.new(|cx| InputState::new(window, cx).placeholder("Search plugins…")); + let auth_email_input = + cx.new(|cx| InputState::new(window, cx).placeholder("you@elydora.com")); + let auth_otp_input = cx.new(|cx| InputState::new(window, cx).placeholder("123456")); let translucency_slider = cx.new(|_cx| { SliderState::new() .min(0.0) @@ -237,6 +261,9 @@ impl ElyShell { web_surfaces: WebSurfaceStore::new(), sync_inbox_rx, sync_inbox_tx, + auth_email_input, + auth_otp_input, + auth_flow_phase: auth::AuthFlowPhase::Idle, _command_subscription: command_subscription, _translucency_subscription: translucency_subscription, }; @@ -280,29 +307,50 @@ impl ElyShell { /// state on `BrowserCore`. Returns `true` when at least one /// update was applied so callers can `cx.notify()` accordingly. pub(super) fn drain_sync_updates(&mut self) -> bool { - let ShellState::Ready(core) = &mut self.state else { - return false; - }; - let mut latest: Option = None; + let mut latest_connection: Option = None; + let mut auth_changed = false; + let mut trigger_initial_sync = false; while let Ok(update) = self.sync_inbox_rx.try_recv() { - latest = Some(match update { - SyncStateUpdate::SignedOut => ely_domain::SyncConnectionState::SignedOut, + match update { + SyncStateUpdate::SignedOut => { + latest_connection = Some(ely_domain::SyncConnectionState::SignedOut); + } SyncStateUpdate::AwaitingDeviceApproval => { - ely_domain::SyncConnectionState::AwaitingDeviceApproval + latest_connection = + Some(ely_domain::SyncConnectionState::AwaitingDeviceApproval); } SyncStateUpdate::SyncReady { last_synced_at_secs } => { - ely_domain::SyncConnectionState::SyncReady { last_synced_at_secs } + latest_connection = + Some(ely_domain::SyncConnectionState::SyncReady { last_synced_at_secs }); } SyncStateUpdate::SyncError { message } => { - ely_domain::SyncConnectionState::SyncError { message } + latest_connection = + Some(ely_domain::SyncConnectionState::SyncError { message }); } - }); + SyncStateUpdate::AuthOtpSent { email } => { + self.auth_flow_phase = auth::AuthFlowPhase::AwaitingOtp { email }; + auth_changed = true; + } + SyncStateUpdate::AuthSucceeded { email } => { + self.auth_flow_phase = auth::AuthFlowPhase::Idle; + latest_connection = Some(ely_domain::SyncConnectionState::SignedIn); + trigger_initial_sync = true; + tracing::info!(target: "ely::sync", email = %email, "email OTP sign-in succeeded"); + auth_changed = true; + } + SyncStateUpdate::AuthError { email, message } => { + self.auth_flow_phase = auth::AuthFlowPhase::Error { email, message }; + auth_changed = true; + } + } } - if let Some(state) = latest { + if let (Some(state), ShellState::Ready(core)) = (latest_connection, &mut self.state) { core.set_sync_connection_state(state); - return true; } - false + if trigger_initial_sync { + self.trigger_cloud_sync_upload(); + } + auth_changed || trigger_initial_sync } fn focus_command_mode(&mut self, window: &mut Window, cx: &mut Context) { diff --git a/crates/ely_app/src/shell/settings_actions.rs b/crates/ely_app/src/shell/settings_actions.rs index 77198c4..3cddc22 100644 --- a/crates/ely_app/src/shell/settings_actions.rs +++ b/crates/ely_app/src/shell/settings_actions.rs @@ -246,7 +246,7 @@ impl ElyShell { /// (the UI thread never blocks on the network), and the worker /// reports back through the shell's `sync_inbox` so the sync page /// reflects the new state without waiting for a manual refresh. - pub(super) fn trigger_cloud_sync_upload(&mut self, _cx: &mut Context) { + pub(crate) fn trigger_cloud_sync_upload(&mut self) { let ShellState::Ready(core) = &self.state else { return; }; @@ -318,7 +318,11 @@ fn run_sync_upload( bytes: Vec, inbox: std::sync::mpsc::Sender, ) { - let mut engine = match SyncEngine::for_profile_dir(&profile_dir, device_name, sync_platform()) { + let mut engine = match SyncEngine::for_profile_dir( + &profile_dir, + device_name, + super::sync_platform_label(), + ) { Ok(engine) => engine, Err(error) => { let message = error.to_string(); @@ -364,15 +368,3 @@ fn run_sync_upload( } } } - -const fn sync_platform() -> &'static str { - if cfg!(target_os = "macos") { - "macos" - } else if cfg!(target_os = "windows") { - "windows" - } else if cfg!(target_os = "linux") { - "linux" - } else { - "other" - } -} diff --git a/crates/ely_sync_client/src/email_otp.rs b/crates/ely_sync_client/src/email_otp.rs new file mode 100644 index 0000000..626391d --- /dev/null +++ b/crates/ely_sync_client/src/email_otp.rs @@ -0,0 +1,155 @@ +//! Email + OTP sign-in flow against the Better Auth `email-otp` plugin +//! exposed by the Cloudflare worker. +//! +//! Two endpoints are involved, both unauthenticated and rate-limited +//! by the worker: +//! +//! - `POST /api/auth/email-otp/send-verification-otp` triggers the +//! email send. Body: `{ email, type: "sign-in" }`. Returns 200 on +//! success. +//! - `POST /api/auth/sign-in/email-otp` exchanges the typed code for +//! a Better Auth session. Body: `{ email, otp }`. Returns 200 with +//! `{ token, user, … }` and a `Set-Cookie: better-auth.session_token` +//! header. We accept either delivery channel — the JSON body's +//! `token` field wins, with the cookie as the canonical fallback. + +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use ureq::{Agent, AgentBuilder}; + +use crate::{auth::BearerToken, client::ApiClientConfig, error::SyncClientError}; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +const USER_AGENT: &str = concat!("ELY Browser/", env!("CARGO_PKG_VERSION")); + +#[derive(Serialize)] +struct SendOtpRequest<'a> { + email: &'a str, + #[serde(rename = "type")] + purpose: &'static str, +} + +#[derive(Serialize)] +struct VerifyOtpRequest<'a> { + email: &'a str, + otp: &'a str, +} + +#[derive(Deserialize)] +struct VerifyOtpResponse { + #[serde(default)] + token: Option, +} + +/// Ask the worker to email a one-time code to `email`. The actual +/// delivery happens through the Cloudflare `SEND_EMAIL` binding +/// (sender `browser@elydora.com`). Returns when the worker has +/// accepted the request — it does not wait for the recipient's MTA. +pub fn send_email_otp(config: &ApiClientConfig, email: &str) -> Result<(), SyncClientError> { + let endpoint = format!( + "{}/api/auth/email-otp/send-verification-otp", + config.base_url().trim_end_matches('/') + ); + let agent = build_agent(); + let response = agent + .post(&endpoint) + .set("Content-Type", "application/json") + .send_json(serde_json::to_value(SendOtpRequest { email, purpose: "sign-in" }).map_err( + |error| SyncClientError::Json { endpoint: endpoint.clone(), source: error }, + )?); + match response { + Ok(_) => Ok(()), + Err(ureq::Error::Status(status, raw)) => { + let body = raw.into_string().unwrap_or_default(); + Err(SyncClientError::HttpStatus { endpoint, status, body }) + } + Err(other) => Err(SyncClientError::Http { endpoint, source: Box::new(other) }), + } +} + +/// Verify the user's OTP and return the freshly-issued Better Auth +/// session token wrapped as a [`BearerToken`]. +pub fn verify_email_otp( + config: &ApiClientConfig, + email: &str, + otp: &str, +) -> Result { + let endpoint = + format!("{}/api/auth/sign-in/email-otp", config.base_url().trim_end_matches('/')); + let agent = build_agent(); + let response = agent + .post(&endpoint) + .set("Content-Type", "application/json") + .send_json(serde_json::to_value(VerifyOtpRequest { email, otp }).map_err(|error| { + SyncClientError::Json { endpoint: endpoint.clone(), source: error } + })?); + match response { + Ok(ok) => { + let cookie_token = better_auth_cookie_token(ok.header("set-cookie")); + let body = ok.into_string().map_err(|error| SyncClientError::HttpStatus { + endpoint: endpoint.clone(), + status: 200, + body: error.to_string(), + })?; + let json = serde_json::from_str::(&body).map_err(|error| { + SyncClientError::Json { endpoint: endpoint.clone(), source: error } + })?; + let token = json.token.or(cookie_token).ok_or_else(|| { + SyncClientError::TokenStorage( + "sign-in response did not include a session token".to_string(), + ) + })?; + BearerToken::new(token) + } + Err(ureq::Error::Status(status, raw)) => { + let body = raw.into_string().unwrap_or_default(); + Err(SyncClientError::HttpStatus { endpoint, status, body }) + } + Err(other) => Err(SyncClientError::Http { endpoint, source: Box::new(other) }), + } +} + +fn build_agent() -> Agent { + AgentBuilder::new().timeout(REQUEST_TIMEOUT).user_agent(USER_AGENT).build() +} + +/// Better Auth ships the session through a `Set-Cookie: +/// better-auth.session_token=; …` header. Strip the cookie's +/// attributes and return just the value. Multi-cookie responses are +/// concatenated by `ureq` into a single header line per spec. +fn better_auth_cookie_token(set_cookie: Option<&str>) -> Option { + let header = set_cookie?; + for cookie in header.split(',') { + let trimmed = cookie.trim(); + if let Some(rest) = trimmed.strip_prefix("better-auth.session_token=") { + let token = rest.split(';').next()?.trim(); + if !token.is_empty() { + return Some(token.to_string()); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::better_auth_cookie_token; + + #[test] + fn picks_session_token_out_of_set_cookie_header() { + let header = + "better-auth.session_token=abc.def.ghi; Path=/; HttpOnly; Secure; SameSite=Lax"; + assert_eq!(better_auth_cookie_token(Some(header)), Some("abc.def.ghi".to_string()),); + } + + #[test] + fn ignores_unrelated_cookies() { + let header = "csrf=value; Path=/, other=cookie"; + assert_eq!(better_auth_cookie_token(Some(header)), None); + } + + #[test] + fn returns_none_when_header_is_absent() { + assert_eq!(better_auth_cookie_token(None), None); + } +} diff --git a/crates/ely_sync_client/src/lib.rs b/crates/ely_sync_client/src/lib.rs index d193278..0f5bad4 100644 --- a/crates/ely_sync_client/src/lib.rs +++ b/crates/ely_sync_client/src/lib.rs @@ -27,11 +27,13 @@ pub mod auth; pub mod client; pub mod device; +pub mod email_otp; pub mod error; pub mod snapshot; pub use auth::{BearerToken, BearerTokenStore}; pub use client::{ApiClientConfig, SyncApiClient}; pub use device::{DeviceIdentity, DeviceListResponse, DeviceRecord, DeviceRegistration}; +pub use email_otp::{send_email_otp, verify_email_otp}; pub use error::SyncClientError; pub use snapshot::{SnapshotDownload, SnapshotPayload, SnapshotUploadRequest};