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:
Generated
+1
@@ -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",
|
||||||
|
|||||||
@@ -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"]>;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
if let Some(state) = latest {
|
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), 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>) {
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
|||||||
Reference in New Issue
Block a user