smbcloud_cli/account/login/
process.rs

1use crate::{
2    account::{
3        lib::{authorize_github, is_logged_in, 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,
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 is_logged_in(env) {
34        return Ok(CommandResult {
35            spinner: Spinner::new(
36                spinners::Spinners::SimpleDotsScrolling,
37                succeed_message("Loading"),
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 mut spinner = Spinner::new(
302        spinners::Spinners::SimpleDotsScrolling,
303        succeed_message("Loading"),
304    );
305
306    let login_params = LoginParams {
307        user: UserParam {
308            email: args.username,
309            password: args.password,
310        },
311    };
312
313    let response = match Client::new()
314        .post(build_smb_login_url(env))
315        .json(&login_params)
316        .send()
317        .await
318    {
319        Ok(response) => response,
320        Err(_) => return Err(anyhow!(fail_message("Check your internet connection."))),
321    };
322
323    match response.status() {
324        StatusCode::OK => {
325            // Login successful
326            save_token(env, &response).await?;
327            Ok(CommandResult {
328                spinner,
329                symbol: succeed_symbol(),
330                msg: succeed_message("You are logged in!"),
331            })
332        }
333        StatusCode::NOT_FOUND => {
334            // Account not found and we show signup option
335            Ok(CommandResult {
336                spinner,
337                symbol: fail_symbol(),
338                msg: fail_message("Account not found. Please signup!"),
339            })
340        }
341        StatusCode::UNPROCESSABLE_ENTITY => {
342            spinner.stop_and_persist(
343                &succeed_symbol(),
344                succeed_message("Please complete registration"),
345            );
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: succeed_symbol(),
397                msg: succeed_message("Doing nothing."),
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        succeed_message("Sending reset password instruction..."),
411    );
412    let response = Client::new()
413        .post(build_smb_resend_reset_password_instructions_url(env))
414        .body(format!("id={}", user.id))
415        .header("Accept", "application/json")
416        .header("Content-Type", "application/x-www-form-urlencoded")
417        .send()
418        .await?;
419
420    match response.status() {
421        StatusCode::OK => {
422            spinner.stop_and_persist(
423                "✅",
424                "Reset password instruction sent! Please check your email.".to_owned(),
425            );
426            input_reset_password_token(env).await
427        }
428        _ => {
429            let error = anyhow!("Failed to send reset password instruction.");
430            Err(error)
431        }
432    }
433}
434
435async fn input_reset_password_token(env: Environment) -> Result<CommandResult> {
436    let token = match Input::<String>::with_theme(&ColorfulTheme::default())
437        .with_prompt("Input reset password token")
438        .interact()
439    {
440        Ok(token) => token,
441        Err(_) => {
442            let error = anyhow!("Invalid token.");
443            return Err(error);
444        }
445    };
446    let password = match Password::with_theme(&ColorfulTheme::default())
447        .with_prompt("New password.")
448        .with_confirmation("Repeat password.", "Error: the passwords don't match.")
449        .interact()
450    {
451        Ok(password) => password,
452        Err(_) => {
453            let error = anyhow!("Invalid password.");
454            return Err(error);
455        }
456    };
457
458    let spinner = Spinner::new(
459        spinners::Spinners::SimpleDotsScrolling,
460        style("Resetting password...").green().bold().to_string(),
461    );
462
463    let password_confirmation = password.clone();
464
465    let params = Param {
466        user: UserUpdatePassword {
467            reset_password_token: token,
468            password,
469            password_confirmation,
470        },
471    };
472
473    let response = Client::new()
474        .put(build_smb_reset_password_url(env))
475        .json(&params)
476        .header("Accept", "application/json")
477        .header("Content-Type", "application/x-www-form-urlencoded")
478        .send()
479        .await?;
480
481    match response.status() {
482        StatusCode::OK => Ok(CommandResult {
483            spinner,
484            symbol: succeed_symbol(),
485            msg: succeed_message("Password reset!"),
486        }),
487        _ => Err(anyhow!(fail_message("Failed to reset password."))),
488    }
489}
490
491fn build_smb_login_url(env: Environment) -> String {
492    let mut url_builder = smb_base_url_builder(env);
493    url_builder.add_route(PATH_USERS_SIGN_IN);
494    url_builder.build()
495}
496
497fn build_smb_resend_email_verification_url(env: Environment) -> String {
498    let mut url_builder = smb_base_url_builder(env);
499    url_builder.add_route(PATH_RESEND_CONFIRMATION);
500    url_builder.build()
501}
502
503fn build_smb_resend_reset_password_instructions_url(env: Environment) -> String {
504    let mut url_builder = smb_base_url_builder(env);
505    url_builder.add_route(PATH_RESET_PASSWORD_INSTRUCTIONS);
506    url_builder.build()
507}
508
509fn build_smb_reset_password_url(env: Environment) -> String {
510    let mut url_builder = smb_base_url_builder(env);
511    url_builder.add_route(PATH_USERS_PASSWORD);
512    url_builder.build()
513}
514
515fn build_smb_connect_github_url(env: Environment) -> String {
516    let mut url_builder = smb_base_url_builder(env);
517    url_builder.add_route(PATH_LINK_GITHUB_ACCOUNT);
518    url_builder.build()
519}