diff --git a/frappe/__init__.py b/frappe/__init__.py index 7bd6d72cb46b414f1e5dd2656ac20a7dbcb13cc7..6e2821df18945454800d7d2905425df6c26d50cf 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -14,7 +14,7 @@ import os, sys, importlib, inspect, json from .exceptions import * from .utils.jinja import get_jenv, get_template, render_template, get_email_from_template -__version__ = '8.7.11' +__version__ = '8.8.0' __title__ = "Frappe Framework" local = Local() diff --git a/frappe/auth.py b/frappe/auth.py index 8845b3e790141d8f750d45ebeb72091ee89fc745..bd510b9fcd0ddf517d76a9fe8164fa8d0bb2699a 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -16,9 +16,14 @@ from frappe.modules.patch_handler import check_session_stopped from frappe.translate import get_lang_code from frappe.utils.password import check_password from frappe.core.doctype.authentication_log.authentication_log import add_authentication_log +from frappe.utils.background_jobs import enqueue +from twofactor import (should_run_2fa, authenticate_for_2factor, + confirm_otp_token, get_cached_user_pass) from six.moves.urllib.parse import quote +import pyotp, base64, os + class HTTPRequest: def __init__(self): # Get Environment variables @@ -62,6 +67,7 @@ class HTTPRequest: def validate_csrf_token(self): if frappe.local.request and frappe.local.request.method=="POST": + if not frappe.local.session: return if not frappe.local.session.data.csrf_token \ or frappe.local.session.data.device=="mobile" \ or frappe.conf.get('ignore_csrf', None): @@ -88,7 +94,7 @@ class HTTPRequest: def connect(self, ac_name = None): """connect to db, from ac_name or db_name""" frappe.local.db = frappe.database.Database(user = self.get_db_name(), \ - password = getattr(conf,'db_password', '')) + password = getattr(conf, 'db_password', '')) class LoginManager: def __init__(self): @@ -98,7 +104,7 @@ class LoginManager: self.user_type = None if frappe.local.form_dict.get('cmd')=='login' or frappe.local.request.path=="/api/method/login": - self.login() + if self.login()==False: return self.resume = False # run login triggers @@ -116,7 +122,12 @@ class LoginManager: def login(self): # clear cache frappe.clear_cache(user = frappe.form_dict.get('usr')) - self.authenticate() + user, pwd = get_cached_user_pass() + self.authenticate(user=user, pwd=pwd) + if should_run_2fa(self.user): + authenticate_for_2factor(self.user) + if not confirm_otp_token(self): + return False self.post_login() def post_login(self): @@ -183,7 +194,7 @@ class LoginManager: if not (user and pwd): user, pwd = frappe.form_dict.get('usr'), frappe.form_dict.get('pwd') if not (user and pwd): - self.fail('Incomplete login details', user=user) + self.fail(_('Incomplete login details'), user=user) if cint(frappe.db.get_value("System Settings", "System Settings", "allow_login_using_mobile_number")): user = frappe.db.get_value("User", filters={"mobile_no": user}, fieldname="name") or user @@ -205,7 +216,9 @@ class LoginManager: except frappe.AuthenticationError: self.fail('Incorrect password', user=user) - def fail(self, message, user="NA"): + def fail(self, message, user=None): + if not user: + user = _('Unknown User') frappe.local.response['message'] = message add_authentication_log(message, user, status="Failed") frappe.db.commit() @@ -302,6 +315,7 @@ class CookieManager: for key in set(self.to_delete): response.set_cookie(key, "", expires=expires) + @frappe.whitelist() def get_logged_user(): return frappe.session.user @@ -317,4 +331,4 @@ def get_website_user_home_page(user): home_page = frappe.get_attr(home_page_method[-1])(user) return '/' + home_page.strip('/') else: - return '/me' + return '/me' \ No newline at end of file diff --git a/frappe/build.js b/frappe/build.js index 707708236b2f0ab90255217d7f29a3802a022a3c..45ab9bc9cf88adc0202ce769c17e592ef3b28c01 100644 --- a/frappe/build.js +++ b/frappe/build.js @@ -10,6 +10,7 @@ const path_join = path.resolve; const app = require('express')(); const http = require('http').Server(app); const io = require('socket.io')(http); +const touch = require("touch"); // basic setup const sites_path = path_join(__dirname, '..', '..', '..', 'sites'); @@ -42,6 +43,7 @@ function build(minify) { for (const output_path in build_map) { pack(output_path, build_map[output_path], minify); } + touch(path_join(sites_path, '.build'), {force:true}); } let socket_connection = false; @@ -228,7 +230,7 @@ function watch_less(ondirty) { const less_paths = app_paths.map(path => path_join(path, 'public', 'less')); const to_watch = filter_valid_paths(less_paths); - chokidar.watch(to_watch).on('change', (filename, stats) => { + chokidar.watch(to_watch).on('change', (filename) => { console.log(filename, 'dirty'); var last_index = filename.lastIndexOf('/'); const less_path = filename.slice(0, last_index); @@ -236,17 +238,18 @@ function watch_less(ondirty) { filename = filename.split('/').pop(); compile_less_file(filename, less_path, public_path) - .then(css_file_path => { - // build the target css file for which this css file is input - for (const target in build_map) { - const sources = build_map[target]; - if (sources.includes(css_file_path)) { - pack(target, sources); - ondirty && ondirty(target); - break; + .then(css_file_path => { + // build the target css file for which this css file is input + for (const target in build_map) { + const sources = build_map[target]; + if (sources.includes(css_file_path)) { + pack(target, sources); + ondirty && ondirty(target); + break; + } } - } - }) + }); + touch(path_join(sites_path, '.build'), {force:true}); }); } @@ -265,6 +268,7 @@ function watch_js(ondirty) { // break; } } + touch(path_join(sites_path, '.build'), {force:true}); }); } diff --git a/frappe/change_log/v8/v8_8_0.md b/frappe/change_log/v8/v8_8_0.md new file mode 100644 index 0000000000000000000000000000000000000000..e93c0adbeb04ea94b1d81ed82b30ed19c92870bd --- /dev/null +++ b/frappe/change_log/v8/v8_8_0.md @@ -0,0 +1,2 @@ +### Two Factor Authentication +- Now you can authenticate user with two factor authentication. You can enable the Two Factor Authentication from System Settings. \ No newline at end of file diff --git a/frappe/client.py b/frappe/client.py index 7d9eb7bbf5ac0024dce53d5fb8a18ceb4a0ffc1e..fafa535e0e753425b5383a74b48e228de8aaef2a 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -296,3 +296,8 @@ def get_js(items): out.append(code) return out + +@frappe.whitelist(allow_guest=True) +def get_time_zone(): + '''Returns default time zone''' + return {"time_zone": frappe.defaults.get_defaults().get("time_zone")} diff --git a/frappe/contacts/doctype/address/address.js b/frappe/contacts/doctype/address/address.js index a8d48601178c05b58d41cda4ea599d8cbee52c26..3cbecfe3274f3fe9e615bae2d8e90f3b435f991a 100644 --- a/frappe/contacts/doctype/address/address.js +++ b/frappe/contacts/doctype/address/address.js @@ -22,6 +22,7 @@ frappe.ui.form.on("Address", { } } }); + frm.refresh_field("links"); }, validate: function(frm) { // clear linked customer / supplier / sales partner on saving... diff --git a/frappe/core/doctype/doctype/boilerplate/test_controller.js b/frappe/core/doctype/doctype/boilerplate/test_controller.js index 6749c53bb07cb3340798f075cbcaac3487dab357..ed27ac02f6a509b7a9e0e90c62614c7c6c7c7d6d 100644 --- a/frappe/core/doctype/doctype/boilerplate/test_controller.js +++ b/frappe/core/doctype/doctype/boilerplate/test_controller.js @@ -8,9 +8,9 @@ QUnit.test("test: {doctype}", function (assert) {{ // number of asserts assert.expect(1); - frappe.run_serially('{doctype}', [ + frappe.run_serially([ // insert a new {doctype} - () => frappe.tests.make([ + () => frappe.tests.make('{doctype}', [ // values to be set {{key: 'value'}} ]), diff --git a/frappe/core/doctype/role/role.json b/frappe/core/doctype/role/role.json index 104ee7d53c591e8e724b482eac395287eb831e90..1eebb71a366a87fa5f2e5a2fc2d861265f1f8b87 100644 --- a/frappe/core/doctype/role/role.json +++ b/frappe/core/doctype/role/role.json @@ -105,6 +105,37 @@ "set_only_once": 0, "unique": 0 }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "0", + "fieldname": "two_factor_auth", + "fieldtype": "Check", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Two Factor Authentication", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, { "allow_bulk_edit": 0, "allow_on_submit": 0, @@ -148,7 +179,7 @@ "issingle": 0, "istable": 0, "max_attachments": 0, - "modified": "2017-05-04 11:03:41.533058", + "modified": "2017-07-06 12:42:57.097914", "modified_by": "Administrator", "module": "Core", "name": "Role", diff --git a/frappe/core/doctype/sms_parameter/README.md b/frappe/core/doctype/sms_parameter/README.md new file mode 100644 index 0000000000000000000000000000000000000000..5935a390d27c19fc29a7dafa57f7c85c97d105d3 --- /dev/null +++ b/frappe/core/doctype/sms_parameter/README.md @@ -0,0 +1 @@ +SMS query parameter for SMS Settings. \ No newline at end of file diff --git a/frappe/core/doctype/sms_parameter/__init__.py b/frappe/core/doctype/sms_parameter/__init__.py new file mode 100755 index 0000000000000000000000000000000000000000..baffc4882521df036913c9481fc1fe3fea71223b --- /dev/null +++ b/frappe/core/doctype/sms_parameter/__init__.py @@ -0,0 +1 @@ +from __future__ import unicode_literals diff --git a/frappe/core/doctype/sms_parameter/sms_parameter.json b/frappe/core/doctype/sms_parameter/sms_parameter.json new file mode 100755 index 0000000000000000000000000000000000000000..b5648ade80d40343c6b6c5fd139c2a357a5d47e5 --- /dev/null +++ b/frappe/core/doctype/sms_parameter/sms_parameter.json @@ -0,0 +1,98 @@ +{ + "allow_copy": 0, + "allow_guest_to_view": 0, + "allow_import": 0, + "allow_rename": 0, + "beta": 0, + "creation": "2013-02-22 01:27:58", + "custom": 0, + "docstatus": 0, + "doctype": "DocType", + "editable_grid": 1, + "fields": [ + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "parameter", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_standard_filter": 0, + "label": "Parameter", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": "150px", + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "unique": 0, + "width": "150px" + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "value", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_standard_filter": 0, + "label": "Value", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": "150px", + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "unique": 0, + "width": "150px" + } + ], + "has_web_view": 0, + "hide_heading": 0, + "hide_toolbar": 0, + "idx": 1, + "image_view": 0, + "in_create": 0, + "is_submittable": 0, + "issingle": 0, + "istable": 1, + "max_attachments": 0, + "modified": "2017-07-22 22:52:53.309396", + "modified_by": "chude.osiegbu@manqala.com", + "module": "Core", + "name": "SMS Parameter", + "owner": "Administrator", + "permissions": [], + "quick_entry": 0, + "read_only": 0, + "read_only_onload": 0, + "show_name_in_global_search": 0, + "track_changes": 0, + "track_seen": 0 +} \ No newline at end of file diff --git a/frappe/core/doctype/sms_parameter/sms_parameter.py b/frappe/core/doctype/sms_parameter/sms_parameter.py new file mode 100644 index 0000000000000000000000000000000000000000..08b220b61aba2aecef7540780794f90569b2b807 --- /dev/null +++ b/frappe/core/doctype/sms_parameter/sms_parameter.py @@ -0,0 +1,10 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe + +from frappe.model.document import Document + +class SMSParameter(Document): + pass \ No newline at end of file diff --git a/frappe/core/doctype/sms_settings/README.md b/frappe/core/doctype/sms_settings/README.md new file mode 100644 index 0000000000000000000000000000000000000000..4fb49803b3c667a06bae782aca9b31b3dcb07dad --- /dev/null +++ b/frappe/core/doctype/sms_settings/README.md @@ -0,0 +1 @@ +Settings for automatically sending SMS from the system. \ No newline at end of file diff --git a/frappe/core/doctype/sms_settings/__init__.py b/frappe/core/doctype/sms_settings/__init__.py new file mode 100755 index 0000000000000000000000000000000000000000..baffc4882521df036913c9481fc1fe3fea71223b --- /dev/null +++ b/frappe/core/doctype/sms_settings/__init__.py @@ -0,0 +1 @@ +from __future__ import unicode_literals diff --git a/frappe/core/doctype/sms_settings/sms_settings.js b/frappe/core/doctype/sms_settings/sms_settings.js new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/frappe/core/doctype/sms_settings/sms_settings.json b/frappe/core/doctype/sms_settings/sms_settings.json new file mode 100755 index 0000000000000000000000000000000000000000..0898ed389e230d7dc5223d22a526f893c2377320 --- /dev/null +++ b/frappe/core/doctype/sms_settings/sms_settings.json @@ -0,0 +1,267 @@ +{ + "allow_copy": 1, + "allow_guest_to_view": 0, + "allow_import": 0, + "allow_rename": 0, + "beta": 0, + "creation": "2013-01-10 16:34:24", + "custom": 0, + "docstatus": 0, + "doctype": "DocType", + "editable_grid": 0, + "fields": [ + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "column_break0", + "fieldtype": "Column Break", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "length": 0, + "no_copy": 0, + "permlevel": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "unique": 0, + "width": "50%" + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "description": "Eg. smsgateway.com/api/send_sms.cgi", + "fieldname": "sms_gateway_url", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_standard_filter": 0, + "label": "SMS Gateway URL", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "description": "Enter url parameter for message", + "fieldname": "message_parameter", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_standard_filter": 0, + "label": "Message Parameter", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "description": "Enter url parameter for receiver nos", + "fieldname": "receiver_parameter", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_standard_filter": 0, + "label": "Receiver Parameter", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "sms_sender_name", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "SMS Sender Name", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "static_parameters_section", + "fieldtype": "Column Break", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "length": 0, + "no_copy": 0, + "permlevel": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "unique": 0, + "width": "50%" + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "description": "Enter static url parameters here (Eg. sender=ERPNext, username=ERPNext, password=1234 etc.)", + "fieldname": "parameters", + "fieldtype": "Table", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Static Parameters", + "length": 0, + "no_copy": 0, + "options": "SMS Parameter", + "permlevel": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + } + ], + "has_web_view": 0, + "hide_heading": 0, + "hide_toolbar": 0, + "icon": "fa fa-cog", + "idx": 1, + "image_view": 0, + "in_create": 0, + "is_submittable": 0, + "issingle": 1, + "istable": 0, + "max_attachments": 0, + "modified": "2017-07-22 22:52:16.066981", + "modified_by": "chude.osiegbu@manqala.com", + "module": "Core", + "name": "SMS Settings", + "owner": "Administrator", + "permissions": [ + { + "amend": 0, + "apply_user_permissions": 0, + "cancel": 0, + "create": 1, + "delete": 0, + "email": 0, + "export": 0, + "if_owner": 0, + "import": 0, + "permlevel": 0, + "print": 0, + "read": 1, + "report": 0, + "role": "System Manager", + "set_user_permissions": 0, + "share": 1, + "submit": 0, + "write": 1 + } + ], + "quick_entry": 0, + "read_only": 0, + "read_only_onload": 0, + "show_name_in_global_search": 0, + "track_changes": 0, + "track_seen": 0 +} \ No newline at end of file diff --git a/frappe/core/doctype/sms_settings/sms_settings.py b/frappe/core/doctype/sms_settings/sms_settings.py new file mode 100644 index 0000000000000000000000000000000000000000..a8b59beffa153d33572c02ac80ed57bd62f268e1 --- /dev/null +++ b/frappe/core/doctype/sms_settings/sms_settings.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe + +from frappe import _, throw, msgprint +from frappe.utils import nowdate + +from frappe.model.document import Document + +class SMSSettings(Document): + pass + +def validate_receiver_nos(receiver_list): + validated_receiver_list = [] + for d in receiver_list: + # remove invalid character + for x in [' ', '+', '-', '(', ')']: + d = d.replace(x, '') + + validated_receiver_list.append(d) + + if not validated_receiver_list: + throw(_("Please enter valid mobile nos")) + + return validated_receiver_list + + +def get_sender_name(): + "returns name as SMS sender" + sender_name = frappe.db.get_single_value('SMS Settings', 'sms_sender_name') or \ + 'ERPNXT' + if len(sender_name) > 6 and \ + frappe.db.get_default("country") == "India": + throw("""As per TRAI rule, sender name must be exactly 6 characters. + Kindly change sender name in Setup --> Global Defaults. + Note: Hyphen, space, numeric digit, special characters are not allowed.""") + return sender_name + +@frappe.whitelist() +def get_contact_number(contact_name, ref_doctype, ref_name): + "returns mobile number of the contact" + number = frappe.db.sql("""select mobile_no, phone from tabContact + where name=%s + and exists( + select name from `tabDynamic Link` where link_doctype=%s and link_name=%s + ) + """, (contact_name, ref_doctype, ref_name)) + + return number and (number[0][0] or number[0][1]) or '' + +@frappe.whitelist() +def send_sms(receiver_list, msg, sender_name = '', success_msg = True): + + import json + if isinstance(receiver_list, basestring): + receiver_list = json.loads(receiver_list) + if not isinstance(receiver_list, list): + receiver_list = [receiver_list] + + receiver_list = validate_receiver_nos(receiver_list) + + arg = { + 'receiver_list' : receiver_list, + 'message' : unicode(msg).encode('utf-8'), + 'sender_name' : sender_name or get_sender_name(), + 'success_msg' : success_msg + } + + if frappe.db.get_value('SMS Settings', None, 'sms_gateway_url'): + send_via_gateway(arg) + else: + msgprint(_("Please Update SMS Settings")) + +def send_via_gateway(arg): + ss = frappe.get_doc('SMS Settings', 'SMS Settings') + args = {ss.message_parameter: arg.get('message')} + for d in ss.get("parameters"): + args[d.parameter] = d.value + + success_list = [] + for d in arg.get('receiver_list'): + args[ss.receiver_parameter] = d + status = send_request(ss.sms_gateway_url, args) + + if 200 <= status < 300: + success_list.append(d) + + if len(success_list) > 0: + args.update(arg) + create_sms_log(args, success_list) + if arg.get('success_msg'): + frappe.msgprint(_("SMS sent to following numbers: {0}").format("\n" + "\n".join(success_list))) + + +def send_request(gateway_url, params): + import requests + response = requests.get(gateway_url, params = params, headers={'Accept': "text/plain, text/html, */*"}) + response.raise_for_status() + return response.status_code + + +# Create SMS Log +# ========================================================= +def create_sms_log(args, sent_to): + sl = frappe.new_doc('SMS Log') + sl.sender_name = args['sender_name'] + sl.sent_on = nowdate() + sl.message = args['message'].decode('utf-8') + sl.no_of_requested_sms = len(args['receiver_list']) + sl.requested_numbers = "\n".join(args['receiver_list']) + sl.no_of_sent_sms = len(sent_to) + sl.sent_to = "\n".join(sent_to) + sl.flags.ignore_permissions = True + sl.save() diff --git a/frappe/core/doctype/sms_settings/test_sms_settings.js b/frappe/core/doctype/sms_settings/test_sms_settings.js new file mode 100644 index 0000000000000000000000000000000000000000..c090d167f58ecca30d5911c9ae87bcfe6d7fab2f --- /dev/null +++ b/frappe/core/doctype/sms_settings/test_sms_settings.js @@ -0,0 +1,23 @@ +/* eslint-disable */ +// rename this file from _test_[name] to test_[name] to activate +// and remove above this line + +QUnit.test("test: SMS Settings", function (assert) { + let done = assert.async(); + + // number of asserts + assert.expect(1); + + frappe.run_serially('SMS Settings', [ + // insert a new SMS Settings + () => frappe.tests.make([ + // values to be set + {key: 'value'} + ]), + () => { + assert.equal(cur_frm.doc.key, 'value'); + }, + () => done() + ]); + +}); diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index bbdf75c08549be7c480f744dc6f56d9144c1e0bb..6405a275bfc01c0365f92c336d8805326c5393ba 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -895,6 +895,165 @@ "set_only_once": 0, "unique": 0 }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 1, + "columns": 0, + "fieldname": "two_factor_authentication", + "fieldtype": "Section Break", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Two Factor Authentication", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "enable_two_factor_auth", + "fieldtype": "Check", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Enable Two Factor Auth", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "OTP App", + "depends_on": "", + "description": "Choose authentication method to be used by all users", + "fieldname": "two_factor_method", + "fieldtype": "Select", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Two Factor Authentication method", + "length": 0, + "no_copy": 0, + "options": "OTP App\nSMS\nEmail", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "depends_on": "eval:doc.two_factor_method == \"OTP App\"", + "description": "Time in seconds to retain QR code image on server. Min:<strong>240</strong>", + "fieldname": "lifespan_qrcode_image", + "fieldtype": "Int", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Expiry time of QR Code Image Page", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "Frappe Framework", + "depends_on": "enable_two_factor_auth", + "fieldname": "otp_issuer_name", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "OTP Issuer Name", + "length": 0, + "no_copy": 0, + "options": "", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "unique": 0 + }, { "allow_bulk_edit": 0, "allow_on_submit": 0, @@ -1027,7 +1186,7 @@ "issingle": 1, "istable": 0, "max_attachments": 0, - "modified": "2017-07-20 22:57:56.466867", + "modified": "2017-08-07 23:29:18.858797", "modified_by": "Administrator", "module": "Core", "name": "System Settings", diff --git a/frappe/core/doctype/system_settings/system_settings.py b/frappe/core/doctype/system_settings/system_settings.py index f7ecfc00bb7fe69f12f38d9c5b3ce92028897eb9..cd7edc6a53c486208cb477f35c4ab297f0e70b66 100644 --- a/frappe/core/doctype/system_settings/system_settings.py +++ b/frappe/core/doctype/system_settings/system_settings.py @@ -9,6 +9,7 @@ from frappe.model import no_value_fields from frappe.translate import set_default_language from frappe.utils import cint from frappe.utils.momentjs import get_all_timezones +from frappe.twofactor import toggle_two_factor_auth class SystemSettings(Document): def validate(self): @@ -25,6 +26,12 @@ class SystemSettings(Document): if len(parts)!=2 or not (cint(parts[0]) or cint(parts[1])): frappe.throw(_("Session Expiry must be in format {0}").format("hh:mm")) + if self.enable_two_factor_auth: + if self.two_factor_method=='SMS': + if not frappe.db.get_value('SMS Settings', None, 'sms_gateway_url'): + frappe.throw(_('Please setup SMS before setting it as an authentication method, via SMS Settings')) + toggle_two_factor_auth(True, roles=['All']) + def on_update(self): for df in self.meta.get("fields"): if df.fieldtype not in no_value_fields: diff --git a/frappe/core/doctype/test_runner/test_runner.js b/frappe/core/doctype/test_runner/test_runner.js index 87ea09fab7529ba179b85196e998f93a4019c2fc..da28ab5a2b9c916ab5eac870bf316e5dc810d712 100644 --- a/frappe/core/doctype/test_runner/test_runner.js +++ b/frappe/core/doctype/test_runner/test_runner.js @@ -23,6 +23,7 @@ frappe.ui.form.on('Test Runner', { }, run_tests: function(frm, files) { + frappe.flags.in_test = true; let require_list = [ "assets/frappe/js/lib/jquery/qunit.js", "assets/frappe/js/lib/jquery/qunit.css" diff --git a/frappe/core/doctype/user/user.js b/frappe/core/doctype/user/user.js index 49c1f8b43727b0dcc32849052da7e430535a278a..5409b569c739ead77a33b94018264f155cfa0274 100644 --- a/frappe/core/doctype/user/user.js +++ b/frappe/core/doctype/user/user.js @@ -78,6 +78,15 @@ frappe.ui.form.on('User', { }) }) + frm.add_custom_button(__("Reset OTP Secret"), function() { + frappe.call({ + method: "frappe.core.doctype.user.user.reset_otp_secret", + args: { + "user": frm.doc.name + } + }) + }) + frm.trigger('enabled'); frm.roles_editor && frm.roles_editor.show(); @@ -111,6 +120,7 @@ frappe.ui.form.on('User', { } cur_frm.dirty(); } + }, validate: function(frm) { if(frm.roles_editor) { diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json index 0796ff76fbc45fcb918eb693c6306894e1c17010..31714b7116057725126341fd2c957030b6d57ccd 100644 --- a/frappe/core/doctype/user/user.json +++ b/frappe/core/doctype/user/user.json @@ -1971,7 +1971,7 @@ "istable": 0, "max_attachments": 5, "menu_index": 0, - "modified": "2017-07-12 19:24:00.824902", + "modified": "2017-07-07 17:18:14.047969", "modified_by": "Administrator", "module": "Core", "name": "User", diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index c5dfbd0e2a0469d2a31255746022441e8adf471e..e7d24baf2e957be8bcb8bf9e19d352f51e9d1ceb 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -14,6 +14,7 @@ import frappe.share import re from frappe.limits import get_limits from frappe.website.utils import is_signup_enabled +from frappe.utils.background_jobs import enqueue STANDARD_USERS = ("Guest", "Administrator") @@ -586,8 +587,8 @@ def get_email_awaiting(user): return waiting else: frappe.db.sql("""update `tabUser Email` - set awaiting_password =0 - where parent = %(user)s""",{"user":user}) + set awaiting_password =0 + where parent = %(user)s""",{"user":user}) return False @frappe.whitelist(allow_guest=False) @@ -675,7 +676,7 @@ def ask_pass_update(): from frappe.utils import set_default users = frappe.db.sql("""SELECT DISTINCT(parent) as user FROM `tabUser Email` - WHERE awaiting_password = 1""", as_dict=True) + WHERE awaiting_password = 1""", as_dict=True) password_list = [ user.get("user") for user in users ] set_default("email_user_password", u','.join(password_list)) @@ -888,4 +889,84 @@ def handle_password_test_fail(result): def update_gravatar(name): gravatar = has_gravatar(name) if gravatar: - frappe.db.set_value('User', name, 'user_image', gravatar) \ No newline at end of file + frappe.db.set_value('User', name, 'user_image', gravatar) + +@frappe.whitelist(allow_guest=True) +def send_token_via_sms(tmp_id,phone_no=None,user=None): + try: + from frappe.core.doctype.sms_settings.sms_settings import send_request + except: + return False + + if not frappe.cache().ttl(tmp_id + '_token'): + return False + ss = frappe.get_doc('SMS Settings', 'SMS Settings') + if not ss.sms_gateway_url: + return False + + token = frappe.cache().get(tmp_id + '_token') + args = {ss.message_parameter: 'verification code is {}'.format(token)} + + for d in ss.get("parameters"): + args[d.parameter] = d.value + + if user: + user_phone = frappe.db.get_value('User', user, ['phone','mobile_no'], as_dict=1) + usr_phone = user_phone.mobile_no or user_phone.phone + if not usr_phone: + return False + else: + if phone_no: + usr_phone = phone_no + else: + return False + + args[ss.receiver_parameter] = usr_phone + status = send_request(ss.sms_gateway_url, args) + + if 200 <= status < 300: + frappe.cache().delete(tmp_id + '_token') + return True + else: + return False + +@frappe.whitelist(allow_guest=True) +def send_token_via_email(tmp_id,token=None): + import pyotp + + user = frappe.cache().get(tmp_id + '_user') + count = token or frappe.cache().get(tmp_id + '_token') + + if ((not user) or (user == 'None') or (not count)): + return False + user_email = frappe.db.get_value('User',user, 'email') + if not user_email: + return False + + otpsecret = frappe.cache().get(tmp_id + '_otp_secret') + hotp = pyotp.HOTP(otpsecret) + + frappe.sendmail( + recipients=user_email, sender=None, subject='Verification Code', + message='<p>Your verification code is {0}</p>'.format(hotp.at(int(count))), + delayed=False, retry=3) + + return True + +@frappe.whitelist(allow_guest=True) +def reset_otp_secret(user): + otp_issuer = frappe.db.get_value('System Settings', 'System Settings', 'otp_issuer_name') + user_email = frappe.db.get_value('User',user, 'email') + if frappe.session.user in ["Administrator", user] : + frappe.defaults.clear_default(user + '_otplogin') + frappe.defaults.clear_default(user + '_otpsecret') + email_args = { + 'recipients':user_email, 'sender':None, 'subject':'OTP Secret Reset - {}'.format(otp_issuer or "Frappe Framework"), + 'message':'<p>Your OTP secret on {} has been reset. If you did not perform this reset and did not request it, please contact your System Administrator immediately.</p>'.format(otp_issuer or "Frappe Framework"), + 'delayed':False, + 'retry':3 + } + enqueue(method=frappe.sendmail, queue='short', timeout=300, event=None, async=True, job_name=None, now=False, **email_args) + return frappe.msgprint(_("OTP Secret has been reset. Re-registration will be required on next login.")) + else: + return frappe.throw(_("OTP secret can only be reset by the Administrator.")) \ No newline at end of file diff --git a/frappe/core/doctype/version/test_version.py b/frappe/core/doctype/version/test_version.py index 82f13242aecde8104a9628141a196f9a03513264..be721b327181d2a1121a7869fe2874d6488c70d1 100644 --- a/frappe/core/doctype/version/test_version.py +++ b/frappe/core/doctype/version/test_version.py @@ -14,12 +14,13 @@ class TestVersion(unittest.TestCase): new_doc = copy.deepcopy(old_doc) old_doc.color = None + new_doc.color = '#fafafa' diff = get_diff(old_doc, new_doc)['changed'] self.assertEquals(get_fieldnames(diff)[0], 'color') self.assertTrue(get_old_values(diff)[0] is None) - self.assertEquals(get_new_values(diff)[0], 'blue') + self.assertEquals(get_new_values(diff)[0], '#fafafa') new_doc.starts_on = "2017-07-20" diff --git a/frappe/custom/doctype/customize_form/test_customize_form.js b/frappe/custom/doctype/customize_form/test_customize_form.js index 5d2be73e0b5e5f74a15a20f388ca1a7954f48aa7..d37afa55809152a8d448d2635656ebbe3e594039 100644 --- a/frappe/custom/doctype/customize_form/test_customize_form.js +++ b/frappe/custom/doctype/customize_form/test_customize_form.js @@ -7,7 +7,7 @@ QUnit.test("test customize form", function(assert) { let done = assert.async(); frappe.run_serially([ () => frappe.set_route('Form', 'Customize Form'), - () => frappe.timeout(2), + () => frappe.timeout(1), () => cur_frm.set_value('doc_type', 'ToDo'), () => frappe.timeout(2), () => { diff --git a/frappe/desk/doctype/event/event.json b/frappe/desk/doctype/event/event.json index 75e949e90f8fc1385dbe3bcf3fc6f163fd39cdf3..12fcf5d0afa64649ecad9b63a5b2f55ebb3ed75a 100644 --- a/frappe/desk/doctype/event/event.json +++ b/frappe/desk/doctype/event/event.json @@ -312,9 +312,9 @@ "bold": 0, "collapsible": 0, "columns": 0, - "default": "blue", + "default": "", "fieldname": "color", - "fieldtype": "Select", + "fieldtype": "Color", "hidden": 0, "ignore_user_permissions": 0, "ignore_xss_filter": 0, @@ -325,7 +325,7 @@ "label": "Color", "length": 0, "no_copy": 0, - "options": "red\ngreen\nblue\nyellow\nskyblue\norange", + "options": "", "permlevel": 0, "precision": "", "print_hide": 0, @@ -895,8 +895,8 @@ "issingle": 0, "istable": 0, "max_attachments": 0, - "modified": "2017-07-06 12:37:44.036819", - "modified_by": "Administrator", + "modified": "2017-08-03 16:34:54.657796", + "modified_by": "faris@erpnext.com", "module": "Desk", "name": "Event", "owner": "Administrator", diff --git a/frappe/desk/doctype/event/test_event.js b/frappe/desk/doctype/event/test_event.js new file mode 100644 index 0000000000000000000000000000000000000000..50dcd9e9aa41b60d245a4e9866d9ea237c3a52ae --- /dev/null +++ b/frappe/desk/doctype/event/test_event.js @@ -0,0 +1,42 @@ + +QUnit.test("test: Event", function (assert) { + let done = assert.async(); + + // number of asserts + assert.expect(4); + + const subject = '_Test Event 1'; + const datetime = frappe.datetime.now_datetime(); + const hex = '#6be273'; + const rgb = 'rgb(107, 226, 115)'; + + frappe.run_serially([ + // insert a new Event + () => frappe.tests.make('Event', [ + // values to be set + {subject: subject}, + {starts_on: datetime}, + {color: hex}, + {event_type: 'Private'} + ]), + () => { + assert.equal(cur_frm.doc.subject, subject, 'Subject correctly set'); + assert.equal(cur_frm.doc.starts_on, datetime, 'Date correctly set'); + assert.equal(cur_frm.doc.color, hex, 'Color correctly set'); + + // set filters explicitly for list view + frappe.route_options = { + event_type: 'Private' + }; + }, + () => frappe.set_route('List', 'Event', 'Calendar'), + () => frappe.timeout(2), + () => { + const bg_color = $(`.result-list:visible .fc-day-grid-event:contains("${subject}")`) + .css('background-color'); + assert.equal(bg_color, rgb, 'Event background color is set correctly'); + }, + () => done() + ]); + +}); diff --git a/frappe/desk/doctype/note/note.js b/frappe/desk/doctype/note/note.js index d905b7ad27fca5e312cd0eb24e66a76222761295..c237998ccfd40a86bd1ea18b10308c3457f5856d 100644 --- a/frappe/desk/doctype/note/note.js +++ b/frappe/desk/doctype/note/note.js @@ -10,7 +10,7 @@ frappe.ui.form.on("Note", { // toggle edit frm.add_custom_button("Edit", function() { frm.events.set_editable(frm, !frm.is_note_editable); - }) + }); frm.events.set_editable(frm, false); } }, @@ -24,12 +24,12 @@ frappe.ui.form.on("Note", { frm.set_df_property("content", "read_only", editable ? 0: 1); // hide all other fields - $.each(frm.fields_dict, function(fieldname, field) { + $.each(frm.fields_dict, function(fieldname) { if(fieldname !== "content") { frm.set_df_property(fieldname, "hidden", editable ? 0: 1); } - }) + }); // no label, description for content either frm.get_field("content").toggle_label(editable); diff --git a/frappe/desk/page/setup_wizard/setup_wizard.js b/frappe/desk/page/setup_wizard/setup_wizard.js index f76787e0e38d13decf5527395116d6f1179bd8e7..5ea6412977f4a2b739b4b9325d4ccc5e3de1d9da 100644 --- a/frappe/desk/page/setup_wizard/setup_wizard.js +++ b/frappe/desk/page/setup_wizard/setup_wizard.js @@ -561,7 +561,7 @@ var frappe_slides = [ } } }, - }, + } ]; var utils = { diff --git a/frappe/desk/page/setup_wizard/setup_wizard.py b/frappe/desk/page/setup_wizard/setup_wizard.py index 8e8fef3359d6cae8bfc3da5ae86583ec5a975115..ad3108b67aef8f4901d2278ccb89bb9919a61f18 100755 --- a/frappe/desk/page/setup_wizard/setup_wizard.py +++ b/frappe/desk/page/setup_wizard/setup_wizard.py @@ -267,3 +267,10 @@ def email_setup_wizard_exception(traceback, args): def get_language_code(lang): return frappe.db.get_value('Language', {'language_name':lang}) + + +def enable_twofactor_all_roles(): + all_role = frappe.get_doc('Role',{'role_name':'All'}) + all_role.two_factor_auth = True + all_role.save(ignore_permissions=True) + diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index 8140a0b11edbaa06e9b7348e8245d8cf382a492b..073576c43724a1f1a5adfd642088fe1a3d518cde 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -77,7 +77,7 @@ def run(report_name, filters=None, user=None): frappe.msgprint(_("Must have report permission to access this report."), raise_exception=True) - columns, result, message, chart = [], [], None, None + columns, result, message, chart, data_to_be_printed = [], [], None, None, None if report.report_type=="Query Report": if not report.query: frappe.msgprint(_("Must specify a Query to run"), raise_exception=True) @@ -99,6 +99,8 @@ def run(report_name, filters=None, user=None): message = res[2] if len(res) > 3: chart = res[3] + if len(res) > 4: + data_to_be_printed = res[4] if report.apply_user_permissions and result: result = get_filtered_data(report.ref_doctype, columns, result, user) @@ -110,7 +112,8 @@ def run(report_name, filters=None, user=None): "result": result, "columns": columns, "message": message, - "chart": chart + "chart": chart, + "data_to_be_printed": data_to_be_printed } diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index 4f4713edbe90ee68295987fca9be72fb2ce6247a..04790de8b684b37d8d49c872b2a6d135d05a394f 100755 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -14,6 +14,7 @@ from frappe.utils.scheduler import log from frappe.email.queue import send from frappe.email.doctype.email_group.email_group import add_subscribers from frappe.utils import parse_addr +from frappe.utils import validate_email_add class Newsletter(Document): @@ -23,6 +24,10 @@ class Newsletter(Document): from `tabEmail Queue` where reference_doctype=%s and reference_name=%s group by status""", (self.doctype, self.name))) or None + def validate(self): + if self.send_from: + validate_email_add(self.send_from, True) + def test_send(self, doctype="Lead"): self.recipients = frappe.utils.split_emails(self.test_email_id) self.queue_all() diff --git a/frappe/exceptions.py b/frappe/exceptions.py index 723c6024969fdf1631c1f1b9b99a06b1e372cd43..ae9fca7e7ae7283096d6c850817576a26703d523 100644 --- a/frappe/exceptions.py +++ b/frappe/exceptions.py @@ -37,6 +37,9 @@ class SessionStopped(Exception): class UnsupportedMediaType(Exception): http_status_code = 415 +class RequestToken(Exception): + http_status_code = 200 + class Redirect(Exception): http_status_code = 301 diff --git a/frappe/hooks.py b/frappe/hooks.py index 2d28b74d91be2939afd73b5c1b799a1e8ea38782..bf990a9f72c02f5457cfbe5c74ca173c8ba934c9 100755 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -128,7 +128,8 @@ scheduler_events = { "frappe.email.doctype.email_account.email_account.pull", "frappe.email.doctype.email_account.email_account.notify_unreplied", "frappe.oauth.delete_oauth2_data", - "frappe.integrations.doctype.razorpay_settings.razorpay_settings.capture_payment" + "frappe.integrations.doctype.razorpay_settings.razorpay_settings.capture_payment", + "frappe.twofactor.delete_all_barcodes_for_users" ], "hourly": [ "frappe.model.utils.link_count.update_link_count", @@ -189,3 +190,5 @@ bot_parsers = [ setup_wizard_exception = "frappe.desk.page.setup_wizard.setup_wizard.email_setup_wizard_exception" before_write_file = "frappe.limits.validate_space_limit" + +otp_methods = ['OTP App','Email','SMS'] diff --git a/frappe/public/build.json b/frappe/public/build.json index 054421286e19f85e645912823d337d2f41e89014..75dbf5063a73459ba9cde91a088c9b640be3fbcf 100755 --- a/frappe/public/build.json +++ b/frappe/public/build.json @@ -87,6 +87,7 @@ "public/js/frappe/ui/messages.js", "public/js/frappe/ui/keyboard.js", "public/js/frappe/ui/emoji.js", + "public/js/frappe/ui/colors.js", "public/js/frappe/request.js", "public/js/frappe/socketio_client.js", diff --git a/frappe/public/css/calendar.css b/frappe/public/css/calendar.css index a46ab7227f75797911f7dce6621f728ef7b4db5c..df530f7c304ea66d1064baca8f3264a8c6ab3c66 100644 --- a/frappe/public/css/calendar.css +++ b/frappe/public/css/calendar.css @@ -73,7 +73,6 @@ th.fc-day-header { background: #cfdce5 !important; } .fc-day-grid-event { - background-color: rgba(94, 100, 255, 0.2) !important; border: none !important; margin: 5px 4px 0 !important; padding: 1px 5px !important; diff --git a/frappe/public/css/email.css b/frappe/public/css/email.css index 4e9dfbaa6e3e3691f0ed23286ad4bafe79a864e2..ccedde274dcaade461141abeb1bc1706c6dd2f46 100644 --- a/frappe/public/css/email.css +++ b/frappe/public/css/email.css @@ -49,7 +49,7 @@ hr { border-top: none; } .email-footer-container { - margin-top: 10px; + margin-top: 30px; } .email-footer-container > div:not(:last-child) { margin-bottom: 5px; diff --git a/frappe/public/css/form.css b/frappe/public/css/form.css index c56811e892805763caa2fd96daff857f88b9e0f1..2dd5aaa1e20672962c7533b674d8fb023bd619c4 100644 --- a/frappe/public/css/form.css +++ b/frappe/public/css/form.css @@ -299,17 +299,32 @@ h6.uppercase, .timeline-item.user-content .action-btns { position: absolute; right: 0; - padding: 5px 15px 2px 5px; + padding: 8px 15px 0 5px; +} +.timeline-item.user-content .action-btns .edit-btn-container { + margin-right: 13px; } .timeline-item.user-content .comment-header { background-color: #fafbfc; - padding: 10px 15px 10px 13px; + padding: 10px 15px 8px 13px; margin: 0px; color: #8D99A6; border-bottom: 1px solid #EBEFF2; } .timeline-item.user-content .comment-header.links-active { - padding-right: 60px; + padding-right: 77px; +} +.timeline-item.user-content .comment-header .asset-details { + display: inline-block; + width: 100%; +} +.timeline-item.user-content .comment-header .asset-details .btn-link { + border: 0; + border-radius: 0; + padding: 0; +} +.timeline-item.user-content .comment-header .asset-details .btn-link:hover { + text-decoration: none; } .timeline-item.user-content .comment-header .commented-on-small { display: none; @@ -334,7 +349,8 @@ h6.uppercase, .timeline-item.user-content .close-btn-container .close { color: inherit; opacity: 1; - padding: 0 0 0 10px; + padding: 0; + font-size: 18px; } .timeline-item.user-content .edit-btn-container { padding: 0; @@ -409,7 +425,8 @@ h6.uppercase, top: 5px; } .timeline-item .reply-link { - padding-left: 7px; + margin-left: 15px; + font-size: 12px; } .timeline-head { background-color: white; diff --git a/frappe/public/css/list.css b/frappe/public/css/list.css index 05cb1d07b620dc14eb12c6b5906e74ef28f43525..49ecce16dec0da3fee76f84d60f9a2756f4d02b9 100644 --- a/frappe/public/css/list.css +++ b/frappe/public/css/list.css @@ -183,6 +183,25 @@ .listview-main-section .octicon-heart { cursor: pointer; } +.listview-main-section .page-form { + padding-left: 17px; +} +@media (max-width: 991px) { + .listview-main-section .page-form { + padding-left: 25px; + } +} +.listview-main-section .page-form .octicon-search { + float: left; + padding-top: 7px; + margin-left: -4px; + margin-right: -4px; +} +@media (max-width: 991px) { + .listview-main-section .page-form .octicon-search { + margin-left: -12px; + } +} .like-action.octicon-heart { color: #ff5858; } diff --git a/frappe/public/css/mobile.css b/frappe/public/css/mobile.css index ebcc52084f0ea8b8c3ede41898153efcdec6e692..cc5b926f13bb0c82d97ff88f6f62bc3aef4de35e 100644 --- a/frappe/public/css/mobile.css +++ b/frappe/public/css/mobile.css @@ -25,6 +25,9 @@ body { body[data-route^="Form"] .page-title h1 { margin-top: 12px; } + body[data-route^="Form"] .page-title h1.editable-title { + padding-right: 80px; + } body[data-route^="Form"] .page-title .indicator { display: inline-block; margin-top: 12px; @@ -197,7 +200,7 @@ body { } body[data-route^="Form"] .page-title .title-text { font-size: 16px; - width: calc(100% - 30px); + width: calc(100% - 90px); } body[data-route^="Form"] .page-title .indicator { float: left; @@ -356,7 +359,10 @@ body { content: none; } .timeline .timeline-item.user-content .action-btns { - padding: 5px 10px 2px 5px; + padding: 7px 10px 2px 5px; + } + .timeline .timeline-item.user-content .action-btns .edit-btn-container { + margin-right: 0; } .timeline .timeline-item.user-content .comment-header { padding: 7px 10px; @@ -364,6 +370,12 @@ body { .timeline .timeline-item.user-content .comment-header .links-active { padding-right: 10px; } + .timeline .timeline-item.user-content .comment-header .reply-link { + margin-left: 0; + } + .timeline .timeline-item.user-content .comment-header .asset-details { + width: calc(100% - 30px); + } .timeline .timeline-item.user-content .avatar-medium { margin-right: 10px; } diff --git a/frappe/public/css/page.css b/frappe/public/css/page.css index f5ccdc5a6ab0a88b65e73d5e71a12e347f3e9244..66a7bbd836cff17c7a64e8f650029a984fd6acf7 100644 --- a/frappe/public/css/page.css +++ b/frappe/public/css/page.css @@ -44,7 +44,6 @@ vertical-align: middle; } .page-title .title-image { - display: inline-block; width: 46px; height: 0; padding: 23px 0; @@ -56,6 +55,7 @@ text-align: center; line-height: 0; float: left; + margin-right: 10px; } .editable-title .title-text { cursor: pointer; diff --git a/frappe/public/css/website.css b/frappe/public/css/website.css index b9b2d733bb0d6d6031c6b89c321f06abf33cbbee..6e33918c6c7526c676c4d83b3f1cb32c82f6eee5 100644 --- a/frappe/public/css/website.css +++ b/frappe/public/css/website.css @@ -507,6 +507,7 @@ li { border-top: 1px solid #EBEFF2; } .page_content { + padding-top: 30px; padding-bottom: 30px; } .carousel-control .icon { @@ -554,6 +555,9 @@ li { .panel-body { padding-left: 15px; } +.page-head { + margin-bottom: -30px; +} .page-head h1, .page-head h2 { margin-top: 0px; @@ -588,9 +592,14 @@ fieldset { width: 100%; } .page-container { - padding: 0px; + display: flex; max-width: 970px; - margin: auto; + margin: 0 auto; +} +@media (max-width: 767px) { + .page-container { + flex-direction: column-reverse; + } } .page-max-width { max-width: 800px; @@ -603,30 +612,28 @@ fieldset { .web-sidebar { position: relative; } -.web-sidebar .sidebar-item { +.web-sidebar .sidebar-item:not(:last-child) { margin: 0px; padding-bottom: 12px; border: none; color: #8D99A6; - font-size: 12px; } -.web-sidebar .sidebar-item .badge { +.web-sidebar .sidebar-item:not(:last-child) .badge { font-weight: normal; } .web-sidebar .sidebar-item a { - color: #36414C !important; + color: #8D99A6; } .web-sidebar .sidebar-item a.active { - color: #36414C !important; - font-weight: 500 !important; -} -.web-sidebar .sidebar-items { - margin-bottom: 30px; + color: #36414C; } .web-sidebar .sidebar-items .title { font-size: 14px; font-weight: bold; } +.web-sidebar .sidebar-items ul { + margin-bottom: 0; +} .page-footer { padding: 15px 0px; border-top: 1px solid #EBEFF2; @@ -712,11 +719,6 @@ textarea { .sidebar-navbar-items a:visited { border-bottom: 0px; } -@media (max-width: 767px) { - .visible-xs { - display: inline-block !important; - } -} .more-block { padding-bottom: 30px; } @@ -790,16 +792,49 @@ a.active { .btn-next-wrapper { margin-top: 60px; } -.sidebar-block, +.sidebar-block { + flex: 1; + font-size: 12px; + border-right: 1px solid #d1d8dd; + padding: 30px; + padding-left: 0px; +} +@media (max-width: 767px) { + .sidebar-block { + font-size: 14px; + border-right: none; + border-top: 1px solid #d1d8dd; + padding-left: 20px; + } +} .page-content { + flex: 6; +} +.page-content h1:first-child { + margin-top: 0; +} +.page-content.with-sidebar { + padding: 30px; + padding-left: 40px; +} +.page-content.without-sidebar { padding-top: 30px; - padding-bottom: 50px; } .your-account-info { margin-top: 30px; } -.page-content.with-sidebar { - padding-left: 50px; +@media (max-width: 767px) { + .visible-xs { + display: inline-block !important; + } + .sidebar-block { + width: 100%; + } + .page-content.with-sidebar { + width: 100%; + padding-left: 20px; + padding-right: 20px; + } } @media screen and (max-width: 480px) { .page-content { diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js index e76ef9105e2a358cf3755f75a4658995bf68f58a..7c15d260bdcf08d9f5f50cce66193010aeef400f 100644 --- a/frappe/public/js/frappe/desk.js +++ b/frappe/public/js/frappe/desk.js @@ -45,6 +45,7 @@ frappe.Application = Class.extend({ this.make_nav_bar(); this.set_favicon(); this.setup_analytics(); + this.setup_beforeunload(); frappe.ui.keys.setup(); this.set_rtl(); @@ -480,6 +481,23 @@ frappe.Application = Class.extend({ } }, + setup_beforeunload: function() { + if (frappe.defaults.get_default('in_selenium')) { + return; + } + window.onbeforeunload = function () { + if (frappe.flags.in_test) return null; + var unsaved_docs = []; + for (doctype in locals) { + for (name in locals[doctype]) { + var doc = locals[doctype][name]; + if(doc.__unsaved) { unsaved_docs.push(doc.name); } + } + } + return unsaved_docs.length ? true : null; + }; + }, + show_notes: function() { var me = this; if(frappe.boot.notes.length) { diff --git a/frappe/public/js/frappe/form/control.js b/frappe/public/js/frappe/form/control.js index 54bb4e0595c680ff1372fd40638509e2f2b4115c..f979bb2cb50c9d0c53e9a6bba9a0ebf06ddb8987 100755 --- a/frappe/public/js/frappe/form/control.js +++ b/frappe/public/js/frappe/form/control.js @@ -688,6 +688,8 @@ frappe.ui.form.ControlColor = frappe.ui.form.ControlData.extend({ }, set_formatted_input: function(value) { this._super(value); + + if(!value) value = '#ffffff'; this.$input.css({ "background-color": value }); @@ -721,6 +723,9 @@ frappe.ui.form.ControlColor = frappe.ui.form.ControlData.extend({ }); }, validate: function (value) { + if(value === '') { + return ''; + } var is_valid = /^#[0-9A-F]{6}$/i.test(value); if(is_valid) { return value; diff --git a/frappe/public/js/frappe/form/footer/timeline.js b/frappe/public/js/frappe/form/footer/timeline.js index 7a48fa2c6fa93f895109901e7e648b3d6c6bd2dd..a950aed6a8ce83cf5d99a0d00124b19bc8c57793 100644 --- a/frappe/public/js/frappe/form/footer/timeline.js +++ b/frappe/public/js/frappe/form/footer/timeline.js @@ -159,12 +159,12 @@ frappe.ui.form.Timeline = Class.extend({ this.prepare_timeline_item(c); var $timeline_item = $(frappe.render_template("timeline_item", {data:c, frm:this.frm})) .appendTo(me.list) - .on("click", ".close", function() { + .on("click", ".delete-comment", function() { var name = $timeline_item.data('name'); me.delete_comment(name); return false; }) - .on('click', '.edit', function(e) { + .on('click', '.edit-comment', function(e) { e.preventDefault(); var name = $timeline_item.data('name'); @@ -176,6 +176,7 @@ frappe.ui.form.Timeline = Class.extend({ var content = $timeline_item.find('.timeline-item-content').html(); $edit_btn + .text("Save") .find('i') .removeClass('octicon-pencil') .addClass('octicon-check'); @@ -232,6 +233,7 @@ frappe.ui.form.Timeline = Class.extend({ new frappe.views.CommunicationComposer({ doc: me.frm.doc, txt: "", + title: __('Reply'), frm: me.frm, last_email: last_email }); @@ -251,11 +253,11 @@ frappe.ui.form.Timeline = Class.extend({ c["edit"] = ""; if(c.communication_type=="Comment" && (c.comment_type || "Comment") === "Comment") { if(frappe.model.can_delete("Communication")) { - c["delete"] = '<a class="close" title="Delete" href="#"><i class="octicon octicon-x"></i></a>'; + c["delete"] = '<a class="close delete-comment" title="Delete" href="#"><i class="octicon octicon-x"></i></a>'; } if(frappe.user.name == c.sender || (frappe.user.name == 'Administrator')) { - c["edit"] = '<a class="edit" title="Edit" href="#"><i class="octicon octicon-pencil"></i></a>'; + c["edit"] = '<a class="edit-comment text-muted" title="Edit" href="#">Edit</a>'; } } c.comment_on_small = comment_when(c.creation, true); diff --git a/frappe/public/js/frappe/form/footer/timeline_item.html b/frappe/public/js/frappe/form/footer/timeline_item.html index 215dd06fc24bbe20a51248dc8e89d9ba8f1efa1d..4baa5504c58a2fea43fe4e3171b337324afdfdb3 100755 --- a/frappe/public/js/frappe/form/footer/timeline_item.html +++ b/frappe/public/js/frappe/form/footer/timeline_item.html @@ -91,7 +91,7 @@ {% if (data.communication_medium === "Email" && data.sender !== frappe.session.user_email) { %} <a class="text-muted reply-link pull-right timeline-content-show" - data-name="{%= data.name %}" title="{%= __("Reply") %}"><i class="octicon octicon-mail-reply"></i></a> + data-name="{%= data.name %}" title="{%= __("Reply") %}">{%= __("Reply") %}</a> {% } %} {% } %} <span class="text-muted commented-on hidden-xs"> diff --git a/frappe/public/js/frappe/form/print.js b/frappe/public/js/frappe/form/print.js index 0e7e75299a333fc1da5e564aa46d0e4bfa8a2701..cc99eae370b4911329bfb4c891e8aa0cabdc8ebd 100644 --- a/frappe/public/js/frappe/form/print.js +++ b/frappe/public/js/frappe/form/print.js @@ -132,11 +132,14 @@ frappe.ui.form.PrintPreview = Class.extend({ show_footer: function() { // footer is hidden by default as reqd by pdf generation // simple hack to show it in print preview - this.wrapper.find('.print-format').css('position', 'relative'); + this.wrapper.find('.page-break').css({ + 'display': 'flex', + 'flex-direction': 'column' + }); this.wrapper.find('#footer-html').attr('style', ` display: block !important; - position: absolute; - bottom: 0.75in; + order: 1; + margin-top: 20px; `); }, printit: function () { diff --git a/frappe/public/js/frappe/ui/base_list.js b/frappe/public/js/frappe/ui/base_list.js index 6426db79884396e500835336018c887b9f4282ad..383efccded9a581fc8f81b786637f2e33fafa20e 100644 --- a/frappe/public/js/frappe/ui/base_list.js +++ b/frappe/public/js/frappe/ui/base_list.js @@ -197,14 +197,14 @@ frappe.ui.BaseList = Class.extend({ onchange: () => { me.refresh(true); } }); - this.meta.fields.forEach(function(df) { + this.meta.fields.forEach(function(df, i) { if(df.in_standard_filter && !frappe.model.no_value_type.includes(df.fieldtype)) { let options = df.options; let condition = '='; let fieldtype = df.fieldtype; if (['Text', 'Small Text', 'Text Editor', 'Data'].includes(fieldtype)) { - fieldtype = 'Data', - condition = 'like' + fieldtype = 'Data'; + condition = 'like'; } if(df.fieldtype == "Select" && df.options) { options = df.options.split("\n"); @@ -213,7 +213,7 @@ frappe.ui.BaseList = Class.extend({ options = options.join("\n"); } } - me.page.add_field({ + let f = me.page.add_field({ fieldtype: fieldtype, label: __(df.label), options: options, @@ -221,6 +221,13 @@ frappe.ui.BaseList = Class.extend({ condition: condition, onchange: () => {me.refresh(true);} }); + filter_count ++; + if (filter_count > 3) { + $(f.wrapper).addClass('hidden-sm').addClass('hidden-xs'); + } + if (filter_count > 5) { + return false; + } } }); } diff --git a/frappe/public/js/frappe/ui/colors.js b/frappe/public/js/frappe/ui/colors.js new file mode 100644 index 0000000000000000000000000000000000000000..1b3e41ff2769ec4955d6dc899d55d18a02d38d0d --- /dev/null +++ b/frappe/public/js/frappe/ui/colors.js @@ -0,0 +1,121 @@ +// Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors +// MIT License. See license.txt + +frappe.provide("frappe.ui"); + +frappe.ui.color_map = { + red: ["#ffc4c4", "#ff8989", "#ff4d4d", "#a83333"], + brown: ["#ffe8cd", "#ffd19c", "#ffb868", "#a87945"], + orange: ["#ffd2c2", "#ffa685", "#ff7846", "#a85b5b"], + peach: ["#ffd7d7", "#ffb1b1", "#ff8989", "#a84f2e"], + yellow: ["#fffacd", "#fff168", "#fff69c", "#a89f45"], + yellowgreen: ["#ebf8cc", "#d9f399", "#c5ec63", "#7b933d"], + green: ["#cef6d1", "#9deca2", "#6be273", "#428b46"], + cyan: ["#d2f8ed", "#a4f3dd", "#77ecca", "#49937e"], + skyblue: ["#d2f1ff", "#a6e4ff", "#78d6ff", "#4f8ea8"], + blue: ["#d2d2ff", "#a3a3ff", "#7575ff", "#4d4da8"], + purple: ["#dac7ff", "#b592ff", "#8e58ff", "#5e3aa8"], + pink: ["#f8d4f8", "#f3aaf0", "#ec7dea", "#934f92"] +}; + +frappe.ui.color = { + get: function(color_name, shade) { + if(color_name && shade) return this.get_color_shade(color_name, shade); + if(color_name) return this.get_color_shade(color_name, 'default'); + return frappe.ui.color_map; + }, + get_color: function(color_name) { + const color_names = Object.keys(frappe.ui.color_map); + if(color_names.includes(color_name)) { + return frappe.ui.color_map[color_name]; + } else { + throw new RangeError(`${color_name} can be one of ${color_names}`); + } + }, + get_color_shade: function(color_name, shade) { + const shades = { + 'default': 2, + 'light': 1, + 'extra-light': 0, + 'dark': 3 + }; + + if(Object.keys(shades).includes(shade)) { + return frappe.ui.color_map[color_name][shades[shade]]; + } else { + throw new RangeError(`${shade} can be one of ${Object.keys(shades)}`); + } + }, + all: function() { + return Object.values(frappe.ui.color_map) + .reduce((acc, curr) => acc.concat(curr) , []); + }, + names: function() { + return Object.keys(frappe.ui.color_map); + }, + validate: function(color_name) { + if(!color_name) return false; + if(color_name.startsWith('#')) { + return this.all().includes(color_name); + } + return this.names().includes(color_name); + }, + get_color_name: function(hex) { + for (const key in frappe.ui.color_map) { + const colors = frappe.ui.color_map[key]; + if (colors.includes(hex)) return key; + } + }, + get_contrast_color: function(hex) { + if(!this.validate(hex)) { + const brightness = this.brightness(hex); + if(brightness < 128) { + return this.lighten(hex, 0.5); + } + return this.lighten(hex, -0.5); + } + + const color_name = this.get_color_name(hex); + const colors = this.get_color(color_name); + const shade_value = colors.indexOf(hex); + if(shade_value <= 1) { + return this.get(color_name, 'dark'); + } + return this.get(color_name, 'extra-light'); + }, + + lighten(color, percent) { + // https://stackoverflow.com/a/13542669/5353542 + var f = parseInt(color.slice(1), 16), + t = percent < 0 ? 0 : 255, + p = percent < 0 ? percent * -1 : percent, + R = f >> 16, + G = f >> 8 & 0x00FF, + B = f & 0x0000FF; + return "#" + + (0x1000000 + + (Math.round((t - R) * p) + R) * + 0x10000 + + (Math.round((t - G) * p) + G) * + 0x100 + (Math.round((t - B) * p) + B) + ).toString(16).slice(1); + }, + + hex_to_rgb(hex) { + if(hex.startsWith('#')) { + hex = hex.substring(1); + } + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + return {r, g, b}; + }, + + brightness(hex) { + const rgb = this.hex_to_rgb(hex); + // https://www.w3.org/TR/AERT#color-contrast + // 255 - brightest (#fff) + // 0 - darkest (#000) + return (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000; + } +}; diff --git a/frappe/public/js/frappe/ui/page.js b/frappe/public/js/frappe/ui/page.js index aebb74c4ea1fa2d7ed14e360dbfe6e53078d13fb..8fb77c625cbb37c6eb8c6749326f4bd055aca194 100644 --- a/frappe/public/js/frappe/ui/page.js +++ b/frappe/public/js/frappe/ui/page.js @@ -401,8 +401,13 @@ frappe.ui.Page = Class.extend({ .addClass('col-md-2') .attr("title", __(df.label)).tooltip(); + // html fields in toolbar are only for display + if (df.fieldtype=='HTML') { + return; + } + // hidden fields dont have $input - if(!f.$input) f.make_input(); + if (!f.$input) f.make_input(); f.$input.addClass("input-sm").attr("placeholder", __(df.label)); diff --git a/frappe/public/js/frappe/views/calendar/calendar.js b/frappe/public/js/frappe/views/calendar/calendar.js index ce49e5c33d00b07ad497973b6d4db8e4a44531de..4b8b4febf939a35c4b205ba1b8223261b0a70cce 100644 --- a/frappe/public/js/frappe/views/calendar/calendar.js +++ b/frappe/public/js/frappe/views/calendar/calendar.js @@ -100,7 +100,8 @@ frappe.views.Calendar = Class.extend({ color_map: { "danger": "red", "success": "green", - "warning": "orange" + "warning": "orange", + "default": "blue" }, get_system_datetime: function(date) { date._offset = moment.user_utc_offset; @@ -232,25 +233,28 @@ frappe.views.Calendar = Class.extend({ d.end = frappe.datetime.convert_to_user_tz(d.end); me.fix_end_date_for_event_render(d); - - let color; - if(me.get_css_class) { - color = me.color_map[me.get_css_class(d)]; - // if invalid, fallback to blue color - if(!Object.values(me.color_map).includes(color)) { - color = "blue"; - } - } else { - // color field can be set in {doctype}_calendar.js - // see event_calendar.js - color = d.color; - } - - if(!color) color = "blue"; - d.className = "fc-bg-" + color; + me.prepare_colors(d); return d; }); }, + prepare_colors: function(d) { + let color, color_name; + if(this.get_css_class) { + color_name = this.color_map[this.get_css_class(d)]; + color_name = + frappe.ui.color.validate(color_name) ? + color_name : + 'blue'; + d.backgroundColor = frappe.ui.color.get(color_name, 'extra-light'); + d.textColor = frappe.ui.color.get(color_name, 'dark'); + } else { + color = d.color; + if(!color) color = frappe.ui.color.get('blue', 'extra-light'); + d.backgroundColor = color; + d.textColor = frappe.ui.color.get_contrast_color(color); + } + return d; + }, update_event: function(event, revertFunc) { var me = this; frappe.model.remove_from_locals(me.doctype, event.name); diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 1315da12ed01f0246195f27638a93e7783bd094c..85d0b4820f00a0908a032c7d536ceb1d72326b9f 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -12,7 +12,7 @@ frappe.views.CommunicationComposer = Class.extend({ make: function() { var me = this; this.dialog = new frappe.ui.Dialog({ - title: (this.subject || ""), + title: (this.title || this.subject || __("New Email")), no_submit_on_enter: true, fields: this.get_fields(), primary_action_label: __("Send"), @@ -49,12 +49,12 @@ frappe.views.CommunicationComposer = Class.extend({ var fields= [ {label:__("To"), fieldtype:"Data", reqd: 0, fieldname:"recipients",length:524288}, {fieldtype: "Section Break", collapsible: 1, label: "CC & Standard Reply"}, - {label:__("CC"), fieldtype:"Data", fieldname:"cc",length:524288}, + {label:__("CC"), fieldtype:"Data", fieldname:"cc", length:524288}, {label:__("Standard Reply"), fieldtype:"Link", options:"Standard Reply", fieldname:"standard_reply"}, {fieldtype: "Section Break"}, {label:__("Subject"), fieldtype:"Data", reqd: 1, - fieldname:"subject",length:524288}, + fieldname:"subject", length:524288}, {fieldtype: "Section Break"}, {label:__("Message"), fieldtype:"Text Editor", reqd: 1, fieldname:"content"}, @@ -444,6 +444,7 @@ frappe.views.CommunicationComposer = Class.extend({ send_email: function(btn, form_values, selected_attachments, print_html, print_format) { var me = this; + me.dialog.hide(); if((form_values.send_email || form_values.communication_medium === "Email") && !form_values.recipients) { frappe.msgprint(__("Enter Email Recipient(s)")); @@ -496,8 +497,6 @@ frappe.views.CommunicationComposer = Class.extend({ [ frappe.utils.escape_html(r.message["emails_not_sent_to"]) ]) ); } - me.dialog.hide(); - if ((frappe.last_edited_communication[me.doc] || {})[me.key]) { delete frappe.last_edited_communication[me.doc][me.key]; } @@ -506,7 +505,7 @@ frappe.views.CommunicationComposer = Class.extend({ cur_frm.timeline.input && cur_frm.timeline.input.val(""); cur_frm.reload_doc(); } - + // try the success callback if it exists if (me.success) { try { @@ -515,10 +514,10 @@ frappe.views.CommunicationComposer = Class.extend({ console.log(e); } } - + } else { frappe.msgprint(__("There were errors while sending email. Please try again.")); - + // try the error callback if it exists if (me.error) { try { diff --git a/frappe/public/js/frappe/views/reports/query_report.js b/frappe/public/js/frappe/views/reports/query_report.js index ceb0a696bb7f1751d09ba6637af46c683abd2a6d..ba78c489ca4a495baa955edd40b9737a82e9f326 100644 --- a/frappe/public/js/frappe/views/reports/query_report.js +++ b/frappe/public/js/frappe/views/reports/query_report.js @@ -184,12 +184,12 @@ frappe.views.QueryReport = Class.extend({ frappe.msgprint(__("You are not allowed to print this report")); return false; } - if(this.html_format) { var content = frappe.render(this.html_format, { data: frappe.slickgrid_tools.get_filtered_items(this.dataView), filters: this.get_values(), - report: this + report: this, + data_to_be_printed: this.data_to_be_printed }); frappe.render_grid({ @@ -223,7 +223,8 @@ frappe.views.QueryReport = Class.extend({ var content = frappe.render(this.html_format, { data: frappe.slickgrid_tools.get_filtered_items(this.dataView), filters:this.get_values(), - report:this + report:this, + data_to_be_printed: this.data_to_be_printed }); //Render Report in HTML @@ -487,6 +488,7 @@ frappe.views.QueryReport = Class.extend({ this.set_message(res.message); this.setup_chart(res); + this.set_print_data(res.data_to_be_printed); this.toggle_expand_collapse_buttons(this.is_tree_report); }, @@ -897,5 +899,9 @@ frappe.views.QueryReport = Class.extend({ if(this.chart && opts.data && opts.data.rows && opts.data.rows.length) { this.chart_area.toggle(true); } + }, + + set_print_data: function(data_to_be_printed) { + this.data_to_be_printed = data_to_be_printed; } }) diff --git a/frappe/public/js/legacy/form.js b/frappe/public/js/legacy/form.js index e5ac26c2f9650bad8eb84f013256e5b6d9fe4c8d..69f03327effbd56bba4ce2c0ddcac5be9eeade67 100644 --- a/frappe/public/js/legacy/form.js +++ b/frappe/public/js/legacy/form.js @@ -335,7 +335,7 @@ _f.Frm.prototype.refresh_header = function(is_a_different_doc) { ! this.is_dirty() && ! this.is_new() && this.doc.docstatus===0) { - this.dashboard.add_comment(__('Submit this document to confirm'), 'alert-warning', true); + this.dashboard.add_comment(__('Submit this document to confirm'), 'orange', true); } this.clear_custom_buttons(); @@ -459,6 +459,7 @@ _f.Frm.prototype.refresh = function(docname) { _f.Frm.prototype.show_if_needs_refresh = function() { if(this.doc.__needs_refresh) { if(this.doc.__unsaved) { + this.dashboard.clear_headline(); this.dashboard.set_headline_alert(__("This form has been modified after you have loaded it") + '<a class="btn btn-xs btn-primary pull-right" onclick="cur_frm.reload_doc()">' + __("Refresh") + '</a>', "alert-warning"); diff --git a/frappe/public/less/calendar.less b/frappe/public/less/calendar.less index f69a7323e382411cd6964f32866e3e2981371a59..482beb3cfe1ade25d6f7a4b568b5ad3f6301f288 100644 --- a/frappe/public/less/calendar.less +++ b/frappe/public/less/calendar.less @@ -27,7 +27,7 @@ th.fc-widget-header { .fc-unthemed .fc-today { background-color: #FFF !important; - + .fc-day-number { background-color: @brand-primary; min-width: 20px; @@ -90,7 +90,6 @@ th.fc-day-header { } .fc-day-grid-event { - background-color: rgba(94, 100, 255, 0.2) !important; border: none !important; margin: 5px 4px 0 !important; padding: 1px 5px !important; diff --git a/frappe/public/less/email.less b/frappe/public/less/email.less index f07107891ae32bb932f3323113100f0feeb8e853..b7f9ee90fccd967c1b2b7d7c2cbede9df17fcc88 100644 --- a/frappe/public/less/email.less +++ b/frappe/public/less/email.less @@ -64,7 +64,7 @@ hr { } .email-footer-container { - margin-top: 10px; + margin-top: 30px; & > div:not(:last-child) { margin-bottom: 5px; diff --git a/frappe/public/less/form.less b/frappe/public/less/form.less index 12860bc07d5007c54cc52ddc444d46359367e906..3440d6f4b52c6e69cbb36fc4acc3dde0c6b2e4e2 100644 --- a/frappe/public/less/form.less +++ b/frappe/public/less/form.less @@ -391,17 +391,32 @@ h6.uppercase, .h6.uppercase { .action-btns { position: absolute; right: 0; - padding: 5px 15px 2px 5px; + padding: 8px 15px 0 5px; + .edit-btn-container { + margin-right: 13px; + } } .comment-header { background-color: @light-bg; - padding: 10px 15px 10px 13px; + padding: 10px 15px 8px 13px; margin: 0px; color: @text-muted; border-bottom: 1px solid @light-border-color; &.links-active { - padding-right: 60px; + padding-right: 77px; + } + .asset-details { + display: inline-block; + width: 100%; + .btn-link { + border: 0; + border-radius: 0; + padding: 0; + &:hover { + text-decoration: none; + } + } } .commented-on-small { display: none; @@ -434,7 +449,8 @@ h6.uppercase, .h6.uppercase { .close { color: inherit; opacity: 1; - padding: 0 0 0 10px; + padding: 0; + font-size: 18px; } } @@ -530,7 +546,8 @@ h6.uppercase, .h6.uppercase { } .timeline-item .reply-link { - padding-left: 7px; + margin-left: 15px; + font-size: 12px; } .timeline-head { diff --git a/frappe/public/less/list.less b/frappe/public/less/list.less index 517c6e4059d8301db8980152492be9975dab3904..d14a533ea9e2cf22a838689e57d26b192bc4360e 100644 --- a/frappe/public/less/list.less +++ b/frappe/public/less/list.less @@ -226,8 +226,27 @@ padding: 5px 15px; } -.listview-main-section .octicon-heart { - cursor: pointer; +.listview-main-section { + .octicon-heart { + cursor: pointer; + } + .page-form { + padding-left: 17px; + + @media (max-width: @screen-sm) { + padding-left: 25px; + } + + .octicon-search { + float: left; + padding-top: 7px; + margin-left: -4px; + margin-right: -4px; + @media (max-width: @screen-sm) { + margin-left: -12px; + } + } + } } .like-action.octicon-heart { diff --git a/frappe/public/less/mobile.less b/frappe/public/less/mobile.less index 40d673c169e8ceef7e1a81471472bdfcbeac38bd..3c93a88177d996f61d61eea6af2140863011d508 100644 --- a/frappe/public/less/mobile.less +++ b/frappe/public/less/mobile.less @@ -34,6 +34,9 @@ body { body[data-route^="Form"] { .page-title h1 { margin-top: 12px; + &.editable-title { + padding-right: 80px; + } } .page-title .indicator { @@ -230,7 +233,7 @@ body { .page-title { .title-text { font-size: 16px; - width: calc(~"100% - 30px"); + width: calc(~"100% - 90px"); } .indicator { float: left; @@ -432,13 +435,22 @@ body { } } .action-btns { - padding: 5px 10px 2px 5px; + padding: 7px 10px 2px 5px; + .edit-btn-container { + margin-right: 0; + } } .comment-header{ padding: 7px 10px; .links-active { padding-right: 10px; } + .reply-link { + margin-left: 0; + } + .asset-details { + width: calc(~"100% - 30px") + } } .avatar-medium { margin-right: 10px; diff --git a/frappe/public/less/page.less b/frappe/public/less/page.less index d141c5bc133e3e01b5054462b99d4c5c5141f8d1..5dc338d3ece02dcaf9c98e6f992b532da24d2d15 100644 --- a/frappe/public/less/page.less +++ b/frappe/public/less/page.less @@ -54,7 +54,6 @@ } .title-image { - display: inline-block; width: 46px; height: 0; padding: 23px 0; @@ -66,6 +65,7 @@ text-align: center; line-height: 0; float: left; + margin-right: 10px; } } diff --git a/frappe/public/less/website.less b/frappe/public/less/website.less index 6e9e7916a1a4c9454d5a625498fa9f96ebb14b93..87f5065e66ad40b16884667a182dd46f40e4efc9 100644 --- a/frappe/public/less/website.less +++ b/frappe/public/less/website.less @@ -125,6 +125,7 @@ li { } .page_content { + padding-top: 30px; padding-bottom: 30px; } @@ -181,6 +182,7 @@ li { } .page-head { + margin-bottom: -30px; h1, h2 { margin-top: 0px; } @@ -221,9 +223,13 @@ fieldset { } .page-container { - padding: 0px; + display: flex; max-width: 970px; - margin: auto; + margin: 0 auto; + + @media(max-width: @screen-xs) { + flex-direction: column-reverse; + } } .page-max-width { @@ -241,12 +247,11 @@ fieldset { .web-sidebar { position: relative; - .sidebar-item { + .sidebar-item:not(:last-child) { margin: 0px; padding-bottom: 12px; border: none; color: @text-muted; - font-size: 12px; .badge { font-weight: normal; @@ -255,21 +260,22 @@ fieldset { } .sidebar-item a { - color: @text-color !important; - } + color: @text-muted; - .sidebar-item a.active { - color: @text-color !important; - font-weight: 500 !important; + &.active { + color: @text-color; + } } .sidebar-items { - // margin-top:30px; - margin-bottom:30px; .title{ font-size: 14px; font-weight: bold; } + + ul { + margin-bottom: 0; + } } } @@ -378,11 +384,6 @@ textarea { } } -@media (max-width: 767px) { - .visible-xs { - display: inline-block !important; - } -} .more-block { padding-bottom: 30px; @@ -477,16 +478,54 @@ a.active { margin-top: 60px; } -.sidebar-block, .page-content { +.sidebar-block { + flex: 1; + font-size: @text-medium; + border-right: 1px solid @border-color; + padding: 30px; + padding-left: 0px; + + @media(max-width: @screen-xs) { + font-size: @text-regular; + border-right: none; + border-top: 1px solid @border-color; + padding-left: 20px; + } +} + +.page-content { + flex: 6; + + h1:first-child { + margin-top: 0; + } +} + +.page-content.with-sidebar { + padding: 30px; + padding-left: 40px; +} + +.page-content.without-sidebar { padding-top: 30px; - padding-bottom: 50px; } + .your-account-info { margin-top: 30px; } -.page-content.with-sidebar { - padding-left: 50px; +@media (max-width: 767px) { + .visible-xs { + display: inline-block !important; + } + .sidebar-block { + width: 100%; + } + .page-content.with-sidebar { + width: 100%; + padding-left: 20px; + padding-right: 20px; + } } @media screen and (max-width: 480px) { diff --git a/frappe/templates/includes/list/list.html b/frappe/templates/includes/list/list.html index 3a1c72fb5d31f0f74db8c7863a6aacacb5ca75a6..bf2efba02be3878b688157a418d3f801707f0140 100644 --- a/frappe/templates/includes/list/list.html +++ b/frappe/templates/includes/list/list.html @@ -20,7 +20,7 @@ {% endfor %} </div> <div class="more-block {% if not show_more -%} hide {%- endif %}"> - <button class="btn btn-default btn-more">{{ _("More") }}</button> + <button class="btn btn-default btn-more btn-sm">{{ _("More") }}</button> </div> </div> {%- endif %} diff --git a/frappe/templates/includes/login/login.js b/frappe/templates/includes/login/login.js index 88eb76daf2043a7c35eda4c0b99ced2143503619..69e1199254e5b580ade23fc268d004d02014064a 100644 --- a/frappe/templates/includes/login/login.js +++ b/frappe/templates/includes/login/login.js @@ -5,11 +5,14 @@ window.disable_signup = {{ disable_signup and "true" or "false" }}; window.login = {}; +window.verify = {}; + login.bind_events = function() { $(window).on("hashchange", function() { login.route(); }); + $(".form-login").on("submit", function(event) { event.preventDefault(); var args = {}; @@ -92,6 +95,11 @@ login.login = function() { $(".for-login").toggle(true); } +login.steptwo = function() { + login.reset_sections(); + $(".for-login").toggle(true); +} + login.forgot = function() { login.reset_sections(); $(".for-forgot").toggle(true); @@ -150,7 +158,7 @@ login.login_handlers = (function() { var login_handlers = { 200: function(data) { - if(data.message=="Logged In") { + if(data.message == 'Logged In'){ login.set_indicator("{{ _("Success") }}", 'green'); window.location.href = get_url_arg("redirect-to") || data.home_page; } else if(data.message=="No App") { @@ -190,15 +198,31 @@ login.login_handlers = (function() { } //login.set_indicator(__(data.message), 'green'); } + + //OTP verification + if(data.verification && data.message != 'Logged In') { + login.set_indicator("{{ _("Success") }}", 'green'); + + document.cookie = "tmp_id="+data.tmp_id; + + if (data.verification.method == 'OTP App'){ + continue_otp_app(data.verification.setup, data.verification.qrcode); + } else if (data.verification.method == 'SMS'){ + continue_sms(data.verification.setup, data.verification.prompt); + } else if (data.verification.method == 'Email'){ + continue_email(data.verification.setup, data.verification.prompt); + } + } }, 401: get_error_handler("{{ _("Invalid Login. Try again.") }}"), 417: get_error_handler("{{ _("Oops! Something went wrong") }}") }; return login_handlers; -})(); +} )(); frappe.ready(function() { + login.bind_events(); if (!window.location.hash) { @@ -210,3 +234,76 @@ frappe.ready(function() { $(".form-signup, .form-forgot").removeClass("hide"); $(document).trigger('login_rendered'); }); + +var verify_token = function(event) { + $(".form-verify").on("submit", function(eventx) { + eventx.preventDefault(); + var args = {}; + args.cmd = "login"; + args.otp = $("#login_token").val(); + args.tmp_id = frappe.get_cookie('tmp_id'); + if(!args.otp) { + frappe.msgprint('{{ _("Login token required") }}'); + return false; + } + login.call(args); + return false; + }); +} + +var request_otp = function(r){ + $('.login-content').empty().append($('<div>').attr({'id':'twofactor_div'}).html( + '<form class="form-verify">\ + <div class="page-card-head">\ + <span class="indicator blue" data-text="Verification">Verification</span>\ + </div>\ + <div id="otp_div"></div>\ + <input type="text" id="login_token" autocomplete="off" class="form-control" placeholder="Verification Code" required="" autofocus="">\ + <button class="btn btn-sm btn-primary btn-block" id="verify_token">Verify</button>\ + </form>')); + // add event handler for submit button + verify_token(); +} + +var continue_otp_app = function(setup, qrcode){ + request_otp(); + var qrcode_div = $('<div class="text-muted" style="padding-bottom: 15px;"></div>'); + + if (setup){ + direction = $('<div>').attr('id','qr_info').text('Enter Code displayed in OTP App.'); + qrcode_div.append(direction); + $('#otp_div').prepend(qrcode_div); + } else { + direction = $('<div>').attr('id','qr_info').text('OTP setup using OTP App was not completed. Please contact Administrator.'); + qrcode_div.append(direction); + $('#otp_div').prepend(qrcode_div); + } +} + +var continue_sms = function(setup, prompt){ + request_otp(); + var sms_div = $('<div class="text-muted" style="padding-bottom: 15px;"></div>'); + + if (setup){ + sms_div.append(prompt) + $('#otp_div').prepend(sms_div); + } else { + direction = $('<div>').attr('id','qr_info').text(prompt || 'SMS was not sent. Please contact Administrator.'); + sms_div.append(direction); + $('#otp_div').prepend(sms_div) + } +} + +var continue_email = function(setup, prompt){ + request_otp(); + var email_div = $('<div class="text-muted" style="padding-bottom: 15px;"></div>'); + + if (setup){ + email_div.append(prompt) + $('#otp_div').prepend(email_div); + } else { + var direction = $('<div>').attr('id','qr_info').text(prompt || 'Verification code email not sent. Please contact Administrator.'); + email_div.append(direction); + $('#otp_div').prepend(email_div); + } +} \ No newline at end of file diff --git a/frappe/templates/web.html b/frappe/templates/web.html index 3f0fc56a856459ac817ac02ff625c2308d674ff7..44b5c1cb8cb51d4cbee3b27fa970e801e9a29076 100644 --- a/frappe/templates/web.html +++ b/frappe/templates/web.html @@ -6,13 +6,12 @@ data-path="{{ pathname }}" {%- if page_or_generator=="Generator" %} data-doctype="{{ doctype }}"{% endif %}> - <div class="row {% if show_sidebar %}vert-line{% endif %}"> {% if show_sidebar %} - <div class="col-sm-3 sidebar-block hidden-xs"> + <div class="sidebar-block"> {% include "templates/includes/web_sidebar.html" %} </div> {% endif %} - <div class="{% if show_sidebar %}page-content with-sidebar col-sm-9{% else %} page-content col-sm-12 {% endif %}"> + <div class="{% if show_sidebar %}page-content with-sidebar{% else %}page-content without-sidebar{% endif %}"> <div class="page-content-wrapper"> <div class="row page-head"> <div class='col-sm-12'> @@ -48,7 +47,7 @@ {%- block page_content -%}{%- endblock -%} </div> </div> + <!-- sidebar ends --> </div> - </div> </div> {% endblock %} diff --git a/frappe/tests/test_twofactor.py b/frappe/tests/test_twofactor.py new file mode 100644 index 0000000000000000000000000000000000000000..e993b2d517fd5fe0bd45bc443fd3fef5e8f07285 --- /dev/null +++ b/frappe/tests/test_twofactor.py @@ -0,0 +1,132 @@ +# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt +from __future__ import unicode_literals + +import unittest, frappe, pyotp +from werkzeug.wrappers import Request +from werkzeug.test import EnvironBuilder +from frappe.auth import HTTPRequest +from frappe.twofactor import (should_run_2fa, authenticate_for_2factor, get_cached_user_pass, + two_factor_is_enabled_for_, confirm_otp_token, get_otpsecret_for_, get_verification_obj, + render_string_template) + +import time + +class TestTwoFactor(unittest.TestCase): + def setUp(self): + self.http_requests = create_http_request() + self.login_manager = frappe.local.login_manager + self.user = self.login_manager.user + + def tearDown(self): + frappe.local.response['verification'] = None + frappe.local.response['tmp_id'] = None + disable_2fa() + frappe.clear_cache(user=self.user) + + def test_should_run_2fa(self): + '''Should return true if enabled.''' + toggle_2fa_all_role(state=True) + self.assertTrue(should_run_2fa(self.user)) + toggle_2fa_all_role(state=False) + self.assertFalse(should_run_2fa(self.user)) + + def test_get_cached_user_pass(self): + '''Cached data should not contain user and pass before 2fa.''' + user,pwd = get_cached_user_pass() + self.assertTrue(all([not user, not pwd])) + + def test_authenticate_for_2factor(self): + '''Verification obj and tmp_id should be set in frappe.local.''' + authenticate_for_2factor(self.user) + verification_obj = frappe.local.response['verification'] + tmp_id = frappe.local.response['tmp_id'] + self.assertTrue(verification_obj) + self.assertTrue(tmp_id) + for k in ['_usr','_pwd','_otp_secret']: + self.assertTrue(frappe.cache().get('{0}{1}'.format(tmp_id,k)), + '{} not available'.format(k)) + + def test_two_factor_is_enabled_for_user(self): + '''Should return true if enabled for user.''' + toggle_2fa_all_role(state=True) + self.assertTrue(two_factor_is_enabled_for_(self.user)) + toggle_2fa_all_role(state=False) + self.assertFalse(two_factor_is_enabled_for_(self.user)) + + def test_get_otpsecret_for_user(self): + '''OTP secret should be set for user.''' + self.assertTrue(get_otpsecret_for_(self.user)) + self.assertTrue(frappe.db.get_default(self.user + '_otpsecret')) + + def test_confirm_otp_token(self): + '''Ensure otp is confirmed''' + authenticate_for_2factor(self.user) + tmp_id = frappe.local.response['tmp_id'] + otp = 'wrongotp' + with self.assertRaises(frappe.AuthenticationError): + confirm_otp_token(self.login_manager,otp=otp,tmp_id=tmp_id) + otp = get_otp(self.user) + self.assertTrue(confirm_otp_token(self.login_manager,otp=otp,tmp_id=tmp_id)) + if frappe.flags.tests_verbose: + print('Sleeping for 30secs to confirm token expires..') + time.sleep(30) + with self.assertRaises(frappe.AuthenticationError): + confirm_otp_token(self.login_manager,otp=otp,tmp_id=tmp_id) + + def test_get_verification_obj(self): + '''Confirm verification object is returned.''' + otp_secret = get_otpsecret_for_(self.user) + token = int(pyotp.TOTP(otp_secret).now()) + self.assertTrue(get_verification_obj(self.user,token,otp_secret)) + + def test_render_string_template(self): + '''String template renders as expected with variables.''' + args = {'issuer_name':'Frappe Technologies'} + _str = 'Verification Code from {{issuer_name}}' + _str = render_string_template(_str,args) + self.assertEqual(_str,'Verification Code from Frappe Technologies') + + +def set_request(**kwargs): + builder = EnvironBuilder(**kwargs) + frappe.local.request = Request(builder.get_environ()) + +def create_http_request(): + '''Get http request object.''' + set_request(method='POST', path='login') + enable_2fa() + frappe.form_dict['usr'] = 'test@erpnext.com' + frappe.form_dict['pwd'] = 'test' + frappe.local.form_dict['cmd'] = 'login' + http_requests = HTTPRequest() + return http_requests + +def enable_2fa(): + '''Enable Two factor in system settings.''' + system_settings = frappe.get_doc('System Settings') + system_settings.enable_two_factor_auth = 1 + system_settings.two_factor_method = 'OTP App' + system_settings.save(ignore_permissions=True) + frappe.db.commit() + +def disable_2fa(): + system_settings = frappe.get_doc('System Settings') + system_settings.enable_two_factor_auth = 0 + system_settings.save(ignore_permissions=True) + frappe.db.commit() + +def toggle_2fa_all_role(state=None): + '''Enable or disable 2fa for 'all' role on the system.''' + all_role = frappe.get_doc('Role','All') + if state == None: + state = False if all_role.two_factor_auth == True else False + if state not in [True,False]:return + all_role.two_factor_auth = state + all_role.save(ignore_permissions=True) + frappe.db.commit() + +def get_otp(user): + otp_secret = get_otpsecret_for_(user) + otp = pyotp.TOTP(otp_secret) + return otp.now() \ No newline at end of file diff --git a/frappe/tests/ui/test_test_runner.py b/frappe/tests/ui/test_test_runner.py index 8b396b6b957d8366c90ebf9717543f6eade3dbd4..fec5a20d8297895d9aa8e09d4f9671525e18d41c 100644 --- a/frappe/tests/ui/test_test_runner.py +++ b/frappe/tests/ui/test_test_runner.py @@ -6,6 +6,7 @@ class TestTestRunner(unittest.TestCase): def test_test_runner(self): driver = TestDriver() driver.login() + frappe.db.set_default('in_selenium', '1') for test in get_tests(): if test.startswith('#'): continue @@ -33,6 +34,7 @@ class TestTestRunner(unittest.TestCase): print('Checking if passed "{0}"'.format(test)) self.assertTrue('Tests Passed' in console) time.sleep(1) + frappe.db.set_default('in_selenium', None) driver.close() def get_tests(): diff --git a/frappe/tests/ui/tests.txt b/frappe/tests/ui/tests.txt index 93907305f70ff408546de05c7277a2947abe1e26..221257babc26c6ed88509383f1693b37bd6b952e 100644 --- a/frappe/tests/ui/tests.txt +++ b/frappe/tests/ui/tests.txt @@ -9,4 +9,5 @@ frappe/tests/ui/test_kanban/test_kanban_filters.js frappe/tests/ui/test_kanban/test_kanban_column.js frappe/core/doctype/report/test_query_report.js frappe/tests/ui/test_linked_with.js -frappe/custom/doctype/customize_form/test_customize_form.js \ No newline at end of file +frappe/custom/doctype/customize_form/test_customize_form.js +frappe/desk/doctype/event/test_event.js diff --git a/frappe/twofactor.py b/frappe/twofactor.py new file mode 100644 index 0000000000000000000000000000000000000000..bb796fd2965ef5b1541d7d31e9322f6c30cf7e93 --- /dev/null +++ b/frappe/twofactor.py @@ -0,0 +1,369 @@ +# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +from __future__ import unicode_literals + +import frappe +from frappe import _ +import pyotp, os +from frappe.utils.background_jobs import enqueue +from jinja2 import Template +from pyqrcode import create as qrcreate +from StringIO import StringIO +from base64 import b64encode, b32encode +from frappe.utils import get_url, get_datetime, time_diff_in_seconds + +class ExpiredLoginException(Exception): pass + +def toggle_two_factor_auth(state, roles=[]): + '''Enable or disable 2FA in site_config and roles''' + for role in roles: + role = frappe.get_doc('Role', {'role_name': role}) + role.two_factor_auth = state + role.save(ignore_permissions=True) + +def two_factor_is_enabled(user=None): + '''Returns True if 2FA is enabled.''' + enabled = int(frappe.db.get_value('System Settings', None, 'enable_two_factor_auth') or 0) + if not user or not enabled: + return enabled + return two_factor_is_enabled_for_(user) + +def should_run_2fa(user): + '''Check if 2fa should run.''' + return two_factor_is_enabled(user=user) + +def get_cached_user_pass(): + '''Get user and password if set.''' + user = pwd = None + tmp_id = frappe.form_dict.get('tmp_id') + if tmp_id: + user = frappe.cache().get(tmp_id+'_usr') + pwd = frappe.cache().get(tmp_id+'_pwd') + return (user, pwd) + +def authenticate_for_2factor(user): + '''Authenticate two factor for enabled user before login.''' + if frappe.form_dict.get('otp'): + return + otp_secret = get_otpsecret_for_(user) + token = int(pyotp.TOTP(otp_secret).now()) + tmp_id = frappe.generate_hash(length=8) + cache_2fa_data(user, token, otp_secret, tmp_id) + verification_obj = get_verification_obj(user, token, otp_secret) + # Save data in local + frappe.local.response['verification'] = verification_obj + frappe.local.response['tmp_id'] = tmp_id + +def cache_2fa_data(user, token, otp_secret, tmp_id): + '''Cache and set expiry for data.''' + pwd = frappe.form_dict.get('pwd') + verification_method = get_verification_method() + + # set increased expiry time for SMS and Email + if verification_method in ['SMS', 'Email']: + expiry_time = 300 + frappe.cache().set(tmp_id + '_token', token) + frappe.cache().expire(tmp_id + '_token', expiry_time) + else: + expiry_time = 180 + for k, v in {'_usr': user, '_pwd': pwd, '_otp_secret': otp_secret}.iteritems(): + frappe.cache().set("{0}{1}".format(tmp_id, k), v) + frappe.cache().expire("{0}{1}".format(tmp_id, k), expiry_time) + +def two_factor_is_enabled_for_(user): + '''Check if 2factor is enabled for user.''' + if isinstance(user, basestring): + user = frappe.get_doc('User', user) + + roles = [frappe.db.escape(d.role) for d in user.roles or []] + roles.append('All') + + query = """select name from `tabRole` where two_factor_auth=1 + and name in ({0}) limit 1""".format(', '.join('\"{}\"'.format(i) for \ + i in roles)) + if len(frappe.db.sql(query)) > 0: + return True + + return False + +def get_otpsecret_for_(user): + '''Set OTP Secret for user even if not set.''' + otp_secret = frappe.db.get_default(user + '_otpsecret') + if not otp_secret: + otp_secret = b32encode(os.urandom(10)).decode('utf-8') + frappe.db.set_default(user + '_otpsecret', otp_secret) + frappe.db.commit() + return otp_secret + +def get_verification_method(): + return frappe.db.get_value('System Settings', None, 'two_factor_method') + +def confirm_otp_token(login_manager, otp=None, tmp_id=None): + '''Confirm otp matches.''' + if not otp: + otp = frappe.form_dict.get('otp') + if not otp: + if two_factor_is_enabled_for_(login_manager.user): + return False + return True + if not tmp_id: + tmp_id = frappe.form_dict.get('tmp_id') + hotp_token = frappe.cache().get(tmp_id + '_token') + otp_secret = frappe.cache().get(tmp_id + '_otp_secret') + if not otp_secret: + raise ExpiredLoginException(_('Login session expired, refresh page to retry')) + hotp = pyotp.HOTP(otp_secret) + if hotp_token: + if hotp.verify(otp, int(hotp_token)): + frappe.cache().delete(tmp_id + '_token') + return True + else: + login_manager.fail(_('Incorrect Verification code'), login_manager.user) + + totp = pyotp.TOTP(otp_secret) + if totp.verify(otp): + # show qr code only once + if not frappe.db.get_default(login_manager.user + '_otplogin'): + frappe.db.set_default(login_manager.user + '_otplogin', 1) + delete_qrimage(login_manager.user) + return True + else: + login_manager.fail(_('Incorrect Verification code'), login_manager.user) + + +def get_verification_obj(user, token, otp_secret): + otp_issuer = frappe.db.get_value('System Settings', 'System Settings', 'otp_issuer_name') + verification_method = get_verification_method() + verification_obj = None + if verification_method == 'SMS': + verification_obj = process_2fa_for_sms(user, token, otp_secret) + elif verification_method == 'OTP App': + #check if this if the first time that the user is trying to login. If so, send an email + if not frappe.db.get_default(user + '_otplogin'): + verification_obj = process_2fa_for_email(user, token, otp_secret, otp_issuer, method='OTP App') + else: + verification_obj = process_2fa_for_otp_app(user, otp_secret, otp_issuer) + elif verification_method == 'Email': + verification_obj = process_2fa_for_email(user, token, otp_secret, otp_issuer) + return verification_obj + + +def process_2fa_for_sms(user, token, otp_secret): + '''Process sms method for 2fa.''' + phone = frappe.db.get_value('User', user, ['phone', 'mobile_no'], as_dict=1) + phone = phone.mobile_no or phone.phone + status = send_token_via_sms(otp_secret, token=token, phone_no=phone) + verification_obj = { + 'token_delivery': status, + 'prompt': status and 'Enter verification code sent to {}'.format(phone[:4] + '******' + phone[-3:]), + 'method': 'SMS', + 'setup': status + } + return verification_obj + +def process_2fa_for_otp_app(user, otp_secret, otp_issuer): + '''Process OTP App method for 2fa.''' + totp_uri = pyotp.TOTP(otp_secret).provisioning_uri(user, issuer_name=otp_issuer) + if frappe.db.get_default(user + '_otplogin'): + otp_setup_completed = True + else: + otp_setup_completed = False + + verification_obj = { + 'totp_uri': totp_uri, + 'method': 'OTP App', + 'qrcode': get_qr_svg_code(totp_uri), + 'setup': otp_setup_completed + } + return verification_obj + +def process_2fa_for_email(user, token, otp_secret, otp_issuer, method='Email'): + '''Process Email method for 2fa.''' + subject = None + message = None + status = True + prompt = '' + if method == 'OTP App' and not frappe.db.get_default(user + '_otplogin'): + '''Sending one-time email for OTP App''' + totp_uri = pyotp.TOTP(otp_secret).provisioning_uri(user, issuer_name=otp_issuer) + qrcode_link = get_link_for_qrcode(user, totp_uri) + message = get_email_body_for_qr_code({'qrcode_link': qrcode_link}) + subject = get_email_subject_for_qr_code({'qrcode_link': qrcode_link}) + prompt = _('Please check your registered email address for instructions on how to proceed. Do not close this window as you will have to return to it.') + else: + '''Sending email verification''' + prompt = _('Verification code has been sent to your registered email address.') + status = send_token_via_email(user, token, otp_secret, otp_issuer, subject=subject, message=message) + verification_obj = { + 'token_delivery': status, + 'prompt': status and prompt, + 'method': 'Email', + 'setup': status + } + return verification_obj + +def get_email_subject_for_2fa(kwargs_dict): + '''Get email subject for 2fa.''' + subject_template = _('Login Verification Code from {}').format(frappe.db.get_value('System Settings', 'System Settings', 'otp_issuer_name')) + subject = render_string_template(subject_template, kwargs_dict) + return subject + +def get_email_body_for_2fa(kwargs_dict): + '''Get email body for 2fa.''' + body_template = 'Enter this code to complete your login:<br><br> <b>{{otp}}</b>' + body = render_string_template(body_template, kwargs_dict) + return body + +def get_email_subject_for_qr_code(kwargs_dict): + '''Get QRCode email subject.''' + subject_template = _('One Time Password (OTP) Registration Code from {}').format(frappe.db.get_value('System Settings', 'System Settings', 'otp_issuer_name')) + subject = render_string_template(subject_template, kwargs_dict) + return subject + +def get_email_body_for_qr_code(kwargs_dict): + '''Get QRCode email body.''' + body_template = 'Please click on the following link and follow the instructions on the page.<br><br> {{qrcode_link}}' + body = render_string_template(body_template, kwargs_dict) + return body + +def render_string_template(_str, kwargs_dict): + '''Render string with jinja.''' + s = Template(_str) + s = s.render(**kwargs_dict) + return s + +def get_link_for_qrcode(user, totp_uri): + '''Get link to temporary page showing QRCode.''' + key = frappe.generate_hash(length=20) + key_user = "{}_user".format(key) + key_uri = "{}_uri".format(key) + lifespan = int(frappe.db.get_value('System Settings', 'System Settings', 'lifespan_qrcode_image')) + if lifespan<=0: + lifespan = 240 + frappe.cache().set_value(key_uri, totp_uri, expires_in_sec=lifespan) + frappe.cache().set_value(key_user, user, expires_in_sec=lifespan) + return get_url('/qrcode?k={}'.format(key)) + +def send_token_via_sms(otpsecret, token=None, phone_no=None): + '''Send token as sms to user.''' + otp_issuer = frappe.db.get_value('System Settings', 'System Settings', 'otp_issuer_name') + try: + from frappe.core.doctype.sms_settings.sms_settings import send_request + except: + return False + + if not phone_no: + return False + + ss = frappe.get_doc('SMS Settings', 'SMS Settings') + if not ss.sms_gateway_url: + return False + + hotp = pyotp.HOTP(otpsecret) + args = {ss.message_parameter: 'Your verification code is {}'.format(hotp.at(int(token))), ss.sms_sender_name: otp_issuer} + for d in ss.get("parameters"): + args[d.parameter] = d.value + + args[ss.receiver_parameter] = phone_no + + sms_args = {'gateway_url': ss.sms_gateway_url, 'params': args} + enqueue(method=send_request, queue='short', timeout=300, event=None, async=True, job_name=None, now=False, **sms_args) + return True + +def send_token_via_email(user, token, otp_secret, otp_issuer, subject=None, message=None): + '''Send token to user as email.''' + user_email = frappe.db.get_value('User', user, 'email') + if not user_email: + return False + hotp = pyotp.HOTP(otp_secret) + otp = hotp.at(int(token)) + template_args = {'otp': otp, 'otp_issuer': otp_issuer} + if not subject: + subject = get_email_subject_for_2fa(template_args) + if not message: + message = get_email_body_for_2fa(template_args) + + email_args = { + 'recipients': user_email, + 'sender': None, + 'subject': subject, + 'message': message, + 'header': [_('Verfication Code'), 'blue'], + 'delayed': False, + 'retry':3 + } + + enqueue(method=frappe.sendmail, queue='short', + timeout=300, event=None, async=True, job_name=None, now=False, **email_args) + return True + +def get_qr_svg_code(totp_uri): + '''Get SVG code to display Qrcode for OTP.''' + url = qrcreate(totp_uri) + svg = '' + stream = StringIO() + try: + url.svg(stream, scale=4, background="#eee", module_color="#222") + svg = stream.getvalue().replace('\n', '') + svg = b64encode(bytes(svg)) + finally: + stream.close() + return svg + +def qrcode_as_png(user, totp_uri): + '''Save temporary Qrcode to server.''' + from frappe.utils.file_manager import save_file + folder = create_barcode_folder() + png_file_name = '{}.png'.format(frappe.generate_hash(length=20)) + file_obj = save_file(png_file_name, png_file_name, 'User', user, folder=folder) + frappe.db.commit() + file_url = get_url(file_obj.file_url) + file_path = os.path.join(frappe.get_site_path('public', 'files'), file_obj.file_name) + url = qrcreate(totp_uri) + with open(file_path, 'w') as png_file: + url.png(png_file, scale=8, module_color=[0, 0, 0, 180], background=[0xff, 0xff, 0xcc]) + return file_url + +def create_barcode_folder(): + '''Get Barcodes folder.''' + folder_name = 'Barcodes' + folder = frappe.db.exists('File', {'file_name': folder_name}) + if folder: + return folder + folder = frappe.get_doc({ + 'doctype': 'File', + 'file_name': folder_name, + 'is_folder':1, + 'folder': 'Home' + }) + folder.insert(ignore_permissions=True) + return folder.name + +def delete_qrimage(user, check_expiry=False): + '''Delete Qrimage when user logs in.''' + user_barcodes = frappe.get_all('File', {'attached_to_doctype': 'User', + 'attached_to_name': user, 'folder': 'Home/Barcodes'}) + for barcode in user_barcodes: + if check_expiry and not should_remove_barcode_image(barcode): continue + barcode = frappe.get_doc('File', barcode.name) + frappe.delete_doc('File', barcode.name, ignore_permissions=True) + +def delete_all_barcodes_for_users(): + '''Task to delete all barcodes for user.''' + users = frappe.get_all('User', {'enabled':1}) + for user in users: + delete_qrimage(user.name, check_expiry=True) + +def should_remove_barcode_image(barcode): + '''Check if it's time to delete barcode image from server. ''' + if isinstance(barcode, basestring): + barcode = frappe.get_doc('File', barcode) + lifespan = frappe.db.get_value('System Settings', 'System Settings', 'lifespan_qrcode_image') + if time_diff_in_seconds(get_datetime(), barcode.creation) > int(lifespan): + return True + return False + +def disable(): + frappe.db.set_value('System Settings', None, 'enable_two_factor_auth', 0) + diff --git a/frappe/utils/redis_wrapper.py b/frappe/utils/redis_wrapper.py index fe90f561f657156f0e7447b51942784911a340ba..4feafda718c6ebe38b4f94016390252dcf814d3b 100644 --- a/frappe/utils/redis_wrapper.py +++ b/frappe/utils/redis_wrapper.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import redis, frappe, re -import cPickle as pickle +from six.moves import cPickle as pickle from frappe.utils import cstr from six import iteritems diff --git a/frappe/website/router.py b/frappe/website/router.py index 66781baf15675fad463559625452d6228e58ce19..ae90645ea43e75732c8609139eaaafd6bc4e226e 100644 --- a/frappe/website/router.py +++ b/frappe/website/router.py @@ -35,7 +35,6 @@ def get_page_context(path): page_context = make_page_context(path) if can_cache(page_context.no_cache): page_context_cache[frappe.local.lang] = page_context - frappe.cache().hset("page_context", path, page_context_cache) return page_context diff --git a/frappe/website/utils.py b/frappe/website/utils.py index c4f167f2bc4d7998b97312fbd73743363efa9969..7a80d03f851ba29842bd00f31b8fb64ba8765dff 100644 --- a/frappe/website/utils.py +++ b/frappe/website/utils.py @@ -24,7 +24,11 @@ def find_first_image(html): return None def can_cache(no_cache=False): - return not (frappe.conf.disable_website_cache or getattr(frappe.local, "no_cache", False) or no_cache) + if frappe.conf.disable_website_cache or frappe.conf.developer_mode: + return False + if getattr(frappe.local, "no_cache", False): + return False + return not no_cache def get_comment_list(doctype, name): return frappe.db.sql("""select diff --git a/frappe/www/desk.html b/frappe/www/desk.html index 5572df07faf5d2c63a88ef078e29ea23c52d5afa..841b82bf99daab74779a6c33fd0afeedaf2a2763 100644 --- a/frappe/www/desk.html +++ b/frappe/www/desk.html @@ -1,6 +1,11 @@ <!DOCTYPE html> <head> - <meta charset="utf-8"> + <!-- Chrome, Firefox OS and Opera --> + <meta name="theme-color" content="#7575ff"> + <!-- Windows Phone --> + <meta name="msapplication-navbutton-color" content="#7575ff"> + <!-- iOS Safari --> + <meta name="apple-mobile-web-app-status-bar-style" content="#7575ff"> <meta charset="utf-8"> <meta content="text/html;charset=utf-8" http-equiv="Content-Type"> <meta content="utf-8" http-equiv="encoding"> <meta name="author" content=""> @@ -15,7 +20,7 @@ <link rel="icon" href="{{ favicon or "/assets/frappe/images/favicon.png" }}" type="image/x-icon"> {% for include in include_css -%} - <link type="text/css" rel="stylesheet" href="{{ include }}"> + <link type="text/css" rel="stylesheet" href="{{ include }}?ver={{ build_version }}"> {%- endfor -%} </head> <body> @@ -50,7 +55,7 @@ </script> {% for include in include_js %} - <script type="text/javascript" src="{{ include }}"></script> + <script type="text/javascript" src="{{ include }}?ver={{ build_version }}"></script> {% endfor %} {% include "templates/includes/app_analytics/google_analytics.html" %} {% include "templates/includes/app_analytics/mixpanel_analytics.html" %} diff --git a/frappe/www/desk.py b/frappe/www/desk.py index ccc9577b34e3fe07e8681d84bb255acd5304a311..7dbaaf41c616727f2be2bf5fb1f32d16a89e9fc1 100644 --- a/frappe/www/desk.py +++ b/frappe/www/desk.py @@ -35,7 +35,8 @@ def get_context(context): # remove script tags from boot boot_json = re.sub("\<script\>[^<]*\</script\>", "", boot_json) - return { + context.update({ + "no_cache": 1, "build_version": get_build_version(), "include_js": hooks["app_include_js"], "include_css": hooks["app_include_css"], @@ -46,7 +47,7 @@ def get_context(context): (boot.user.background_image or boot.default_background_image) or None), "google_analytics_id": frappe.conf.get("google_analytics_id"), "mixpanel_id": frappe.conf.get("mixpanel_id") - } + }) @frappe.whitelist() def get_desk_assets(build_version): @@ -64,7 +65,7 @@ def get_desk_assets(build_version): try: with open(os.path.join(frappe.local.sites_path, path) ,"r") as f: assets[0]["data"] = assets[0]["data"] + "\n" + text_type(f.read(), "utf-8") - except IOError as e: + except IOError: pass for path in data["include_css"]: @@ -78,5 +79,4 @@ def get_desk_assets(build_version): } def get_build_version(): - return str(os.path.getmtime(os.path.join(frappe.local.sites_path, "assets", "js", - "desk.min.js"))) + return str(os.path.getmtime(os.path.join(frappe.local.sites_path, '.build'))) diff --git a/frappe/www/login.html b/frappe/www/login.html index e95f0fe63be012f2e5eec34e618b65b38b5ba344..003b234bb6c48d8db37767dcbd68356fcf49cfef 100644 --- a/frappe/www/login.html +++ b/frappe/www/login.html @@ -9,16 +9,16 @@ {% block page_content %} <!-- {{ for_test }} --> <section class='for-login'> - <div class="login-content page-card" style="margin-top: 20px;"> + <div class="login-content page-card" style="margin-top: 30px;"> <form class="form-signin form-login" role="form"> <div class="page-card-head"> <span class="indicator blue" data-text="{{ _("Sign In") }}"></span> </div> <input type="text" id="login_email" - class="form-control" placeholder="{{ - _('Email address or Mobile number') - if frappe.utils.cint(frappe.db.get_value('System Settings', 'System Settings', 'allow_login_using_mobile_number')) + class="form-control" placeholder="{{ + _('Email address or Mobile number') + if frappe.utils.cint(frappe.db.get_value('System Settings', 'System Settings', 'allow_login_using_mobile_number')) else _('Email address') }}" required autofocus> diff --git a/frappe/www/login.py b/frappe/www/login.py index cc149abbec9b2f7a1022d87febfeb80fb08b73f5..5002a44b35af7a8f5e99d57210aee09158b0eb16 100644 --- a/frappe/www/login.py +++ b/frappe/www/login.py @@ -68,4 +68,3 @@ def login_via_token(login_token): frappe.local.login_manager = LoginManager() redirect_post_login(desk_user = frappe.db.get_value("User", frappe.session.user, "user_type")=="System User") - diff --git a/frappe/www/qrcode.html b/frappe/www/qrcode.html new file mode 100644 index 0000000000000000000000000000000000000000..4cbedb1060e8797ae3b581c3aab235e901659b76 --- /dev/null +++ b/frappe/www/qrcode.html @@ -0,0 +1,27 @@ +{% extends "templates/web.html" %} + +{% block title %}{{ _("QR Code") }}{% endblock %} + +{% block page_content %} +<h1>{{ _("QR Code for Login Verification") }}</h1> +<div class='row'> + <div class='col-sm-6'> + <p>{{ _("Hi {0}").format(qr_code_user.first_name) }},</p> + + <p>{{ _("Steps to verify your login") }}:</p> + <ol> + <li> {{ _("Open your authentication app on your mobile phone.") }} + <li> {{ _("Scan the QR Code and enter the resulting code displayed.") }} + <li> {{ _("Return to the Verification screen and enter the code displayed by your authentication app") }} + </ol> + </p> + <br> + <p class='text-muted small'>{{ _("Authentication Apps you can use are: ") }} + Google Authenticator, Lastpass Authenticator, Authy and Duo Mobile. + </p> + </div> + <div class='col-sm-6' style='padding-top: 15px;'> + <img src="data:image/svg+xml;base64,{{qrcode_svg}}"> + </div> +</div> +{% endblock %} \ No newline at end of file diff --git a/frappe/www/qrcode.py b/frappe/www/qrcode.py new file mode 100644 index 0000000000000000000000000000000000000000..bf7d79236e3d885a8a3f0806ae1aa90e405a1690 --- /dev/null +++ b/frappe/www/qrcode.py @@ -0,0 +1,37 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +from __future__ import unicode_literals + +import frappe +from frappe import _ +from urlparse import parse_qs +from frappe.twofactor import get_qr_svg_code + +def get_context(context): + context.no_cache = 1 + context.qr_code_user,context.qrcode_svg = get_user_svg_from_cache() + +def get_query_key(): + '''Return query string arg.''' + query_string = frappe.local.request.query_string + query = parse_qs(query_string) + if not 'k' in query.keys(): + frappe.throw(_('Not Permitted'),frappe.PermissionError) + query = (query['k'][0]).strip() + if False in [i.isalpha() or i.isdigit() for i in query]: + frappe.throw(_('Not Permitted'),frappe.PermissionError) + return query + +def get_user_svg_from_cache(): + '''Get User and SVG code from cache.''' + key = get_query_key() + totp_uri = frappe.cache().get_value("{}_uri".format(key)) + user = frappe.cache().get_value("{}_user".format(key)) + if not totp_uri or not user: + frappe.throw(_('Page has expired!'),frappe.PermissionError) + if not frappe.db.exists('User',user): + frappe.throw(_('Not Permitted'), frappe.PermissionError) + user = frappe.get_doc('User',user) + svg = get_qr_svg_code(totp_uri) + return (user,svg) diff --git a/package.json b/package.json index b26b4f2a53ed74122dadc3c2bdb9c312c86562f5..10ad3df7ea4d6dc866ab49e79160e0bf53b25a24 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "nightwatch": "^0.9.16", "redis": "^2.7.1", "socket.io": "^2.0.1", - "superagent": "^3.5.2" + "superagent": "^3.5.2", + "touch": "^3.1.0" } } diff --git a/requirements.txt b/requirements.txt index 5beb2ecc3c179b4ed4aa6fecd945f99b8dbd8642..0f6a4ef421a60a35ac28ac0528a04ba46da13475 100644 --- a/requirements.txt +++ b/requirements.txt @@ -41,4 +41,8 @@ oauthlib PyJWT pypdf openpyxl +pyotp +pyqrcode +pypng premailer +