smbcloud_cli/account/login/
process.rs

1use crate::{
2    account::{
3        lib::{authorize_github, save_token},
4        signup::{do_signup, SignupMethod},
5    },
6    cli::CommandResult,
7    ui::{fail_message, fail_symbol, succeed_message, succeed_symbol},
8};
9use anyhow::{anyhow, Result};
10use console::{style, Term};
11use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password, Select};
12use log::debug;
13use reqwest::{Client, StatusCode};
14use smbcloud_model::{
15    account::{ErrorCode, GithubInfo, SmbAuthorization, User},
16    forgot::{Param, UserUpdatePassword},
17    login::{LoginArgs, LoginParams, UserParam},
18    signup::{GithubEmail, Provider, SignupGithubParams, SignupUserGithub},
19};
20use smbcloud_networking::{
21    constants::{
22        PATH_LINK_GITHUB_ACCOUNT, PATH_RESEND_CONFIRMATION, PATH_RESET_PASSWORD_INSTRUCTIONS,
23        PATH_USERS_PASSWORD, PATH_USERS_SIGN_IN,
24    },
25    environment::Environment,
26    smb_base_url_builder, smb_token_file_path,
27};
28use smbcloud_utils::email_validation;
29use spinners::Spinner;
30
31pub async fn process_login(env: Environment) -> Result<CommandResult> {
32    // Check if token file exists
33    if smb_token_file_path(env).is_some() {
34        return Ok(CommandResult {
35            spinner: Spinner::new(
36                spinners::Spinners::SimpleDotsScrolling,
37                style("Loading...").green().bold().to_string(),
38            ),
39            symbol: fail_symbol(),
40            msg: fail_message("You are already logged in. Please logout first."),
41        });
42    }
43
44    let signup_methods = vec![SignupMethod::Email, SignupMethod::GitHub];
45    let selection = match Select::with_theme(&ColorfulTheme::default())
46        .items(&signup_methods)
47        .default(0)
48        .interact_on_opt(&Term::stderr())
49        .map(|i| signup_methods[i.unwrap()])
50    {
51        Ok(method) => method,
52        Err(_) => {
53            let error = anyhow!("No selection made.");
54            return Err(error);
55        }
56    };
57
58    match selection {
59        SignupMethod::Email => login_with_email(env).await,
60        SignupMethod::GitHub => login_with_github(env).await,
61    }
62}
63
64// Private functions
65
66async fn login_with_github(env: Environment) -> Result<CommandResult> {
67    match authorize_github(&env).await {
68        Ok(result) => process_authorization(env, result).await,
69        Err(err) => {
70            let error = anyhow!("Failed to authorize your GitHub account. {}", err);
71            Err(error)
72        }
73    }
74}
75
76async fn process_authorization(env: Environment, auth: SmbAuthorization) -> Result<CommandResult> {
77    // What to do if not logged in with GitHub?
78    // Check error_code first
79    if let Some(error_code) = auth.error_code {
80        debug!("{}", error_code);
81        match error_code {
82            ErrorCode::EmailNotFound => {
83                return create_new_account(env, auth.user_email, auth.user_info).await
84            }
85            ErrorCode::EmailUnverified => return send_email_verification(env, auth.user).await,
86            ErrorCode::PasswordNotSet => {
87                // Only for email and password login
88                let error = anyhow!("Password not set.");
89                return Err(error);
90            }
91            ErrorCode::GithubNotLinked => return connect_github_account(env, auth).await,
92        }
93    }
94
95    // Logged in with GitHub!
96    // Token handling is in the lib.rs account module.
97    if let Some(user) = auth.user {
98        let spinner = Spinner::new(
99            spinners::Spinners::SimpleDotsScrolling,
100            style("Logging you in...").green().bold().to_string(),
101        );
102        // We're logged in with GitHub.
103        return Ok(CommandResult {
104            spinner,
105            symbol: "✅".to_owned(),
106            msg: format!("You are logged in with GitHub as {}.", user.email),
107        });
108    }
109
110    let error: anyhow::Error = anyhow!("Failed to login with GitHub.");
111    Err(error)
112}
113
114async fn create_new_account(
115    env: Environment,
116    user_email: Option<GithubEmail>,
117    user_info: Option<GithubInfo>,
118) -> Result<CommandResult> {
119    let confirm = match Confirm::with_theme(&ColorfulTheme::default())
120        .with_prompt("Do you want to create a new account?")
121        .interact()
122    {
123        Ok(confirm) => confirm,
124        Err(_) => {
125            let error = anyhow!("Invalid input.");
126            return Err(error);
127        }
128    };
129
130    // Create account if user confirms
131    if !confirm {
132        let spinner = Spinner::new(
133            spinners::Spinners::SimpleDotsScrolling,
134            style("Logging you in...").green().bold().to_string(),
135        );
136        return Ok(CommandResult {
137            spinner,
138            symbol: "✅".to_owned(),
139            msg: "Please accept to link your GitHub account.".to_owned(),
140        });
141    }
142
143    if let (Some(email), Some(info)) = (user_email, user_info) {
144        let params = SignupGithubParams {
145            user: SignupUserGithub {
146                email: email.email,
147                authorizations_attributes: vec![Provider {
148                    uid: info.id.to_string(),
149                    provider: 0,
150                }],
151            },
152        };
153
154        return do_signup(env, &params).await;
155    }
156
157    Err(anyhow!("Shouldn't be here."))
158}
159
160async fn send_email_verification(env: Environment, user: Option<User>) -> Result<CommandResult> {
161    // Return early if user is null
162    if let Some(user) = user {
163        let confirm = match Confirm::with_theme(&ColorfulTheme::default())
164            .with_prompt("Do you want to send a new verification email?")
165            .interact()
166        {
167            Ok(confirm) => confirm,
168            Err(_) => {
169                let error = anyhow!("Invalid input.");
170                return Err(error);
171            }
172        };
173
174        // Send verification email if user confirms
175        if !confirm {
176            let spinner = Spinner::new(
177                spinners::Spinners::SimpleDotsScrolling,
178                style("Cancel operation.").green().bold().to_string(),
179            );
180            return Ok(CommandResult {
181                spinner,
182                symbol: "✅".to_owned(),
183                msg: "Doing nothing.".to_owned(),
184            });
185        }
186        resend_email_verification(env, user).await
187    } else {
188        let error = anyhow!("Failed to get user.");
189        Err(error)
190    }
191}
192
193async fn resend_email_verification(env: Environment, user: User) -> Result<CommandResult> {
194    let spinner = Spinner::new(
195        spinners::Spinners::SimpleDotsScrolling,
196        style("Sending verification email...")
197            .green()
198            .bold()
199            .to_string(),
200    );
201
202    let response = Client::new()
203        .post(build_smb_resend_email_verification_url(env))
204        .body(format!("id={}", user.id))
205        .header("Accept", "application/json")
206        .header("Content-Type", "application/x-www-form-urlencoded")
207        .send()
208        .await?;
209
210    match response.status() {
211        reqwest::StatusCode::OK => Ok(CommandResult {
212            spinner,
213            symbol: "✅".to_owned(),
214            msg: "Verification email sent!".to_owned(),
215        }),
216        _ => {
217            let error = anyhow!("Failed to send verification email.");
218            Err(error)
219        }
220    }
221}
222
223async fn connect_github_account(env: Environment, auth: SmbAuthorization) -> Result<CommandResult> {
224    let confirm = match Confirm::with_theme(&ColorfulTheme::default())
225        .with_prompt("Do you want to link your GitHub account?")
226        .interact()
227    {
228        Ok(confirm) => confirm,
229        Err(_) => {
230            let error = anyhow!("Invalid input.");
231            return Err(error);
232        }
233    };
234
235    // Link GitHub account if user confirms
236    if !confirm {
237        let spinner = Spinner::new(
238            spinners::Spinners::SimpleDotsScrolling,
239            style("Cancel operation.").green().bold().to_string(),
240        );
241        return Ok(CommandResult {
242            spinner,
243            symbol: "✅".to_owned(),
244            msg: "Doing nothing.".to_owned(),
245        });
246    }
247
248    let spinner = Spinner::new(
249        spinners::Spinners::SimpleDotsScrolling,
250        style("Linking your GitHub account...")
251            .green()
252            .bold()
253            .to_string(),
254    );
255
256    let response = Client::new()
257        .post(build_smb_connect_github_url(env))
258        .json(&auth)
259        .header("Accept", "application/json")
260        .header("Content-Type", "application/x-www-form-urlencoded")
261        .send()
262        .await?;
263
264    match response.status() {
265        reqwest::StatusCode::OK => Ok(CommandResult {
266            spinner,
267            symbol: "✅".to_owned(),
268            msg: "GitHub account linked!".to_owned(),
269        }),
270        _ => {
271            let error = anyhow!("Failed to link GitHub account.");
272            Err(error)
273        }
274    }
275}
276
277async fn login_with_email(env: Environment) -> Result<CommandResult> {
278    println!("Provide your login credentials.");
279    let username = match Input::<String>::with_theme(&ColorfulTheme::default())
280        .with_prompt("Email")
281        .validate_with(|email: &String| email_validation(email))
282        .interact()
283    {
284        Ok(email) => email,
285        Err(_) => {
286            let error = anyhow!("Invalid email.");
287            return Err(error);
288        }
289    };
290    let password = match Password::with_theme(&ColorfulTheme::default())
291        .with_prompt("Password")
292        .interact()
293    {
294        Ok(password) => password,
295        Err(_) => {
296            let error = anyhow!("Invalid password.");
297            return Err(error);
298        }
299    };
300    do_process_login(env, LoginArgs { username, password }).await
301}
302
303async fn do_process_login(env: Environment, args: LoginArgs) -> Result<CommandResult> {
304    let login_params = LoginParams {
305        user: UserParam {
306            email: args.username,
307            password: args.password,
308        },
309    };
310
311    let response = match Client::new()
312        .post(build_smb_login_url(env))
313        .json(&login_params)
314        .send()
315        .await
316    {
317        Ok(response) => response,
318        Err(_) => return Err(anyhow!(fail_message("Check your internet connection."))),
319    };
320
321    match response.status() {
322        StatusCode::OK => {
323            // Login successful
324            save_token(env, &response).await?;
325            Ok(CommandResult {
326                spinner: Spinner::new(
327                    spinners::Spinners::SimpleDotsScrolling,
328                    style("Loading...").green().bold().to_string(),
329                ),
330                symbol: succeed_symbol(),
331                msg: succeed_message("You are logged in!"),
332            })
333        }
334        StatusCode::NOT_FOUND => {
335            // Account not found and we show signup option
336            Ok(CommandResult {
337                spinner: Spinner::new(
338                    spinners::Spinners::SimpleDotsScrolling,
339                    style("Account not found.").green().bold().to_string(),
340                ),
341                symbol: fail_symbol(),
342                msg: fail_message("Please signup!"),
343            })
344        }
345        StatusCode::UNPROCESSABLE_ENTITY => {
346            // Account found but email not verified / password not set
347            let result: SmbAuthorization = response.json().await?;
348            // println!("Result: {:#?}", &result);
349            verify_or_set_password(&env, result).await
350        }
351        _ => Err(anyhow!(fail_message(
352            "Login failed. Check your username and password."
353        ))),
354    }
355}
356
357async fn verify_or_set_password(
358    env: &Environment,
359    result: SmbAuthorization,
360) -> Result<CommandResult> {
361    match result.error_code {
362        Some(error_code) => {
363            debug!("{}", error_code);
364            match error_code {
365                ErrorCode::EmailUnverified => send_email_verification(*env, result.user).await,
366                ErrorCode::PasswordNotSet => send_reset_password(*env, result.user).await,
367                _ => Err(anyhow!("Shouldn't be here.")),
368            }
369        }
370        None => Err(anyhow!("Shouldn't be here.")),
371    }
372}
373
374async fn send_reset_password(env: Environment, user: Option<User>) -> Result<CommandResult> {
375    // Return early if user is null
376    if let Some(user) = user {
377        let confirm = match Confirm::with_theme(&ColorfulTheme::default())
378            .with_prompt("Do you want to reset your password?")
379            .interact()
380        {
381            Ok(confirm) => confirm,
382            Err(_) => {
383                let error = anyhow!("Invalid input.");
384                return Err(error);
385            }
386        };
387
388        // Send verification email if user confirms
389        if !confirm {
390            let spinner = Spinner::new(
391                spinners::Spinners::SimpleDotsScrolling,
392                style("Cancel operation.").green().bold().to_string(),
393            );
394            return Ok(CommandResult {
395                spinner,
396                symbol: style("✔").green().to_string(),
397                msg: "Doing nothing.".to_owned(),
398            });
399        }
400        resend_reset_password_instruction(env, user).await
401    } else {
402        let error = anyhow!("Failed to get user.");
403        Err(error)
404    }
405}
406
407async fn resend_reset_password_instruction(env: Environment, user: User) -> Result<CommandResult> {
408    let mut spinner = Spinner::new(
409        spinners::Spinners::SimpleDotsScrolling,
410        style("Sending reset password instruction...")
411            .green()
412            .bold()
413            .to_string(),
414    );
415    let response = Client::new()
416        .post(build_smb_resend_reset_password_instructions_url(env))
417        .body(format!("id={}", user.id))
418        .header("Accept", "application/json")
419        .header("Content-Type", "application/x-www-form-urlencoded")
420        .send()
421        .await?;
422
423    match response.status() {
424        StatusCode::OK => {
425            spinner.stop_and_persist(
426                "✅",
427                "Reset password instruction sent! Please check your email.".to_owned(),
428            );
429            input_reset_password_token(env).await
430        }
431        _ => {
432            let error = anyhow!("Failed to send reset password instruction.");
433            Err(error)
434        }
435    }
436}
437
438async fn input_reset_password_token(env: Environment) -> Result<CommandResult> {
439    let token = match Input::<String>::with_theme(&ColorfulTheme::default())
440        .with_prompt("Input reset password token")
441        .interact()
442    {
443        Ok(token) => token,
444        Err(_) => {
445            let error = anyhow!("Invalid token.");
446            return Err(error);
447        }
448    };
449    let password = match Password::with_theme(&ColorfulTheme::default())
450        .with_prompt("New password.")
451        .with_confirmation("Repeat password.", "Error: the passwords don't match.")
452        .interact()
453    {
454        Ok(password) => password,
455        Err(_) => {
456            let error = anyhow!("Invalid password.");
457            return Err(error);
458        }
459    };
460
461    let spinner = Spinner::new(
462        spinners::Spinners::SimpleDotsScrolling,
463        style("Resetting password...").green().bold().to_string(),
464    );
465
466    let password_confirmation = password.clone();
467
468    let params = Param {
469        user: UserUpdatePassword {
470            reset_password_token: token,
471            password,
472            password_confirmation,
473        },
474    };
475
476    let response = Client::new()
477        .put(build_smb_reset_password_url(env))
478        .json(&params)
479        .header("Accept", "application/json")
480        .header("Content-Type", "application/x-www-form-urlencoded")
481        .send()
482        .await?;
483
484    match response.status() {
485        StatusCode::OK => Ok(CommandResult {
486            spinner,
487            symbol: succeed_symbol(),
488            msg: succeed_message("Password reset!"),
489        }),
490        _ => Err(anyhow!(fail_message("Failed to reset password."))),
491    }
492}
493
494fn build_smb_login_url(env: Environment) -> String {
495    let mut url_builder = smb_base_url_builder(env);
496    url_builder.add_route(PATH_USERS_SIGN_IN);
497    url_builder.build()
498}
499
500fn build_smb_resend_email_verification_url(env: Environment) -> String {
501    let mut url_builder = smb_base_url_builder(env);
502    url_builder.add_route(PATH_RESEND_CONFIRMATION);
503    url_builder.build()
504}
505
506fn build_smb_resend_reset_password_instructions_url(env: Environment) -> String {
507    let mut url_builder = smb_base_url_builder(env);
508    url_builder.add_route(PATH_RESET_PASSWORD_INSTRUCTIONS);
509    url_builder.build()
510}
511
512fn build_smb_reset_password_url(env: Environment) -> String {
513    let mut url_builder = smb_base_url_builder(env);
514    url_builder.add_route(PATH_USERS_PASSWORD);
515    url_builder.build()
516}
517
518fn build_smb_connect_github_url(env: Environment) -> String {
519    let mut url_builder = smb_base_url_builder(env);
520    url_builder.add_route(PATH_LINK_GITHUB_ACCOUNT);
521    url_builder.build()
522}