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: succeed_symbol(),
183                msg: succeed_message("Doing nothing."),
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: succeed_symbol(),
214            msg: succeed_message("Verification email sent!"),
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            succeed_message("Cancel operation."),
240        );
241        return Ok(CommandResult {
242            spinner,
243            symbol: succeed_symbol(),
244            msg: succeed_message("Doing nothing."),
245        });
246    }
247
248    let spinner = Spinner::new(
249        spinners::Spinners::SimpleDotsScrolling,
250        succeed_message("Linking your GitHub account..."),
251    );
252
253    let response = Client::new()
254        .post(build_smb_connect_github_url(env))
255        .json(&auth)
256        .header("Accept", "application/json")
257        .header("Content-Type", "application/x-www-form-urlencoded")
258        .send()
259        .await?;
260
261    match response.status() {
262        reqwest::StatusCode::OK => Ok(CommandResult {
263            spinner,
264            symbol: succeed_symbol(),
265            msg: succeed_message("GitHub account linked!"),
266        }),
267        _ => {
268            let error = anyhow!("Failed to link GitHub account.");
269            Err(error)
270        }
271    }
272}
273
274async fn login_with_email(env: Environment) -> Result<CommandResult> {
275    println!("Provide your login credentials.");
276    let username = match Input::<String>::with_theme(&ColorfulTheme::default())
277        .with_prompt("Email")
278        .validate_with(|email: &String| email_validation(email))
279        .interact()
280    {
281        Ok(email) => email,
282        Err(_) => {
283            let error = anyhow!("Invalid email.");
284            return Err(error);
285        }
286    };
287    let password = match Password::with_theme(&ColorfulTheme::default())
288        .with_prompt("Password")
289        .interact()
290    {
291        Ok(password) => password,
292        Err(_) => {
293            let error = anyhow!("Invalid password.");
294            return Err(error);
295        }
296    };
297    do_process_login(env, LoginArgs { username, password }).await
298}
299
300async fn do_process_login(env: Environment, args: LoginArgs) -> Result<CommandResult> {
301    let login_params = LoginParams {
302        user: UserParam {
303            email: args.username,
304            password: args.password,
305        },
306    };
307
308    let response = match Client::new()
309        .post(build_smb_login_url(env))
310        .json(&login_params)
311        .send()
312        .await
313    {
314        Ok(response) => response,
315        Err(_) => return Err(anyhow!(fail_message("Check your internet connection."))),
316    };
317
318    match response.status() {
319        StatusCode::OK => {
320            // Login successful
321            save_token(env, &response).await?;
322            Ok(CommandResult {
323                spinner: Spinner::new(
324                    spinners::Spinners::SimpleDotsScrolling,
325                    style("Loading...").green().bold().to_string(),
326                ),
327                symbol: succeed_symbol(),
328                msg: succeed_message("You are logged in!"),
329            })
330        }
331        StatusCode::NOT_FOUND => {
332            // Account not found and we show signup option
333            Ok(CommandResult {
334                spinner: Spinner::new(
335                    spinners::Spinners::SimpleDotsScrolling,
336                    style("Account not found.").green().bold().to_string(),
337                ),
338                symbol: fail_symbol(),
339                msg: fail_message("Please signup!"),
340            })
341        }
342        StatusCode::UNPROCESSABLE_ENTITY => {
343            // Account found but email not verified / password not set
344            let result: SmbAuthorization = response.json().await?;
345            // println!("Result: {:#?}", &result);
346            verify_or_set_password(&env, result).await
347        }
348        _ => Err(anyhow!(fail_message(
349            "Login failed. Check your username and password."
350        ))),
351    }
352}
353
354async fn verify_or_set_password(
355    env: &Environment,
356    result: SmbAuthorization,
357) -> Result<CommandResult> {
358    match result.error_code {
359        Some(error_code) => {
360            debug!("{}", error_code);
361            match error_code {
362                ErrorCode::EmailUnverified => send_email_verification(*env, result.user).await,
363                ErrorCode::PasswordNotSet => send_reset_password(*env, result.user).await,
364                _ => Err(anyhow!("Shouldn't be here.")),
365            }
366        }
367        None => Err(anyhow!("Shouldn't be here.")),
368    }
369}
370
371async fn send_reset_password(env: Environment, user: Option<User>) -> Result<CommandResult> {
372    // Return early if user is null
373    if let Some(user) = user {
374        let confirm = match Confirm::with_theme(&ColorfulTheme::default())
375            .with_prompt("Do you want to reset your password?")
376            .interact()
377        {
378            Ok(confirm) => confirm,
379            Err(_) => {
380                let error = anyhow!("Invalid input.");
381                return Err(error);
382            }
383        };
384
385        // Send verification email if user confirms
386        if !confirm {
387            let spinner = Spinner::new(
388                spinners::Spinners::SimpleDotsScrolling,
389                style("Cancel operation.").green().bold().to_string(),
390            );
391            return Ok(CommandResult {
392                spinner,
393                symbol: succeed_symbol(),
394                msg: succeed_message("Doing nothing."),
395            });
396        }
397        resend_reset_password_instruction(env, user).await
398    } else {
399        let error = anyhow!("Failed to get user.");
400        Err(error)
401    }
402}
403
404async fn resend_reset_password_instruction(env: Environment, user: User) -> Result<CommandResult> {
405    let mut spinner = Spinner::new(
406        spinners::Spinners::SimpleDotsScrolling,
407        succeed_message("Sending reset password instruction..."),
408    );
409    let response = Client::new()
410        .post(build_smb_resend_reset_password_instructions_url(env))
411        .body(format!("id={}", user.id))
412        .header("Accept", "application/json")
413        .header("Content-Type", "application/x-www-form-urlencoded")
414        .send()
415        .await?;
416
417    match response.status() {
418        StatusCode::OK => {
419            spinner.stop_and_persist(
420                "✅",
421                "Reset password instruction sent! Please check your email.".to_owned(),
422            );
423            input_reset_password_token(env).await
424        }
425        _ => {
426            let error = anyhow!("Failed to send reset password instruction.");
427            Err(error)
428        }
429    }
430}
431
432async fn input_reset_password_token(env: Environment) -> Result<CommandResult> {
433    let token = match Input::<String>::with_theme(&ColorfulTheme::default())
434        .with_prompt("Input reset password token")
435        .interact()
436    {
437        Ok(token) => token,
438        Err(_) => {
439            let error = anyhow!("Invalid token.");
440            return Err(error);
441        }
442    };
443    let password = match Password::with_theme(&ColorfulTheme::default())
444        .with_prompt("New password.")
445        .with_confirmation("Repeat password.", "Error: the passwords don't match.")
446        .interact()
447    {
448        Ok(password) => password,
449        Err(_) => {
450            let error = anyhow!("Invalid password.");
451            return Err(error);
452        }
453    };
454
455    let spinner = Spinner::new(
456        spinners::Spinners::SimpleDotsScrolling,
457        style("Resetting password...").green().bold().to_string(),
458    );
459
460    let password_confirmation = password.clone();
461
462    let params = Param {
463        user: UserUpdatePassword {
464            reset_password_token: token,
465            password,
466            password_confirmation,
467        },
468    };
469
470    let response = Client::new()
471        .put(build_smb_reset_password_url(env))
472        .json(&params)
473        .header("Accept", "application/json")
474        .header("Content-Type", "application/x-www-form-urlencoded")
475        .send()
476        .await?;
477
478    match response.status() {
479        StatusCode::OK => Ok(CommandResult {
480            spinner,
481            symbol: succeed_symbol(),
482            msg: succeed_message("Password reset!"),
483        }),
484        _ => Err(anyhow!(fail_message("Failed to reset password."))),
485    }
486}
487
488fn build_smb_login_url(env: Environment) -> String {
489    let mut url_builder = smb_base_url_builder(env);
490    url_builder.add_route(PATH_USERS_SIGN_IN);
491    url_builder.build()
492}
493
494fn build_smb_resend_email_verification_url(env: Environment) -> String {
495    let mut url_builder = smb_base_url_builder(env);
496    url_builder.add_route(PATH_RESEND_CONFIRMATION);
497    url_builder.build()
498}
499
500fn build_smb_resend_reset_password_instructions_url(env: Environment) -> String {
501    let mut url_builder = smb_base_url_builder(env);
502    url_builder.add_route(PATH_RESET_PASSWORD_INSTRUCTIONS);
503    url_builder.build()
504}
505
506fn build_smb_reset_password_url(env: Environment) -> String {
507    let mut url_builder = smb_base_url_builder(env);
508    url_builder.add_route(PATH_USERS_PASSWORD);
509    url_builder.build()
510}
511
512fn build_smb_connect_github_url(env: Environment) -> String {
513    let mut url_builder = smb_base_url_builder(env);
514    url_builder.add_route(PATH_LINK_GITHUB_ACCOUNT);
515    url_builder.build()
516}