diff options
author | Marius David Wieschollek <passwords.public@mdns.eu> | 2021-04-11 11:25:42 +0300 |
---|---|---|
committer | Marius David Wieschollek <passwords.public@mdns.eu> | 2021-04-11 11:25:42 +0300 |
commit | 38c0e18a18a29b07dd7e14b0955a9640e8175f8d (patch) | |
tree | 9b68c6812701fbee437a39faff49301d40e3f523 | |
parent | e11c309c22118850576016a7849882a903094734 (diff) | |
parent | 6ebfb621d5d25b3d679f1a1a244307cf9e0c0a97 (diff) |
Merge #167flo-mic-view-edit-password
Signed-off-by: Marius David Wieschollek <passwords.public@mdns.eu>
21 files changed, 1636 insertions, 222 deletions
diff --git a/src/js/Controller/Client/FillPassword.js b/src/js/Controller/Client/FillPassword.js index c227b64..3aa416e 100644 --- a/src/js/Controller/Client/FillPassword.js +++ b/src/js/Controller/Client/FillPassword.js @@ -11,7 +11,7 @@ export default class FillPassword extends AbstractController { */ async execute(message, reply) { try { - let result = this._fillPassword(message.getPayload().user, message.getPayload().password, message.getPayload().submit); + let result = this._fillPassword(message.getPayload().user, message.getPayload().password, message.getPayload().submit, message.getPayload().formFields); if(result) reply.setPayload(true); } catch(e) { @@ -24,12 +24,15 @@ export default class FillPassword extends AbstractController { * @param {String} user * @param {String} password * @param {Boolean} trySubmit + * @param {Array} formFields * @returns {Boolean} */ - _fillPassword(user, password, trySubmit) { + _fillPassword(user, password, trySubmit, formFields) { let forms = new FormService().getLoginFields(); if(forms.length === 0) return false; + this._fillCustomForms(formFields); + for(let i = 0; i < forms.length; i++) { let form = forms[i]; if(form.user) this._insertTextIntoField(form.user, user); @@ -46,6 +49,22 @@ export default class FillPassword extends AbstractController { return true; } + /** + * + * @param {Array} formFields + */ + _fillCustomForms(formFields) { + formFields.forEach((field) => { + var element = document.getElementById(field.id); + if(element !== null && element !== undefined) { + if(!element.readOnly && !element.disabled && !element.hidden) { + this._insertTextIntoField(element, field.value); + } + } + }) + + } + /** * * @param {Element} field diff --git a/src/js/Controller/Password/Fill.js b/src/js/Controller/Password/Fill.js index 13fc853..176931a 100644 --- a/src/js/Controller/Password/Fill.js +++ b/src/js/Controller/Password/Fill.js @@ -6,6 +6,7 @@ import SettingsService from '@js/Services/SettingsService'; import ErrorManager from '@js/Manager/ErrorManager'; import PasswordStatisticsService from "@js/Services/PasswordStatisticsService"; import Message from "@js/Models/Message/Message"; +import AutofillManager from "@js/Manager/AutofillManager"; export default class Fill extends AbstractController { @@ -37,6 +38,7 @@ export default class Fill extends AbstractController { payload : { user : password.getUserName(), password: password.getPassword(), + formFields: AutofillManager.getCustomFormFields(password), submit : await SettingsService.getValue('paste.form.submit') } } diff --git a/src/js/Controller/Password/Update.js b/src/js/Controller/Password/Update.js new file mode 100644 index 0000000..cdbcef5 --- /dev/null +++ b/src/js/Controller/Password/Update.js @@ -0,0 +1,124 @@ +import AbstractController from '@js/Controller/AbstractController'; +import SearchIndex from '@js/Search/Index/SearchIndex'; +import ApiRepository from "@js/Repositories/ApiRepository"; +import ToastService from "@js/Services/ToastService"; +import ErrorManager from "@js/Manager/ErrorManager"; +import HiddenFolderHelper from "@js/Helper/HiddenFolderHelper"; +import SearchQuery from '@js/Search/Query/SearchQuery'; + +export default class Update extends AbstractController { + + async execute(message, reply) { + let {data} = message.getPayload(), + query = new SearchQuery(), + password = /** @type {EnhancedPassword} **/ query + .where(query.field('id').equals(data.id)) + .hidden(true|false) + .execute()[0], + api = /** @type {PasswordsClient} **/ await ApiRepository.findById(password.getServer().getId()); + + if(password !== null && !password.isTrashed()) { + password + .setFavorite(this._setProperty('favorite', data, password)) + .setLabel(this._setProperty('label', data, password)) + .setUserName(this._setProperty('username', data, password)) + .setPassword(this._setProperty('password', data, password)) + .setUrl(this._setProperty('url', data, password)) + .setEdited(this._setEdited(data, password)) + .setCustomFields(this._setProperty('customFields', data, password)) + .setNotes(this._setProperty('notes', data, password)) + .setFolder(await this._setFolder(api, data, password)) + .setHidden(this._setProperty('hidden', data, password)); + + await this._updatePassword(api, password); + reply.setPayload({success: true, data: data}); + } else { + reply.setPayload({success: false}); + this._returnError() + } + } + + /** + * + * @param {String} property + * @param {JSON} data + * @param {EnhancedPassword} password + * @returns {*} + * @private + */ + _setProperty(property, data, password) { + if(data.hasOwnProperty(property)) { + return data[property]; + } else { + return password.getProperty(property); + } + } + + /** + * + * @param {JSON} data + * @param {EnhancedPassword} password + * @returns {Date} + * @private + */ + _setEdited(data, password) { + var updateEdited = false; + for(var item in data) { + if(item !== 'id' && item !== 'favorite') { + updateEdited = true; + } + } + return updateEdited ? new Date():password.getEdited(); + } + + /** + * + * @param {String} property + * @param {JSON} data + * @param {EnhancedPassword} password + * @returns {String} + * @private + */ + async _setFolder(api, data, password) { + if(!data.hasOwnProperty('hidden')) { + return password.getFolder(); + } + + let helper = new HiddenFolderHelper(); + var hiddenFolder = await helper.getHiddenFolderId(api); + if(data.hidden && password.getFolder() !== hiddenFolder) { + return hiddenFolder; + } else if(!data.hidden && password.getFolder() === hiddenFolder) { + return "00000000-0000-0000-0000-000000000000"; + } + return password.getFolder(); + } + + /** + * + * @param {PasswordsClient} api + * @param {EnhancedPassword} password + * @private + */ + async _updatePassword(api, password) { + let repository = /** @type {PasswordRepository} **/ api.getInstance('repository.password'); + await repository.update(password); + password = await repository.findById(password.getId()); + + SearchIndex.removeItem(password); + SearchIndex.addItem(password, true); + ToastService.success('ToastPasswordUpdated') + .catch(ErrorManager.catch); + } + + /** + * + * @private + */ + _returnError() { + ToastService + .error('ToastPasswordUpdateFailed') + .catch(ErrorManager.catch); + } + +}
\ No newline at end of file diff --git a/src/js/Manager/AutofillManager.js b/src/js/Manager/AutofillManager.js index 16f122c..f278e8c 100644 --- a/src/js/Manager/AutofillManager.js +++ b/src/js/Manager/AutofillManager.js @@ -91,11 +91,34 @@ export default new class AutofillManager { tab : TabManager.currentTabId, silent : true, payload : { - user : password.getUserName(), - password: password.getPassword(), - submit : false + user : password.getUserName(), + password : password.getPassword(), + formFields: this.getCustomFormFields(password), + submit : false } } ).catch(ErrorManager.catchEvt); } + + /** + * + * @param {Password} password + * @returns {Array} + * @private + */ + getCustomFormFields(password) { + var formFields = []; + var customFields = password.getCustomFields(); + customFields._elements.forEach((e) => { + if(e.getType() === 'data' && e.getLabel().startsWith('ext:field/')) { + formFields.push( + { + id : e.getLabel().replace('ext:field/', ''), + value: e.getValue() + } + ) + } + }) + return formFields; + } }; diff --git a/src/js/Manager/ControllerManager.js b/src/js/Manager/ControllerManager.js index 51e31fa..c8e0691 100644 --- a/src/js/Manager/ControllerManager.js +++ b/src/js/Manager/ControllerManager.js @@ -68,6 +68,13 @@ class ControllerManager { } ); MessageService.listen( + 'password.update', + async (message, reply) => { + let module = await import(/* webpackChunkName: "PasswordUpdate" */ '@js/Controller/Password/Update'); + await this._executeController(module, message, reply); + } + ); + MessageService.listen( 'folder.list', async (message, reply) => { let module = await import(/* webpackChunkName: "FolderList" */ '@js/Controller/Folder/List'); diff --git a/src/js/Manager/MiningManager.js b/src/js/Manager/MiningManager.js index ccb2eaf..59f9691 100644 --- a/src/js/Manager/MiningManager.js +++ b/src/js/Manager/MiningManager.js @@ -85,7 +85,11 @@ class MiningManager { .setTaskField('username', data.user.value) .setTaskField('password', data.password.value) .setTaskField('url', data.url) + .setTaskField('notes', '') .setTaskField('hidden', hidden) + .setTaskField('created', '') + .setTaskField('edited', '') + .setTaskField('customFields', []) .setTaskManual(data.manual) .setTaskNew(true); @@ -96,6 +100,10 @@ class MiningManager { .setTaskField('label', basePassword.getLabel()) .setTaskField('url', basePassword.getUrl()) .setTaskField('hidden', basePassword.getHidden()) + .setTaskField('notes', basePassword.getNotes()) + .setTaskField('created', basePassword.getCreated()) + .setTaskField('edited', basePassword.getEdited()) + .setTaskField('customFields', basePassword.getCustomFields()) .setTaskNew(false); } } @@ -151,6 +159,7 @@ class MiningManager { password.setFolder(await helper.getHiddenFolderId(api)); } + password = this._enforcePasswordPropertyLengths(password); await api.getPasswordRepository().create(password); SearchIndex.addItem(password); @@ -167,6 +176,7 @@ class MiningManager { query = new SearchQuery(), password = /** @type {EnhancedPassword} **/ query .where(query.field('id').equals(task.getResultField('id'))) + .hidden(true|false) .execute()[0]; password @@ -175,15 +185,19 @@ class MiningManager { .setPassword(task.getResultField('password')) .setUrl(task.getResultField('url')) .setEdited(new Date()) + .setCustomFields(task.getResultField('customFields')) + .setNotes(task.getResultField('notes')) + .setFolder(await this._setFolder(api, task, password)) .setHidden(task.getResultField('hidden')); - this._enforcePasswordPropertyLengths(password); + password = this._enforcePasswordPropertyLengths(password); if(password.isHidden()) { let helper = new HiddenFolderHelper(); password.setFolder(await helper.getHiddenFolderId(api)); } await api.getPasswordRepository().update(password); + password = await api.getPasswordRepository().findById(password.getId()); SearchIndex.removeItem(password); SearchIndex.addItem(password); @@ -192,6 +206,29 @@ class MiningManager { } /** + * + * @param {String} property + * @param {MiningItem} task + * @param {EnhancedPassword} password + * @returns {EnhancedPassword} + * @private + */ + async _setFolder(api, task, password) { + if(task.getResultField('hidden') === password.getHidden()) { + return password.getFolder(); + } + + let helper = new HiddenFolderHelper(); + var hiddenFolder = await helper.getHiddenFolderId(api); + if(task.getResultField('hidden') && password.getFolder() !== hiddenFolder) { + return hiddenFolder; + } else if(!task.getResultField('hidden') && password.getFolder() === hiddenFolder) { + return "00000000-0000-0000-0000-000000000000"; + } + return password.getFolder(); + } + + /** * @param {Object} data * @return {Boolean} */ @@ -293,6 +330,7 @@ class MiningManager { /** * * @param {Password} password + * @returns {Password} * @private */ _enforcePasswordPropertyLengths(password) { @@ -308,6 +346,72 @@ class MiningManager { if(password.getUrl().length > 2048) { password.setUrl(password.getUrl().substr(0, 2048)); } + if(password.getNotes().length > 4096) { + password.setNotes(password.getNotes().substr(0, 4096)); + } + return this._enforcePasswordCustomPropertyLengths(password); + } + + /** + * + * @param {Password} password + * @returns {Password} + * @private + */ + _enforcePasswordCustomPropertyLengths(password) { + let customFields = password.getCustomFields(); + if(Array.isArray(customFields._elements)) { + password.setCustomFields(this._enforcePasswordCustomPropertyLengthsInObject(customFields)); + } else { + password.setCustomFields(this._enforcePasswordCustomPropertyLengthsInArray(customFields)); + } + return password; + } + + /** + * + * @param {Array} fields + * @returns {Array} + * @private + */ + _enforcePasswordCustomPropertyLengthsInArray(fields) { + for(var i = 0; i < fields.length; i++) { + if((fields[i].label === "" && fields[i].value === "" && fields[i].type !== "data" && fields[i].type !== "file") + || fields[i].label === "ext:field/" && fields[i].type === 'data') { + + fields.splice(i, 1); + i--; + } + } + if(fields.length > 0) { + while(JSON.stringify(fields).length > 8192) { + fields.pop() + } + } + return fields; + } + + /** + * + * @param {Object} fields + * @returns {Object} + * @private + */ + _enforcePasswordCustomPropertyLengthsInObject(fields) { + for(var i = 0; i < fields.length; i++) { + var field = fields.get(i); + if((field.getLabel() === "" && field.getValue() === "" && field.getType() !== "data" && field.getType() !== "file") + || field.getLabel() === "ext:field/" && field.getType() === 'data') { + fields._elements.splice(i, 1); + i--; + } + } + if(fields.length > 0) { + while(JSON.stringify(fields).length > 8192) { + fields._elements.splice(fields.length -1, 1); + } + } + return fields; } } diff --git a/src/platform/generic/_locales/de/messages.json b/src/platform/generic/_locales/de/messages.json index fdb9d81..d814e24 100644 --- a/src/platform/generic/_locales/de/messages.json +++ b/src/platform/generic/_locales/de/messages.json @@ -243,6 +243,10 @@ "message" : "In den Papierkorb verschieben", "description": "Title of the menu option to move a password to the trash" }, + "PasswordItemViewEdit" : { + "message" : "Passwort ansehen/bearbeiten", + "description": "Title of the menu option to view and edit a password" + }, "ValidationLabel" : { "message" : "Titel", "description": "Name of the account label field in account validation message. This value is used when the user attempts to save an account with an invalid label" @@ -455,6 +459,26 @@ "message" : "Ordner", "description": "Label for the folder count in the server info in the browse tab" }, + "LabelFolder" : { + "message" : "Ordner", + "description": "Label for the folder of this password." + }, + "LabelCreated" : { + "message" : "Erstellt", + "description": "Label for the creation time of this password." + }, + "LabelEdited" : { + "message" : "Geändert", + "description": "Label for the last edit time of this password." + }, + "LabelNotes" : { + "message" : "Notitzen", + "description": "Label for the notes of this password." + }, + "LabelCustomFields" : { + "message" : "Eigene Felder", + "description": "Label for the custom fields of this password" + }, "LabelTags" : { "message" : "Tags", "description": "Label for the tag count in the server info in the browse tab" @@ -641,6 +665,14 @@ } } }, + "ToastPasswordUpdated" : { + "message" : "Passwort aktualisiert", + "description": "Text of the toast notification when password was updated" + }, + "ToastPasswordUpdateFailed" : { + "message" : "Passwort aktualisierung fehlgeschlagen", + "description": "Text of the toast notification when password update failed" + }, "PasswordPastedSuccess" : { "message" : "$LABEL$ eingefügt", "description" : "Text of the toast notification when a password entry was pasted successfully into the current tab", @@ -1314,5 +1346,39 @@ "InputSliderOff" : { "message" : "ausgeschaltet", "description": "Tooltip of any slider input element that is currently in the inactive/disabled state" + }, + "PasswordCustomFieldsTypeText" : { + "message" : "Text", + "description": "Label of the password custom setting type 'text'." + }, + "PasswordCustomFieldsTypeSecret" : { + "message" : "Geheimnis", + "description": "Label of the password custom setting type 'secret'." + }, + "PasswordCustomFieldsTypeEmail" : { + "message" : "Email", + "description": "Label of the password custom setting type 'email'." + }, + "PasswordCustomFieldsTypeUrl" : { + "message" : "Link", + "description": "Label of the password custom setting type 'url'." + }, + "PasswordCustomFieldsTypeFormField" : { + "message" : "Form Feld", + "description": "Label of the password custom setting form field to insert custom values to a web page." + }, + "PasswordEditInvalidValue" : { + "message" : "Ungültiger Wert", + "description": "Label of the password edit 'invalid value' message." + }, + "PasswordEditMaxAllowedCharacter" : { + "message" : "Maximal $CHARACTERS$ Zeichen erlaubt", + "description": "Label of the password edit max allowed character message.", + "placeholders": { + "characters": { + "content": "$1", + "example": "One of 1, 48, 320, 370, 2048 or 8192" + } + } } } diff --git a/src/platform/generic/_locales/en/messages.json b/src/platform/generic/_locales/en/messages.json index 619b7a2..204b9e8 100644 --- a/src/platform/generic/_locales/en/messages.json +++ b/src/platform/generic/_locales/en/messages.json @@ -257,6 +257,10 @@ "message" : "Move to trash", "description": "Title of the menu option to move a password to the trash" }, + "PasswordItemViewEdit" : { + "message" : "View/edit password", + "description": "Title of the menu option to view and edit a password" + }, "ValidationLabel" : { "message" : "label", "description": "Name of the account label field in account validation message. This value is used when the user attempts to save an account with an invalid label" @@ -469,6 +473,26 @@ "message" : "Folders", "description": "Label for the folder count in the server info in the browse tab" }, + "LabelFolder" : { + "message" : "Folder", + "description": "Label for the folder of this password." + }, + "LabelCreated" : { + "message" : "Created", + "description": "Label for the creation time of this password." + }, + "LabelEdited" : { + "message" : "Modified", + "description": "Label for the last edit time of this password." + }, + "LabelNotes" : { + "message" : "Notes", + "description": "Label for the notes of this password." + }, + "LabelCustomFields" : { + "message" : "Custom Fields", + "description": "Label for the custom fields of this password" + }, "LabelTags" : { "message" : "Tags", "description": "Label for the tag count in the server info in the browse tab" @@ -665,6 +689,14 @@ } } }, + "ToastPasswordUpdated" : { + "message" : "Password updated", + "description": "Text of the toast notification when password was updated" + }, + "ToastPasswordUpdateFailed" : { + "message" : "Password update failed", + "description": "Text of the toast notification when password update failed" + }, "PasswordPastedError" : { "message" : "Could not paste $LABEL$", "description" : "Text of the toast notification when a password could not be pasted into the current tab", @@ -1328,5 +1360,39 @@ "InputSliderOff" : { "message" : "Currently off", "description": "Tooltip of any slider input element that is currently in the inactive/disabled state" + }, + "PasswordCustomFieldsTypeText" : { + "message" : "Text", + "description": "Label of the password custom setting type 'text'." + }, + "PasswordCustomFieldsTypeSecret" : { + "message" : "Secret", + "description": "Label of the password custom setting type 'secret'." + }, + "PasswordCustomFieldsTypeEmail" : { + "message" : "Email", + "description": "Label of the password custom setting type 'email'." + }, + "PasswordCustomFieldsTypeUrl" : { + "message" : "Link", + "description": "Label of the password custom setting type 'url'." + }, + "PasswordCustomFieldsTypeFormField" : { + "message" : "Form field", + "description": "Label of the password custom setting form field to insert custom values to a web page." + }, + "PasswordEditInvalidValue" : { + "message" : "Invalid value", + "description": "Label of the password edit 'invalid value' message." + }, + "PasswordEditMaxAllowedCharacter" : { + "message" : "Only $CHARACTERS$ characters allowed", + "description": "Label of the password edit max allowed character message.", + "placeholders": { + "characters": { + "content": "$1", + "example": "One of 1, 48, 320, 370, 2048 or 8192" + } + } } } diff --git a/src/vue/App/Popup.vue b/src/vue/App/Popup.vue index 7030c45..14bcee6 100644 --- a/src/vue/App/Popup.vue +++ b/src/vue/App/Popup.vue @@ -112,8 +112,11 @@ .send({type: 'popup.status.set', payload: {tab}}); }, searchEvent($event) { - this.search.query = $event; - this.$refs.tabs.setActive('search'); + var pwdViews = document.getElementsByClassName("password-view") + if(pwdViews.length === 0) { + this.search.query = $event; + this.$refs.tabs.setActive('search'); + } } } }; diff --git a/src/vue/Components/Collected/MinedProperty.vue b/src/vue/Components/Collected/MinedProperty.vue deleted file mode 100644 index 78541e5..0000000 --- a/src/vue/Components/Collected/MinedProperty.vue +++ /dev/null @@ -1,149 +0,0 @@ -<template> - <div :class="classList"> - <label :for="id" @dblclick="edit()" :title="title">{{ label }}</label> - <div @dblclick="edit()" v-if="!editing" :title="title">{{ text }}</div> - <input-field :id="id" v-model="value" @keypress="checkEnter($event)" title="TitleEnterToExit" v-else-if="type === 'text'"/> - <slider-field :id="id" v-model="value" v-else/> - </div> -</template> - -<script> - import LocalisationService from '@js/Services/LocalisationService'; - import MiningItem from '@js/Models/Queue/MiningItem'; - import MessageService from '@js/Services/MessageService'; - import InputField from '@vue/Components/Form/InputField'; - import SliderField from "@vue/Components/Form/SliderField"; - - export default { - components: {SliderField, InputField}, - props : { - item : { - type: MiningItem - }, - field: { - type: String - } - }, - - data() { - return { - editing: this.field === 'hidden', - value : this.item.getResultField(this.field) - }; - }, - - computed: { - label() { - if(['label', 'url', 'username', 'password', 'hidden'].indexOf(this.field) !== -1) { - return LocalisationService.translate(`Label${this.field.capitalize()}`); - } - - return this.field; - }, - text() { - return this.field === 'password' ? '':this.value; - }, - classList() { - return `mining-property field-${this.field}`; - }, - id() { - return `property-${this.field}`; - }, - type() { - return this.field === 'hidden' ? 'checkbox':'text'; - }, - title() { - if(this.field === 'hidden') return ''; - return this.editing ? LocalisationService.translate('TitleEnterToExit'):LocalisationService.translate('TitleClickToEdit'); - } - }, - - methods: { - edit() { - this.editing = true; - }, - checkEnter($event) { - if($event.key === 'Enter') { - $event.preventDefault(); - this.editing = false; - this.item.setResultField(this.field, this.value); - - MessageService - .send({type: 'popup.mining.update', payload: this.item}); - } - } - }, - watch : { - value(value) { - this.item.setResultField(this.field, value); - } - } - - }; -</script> - -<style lang="scss"> -.mining-property { - padding : .5rem; - - label { - cursor : pointer; - display : block; - font-weight : bold; - color : var(--element-active-fg-color) - } - - div { - cursor : pointer; - padding : .25rem; - box-sizing : border-box; - border-radius : 3px; - border : none; - line-height : 2rem; - height : 2.5rem; - white-space : nowrap; - text-overflow : ellipsis; - overflow : hidden; - box-shadow : 0 0 0 1px transparent; - transition : box-shadow .15s ease-in-out; - color : var(--element-fg-color); - - &:hover { - box-shadow : 0 0 0 1px var(--element-hover-bg-color); - } - } - - input { - width : 100%; - padding : .25rem; - box-sizing : border-box; - box-shadow : 0 0 0 1px var(--element-active-fg-color); - border-radius : 3px; - border : none; - line-height : 2rem; - background-color : var(--element-bg-color); - color : var(--element-fg-color); - } - - &.field-password { - div { - font-family : "Font Awesome 5 Free", sans-serif; - font-size : .5rem; - letter-spacing : .25rem; - font-weight : bold; - } - } - - &.field-hidden { - display : flex; - - label { - flex-grow : 1; - } - - .input-slider { - margin-top : 2px; - } - } -} -</style>
\ No newline at end of file diff --git a/src/vue/Components/Collected/MiningItem.vue b/src/vue/Components/Collected/MiningItem.vue deleted file mode 100644 index b43a7a8..0000000 --- a/src/vue/Components/Collected/MiningItem.vue +++ /dev/null @@ -1,54 +0,0 @@ -<template> - <div class="mining-item"> - <translate say="MiningItemIsNew" class="create-info" v-if="item.isNew()"> - <icon slot="before" icon="info-circle" font="solid"/> - </translate> - <translate say="MiningItemIsUpdate" class="create-info" :variables="updateVariables" v-else> - <icon slot="before" icon="info-circle" font="solid"/> - </translate> - <mined-property :field="field" :item="item" v-for="field in fields" :key="field"/> - </div> -</template> - -<script> - import MiningItem from '@js/Models/Queue/MiningItem'; - import MinedProperty from '@vue/Components/Collected/MinedProperty'; - import Translate from '@vue/Components/Translate'; - import Icon from '@vue/Components/Icon'; - - export default { - components: {Icon, Translate, MinedProperty}, - props : { - item: { - type: MiningItem - } - }, - computed : { - updateVariables() { - return [ - this.item.getTask().fields.label - ]; - }, - fields() { - let resultFields = this.item.listResultFields(), - fields = []; - - for(let resultField of resultFields) { - if(resultField !== 'id') fields.push(resultField); - } - - return fields; - } - } - }; -</script> - -<style lang="scss"> -.mining-item { - .create-info { - display : block; - padding : 1rem .5rem .25rem .5rem; - color : var(--element-active-fg-color); - } -} -</style>
\ No newline at end of file diff --git a/src/vue/Components/Form/InputField.vue b/src/vue/Components/Form/InputField.vue index 65b3e94..e902f54 100644 --- a/src/vue/Components/Form/InputField.vue +++ b/src/vue/Components/Form/InputField.vue @@ -1,9 +1,21 @@ <template> - <input :type="type" + <textarea v-if="type === 'textarea'" + :value="value" + :placeholder="getPlaceholder" + :title="getTitle" + :readonly="readonly" + filled + auto-grow + v-on="listeners" + @input="handleInput" + @change="handleChange"/> + <input v-else + :type="type" :value="value" :checked="isChecked" :placeholder="getPlaceholder" :title="getTitle" + :readonly="readonly" v-on="listeners" @input="handleInput" @change="handleChange"/> @@ -34,6 +46,10 @@ title : { type : String, default: '' + }, + readonly : { + type : Boolean, + default: false } }, diff --git a/src/vue/Components/Form/SliderField.vue b/src/vue/Components/Form/SliderField.vue index 729c113..e1acbf4 100644 --- a/src/vue/Components/Form/SliderField.vue +++ b/src/vue/Components/Form/SliderField.vue @@ -6,6 +6,7 @@ ref="checkbox" :id="id" :name="name" + :readonly="readonly" v-model="model" v-on="listeners" /> </span> @@ -31,6 +32,10 @@ name : { type : String, default: undefined + }, + readonly: { + type : Boolean, + default: false } }, @@ -66,7 +71,9 @@ methods: { toggleSwitch() { - this.model = !this.model; + if(this.readonly === false) { + this.model = !this.model; + } } }, diff --git a/src/vue/Components/Icon.vue b/src/vue/Components/Icon.vue index cdbc5b4..b8f6a2f 100644 --- a/src/vue/Components/Icon.vue +++ b/src/vue/Components/Icon.vue @@ -50,7 +50,6 @@ let icon = this.hover && this.hoverIcon !== null ? this.hoverIcon:this.icon, font = this.hover && this.hoverFont !== null ? this.hoverFont:this.font, style = font === 'solid' ? 'fas':'far'; - if(this.spin) style += ' fa-spin'; return `icon icon-${icon} font-${font} ${style} fa-${icon}`; diff --git a/src/vue/Components/List/Item/Menu/PasswordMenu.vue b/src/vue/Components/List/Item/Menu/PasswordMenu.vue index 93b0ef8..f343694 100644 --- a/src/vue/Components/List/Item/Menu/PasswordMenu.vue +++ b/src/vue/Components/List/Item/Menu/PasswordMenu.vue @@ -4,6 +4,9 @@ <icon icon="globe-europe" font="solid" slot="before"/> <icon class="option" icon="clipboard" slot="after" @click.stop="$emit('copy', 'url')"/> </translate> + <translate tag="div" class="menu-item" say="PasswordItemViewEdit" @click="$emit('toggleEntry')"> + <icon icon="file-alt" font="solid" slot="before"/> + </translate> <translate tag="div" class="menu-item" say="PasswordItemToTrash" @click="moveToTrash"> <icon icon="trash" font="solid" slot="before"/> </translate> diff --git a/src/vue/Components/List/Item/Password.vue b/src/vue/Components/List/Item/Password.vue index e5c1163..0b154b8 100644 --- a/src/vue/Components/List/Item/Password.vue +++ b/src/vue/Components/List/Item/Password.vue @@ -1,6 +1,6 @@ <template> <li class="item password-item"> - <div class="item-main" :class="{'has-menu':showMenu}"> + <div class="item-main" :class="{'has-menu':(showMenu || showEntry ? true : false)}"> <div class="label" @click="sendPassword()" :title="title" v-on:transitionend="calculateOverflow"> <favicon :password="password.getId()" :size="32" v-if="favicon" /> <div ref="scrollContainer" class="scroll-container" :style="titleVars"> @@ -10,11 +10,12 @@ <div class="options"> <icon icon="user" hover-icon="clipboard" @click="copy('username')" draggable="true" @dragstart="drag($event, 'username')" /> <icon icon="key" font="solid" hover-icon="clipboard" hover-font="regular" @click="copy('password')" draggable="true" @dragstart="drag($event, 'password')" /> - <icon icon="ellipsis-h" font="solid" @click="showMenu = !showMenu" /> + <icon icon="ellipsis-h" font="solid" @click="toggleMenu()" /> </div> <icon :class="securityClass" icon="shield-alt" font="solid" /> </div> - <password-menu :show="showMenu" :password="password" v-on:copy="copy($event)" v-on:delete="$emit('delete', password)" /> + <password-menu :show="showMenu" :password="password" v-on:copy="copy($event)" v-on:delete="$emit('delete', password)" v-on:toggleEntry="toggleEntry()"/> + <password-view v-if="showEntry" :password="password" v-on:toggleEntry="toggleEntry()"/> </li> </template> @@ -30,9 +31,10 @@ import PasswordSettingsManager from '@js/Manager/PasswordSettingsManager'; import Translate from '@vue/Components/Translate'; import PasswordMenu from '@vue/Components/List/Item/Menu/PasswordMenu'; + import PasswordView from '@vue/Components/Password/View'; export default { - components: {PasswordMenu, Translate, Favicon, Icon}, + components: {PasswordMenu, Translate, Favicon, Icon, PasswordView}, props : { password: { type: Password @@ -51,7 +53,8 @@ return { active : true, showMenu: false, - overflow: 0 + overflow: 0, + showEntry: false }; }, @@ -140,6 +143,17 @@ drag(event, property) { let data = this.password.getProperty(property); event.dataTransfer.setData('text/plain', data); + }, + toggleMenu() { + if(this.showEntry === true) { + this.showEntry = false; + } else { + this.showMenu = !this.showMenu; + } + }, + toggleEntry() { + this.showMenu = false; + this.showEntry = !this.showEntry; } } }; diff --git a/src/vue/Components/Password/CustomProperty.vue b/src/vue/Components/Password/CustomProperty.vue new file mode 100644 index 0000000..1e05467 --- /dev/null +++ b/src/vue/Components/Password/CustomProperty.vue @@ -0,0 +1,338 @@ +<template> + <div :class="classList" v-if="showField"> + <div :class="'property-label' + activeClassName"> + <input-field :class="labelClassName" :readonly="!editable" v-model="label"/> + <select-field v-if="editable" :class="activeClassName" v-model="type" :options="customTypeOptions"/> + </div> + <label v-if="labelError" class="error">{{labelErrorText}}</label> + <div class="property-value"> + <a v-if="type === 'url' && !editable" :href="value">{{value}}</a> + <input-field ref="value" v-else :class="valueClassName" @click="copy(field.name)" :type="getInputType" v-model="value" :readonly="!editable"/> + <icon class="password-eye" v-if="type === 'secret'" @click="plainText = !plainText" :icon="passwordIcon" font="solid"/> + </div> + <label v-if="valueError" class="error">{{valueErrorText}}</label> + </div> +</template> + +<script> + import InputField from '@vue/Components/Form/InputField'; + import ToastService from '@js/Services/ToastService'; + import MessageService from '@js/Services/MessageService'; + import ErrorManager from '@js/Manager/ErrorManager'; + import SelectField from '@vue/Components/Form/SelectField'; + import Icon from "@vue/Components/Icon"; + import LocalisationService from '@js/Services/LocalisationService'; + + export default { + components: { Icon, InputField, SelectField }, + props : { + field : { + type : Object + }, + editable : { + type : Boolean + }, + maxLength: { + type : Boolean + } + }, + + data() { + return { + value : this.field.value, + label : this.getLabel(), + type : this.getType(), + plainText : false, + labelError: false, + valueError: false + }; + }, + + computed: { + customTypeOptions() { + return [ + { + id : 'text', + label: 'PasswordCustomFieldsTypeText' + }, + { + id : 'secret', + label: 'PasswordCustomFieldsTypeSecret' + }, + { + id : 'email', + label: 'PasswordCustomFieldsTypeEmail' + }, + { + id : 'url', + label: 'PasswordCustomFieldsTypeUrl', + }, + { + id : 'formfield', + label: 'PasswordCustomFieldsTypeFormField', + } + ]; + }, + labelClassName() { + var name = "label" + (this.editable === true ? " active": ""); + name += this.labelError === true ? " error": ""; + return name; + }, + valueClassName() { + var name = this.editable === true ? "active": ""; + name += this.valueError === true ? " error": ""; + name += this.type !== "url" && this.editable === false ? " allow-copy": ""; + return name; + }, + activeClassName() { + return this.editable === true ? " active": ""; + }, + showField() { + if(this.type === "file") return false; + if(this.type === "data" && !this.value.startsWith("ext:field/")) return false; + if(this.editable || this.value !== '') { + return true; + } + return false; + }, + classList() { + var result = "password-view-customproperty"; + if(!this.editable) { + result += " readonly"; + } + return result; + }, + passwordIcon() { + if(this.plainText) { + return "eye-slash"; + } + return "eye"; + }, + getInputType() { + if(this.type === "secret" && this.plainText !== true) { + return "password"; + } + return "text"; + }, + labelErrorText() { + return LocalisationService.translate([`PasswordEditMaxAllowedCharacter`, this.getMaxLength('label', this.label.length)]); + }, + valueErrorText() { + if(!this.validateUrl()) { + return LocalisationService.translate(`PasswordEditInvalidValue`); + } + if(!this.validateEmail()) { + return LocalisationService.translate(`PasswordEditInvalidValue`); + } + return LocalisationService.translate([`PasswordEditMaxAllowedCharacter`, this.getMaxLength(this.type, this.value.length)]); + } + }, + + methods: { + copy() { + if(this.editable) return; + var type = (this.type === "secret" ? "password":"text"); + let data = this.value; + MessageService.send({type: 'clipboard.write', payload: {type: type, value: data}}).catch(ErrorManager.catch); + + ToastService.success(['PasswordPropertyCopied', this.field.label]) + .catch(ErrorManager.catch); + }, + getType() { + if(this.field.type === 'data' && this.field.label.startsWith('ext:field/')) { + return 'formfield'; + } + return this.field.type; + }, + getLabel() { + if(this.field.type === 'data' && this.field.label.startsWith('ext:field/')) { + return this.field.label.replace('ext:field/', ''); + } + return this.field.label; + }, + validateUrl(url) { + if(this.type !== "url") return true; + var urlRegex = /^(https?|ftps?|ssh):\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i + var uncRegex = /^\\\\([^\\:\|\[\]\/";<>+=,?* _]+)\\([\u0020-\u0021\u0023-\u0029\u002D-\u002E\u0030-\u0039\u0040-\u005A\u005E-\u007B\u007E-\u00FF]{1,80})(((?:\\[\u0020-\u0021\u0023-\u0029\u002D-\u002E\u0030-\u0039\u0040-\u005A\u005E-\u007B\u007E-\u00FF]{1,255})+?|)(?:\\((?:[\u0020-\u0021\u0023-\u0029\u002B-\u002E\u0030-\u0039\u003B\u003D\u0040-\u005B\u005D-\u007B]{1,255}){1}(?:\:(?=[\u0001-\u002E\u0030-\u0039\u003B-\u005B\u005D-\u00FF]|\:)(?:([\u0001-\u002E\u0030-\u0039\u003B-\u005B\u005D-\u00FF]+(?!\:)|[\u0001-\u002E\u0030-\u0039\u003B-\u005B\u005D-\u00FF]*)(?:\:([\u0001-\u002E\u0030-\u0039\u003B-\u005B\u005D-\u00FF]+)|))|)))|)$/; + + if(urlRegex.test(this.value) || uncRegex.test(this.value)) { + return true; + } + return false; + }, + validateEmail() { + if(this.type !== "email") return true; + const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + return re.test(String(this.value).toLowerCase()); + }, + getMaxLength(type, length) { + switch (type) { + case 'secret': var typeLength = 256; break; + case 'label': var typeLength = 48; break; + default: var typeLength = 320; + } + var maxAllowedField = typeLength - length; + maxAllowedField = (maxAllowedField < this.maxLength ? maxAllowedField:this.maxLength); + return maxAllowedField + length; + }, + updateLabel() { + if(this.getMaxLength('label', this.label.length) >= this.label.length) { + this.labelError = false; + this.field.label = this.label; + return true; + } + this.labelError = true; + return false; + }, + updateValue() { + if(this.validateEmail() && this.validateUrl() ) { + if(this.getMaxLength(this.type, this.value.length) >= this.value.length) + this.valueError = false; + this.field.value = this.value; + return true; + } + this.valueError = true; + return false; + }, + updateInput() { + if(this.updateValue() && this.updateLabel()) { + if(this.type === 'formfield') { + this.field.label = 'ext:field/' + this.label.replace('ext:field/', ''); + this.field.type = 'data'; + } else { + this.field.type = this.type; + } + + this.$emit('updateField'); + this.$emit('error', this.field, false); + } else { + this.$emit('error', this.field, true) + } + + } + }, + + watch : { + value(value) { + if(value === undefined || value === null) return; + this.updateInput(); + }, + label(label) { + if(label === undefined || label === null) return; + this.updateInput(); + }, + type(type) { + if(type === undefined || type === null) return; + this.updateInput(); + }, + editable(value) { + if(value === false) { + this.plainText = false; + } + } + } + + }; +</script> + +<style lang="scss"> +.password-view-customproperty { + padding : .5rem; + cursor : initial; + + .property-label { + display : flex; + flex-direction : row; + justify-content : space-between; + line-height : 1rem; + + &.active { + line-height : 2rem; + margin-bottom : .25rem; + } + + .input-select { + padding : 0; + position : relative; + top : -.25rem; + + select { + padding : 0 1.5rem 0 0; + } + + &.active{ + padding : .25rem; + top : 0; + + select { + padding : .25rem 1.75rem .25rem .25rem; + } + } + } + + input.label { + font-weight : 550; + padding : 0; + line-height : 1rem; + cursor : default; + + &.active { + padding : .25rem; + font-weight : initial; + line-height : 2rem; + cursor : initial; + } + } + } + + + .property-value { + display : flex; + flex-direction : row; + position : relative; + } + + .password-eye { + cursor : pointer; + position : absolute; + right : 0rem; + top : .7rem; + background-color : var(--element-hover-bg-color); + } + + .input-select { + margin-left : .25rem; + } + + input.active, .label.active, .input-select.active { + box-shadow : 0 0 0 1px var(--element-active-fg-color); + + &.error{ + box-shadow : 1px 1px 1px 0px var(--error-bg-color); + border : solid; + border-width : .3px; + border-color : var(--error-bg-color); + } + } + + label.error { + padding : .25rem; + color : var(--error-bg-color); + line-height : 1.5rem; + + } + + + .readonly { + box-shadow : none; + border : none; + } + + input.allow-copy { + &:hover, &:active { + cursor : pointer; + border : none; + } + } +} +</style>
\ No newline at end of file diff --git a/src/vue/Components/Password/Mining.vue b/src/vue/Components/Password/Mining.vue new file mode 100644 index 0000000..da378d4 --- /dev/null +++ b/src/vue/Components/Password/Mining.vue @@ -0,0 +1,210 @@ +<template> + <div class="item-menu password-mining"> + <translate say="MiningItemIsNew" class="create-info" v-if="item.isNew()"> + <icon slot="before" icon="info-circle" font="solid"/> + </translate> + <translate say="MiningItemIsUpdate" class="create-info" :variables="updateVariables" v-else> + <icon slot="before" icon="info-circle" font="solid"/> + </translate> + + <property :password="password" :editable="editable" :field="field" v-for="field in defaultFields" :key="field" v-on:updateField="updateField" v-on:error="handleValidationError"/> + <label class="custom-fields">{{customFieldsLabel}}</label> + <custom-property :field="field" :editable="editable" v-for="field in customFields" :key="field" v-on:updateField="updateCustomField" v-on:error="handleValidationError" :maxLength="customFieldLength"/> + </div> +</template> + +<script> + import Icon from "@vue/Components/Icon"; + import MiningItem from '@js/Models/Queue/MiningItem'; + import Property from '@vue/Components/Password/Property'; + import CustomProperty from '@vue/Components/Password/CustomProperty'; + import Translate from '@vue/Components/Translate'; + import LocalisationService from '@js/Services/LocalisationService'; + import MessageService from '@js/Services/MessageService'; + + export default { + components: {Icon, Translate, Property, CustomProperty}, + props : { + item: { + type: MiningItem + } + }, + + data() { + return { + editable : true, + defaultFields : this.getDefaultFields(), + customFields : this.getCustomFields(), + updatedFields : {}, + errorQueue : [], + customFieldLength: 8192 + } + }, + + computed: { + updateVariables() { + return [ + this.item.getTask().fields.label + ]; + }, + showNewCustomField() { + if(this.allowNewCustomField()) { + return true; + } + return false; + }, + customFieldsLabel() { + return LocalisationService.translate(`LabelCustomFields`); + } + }, + + methods : { + getDefaultFields() { + var fields = []; + this.item.listResultFields().forEach((property) => { + if(property === "password") { + fields.push(this.getFieldObject(property, "password", true, true, 256)); + } + if(!this.item.isNew() && (property === "edited" || property === "created")) { + fields.push(this.getFieldObject(property, "datetime", false, false, undefined)); + } + if(property === "label") { + fields.push(this.getFieldObject(property, "text", true, false, 64)); + } + if(property === "notes") { + fields.push(this.getFieldObject(property, "textarea", true, true, 4096)); + } + if(property === "url") { + fields.push(this.getFieldObject(property, "url", true, true, 2048)); + } + if(property === "username") { + fields.push(this.getFieldObject(property, "text", true, true, 64)); + } + if(property === "hidden") { + fields.push(this.getFieldObject(property, "checkbox", true, false, undefined)); + } + }) + return fields; + }, + getFieldObject(property, type, editable, allowCopy, maxLength) { + return { + name : property, + type : type, + value : this.item.getResultField(property), + editable : editable, + allowCopy: allowCopy, + maxLength: maxLength + } + }, + getCustomFields() { + var result = this.item.getResultField('customFields'); + result.push(this.getNewCustomField()); + this.customFieldLength = 8192 - JSON.stringify(result).length; + return result; + }, + getNewCustomField() { + return ( + { + label : "", + value : "", + type : "text" + } + ) + }, + allowNewCustomField() { + if(this.customFields === undefined + || this.customFields.length >= 20) return false; + var allowNew = true; + this.customFields.forEach((e) => { + if(e.label === "" && e.value === "" && e.type !== "data" && e.type !== "file") { + allowNew = false; + } + }) + + return allowNew; + }, + updateField(field, value) { + this.item.setResultField(field, value) + this.update(); + }, + updateCustomField() { + this.item.setResultField('customFields', this.customFields) + this.update(); + this.customFieldLength = 8192 - JSON.stringify(this.customFields).length; + if(this.allowNewCustomField()) { + this.customFields.push(this.getNewCustomField()); + } + }, + update() { + MessageService + .send({type: 'popup.mining.update', payload: this.item}); + }, + handleValidationError(field, error) { + if(this.errorQueue.indexOf(field) !== -1) { + if(error === false) { + this.errorQueue.splice(this.errorQueue.indexOf(field), 1); + } + } else { + if(error === true) { + this.errorQueue.push(field) + } + } + } + } + }; +</script> + +<style lang="scss"> +.item-menu.password-mining { + background-color : var(--element-hover-bg-color); + color : var(--element-hover-fg-color); + + .create-info { + display : block; + padding : 1rem .5rem .25rem .5rem; + color : var(--element-active-fg-color); + } + + .icon { + text-align : center; + width : 3rem; + display : inline-block; + } + + label.custom-fields { + display : block; + font-weight : 550; + line-height : 1rem;; + padding-left : .5rem; + padding-bottom : .25rem; + } + + input, textarea { + width : 100%; + padding : .25rem; + box-sizing : border-box; + border-radius : 3px; + border : none; + line-height : 2rem; + background-color : var(--element-hover-bg-color); + color : var(--element-fg-color); + scrollbar-width : thin; + } + + input:focus, + select:focus, + textarea:focus, + button:focus { + outline: none; + } + + a { + width : 100%; + padding : .25rem; + line-height : 2rem; + background-color : var(--element-hover-bg-color); + color : var(--element-active-fg-color); + } + +} +</style>
\ No newline at end of file diff --git a/src/vue/Components/Password/Property.vue b/src/vue/Components/Password/Property.vue new file mode 100644 index 0000000..0e87668 --- /dev/null +++ b/src/vue/Components/Password/Property.vue @@ -0,0 +1,301 @@ +<template> + <div :class="classList" v-if="canEdit || value !== ''"> + <div v-if="field.type === 'checkbox'" class="password-checkbox"> + <label class="property-label">{{ label }}</label> + <slider-field v-model="value" :readonly="!canEdit" :class="activeClassName"/> + </div> + <label v-if="field.type !== 'checkbox'" class="property-label">{{ label }}</label> + <div v-if="field.type !== 'checkbox'" class="property-value"> + <a v-if="field.type === 'url' && !canEdit" :href="value">{{text}}</a> + <input-field v-else-if="field.type === 'datetime' || field.type === 'folder'" v-model="text" :readonly="true" class="readonly"/> + <input-field v-else v-model="value" :type="getInputType" @click="copyProperty(field.name)" @dblclick="copyNotes(field.name)" :readonly="!canEdit" :class="activeClassName"/> + <div class="password-icon"> + <icon v-if="editable && field.type === 'password'" @click="generatePassword" icon="sync" font="solid" :spin="generating"/> + <icon v-if="field.type === 'password'" @click="plainText = !plainText" :icon="passwordIcon" font="solid"/> + </div> + </div> + <label v-if="valueError" class="error">{{valueErrorText}}</label> + </div> +</template> + +<script> + import Password from 'passwords-client/src/Model/Password/Password'; + import InputField from '@vue/Components/Form/InputField'; + import SliderField from '@vue/Components/Form/SliderField'; + import ToastService from '@js/Services/ToastService'; + import MessageService from '@js/Services/MessageService'; + import ErrorManager from '@js/Manager/ErrorManager'; + import LocalisationService from '@js/Services/LocalisationService'; + import SettingsService from '@js/Services/SettingsService'; + import Icon from "@vue/Components/Icon"; + + export default { + components: { Icon, InputField, SliderField }, + props : { + password : { + type : Password + }, + field : { + type : JSON + }, + editable : { + type : Boolean + } + }, + + data() { + return { + value : this.field.value, + plainText : false, + folder : undefined, + valueError : false, + generating : true, + numbers : false, + special : false, + strength : 1, + }; + }, + + async mounted() { + var response = await MessageService.send({type: 'folder.show', payload: this.value}); + var payload = response.getPayload(); + if(payload !== undefined && payload !== null ) { + this.folder = payload; + } + + let promises = [ + this.loadSetting('strength'), + this.loadSetting('numbers'), + this.loadSetting('special') + ]; + + Promise.all(promises).then(() => { + this.generating = false; + }); + }, + + computed: { + canEdit() { + if(this.editable && this.field.editable) { + return true; + } + return false; + }, + label() { + return LocalisationService.translate(`Label${this.field.name.capitalize()}`); + }, + text() { + var type = this.field.type; + if(type === 'datetime') { + if(typeof this.value === "number") { + return new Date(this.value * 1000).toLocaleString(); + } else { + return new Date(this.value.toString()).toLocaleString(); + } + } + if(type === 'folder') { + if(this.folder === undefined) { + return this.value; + } + + return this.folder.getLabel(); + } + return this.value; + }, + classList() { + var result = "password-view-property"; + if(!this.canEdit) { + result += " readonly"; + } + if(this.field.allowCopy && this.editable === false) { + result += " allow-copy"; + } + return result; + }, + getInputType() { + if(this.field.type === "textarea") { + return "textarea"; + } + if(this.field.type === "password" && this.plainText !== true) { + return "password"; + } + return "text"; + }, + activeClassName() { + var result = this.editable === true ? " active": ""; + result += this.field.type === 'password' ? ' password-edit':''; + return result; + }, + passwordIcon() { + if(this.plainText) { + return "eye-slash"; + } + return "eye"; + }, + valueErrorText() { + if(!this.validateUrl()) { + return LocalisationService.translate(`PasswordEditInvalidValue`); + } + return LocalisationService.translate([`PasswordEditMaxAllowedCharacter`, this.field.maxLength]); + } + }, + + methods: { + copy(property) { + let data = this.field.value; + MessageService.send({type: 'clipboard.write', payload: {type: this.field.type, value: data}}).catch(ErrorManager.catch); + + let label = LocalisationService.translate(`Label${property.capitalize()}`); + ToastService.success(['PasswordPropertyCopied', label]) + .catch(ErrorManager.catch); + }, + copyProperty(property) { + if(this.field.allowCopy == false || this.editable === true || property === 'notes') return; + this.copy(property); + }, + copyNotes(property) { + if(this.editable === true || property !== 'notes') return; + this.copy(property); + }, + async loadSetting(type) { + this[type] = await SettingsService.getValue(`password.generator.${type}`); + }, + async generatePassword() { + if(this.generating) return; + this.generating = true; + let response = /** @type {Message} **/ await MessageService + .send({type: 'password.generate', payload: {numbers: this.numbers, special: this.special, strength: this.strength}}); + let data = response.getPayload(); + if(data.success) { + this.value = data.password; + } + this.generating = false; + }, + validateLength() { + if(this.field.maxLength === undefined) return true; + if(this.field.maxLength <= this.value.length) { + return false; + } + return true; + }, + validateUrl() { + if(this.field.type !== "url") return true; + var urlRegex = /^(https?|ftps?|ssh):\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i + var uncRegex = /^\\\\([^\\:\|\[\]\/";<>+=,?* _]+)\\([\u0020-\u0021\u0023-\u0029\u002D-\u002E\u0030-\u0039\u0040-\u005A\u005E-\u007B\u007E-\u00FF]{1,80})(((?:\\[\u0020-\u0021\u0023-\u0029\u002D-\u002E\u0030-\u0039\u0040-\u005A\u005E-\u007B\u007E-\u00FF]{1,255})+?|)(?:\\((?:[\u0020-\u0021\u0023-\u0029\u002B-\u002E\u0030-\u0039\u003B\u003D\u0040-\u005B\u005D-\u007B]{1,255}){1}(?:\:(?=[\u0001-\u002E\u0030-\u0039\u003B-\u005B\u005D-\u00FF]|\:)(?:([\u0001-\u002E\u0030-\u0039\u003B-\u005B\u005D-\u00FF]+(?!\:)|[\u0001-\u002E\u0030-\u0039\u003B-\u005B\u005D-\u00FF]*)(?:\:([\u0001-\u002E\u0030-\u0039\u003B-\u005B\u005D-\u00FF]+)|))|)))|)$/; + + if(urlRegex.test(this.value) || uncRegex.test(this.value)) { + return true; + } + return false; + } + }, + + watch : { + value(value) { + if(value === undefined || value === null) return; + if(this.validateLength() && this.validateUrl()) { + this.valueError = false; + this.$emit('updateField', this.field.name, value); + this.$emit('error', this.field, false); + } else { + this.$emit('error', this.field, true) + this.valueError = true; + } + }, + editable(value) { + if(value === false) { + this.plainText = false; + } + } + } + + }; +</script> + +<style lang="scss"> +.password-view-property { + padding : .5rem; + cursor : initial; + + padding : .5rem; + cursor : initial; + + .property-label { + display : block; + font-weight : 550; + line-height : 1rem; + } + + .property-value { + display : flex; + flex-direction : row; + position : relative; + } + + .password-icon { + cursor : pointer; + position : absolute; + right : .5rem; + top : .75rem; + background-color : var(--element-hover-bg-color); + + span.icon { + width : 1.75rem; + } + } + + input.password-edit { + padding-right: 2.5rem !important; + + &.active { + padding-right: 4.5rem !important; + } + } + + + input.active, textarea.active, .label.active { + box-shadow : 0 0 0 1px var(--element-active-fg-color); + + &.error{ + box-shadow : 1px 1px 1px 0px var(--error-bg-color); + border : solid; + border-width : .3px; + border-color : var(--error-bg-color); + } + } + + label.error { + padding : .25rem; + color : var(--error-bg-color); + line-height : 1.5rem; + + } + + .readonly { + box-shadow : none; + border : none; + } + + &.allow-copy { + input:hover, input:active { + cursor : pointer; + border : none; + } + } + + .password-checkbox { + display : flex; + flex-direction : row; + justify-content : space-between; + padding-right : 1rem; + + .input-slider { + cursor : default; + + &.active { + cursor : pointer; + } + } + } +} +</style>
\ No newline at end of file diff --git a/src/vue/Components/Password/View.vue b/src/vue/Components/Password/View.vue new file mode 100644 index 0000000..bffb947 --- /dev/null +++ b/src/vue/Components/Password/View.vue @@ -0,0 +1,315 @@ +<template> + <div class="item-menu password-view"> + <div class="password-header"> + <div class="left-space"/> + <div class="badge-container"> + <icon :class="securityClass" icon="shield-alt" font="solid"/> + <icon class="favorite" @click="updateFavorite()" icon="star" :font="favoriteIconSolid"/> + <icon :icon="sharedIcon" font="solid"/> + </div> + <div class="action-icon"> + <icon v-if="password.getProperty('editable')" :icon="actionIcon" @click="toggleAction()" :class="actionClassList"/> + </div> + </div> + <property :editable="editable" :field="field" v-for="field in defaultFields" :key="field" v-on:updateField="updateField" v-on:error="handleValidationError"/> + <label v-if="customFields.length > 1" class="custom-fields">{{customFieldsLabel}}</label> + <custom-property :field="field" :editable="editable" v-for="field in customFields" :key="field" v-on:updateField="updateCustomField" v-on:error="handleValidationError" :maxLength="customFieldLength"/> + </div> +</template> + +<script> + import Icon from "@vue/Components/Icon"; + import Password from "passwords-client/src/Model/Password/Password"; + import Property from '@vue/Components/Password/Property'; + import CustomProperty from '@vue/Components/Password/CustomProperty'; + import MessageService from "@js/Services/MessageService"; + import LocalisationService from '@js/Services/LocalisationService'; + + export default { + components: {Icon, Property, CustomProperty}, + props : { + password: { + type: Password + } + }, + + data() { + return { + editable : false, + defaultFields : this.getDefaultFields(), + customFields : this.getCustomFields(), + updatedFields : {}, + errorQueue : [], + customFieldLength: 8192 + } + }, + + computed: { + securityClass() { + let types = ['secure', 'warn', 'bad']; + + return `security ${types[this.password.getStatus()]}`; + }, + favoriteIconSolid() { + if(this.password.getProperty('favorite') === true) { + return "solid"; + } + return "reqular"; + }, + sharedIcon() { + if(this.password.getProperty('shared') === true) { + return "users"; + } + return "user-shield"; + }, + showNewCustomField() { + if(this.allowNewCustomField()) { + return true; + } + return false; + }, + actionClassList() { + return this.editable === true && this.errorQueue.length > 0 ? "action-icon disabled":"action-icon"; + }, + actionIcon() { + return this.editable ? "save":"edit"; + }, + customFieldsLabel() { + return LocalisationService.translate(`LabelCustomFields`); + } + }, + + methods : { + getDefaultFields() { + var fields = []; + for (var property in this.password.getProperties()) { + if(property === "password") { + fields.push(this.getFieldObject(property, "password", true, true, 256)); + } + if(property === "edited" || property === "created") { + fields.push(this.getFieldObject(property, "datetime", false, false, undefined)); + } + if(property === "label") { + fields.push(this.getFieldObject(property, "text", true, false, 64)); + } + if(property === "folder") { + fields.push(this.getFieldObject(property, "folder", false, false, undefined)); + } + if(property === "notes") { + fields.push(this.getFieldObject(property, "textarea", true, true, 4096)); + } + if(property === "url") { + fields.push(this.getFieldObject(property, "url", true, true, 2048)); + } + if(property === "username") { + fields.push(this.getFieldObject(property, "text", true, true, 64)); + } + if(property === "hidden") { + fields.push(this.getFieldObject(property, "checkbox", true, false, undefined)); + } + } + return fields; + }, + getFieldObject(property, type, editable, allowCopy, maxLength) { + return { + name : property, + type : type, + value : this.password.getProperty(property), + editable : editable, + allowCopy: allowCopy, + maxLength: maxLength + } + }, + getCustomFields() { + var result = this.password.getProperty('customFields'); + result.push(this.getNewCustomField()); + this.customFieldLength = 8192 - JSON.stringify(result).length; + return result; + }, + getNewCustomField() { + return ( + { + label : "", + value : "", + type : "text" + } + ) + }, + allowNewCustomField() { + if(this.customFields === undefined + || this.customFields.length >= 20) return false; + if(this.updatedFields !== undefined + && this.updatedFields.customFields !== undefined + && this.updatedFields.customFields.length >=20) return false; + return true; + }, + async updateFavorite() { + var data = { + id: this.password.getId(), + favorite: !this.password.getFavorite() + } + var result = await MessageService.send({type: 'password.update', payload: {data: data}}); + if(result.getPayload().success === true) { + this.password.setProperties(result.getPayload().data); + } + }, + toggleAction() { + if(this.errorQueue.length > 0) return; + if(this.editable === true) { + this.save(); + } + this.editable = !this.editable; + }, + save() { + if(Object.keys(this.updatedFields).length !== 0) { + this.removeEmptyCustomFields(); + this.updatedFields.id = this.password.getId(); + MessageService.send({type: 'password.update', payload: {data: this.updatedFields}}); + this.updatedFields = {}; + } + }, + updateField(field, value) { + this.updatedFields[field] = value; + }, + updateCustomField() { + this.updatedFields.customFields = this.customFields; + this.customFieldLength = 8192 - JSON.stringify(this.customFields).length; + if(!this.allowNewCustomField()) return; + var emptyFieldAvailable = false; + this.updatedFields.customFields.forEach((e) => { + if(e.label === "" && e.value === "" && e.type !== "data" && e.type !== "file") { + emptyFieldAvailable = true; + } + }) + if(!emptyFieldAvailable) { + this.customFields.push(this.getNewCustomField()); + } + }, + removeEmptyCustomFields(){ + if(this.updatedFields.customFields === undefined) return; + this.updatedFields.customFields.forEach((e) => { + if((e.label === "" && e.value === "" && e.type !== "data" && e.type !== "file") + || e.label === "ext:field/" && e.type === 'data') { + + var i = this.updatedFields.customFields.indexOf(e) + this.updatedFields.customFields.splice(i, 1); + } + }) + }, + handleValidationError(field, error) { + if(this.errorQueue.indexOf(field) !== -1) { + if(error === false) { + this.errorQueue.splice(this.errorQueue.indexOf(field), 1); + } + } else { + if(error === true) { + this.errorQueue.push(field) + } + } + } + } + }; +</script> + +<style lang="scss"> +.item-menu.password-view { + background-color : var(--element-hover-bg-color); + color : var(--element-hover-fg-color); + + .icon { + text-align : center; + width : 3rem; + display : inline-block; + } + + .password-header { + line-height : 3rem; + display : flex; + justify-content: space-between; + margin-bottom : -1rem; + + .left-space, .action-icon { + width : 3rem + } + + .badge-container { + .security { + &.secure { + color : var(--success-bg-color) + } + + &.warn { + color : var(--warning-bg-color) + } + + &.bad { + color : var(--error-bg-color) + } + } + + .favorite { + cursor : pointer; + color : var(--warning-bg-color); + font-size : calc(var(--font-size) + 1rem); + position : relative; + bottom : -.25rem; + } + } + + .action-icon { + + &.icon.disabled { + opacity : .5; + + &:hover { + cursor : initial; + background-color : initial; + color : initial; + } + } + + &.icon:hover { + cursor : pointer; + background-color : var(--button-hover-bg-color); + color : var(--button-hover-fg-color); + } + } + } + + label.custom-fields { + display : block; + font-weight : 550; + line-height : 1rem;; + padding-left : .5rem; + padding-bottom : .25rem; + } + + input, textarea { + width : 100%; + padding : .25rem; + box-sizing : border-box; + border-radius : 3px; + border : none; + line-height : 2rem; + background-color : var(--element-hover-bg-color); + color : var(--element-fg-color); + scrollbar-width : thin; + } + + input:focus, + select:focus, + textarea:focus, + button:focus { + outline: none; + } + + a { + width : 100%; + padding : .25rem; + line-height : 2rem; + background-color : var(--element-hover-bg-color); + color : var(--element-active-fg-color); + } + +} +</style>
\ No newline at end of file diff --git a/src/vue/Components/Popup/Collected.vue b/src/vue/Components/Popup/Collected.vue index 03f1f01..56de5ff 100644 --- a/src/vue/Components/Popup/Collected.vue +++ b/src/vue/Components/Popup/Collected.vue @@ -5,7 +5,7 @@ <icon icon="trash-alt" @click="discard(item)"/> <icon icon="save" @click="save(item)"/> </div> - <mining-item :item="item" :slot="item.getId()" :key="item.getId()" v-for="item of items"/> + <password-mining :item="item" :slot="item.getId()" :key="item.getId()" v-for="item of items"/> </foldout> <translate tag="div" class="no-results" say="NoCollectedPasswords" v-if="items.length === 0"/> <translate class="collected-add-blank" say="AddPasswordForCurrentTab" @click="addBlankPassword" v-if="items.length === 0"> @@ -19,12 +19,12 @@ import Foldout from '@vue/Components/Foldout'; import Translate from '@vue/Components/Translate'; import MiningClient from '@js/Queue/Client/MiningClient'; - import MiningItem from '@vue/Components/Collected/MiningItem'; + import PasswordMining from '@vue/Components/Password/Mining'; import MessageService from '@js/Services/MessageService'; import ErrorManager from '@js/Manager/ErrorManager'; export default { - components: {MiningItem, Translate, Foldout, Icon}, + components: {Translate, Foldout, Icon, PasswordMining}, props: { initialStatus: { |