Fill best matching password entry with Ctrl+Shift+F (#131)
This commit is contained in:
23
README.md
23
README.md
@@ -121,7 +121,7 @@ Browserpass was designed with an assumption that certain conventions are being f
|
||||
|
||||
### First steps in browser extension
|
||||
|
||||
Click on the icon or use <kbd>Ctrl+Shift+L</kbd> to open Browserpass with the entries that match current domain.
|
||||
Click on the icon or use <kbd>Ctrl+Shift+L</kbd> to open the Browserpass popup with the entries that match the current domain. You can also use <kbd>Ctrl+Shift+F</kbd> to fill the form with the best matching credentials without even opening the popup (the best matching credentials are the first ones on the list if you open the popup).
|
||||
|
||||
How to change the shortcut:
|
||||
|
||||
@@ -139,19 +139,20 @@ If you want to intentionally disable phishing attack protection and search the e
|
||||
Note: If the cursor is located in the search input field, every shortcut that works on the selected entry will be applied on the first entry in the popup list.
|
||||
|
||||
| Shortcut | Action |
|
||||
| ---------------------------------------------------- | ----------------------------------------------- |
|
||||
| <kbd>Ctrl+Shift+L</kbd> | Open Browserpass popup |
|
||||
| <kbd>Enter</kbd> | Submit form with currently selected credentials |
|
||||
| Arrow keys and <kbd>Tab</kbd> / <kbd>Shift+Tab</kbd> | Navigate popup list |
|
||||
| <kbd>Ctrl+C</kbd> | Copy password to clipboard |
|
||||
| <kbd>Ctrl+Shift+C</kbd> | Copy username to clipboard |
|
||||
| <kbd>Ctrl+G</kbd> | Open URL in the current tab |
|
||||
| <kbd>Ctrl+Shift+G</kbd> | Open URL in the new tab |
|
||||
| <kbd>Backspace</kbd> (with no search text entered) | Search passwords in the entire password store |
|
||||
| ---------------------------------------------------- | ------------------------------------------------ |
|
||||
| <kbd>Ctrl+Shift+L</kbd> | Open Browserpass popup |
|
||||
| <kbd>Ctrl+Shift+F</kbd> | Fill the form with the best matching credentials |
|
||||
| <kbd>Enter</kbd> | Submit form with currently selected credentials |
|
||||
| Arrow keys and <kbd>Tab</kbd> / <kbd>Shift+Tab</kbd> | Navigate popup list |
|
||||
| <kbd>Ctrl+C</kbd> | Copy password to clipboard |
|
||||
| <kbd>Ctrl+Shift+C</kbd> | Copy username to clipboard |
|
||||
| <kbd>Ctrl+G</kbd> | Open URL in the current tab |
|
||||
| <kbd>Ctrl+Shift+G</kbd> | Open URL in the new tab |
|
||||
| <kbd>Backspace</kbd> (with no search text entered) | Search passwords in the entire password store |
|
||||
|
||||
### Password matching and sorting
|
||||
|
||||
When you first open Browserpass popup, you will see a badge with the current domain name in the search input field:
|
||||
When you first open the Browserpass popup, you will see a badge with the current domain name in the search input field:
|
||||
|
||||

|
||||
|
||||
|
@@ -57,6 +57,30 @@ chrome.runtime.onMessage.addListener(function(message, sender, sendResponse) {
|
||||
return true;
|
||||
});
|
||||
|
||||
// handle keyboard shortcuts
|
||||
chrome.commands.onCommand.addListener(async command => {
|
||||
switch (command) {
|
||||
case "fillBest":
|
||||
try {
|
||||
const settings = await getFullSettings();
|
||||
if (settings.tab.url.match(/^(chrome|about):/)) {
|
||||
// only fill on real domains
|
||||
return;
|
||||
}
|
||||
handleMessage(settings, { action: "listFiles" }, listResults => {
|
||||
const logins = helpers.prepareLogins(listResults.files, settings);
|
||||
const bestLogin = helpers.filterSortLogins(logins, "", true)[0];
|
||||
if (bestLogin) {
|
||||
handleMessage(settings, { action: "fill", login: bestLogin }, () => {});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
chrome.runtime.onInstalled.addListener(onExtensionInstalled);
|
||||
|
||||
//----------------------------------- Function definitions ----------------------------------//
|
||||
@@ -78,27 +102,18 @@ async function updateMatchingPasswordsCount(tabId) {
|
||||
}
|
||||
|
||||
// Get tab info
|
||||
let currentDomain = undefined;
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
currentDomain = new URL(tab.url).hostname;
|
||||
settings.host = new URL(tab.url).hostname;
|
||||
} catch (e) {
|
||||
throw new Error(`Unable to determine domain of the tab with id ${tabId}`);
|
||||
}
|
||||
|
||||
let matchedPasswordsCount = 0;
|
||||
for (var storeId in response.data.files) {
|
||||
for (var key in response.data.files[storeId]) {
|
||||
const login = response.data.files[storeId][key].replace(/\.gpg$/i, "");
|
||||
const domain = helpers.pathToDomain(storeId + "/" + login, currentDomain);
|
||||
const inCurrentDomain =
|
||||
currentDomain === domain || currentDomain.endsWith("." + domain);
|
||||
const recent = settings.recent[sha1(currentDomain + sha1(storeId + sha1(login)))];
|
||||
if (recent || inCurrentDomain) {
|
||||
matchedPasswordsCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
const logins = helpers.prepareLogins(response.data.files, settings);
|
||||
const matchedPasswordsCount = logins.reduce(
|
||||
(acc, login) => acc + (login.recent.count || login.inCurrentDomain ? 1 : 0),
|
||||
0
|
||||
);
|
||||
|
||||
if (matchedPasswordsCount) {
|
||||
// Set badge for the current tab
|
||||
|
240
src/helpers.js
240
src/helpers.js
@@ -1,10 +1,14 @@
|
||||
//------------------------------------- Initialisation --------------------------------------//
|
||||
"use strict";
|
||||
|
||||
const FuzzySort = require("fuzzysort");
|
||||
const TldJS = require("tldjs");
|
||||
const sha1 = require("sha1");
|
||||
|
||||
module.exports = {
|
||||
pathToDomain
|
||||
pathToDomain,
|
||||
prepareLogins,
|
||||
filterSortLogins
|
||||
};
|
||||
|
||||
//----------------------------------- Function definitions ----------------------------------//
|
||||
@@ -37,3 +41,237 @@ function pathToDomain(path, currentHost) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare list of logins based on provided files
|
||||
*
|
||||
* @since 3.1.0
|
||||
*
|
||||
* @param string array List of password files
|
||||
* @param string object Settings object
|
||||
* @return array List of logins
|
||||
*/
|
||||
function prepareLogins(files, settings) {
|
||||
const logins = [];
|
||||
let index = 0;
|
||||
|
||||
for (let storeId in files) {
|
||||
for (let key in files[storeId]) {
|
||||
// set login fields
|
||||
const login = {
|
||||
index: index++,
|
||||
store: settings.stores[storeId],
|
||||
login: files[storeId][key].replace(/\.gpg$/i, ""),
|
||||
allowFill: true
|
||||
};
|
||||
login.domain = pathToDomain(storeId + "/" + login.login, settings.host);
|
||||
login.inCurrentDomain =
|
||||
settings.host == login.domain || settings.host.endsWith("." + login.domain);
|
||||
login.recent =
|
||||
settings.recent[sha1(settings.host + sha1(login.store.id + sha1(login.login)))];
|
||||
if (!login.recent) {
|
||||
login.recent = {
|
||||
when: 0,
|
||||
count: 0
|
||||
};
|
||||
}
|
||||
|
||||
logins.push(login);
|
||||
}
|
||||
}
|
||||
|
||||
return logins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter and sort logins
|
||||
*
|
||||
* @since 3.1.0
|
||||
*
|
||||
* @param string array List of logins
|
||||
* @param string object Settings object
|
||||
* @return array Filtered and sorted list of logins
|
||||
*/
|
||||
function filterSortLogins(logins, searchQuery, currentDomainOnly) {
|
||||
var fuzzyFirstWord = searchQuery.substr(0, 1) !== " ";
|
||||
searchQuery = searchQuery.trim();
|
||||
|
||||
// get candidate list
|
||||
var candidates = logins.map(candidate => {
|
||||
let lastSlashIndex = candidate.login.lastIndexOf("/") + 1;
|
||||
return Object.assign(candidate, {
|
||||
path: candidate.login.substr(0, lastSlashIndex),
|
||||
display: candidate.login.substr(lastSlashIndex)
|
||||
});
|
||||
});
|
||||
|
||||
var mostRecent = null;
|
||||
if (currentDomainOnly) {
|
||||
var recent = candidates.filter(function(login) {
|
||||
if (login.recent.count > 0) {
|
||||
// find most recently used login
|
||||
if (!mostRecent || login.recent.when > mostRecent.recent.when) {
|
||||
mostRecent = login;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
var remainingInCurrentDomain = candidates.filter(
|
||||
login => login.inCurrentDomain && !login.recent.count
|
||||
);
|
||||
candidates = recent.concat(remainingInCurrentDomain);
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => {
|
||||
// show most recent first
|
||||
if (a === mostRecent) {
|
||||
return -1;
|
||||
}
|
||||
if (b === mostRecent) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// sort by frequency
|
||||
var countDiff = b.recent.count - a.recent.count;
|
||||
if (countDiff) {
|
||||
return countDiff;
|
||||
}
|
||||
|
||||
// sort by specificity, only if filtering for one domain
|
||||
if (currentDomainOnly) {
|
||||
var domainLevelsDiff =
|
||||
(b.login.match(/\./g) || []).length - (a.login.match(/\./g) || []).length;
|
||||
if (domainLevelsDiff) {
|
||||
return domainLevelsDiff;
|
||||
}
|
||||
}
|
||||
|
||||
// sort alphabetically
|
||||
return a.login.localeCompare(b.login);
|
||||
});
|
||||
|
||||
if (searchQuery.length) {
|
||||
let filter = searchQuery.split(/\s+/);
|
||||
let fuzzyFilter = fuzzyFirstWord ? filter[0] : "";
|
||||
let substringFilters = filter.slice(fuzzyFirstWord ? 1 : 0).map(w => w.toLowerCase());
|
||||
|
||||
// First reduce the list by running the substring search
|
||||
substringFilters.forEach(function(word) {
|
||||
candidates = candidates.filter(c => c.login.toLowerCase().indexOf(word) >= 0);
|
||||
});
|
||||
|
||||
// Then run the fuzzy filter
|
||||
let fuzzyResults = {};
|
||||
if (fuzzyFilter) {
|
||||
candidates = FuzzySort.go(fuzzyFilter, candidates, {
|
||||
keys: ["login", "store.name"],
|
||||
allowTypo: false
|
||||
}).map(result => {
|
||||
fuzzyResults[result.obj.login] = result;
|
||||
return result.obj;
|
||||
});
|
||||
}
|
||||
|
||||
// Finally highlight all matches
|
||||
candidates = candidates.map(c => highlightMatches(c, fuzzyResults, substringFilters));
|
||||
}
|
||||
|
||||
// Prefix root entries with slash to let them have some visible path
|
||||
candidates.forEach(c => {
|
||||
c.path = c.path || "/";
|
||||
});
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
//----------------------------------- Private functions ----------------------------------//
|
||||
|
||||
/**
|
||||
* Highlight filter matches
|
||||
*
|
||||
* @since 3.0.0
|
||||
*
|
||||
* @param object entry password entry
|
||||
* @param object fuzzyResults positions of fuzzy filter matches
|
||||
* @param array substringFilters list of substring filters applied
|
||||
* @return object entry with highlighted matches
|
||||
*/
|
||||
function highlightMatches(entry, fuzzyResults, substringFilters) {
|
||||
// Add all positions of the fuzzy search to the array
|
||||
let matches = (fuzzyResults[entry.login] && fuzzyResults[entry.login][0]
|
||||
? fuzzyResults[entry.login][0].indexes
|
||||
: []
|
||||
).slice();
|
||||
|
||||
// Add all positions of substring searches to the array
|
||||
let login = entry.login.toLowerCase();
|
||||
for (let word of substringFilters) {
|
||||
let startIndex = login.indexOf(word);
|
||||
for (let i = 0; i < word.length; i++) {
|
||||
matches.push(startIndex + i);
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare the final array of matches before
|
||||
matches = sortUnique(matches, (a, b) => a - b);
|
||||
|
||||
const OPEN = "<em>";
|
||||
const CLOSE = "</em>";
|
||||
let highlighted = "";
|
||||
var matchesIndex = 0;
|
||||
var opened = false;
|
||||
for (var i = 0; i < entry.login.length; ++i) {
|
||||
var char = entry.login[i];
|
||||
|
||||
if (i == entry.path.length) {
|
||||
if (opened) {
|
||||
highlighted += CLOSE;
|
||||
}
|
||||
var path = highlighted;
|
||||
highlighted = "";
|
||||
if (opened) {
|
||||
highlighted += OPEN;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches[matchesIndex] === i) {
|
||||
matchesIndex++;
|
||||
if (!opened) {
|
||||
opened = true;
|
||||
highlighted += OPEN;
|
||||
}
|
||||
} else {
|
||||
if (opened) {
|
||||
opened = false;
|
||||
highlighted += CLOSE;
|
||||
}
|
||||
}
|
||||
highlighted += char;
|
||||
}
|
||||
if (opened) {
|
||||
opened = false;
|
||||
highlighted += CLOSE;
|
||||
}
|
||||
let display = highlighted;
|
||||
|
||||
return Object.assign(entry, {
|
||||
path: path,
|
||||
display: display
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort and remove duplicates
|
||||
*
|
||||
* @since 3.0.0
|
||||
*
|
||||
* @param array array items to sort
|
||||
* @param function comparator sort comparator
|
||||
* @return array sorted items without duplicates
|
||||
*/
|
||||
function sortUnique(array, comparator) {
|
||||
return array
|
||||
.sort(comparator)
|
||||
.filter((elem, index, arr) => index == !arr.length || arr[index - 1] != elem);
|
||||
}
|
||||
|
@@ -39,6 +39,12 @@
|
||||
"suggested_key": {
|
||||
"default": "Ctrl+Shift+L"
|
||||
}
|
||||
},
|
||||
"fillBest": {
|
||||
"suggested_key": {
|
||||
"default": "Ctrl+Shift+F"
|
||||
},
|
||||
"description": "Fill form with the best matching credentials"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -42,6 +42,12 @@
|
||||
"suggested_key": {
|
||||
"default": "Ctrl+Shift+L"
|
||||
}
|
||||
},
|
||||
"fillBest": {
|
||||
"suggested_key": {
|
||||
"default": "Ctrl+Shift+F"
|
||||
},
|
||||
"description": "Fill form with the best matching credentials"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,9 +1,9 @@
|
||||
module.exports = Interface;
|
||||
|
||||
const m = require("mithril");
|
||||
const FuzzySort = require("fuzzysort");
|
||||
const Moment = require("moment");
|
||||
const SearchInterface = require("./searchinterface");
|
||||
const helpers = require("../helpers");
|
||||
|
||||
const LATEST_NATIVE_APP_VERSION = 3000003;
|
||||
|
||||
@@ -144,188 +144,11 @@ function view(ctl, params) {
|
||||
/**
|
||||
* Run a search
|
||||
*
|
||||
* @param string s Search string
|
||||
* @param string searchQuery Search query
|
||||
* @return void
|
||||
*/
|
||||
function search(s) {
|
||||
var self = this;
|
||||
var fuzzyFirstWord = s.substr(0, 1) !== " ";
|
||||
s = s.trim();
|
||||
|
||||
// get candidate list
|
||||
var candidates = this.logins.map(candidate => {
|
||||
let lastSlashIndex = candidate.login.lastIndexOf("/") + 1;
|
||||
return Object.assign(candidate, {
|
||||
path: candidate.login.substr(0, lastSlashIndex),
|
||||
display: candidate.login.substr(lastSlashIndex)
|
||||
});
|
||||
});
|
||||
var mostRecent = null;
|
||||
if (this.currentDomainOnly) {
|
||||
var recent = candidates.filter(function(login) {
|
||||
if (login.recent.count > 0) {
|
||||
// find most recently used login
|
||||
if (!mostRecent || login.recent.when > mostRecent.recent.when) {
|
||||
mostRecent = login;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
var remainingInCurrentDomain = candidates.filter(
|
||||
login => login.inCurrentDomain && !login.recent.count
|
||||
);
|
||||
candidates = recent.concat(remainingInCurrentDomain);
|
||||
}
|
||||
candidates.sort((a, b) => {
|
||||
// show most recent first
|
||||
if (a === mostRecent) {
|
||||
return -1;
|
||||
}
|
||||
if (b === mostRecent) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// sort by frequency
|
||||
var countDiff = b.recent.count - a.recent.count;
|
||||
if (countDiff) {
|
||||
return countDiff;
|
||||
}
|
||||
|
||||
// sort by specificity, only if filtering for one domain
|
||||
if (this.currentDomainOnly) {
|
||||
var domainLevelsDiff =
|
||||
(b.login.match(/\./g) || []).length - (a.login.match(/\./g) || []).length;
|
||||
if (domainLevelsDiff) {
|
||||
return domainLevelsDiff;
|
||||
}
|
||||
}
|
||||
|
||||
// sort alphabetically
|
||||
return a.login.localeCompare(b.login);
|
||||
});
|
||||
|
||||
if (s.length) {
|
||||
let filter = s.split(/\s+/);
|
||||
let fuzzyFilter = fuzzyFirstWord ? filter[0] : "";
|
||||
let substringFilters = filter.slice(fuzzyFirstWord ? 1 : 0).map(w => w.toLowerCase());
|
||||
|
||||
// First reduce the list by running the substring search
|
||||
substringFilters.forEach(function(word) {
|
||||
candidates = candidates.filter(c => c.login.toLowerCase().indexOf(word) >= 0);
|
||||
});
|
||||
|
||||
// Then run the fuzzy filter
|
||||
let fuzzyResults = {};
|
||||
if (fuzzyFilter) {
|
||||
candidates = FuzzySort.go(fuzzyFilter, candidates, {
|
||||
keys: ["login", "store.name"],
|
||||
allowTypo: false
|
||||
}).map(result => {
|
||||
fuzzyResults[result.obj.login] = result;
|
||||
return result.obj;
|
||||
});
|
||||
}
|
||||
|
||||
// Finally highlight all matches
|
||||
candidates = candidates.map(c => highlightMatches(c, fuzzyResults, substringFilters));
|
||||
}
|
||||
|
||||
// Prefix root entries with slash to let them have some visible path
|
||||
candidates.forEach(c => {
|
||||
c.path = c.path || "/";
|
||||
});
|
||||
|
||||
this.results = candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight filter matches
|
||||
*
|
||||
* @since 3.0.0
|
||||
*
|
||||
* @param object entry password entry
|
||||
* @param object fuzzyResults positions of fuzzy filter matches
|
||||
* @param array substringFilters list of substring filters applied
|
||||
* @return object entry with highlighted matches
|
||||
*/
|
||||
function highlightMatches(entry, fuzzyResults, substringFilters) {
|
||||
// Add all positions of the fuzzy search to the array
|
||||
let matches = (fuzzyResults[entry.login] && fuzzyResults[entry.login][0]
|
||||
? fuzzyResults[entry.login][0].indexes
|
||||
: []
|
||||
).slice();
|
||||
|
||||
// Add all positions of substring searches to the array
|
||||
let login = entry.login.toLowerCase();
|
||||
for (let word of substringFilters) {
|
||||
let startIndex = login.indexOf(word);
|
||||
for (let i = 0; i < word.length; i++) {
|
||||
matches.push(startIndex + i);
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare the final array of matches before
|
||||
matches = sortUnique(matches, (a, b) => a - b);
|
||||
|
||||
const OPEN = "<em>";
|
||||
const CLOSE = "</em>";
|
||||
let highlighted = "";
|
||||
var matchesIndex = 0;
|
||||
var opened = false;
|
||||
for (var i = 0; i < entry.login.length; ++i) {
|
||||
var char = entry.login[i];
|
||||
|
||||
if (i == entry.path.length) {
|
||||
if (opened) {
|
||||
highlighted += CLOSE;
|
||||
}
|
||||
var path = highlighted;
|
||||
highlighted = "";
|
||||
if (opened) {
|
||||
highlighted += OPEN;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches[matchesIndex] === i) {
|
||||
matchesIndex++;
|
||||
if (!opened) {
|
||||
opened = true;
|
||||
highlighted += OPEN;
|
||||
}
|
||||
} else {
|
||||
if (opened) {
|
||||
opened = false;
|
||||
highlighted += CLOSE;
|
||||
}
|
||||
}
|
||||
highlighted += char;
|
||||
}
|
||||
if (opened) {
|
||||
opened = false;
|
||||
highlighted += CLOSE;
|
||||
}
|
||||
let display = highlighted;
|
||||
|
||||
return Object.assign(entry, {
|
||||
path: path,
|
||||
display: display
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort and remove duplicates
|
||||
*
|
||||
* @since 3.0.0
|
||||
*
|
||||
* @param array array items to sort
|
||||
* @param function comparator sort comparator
|
||||
* @return array sorted items without duplicates
|
||||
*/
|
||||
function sortUnique(array, comparator) {
|
||||
return array
|
||||
.sort(comparator)
|
||||
.filter((elem, index, arr) => index == !arr.length || arr[index - 1] != elem);
|
||||
function search(searchQuery) {
|
||||
this.results = helpers.filterSortLogins(this.logins, searchQuery, this.currentDomainOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -2,7 +2,6 @@
|
||||
"use strict";
|
||||
|
||||
require("chrome-extension-async");
|
||||
const sha1 = require("sha1");
|
||||
const Interface = require("./interface");
|
||||
const helpers = require("../helpers");
|
||||
|
||||
@@ -58,34 +57,11 @@ async function run() {
|
||||
throw new Error(response.message);
|
||||
}
|
||||
|
||||
var logins = [];
|
||||
var index = 0;
|
||||
for (var storeId in response.files) {
|
||||
for (var key in response.files[storeId]) {
|
||||
// set login fields
|
||||
var login = {
|
||||
index: index++,
|
||||
store: settings.stores[storeId],
|
||||
login: response.files[storeId][key].replace(/\.gpg$/i, ""),
|
||||
allowFill: true
|
||||
};
|
||||
login.domain = helpers.pathToDomain(storeId + "/" + login.login, settings.host);
|
||||
login.inCurrentDomain =
|
||||
settings.host == login.domain || settings.host.endsWith("." + login.domain);
|
||||
login.recent =
|
||||
settings.recent[sha1(settings.host + sha1(login.store.id + sha1(login.login)))];
|
||||
if (!login.recent) {
|
||||
login.recent = {
|
||||
when: 0,
|
||||
count: 0
|
||||
};
|
||||
}
|
||||
// bind handlers
|
||||
login.doAction = withLogin.bind({ settings: settings, login: login });
|
||||
|
||||
logins.push(login);
|
||||
}
|
||||
const logins = helpers.prepareLogins(response.files, settings);
|
||||
for (let login of logins) {
|
||||
login.doAction = withLogin.bind({ settings: settings, login: login });
|
||||
}
|
||||
|
||||
var popup = new Interface(settings, logins);
|
||||
popup.attach(document.body);
|
||||
} catch (e) {
|
||||
|
Reference in New Issue
Block a user