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
+234 -4
View File
@@ -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<ElyShell>) -> AnyElement {
fn render_left_column(
shell: &mut ElyShell,
snapshot: &BrowserSnapshot,
cx: &mut Context<ElyShell>,
) -> AnyElement {
div()
.flex()
.flex_col()
@@ -43,6 +49,7 @@ fn render_left_column(snapshot: &BrowserSnapshot, cx: &mut Context<ElyShell>) ->
.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<ElyShell>) -
.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 {
div()
.flex()
@@ -151,7 +381,7 @@ fn render_reset_button(cx: &mut Context<ElyShell>) -> 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(