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