chiark / gitweb /
Support registering a new account via this client.
authorSimon Tatham <anakin@pobox.com>
Fri, 19 Jan 2024 08:05:03 +0000 (08:05 +0000)
committerSimon Tatham <anakin@pobox.com>
Sat, 20 Jan 2024 14:57:13 +0000 (14:57 +0000)
This is provided as a second workflow on the TUI login page. You get
to enter a username, email address and password, and register your
account. You still have to respond to an email sent by the
server (just as much as logging in, it needs to know that you're
really the owner of that email address); this can be done by clicking
the link in the message, or pasting it into the login workflow,
whichever you think is easiest.

Clicking the link has weird effects if you set a redirect_uri of
"urn:ietf:wg:oauth:2.0:oob": the Mastodon web server actually tries to
redirect your browser to that URI, which causes at least Firefox to
prompt for an application to open it in, which is thoroughly confusing
and looks more like an error message than the indication of success it
actually is. So instead I've made it redirect to a page on my web
site, which is still a bit odd, but less odd than that.

TODO.md
src/client.rs
src/login.rs
src/tui.rs

diff --git a/TODO.md b/TODO.md
index c3ab91ebbaa648a7c2a7aa248a9c9664c99ada5c..e555280d6f29cd75469a639e16270e5e90845ab8 100644 (file)
--- a/TODO.md
+++ b/TODO.md
@@ -4,16 +4,24 @@ When you favourite a post that you saw via a boost, the [F] doesn't
 appear. Presumably some confusion between the original status and the
 boosting status.
 
-If you're in the post-compose menu, press [ESC] to go to the utilities
-menu, then [RET] to return to where you were, the client panics
-because `TuiLogicalState::new_activity_state` was trying to go into
-the post-compose menu without the previous activity having passed it a
-`Post`, because the activity in question was the utilities menu which
-hasn't got one. I think probably I need to fix this by keeping draft
-posts in a `RefCell` in the `TuiLogicalState` rather than passing them
-as arguments. That would also make it easier to view suspended ones in
-a drafts menu, and to reuse the same mechanism for editing things
-other than posts, such as your account bio.
+Some state fails to be retained if you temporarily leave an activity
+via [ESC] and then return to it (say via [RET]). In the post-compose
+menu, the client panics because `TuiLogicalState::new_activity_state`
+was trying to go into the post-compose menu without the previous
+activity having passed it a `Post`, because the activity in question
+was the utilities menu which hasn't got one. And in login / account
+registration, we go right back to the beginning. _Some_ new technique
+is needed for persisting the state of activities on the current stack;
+perhaps we need to start keeping a `Vec<Box<dyn ActivityState>>`? Or
+perhaps just have each activity save its absolutely necessary stuff?
+Not sure which is better.
+
+Server error reporting: HTTP error codes from Mastodon API requests
+are generally accompanied by a JSON body containing a more useful
+message. For example, if you prematurely try to start the full client
+after an unconfirmed account registration, the error code just says
+"403 Forbidden", but the message body explains that the email address
+isn't confirmed yet, so that a user might actually know what to do.
 
 # Missing features
 
@@ -130,10 +138,20 @@ post of your own, then in real Mono, finishing up your post causes you
 to be belatedly thrown into your mentions. Perhaps we should replicate
 that.
 
-### Saving a draft
+### Longer-term draft saving
 
-If you ESC out of the editor, your draft should be saved to come back
-to.
+If you've got a post half written and you remove the composition
+activity completely from the activity stack, say by \[ESC\]\[G\], it
+would be nice to save the draft _somewhere_ to come back to.
+
+Real Mono puts it in a weird limbo, so that it's never quite obvious
+whether you currently have one, or how to recall it if you do (several
+keystrokes _might_ and it's not reliably the same one every time). So
+I think we can do better, probably via a visible 'Drafts' menu or some
+such. (This will also require dynamic menu keypress assignment.)
+
+This is separate from the question of temporarily leaving a
+composition activity that's still _on_ the activity stack, via [ESC].
 
 ## Editor improvements
 
@@ -325,36 +343,6 @@ to do to make sure this works well:
   the same as 'follow'; if so, perhaps you get back a Relationship
   that says `requested` rather than `following`?)
 
-### Registering an account
-
-There's an [API
-method](https://docs.joinmastodon.org/methods/accounts/#create) to
-_create_ an account on Mastodon, authenticated by an app rather than
-user token. Perhaps that means we could support directly registering
-via Mastodonochrome, without having to first use the web interface?
-
-It's harder than ordinary login, though. You have to submit a password
-for the web UI, which means we have to prompt for one, twice, and
-perhaps also judge its strength. And you have to confirm that the user
-has accepted the rules etc, which means actually showing the rules to
-the user.
-
-And it's harder for the user, because even after this reports success
-(with a user token for the app to keep) they still have to wait for a
-confirmation email and follow the link from it.
-
-Even so, it would be nice to do this. I'd like to do registration (and
-also ordinary login) by actually setting up a Tui, so that you get it
-in the same visual style as everything else, and we can format the
-server rules nicely and let the user page up and down them. But there
-are type-checking implications in the current code structure: `Tui`
-owns a `Client`, which unconditionally owns a set of login details. We
-could make `Client` instead own an `Option<AuthConfig>`, but then
-_everything_ in normal operation would have to either unwrap it or be
-prepared to throw a `ClientError`. The alternative is to make some
-part of `Tui` generic, and for login or registration, use a parameter
-type other than `Client`. Either is awkward.
-
 ### General search
 
 The [search API
index 2b597cc911e87d092376a105a762b2751c1f9b48..1444eae7d29e3feb78fc48f690798a75a49c510f 100644 (file)
@@ -446,9 +446,13 @@ pub fn execute_and_log_request(
 }
 
 const REDIRECT_MAGIC_STRING: &str = "urn:ietf:wg:oauth:2.0:oob";
+const WEBSITE: &str =
+    "https://www.chiark.greenend.org.uk/~sgtatham/mastodonochrome/";
+const WEBSITE_REGISTERED: &str = "https://www.chiark.greenend.org.uk/~sgtatham/mastodonochrome/registered.html";
 
 pub enum AppTokenType<'a> {
-    ClientCredentials,
+    ClientCredentialsLogin,
+    ClientCredentialsRegister,
     AuthorizationCode(&'a str),
 }
 
@@ -1502,12 +1506,37 @@ impl Client {
         Ok(())
     }
 
-    pub fn register_client(&mut self) -> Result<Application, ClientError> {
+    pub fn register_client(
+        &mut self,
+        creating_account: bool,
+    ) -> Result<Application, ClientError> {
+        // If we're logging in to an existing account, we set
+        // redirect_uris to the magic string that causes the Mastodon
+        // server's emailed link to display the code visibly to the
+        // user, so that they can paste it back into our TUI to let it
+        // retrieve its user token.
+        //
+        // If we're _creating_ an account, we instead set it to
+        // redirect to a Mastodonochrome web page, because in that
+        // situation, the browser only needs to say 'ok' - and what
+        // happens if a browser tries to redirect to that urn:... URI
+        // is confusing and looks more like an error than a success.
+        //
+        // The fact that "redirect_uris" is plural in this request and
+        // singular in the followup ones suggests that we _ought_ to
+        // be able to permit both possibilities here, and in the next
+        // request, pick one of them. But I don't know how to do that,
+        // so instead, we specify only the right one of them here.
+        let redirect_uri = if creating_account {
+            WEBSITE_REGISTERED
+        } else {
+            REDIRECT_MAGIC_STRING
+        };
         let req = Req::post("/api/v1/apps")
-            .param("redirect_uris", REDIRECT_MAGIC_STRING)
+            .param("redirect_uris", redirect_uri)
             .param("client_name", "Mastodonochrome")
             .param("scopes", "read write push")
-            .param("website", "https://www.chiark.greenend.org.uk/~sgtatham/mastodonochrome/");
+            .param("website", WEBSITE);
         let (url, rsp) = self.api_request(req)?;
         let rspstatus = rsp.status();
         if !rspstatus.is_success() {
@@ -1542,15 +1571,19 @@ impl Client {
         }?;
 
         let req = Req::post("/oauth/token")
-            .param("redirect_uri", REDIRECT_MAGIC_STRING)
             .param("client_id", client_id)
             .param("client_secret", client_secret);
         let req = match toktype {
-            AppTokenType::ClientCredentials => {
-                req.param("grant_type", "client_credentials")
-            }
+            AppTokenType::ClientCredentialsLogin => req
+                .param("grant_type", "client_credentials")
+                .param("redirect_uri", REDIRECT_MAGIC_STRING),
+            AppTokenType::ClientCredentialsRegister => req
+                .param("grant_type", "client_credentials")
+                .param("redirect_uri", WEBSITE_REGISTERED)
+                .param("scope", "read write push"),
             AppTokenType::AuthorizationCode(code) => req
                 .param("grant_type", "authorization_code")
+                .param("redirect_uri", REDIRECT_MAGIC_STRING)
                 .param("code", code)
                 .param("scope", "read write push"),
         };
@@ -1604,4 +1637,55 @@ impl Client {
             .url(self.auth.instance_url.as_deref().expect("should have set up an instance URL before calling get_auth_url"))?;
         Ok(url.to_string())
     }
+
+    pub fn register_account(
+        &mut self,
+        username: &str,
+        email: &str,
+        password: &str,
+        language: &str,
+    ) -> Result<Token, ClientError> {
+        let req = Req::post("api/v1/accounts")
+            .param("username", username)
+            .param("email", email)
+            .param("password", password)
+            .param("agreement", true) // the calling UI should have confirmed
+            .param("locale", language);
+        let (url, rsp) = self.api_request(req)?;
+        let rspstatus = rsp.status();
+        if !rspstatus.is_success() {
+            Err(ClientError::UrlError(url, rspstatus.to_string()))
+        } else {
+            let tok: Token = match serde_json::from_str(&rsp.text()?) {
+                Ok(tok) => Ok(tok),
+                Err(e) => Err(ClientError::UrlError(url, e.to_string())),
+            }?;
+            Ok(tok)
+        }
+    }
+
+    pub fn register_confirmation(
+        &mut self,
+        urlstr: &str,
+    ) -> Result<(), ClientError> {
+        let url = match Url::parse(urlstr) {
+            Ok(url) => Ok(url),
+            Err(e) => Err(ClientError::UrlParseError(
+                urlstr.to_owned(),
+                e.to_string(),
+            )),
+        }?;
+        let req = self.client.request(reqwest::Method::GET, url).build()?;
+        let (rsp, log) = execute_and_log_request(&self.client, req)?;
+        self.consume_transaction_log(log);
+        let rspstatus = rsp.status();
+        if rspstatus.is_redirection() || rspstatus.is_success() {
+            Ok(())
+        } else {
+            Err(ClientError::UrlError(
+                urlstr.to_owned(),
+                rspstatus.to_string(),
+            ))
+        }
+    }
 }
index 1190d92b688bd54259af6f50a7885cae1f940183..cb1295366c0f911138ca3698266edef3e5a5071e 100644 (file)
 use reqwest::Url;
+use std::cell::RefCell;
+use std::rc::Rc;
+use sys_locale::get_locale;
 
+use super::activity_stack::UtilityActivity;
 use super::auth::AuthConfig;
 use super::client::{AppTokenType, Client, ClientError};
 use super::coloured_string::*;
 use super::config::ConfigLocation;
-use super::editor::EditableMenuLine;
+use super::editor::{
+    count_edit_chars, EditableMenuLine, EditableMenuLineData,
+};
 use super::text::*;
 use super::tui::{
-    ActivityState, CursorPosition, LogicalAction, OurKey, OurKey::*,
+    ActivityState, CursorPosition, LogicalAction, OurKey, OurKey::*, TuiError,
 };
 use super::types::{Account, Application, Instance};
 use super::TopLevelError;
 
+#[derive(Default)]
+struct MandatoryString(String);
+
+impl MandatoryString {
+    fn ok(&self) -> bool {
+        !self.0.is_empty()
+    }
+}
+
+impl EditableMenuLineData for MandatoryString {
+    fn display(&self) -> ColouredString {
+        if self.0.is_empty() {
+            ColouredString::uniform("none", 'r')
+        } else {
+            ColouredString::plain(&self.0)
+        }
+    }
+
+    fn to_text(&self) -> String {
+        self.0.clone()
+    }
+
+    fn update(&mut self, text: &str) {
+        *self = Self(text.to_owned());
+    }
+}
+
+#[derive(Debug)]
+struct Password {
+    this: Rc<RefCell<String>>, // the password we're editing
+    other: Rc<RefCell<String>>, // the one in the other field
+    confirmation: bool,        // are we the confirmation field?
+}
+
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+enum PasswordDiagnostic {
+    OrigGood,
+    ConfirmGood,
+    Empty,
+    TooShort,
+    TooLong,
+    Mismatch,
+    SilentMismatch,
+}
+
+impl Password {
+    fn diagnostic(&self) -> PasswordDiagnostic {
+        let thisstr = self.this.borrow();
+        let nchars = count_edit_chars(&thisstr);
+        if thisstr.is_empty() {
+            PasswordDiagnostic::Empty
+        } else if self.confirmation {
+            let otherstr = self.other.borrow();
+            // No need to diagnose a mismatch if the other password
+            // field is empty, because we're already displaying an
+            // error message in the other edit line.
+            if otherstr.is_empty() {
+                PasswordDiagnostic::SilentMismatch
+            } else if *thisstr != *otherstr {
+                PasswordDiagnostic::Mismatch
+            } else {
+                PasswordDiagnostic::ConfirmGood
+            }
+        } else {
+            // Diagnose bad password lengths. I'm not 100% sure
+            // whether this can be reconfigured per Mastodon
+            // instance, but as of
+            // https://github.com/mastodon/mastodon commit
+            // bd415af9a11fe7057c9f428b7bdaeb8d4fb3a77b,
+            // config/initializers/devise.rb line 281 sets
+            // 'config.password_length = 8..72'.
+            if nchars < 8 {
+                PasswordDiagnostic::TooShort
+            } else if nchars > 72 {
+                PasswordDiagnostic::TooLong
+            } else {
+                PasswordDiagnostic::OrigGood
+            }
+        }
+    }
+
+    fn ok(&self) -> bool {
+        match self.diagnostic() {
+            PasswordDiagnostic::OrigGood | PasswordDiagnostic::ConfirmGood => {
+                true
+            }
+            _ => false,
+        }
+    }
+}
+impl EditableMenuLineData for Password {
+    const SECRET: bool = true;
+
+    fn display(&self) -> ColouredString {
+        let nchars = count_edit_chars(&self.this.borrow());
+        let masked = ColouredString::plain("*").repeat(nchars);
+        match self.diagnostic() {
+            PasswordDiagnostic::OrigGood => {
+                masked + ColouredString::uniform(" length ok", 'f')
+            }
+            PasswordDiagnostic::ConfirmGood => {
+                masked + ColouredString::uniform(" match", 'f')
+            }
+            PasswordDiagnostic::Empty => ColouredString::uniform("none", 'r'),
+            PasswordDiagnostic::SilentMismatch => masked,
+            PasswordDiagnostic::TooShort => {
+                masked + ColouredString::uniform(" too short!", 'r')
+            }
+            PasswordDiagnostic::TooLong => {
+                masked + ColouredString::uniform(" too long!", 'r')
+            }
+            PasswordDiagnostic::Mismatch => {
+                masked + ColouredString::uniform(" mismatch!", 'r')
+            }
+        }
+    }
+
+    fn to_text(&self) -> String {
+        self.this.borrow().clone()
+    }
+
+    fn update(&mut self, text: &str) {
+        *self.this.borrow_mut() = text.to_owned();
+    }
+}
+
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 enum LoginState {
     Initial,
@@ -20,6 +152,9 @@ enum LoginState {
     LoginTokenError,
     LoginSuccess,
     LoginFailure,
+    RegisterInput,
+    RegisterFinal,
+    RegisterFailure,
 }
 
 struct LoginMenu {
@@ -30,10 +165,20 @@ struct LoginMenu {
     para_server_id: Paragraph,
     el_server: EditableMenuLine<String>,
     ml_login: MenuKeypressLine,
+    ml_register: MenuKeypressLine,
     para_login_instructions: Paragraph,
     para_login_url: Paragraph,
     el_logincode: EditableMenuLine<String>,
     para_login_outcome: Paragraph,
+    el_username: EditableMenuLine<MandatoryString>,
+    el_email: EditableMenuLine<MandatoryString>,
+    el_password: EditableMenuLine<Password>,
+    el_password_confirm: EditableMenuLine<Password>,
+    ml_rules: MenuKeypressLine,
+    cl_accept: CyclingMenuLine<bool>,
+    el_register_confirm: EditableMenuLine<String>,
+    ml_register_done: MenuKeypressLine,
+    para_regconfirm_outcome: Paragraph,
     state: LoginState,
     application: Option<Application>,
 }
@@ -67,6 +212,13 @@ impl LoginMenu {
                 'H',
             ),
         );
+        let ml_register = MenuKeypressLine::new(
+            Pr('R'),
+            ColouredString::uniform(
+                "Register on this server as a new user",
+                'H',
+            ),
+        );
 
         let para_server_id =
             Paragraph::new().set_centred(true).set_indent(10, 10);
@@ -80,9 +232,69 @@ impl LoginMenu {
             "".to_owned(),
         );
 
+        let el_username = EditableMenuLine::new(
+            Pr('N'),
+            ColouredString::plain("Name of your new account: "),
+            MandatoryString::default(),
+        );
+        let el_email = EditableMenuLine::new(
+            Pr('E'),
+            ColouredString::plain("Email address to associate with account: "),
+            MandatoryString::default(),
+        );
+        let password1 = Rc::new(RefCell::new("".to_owned()));
+        let password2 = Rc::new(RefCell::new("".to_owned()));
+        let password1c = Rc::clone(&password1);
+        let el_password = EditableMenuLine::new(
+            Pr('P'),
+            ColouredString::plain("Password for the instance website: "),
+            Password {
+                this: password1c,
+                other: Rc::clone(&password2),
+                confirmation: false,
+            },
+        );
+        let el_password_confirm = EditableMenuLine::new(
+            Ctrl('P'),
+            ColouredString::plain("Password again for confirmation:   "),
+            Password {
+                this: password2,
+                other: password1,
+                confirmation: true,
+            },
+        );
+        let ml_rules = MenuKeypressLine::new(
+            Pr('R'),
+            ColouredString::uniform("Read the server rules", 'H'),
+        );
+        let cl_accept = CyclingMenuLine::new(
+            Pr('A'),
+            ColouredString::plain("Do you accept the server rules? "),
+            &[
+                (false, ColouredString::uniform("no", 'r')),
+                (true, ColouredString::uniform("yes", 'f')),
+            ],
+            false,
+        );
+
         let para_login_outcome =
             Paragraph::new().set_centred(true).set_indent(10, 10);
 
+        let el_register_confirm = EditableMenuLine::new(
+            Pr('U'),
+            ColouredString::plain("Confirmation URL: "),
+            "".to_owned(),
+        );
+        let ml_register_done = MenuKeypressLine::new(
+            Pr('C'),
+            ColouredString::plain(
+                "Continue to Main Menu (I followed the link in a browser)",
+            ),
+        );
+
+        let para_regconfirm_outcome =
+            Paragraph::new().set_centred(true).set_indent(10, 10);
+
         let mut menu = LoginMenu {
             cfgloc,
             title,
@@ -90,11 +302,21 @@ impl LoginMenu {
             para_intro,
             el_server,
             ml_login,
+            ml_register,
             para_server_id,
             para_login_instructions,
             para_login_url,
             el_logincode,
             para_login_outcome,
+            el_username,
+            el_email,
+            el_password,
+            el_password_confirm,
+            ml_rules,
+            cl_accept,
+            el_register_confirm,
+            ml_register_done,
+            para_regconfirm_outcome,
             state: LoginState::Initial,
             application: None,
         };
@@ -109,15 +331,45 @@ impl LoginMenu {
         self.alignment_line.check_widths(&mut lmaxwid, &mut rmaxwid);
         self.el_server.check_widths(&mut lmaxwid, &mut rmaxwid);
         self.ml_login.check_widths(&mut lmaxwid, &mut rmaxwid);
+        self.ml_register.check_widths(&mut lmaxwid, &mut rmaxwid);
         self.el_logincode.check_widths(&mut lmaxwid, &mut rmaxwid);
+        self.el_username.check_widths(&mut lmaxwid, &mut rmaxwid);
+        self.el_email.check_widths(&mut lmaxwid, &mut rmaxwid);
+        self.el_password.check_widths(&mut lmaxwid, &mut rmaxwid);
+        self.el_password_confirm
+            .check_widths(&mut lmaxwid, &mut rmaxwid);
+        self.ml_rules.check_widths(&mut lmaxwid, &mut rmaxwid);
+        self.cl_accept.check_widths(&mut lmaxwid, &mut rmaxwid);
+        self.el_register_confirm
+            .check_widths(&mut lmaxwid, &mut rmaxwid);
+        self.ml_register_done
+            .check_widths(&mut lmaxwid, &mut rmaxwid);
 
         self.el_server.reset_widths();
         self.ml_login.reset_widths();
+        self.ml_register.reset_widths();
         self.el_logincode.reset_widths();
+        self.el_username.reset_widths();
+        self.el_email.reset_widths();
+        self.el_password.reset_widths();
+        self.el_password_confirm.reset_widths();
+        self.ml_rules.reset_widths();
+        self.cl_accept.reset_widths();
+        self.el_register_confirm.reset_widths();
+        self.ml_register_done.reset_widths();
 
         self.el_server.ensure_widths(lmaxwid, rmaxwid);
         self.ml_login.ensure_widths(lmaxwid, rmaxwid);
+        self.ml_register.ensure_widths(lmaxwid, rmaxwid);
         self.el_logincode.ensure_widths(lmaxwid, rmaxwid);
+        self.el_username.ensure_widths(lmaxwid, rmaxwid);
+        self.el_email.ensure_widths(lmaxwid, rmaxwid);
+        self.el_password.ensure_widths(lmaxwid, rmaxwid);
+        self.el_password_confirm.ensure_widths(lmaxwid, rmaxwid);
+        self.ml_rules.ensure_widths(lmaxwid, rmaxwid);
+        self.cl_accept.ensure_widths(lmaxwid, rmaxwid);
+        self.el_register_confirm.ensure_widths(lmaxwid, rmaxwid);
+        self.ml_register_done.ensure_widths(lmaxwid, rmaxwid);
 
         (lmaxwid, rmaxwid)
     }
@@ -145,7 +397,7 @@ impl LoginMenu {
             }
         };
         let instance_url = url.as_str().trim_end_matches('/');
-        self.el_server.set_data(instance_url.to_owned());
+        self.el_server.set_text(instance_url);
         client.auth = AuthConfig::default();
         client.auth.instance_url = Some(instance_url.to_owned());
 
@@ -195,9 +447,9 @@ impl LoginMenu {
         client: &mut Client,
     ) -> Result<String, ClientError> {
         // Register the client and get its details
-        let app = client.register_client()?;
-        let app_token =
-            client.get_app_token(&app, AppTokenType::ClientCredentials)?;
+        let app = client.register_client(false)?;
+        let app_token = client
+            .get_app_token(&app, AppTokenType::ClientCredentialsLogin)?;
         client.auth.user_token = Some(app_token.access_token);
         let _app: Application = client.verify_app_credentials()?;
 
@@ -266,66 +518,177 @@ impl LoginMenu {
         let code = self.el_logincode.get_data().trim();
         let app = self
             .application
-            .as_ref()
+            .clone()
             .expect("Can't get here without registering an application");
 
         // Use our code to get the final user access token and all the
         // account details
         let token = client
-            .get_app_token(app, AppTokenType::AuthorizationCode(code))?;
+            .get_app_token(&app, AppTokenType::AuthorizationCode(code))?;
         client.auth.user_token = Some(token.access_token.clone());
-        let account: Account = client.verify_account_credentials()?;
-        let instance: Instance = client.instance()?;
+        client.auth.client_id = Some(app.client_id.clone().unwrap());
+        client.auth.client_secret = Some(app.client_secret.clone().unwrap());
+        Ok(finish_account_setup(client, &self.cfgloc)?)
+    }
+
+    fn verify_login_code(&mut self, client: &mut Client) -> LogicalAction {
+        match self.verify_login_code_fallible(client) {
+            Ok(_) => {
+                self.para_login_outcome.clear();
+                self.para_login_outcome.push_text(
+                    ColouredString::uniform("Success! Logged in as ", 'H'),
+                    false,
+                );
+                self.para_login_outcome.push_text(
+                    ColouredString::uniform(&client.our_account_fq(), 'K'),
+                    false,
+                );
+                self.para_login_outcome.push_text(
+                    ColouredString::general(
+                        ". Now press [SPACE] to continue to the Main Menu.",
+                        "HHHHHHHHHHHHHKKKKKHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH",
+                    ),
+                    false,
+                );
+                self.state = LoginState::LoginSuccess;
+                LogicalAction::Nothing
+            }
+            Err(e) => {
+                self.para_login_outcome.clear();
+                self.para_login_outcome.push_text(
+                    ColouredString::uniform(
+                        &format!("Error logging in: {e}"),
+                        'r',
+                    ),
+                    false,
+                );
+                self.state = LoginState::LoginFailure;
+                LogicalAction::Beep
+            }
+        }
+    }
+
+    fn register_account_fallible(
+        &mut self,
+        client: &mut Client,
+    ) -> Result<(), TopLevelError> {
+        // Register the client and get its details
+        let app = client.register_client(true)?;
+        let app_token = client
+            .get_app_token(&app, AppTokenType::ClientCredentialsRegister)?;
+        client.auth.user_token = Some(app_token.access_token);
+
+        assert!(
+            self.cl_accept.get_value(),
+            "This keystroke should have been locked until you accepted"
+        );
+
+        // FIXME: ability to configure this?
+        let language = get_locale()
+            .as_deref()
+            .and_then(|s| s.split('-').next())
+            .map(|s| if s.is_empty() { "en" } else { s })
+            .unwrap_or("en")
+            .to_owned();
 
+        // Send the account registration request
+        let token = client.register_account(
+            &self.el_username.get_data().0,
+            &self.el_email.get_data().0,
+            &self.el_password.get_data().this.borrow(),
+            &language,
+        )?;
+
+        // Now we have a user token. Save what we have of it.
+        let instance_url = client.auth.instance_url.take();
         client.auth = AuthConfig {
-            account_id: Some(account.id),
-            username: Some(account.username),
-            instance_url: client.auth.instance_url.clone(),
-            instance_domain: Some(instance.domain),
+            account_id: None, // we don't have this yet
+            username: None,   // this isn't confirmed either
+            instance_url,
+            instance_domain: None, // we'd have to request an Instance again
             client_id: Some(app.client_id.clone().unwrap()),
             client_secret: Some(app.client_secret.clone().unwrap()),
-            user_token: Some(token.access_token),
+            user_token: Some(token.access_token.clone()),
         };
 
         let mut json = serde_json::to_string_pretty(&client.auth).unwrap();
         json.push('\n');
         self.cfgloc.create_file("auth", &json)?;
 
+        // FIXME: in final step, self.finish_account_setup(client, &app, token.access_token.clone())
+        self.state = LoginState::RegisterFinal;
+
         Ok(())
     }
 
-    fn verify_login_code(&mut self, client: &mut Client) -> LogicalAction {
-        match self.verify_login_code_fallible(client) {
+    fn register_account(&mut self, client: &mut Client) -> LogicalAction {
+        match self.register_account_fallible(client) {
             Ok(_) => {
                 self.para_login_outcome.clear();
                 self.para_login_outcome.push_text(
-                    ColouredString::uniform("Success! Logged in as ", 'H'),
+                    ColouredString::uniform(
+                        "Success! The server should have sent a confirmation to your email address ",
+                        'H',
+                    ),
                     false,
                 );
                 self.para_login_outcome.push_text(
-                    ColouredString::uniform(&client.our_account_fq(), 'K'),
+                    ColouredString::uniform(&self.el_email.get_data().0, 'K'),
                     false,
                 );
                 self.para_login_outcome.push_text(
-                    ColouredString::general(
-                        ". Now press [SPACE] to continue to the Main Menu.",
-                        "HHHHHHHHHHHHHKKKKKHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH",
+                    ColouredString::uniform(
+                        ". Either paste the confirmation URL from that email below, or visit it in a browser.",
+                        'H',
                     ),
                     false,
                 );
-                self.state = LoginState::LoginSuccess;
+                self.state = LoginState::RegisterFinal;
                 LogicalAction::Nothing
             }
             Err(e) => {
                 self.para_login_outcome.clear();
                 self.para_login_outcome.push_text(
                     ColouredString::uniform(
-                        &format!("Error logging in: {e}"),
+                        &format!("Error registering account: {e}"),
+                        'r',
+                    ),
+                    false,
+                );
+                self.state = LoginState::RegisterFailure;
+                LogicalAction::Beep
+            }
+        }
+    }
+
+    fn confirm_registration_fallible(
+        &mut self,
+        client: &mut Client,
+        with_url: bool,
+    ) -> Result<(), TopLevelError> {
+        if with_url {
+            let url = self.el_register_confirm.get_data();
+            client.register_confirmation(url)?;
+        }
+        Ok(finish_account_setup(client, &self.cfgloc)?)
+    }
+
+    fn confirm_registration(
+        &mut self,
+        client: &mut Client,
+        with_url: bool,
+    ) -> LogicalAction {
+        match self.confirm_registration_fallible(client, with_url) {
+            Ok(_) => LogicalAction::FinishedLoggingIn,
+            Err(e) => {
+                self.para_regconfirm_outcome.clear();
+                self.para_regconfirm_outcome.push_text(
+                    ColouredString::uniform(
+                        &format!("Error confirming registration: {e}"),
                         'r',
                     ),
                     false,
                 );
-                self.state = LoginState::LoginFailure;
                 LogicalAction::Beep
             }
         }
@@ -357,6 +720,7 @@ impl ActivityState for LoginMenu {
         if self.state == LoginState::ServerValid {
             lines.extend_from_slice(&BlankLine::render_static());
             lines.extend_from_slice(&self.ml_login.render(w));
+            lines.extend_from_slice(&self.ml_register.render(w));
         }
         if self.state == LoginState::LoginToken
             || self.state == LoginState::LoginTokenError
@@ -388,6 +752,49 @@ impl ActivityState for LoginMenu {
             lines.extend_from_slice(&BlankLine::render_static());
             push_split_lines(&mut lines, self.para_login_outcome.render(w));
         }
+        if self.state == LoginState::RegisterInput
+            || self.state == LoginState::RegisterFailure
+        {
+            lines.extend_from_slice(&BlankLine::render_static());
+            lines.push(self.el_username.render(
+                w,
+                &mut cursorpos,
+                lines.len(),
+            ));
+            lines.push(self.el_email.render(w, &mut cursorpos, lines.len()));
+            lines.push(self.el_password.render(
+                w,
+                &mut cursorpos,
+                lines.len(),
+            ));
+            lines.push(self.el_password_confirm.render(
+                w,
+                &mut cursorpos,
+                lines.len(),
+            ));
+            lines.extend_from_slice(&self.ml_rules.render(w));
+            lines.extend_from_slice(&self.cl_accept.render(w));
+        }
+        if self.state == LoginState::RegisterFailure
+            || self.state == LoginState::RegisterFinal
+        {
+            lines.extend_from_slice(&BlankLine::render_static());
+            push_split_lines(&mut lines, self.para_login_outcome.render(w));
+        }
+        if self.state == LoginState::RegisterFinal {
+            lines.extend_from_slice(&BlankLine::render_static());
+            lines.push(self.el_register_confirm.render(
+                w,
+                &mut cursorpos,
+                lines.len(),
+            ));
+            lines.extend_from_slice(&self.ml_register_done.render(w));
+            lines.extend_from_slice(&BlankLine::render_static());
+            push_split_lines(
+                &mut lines,
+                self.para_regconfirm_outcome.render(w),
+            );
+        }
 
         while lines.len() + 1 < h {
             lines.extend_from_slice(&BlankLine::render_static());
@@ -411,6 +818,20 @@ impl ActivityState for LoginMenu {
                 }
             }
             LoginState::LoginSuccess => status.add(Space, "Main Menu", 100),
+            LoginState::RegisterInput => {
+                if self.el_username.get_data().ok()
+                    && self.el_email.get_data().ok()
+                    && self.el_password.get_data().ok()
+                    && self.el_password_confirm.get_data().ok()
+                    && self.cl_accept.get_value()
+                {
+                    status.add(Space, "Register account", 100)
+                } else {
+                    status.message(
+                        "Fill in all fields and accept the server rules",
+                    )
+                }
+            }
             _ => status.add(Pr('Q'), "Quit", 100),
         }
         .finalise();
@@ -437,6 +858,25 @@ impl ActivityState for LoginMenu {
                 return self.verify_login_code(client);
             }
             return LogicalAction::Nothing;
+        } else if self.el_username.handle_keypress(key)
+            || self.el_email.handle_keypress(key)
+            || self.el_password_confirm.handle_keypress(key)
+        {
+            self.fix_widths();
+            return LogicalAction::Nothing;
+        } else if self.el_password.handle_keypress(key) {
+            self.fix_widths();
+            if !self.el_password.is_editing() {
+                self.el_password_confirm.set_text("");
+                self.el_password_confirm.start_editing();
+            }
+            return LogicalAction::Nothing;
+        } else if self.el_register_confirm.handle_keypress(key) {
+            self.fix_widths();
+            if !self.el_register_confirm.is_editing() {
+                return self.confirm_registration(client, true);
+            }
+            return LogicalAction::Nothing;
         }
 
         match key {
@@ -454,11 +894,69 @@ impl ActivityState for LoginMenu {
                     LogicalAction::Nothing
                 }
             }
+            Pr('r') | Pr('R') => {
+                if self.state == LoginState::RegisterInput {
+                    // R here means 'read rules'
+                    LogicalAction::Goto(UtilityActivity::InstanceRules.into())
+                } else if self.state == LoginState::ServerValid {
+                    // R here means 'register as new user'
+                    self.state = LoginState::RegisterInput;
+                    LogicalAction::Nothing
+                } else {
+                    LogicalAction::Nothing
+                }
+            }
             Pr('c') | Pr('C') => {
                 if self.state == LoginState::LoginToken
                     || self.state == LoginState::LoginFailure
                 {
                     self.el_logincode.start_editing()
+                } else if self.state == LoginState::RegisterFinal {
+                    self.confirm_registration(client, false)
+                } else {
+                    LogicalAction::Nothing
+                }
+            }
+            Pr('n') | Pr('N') => {
+                if self.state == LoginState::RegisterInput {
+                    self.el_username.start_editing()
+                } else {
+                    LogicalAction::Nothing
+                }
+            }
+            Pr('e') | Pr('E') => {
+                if self.state == LoginState::RegisterInput {
+                    self.el_email.start_editing()
+                } else {
+                    LogicalAction::Nothing
+                }
+            }
+            Pr('p') | Pr('P') => {
+                if self.state == LoginState::RegisterInput {
+                    self.el_password.set_text("");
+                    self.el_password.start_editing()
+                } else {
+                    LogicalAction::Nothing
+                }
+            }
+            Ctrl('P') => {
+                if self.state == LoginState::RegisterInput {
+                    self.el_password_confirm.set_text("");
+                    self.el_password_confirm.start_editing()
+                } else {
+                    LogicalAction::Nothing
+                }
+            }
+            Pr('a') | Pr('A') => {
+                if self.state == LoginState::RegisterInput {
+                    self.cl_accept.cycle()
+                } else {
+                    LogicalAction::Nothing
+                }
+            }
+            Pr('u') | Pr('U') => {
+                if self.state == LoginState::RegisterFinal {
+                    self.el_register_confirm.start_editing()
                 } else {
                     LogicalAction::Nothing
                 }
@@ -466,6 +964,14 @@ impl ActivityState for LoginMenu {
             Space => {
                 if self.state == LoginState::LoginSuccess {
                     LogicalAction::FinishedLoggingIn
+                } else if self.state == LoginState::RegisterInput
+                    && self.el_username.get_data().ok()
+                    && self.el_email.get_data().ok()
+                    && self.el_password.get_data().ok()
+                    && self.el_password_confirm.get_data().ok()
+                    && self.cl_accept.get_value()
+                {
+                    self.register_account(client)
                 } else {
                     LogicalAction::Nothing
                 }
@@ -481,9 +987,41 @@ impl ActivityState for LoginMenu {
     fn resize(&mut self, w: usize, _h: usize) {
         self.el_server.resize(w);
         self.el_logincode.resize(w);
+        self.el_username.resize(w);
+        self.el_email.resize(w);
+        self.el_password.resize(w);
+        self.el_password_confirm.resize(w);
+        self.el_register_confirm.resize(w);
     }
 }
 
+pub fn finish_account_setup(
+    client: &mut Client,
+    cfgloc: &ConfigLocation,
+) -> Result<(), TuiError> {
+    let account: Account = client.verify_account_credentials()?;
+    let instance: Instance = client.instance()?;
+
+    let token = client.auth.user_token.take();
+    let client_id = client.auth.client_id.take();
+    let client_secret = client.auth.client_secret.take();
+    client.auth = AuthConfig {
+        account_id: Some(account.id),
+        username: Some(account.username),
+        instance_url: client.auth.instance_url.clone(),
+        instance_domain: Some(instance.domain),
+        client_id,
+        client_secret,
+        user_token: token,
+    };
+
+    let mut json = serde_json::to_string_pretty(&client.auth).unwrap();
+    json.push('\n');
+    cfgloc.create_file("auth", &json)?;
+
+    Ok(())
+}
+
 pub fn login_menu(cfgloc: ConfigLocation) -> Box<dyn ActivityState> {
     Box::new(LoginMenu::new(cfgloc))
 }
index 1a6944841755ee07b5acc55563d0648a58a39ad2..99e90ec933068faf74890a3c4847c847a9b6433d 100644 (file)
@@ -28,7 +28,7 @@ use super::coloured_string::*;
 use super::config::ConfigLocation;
 use super::editor::*;
 use super::file::*;
-use super::login::login_menu;
+use super::login::{finish_account_setup, login_menu};
 use super::menu::*;
 use super::options::*;
 use super::posting::*;
@@ -299,6 +299,10 @@ impl Tui {
         client.set_writable(!readonly);
         client.set_logfile(logfile);
 
+        if !client.auth.is_logged_in() && client.auth.user_token.is_some() {
+            finish_account_setup(&mut client, cfgloc)?;
+        }
+
         let mut state = TuiLogicalState::new(&client, cfgloc.clone());
         state.load_ldb()?;