瀏覽代碼

Add a suggestion box on site field

If a user paste an url like https://WWW.example.org/ in the site field.
LessPass is suggesting :

  * example
  * example.org
  * www.example.org

Fixes: https://github.com/lesspass/lesspass/issues/295
Fixes: https://github.com/lesspass/lesspass/issues/294
pull/342/head
Guillaume Vincent 6 年之前
父節點
當前提交
aa6c82f8c9
共有 6 個檔案被更改,包括 281 行新增165 行删除
  1. +5
    -0
      package-lock.json
  2. +2
    -1
      package.json
  3. +91
    -0
      src/components/InputSite.vue
  4. +6
    -4
      src/services/url-parser.js
  5. +167
    -160
      src/views/PasswordGenerator.vue
  6. +10
    -0
      test/unit/url-parser.js

+ 5
- 0
package-lock.json 查看文件

@@ -8299,6 +8299,11 @@
"integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=",
"dev": true
},
"lodash.uniqby": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz",
"integrity": "sha1-2ZwHpmnp5tJOE2Lf4mbGdhavEwI="
},
"log-symbols": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz",


+ 2
- 1
package.json 查看文件

@@ -24,8 +24,8 @@
]
},
"dependencies": {
"@oslab/btoa": "^0.1.0",
"@oslab/atob": "^0.1.0",
"@oslab/btoa": "^0.1.0",
"awesomplete": "^1.1.2",
"axios": "^0.18.0",
"bootstrap": "^4.0.0",
@@ -35,6 +35,7 @@
"jwt-decode": "^2.2.0",
"lesspass": "^6.0.0",
"lodash.debounce": "^4.0.8",
"lodash.uniqby": "^4.7.0",
"vue": "^2.5.16",
"vue-polyglot": "^0.2.3",
"vue-router": "^3.0.1",


+ 91
- 0
src/components/InputSite.vue 查看文件

@@ -0,0 +1,91 @@
<style>
.awesomplete mark {
background-color: transparent !important;
padding: 0;
margin: 0;
color: inherit;
}
</style>
<template>
<div class="inputSite">
<label for="siteField" class="sr-only">{{ label }}</label>
<div class="inner-addon left-addon">
<i class="fa fa-globe"></i>
<input
id="siteField"
name="siteField"
ref="siteField"
class="form-control awesomplete"
autocorrect="off"
autocapitalize="none"
v-bind:placeholder="label"
v-model="site">
</div>
</div>
</template>
<script>
import debounce from "lodash.debounce";
import uniqBy from "lodash.uniqby";
import { mapGetters, mapState } from "vuex";
import Awesomplete from "awesomplete";
import { getSuggestions } from "../services/url-parser";

export default {
name: "inputSite",
props: {
value: String,
label: String,
passwords: {
type: Array,
default: () => []
}
},
mounted() {
this.awesomplete = new Awesomplete(this.$refs.siteField);
},
computed: {
site: {
get: function() {
return this.value;
},
set: function(newValue) {
this.$emit("input", newValue);
}
}
},
watch: {
site: function(newValue, oldValue) {
const suggestions = getSuggestions(newValue).map(suggestion => {
return { label: suggestion, value: null };
});
const passwordProfiles = this.passwords.map(password => {
return { label: password.site, value: password };
});
this.awesomplete.list = uniqBy(
passwordProfiles.concat(suggestions),
"label"
);
this.awesomplete.filter = function(site, input) {
const inputLowercase = input.toLowerCase();
const siteLowercase = site.trim().toLowerCase();
return (
siteLowercase.indexOf(inputLowercase) !== -1 ||
inputLowercase.indexOf(siteLowercase) !== -1
);
};
const vm = this;
this.awesomplete.replace = function(password) {
this.input.value = password.label;
if (password.value) {
vm.$emit("passwordProfileSelected", password.value);
} else {
vm.$emit("suggestionSelected");
}
};

this.awesomplete.evaluate();
}
},
methods: {}
};
</script>

+ 6
- 4
src/services/url-parser.js 查看文件

@@ -16,10 +16,12 @@ function isAnIpAddressWithPort(address) {

export function getSuggestions(url) {
const cleanedUrl = cleanUrl(url) || url;
const urlElements = cleanedUrl.split(".");
if (isAnIpAddressWithPort(cleanedUrl) || urlElements.length < 2) {
return [];
}
if (isAnIpAddressWithPort(cleanedUrl)) return [];
const urlElements = cleanedUrl
.toLowerCase()
.split(".")
.filter(element => element.length >= 2);
if (urlElements.length < 2) return [];
const baseName = urlElements[urlElements.length - 2];
const tld = urlElements[urlElements.length - 1];
return urlElements.reduceRight(


+ 167
- 160
src/views/PasswordGenerator.vue 查看文件

@@ -1,31 +1,25 @@
<style>
#generated-password {
font-family: Consolas, Menlo, Monaco, Courier New, monospace, sans-serif;
}
#generated-password {
font-family: Consolas, Menlo, Monaco, Courier New, monospace, sans-serif;
}

div.awesomplete {
display: block;
}
div.awesomplete {
display: block;
}

div.awesomplete > ul {
z-index: 11;
}
div.awesomplete > ul {
z-index: 11;
}
</style>
<template>
<form id="password-generator">
<form id="password-generator" v-on:submit.prevent="generatePassword">
<div class="form-group">
<label for="site" class="sr-only">{{ $t('Site') }}</label>
<div class="inner-addon left-addon">
<i class="fa fa-globe"></i>
<input id="site"
name="site"
ref="site"
class="form-control awesomplete"
autocorrect="off"
autocapitalize="none"
v-bind:placeholder="$t('Site')"
v-model="password.site">
</div>
<input-site ref="site"
v-model="password.site"
v-bind:passwords="passwords"
v-bind:label="$t('Site')"
v-on:suggestionSelected="focusBestInputField"
v-on:passwordProfileSelected="setPasswordProfile"></input-site>
</div>
<remove-auto-complete></remove-auto-complete>
<div class="form-group">
@@ -46,16 +40,15 @@
<div class="form-group">
<master-password ref="masterPassword"
v-model="masterPassword"
v-on:keyupEnter="generatePassword"
v-on:generatePassword="generatePassword"
v-bind:label="$t('Master Password')"></master-password>
</div>
<div class="form-group"
v-bind:class="{ 'mb-0': !showOptions }">
<div v-if="!passwordGenerated">
<button id="generatePassword__btn"
type="button"
class="btn btn-primary border-blue"
v-on:click="generatePassword">
type="submit"
class="btn btn-primary border-blue">
{{ $t('Generate') }}
</button>
<button type="button"
@@ -111,150 +104,164 @@
</form>
</template>
<script type="text/ecmascript-6">
import LessPass from 'lesspass';
import {mapGetters, mapState} from 'vuex';
import copy from 'copy-text-to-clipboard';
import RemoveAutoComplete from '../components/RemoveAutoComplete.vue';
import MasterPassword from '../components/MasterPassword.vue';
import Options from '../components/Options.vue';
import {showTooltip} from '../services/tooltip';
import message from '../services/message';
import Awesomplete from 'awesomplete';
import * as urlParser from "../services/url-parser";
import LessPass from "lesspass";
import { mapGetters, mapState } from "vuex";
import copy from "copy-text-to-clipboard";
import RemoveAutoComplete from "../components/RemoveAutoComplete.vue";
import MasterPassword from "../components/MasterPassword.vue";
import InputSite from "../components/InputSite.vue";
import Options from "../components/Options.vue";
import { showTooltip } from "../services/tooltip";
import message from "../services/message";
import Awesomplete from "awesomplete";
import * as urlParser from "../services/url-parser";

export default {
name: 'password-generator-view',
components: {
RemoveAutoComplete,
MasterPassword,
Options
export default {
name: "password-generator-view",
components: {
RemoveAutoComplete,
InputSite,
MasterPassword,
Options
},
computed: {
...mapState(["passwords", "password"]),
...mapGetters(["passwordURL", "isDefaultProfile"])
},
beforeMount() {
this.$store.dispatch("getPasswords").then(() => {
urlParser.getSite().then(site => {
this.$store.dispatch("loadPasswordProfile", { site });
});
this.$store.dispatch("getPasswordFromUrlQuery", {
query: this.$route.query
});
});
},
mounted() {
setTimeout(() => {
this.focusBestInputField();
}, 500);
},
data() {
return {
showOptions: false,
masterPassword: "",
passwordGenerated: "",
cleanTimeout: null
};
},
watch: {
password: {
handler: function() {
this.cleanErrors();
},
deep: true
}
},
methods: {
togglePasswordType(element) {
if (element.type === "password") {
element.type = "text";
} else {
element.type = "password";
}
},
computed: {
...mapState(['passwords', 'password']),
...mapGetters(['passwordURL', 'isDefaultProfile'])
cleanErrors() {
clearTimeout(this.cleanTimeout);
this.passwordGenerated = "";
},
beforeMount() {
this.$store
.dispatch('getPasswords')
.then(() => {
urlParser.getSite().then(site => {
this.$store.dispatch('loadPasswordProfile', {site});
});
this.$store.dispatch('getPasswordFromUrlQuery', {query: this.$route.query});
});
cleanFormInSeconds(seconds = 15) {
this.cleanTimeout = setTimeout(() => {
this.masterPassword = "";
this.passwordGenerated = "";
}, 1000 * seconds);
},
mounted() {
setTimeout(() => {
this.focusBestInputField();
}, 500);
generatePassword() {
const site = this.password.site;
const login = this.password.login;
const masterPassword = this.masterPassword;
if ((!site && !login) || !masterPassword) {
message.error(
this.$t(
"SiteLoginMasterPasswordMandatory",
"Site, login, and master password fields are mandatory."
)
);
return;
}
this.cleanErrors();
const passwordProfile = {
lowercase: this.password.lowercase,
uppercase: this.password.uppercase,
numbers: this.password.numbers,
symbols: this.password.symbols,
length: this.password.length,
counter: this.password.counter,
version: this.password.version
};
return LessPass.generatePassword(
site,
login,
masterPassword,
passwordProfile
).then(passwordGenerated => {
this.passwordGenerated = passwordGenerated;
this.cleanFormInSeconds(30);
});
},
focusBestInputField() {
const site = this.$refs.site.$refs.siteField;
const login = this.$refs.login;
const masterPassword = this.$refs.masterPassword;
if (site && !site.value) return void site.focus();
if (login && !login.value) return void login.focus();
masterPassword.$refs.passwordField.focus();
},
data() {
return {
showOptions: false,
masterPassword: '',
passwordGenerated: '',
cleanTimeout: null
copyPassword() {
const copied = copy(this.passwordGenerated);
if (copied) {
showTooltip(
document.getElementById("copyPasswordButton"),
this.$t("Copied", "copied !")
);
} else {
message.warning(
this.$t(
"SorryCopy",
"We are sorry the copy only works on modern browsers"
)
);
}
},
watch: {
'passwords': function(passwords) {
var site = this.$refs.site;
const self = this;
if (site !== null && passwords.length > 0) {
new Awesomplete(site, {
list: passwords.map(password => {
return {label: password.site + ' ' + password.login, value: password}
}),
replace: function(password) {
self.$store.dispatch('savePassword', {password: password.value});
this.input.value = password.value.site;
self.focusBestInputField();
}
});
}
},
'password': {
handler: function() {
this.cleanErrors();
},
deep: true
sharePasswordProfile() {
const copied = copy(this.passwordURL);
if (copied) {
const copySuccessMessage = this.$t(
"PasswordProfileCopied",
"Your password profile has been copied"
);
showTooltip(
document.getElementById("sharePasswordProfileButton"),
copySuccessMessage,
"hint--top-left"
);
} else {
message.warning(
this.$t(
"SorryCopy",
"We are sorry the copy only works on modern browsers"
)
);
}
},
methods: {
togglePasswordType(element) {
if (element.type === 'password') {
element.type = 'text';
} else {
element.type = 'password';
}
},
cleanErrors() {
clearTimeout(this.cleanTimeout);
this.passwordGenerated = '';
},
cleanFormInSeconds(seconds = 15) {
this.cleanTimeout = setTimeout(() => {
this.masterPassword = '';
this.passwordGenerated = '';
}, 1000 * seconds);
},
generatePassword() {
const site = this.password.site;
const login = this.password.login;
const masterPassword = this.masterPassword;
if (!site && !login || !masterPassword) {
message.error(this.$t('SiteLoginMasterPasswordMandatory', 'Site, login, and master password fields are mandatory.'));
return;
}
this.cleanErrors();
const passwordProfile = {
lowercase: this.password.lowercase,
uppercase: this.password.uppercase,
numbers: this.password.numbers,
symbols: this.password.symbols,
length: this.password.length,
counter: this.password.counter,
version: this.password.version,
};
return LessPass.generatePassword(site, login, masterPassword, passwordProfile).then(passwordGenerated => {
this.passwordGenerated = passwordGenerated;
this.cleanFormInSeconds(30);
setPasswordProfile(passwordProfile) {
this.$store
.dispatch("savePassword", { password: passwordProfile })
.then(() => {
this.focusBestInputField();
});
},
focusBestInputField() {
const site = this.$refs.site;
const login = this.$refs.login;
const masterPassword = this.$refs.masterPassword;
if(site && !site.value){
site.focus()
}else if(login && !login.value){
login.focus()
}else if(masterPassword){
masterPassword.$refs.passwordField.focus()
}
},
copyPassword() {
const copied = copy(this.passwordGenerated);
if (copied) {
showTooltip(document.getElementById('copyPasswordButton'), this.$t('Copied', 'copied !'));
} else {
message.warning(this.$t('SorryCopy', 'We are sorry the copy only works on modern browsers'))
}
},
sharePasswordProfile() {
const copied = copy(this.passwordURL);
if (copied) {
const copySuccessMessage = this.$t('PasswordProfileCopied', 'Your password profile has been copied');
showTooltip(
document.getElementById('sharePasswordProfileButton'),
copySuccessMessage,
'hint--top-left'
);
}
else {
message.warning(this.$t('SorryCopy', 'We are sorry the copy only works on modern browsers'))
}
}
}
}
};
</script>

+ 10
- 0
test/unit/url-parser.js 查看文件

@@ -55,6 +55,16 @@ test("getSuggestions", t => {
urlParser.getSuggestions("https://192.168.1.1:10443/webapp/")
);
t.deepEqual([], urlParser.getSuggestions("example"));
t.deepEqual([], urlParser.getSuggestions("example."));
t.deepEqual([], urlParser.getSuggestions("example.o"));
t.deepEqual(
urlParser.getSuggestions("http://example.org"),
urlParser.getSuggestions("https://example.org")
);
t.deepEqual(
["example", "example.org"],
urlParser.getSuggestions("EXAMPLE.org")
);
});

test("getSite", t => {


Loading…
取消
儲存