Account · Sign in: log in, sign up, providers, a link by email, a second factor
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.
This commit is contained in:
1 parent
69befddeb3
commit
c9e3400a71
15 files changed
+1455
-10
No files matched your search
@@ -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] 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] T34 Dashboard — `tasks/ch31-43.md` (its two maps lines wait for T41)
|
||||||
- [x] T35 Settings — `tasks/ch31-43.md`
|
- [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
|
- [ ] T37 Onboarding & Help
|
||||||
- [ ] T38 Interaction
|
- [ ] T38 Interaction
|
||||||
- [ ] T39 Theme
|
- [ ] T39 Theme
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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<SharedString>,
|
||||||
|
error: Option<SharedString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<SharedString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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."))),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod account;
|
||||||
mod agent;
|
mod agent;
|
||||||
mod buttons;
|
mod buttons;
|
||||||
mod calendar;
|
mod calendar;
|
||||||
@@ -85,6 +86,7 @@ pub const ALL: &[Page] = &[
|
|||||||
devtools::PAGE,
|
devtools::PAGE,
|
||||||
dashboard::PAGE,
|
dashboard::PAGE,
|
||||||
settings::PAGE,
|
settings::PAGE,
|
||||||
|
account::PAGE,
|
||||||
theme::PAGE,
|
theme::PAGE,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||||
|
|
||||||
|
/// 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<OnText>,
|
||||||
|
on_recovery: Option<Run>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TwoFactorInput {
|
||||||
|
pub fn new(id: impl Into<ElementId>) -> 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)),
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<dyn Fn(&mut Window, &mut App)>;
|
||||||
|
|
||||||
|
/// 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<TextInput> {
|
||||||
|
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<ElementId>,
|
||||||
|
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<dyn Fn(&Login, &mut Window, &mut App)>;
|
||||||
|
|
||||||
|
/// 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<SharedString>,
|
||||||
|
busy: bool,
|
||||||
|
on_submit: Option<OnLogin>,
|
||||||
|
on_forgot: Option<Run>,
|
||||||
|
on_sign_up: Option<Run>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoginForm {
|
||||||
|
pub fn new(id: impl Into<ElementId>) -> 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<SharedString>) -> 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)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||||
|
|
||||||
|
/// 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<OnText>,
|
||||||
|
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<OnText>,
|
||||||
|
on_other: Option<Run>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MagicLinkForm {
|
||||||
|
pub fn new(id: impl Into<ElementId>) -> 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<OnText>,
|
||||||
|
on_back: Option<Run>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ForgotPassword {
|
||||||
|
pub fn new(id: impl Into<ElementId>) -> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||||
|
|
||||||
|
/// 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<SharedString>,
|
||||||
|
on_pick: Option<OnKey>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OAuthButtons {
|
||||||
|
/// `providers` are each a key and the name shown.
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
providers: impl IntoIterator<Item = (impl Into<SharedString>, impl Into<SharedString>)>,
|
||||||
|
) -> 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<SharedString>) -> 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);
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<dyn Fn(&Signup, &mut Window, &mut App)>;
|
||||||
|
|
||||||
|
/// 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<SharedString>,
|
||||||
|
error: Option<SharedString>,
|
||||||
|
busy: bool,
|
||||||
|
on_submit: Option<OnSignup>,
|
||||||
|
on_sign_in: Option<Run>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignupForm {
|
||||||
|
pub fn new(id: impl Into<ElementId>) -> 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<SharedString>) -> Self {
|
||||||
|
self.terms = Some(label.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why the last try failed, shown above Create account.
|
||||||
|
pub fn error(mut self, error: impl Into<SharedString>) -> 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Bench>) -> 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<String>,
|
||||||
|
pub(super) sent: bool,
|
||||||
|
pub(super) failed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Bench {
|
||||||
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div().w(px(360.0)).child((self.part)(self, cx.entity()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn bench(
|
||||||
|
part: Part,
|
||||||
|
cx: &mut TestAppContext,
|
||||||
|
) -> (Entity<Bench>, &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<Bench>, words: String, cx: &mut App) {
|
||||||
|
owner.update(cx, |bench, cx| {
|
||||||
|
bench.said.push(words);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn said(host: &Entity<Bench>, cx: &mut VisualTestContext) -> Vec<String> {
|
||||||
|
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;
|
||||||
@@ -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<Bench>) -> 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<Bench>) -> 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<Bench>) -> 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<Bench>) -> 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<Bench>) -> 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<Bench>) -> 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"]);
|
||||||
|
}
|
||||||
+19
-2
@@ -13,6 +13,7 @@ type OnCode = Rc<dyn Fn(&str, &mut Window, &mut App)>;
|
|||||||
struct Pin {
|
struct Pin {
|
||||||
input: Entity<TextInput>,
|
input: Entity<TextInput>,
|
||||||
length: usize,
|
length: usize,
|
||||||
|
attempt: usize,
|
||||||
on_complete: Option<OnCode>,
|
on_complete: Option<OnCode>,
|
||||||
_events: Subscription,
|
_events: Subscription,
|
||||||
}
|
}
|
||||||
@@ -24,6 +25,7 @@ pub struct PinInput {
|
|||||||
length: usize,
|
length: usize,
|
||||||
letters: bool,
|
letters: bool,
|
||||||
masked: bool,
|
masked: bool,
|
||||||
|
attempt: usize,
|
||||||
on_complete: Option<OnCode>,
|
on_complete: Option<OnCode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,10 +37,17 @@ impl PinInput {
|
|||||||
length,
|
length,
|
||||||
letters: false,
|
letters: false,
|
||||||
masked: false,
|
masked: false,
|
||||||
|
attempt: 0,
|
||||||
on_complete: None,
|
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.
|
/// Accepts letters as well as digits.
|
||||||
pub fn letters(mut self) -> Self {
|
pub fn letters(mut self) -> Self {
|
||||||
self.letters = true;
|
self.letters = true;
|
||||||
@@ -60,7 +69,7 @@ impl PinInput {
|
|||||||
|
|
||||||
impl RenderOnce for PinInput {
|
impl RenderOnce for PinInput {
|
||||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
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<Pin>| {
|
let state = window.use_keyed_state(self.id.clone(), cx, |window, cx: &mut Context<Pin>| {
|
||||||
let input = cx.new(|cx| {
|
let input = cx.new(|cx| {
|
||||||
TextInput::new(window, cx)
|
TextInput::new(window, cx)
|
||||||
@@ -79,11 +88,19 @@ impl RenderOnce for PinInput {
|
|||||||
Pin {
|
Pin {
|
||||||
input,
|
input,
|
||||||
length,
|
length,
|
||||||
|
attempt,
|
||||||
on_complete: None,
|
on_complete: None,
|
||||||
_events: events,
|
_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 input = state.read(cx).input.clone();
|
||||||
let (code, focused, caret_on) = {
|
let (code, focused, caret_on) = {
|
||||||
let field = input.read(cx);
|
let field = input.read(cx);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod account;
|
||||||
pub mod agent;
|
pub mod agent;
|
||||||
mod assets;
|
mod assets;
|
||||||
|
|
||||||
|
|||||||
+13
-7
@@ -168,14 +168,17 @@
|
|||||||
- [x] AdvancedSettings / FeatureFlags
|
- [x] AdvancedSettings / FeatureFlags
|
||||||
- [x] DeveloperMode Toggle
|
- [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
|
- [ ] ProfileCard
|
||||||
- [ ] ProfileEditor
|
- [ ] ProfileEditor
|
||||||
- [ ] AccountSwitcher
|
- [ ] AccountSwitcher
|
||||||
@@ -183,6 +186,9 @@
|
|||||||
- [ ] UserMenu
|
- [ ] UserMenu
|
||||||
- [ ] SessionList
|
- [ ] SessionList
|
||||||
- [ ] ApiKeyManager
|
- [ ] ApiKeyManager
|
||||||
|
|
||||||
|
## T36c Account · Billing and team
|
||||||
|
|
||||||
- [ ] SubscriptionCard / PlanBadge
|
- [ ] SubscriptionCard / PlanBadge
|
||||||
- [ ] UsageQuota
|
- [ ] UsageQuota
|
||||||
- [ ] BillingHistory
|
- [ ] BillingHistory
|
||||||
|
|||||||
Reference in new issue
Block a user