Add email + OTP sign-in flow for cloud sync

Replace the "drop a session token in a file" workflow with a real
Chrome-style email login. The Cloudflare worker already had Better
Auth's `email-otp` plugin wired into `SEND_EMAIL`; this commit
builds the renderer-side counterpart.

Worker side:
- Move the OTP sender from `auth@elydora.com` to `browser@elydora.com`
  (wrangler.toml `allowed_sender_addresses` + better_auth.ts
  `EMAIL_OTP_FROM_ADDRESS`). Worker must be redeployed to pick this up.

Client side (`ely_sync_client::email_otp`):
- `send_email_otp(config, email)` POSTs `/api/auth/email-otp/send-verification-otp`
  with `{ email, type: "sign-in" }`.
- `verify_email_otp(config, email, otp)` POSTs `/api/auth/sign-in/email-otp`,
  reads the Better Auth session token from the JSON body's `token` field
  with the `Set-Cookie: better-auth.session_token=…` header as the
  documented fallback channel, and returns it as a `BearerToken`.

Shell side (`shell/auth.rs` + `shell/internal_pages/sync.rs`):
- New `AuthFlowPhase` (Idle / SendingCode / AwaitingOtp / Verifying /
  Error) tracks the in-flight form. Two off-thread workers run the
  HTTP exchanges so the GPUI render loop never blocks.
- Successful verify saves the bearer via `SyncEngine::install_bearer`
  and triggers an immediate snapshot upload, so the user is signed in
  + initial-synced in one click.
- Sync settings page replaces the bare "Sync now" button row with an
  account card: when SignedOut → email field + Send code → OTP field
  + Verify / Resend; when signed in → an account chip + Sign out.
- `trigger_cloud_sync_upload` no longer takes a `Context` param so
  the post-auth path can fire it from the inbox-drain pass without
  needing a window context.
This commit is contained in:
2026-05-15 21:32:50 -04:00
parent 80cff6dad3
commit 74b3de54ed
10 changed files with 734 additions and 38 deletions
Generated
+1
View File
@@ -2250,6 +2250,7 @@ dependencies = [
"ely_browser_core", "ely_browser_core",
"ely_design_system", "ely_design_system",
"ely_domain", "ely_domain",
"ely_sync_client",
"gpui", "gpui",
"gpui-component", "gpui-component",
"gpui-component-assets", "gpui-component-assets",
+1 -1
View File
@@ -7,7 +7,7 @@ import { jsonResponse } from "./responses.js";
const APP_NAME = "ELY Browser"; const APP_NAME = "ELY Browser";
const AUTH_BASE_PATH = "/api/auth"; const AUTH_BASE_PATH = "/api/auth";
const AUTH_CALLBACK_URL = "ely://auth/callback"; 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; const EMAIL_OTP_EXPIRES_IN_SECONDS = 300;
type BetterAuthDatabase = NonNullable<BetterAuthOptions["database"]>; type BetterAuthDatabase = NonNullable<BetterAuthOptions["database"]>;
+1 -1
View File
@@ -19,7 +19,7 @@ id = "ba8f06dd5cc34bc4bc0f8a305c570cfb"
[[send_email]] [[send_email]]
name = "SEND_EMAIL" name = "SEND_EMAIL"
allowed_sender_addresses = ["auth@elydora.com"] allowed_sender_addresses = ["browser@elydora.com"]
[vars] [vars]
ELY_ENVIRONMENT = "production" ELY_ENVIRONMENT = "production"
+1
View File
@@ -15,6 +15,7 @@ ed25519-dalek.workspace = true
ely_browser_core = { path = "../ely_browser_core" } ely_browser_core = { path = "../ely_browser_core" }
ely_design_system = { path = "../ely_design_system" } ely_design_system = { path = "../ely_design_system" }
ely_domain = { path = "../ely_domain" } ely_domain = { path = "../ely_domain" }
ely_sync_client = { path = "../ely_sync_client" }
gpui.workspace = true gpui.workspace = true
gpui-component.workspace = true gpui-component.workspace = true
gpui-component-assets.workspace = true gpui-component-assets.workspace = true
+267
View File
@@ -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<Self>) {
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<Self>) {
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>) {
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<Self>) -> String {
self.auth_email_input.read(cx).value().to_string()
}
fn read_auth_otp_input(&self, cx: &Context<Self>) -> String {
self.auth_otp_input.read(cx).value().to_string()
}
}
fn normalize_email(raw: &str) -> Option<String> {
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<ely_domain::ProfileId> {
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<SyncStateUpdate>) {
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<SyncStateUpdate>,
) {
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());
}
}
+234 -4
View File
@@ -7,7 +7,9 @@ use gpui::{
AnyElement, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, SharedString, AnyElement, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, SharedString,
StatefulInteractiveElement, Styled, div, prelude::FluentBuilder, px, rgb, rgba, 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; use crate::brand::SYNC_SERVICE_NAME;
@@ -27,14 +29,18 @@ impl ElyShell {
.grid() .grid()
.grid_cols(2) .grid_cols(2)
.gap(px(32.0)) .gap(px(32.0))
.child(render_left_column(snapshot, cx)) .child(render_left_column(self, snapshot, cx))
.child(render_right_column(snapshot, cx)), .child(render_right_column(snapshot, cx)),
), ),
) )
} }
} }
fn render_left_column(snapshot: &BrowserSnapshot, cx: &mut Context<ElyShell>) -> AnyElement { fn render_left_column(
shell: &mut ElyShell,
snapshot: &BrowserSnapshot,
cx: &mut Context<ElyShell>,
) -> AnyElement {
div() div()
.flex() .flex()
.flex_col() .flex_col()
@@ -43,6 +49,7 @@ fn render_left_column(snapshot: &BrowserSnapshot, cx: &mut Context<ElyShell>) ->
.child(render_status_pill(snapshot)) .child(render_status_pill(snapshot))
.child(render_serif_headline()) .child(render_serif_headline())
.child(render_intro_paragraph()) .child(render_intro_paragraph())
.child(render_account_card(shell, snapshot, cx))
.child(render_metrics_card(snapshot, cx)) .child(render_metrics_card(snapshot, cx))
.into_any_element() .into_any_element()
} }
@@ -118,6 +125,229 @@ fn render_metrics_card(snapshot: &BrowserSnapshot, cx: &mut Context<ElyShell>) -
.into_any_element() .into_any_element()
} }
fn render_account_card(
shell: &ElyShell,
snapshot: &BrowserSnapshot,
cx: &mut Context<ElyShell>,
) -> 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<ElyShell>) -> Vec<AnyElement> {
let mut elements: Vec<AnyElement> = 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<gpui_component::input::InputState>,
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<F>(
id: &'static str,
label: &'static str,
disabled: bool,
cx: &mut Context<ElyShell>,
handler: F,
) -> AnyElement
where
F: Fn(&mut ElyShell, &mut Context<ElyShell>) + '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<P, S>(
disabled: bool,
cx: &mut Context<ElyShell>,
primary: P,
secondary: S,
) -> AnyElement
where
P: Fn(&mut ElyShell, &mut Context<ElyShell>) + 'static,
S: Fn(&mut ElyShell, &mut Context<ElyShell>) + '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<ElyShell>) -> 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 { fn render_metric(label: &'static str, value: usize, color: u32) -> AnyElement {
div() div()
.flex() .flex()
@@ -151,7 +381,7 @@ fn render_reset_button(cx: &mut Context<ElyShell>) -> AnyElement {
.cursor_pointer() .cursor_pointer()
.hover(|style| style.opacity(0.92)) .hover(|style| style.opacity(0.92))
.active(|style| style.opacity(0.78)) .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("Sync now"),
) )
.child( .child(
+66 -18
View File
@@ -1,4 +1,5 @@
mod archive_labels; mod archive_labels;
mod auth;
mod bookmark_files; mod bookmark_files;
mod bookmarks; mod bookmarks;
pub(crate) mod chrome; pub(crate) mod chrome;
@@ -105,21 +106,41 @@ pub struct ElyShell {
/// `tick_external_web_surfaces`. /// `tick_external_web_surfaces`.
sync_inbox_rx: std::sync::mpsc::Receiver<SyncStateUpdate>, sync_inbox_rx: std::sync::mpsc::Receiver<SyncStateUpdate>,
pub(crate) sync_inbox_tx: std::sync::mpsc::Sender<SyncStateUpdate>, pub(crate) sync_inbox_tx: std::sync::mpsc::Sender<SyncStateUpdate>,
pub(crate) auth_email_input: Entity<InputState>,
pub(crate) auth_otp_input: Entity<InputState>,
pub(crate) auth_flow_phase: auth::AuthFlowPhase,
_command_subscription: Subscription, _command_subscription: Subscription,
_translucency_subscription: Subscription, _translucency_subscription: Subscription,
} }
/// Messages the off-thread sync worker pushes back to the shell so the /// Messages the off-thread sync workers push back to the shell so
/// `SyncConnectionState` on `BrowserCore` reflects the live engine /// `SyncConnectionState` on `BrowserCore` and the in-flight auth
/// without the UI thread ever touching the network. `SignedIn` is the /// form reflect live state without the UI thread ever touching the
/// initial-probe state set synchronously on shell startup and does not /// network. `SignedIn` is the initial-probe state set synchronously
/// flow through this channel. /// on shell startup and does not flow through this channel.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) enum SyncStateUpdate { pub(crate) enum SyncStateUpdate {
SignedOut, SignedOut,
AwaitingDeviceApproval, AwaitingDeviceApproval,
SyncReady { last_synced_at_secs: u64 }, SyncReady { last_synced_at_secs: u64 },
SyncError { message: String }, 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 { impl ElyShell {
@@ -140,6 +161,9 @@ impl ElyShell {
cx.new(|cx| InputState::new(window, cx).placeholder("Search ELY or type a command…")); cx.new(|cx| InputState::new(window, cx).placeholder("Search ELY or type a command…"));
let plugin_search_input = let plugin_search_input =
cx.new(|cx| InputState::new(window, cx).placeholder("Search plugins…")); 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| { let translucency_slider = cx.new(|_cx| {
SliderState::new() SliderState::new()
.min(0.0) .min(0.0)
@@ -237,6 +261,9 @@ impl ElyShell {
web_surfaces: WebSurfaceStore::new(), web_surfaces: WebSurfaceStore::new(),
sync_inbox_rx, sync_inbox_rx,
sync_inbox_tx, sync_inbox_tx,
auth_email_input,
auth_otp_input,
auth_flow_phase: auth::AuthFlowPhase::Idle,
_command_subscription: command_subscription, _command_subscription: command_subscription,
_translucency_subscription: translucency_subscription, _translucency_subscription: translucency_subscription,
}; };
@@ -280,29 +307,50 @@ impl ElyShell {
/// state on `BrowserCore`. Returns `true` when at least one /// state on `BrowserCore`. Returns `true` when at least one
/// update was applied so callers can `cx.notify()` accordingly. /// update was applied so callers can `cx.notify()` accordingly.
pub(super) fn drain_sync_updates(&mut self) -> bool { pub(super) fn drain_sync_updates(&mut self) -> bool {
let ShellState::Ready(core) = &mut self.state else { let mut latest_connection: Option<ely_domain::SyncConnectionState> = None;
return false; let mut auth_changed = false;
}; let mut trigger_initial_sync = false;
let mut latest: Option<ely_domain::SyncConnectionState> = None;
while let Ok(update) = self.sync_inbox_rx.try_recv() { while let Ok(update) = self.sync_inbox_rx.try_recv() {
latest = Some(match update { match update {
SyncStateUpdate::SignedOut => ely_domain::SyncConnectionState::SignedOut, SyncStateUpdate::SignedOut => {
latest_connection = Some(ely_domain::SyncConnectionState::SignedOut);
}
SyncStateUpdate::AwaitingDeviceApproval => { SyncStateUpdate::AwaitingDeviceApproval => {
ely_domain::SyncConnectionState::AwaitingDeviceApproval latest_connection =
Some(ely_domain::SyncConnectionState::AwaitingDeviceApproval);
} }
SyncStateUpdate::SyncReady { last_synced_at_secs } => { 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 } => { 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); 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<Self>) { fn focus_command_mode(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+6 -14
View File
@@ -246,7 +246,7 @@ impl ElyShell {
/// (the UI thread never blocks on the network), and the worker /// (the UI thread never blocks on the network), and the worker
/// reports back through the shell's `sync_inbox` so the sync page /// reports back through the shell's `sync_inbox` so the sync page
/// reflects the new state without waiting for a manual refresh. /// reflects the new state without waiting for a manual refresh.
pub(super) fn trigger_cloud_sync_upload(&mut self, _cx: &mut Context<Self>) { pub(crate) fn trigger_cloud_sync_upload(&mut self) {
let ShellState::Ready(core) = &self.state else { let ShellState::Ready(core) = &self.state else {
return; return;
}; };
@@ -318,7 +318,11 @@ fn run_sync_upload(
bytes: Vec<u8>, bytes: Vec<u8>,
inbox: std::sync::mpsc::Sender<super::SyncStateUpdate>, inbox: std::sync::mpsc::Sender<super::SyncStateUpdate>,
) { ) {
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, Ok(engine) => engine,
Err(error) => { Err(error) => {
let message = error.to_string(); 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"
}
}
+155
View File
@@ -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<String>,
}
/// 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<BearerToken, SyncClientError> {
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::<VerifyOtpResponse>(&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=<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<String> {
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);
}
}
+2
View File
@@ -27,11 +27,13 @@
pub mod auth; pub mod auth;
pub mod client; pub mod client;
pub mod device; pub mod device;
pub mod email_otp;
pub mod error; pub mod error;
pub mod snapshot; pub mod snapshot;
pub use auth::{BearerToken, BearerTokenStore}; pub use auth::{BearerToken, BearerTokenStore};
pub use client::{ApiClientConfig, SyncApiClient}; pub use client::{ApiClientConfig, SyncApiClient};
pub use device::{DeviceIdentity, DeviceListResponse, DeviceRecord, DeviceRegistration}; pub use device::{DeviceIdentity, DeviceListResponse, DeviceRecord, DeviceRegistration};
pub use email_otp::{send_email_otp, verify_email_otp};
pub use error::SyncClientError; pub use error::SyncClientError;
pub use snapshot::{SnapshotDownload, SnapshotPayload, SnapshotUploadRequest}; pub use snapshot::{SnapshotDownload, SnapshotPayload, SnapshotUploadRequest};