From c9e3400a71f6138af92dc95409f86aa46396ffdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 13:12:49 -0400 Subject: [PATCH] =?UTF-8?q?Account=20=C2=B7=20Sign=20in:=20log=20in,=20sig?= =?UTF-8?q?n=20up,=20providers,=20a=20link=20by=20email,=20a=20second=20fa?= =?UTF-8?q?ctor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LoginForm takes an email and a password, keeps one signed in on a box, and signs in on Enter once the address reads and a password is typed; a failed try says why above Sign in. SignupForm ticks a new password's rules as they hold and waits for all of them and the terms. OAuthButtons offer each provider, one spinning while the rest rest. MagicLinkForm and ForgotPassword send a link to an address and, once sent, say where it went and offer it again. TwoFactorInput takes a six-digit code at once and, after one fails, clears the boxes through forms::PinInput's new attempt, keeping its focus. The Account chapter splits into sign-in, profile and access, and billing and team. --- TASKS.md | 4 +- examples/gallery/pages/account/mod.rs | 46 ++++ examples/gallery/pages/account/sign_in.rs | 177 +++++++++++++ examples/gallery/pages/mod.rs | 2 + src/account/factor.rs | 122 +++++++++ src/account/login.rs | 194 +++++++++++++++ src/account/mail.rs | 288 ++++++++++++++++++++++ src/account/mod.rs | 14 ++ src/account/oauth.rs | 88 +++++++ src/account/signup.rs | 211 ++++++++++++++++ src/account/tests/mod.rs | 85 +++++++ src/account/tests/sign_in.rs | 192 +++++++++++++++ src/forms/pin.rs | 21 +- src/lib.rs | 1 + tasks/ch31-43.md | 20 +- 15 files changed, 1455 insertions(+), 10 deletions(-) create mode 100644 examples/gallery/pages/account/mod.rs create mode 100644 examples/gallery/pages/account/sign_in.rs create mode 100644 src/account/factor.rs create mode 100644 src/account/login.rs create mode 100644 src/account/mail.rs create mode 100644 src/account/mod.rs create mode 100644 src/account/oauth.rs create mode 100644 src/account/signup.rs create mode 100644 src/account/tests/mod.rs create mode 100644 src/account/tests/sign_in.rs diff --git a/TASKS.md b/TASKS.md index aa0964a..26aa60e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -125,7 +125,9 @@ Per-component lines live in `tasks/`. Tags there: - [x] T33d DB & Dev Tools · Tools and machines — `tasks/ch31-43.md` - [x] T34 Dashboard — `tasks/ch31-43.md` (its two maps lines wait for T41) - [x] T35 Settings — `tasks/ch31-43.md` -- [ ] T36 Account +- [ ] T36a Account · Sign in +- [ ] T36b Account · Profile and access +- [ ] T36c Account · Billing and team - [ ] T37 Onboarding & Help - [ ] T38 Interaction - [ ] T39 Theme diff --git a/examples/gallery/pages/account/mod.rs b/examples/gallery/pages/account/mod.rs new file mode 100644 index 0000000..1f0bff6 --- /dev/null +++ b/examples/gallery/pages/account/mod.rs @@ -0,0 +1,46 @@ +use gpui::{AnyElement, App, Window, div, prelude::*}; + +mod sign_in; + +use super::Page; +use crate::script::Step; + +pub const PAGE: Page = Page { + number: 36, + slug: "account", + title: "Account", + summary: "Signing in and up, a link by email, a second factor, and the account once inside.", + render, + script: SCRIPT, +}; + +const SCRIPT: &[Step] = &[ + Step::DownAt("account-login", 150.0, 280.0), + Step::UpAt("account-login", 150.0, 280.0), + Step::Type("ada@example.com"), + Step::Key("tab"), + Step::Type("hunter22"), + Step::Key("enter"), + Step::Wait(200), + Step::Shot("sign-in-failed"), + Step::DownAt("account-magic", 150.0, 150.0), + Step::UpAt("account-magic", 150.0, 150.0), + Step::Type("ada@example.com"), + Step::Key("enter"), + Step::Wait(200), + Step::Shot("link-sent"), + Step::DownAt("account-factor", 120.0, 110.0), + Step::UpAt("account-factor", 120.0, 110.0), + Step::Type("123456"), + Step::Wait(200), + Step::Shot("code-failed"), +]; + +fn render(window: &mut Window, cx: &mut App) -> AnyElement { + div() + .child(sign_in::sign_in(window, cx)) + .child(sign_in::sign_up(window, cx)) + .child(sign_in::by_email(window, cx)) + .child(sign_in::second_factor(window, cx)) + .into_any_element() +} diff --git a/examples/gallery/pages/account/sign_in.rs b/examples/gallery/pages/account/sign_in.rs new file mode 100644 index 0000000..655bf3d --- /dev/null +++ b/examples/gallery/pages/account/sign_in.rs @@ -0,0 +1,177 @@ +use ely_gpui_component::{ + account::{ForgotPassword, LoginForm, MagicLinkForm, OAuthButtons, SignupForm, TwoFactorInput}, + feedback::InlineMessage, + layout::{Card, CardHeader}, + primitives::{Divider, Severity}, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use crate::{ + probe::probe, + ui::{change, keep, section}, +}; + +/// The sign-in demo: the provider on its way, and why the last try failed. +#[derive(Default)] +struct SignIn { + provider: Option, + error: Option, +} + +pub fn sign_in(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = keep("account-sign-in", SignIn::default, window, cx); + let now = state.read(cx); + let (provider, error) = (now.provider.clone(), now.error.clone()); + let [picked, tried] = [(); 2].map(|_| state.clone()); + let mut providers = OAuthButtons::new( + "account-oauth", + [ + ("github", "GitHub"), + ("google", "Google"), + ("apple", "Apple"), + ], + ) + .on_pick(move |key, _, cx| change(&picked, cx, |state| state.provider = Some(key.clone()))); + if let Some(provider) = provider { + providers = providers.busy(provider); + } + let mut form = LoginForm::new("account-login") + .on_submit(move |_, _, cx| { + change(&tried, cx, |state| { + state.error = Some("No account uses that email and password.".into()) + }) + }) + .on_forgot(|_, _| log::info!("gallery: forgot password")) + .on_sign_up(|_, _| log::info!("gallery: sign up")); + if let Some(error) = error { + form = form.error(error); + } + section( + "LoginForm · OAuthButtons", + "Sign in with a provider or with an email and a password. Sign in rests until the address reads and a password is typed; Enter in a field signs in, and a failed try says why above the button.", + cx, + ) + .child(probe( + "account-login", + div().w(px(380.)).child( + Card::new() + .header(CardHeader::new("Sign in to Ely").description("Welcome back.")) + .child(div().flex().flex_col().gap_5().child(providers).child(Divider::horizontal().label("or")).child(form)), + ), + )) +} + +/// The sign-up demo: why the last try failed. +#[derive(Default)] +struct SignUp { + error: Option, +} + +pub fn sign_up(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = keep("account-sign-up", SignUp::default, window, cx); + let error = state.read(cx).error.clone(); + let tried = state.clone(); + let mut form = SignupForm::new("account-signup") + .terms("I agree to the terms of service") + .on_submit(move |signup, _, cx| { + let taken = format!("An account already uses {}.", signup.email); + change(&tried, cx, |state| state.error = Some(taken.into())) + }) + .on_sign_in(|_, _| log::info!("gallery: sign in")); + if let Some(error) = error { + form = form.error(error); + } + section( + "SignupForm", + "A name, an email and a new password, whose rules tick as they hold. Create account waits for every rule and the terms.", + cx, + ) + .child(div().w(px(380.)).child(Card::new().header(CardHeader::new("Create an account")).child(form))) +} + +/// The email demos: whether each link went. +#[derive(Default)] +struct ByEmail { + magic: bool, + reset: bool, +} + +pub fn by_email(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = keep("account-by-email", ByEmail::default, window, cx); + let now = state.read(cx); + let (magic, reset) = (now.magic, now.reset); + let [sent, other, asked, back] = [(); 4].map(|_| state.clone()); + section( + "MagicLinkForm · ForgotPassword", + "A link by email: one that signs in, one that sets a new password. Once sent, each says where it went and offers it again.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .gap_6() + .child(probe( + "account-magic", + div().w(px(360.)).child( + Card::new().header(CardHeader::new("Sign in by email")).child( + MagicLinkForm::new("account-magic") + .sent(magic) + .on_send(move |_, _, cx| change(&sent, cx, |state| state.magic = true)) + .on_other(move |_, cx| change(&other, cx, |state| state.magic = false)), + ), + ), + )) + .child( + div().w(px(360.)).child( + Card::new().header(CardHeader::new("Reset your password")).child( + ForgotPassword::new("account-forgot") + .sent(reset) + .on_send(move |_, _, cx| change(&asked, cx, |state| state.reset = true)) + .on_back(move |_, cx| change(&back, cx, |state| state.reset = false)), + ), + ), + ), + ) +} + +/// The code the demo takes. +const CODE: &str = "246810"; + +/// The second-factor demo: codes that failed, and whether one passed. +#[derive(Default)] +struct Factor { + failed: usize, + passed: bool, +} + +pub fn second_factor(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = keep("account-factor", Factor::default, window, cx); + let now = state.read(cx); + let (failed, passed) = (now.failed, now.passed); + let checked = state.clone(); + section( + "TwoFactorInput", + format!("Six boxes for an authenticator's code; a full code goes at once. A code that fails clears the boxes and says so. The demo takes {CODE}."), + cx, + ) + .child(probe( + "account-factor", + div().w(px(380.)).child( + Card::new() + .header(CardHeader::new("Two-step sign-in")) + .child( + TwoFactorInput::new("account-factor") + .failed(failed) + .on_code(move |code, _, cx| { + change(&checked, cx, |state| match code.as_ref() == CODE { + true => state.passed = true, + false => state.failed += 1, + }) + }) + .on_recovery(|_, _| log::info!("gallery: recovery code")), + ) + .children(passed.then(|| InlineMessage::new(Severity::Success, "Signed in."))), + ), + )) +} diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index ac75974..484bef6 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -1,3 +1,4 @@ +mod account; mod agent; mod buttons; mod calendar; @@ -85,6 +86,7 @@ pub const ALL: &[Page] = &[ devtools::PAGE, dashboard::PAGE, settings::PAGE, + account::PAGE, theme::PAGE, ]; diff --git a/src/account/factor.rs b/src/account/factor.rs new file mode 100644 index 0000000..5d8e69c --- /dev/null +++ b/src/account/factor.rs @@ -0,0 +1,122 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, +}; + +use super::login::Run; +use crate::{ + feedback::InlineMessage, + forms::PinInput, + primitives::Severity, + theme::{ActiveTheme, TextSize}, + typography::Link, +}; + +type OnText = Rc; + +/// Digits in a code from an authenticator app. +const DIGITS: usize = 6; + +/// The code from an authenticator app, in six boxes. A full code goes to the owner at once; after one fails the boxes clear for the next, and a recovery code can stand in. +#[derive(IntoElement)] +pub struct TwoFactorInput { + id: ElementId, + failed: usize, + busy: bool, + on_code: Option, + on_recovery: Option, +} + +impl TwoFactorInput { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + failed: 0, + busy: false, + on_code: None, + on_recovery: None, + } + } + + /// How many codes have failed; each new failure clears the boxes and says so. + pub fn failed(mut self, failed: usize) -> Self { + self.failed = failed; + self + } + + /// While the owner checks a code. + pub fn busy(mut self, busy: bool) -> Self { + self.busy = busy; + self + } + + pub fn on_code( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_code = Some(Rc::new(handler)); + self + } + + /// Shows "Use a recovery code". + pub fn on_recovery(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_recovery = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for TwoFactorInput { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let on_code = self + .on_code + .unwrap_or_else(|| panic!("two-factor input {id:?} has no on_code")); + let theme = cx.theme(); + let note = match (self.busy, self.failed) { + (true, _) => Some( + div() + .text_color(theme.colors.fg_muted) + .child("Checking the code…") + .into_any_element(), + ), + (false, 0) => None, + (false, _) => Some( + InlineMessage::new( + Severity::Danger, + "That code did not work. Try the one showing now.", + ) + .into_any_element(), + ), + }; + div() + .flex() + .flex_col() + .gap_3() + .text_size(theme.text_size(TextSize::Sm)) + .child( + div().flex().child( + div() + .flex_1() + .min_w_0() + .text_color(theme.colors.fg_muted) + .child("Enter the six-digit code from your authenticator app."), + ), + ) + .child( + PinInput::new((id.clone(), "code"), DIGITS) + .attempt(self.failed) + .on_complete(move |code, window, cx| { + log::info!("two-factor input: code entered"); + on_code(&SharedString::from(code.to_string()), window, cx); + }), + ) + .children(note) + .children(self.on_recovery.map(|run| { + div().child( + Link::new((id.clone(), "recovery"), "Use a recovery code") + .on_click(move |_, window, cx| run(window, cx)), + ) + })) + } +} diff --git a/src/account/login.rs b/src/account/login.rs new file mode 100644 index 0000000..d54d312 --- /dev/null +++ b/src/account/login.rs @@ -0,0 +1,194 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce, + SharedString, Styled, Window, div, +}; + +use crate::{ + buttons::{Button, ButtonVariant}, + feedback::InlineMessage, + forms::{Checkbox, EmailInput, Enter, FormField, PasswordInput, TextInput, is_email}, + primitives::Severity, + theme::{ActiveTheme, TextSize}, + typography::Link, +}; + +pub(super) type Run = Rc; + +/// A form's text field, kept under the form's id; `masked` hides what is typed. +pub(super) fn field( + id: &ElementId, + key: &'static str, + hint: &'static str, + masked: bool, + window: &mut Window, + cx: &mut App, +) -> Entity { + window.use_keyed_state((id.clone(), key), cx, move |window, cx| { + let field = TextInput::new(window, cx).placeholder(hint); + if masked { field.masked() } else { field } + }) +} + +/// Muted words and a link on one line, as under a form: "No account? Sign up". +pub(super) fn aside( + id: impl Into, + words: &'static str, + link: &'static str, + run: Run, + cx: &App, +) -> impl IntoElement { + let theme = cx.theme(); + div() + .flex() + .flex_wrap() + .justify_center() + .gap_1() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(words) + .child(Link::new(id, link).on_click(move |_, window, cx| run(window, cx))) +} + +/// What a sign-in hands its owner. +#[derive(Clone, Debug, PartialEq)] +pub struct Login { + pub email: SharedString, + pub password: SharedString, + pub remember: bool, +} + +type OnLogin = Rc; + +/// An email and a password, a box to stay signed in, and ways to a forgotten password or a new account. Sign in rests until the address reads and a password is typed; Enter in a field signs in. +#[derive(IntoElement)] +pub struct LoginForm { + id: ElementId, + error: Option, + busy: bool, + on_submit: Option, + on_forgot: Option, + on_sign_up: Option, +} + +impl LoginForm { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + error: None, + busy: false, + on_submit: None, + on_forgot: None, + on_sign_up: None, + } + } + + /// Why the last try failed, shown above Sign in. + pub fn error(mut self, error: impl Into) -> Self { + self.error = Some(error.into()); + self + } + + /// While the owner checks: Sign in spins and rests. + pub fn busy(mut self, busy: bool) -> Self { + self.busy = busy; + self + } + + pub fn on_submit(mut self, handler: impl Fn(&Login, &mut Window, &mut App) + 'static) -> Self { + self.on_submit = Some(Rc::new(handler)); + self + } + + /// Shows "Forgot password?". + pub fn on_forgot(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_forgot = Some(Rc::new(handler)); + self + } + + /// Shows "No account? Sign up". + pub fn on_sign_up(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_sign_up = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for LoginForm { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let email = field(&id, "email", "name@example.com", false, window, cx); + let password = field(&id, "password", "Password", true, window, cx); + let remember = window.use_keyed_state((id.clone(), "remember"), cx, |_, _| false); + let login = Login { + email: email.read(cx).text().trim().to_string().into(), + password: password.read(cx).text().to_string().into(), + remember: *remember.read(cx), + }; + let remembered = login.remember; + let ready = is_email(&login.email) && !login.password.is_empty() && !self.busy; + let on_submit = self + .on_submit + .unwrap_or_else(|| panic!("login form {id:?} has no on_submit")); + let submit: Run = Rc::new(move |window, cx| { + log::info!("login form: sign in"); + on_submit(&login, window, cx); + }); + let enter = submit.clone(); + div() + .flex() + .flex_col() + .gap_4() + .capture_action(move |_: &Enter, window, cx| { + if ready { + cx.stop_propagation(); + enter(window, cx); + } + }) + .child( + FormField::new((id.clone(), "email-field"), "Email").child(EmailInput::new(&email)), + ) + .child( + FormField::new((id.clone(), "password-field"), "Password") + .child(PasswordInput::new(&password)), + ) + .child( + div() + .flex() + .flex_wrap() + .items_center() + .justify_between() + .gap_2() + .child( + Checkbox::new((id.clone(), "remember"), remembered) + .label("Keep me signed in") + .on_change(move |on, _, cx| { + remember.update(cx, |remember, cx| { + *remember = on; + cx.notify(); + }) + }), + ) + .children(self.on_forgot.map(|run| { + Link::new((id.clone(), "forgot"), "Forgot password?") + .on_click(move |_, window, cx| run(window, cx)) + })), + ) + .children( + self.error + .map(|error| InlineMessage::new(Severity::Danger, error)), + ) + .child( + Button::new((id.clone(), "submit"), "Sign in") + .variant(ButtonVariant::Primary) + .full_width() + .loading(self.busy) + .disabled(!ready) + .on_click(move |_, window, cx| submit(window, cx)), + ) + .children( + self.on_sign_up + .map(|run| aside((id.clone(), "sign-up"), "No account?", "Sign up", run, cx)), + ) + } +} diff --git a/src/account/mail.rs b/src/account/mail.rs new file mode 100644 index 0000000..6b9be6b --- /dev/null +++ b/src/account/mail.rs @@ -0,0 +1,288 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, + RenderOnce, SharedString, Styled, Window, div, +}; + +use super::login::{Run, field}; +use crate::{ + buttons::{Button, ButtonVariant}, + forms::{EmailInput, Enter, FormField, is_email}, + primitives::{Icon, IconName}, + theme::{ActiveTheme, IconSize, TextSize}, + typography::Link, +}; + +type OnText = Rc; + +/// The words that set one email step apart from another. +struct Words { + intro: &'static str, + send: &'static str, + sent: &'static str, + note: fn(&str) -> String, +} + +/// A way out: its label and what it runs. +type Way = Option<(&'static str, Run)>; + +/// One email step: its words, where it stands, and its ways out. `back` shows in both steps, `other` once sent. +struct Step { + id: ElementId, + words: Words, + sent: bool, + busy: bool, + on_send: Option, + back: Way, + other: Way, +} + +/// An address to send a link to, then where the link went with a way to send it again. +fn email_step(step: Step, window: &mut Window, cx: &mut App) -> AnyElement { + let Step { + id, + words, + sent, + busy, + on_send, + back, + other, + } = step; + let email = field(&id, "email", "name@example.com", false, window, cx); + let address: SharedString = email.read(cx).text().trim().to_string().into(); + let ready = is_email(&address) && !busy; + let on_send = on_send.unwrap_or_else(|| panic!("email step {id:?} has no on_send")); + let send: Run = { + let address = address.clone(); + Rc::new(move |window, cx| { + log::info!("email step: send"); + on_send(&address, window, cx); + }) + }; + let theme = cx.theme(); + let muted = |text: SharedString| { + div().flex().child( + div() + .flex_1() + .min_w_0() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(text), + ) + }; + let back = back.map(|(label, run)| { + Link::new((id.clone(), "back"), label).on_click(move |_, window, cx| run(window, cx)) + }); + if sent { + let other = other.map(|(label, run)| { + Button::new((id.clone(), "other"), label) + .variant(ButtonVariant::Ghost) + .on_click(move |_, window, cx| run(window, cx)) + }); + return div() + .flex() + .flex_col() + .gap_4() + .child( + div() + .flex() + .items_start() + .gap_3() + .child( + Icon::new(IconName::CircleCheck) + .size(IconSize::Md) + .color(theme.colors.success), + ) + .child( + div() + .flex_1() + .min_w_0() + .child(div().font_weight(FontWeight::MEDIUM).child(words.sent)) + .child(muted((words.note)(&address).into())), + ), + ) + .child( + div() + .flex() + .flex_wrap() + .gap_2() + .child( + Button::new((id.clone(), "resend"), "Send again") + .loading(busy) + .disabled(busy) + .on_click(move |_, window, cx| send(window, cx)), + ) + .children(other), + ) + .children(back) + .into_any_element(); + } + let enter = send.clone(); + div() + .flex() + .flex_col() + .gap_4() + .capture_action(move |_: &Enter, window, cx| { + if ready { + cx.stop_propagation(); + enter(window, cx); + } + }) + .child(muted(words.intro.into())) + .child(FormField::new((id.clone(), "email-field"), "Email").child(EmailInput::new(&email))) + .child( + Button::new((id.clone(), "send"), words.send) + .variant(ButtonVariant::Primary) + .full_width() + .loading(busy) + .disabled(!ready) + .on_click(move |_, window, cx| send(window, cx)), + ) + .children(back) + .into_any_element() +} + +/// Sign in by a link sent to an address: the address, then where the link went, with a way to send it again or to use another address. +#[derive(IntoElement)] +pub struct MagicLinkForm { + id: ElementId, + sent: bool, + busy: bool, + on_send: Option, + on_other: Option, +} + +impl MagicLinkForm { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + sent: false, + busy: false, + on_send: None, + on_other: None, + } + } + + /// Once the owner has sent the link. + pub fn sent(mut self, sent: bool) -> Self { + self.sent = sent; + self + } + + /// While the owner sends. + pub fn busy(mut self, busy: bool) -> Self { + self.busy = busy; + self + } + + /// Runs with the address, for the first link and each one after. + pub fn on_send( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_send = Some(Rc::new(handler)); + self + } + + /// Shows "Use another email" once sent; the owner clears `sent`. + pub fn on_other(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_other = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for MagicLinkForm { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let words = Words { + intro: "We will email you a link that signs you in. No password needed.", + send: "Email me a link", + sent: "Check your email", + note: |address| { + format!("A sign-in link is on its way to {address}. Open it on this device.") + }, + }; + let other = self.on_other.map(|run| ("Use another email", run)); + let step = Step { + id: self.id, + words, + sent: self.sent, + busy: self.busy, + on_send: self.on_send, + back: None, + other, + }; + email_step(step, window, cx) + } +} + +/// A forgotten password: the account's address, then word that a reset link is on its way, with a way to send it again and a way back to sign in. +#[derive(IntoElement)] +pub struct ForgotPassword { + id: ElementId, + sent: bool, + busy: bool, + on_send: Option, + on_back: Option, +} + +impl ForgotPassword { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + sent: false, + busy: false, + on_send: None, + on_back: None, + } + } + + /// Once the owner has sent the link. + pub fn sent(mut self, sent: bool) -> Self { + self.sent = sent; + self + } + + /// While the owner sends. + pub fn busy(mut self, busy: bool) -> Self { + self.busy = busy; + self + } + + /// Runs with the address, for the first link and each one after. + pub fn on_send( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_send = Some(Rc::new(handler)); + self + } + + /// Shows "Back to sign in". + pub fn on_back(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_back = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ForgotPassword { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let words = Words { + intro: "Enter the email you signed up with and we will send a link to set a new password.", + send: "Send reset link", + sent: "Check your email", + note: |address| format!("If an account uses {address}, a reset link is on its way."), + }; + let back = self.on_back.map(|run| ("Back to sign in", run)); + let step = Step { + id: self.id, + words, + sent: self.sent, + busy: self.busy, + on_send: self.on_send, + back, + other: None, + }; + email_step(step, window, cx) + } +} diff --git a/src/account/mod.rs b/src/account/mod.rs new file mode 100644 index 0000000..80ba2ea --- /dev/null +++ b/src/account/mod.rs @@ -0,0 +1,14 @@ +mod factor; +mod login; +mod mail; +mod oauth; +mod signup; + +#[cfg(all(test, feature = "test-support"))] +mod tests; + +pub use factor::TwoFactorInput; +pub use login::{Login, LoginForm}; +pub use mail::{ForgotPassword, MagicLinkForm}; +pub use oauth::OAuthButtons; +pub use signup::{Signup, SignupForm, password_rules}; diff --git a/src/account/oauth.rs b/src/account/oauth.rs new file mode 100644 index 0000000..89edfdc --- /dev/null +++ b/src/account/oauth.rs @@ -0,0 +1,88 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, +}; + +use crate::buttons::{Button, ButtonVariant}; + +type OnKey = Rc; + +/// A button for each sign-in provider, stacked full width: "Continue with GitHub". While one is on its way it spins and the others rest. +#[derive(IntoElement)] +pub struct OAuthButtons { + id: ElementId, + providers: Vec<(SharedString, SharedString)>, + busy: Option, + on_pick: Option, +} + +impl OAuthButtons { + /// `providers` are each a key and the name shown. + pub fn new( + id: impl Into, + providers: impl IntoIterator, impl Into)>, + ) -> Self { + let id = id.into(); + let providers: Vec<_> = providers + .into_iter() + .map(|(key, name)| (key.into(), name.into())) + .collect(); + assert!(!providers.is_empty(), "oauth buttons {id:?}: no provider"); + Self { + id, + providers, + busy: None, + on_pick: None, + } + } + + /// The provider on its way. + pub fn busy(mut self, key: impl Into) -> Self { + let key = key.into(); + assert!( + self.providers.iter().any(|(each, _)| *each == key), + "oauth buttons: no provider {key}" + ); + self.busy = Some(key); + self + } + + pub fn on_pick( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_pick = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for OAuthButtons { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let id = self.id; + let on_pick = self + .on_pick + .unwrap_or_else(|| panic!("oauth buttons {id:?} have no on_pick")); + let busy = self.busy; + div() + .flex() + .flex_col() + .gap_2() + .children(self.providers.into_iter().map(|(key, name)| { + let mine = busy.as_ref() == Some(&key); + let pick = on_pick.clone(); + Button::new( + (id.clone(), format!("provider-{key}")), + format!("Continue with {name}"), + ) + .variant(ButtonVariant::Outline) + .full_width() + .loading(mine) + .disabled(busy.is_some() && !mine) + .on_click(move |_, window, cx| { + log::info!("oauth buttons: {key}"); + pick(&key, window, cx); + }) + })) + } +} diff --git a/src/account/signup.rs b/src/account/signup.rs new file mode 100644 index 0000000..f4f8227 --- /dev/null +++ b/src/account/signup.rs @@ -0,0 +1,211 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, + Styled, Window, div, +}; + +use super::login::{Run, aside, field}; +use crate::{ + buttons::{Button, ButtonVariant}, + feedback::InlineMessage, + forms::{Checkbox, EmailInput, Enter, FormField, Input, PasswordInput, is_email}, + primitives::{Icon, IconName, Severity}, + theme::{ActiveTheme, IconSize, TextSize}, +}; + +/// What a sign-up hands its owner. +#[derive(Clone, Debug, PartialEq)] +pub struct Signup { + pub name: SharedString, + pub email: SharedString, + pub password: SharedString, +} + +/// The rules a new password keeps, each with whether `password` keeps it. +pub fn password_rules(password: &str) -> [(&'static str, bool); 3] { + [ + ("At least 8 characters", password.chars().count() >= 8), + ("A letter", password.chars().any(char::is_alphabetic)), + ("A number", password.chars().any(|ch| ch.is_ascii_digit())), + ] +} + +type OnSignup = Rc; + +/// A name, an email and a new password with the rules it keeps, ticked as they hold, and terms to accept when given. Create account rests until all of it holds; Enter in a field sends it. +#[derive(IntoElement)] +pub struct SignupForm { + id: ElementId, + terms: Option, + error: Option, + busy: bool, + on_submit: Option, + on_sign_in: Option, +} + +impl SignupForm { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + terms: None, + error: None, + busy: false, + on_submit: None, + on_sign_in: None, + } + } + + /// A box the person ticks to accept, such as "I agree to the terms of service". + pub fn terms(mut self, label: impl Into) -> Self { + self.terms = Some(label.into()); + self + } + + /// Why the last try failed, shown above Create account. + pub fn error(mut self, error: impl Into) -> Self { + self.error = Some(error.into()); + self + } + + /// While the owner works: Create account spins and rests. + pub fn busy(mut self, busy: bool) -> Self { + self.busy = busy; + self + } + + pub fn on_submit(mut self, handler: impl Fn(&Signup, &mut Window, &mut App) + 'static) -> Self { + self.on_submit = Some(Rc::new(handler)); + self + } + + /// Shows "Have an account? Sign in". + pub fn on_sign_in(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_sign_in = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for SignupForm { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let name = field(&id, "name", "Your name", false, window, cx); + let email = field(&id, "email", "name@example.com", false, window, cx); + let password = field(&id, "password", "A new password", true, window, cx); + let accepted = window.use_keyed_state((id.clone(), "terms"), cx, |_, _| false); + let signup = Signup { + name: name.read(cx).text().trim().to_string().into(), + email: email.read(cx).text().trim().to_string().into(), + password: password.read(cx).text().to_string().into(), + }; + let rules = password_rules(&signup.password); + let agreed = *accepted.read(cx); + let ready = !signup.name.is_empty() + && is_email(&signup.email) + && rules.iter().all(|(_, kept)| *kept) + && (self.terms.is_none() || agreed) + && !self.busy; + let on_submit = self + .on_submit + .unwrap_or_else(|| panic!("sign-up form {id:?} has no on_submit")); + let submit: Run = Rc::new(move |window, cx| { + log::info!("sign-up form: create account"); + on_submit(&signup, window, cx); + }); + let enter = submit.clone(); + let theme = cx.theme(); + let rule = |(rule, kept): (&'static str, bool)| { + let color = if kept { + theme.colors.success + } else { + theme.colors.fg_muted + }; + div() + .flex() + .items_center() + .gap_2() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(color) + .child( + Icon::new(if kept { + IconName::CircleCheck + } else { + IconName::Circle + }) + .size(IconSize::Sm) + .color(color), + ) + .child(rule) + }; + let rules = div().flex().flex_col().gap_1().children(rules.map(rule)); + div() + .flex() + .flex_col() + .gap_4() + .capture_action(move |_: &Enter, window, cx| { + if ready { + cx.stop_propagation(); + enter(window, cx); + } + }) + .child(FormField::new((id.clone(), "name-field"), "Name").child(Input::new(&name))) + .child( + FormField::new((id.clone(), "email-field"), "Email").child(EmailInput::new(&email)), + ) + .child( + FormField::new((id.clone(), "password-field"), "Password") + .child(PasswordInput::new(&password)) + .child(rules), + ) + .children(self.terms.map(|terms| { + Checkbox::new((id.clone(), "terms"), agreed) + .label(terms) + .on_change(move |on, _, cx| { + accepted.update(cx, |accepted, cx| { + *accepted = on; + cx.notify(); + }) + }) + })) + .children( + self.error + .map(|error| InlineMessage::new(Severity::Danger, error)), + ) + .child( + Button::new((id.clone(), "submit"), "Create account") + .variant(ButtonVariant::Primary) + .full_width() + .loading(self.busy) + .disabled(!ready) + .on_click(move |_, window, cx| submit(window, cx)), + ) + .children(self.on_sign_in.map(|run| { + aside( + (id.clone(), "sign-in"), + "Have an account?", + "Sign in", + run, + cx, + ) + })) + } +} + +#[cfg(test)] +mod tests { + use super::password_rules; + + #[test] + fn a_password_keeps_each_rule_on_its_own() { + let kept = |password: &str| password_rules(password).map(|(_, kept)| kept); + assert_eq!(kept(""), [false, false, false]); + assert_eq!(kept("abcdefgh"), [true, true, false]); + assert_eq!(kept("12345678"), [true, false, true]); + assert_eq!( + kept("été2026"), + [false, true, true], + "seven characters, counted as characters" + ); + assert_eq!(kept("été20261"), [true, true, true]); + } +} diff --git a/src/account/tests/mod.rs b/src/account/tests/mod.rs new file mode 100644 index 0000000..1054803 --- /dev/null +++ b/src/account/tests/mod.rs @@ -0,0 +1,85 @@ +use gpui::{ + AnyElement, App, Context, Entity, IntoElement, KeyBinding, ParentElement, Render, Styled, + TestAppContext, VisualTestContext, Window, div, px, +}; + +use crate::{forms, primitives::FocusNext, theme::Theme}; + +pub(super) type Part = fn(&Bench, Entity) -> AnyElement; + +/// A view that shows one account part, keeps what it heard, and holds what the owner decides: whether a link went, and how many codes failed. +pub(super) struct Bench { + part: Part, + said: Vec, + pub(super) sent: bool, + pub(super) failed: usize, +} + +impl Render for Bench { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div().w(px(360.0)).child((self.part)(self, cx.entity())) + } +} + +pub(super) fn bench( + part: Part, + cx: &mut TestAppContext, +) -> (Entity, &mut VisualTestContext) { + cx.update(|cx| { + Theme::init(cx); + Theme::update(cx, |theme| theme.reduced_motion = true); + forms::bind_keys(cx); + cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]); + }); + let (host, cx) = cx.add_window_view(|_, _| Bench { + part, + said: Vec::new(), + sent: false, + failed: 0, + }); + settle(cx); + (host, cx) +} + +pub(super) fn settle(cx: &mut VisualTestContext) { + for _ in 0..3 { + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + } + cx.run_until_parked(); +} + +pub(super) fn say(owner: &Entity, words: String, cx: &mut App) { + owner.update(cx, |bench, cx| { + bench.said.push(words); + cx.notify(); + }); +} + +pub(super) fn said(host: &Entity, cx: &mut VisualTestContext) -> Vec { + host.read_with(cx, |bench, _| bench.said.clone()) +} + +/// Focus on the `stops`th Tab stop from the top. +pub(super) fn tab(stops: usize, cx: &mut VisualTestContext) { + cx.update(|window, _| { + window.blur(); + (0..stops).for_each(|_| window.focus_next()); + }); + settle(cx); +} + +pub(super) fn tap(key: &str, cx: &mut VisualTestContext) { + cx.simulate_keystrokes(key); + cx.simulate_event(gpui::KeyUpEvent { + keystroke: gpui::Keystroke::parse(key).expect("a key"), + }); + settle(cx); +} + +pub(super) fn write(text: &str, cx: &mut VisualTestContext) { + cx.simulate_input(text); + settle(cx); +} + +mod sign_in; diff --git a/src/account/tests/sign_in.rs b/src/account/tests/sign_in.rs new file mode 100644 index 0000000..c53adcc --- /dev/null +++ b/src/account/tests/sign_in.rs @@ -0,0 +1,192 @@ +use gpui::{AnyElement, Entity, IntoElement, TestAppContext}; + +use super::{Bench, bench, said, say, settle, tab, tap, write}; +use crate::account::{ + ForgotPassword, LoginForm, MagicLinkForm, OAuthButtons, SignupForm, TwoFactorInput, +}; + +fn login(_: &Bench, owner: Entity) -> AnyElement { + LoginForm::new("login") + .on_submit(move |login, _, cx| { + say( + &owner, + format!("{} {} {}", login.email, login.password, login.remember), + cx, + ) + }) + .on_forgot(|_, _| {}) + .into_any_element() +} + +/// Stops: email, password, its eye, the box, the forgotten password, Sign in. +#[gpui::test] +fn sign_in_waits_for_an_address_and_a_password_and_enter_sends(cx: &mut TestAppContext) { + let (host, cx) = bench(login, cx); + tab(1, cx); + write("ada@example", cx); + tab(2, cx); + write("secret", cx); + tap("enter", cx); + assert!( + said(&host, cx).is_empty(), + "an address without a dot is no address" + ); + tab(1, cx); + write(".com", cx); + tab(4, cx); + tap("space", cx); + tab(2, cx); + tap("enter", cx); + assert_eq!(said(&host, cx), ["ada@example.com secret true"]); +} + +fn signup(_: &Bench, owner: Entity) -> AnyElement { + SignupForm::new("signup") + .terms("I agree to the terms") + .on_submit(move |signup, _, cx| { + say( + &owner, + format!("{} {} {}", signup.name, signup.email, signup.password), + cx, + ) + }) + .into_any_element() +} + +/// Stops: name, email, password, its eye, the terms, then Create account once it holds. +#[gpui::test] +fn sign_up_waits_for_every_rule_and_the_terms(cx: &mut TestAppContext) { + let (host, cx) = bench(signup, cx); + tab(1, cx); + write("Ada", cx); + tab(2, cx); + write("ada@example.com", cx); + tab(3, cx); + write("abcdefg1", cx); + tap("enter", cx); + assert!(said(&host, cx).is_empty(), "the terms are not yet accepted"); + tab(5, cx); + tap("space", cx); + tab(3, cx); + tap("backspace", cx); + tap("enter", cx); + assert!( + said(&host, cx).is_empty(), + "seven characters and no number meet no rule but one" + ); + write("1", cx); + tap("enter", cx); + assert_eq!(said(&host, cx), ["Ada ada@example.com abcdefg1"]); +} + +fn providers(bench: &Bench, owner: Entity) -> AnyElement { + let buttons = OAuthButtons::new("oauth", [("github", "GitHub"), ("google", "Google")]) + .on_pick(move |key, _, cx| say(&owner, key.to_string(), cx)); + match bench.sent { + true => buttons.busy("github").into_any_element(), + false => buttons.into_any_element(), + } +} + +/// Stops: GitHub, Google; while GitHub is on its way, neither. +#[gpui::test] +fn a_provider_hands_its_key_and_rests_while_one_is_on_its_way(cx: &mut TestAppContext) { + let (host, cx) = bench(providers, cx); + tab(2, cx); + tap("space", cx); + assert_eq!(said(&host, cx), ["google"]); + host.update(cx, |bench, cx| { + bench.sent = true; + cx.notify(); + }); + settle(cx); + tab(1, cx); + tap("space", cx); + assert_eq!( + said(&host, cx), + ["google"], + "no provider takes a press while one is on its way" + ); +} + +fn magic(bench: &Bench, owner: Entity) -> AnyElement { + let other = owner.clone(); + MagicLinkForm::new("magic") + .sent(bench.sent) + .on_send(move |address, _, cx| { + say(&owner, format!("send {address}"), cx); + owner.update(cx, |bench, _| bench.sent = true); + }) + .on_other(move |_, cx| { + other.update(cx, |bench, cx| { + bench.sent = false; + cx.notify(); + }) + }) + .into_any_element() +} + +/// Stops: email, then the link button; once sent, Send again and Use another email. +#[gpui::test] +fn a_link_goes_to_the_address_and_again_on_request(cx: &mut TestAppContext) { + let (host, cx) = bench(magic, cx); + tab(1, cx); + write("ada@example.com", cx); + tap("enter", cx); + tab(1, cx); + tap("space", cx); + assert_eq!( + said(&host, cx), + ["send ada@example.com", "send ada@example.com"] + ); + tab(2, cx); + tap("space", cx); + assert!( + !host.read_with(cx, |bench, _| bench.sent), + "Use another email goes back to the address" + ); +} + +fn forgot(bench: &Bench, owner: Entity) -> AnyElement { + let back = owner.clone(); + ForgotPassword::new("forgot") + .sent(bench.sent) + .on_send(move |address, _, cx| say(&owner, format!("reset {address}"), cx)) + .on_back(move |_, cx| say(&back, "back".into(), cx)) + .into_any_element() +} + +/// Stops: email, then Back to sign in while the send rests. +#[gpui::test] +fn a_reset_waits_for_an_address_and_offers_the_way_back(cx: &mut TestAppContext) { + let (host, cx) = bench(forgot, cx); + tab(2, cx); + tap("enter", cx); + assert_eq!( + said(&host, cx), + ["back"], + "with no address the send rests, so the second stop is the way back" + ); +} + +fn factor(bench: &Bench, owner: Entity) -> AnyElement { + TwoFactorInput::new("factor") + .failed(bench.failed) + .on_code(move |code, _, cx| say(&owner, code.to_string(), cx)) + .into_any_element() +} + +/// Stops: the code. +#[gpui::test] +fn a_failed_code_clears_the_boxes_for_the_next(cx: &mut TestAppContext) { + let (host, cx) = bench(factor, cx); + tab(1, cx); + write("123456", cx); + host.update(cx, |bench, cx| { + bench.failed = 1; + cx.notify(); + }); + settle(cx); + write("654321", cx); + assert_eq!(said(&host, cx), ["123456", "654321"]); +} diff --git a/src/forms/pin.rs b/src/forms/pin.rs index 6c75b9d..ec6199d 100644 --- a/src/forms/pin.rs +++ b/src/forms/pin.rs @@ -13,6 +13,7 @@ type OnCode = Rc; struct Pin { input: Entity, length: usize, + attempt: usize, on_complete: Option, _events: Subscription, } @@ -24,6 +25,7 @@ pub struct PinInput { length: usize, letters: bool, masked: bool, + attempt: usize, on_complete: Option, } @@ -35,10 +37,17 @@ impl PinInput { length, letters: false, masked: false, + attempt: 0, on_complete: None, } } + /// Clears the boxes whenever `attempt` changes, so a code that failed makes way for the next. + pub fn attempt(mut self, attempt: usize) -> Self { + self.attempt = attempt; + self + } + /// Accepts letters as well as digits. pub fn letters(mut self) -> Self { self.letters = true; @@ -60,7 +69,7 @@ impl PinInput { impl RenderOnce for PinInput { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let (length, letters) = (self.length, self.letters); + let (length, letters, attempt) = (self.length, self.letters, self.attempt); let state = window.use_keyed_state(self.id.clone(), cx, |window, cx: &mut Context| { let input = cx.new(|cx| { TextInput::new(window, cx) @@ -79,11 +88,19 @@ impl RenderOnce for PinInput { Pin { input, length, + attempt, on_complete: None, _events: events, } }); - state.update(cx, |pin, _| pin.on_complete = self.on_complete.clone()); + state.update(cx, |pin, cx| { + pin.on_complete = self.on_complete.clone(); + if pin.attempt != attempt { + pin.attempt = attempt; + log::info!("pin input: cleared for attempt {attempt}"); + pin.input.update(cx, |field, cx| field.set_text("", cx)); + } + }); let input = state.read(cx).input.clone(); let (code, focused, caret_on) = { let field = input.read(cx); diff --git a/src/lib.rs b/src/lib.rs index 93adcb4..ac45c99 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod account; pub mod agent; mod assets; diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index 8444cde..6bdf1ff 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -168,14 +168,17 @@ - [x] AdvancedSettings / FeatureFlags - [x] DeveloperMode Toggle -## T36 Account — `src/account` +## T36a Account · Sign in — `src/account` + +- [x] LoginForm +- [x] SignupForm +- [x] OAuthButtons +- [x] MagicLinkForm +- [x] TwoFactorInput +- [x] ForgotPassword + +## T36b Account · Profile and access -- [ ] LoginForm -- [ ] SignupForm -- [ ] OAuthButtons -- [ ] MagicLinkForm -- [ ] TwoFactorInput -- [ ] ForgotPassword - [ ] ProfileCard - [ ] ProfileEditor - [ ] AccountSwitcher @@ -183,6 +186,9 @@ - [ ] UserMenu - [ ] SessionList - [ ] ApiKeyManager + +## T36c Account · Billing and team + - [ ] SubscriptionCard / PlanBadge - [ ] UsageQuota - [ ] BillingHistory