From 7e04c08d9ce9822e9ac4313105724d03eb3a6fe9 Mon Sep 17 00:00:00 2001 From: Shahzad Bhatti Date: Sat, 2 Dec 2023 12:42:12 -0800 Subject: [PATCH] Added support for due date --- assets/javascript/plexpass.js | 49 ++++++++++++++++--- src/controller/models.rs | 89 ++++++++++++++++++++++++++++++++--- src/csv.rs | 57 ++++++++++++++++++++-- src/domain/args.rs | 18 +++++-- src/domain/models.rs | 59 +++++++++++++++++++---- src/utils.rs | 3 ++ templates/index.html | 26 ++++++++-- 7 files changed, 268 insertions(+), 33 deletions(-) diff --git a/assets/javascript/plexpass.js b/assets/javascript/plexpass.js index 80a19c0..e934506 100644 --- a/assets/javascript/plexpass.js +++ b/assets/javascript/plexpass.js @@ -30,6 +30,8 @@ async function viewAccount(id) { } const riskImage = account.risk_image ? `` : ''; + let expiration = buildExpiration(account.expired, account.expires_at); + let due = buildExpiration(account.overdue, account.due_at); modalBody.innerHTML = ` @@ -80,6 +82,9 @@ async function viewAccount(id) { + + + @@ -125,6 +130,9 @@ async function viewAccount(id) {
Expires At:${expiration}
URL: ${account.website_url || ''}
+ + + @@ -171,6 +179,26 @@ async function viewAccount(id) { await viewModal.show(); } +function buildExpiration(expired, expires_at) { + if (expired) { + return ``; + } else if (expires_at) { + return ``; + } else { + return ''; + } +} + function buildOtpSection(otp, generatedOtp) { if (!otp) { return ''; @@ -278,13 +306,13 @@ async function editAccount(id) { await showAccountForm(account); } -async function addAccount(vault_id) { +async function addAccount(vault_id, kind) { document.getElementById('editAccountTitle').innerText = 'Add Account'; const account = { account_id: '', vault_id: vault_id, version: 0, - kind: 'Login', + kind: kind || 'Logins', label: '', description: '', favorite: false, @@ -330,14 +358,14 @@ function clearClipboardAfterDelay(text, delay) { } } - async function showAccountForm(account) { const favorite = account.favorite ? 'checked' : ''; const modalBody = document.querySelector('#editAccountModal .modal-body'); let category_opts = ''; for (let i = 0; i < document.allCategories.length; i++) { const next = document.allCategories[i]; - const selected = account.category === next ? 'selected' : ''; + const selected = account.category ? (account.category === next ? 'selected' : '') : + (account.kind === next ? 'selected' : ''); category_opts += `\n`; } modalBody.innerHTML = ` @@ -393,6 +421,11 @@ async function showAccountForm(account) { +
+ + +
Please select password expiration date.
+
@@ -440,9 +473,14 @@ async function showAccountForm(account) {
+
+ + +
Please select due date.
+
- +
Custom Fields:
@@ -1263,7 +1301,6 @@ async function registerMFAKey() { let response = await fetch('/ui/webauthn/register_start'); let options = await response.json(); - console.log(JSON.stringify(options)); // Convert challenge from Base64URL to Base64, then to Uint8Array const challengeBase64 = base64UrlToBase64(options.publicKey.challenge); options.publicKey.challenge = Uint8Array.from(atob(challengeBase64), c => c.charCodeAt(0)); diff --git a/src/controller/models.rs b/src/controller/models.rs index c6dee30..378de2f 100644 --- a/src/controller/models.rs +++ b/src/controller/models.rs @@ -302,6 +302,9 @@ pub struct CreateAccountRequest { pub renew_interval_days: Option, // expiration pub expires_at: Option, + // due + pub due_at: Option, + // 2023-12-02T05:11:50.543995 // The custom fields of the account. pub custom_name: Option>, // The custom fields of the account. @@ -344,6 +347,7 @@ impl CreateAccountRequest { notes: None, renew_interval_days: None, expires_at: None, + due_at: None, custom_name: None, custom_value: None, password_min_uppercase: None, @@ -380,6 +384,7 @@ impl CreateAccountRequest { account.details.icon = self.icon.clone(); account.details.renew_interval_days = self.renew_interval_days; account.details.expires_at = self.expires_at; + account.details.due_at = self.due_at; account.credentials.password = self.password.clone(); account.credentials.form_fields = self.form_fields.clone().unwrap_or_default(); @@ -524,11 +529,16 @@ pub struct AccountResponse { // renew interval pub renew_interval_days: Option, // expiration - pub expires_at: Option, + pub expires_at: Option, + pub expired: bool, + // due + pub due_at: Option, + pub overdue: bool, + // 2023-12-02T05:11:50.543995 // The metadata for dates of the account. - pub credentials_updated_at: Option, + pub credentials_updated_at: Option, // The metadata for date when password was analyzed. - pub analyzed_at: Option, + pub analyzed_at: Option, // minimum number of upper_case letters should be included. pub password_min_uppercase: Option, // minimum number of lower_case letters should be included. @@ -583,12 +593,27 @@ impl AccountResponse { password_min_length: Some(account.credentials.password_policy.min_length), password_max_length: Some(account.credentials.password_policy.max_length), renew_interval_days: account.details.renew_interval_days, - expires_at: account.details.expires_at, - credentials_updated_at: account.details.credentials_updated_at, - analyzed_at: account.details.analyzed_at, + expires_at: None, + expired: account.details.is_expired(), + due_at: None, + overdue: account.details.is_due(), + credentials_updated_at: None, + analyzed_at: None, created_at: account.created_at, updated_at: account.updated_at, }; + if let Some(expires_at) = &account.details.expires_at { + res.expires_at = Some(expires_at.format("%Y-%m-%d").to_string()) + } + if let Some(due_at) = &account.details.due_at { + res.due_at = Some(due_at.format("%Y-%m-%d").to_string()) + } + if let Some(credentials_updated_at) = &account.details.credentials_updated_at { + res.credentials_updated_at = Some(credentials_updated_at.format("%Y-%m-%d").to_string()) + } + if let Some(analyzed_at) = &account.details.analyzed_at { + res.analyzed_at = Some(analyzed_at.format("%Y-%m-%d").to_string()) + } if let Some(ref otp) = res.otp { res.generated_otp = Option::from(TOTP::new(otp).generate(30, Utc::now().timestamp() as u64)); } @@ -685,7 +710,9 @@ pub struct UpdateAccountRequest { // renew interval pub renew_interval_days: Option, // expiration - pub expires_at: Option, + pub expires_at: Option, // 2023-12-02T05:11:50.543995 + // due + pub due_at: Option, // 2023-12-02T05:11:50.543995 // minimum number of upper_case letters should be included. pub password_min_uppercase: Option, @@ -728,6 +755,7 @@ impl UpdateAccountRequest { custom_value: None, renew_interval_days: None, expires_at: None, + due_at: None, password_min_uppercase: None, password_min_lowercase: None, password_min_digits: None, @@ -769,6 +797,7 @@ impl UpdateAccountRequest { account.credentials.otp = self.otp.clone(); account.details.renew_interval_days = self.renew_interval_days; account.details.expires_at = self.expires_at; + account.details.due_at = self.due_at; let mut password_policy = PasswordPolicy::new(); @@ -869,6 +898,7 @@ impl Account { "icon" => account.details.icon = Some(value), "renew_interval_days" => account.details.renew_interval_days = Some(value.parse::().unwrap_or(0)), "expires_at" => account.details.expires_at = safe_parse_string_date(Option::from(value)), + "due_at" => account.details.due_at= safe_parse_string_date(Option::from(value)), "custom_name" => custom_names.push(value), "custom_value" => custom_values.push(value), _ => {} @@ -1058,3 +1088,48 @@ pub struct QueryAccountParams { pub q: Option, } +#[cfg(test)] +mod tests { + use chrono::Utc; + use crate::controller::models::{AccountResponse, UpdateAccountRequest}; + use crate::domain::models::{Account, AccountKind}; + + #[test] + fn test_should_serialize_account() { + let mut account = Account::new("vault0", AccountKind::Logins); + account.details.label = Some("my label".into()); + account.details.version = 10; + account.details.favorite = true; + account.details.username = Some("test1".into()); + account.credentials.password = Some("pass".into()); + account.credentials.notes = Some("my notes".into()); + account.details.email = Some("email@mail.cc".into()); + account.details.website_url = Some("https://mail.cc".into()); + account.details.category = Some("Contacts".into()); + account.details.tags = vec!["Personal".to_string()]; + account.details.renew_interval_days = Some(3); + account.details.expires_at = Some(Utc::now().naive_utc()); + account.details.due_at = Some(Utc::now().naive_utc()); + let account_response = AccountResponse::new(&account); + let account_json = serde_json::to_string(&account_response).unwrap(); + let des_update_account: UpdateAccountRequest = serde_json::from_str(&account_json).unwrap(); + let des_account = des_update_account.to_account(); + assert_eq!(account.vault_id, des_account.vault_id); + assert_eq!(account.details.account_id, des_account.details.account_id); + assert_eq!(account.details.version, des_account.details.version); + assert_eq!(account.details.label, des_account.details.label); + assert_eq!(account.details.kind, des_account.details.kind); + assert_eq!(account.details.favorite, des_account.details.favorite); + assert_eq!(account.details.risk, des_account.details.risk); + assert_eq!(account.details.username, des_account.details.username); + assert_eq!(account.details.email, des_account.details.email); + assert_eq!(account.details.category, des_account.details.category); + assert_eq!(account.details.website_url, des_account.details.website_url); + assert_eq!(account.credentials.password, des_account.credentials.password); + assert_eq!(account.credentials.notes, des_account.credentials.notes); + assert_eq!(account.details.tags, des_account.details.tags); + assert_eq!(account.details.renew_interval_days, des_account.details.renew_interval_days); + assert_eq!(account.details.expires_at, des_account.details.expires_at); + assert_eq!(account.details.due_at , des_account.details.due_at); + } +} diff --git a/src/csv.rs b/src/csv.rs index b50c719..aa23cea 100644 --- a/src/csv.rs +++ b/src/csv.rs @@ -11,7 +11,8 @@ pub(crate) struct CSVRecord { #[serde(rename = "Type", alias = "kind", alias = "type")] type_: String, #[serde(alias = "label", alias = "title")] - name: String, // label + name: String, + // label #[serde(alias = "website", alias = "url")] website_url: Option, username: Option, @@ -29,6 +30,7 @@ pub(crate) struct CSVRecord { icon: Option, renew_interval_days: Option, expires_at: Option, + due_at: Option, favorite: Option, } @@ -36,7 +38,7 @@ impl CSVRecord { pub(crate) fn new(account: &Account) -> Self { Self { type_: account.details.kind.to_string(), - name : account.details.label.clone().unwrap_or("".into()), + name: account.details.label.clone().unwrap_or("".into()), website_url: account.details.website_url.clone(), username: account.details.username.clone(), email: account.details.email.clone(), @@ -50,7 +52,8 @@ impl CSVRecord { tags: Some(account.details.tags.clone().join(";")), icon: account.details.icon.clone(), renew_interval_days: account.details.renew_interval_days, - expires_at: account.details.expires_at.map(|expires_at| format!("{}", expires_at.format("2015-09-05"))), + expires_at: account.details.expires_at.map(|expires_at| format!("{}", expires_at.format("%Y-%m-%d %H:%M:%S"))), + due_at: account.details.due_at.map(|due_at| format!("{}", due_at.format("%Y-%m-%d %H:%M:%S"))), favorite: Some(account.details.favorite), } } @@ -89,13 +92,16 @@ impl CSVRecord { account.details.website_url = self.website_url.clone(); account.details.category = self.category.clone(); if let Some(tags) = &self.tags { - account.details.tags = tags.as_str().split("[,;]").map(|s|s.to_string()).collect::>(); + account.details.tags = tags.as_str().split("[,;]").map(|s| s.to_string()).collect::>(); } account.details.icon = self.icon.clone(); account.details.renew_interval_days = self.renew_interval_days; if let Some(expires_at) = &self.expires_at { account.details.expires_at = safe_parse_str_date(expires_at); } + if let Some(due_at) = &self.due_at { + account.details.due_at = safe_parse_str_date(due_at); + } account.details.favorite = self.favorite.unwrap_or(false); account.credentials.password = self.password.clone(); @@ -110,7 +116,9 @@ impl CSVRecord { #[cfg(test)] mod tests { + use chrono::Utc; use crate::csv::CSVRecord; + use crate::domain::models::{Account, AccountKind}; #[test] fn test_should_parse_csv() { @@ -149,4 +157,45 @@ Login,License ,https://www.dol.com/,mylogin6,mypassword6,mynote6,, let loaded = CSVRecord::parse(&buf).unwrap(); assert_eq!(14, loaded.len()); } + + #[test] + fn test_should_serialize_account() { + let mut account = Account::new("vault0", AccountKind::Logins); + account.details.label = Some("my label".into()); + account.details.version = 10; + account.details.favorite = true; + account.details.username = Some("test1".into()); + account.credentials.password = Some("pass".into()); + account.credentials.notes = Some("my notes".into()); + account.details.email = Some("email@mail.cc".into()); + account.details.website_url = Some("https://mail.cc".into()); + account.details.category = Some("Contacts".into()); + account.details.tags = vec!["Personal".to_string()]; + account.details.renew_interval_days = Some(3); + account.details.expires_at = Some(Utc::now().naive_utc()); + account.details.due_at = Some(Utc::now().naive_utc()); + let csv_rec = CSVRecord::new(&account); + let account_json = serde_json::to_string(&csv_rec).unwrap(); + let des_csv_rec: CSVRecord = serde_json::from_str(&account_json).unwrap(); + let des_account = des_csv_rec.to_account(&account.vault_id); + assert_eq!(account.vault_id, des_account.vault_id); + assert_ne!(account.details.account_id, des_account.details.account_id); // should not be equal + assert_ne!(account.details.version, des_account.details.version); // should not be equal + assert_eq!(account.details.label, des_account.details.label); + assert_eq!(account.details.kind, des_account.details.kind); + assert_eq!(account.details.favorite, des_account.details.favorite); + assert_eq!(account.details.risk, des_account.details.risk); + assert_eq!(account.details.username, des_account.details.username); + assert_eq!(account.details.email, des_account.details.email); + assert_eq!(account.details.category, des_account.details.category); + assert_eq!(account.details.website_url, des_account.details.website_url); + assert_eq!(account.credentials.password, des_account.credentials.password); + assert_eq!(account.credentials.notes, des_account.credentials.notes); + assert_eq!(account.details.tags, des_account.details.tags); + assert_eq!(account.details.renew_interval_days, des_account.details.renew_interval_days); + assert_eq!(account.details.expires_at.unwrap().format("%Y-%m-%d").to_string(), + des_account.details.expires_at.unwrap().format("%Y-%m-%d").to_string()); + assert_eq!(account.details.due_at.unwrap().format("%Y-%m-%d").to_string(), + des_account.details.due_at.unwrap().format("%Y-%m-%d").to_string()); + } } diff --git a/src/domain/args.rs b/src/domain/args.rs index 4188673..2fb7ed2 100644 --- a/src/domain/args.rs +++ b/src/domain/args.rs @@ -190,6 +190,9 @@ pub enum CommandActions { /// expiration #[arg(long)] expires_at: Option, + /// due-at + #[arg(long)] + due_at: Option, }, UpdateAccount { /// id of account @@ -266,6 +269,10 @@ pub enum CommandActions { /// expiration #[arg(long)] expires_at: Option, + + /// due-at + #[arg(long)] + due_at: Option, }, GetAccount { /// id of account @@ -465,8 +472,7 @@ pub enum CommandActions { #[arg(long)] account_id: String, }, - GenerateUserOTP { - }, + GenerateUserOTP {}, GenerateAPIToken { /// duration of token #[arg(long)] @@ -606,6 +612,7 @@ impl Args { notes, renew_interval_days, expires_at, + due_at, } => { let account = self.build_account( &vault_id, @@ -626,6 +633,7 @@ impl Args { notes, renew_interval_days, expires_at, + due_at, ); Some(account) } @@ -648,7 +656,8 @@ impl Args { icon, notes, renew_interval_days, - expires_at, .. + expires_at, + due_at, } => { let mut account = self.build_account( &vault_id, @@ -669,6 +678,7 @@ impl Args { notes, renew_interval_days, expires_at, + due_at, ); account.details.account_id = account_id.clone(); Some(account) @@ -747,6 +757,7 @@ impl Args { notes: &Option, renew_interval_days: &Option, expires_at: &Option, + due_at: &Option, ) -> Account { let kind = if let Some(kind) = kind { kind.clone() @@ -769,6 +780,7 @@ impl Args { account.details.icon = icon.clone(); account.details.renew_interval_days = *renew_interval_days; account.details.expires_at = safe_parse_string_date(expires_at.clone()); + account.details.due_at = safe_parse_string_date(due_at.clone()); account.credentials.password = password.clone(); account.credentials.form_fields = HashMap::new(); diff --git a/src/domain/models.rs b/src/domain/models.rs index cac046f..f3af4fe 100644 --- a/src/domain/models.rs +++ b/src/domain/models.rs @@ -774,7 +774,7 @@ impl AccountKind { match self { AccountKind::Logins => VaultKind::Logins, AccountKind::Contacts => VaultKind::Contacts, - AccountKind::Notes => VaultKind::SecureNotes, + AccountKind::Notes => VaultKind::Notes, AccountKind::Custom => VaultKind::Custom, } } @@ -869,6 +869,8 @@ pub struct AccountSummary { pub renew_interval_days: Option, // expiration pub expires_at: Option, + // due-at + pub due_at: Option, // The metadata for date when password was changed. pub credentials_updated_at: Option, // The metadata for date when password was analyzed. @@ -909,6 +911,7 @@ impl AccountSummary { advisories: HashMap::new(), renew_interval_days: None, expires_at: None, + due_at: None, credentials_updated_at: None, analyzed_at: None, } @@ -930,6 +933,9 @@ impl AccountSummary { if lq.contains("favorite") && self.favorite { return true; } + if lq.contains("expire") && self.is_expired() || self.is_due() { + return true; + } if lq.contains("high_risk") && (self.risk == AccountRisk::High || self.risk == AccountRisk::Medium) { return true; @@ -1010,6 +1016,28 @@ impl AccountSummary { } } + pub fn is_expired_or_overdue(&self) -> bool { + self.is_expired() || self.is_due() + } + + pub fn is_expired(&self) -> bool { + if let Some(expires_at) = &self.expires_at { + if expires_at.timestamp_millis() < Utc::now().naive_utc().timestamp_millis() { + return true; + } + } + false + } + + pub fn is_due(&self) -> bool { + if let Some(due_at) = &self.due_at { + if due_at.timestamp_millis() < Utc::now().naive_utc().timestamp_millis() { + return true; + } + } + false + } + pub fn favicon(&self) -> String { if let Some(u) = &self.website_url { if let Ok(mut u) = url::Url::parse(u) { @@ -1027,6 +1055,19 @@ impl AccountSummary { String::new() } + pub fn expires_at(&self) -> String { + if let Some(expires_at) = &self.expires_at { + return expires_at.format("%Y-%m-%d").to_string(); + } + String::from("") + } + pub fn due_at(&self) -> String { + if let Some(due_at) = &self.due_at { + return due_at.format("%Y-%m-%d").to_string(); + } + String::from("") + } + pub fn email(&self) -> String { self.email.clone().unwrap_or("".into()) } @@ -1395,20 +1436,20 @@ impl AccountPasswordSummary { pub const DEFAULT_VAULT_NAMES: [&str; 5] = ["Identity", "Personal", "Work", "Financial", "Secure Notes"]; pub const DEFAULT_CATEGORIES: [&str; 10] = [ - "Contacts", "Logins", + "Contacts", + "Notes", + "Custom", "Finance", "Social", "Shopping", "Travel", "Gaming", - "Notes", "Credit Cards", - "Custom", ]; pub fn top_categories() -> Vec { - DEFAULT_CATEGORIES[0..5].to_vec().iter().map(|s| s.to_string()).collect() + DEFAULT_CATEGORIES[0..3].to_vec().iter().map(|s| s.to_string()).collect() } pub fn all_categories() -> Vec { @@ -1421,7 +1462,7 @@ pub fn all_categories() -> Vec { pub enum VaultKind { Logins, Contacts, - SecureNotes, + Notes, Custom, } @@ -1430,11 +1471,11 @@ impl From<&str> for VaultKind { match s { "Logins" => VaultKind::Logins, "Contacts" => VaultKind::Contacts, - "SecureNotes" => VaultKind::SecureNotes, + "Notes" => VaultKind::Notes, _ => { let s = s.to_lowercase(); if s.contains("note") { - VaultKind::SecureNotes + VaultKind::Notes } else if s.contains("data") || s.contains("custom") { VaultKind::Custom } else { @@ -1450,7 +1491,7 @@ impl Display for VaultKind { match self { VaultKind::Logins => write!(f, "Logins"), VaultKind::Contacts => write!(f, "Contacts"), - VaultKind::SecureNotes => write!(f, "SecureNotes"), + VaultKind::Notes => write!(f, "Notes"), VaultKind::Custom => write!(f, "Custom"), } } diff --git a/src/utils.rs b/src/utils.rs index aa07dd5..ceb424f 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -27,6 +27,9 @@ pub fn safe_parse_str_date(d: &str) -> Option { if let Ok(parsed) = NaiveDate::parse_from_str(d, "%Y-%m-%d") { return Some(NaiveDateTime::new(parsed, NaiveTime::from_hms(0, 0, 0))); } + if let Ok(parsed) = NaiveDate::parse_from_str(d, "%Y-%m-%d %H:%M:%S") { + return Some(NaiveDateTime::new(parsed, NaiveTime::from_hms(0, 0, 0))); + } if let Ok(parsed) = NaiveDate::parse_from_str(d, "%Y/%m/%d") { return Some(NaiveDateTime::new(parsed, NaiveTime::from_hms(0, 0, 0))); } diff --git a/templates/index.html b/templates/index.html index af70763..985a253 100755 --- a/templates/index.html +++ b/templates/index.html @@ -72,6 +72,9 @@ + {% for category in top_categories %}
@@ -114,7 +117,7 @@
- + @@ -137,7 +140,11 @@ {% endif %} - + {% if account.is_expired_or_overdue() %} + + {% else %} + + {% endif %}
Due At:${due}
Notes: ${account.notes || ''}
Label UsernameEmailExpires/Due Date Category & Tags Action
{{account.username()}}{{account.email()}}{{account.expires_at()}} {{account.due_at()}}{{account.expires_at()}} {{account.due_at()}}{{account.all_cat_tags()}}