Merge branch 'develop' into kanban-access-w-perms
This commit is contained in:
commit
53563b9283
40 changed files with 528 additions and 113 deletions
2
.github/workflows/linters.yml
vendored
2
.github/workflows/linters.yml
vendored
|
|
@ -83,4 +83,4 @@ jobs:
|
|||
- uses: actions/checkout@v3
|
||||
- run: |
|
||||
pip install pip-audit
|
||||
pip-audit ${GITHUB_WORKSPACE} --ignore-vuln GHSA-hcpj-qp55-gfph
|
||||
pip-audit ${GITHUB_WORKSPACE}
|
||||
|
|
|
|||
|
|
@ -244,7 +244,7 @@ context("Form Builder", () => {
|
|||
let first_field =
|
||||
".tab-content.active .section-columns-container:first .column:first .field:first";
|
||||
|
||||
cy.get(".fields-container .field[title='Check']").drag(first_field, {
|
||||
cy.get(".fields-container .field[title='Data']").drag(first_field, {
|
||||
target: { x: 100, y: 10 },
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -109,7 +109,8 @@ def new_site(
|
|||
"--with-public-files", help="Restores the public files of the site, given path to its tar file"
|
||||
)
|
||||
@click.option(
|
||||
"--with-private-files", help="Restores the private files of the site, given path to its tar file"
|
||||
"--with-private-files",
|
||||
help="Restores the private files of the site, given path to its tar file",
|
||||
)
|
||||
@click.option(
|
||||
"--force",
|
||||
|
|
@ -191,7 +192,8 @@ def _restore(
|
|||
fg="red",
|
||||
)
|
||||
click.secho(
|
||||
"Use `bench partial-restore` to restore a partial backup to an existing site.", fg="yellow"
|
||||
"Use `bench partial-restore` to restore a partial backup to an existing site.",
|
||||
fg="yellow",
|
||||
)
|
||||
_backup.decryption_rollback()
|
||||
sys.exit(1)
|
||||
|
|
@ -222,7 +224,8 @@ def _restore(
|
|||
fg="red",
|
||||
)
|
||||
click.secho(
|
||||
"Use `bench partial-restore` to restore a partial backup to an existing site.", fg="yellow"
|
||||
"Use `bench partial-restore` to restore a partial backup to an existing site.",
|
||||
fg="yellow",
|
||||
)
|
||||
_backup.decryption_rollback()
|
||||
sys.exit(1)
|
||||
|
|
@ -324,7 +327,8 @@ def partial_restore(context, sql_file_path, verbose, encryption_key=None):
|
|||
# Check for full backup file
|
||||
if "Partial Backup" not in header:
|
||||
click.secho(
|
||||
"Full backup file detected.Use `bench restore` to restore a Frappe Site.", fg="red"
|
||||
"Full backup file detected.Use `bench restore` to restore a Frappe Site.",
|
||||
fg="red",
|
||||
)
|
||||
_backup.decryption_rollback()
|
||||
sys.exit(1)
|
||||
|
|
@ -355,7 +359,8 @@ def partial_restore(context, sql_file_path, verbose, encryption_key=None):
|
|||
# Check for Full backup file.
|
||||
if "Partial Backup" not in header:
|
||||
click.secho(
|
||||
"Full Backup file detected.Use `bench restore` to restore a Frappe Site.", fg="red"
|
||||
"Full Backup file detected.Use `bench restore` to restore a Frappe Site.",
|
||||
fg="red",
|
||||
)
|
||||
_backup.decryption_rollback()
|
||||
sys.exit(1)
|
||||
|
|
@ -391,7 +396,12 @@ def reinstall(
|
|||
|
||||
|
||||
def _reinstall(
|
||||
site, admin_password=None, db_root_username=None, db_root_password=None, yes=False, verbose=False
|
||||
site,
|
||||
admin_password=None,
|
||||
db_root_username=None,
|
||||
db_root_password=None,
|
||||
yes=False,
|
||||
verbose=False,
|
||||
):
|
||||
from frappe.installer import _new_site
|
||||
from frappe.utils.synchronization import filelock
|
||||
|
|
@ -719,7 +729,10 @@ def use(site, sites_path="."):
|
|||
@click.option("--backup-path-private-files", default=None, help="Set path for saving private file")
|
||||
@click.option("--backup-path-conf", default=None, help="Set path for saving config file")
|
||||
@click.option(
|
||||
"--ignore-backup-conf", default=False, is_flag=True, help="Ignore excludes/includes set in config"
|
||||
"--ignore-backup-conf",
|
||||
default=False,
|
||||
is_flag=True,
|
||||
help="Ignore excludes/includes set in config",
|
||||
)
|
||||
@click.option("--verbose", default=False, is_flag=True, help="Add verbosity")
|
||||
@click.option("--compress", default=False, is_flag=True, help="Compress private and public files")
|
||||
|
|
@ -774,7 +787,8 @@ def backup(
|
|||
continue
|
||||
if frappe.get_system_settings("encrypt_backup") and frappe.get_site_config().encryption_key:
|
||||
click.secho(
|
||||
"Backup encryption is turned on. Please note the backup encryption key.", fg="yellow"
|
||||
"Backup encryption is turned on. Please note the backup encryption key.",
|
||||
fg="yellow",
|
||||
)
|
||||
|
||||
odb.print_summary()
|
||||
|
|
@ -1120,14 +1134,31 @@ def stop_recording(context):
|
|||
@click.option(
|
||||
"--bind-tls", is_flag=True, default=False, help="Returns a reference to the https tunnel."
|
||||
)
|
||||
@click.option(
|
||||
"--use-default-authtoken",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Use the auth token present in ngrok's config.",
|
||||
)
|
||||
@pass_context
|
||||
def start_ngrok(context, bind_tls):
|
||||
def start_ngrok(context, bind_tls, use_default_authtoken):
|
||||
"""Start a ngrok tunnel to your local development server."""
|
||||
from pyngrok import ngrok
|
||||
|
||||
site = get_site(context)
|
||||
frappe.init(site=site)
|
||||
|
||||
ngrok_authtoken = frappe.conf.ngrok_authtoken
|
||||
if not use_default_authtoken:
|
||||
if not ngrok_authtoken:
|
||||
click.echo(
|
||||
f"\n{click.style('ngrok_authtoken', fg='yellow')} not found in site config.\n"
|
||||
"Please register for a free ngrok account at: https://dashboard.ngrok.com/signup and place the obtained authtoken in the site config.",
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
ngrok.set_auth_token(ngrok_authtoken)
|
||||
|
||||
port = frappe.conf.http_port or frappe.conf.webserver_port
|
||||
tunnel = ngrok.connect(addr=str(port), host_header=site, bind_tls=bind_tls)
|
||||
print(f"Public URL: {tunnel.public_url}")
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ def unzip_file(name: str):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_attached_images(doctype: str, names: list[str]) -> frappe._dict:
|
||||
def get_attached_images(doctype: str, names: list[str] | str) -> frappe._dict:
|
||||
"""get list of image urls attached in form
|
||||
returns {name: ['image.jpg', 'image.png']}"""
|
||||
|
||||
|
|
|
|||
|
|
@ -195,10 +195,12 @@ class DocType(Document):
|
|||
|
||||
def set_default_in_list_view(self):
|
||||
"""Set default in-list-view for first 4 mandatory fields"""
|
||||
not_allowed_in_list_view = get_fields_not_allowed_in_list_view(self.meta)
|
||||
|
||||
if not [d.fieldname for d in self.fields if d.in_list_view]:
|
||||
cnt = 0
|
||||
for d in self.fields:
|
||||
if d.reqd and not d.hidden and not d.fieldtype in table_fields:
|
||||
if d.reqd and not d.hidden and not d.fieldtype in not_allowed_in_list_view:
|
||||
d.in_list_view = 1
|
||||
cnt += 1
|
||||
if cnt == 4:
|
||||
|
|
@ -1446,10 +1448,7 @@ def validate_fields(meta):
|
|||
fields = meta.get("fields")
|
||||
fieldname_list = [d.fieldname for d in fields]
|
||||
|
||||
not_allowed_in_list_view = list(copy.copy(no_value_fields))
|
||||
not_allowed_in_list_view.append("Attach Image")
|
||||
if meta.istable:
|
||||
not_allowed_in_list_view.remove("Button")
|
||||
not_allowed_in_list_view = get_fields_not_allowed_in_list_view(meta)
|
||||
|
||||
for d in fields:
|
||||
if not d.permlevel:
|
||||
|
|
@ -1490,6 +1489,14 @@ def validate_fields(meta):
|
|||
check_image_field(meta)
|
||||
|
||||
|
||||
def get_fields_not_allowed_in_list_view(meta) -> list[str]:
|
||||
not_allowed_in_list_view = list(copy.copy(no_value_fields))
|
||||
not_allowed_in_list_view.append("Attach Image")
|
||||
if meta.istable:
|
||||
not_allowed_in_list_view.remove("Button")
|
||||
return not_allowed_in_list_view
|
||||
|
||||
|
||||
def validate_permissions_for_doctype(doctype, for_remove=False, alert=False):
|
||||
"""Validates if permissions are set correctly."""
|
||||
doctype = frappe.get_doc("DocType", doctype)
|
||||
|
|
|
|||
|
|
@ -722,6 +722,28 @@ class TestDocType(FrappeTestCase):
|
|||
self.assertEqual(frappe.get_meta(doctype).get_field(field).default, "DELETETHIS")
|
||||
frappe.delete_doc("DocType", doctype)
|
||||
|
||||
def test_not_in_list_view_for_not_allowed_mandatory_field(self):
|
||||
doctype = new_doctype(
|
||||
fields=[
|
||||
{
|
||||
"fieldname": "cover_image",
|
||||
"fieldtype": "Attach Image",
|
||||
"label": "Cover Image",
|
||||
"reqd": 1, # mandatory
|
||||
},
|
||||
{
|
||||
"fieldname": "book_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Book Name",
|
||||
"reqd": 1, # mandatory
|
||||
},
|
||||
],
|
||||
).insert()
|
||||
|
||||
self.assertFalse(doctype.fields[0].in_list_view)
|
||||
self.assertTrue(doctype.fields[1].in_list_view)
|
||||
frappe.delete_doc("DocType", doctype.name)
|
||||
|
||||
|
||||
def new_doctype(
|
||||
name: str | None = None,
|
||||
|
|
@ -759,8 +781,7 @@ def new_doctype(
|
|||
}
|
||||
)
|
||||
|
||||
if fields:
|
||||
for f in fields:
|
||||
doc.append("fields", f)
|
||||
if fields and len(fields) > 0:
|
||||
doc.set("fields", fields)
|
||||
|
||||
return doc
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@
|
|||
"allow_login_using_mobile_number",
|
||||
"allow_login_using_user_name",
|
||||
"disable_user_pass_login",
|
||||
"login_with_email_link",
|
||||
"login_with_email_link_expiry",
|
||||
"allow_error_traceback",
|
||||
"strip_exif_metadata_from_uploaded_images",
|
||||
"allow_older_web_view_links",
|
||||
|
|
@ -416,11 +418,11 @@
|
|||
"label": "Send document Web View link in email"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "prepared_report_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Reports"
|
||||
},
|
||||
"collapsible": 1,
|
||||
"fieldname": "prepared_report_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Reports"
|
||||
},
|
||||
{
|
||||
"default": "Frappe",
|
||||
"description": "The application name will be used in the Login page.",
|
||||
|
|
@ -504,12 +506,26 @@
|
|||
"fieldname": "disable_user_pass_login",
|
||||
"fieldtype": "Check",
|
||||
"label": "Disable Username/Password Login"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Allow users to log in without a password, using a login link sent to their email",
|
||||
"fieldname": "login_with_email_link",
|
||||
"fieldtype": "Check",
|
||||
"label": "Login with email link"
|
||||
},
|
||||
{
|
||||
"default": "10",
|
||||
"depends_on": "login_with_email_link",
|
||||
"fieldname": "login_with_email_link_expiry",
|
||||
"fieldtype": "Int",
|
||||
"label": "Login with email link expiry (in minutes)"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-cog",
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2022-11-28 17:57:05.099512",
|
||||
"modified": "2022-12-20 21:45:37.651668",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "System Settings",
|
||||
|
|
|
|||
|
|
@ -57,18 +57,16 @@
|
|||
],
|
||||
"icon": "fa fa-comment",
|
||||
"links": [],
|
||||
"modified": "2022-01-04 14:12:50.321633",
|
||||
"modified": "2023-01-02 03:56:48.437280",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Email",
|
||||
"name": "Email Template",
|
||||
"naming_rule": "Set by user",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"read": 1,
|
||||
"role": "All",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
"role": "All"
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
|
|
@ -85,5 +83,6 @@
|
|||
],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import json
|
|||
|
||||
# all country info
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
import frappe
|
||||
from frappe.utils.momentjs import get_all_timezones
|
||||
|
|
@ -27,8 +28,13 @@ def get_all():
|
|||
return all_data
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_country_timezone_info():
|
||||
return _get_country_timezone_info()
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _get_country_timezone_info():
|
||||
return {"country_info": get_all(), "all_timezones": get_all_timezones()}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ class Document(BaseDocument):
|
|||
"*",
|
||||
as_dict=True,
|
||||
order_by="idx asc",
|
||||
for_update=self.flags.for_update,
|
||||
)
|
||||
or []
|
||||
)
|
||||
|
|
|
|||
|
|
@ -111,12 +111,6 @@ class Meta(Document):
|
|||
]
|
||||
|
||||
def __init__(self, doctype):
|
||||
# from cache
|
||||
if isinstance(doctype, dict):
|
||||
super().__init__(doctype)
|
||||
self.init_field_caches()
|
||||
return
|
||||
|
||||
if isinstance(doctype, Document):
|
||||
super().__init__(doctype.as_dict())
|
||||
else:
|
||||
|
|
@ -135,7 +129,7 @@ class Meta(Document):
|
|||
|
||||
def process(self):
|
||||
# don't process for special doctypes
|
||||
# prevent's circular dependency
|
||||
# prevents circular dependency
|
||||
if self.name in self.special_doctypes:
|
||||
self.init_field_caches()
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup>
|
||||
import draggable from "vuedraggable";
|
||||
import Field from "./Field.vue";
|
||||
import EditableInput from "./EditableInput.vue";
|
||||
import { ref } from "vue";
|
||||
import { useStore } from "../store";
|
||||
import { move_children_to_parent } from "../utils";
|
||||
|
|
@ -55,6 +56,7 @@ function remove_column() {
|
|||
|
||||
// remove column
|
||||
columns.splice(index, 1);
|
||||
store.selected_field = null;
|
||||
}
|
||||
|
||||
function move_columns_to_section() {
|
||||
|
|
@ -74,25 +76,43 @@ function move_columns_to_section() {
|
|||
@mouseover.stop="hovered = true"
|
||||
@mouseout.stop="hovered = false"
|
||||
>
|
||||
<div class="column-actions" :hidden="store.read_only">
|
||||
<button
|
||||
v-if="section.columns.indexOf(column)"
|
||||
class="btn btn-xs btn-icon"
|
||||
:title="__('Move the current column & the following columns to a new section')"
|
||||
@click="move_columns_to_section"
|
||||
>
|
||||
<div v-html="frappe.utils.icon('move', 'sm')"></div>
|
||||
</button>
|
||||
<button class="btn btn-xs btn-icon" :title="__('Add Column')" @click="add_column">
|
||||
<div v-html="frappe.utils.icon('add', 'sm')"></div>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs btn-icon"
|
||||
:title="__('Remove Column')"
|
||||
@click="remove_column"
|
||||
>
|
||||
<div v-html="frappe.utils.icon('remove', 'sm')"></div>
|
||||
</button>
|
||||
<div
|
||||
:class="[
|
||||
'column-header',
|
||||
column.df.label ? 'has-label' : '',
|
||||
]"
|
||||
:hidden="!column.df.label && store.read_only"
|
||||
>
|
||||
<div class="column-label">
|
||||
<EditableInput
|
||||
:text="column.df.label"
|
||||
:placeholder="__('Column Title')"
|
||||
v-model="column.df.label"
|
||||
/>
|
||||
</div>
|
||||
<div class="column-actions">
|
||||
<button
|
||||
v-if="section.columns.indexOf(column)"
|
||||
class="btn btn-xs btn-icon"
|
||||
:title="__('Move the current column & the following columns to a new section')"
|
||||
@click="move_columns_to_section"
|
||||
>
|
||||
<div v-html="frappe.utils.icon('move', 'sm')"></div>
|
||||
</button>
|
||||
<button class="btn btn-xs btn-icon" :title="__('Add Column')" @click="add_column">
|
||||
<div v-html="frappe.utils.icon('add', 'sm')"></div>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs btn-icon"
|
||||
:title="__('Remove Column')"
|
||||
@click.stop="remove_column"
|
||||
>
|
||||
<div v-html="frappe.utils.icon('remove', 'sm')"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="column.df.description" class="column-description">
|
||||
{{ column.df.description }}
|
||||
</div>
|
||||
<draggable
|
||||
class="column-container"
|
||||
|
|
@ -140,7 +160,7 @@ function move_columns_to_section() {
|
|||
}
|
||||
|
||||
&.selected {
|
||||
.column-actions {
|
||||
.column-header {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
|
|
@ -149,6 +169,48 @@ function move_columns_to_section() {
|
|||
}
|
||||
}
|
||||
|
||||
.column-header {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-left: 0.3rem;
|
||||
|
||||
&.has-label {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.column-label {
|
||||
:deep(span) {
|
||||
font-weight: 600;
|
||||
color: var(--heading-color);
|
||||
}
|
||||
}
|
||||
|
||||
.column-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.btn.btn-icon {
|
||||
padding: 2px;
|
||||
box-shadow: none;
|
||||
opacity: 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background-color: var(--fg-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.column-description {
|
||||
margin-bottom: 10px;
|
||||
margin-left: 0.3rem;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
|
@ -162,22 +224,5 @@ function move_columns_to_section() {
|
|||
min-height: 2rem;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.column-actions {
|
||||
display: none;
|
||||
justify-content: flex-end;
|
||||
padding-bottom: 0.5rem;
|
||||
|
||||
.btn.btn-icon {
|
||||
padding: 2px;
|
||||
box-shadow: none;
|
||||
opacity: 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background-color: var(--fg-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ function remove_field() {
|
|||
}
|
||||
let index = props.column.fields.indexOf(props.field);
|
||||
props.column.fields.splice(index, 1);
|
||||
store.selected_field = null;
|
||||
}
|
||||
|
||||
function move_fields_to_column() {
|
||||
|
|
@ -75,7 +76,7 @@ function move_fields_to_column() {
|
|||
>
|
||||
<div v-html="frappe.utils.icon('move', 'sm')"></div>
|
||||
</button>
|
||||
<button class="btn btn-xs btn-icon" @click="remove_field">
|
||||
<button class="btn btn-xs btn-icon" @click.stop="remove_field">
|
||||
<div v-html="frappe.utils.icon('remove', 'sm')"></div>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ let docfield_df = computed(() => {
|
|||
return false;
|
||||
}
|
||||
|
||||
if (df.fieldname === "reqd" && store.selected_field.fieldtype === "Check") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (df.fieldname === "options") {
|
||||
df.fieldtype = "Small Text";
|
||||
df.options = "";
|
||||
|
|
|
|||
|
|
@ -210,6 +210,10 @@ onMounted(() => {
|
|||
}
|
||||
}
|
||||
|
||||
.section-description {
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
.section-columns {
|
||||
margin-top: 8px;
|
||||
|
||||
|
|
@ -219,6 +223,14 @@ onMounted(() => {
|
|||
padding-right: 15px;
|
||||
margin: 0;
|
||||
|
||||
.column-header {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.column-description {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin: 0;
|
||||
margin-bottom: 1rem;
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ function remove_section() {
|
|||
|
||||
// remove section
|
||||
sections.splice(index, 1);
|
||||
store.selected_field = null;
|
||||
}
|
||||
|
||||
function select_section() {
|
||||
|
|
@ -122,12 +123,13 @@ function move_sections_to_tab() {
|
|||
<button
|
||||
class="btn btn-xs btn-section"
|
||||
:title="__('Remove section')"
|
||||
@click="remove_section"
|
||||
@click.stop="remove_section"
|
||||
>
|
||||
<div v-html="frappe.utils.icon('remove', 'sm')"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="section.df.description" class="section-description">{{ section.df.description }}</div>
|
||||
<div class="section-columns" :class="{ hidden: section.df.collapsible && collapsed }">
|
||||
<draggable
|
||||
class="section-columns-container"
|
||||
|
|
@ -206,6 +208,7 @@ function move_sections_to_tab() {
|
|||
|
||||
:deep(span) {
|
||||
font-weight: 600;
|
||||
color: var(--heading-color);
|
||||
}
|
||||
|
||||
.collapse-indicator {
|
||||
|
|
@ -228,6 +231,12 @@ function move_sections_to_tab() {
|
|||
}
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin-bottom: 10px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.section-columns-container {
|
||||
display: flex;
|
||||
min-height: 2rem;
|
||||
|
|
|
|||
|
|
@ -45,18 +45,26 @@ class FormBuilder {
|
|||
this.store.read_only = this.store.preview;
|
||||
this.read_only = true;
|
||||
});
|
||||
this.customize_form_btn = this.page.add_button(__("For Customize Form"), () => {
|
||||
frappe.set_route("form-builder", this.doctype, "customize");
|
||||
});
|
||||
this.doctype_form_btn = this.page.add_button(__("For DocType Form"), () => {
|
||||
frappe.set_route("form-builder", this.doctype);
|
||||
});
|
||||
|
||||
this.reset_changes_btn = this.page.add_button(__("Reset Changes"), () => {
|
||||
this.store.reset_changes();
|
||||
});
|
||||
|
||||
this.go_to_doctype_btn = this.page.add_menu_item(__("Go to Doctype"), () =>
|
||||
this.go_to_doctype_list_btn = this.page.add_button(
|
||||
__("Go to {0} List", [__(this.doctype)]),
|
||||
() => {
|
||||
window.open(`/app/${frappe.router.slug(this.doctype)}`);
|
||||
}
|
||||
);
|
||||
|
||||
this.customize_form_btn = this.page.add_menu_item(__("Switch to Customize Form"), () => {
|
||||
frappe.set_route("form-builder", this.doctype, "customize");
|
||||
});
|
||||
this.doctype_form_btn = this.page.add_menu_item(__("Switch to DocType Form"), () => {
|
||||
frappe.set_route("form-builder", this.doctype);
|
||||
});
|
||||
|
||||
this.go_to_doctype_btn = this.page.add_menu_item(__("Go to DocType"), () =>
|
||||
frappe.set_route("Form", "DocType", this.doctype)
|
||||
);
|
||||
this.go_to_customize_form_btn = this.page.add_menu_item(__("Go to Customize Form"), () =>
|
||||
|
|
@ -121,9 +129,7 @@ class FormBuilder {
|
|||
? __("Go to {0}", [__(this.doctype)])
|
||||
: __("Go to {0} List", [__(this.doctype)]);
|
||||
|
||||
this.page.add_menu_item(label, () => {
|
||||
window.open(`/app/${frappe.router.slug(this.doctype)}`);
|
||||
});
|
||||
this.go_to_doctype_list_btn.text(label);
|
||||
}
|
||||
|
||||
// toggle preview btn text
|
||||
|
|
|
|||
|
|
@ -226,8 +226,13 @@ export const useStore = defineStore("form-builder-store", {
|
|||
}
|
||||
|
||||
section.columns.forEach((column, k) => {
|
||||
// do not consider first column
|
||||
if (k > 0 || column.fields.length == 0) {
|
||||
// do not consider first column if label is not set
|
||||
if (
|
||||
(k == 0 &&
|
||||
this.is_df_updated(column.df, this.get_df("Column Break"))) ||
|
||||
k > 0 ||
|
||||
column.fields.length == 0
|
||||
) {
|
||||
idx++;
|
||||
column.df.idx = idx;
|
||||
fields.push(column.df);
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@ export function scrub_field_names(fields) {
|
|||
if (d.fieldtype) {
|
||||
if (!d.fieldname) {
|
||||
if (d.label) {
|
||||
d.fieldname = d.label.trim().toLowerCase().replace(" ", "_");
|
||||
d.fieldname = d.label.trim().toLowerCase().replaceAll(" ", "_");
|
||||
if (d.fieldname.endsWith("?")) {
|
||||
d.fieldname = d.fieldname.slice(0, -1);
|
||||
}
|
||||
|
|
@ -295,7 +295,7 @@ export function scrub_field_names(fields) {
|
|||
}
|
||||
} else {
|
||||
d.fieldname =
|
||||
d.fieldtype.toLowerCase().replace(" ", "_") +
|
||||
d.fieldtype.toLowerCase().replaceAll(" ", "_") +
|
||||
"_" +
|
||||
frappe.utils.get_random(4);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,12 +19,20 @@ export default class Column {
|
|||
|
||||
this.form = this.wrapper.find("form").on("submit", () => false);
|
||||
|
||||
if (this.df.description) {
|
||||
$(`
|
||||
<p class="col-sm-12 form-column-description">
|
||||
${__(this.df.description)}
|
||||
</p>
|
||||
`).prependTo(this.wrapper);
|
||||
}
|
||||
|
||||
if (this.df.label) {
|
||||
$(`
|
||||
<label class="control-label">
|
||||
<label class="column-label">
|
||||
${__(this.df.label)}
|
||||
</label>
|
||||
`).appendTo(this.wrapper);
|
||||
`).prependTo(this.wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,14 @@ frappe.ui.form.Control = class BaseControl {
|
|||
.attr("data-fieldname", this.df.fieldname);
|
||||
this.wrapper = this.$wrapper.get(0);
|
||||
this.wrapper.fieldobj = this; // reference for event handlers
|
||||
this.$wrapper.append(`<span class="tooltip-content">${__(this.df.fieldname)}</span>`);
|
||||
|
||||
this.tooltip = $(`<span class="tooltip-content">${__(this.df.fieldname)}</span>`);
|
||||
this.$wrapper.append(this.tooltip);
|
||||
|
||||
this.tooltip.on("click", (e) => {
|
||||
let text = $(e.target).text();
|
||||
frappe.utils.copy_to_clipboard(text);
|
||||
});
|
||||
}
|
||||
|
||||
make_wrapper() {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ frappe.ui.form.Layout = class Layout {
|
|||
}
|
||||
|
||||
this.setup_tab_events();
|
||||
this.setup_tooltip_events();
|
||||
this.frm && this.setup_tooltip_events();
|
||||
this.render();
|
||||
}
|
||||
|
||||
|
|
@ -532,15 +532,21 @@ frappe.ui.form.Layout = class Layout {
|
|||
|
||||
setup_tooltip_events() {
|
||||
$(document).on("keydown", (e) => {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
if (e.altKey) {
|
||||
this.wrapper.addClass("show-tooltip");
|
||||
}
|
||||
});
|
||||
$(document).on("keyup", (e) => {
|
||||
if (!e.metaKey || !e.ctrlKey) {
|
||||
if (!e.altKey) {
|
||||
this.wrapper.removeClass("show-tooltip");
|
||||
}
|
||||
});
|
||||
this.frm.page &&
|
||||
frappe.ui.keys.add_shortcut({
|
||||
shortcut: "alt+hover",
|
||||
page: this.frm.page,
|
||||
description: __("Show Fieldname (click to copy on clipboard)"),
|
||||
});
|
||||
}
|
||||
|
||||
handle_tab(doctype, fieldname, shift) {
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ $input-height: 28px !default;
|
|||
--bg-red: var(--red-50);
|
||||
--bg-gray: var(--gray-200);
|
||||
--bg-light-gray: var(--gray-100);
|
||||
--bg-dark-gray: var(--gray-900);
|
||||
--bg-purple: var(--purple-100);
|
||||
--bg-pink: var(--pink-50);
|
||||
--bg-cyan: var(--cyan-50);
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@
|
|||
--bg-red: var(--red-500);
|
||||
--bg-gray: var(--gray-600);
|
||||
--bg-light-gray: var(--gray-700);
|
||||
--bg-dark-gray: var(--gray-300);
|
||||
--bg-purple: var(--purple-600);
|
||||
|
||||
--text-on-blue: var(--blue-50);
|
||||
|
|
|
|||
|
|
@ -3,16 +3,16 @@
|
|||
|
||||
.tooltip-content {
|
||||
position: absolute;
|
||||
bottom: 104%;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
z-index: 9999;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--gray-dark);
|
||||
background-color: var(--bg-dark-gray);
|
||||
color: var(--text-dark);
|
||||
font-size: var(--text-xs);
|
||||
opacity: 0;
|
||||
cursor: default;
|
||||
cursor: copy;
|
||||
transition: opacity 0.3s, transform 3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
|
@ -70,6 +70,21 @@
|
|||
}
|
||||
}
|
||||
|
||||
.form-column {
|
||||
.form-column-description {
|
||||
margin-bottom: 10px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.column-label {
|
||||
font-weight: 600;
|
||||
color: var(--heading-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-section {
|
||||
display: none !important;
|
||||
border: 0 !important;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ body {
|
|||
}
|
||||
|
||||
.for-forgot,
|
||||
.for-login-with-email-link,
|
||||
.for-signup,
|
||||
.for-email-login {
|
||||
display: none;
|
||||
|
|
@ -14,6 +15,7 @@ body {
|
|||
|
||||
.for-login,
|
||||
.for-forgot,
|
||||
.for-login-with-email-link,
|
||||
.for-signup,
|
||||
.for-email-login {
|
||||
padding: max(10vh, 60px) 0;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@
|
|||
max-width: 800px;
|
||||
margin: auto;
|
||||
|
||||
.tooltip-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.25rem;
|
||||
margin-top: 0;
|
||||
|
|
|
|||
41
frappe/templates/emails/login_with_email_link.html
Normal file
41
frappe/templates/emails/login_with_email_link.html
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{% macro table(content, table_class) %}
|
||||
<table class="{{ table_class or '' }}" cellpadding="0" cellspacing="0" width="100%" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center">
|
||||
{{ content }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro body() %}
|
||||
<table width="100%" cellspacing="0" cellpadding="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<div class="email-header-title">
|
||||
{{ _('Click on the button to log in to {0}').format(app_name) }}
|
||||
</div>
|
||||
<div>{{ _('The link will expire in {0} minutes').format(minutes) }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<div class="btn btn-primary" style="margin-top: 30px;">
|
||||
<a href="{{ link or '#'}}" style="color: #fff; text-decoration: none;">
|
||||
{{ _('Log In To {0}').format(app_name) }}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endmacro %}
|
||||
|
||||
<div class="body-table with-container">
|
||||
<div class="body-content">
|
||||
{{ table(table(body(), 'email-body'), 'email-container') }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -26,9 +26,10 @@ def like(reference_doctype, reference_name, like, route=""):
|
|||
clear_cache(route)
|
||||
|
||||
if like and ref_doc.enable_email_notification:
|
||||
subject = _("Like on {0}: {1}").format(reference_doctype, reference_name)
|
||||
ref_doc_title = ref_doc.get_title()
|
||||
subject = _("Like on {0}: {1}").format(reference_doctype, ref_doc_title)
|
||||
content = _("You have received a ❤️ like on your blog post")
|
||||
message = f"<p>{content} <b>{reference_name}</b></p>"
|
||||
message = f"<p>{content} <b>{ref_doc_title}</b></p>"
|
||||
message = message + "<p><a href='{}/{}#likes' style='font-size: 80%'>{}</a></p>".format(
|
||||
frappe.utils.get_request_site_address(), ref_doc.route, _("View Blog Post")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -55,6 +55,25 @@ login.bind_events = function () {
|
|||
return false;
|
||||
});
|
||||
|
||||
$(".form-login-with-email-link").on("submit", function (event) {
|
||||
event.preventDefault();
|
||||
var args = {};
|
||||
args.cmd = "frappe.www.login.send_login_link";
|
||||
args.email = ($("#login_with_email_link_email").val() || "").trim();
|
||||
if (!args.email) {
|
||||
login.set_status('{{ _("Valid Login id required.") }}', 'red');
|
||||
return false;
|
||||
}
|
||||
login.call(args).then(() => {
|
||||
login.set_status('{{ _("Login link sent to your email") }}', 'blue');
|
||||
$("#login_with_email_link_email").val("");
|
||||
}).catch(() => {
|
||||
login.set_status('{{ _("Send login link") }}', 'blue');
|
||||
});
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
$(".toggle-password").click(function () {
|
||||
var input = $($(this).attr("toggle"));
|
||||
if (input.attr("type") == "password") {
|
||||
|
|
@ -86,6 +105,7 @@ login.bind_events = function () {
|
|||
login.route = function () {
|
||||
var route = window.location.hash.slice(1);
|
||||
if (!route) route = "login";
|
||||
route = route.replaceAll("-", "_");
|
||||
login[route]();
|
||||
}
|
||||
|
||||
|
|
@ -94,6 +114,7 @@ login.reset_sections = function (hide) {
|
|||
$("section.for-login").toggle(false);
|
||||
$("section.for-email-login").toggle(false);
|
||||
$("section.for-forgot").toggle(false);
|
||||
$("section.for-login-with-email-link").toggle(false);
|
||||
$("section.for-signup").toggle(false);
|
||||
}
|
||||
$('section:not(.signup-disabled) .indicator').each(function () {
|
||||
|
|
@ -121,10 +142,22 @@ login.steptwo = function () {
|
|||
|
||||
login.forgot = function () {
|
||||
login.reset_sections();
|
||||
if ($("#login_email").val()) {
|
||||
$("#forgot_email").val($("#login_email").val());
|
||||
}
|
||||
$(".for-forgot").toggle(true);
|
||||
$("#forgot_email").focus();
|
||||
}
|
||||
|
||||
login.login_with_email_link = function () {
|
||||
login.reset_sections();
|
||||
if ($("#login_email").val()) {
|
||||
$("#login_with_email_link_email").val($("#login_email").val());
|
||||
}
|
||||
$(".for-login-with-email-link").toggle(true);
|
||||
$("#login_with_email_link_email").focus();
|
||||
}
|
||||
|
||||
login.signup = function () {
|
||||
login.reset_sections();
|
||||
$(".for-signup").toggle(true);
|
||||
|
|
@ -270,7 +303,7 @@ frappe.ready(function () {
|
|||
$(window).trigger("hashchange");
|
||||
}
|
||||
|
||||
$(".form-signup, .form-forgot").removeClass("hide");
|
||||
$(".form-signup, .form-forgot, .form-login-with-email-link").removeClass("hide");
|
||||
$(document).trigger('login_rendered');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@
|
|||
# License: MIT. See LICENSE
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
import frappe
|
||||
import frappe.utils
|
||||
from frappe.auth import LoginAttemptTracker
|
||||
from frappe.frappeclient import AuthError, FrappeClient
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.www.login import _generate_temporary_login_link
|
||||
|
||||
|
||||
def add_user(email, password, username=None, mobile_no=None):
|
||||
|
|
@ -42,6 +45,9 @@ class TestAuth(FrappeTestCase):
|
|||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
frappe.delete_doc("User", cls.test_user_email, force=True)
|
||||
frappe.local.request_ip = None
|
||||
frappe.form_dict.email = None
|
||||
frappe.local.response["http_status_code"] = None
|
||||
|
||||
def set_system_settings(self, k, v):
|
||||
frappe.db.set_value("System Settings", "System Settings", k, v)
|
||||
|
|
@ -123,6 +129,32 @@ class TestAuth(FrappeTestCase):
|
|||
with self.assertRaises(Exception):
|
||||
FrappeClient(self.HOST_NAME, self.test_user_email, self.test_user_password).get_list("ToDo")
|
||||
|
||||
def test_login_with_email_link(self):
|
||||
|
||||
user = self.test_user_email
|
||||
|
||||
# Logs in
|
||||
res = requests.get(_generate_temporary_login_link(user, 10))
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertTrue(res.cookies.get("sid"))
|
||||
self.assertNotEqual(res.cookies.get("sid"), "Guest")
|
||||
|
||||
# Random incorrect URL
|
||||
res = requests.get(_generate_temporary_login_link(user, 10) + "aa")
|
||||
self.assertEqual(res.cookies.get("sid"), "Guest")
|
||||
|
||||
# POST doesn't work
|
||||
res = requests.post(_generate_temporary_login_link(user, 10))
|
||||
self.assertEqual(res.status_code, 403)
|
||||
|
||||
# Rate limiting
|
||||
for _ in range(6):
|
||||
res = requests.get(_generate_temporary_login_link(user, 10))
|
||||
if res.status_code == 417:
|
||||
break
|
||||
else:
|
||||
self.fail("Rate limting not working")
|
||||
|
||||
|
||||
class TestLoginAttemptTracker(FrappeTestCase):
|
||||
def test_account_lock(self):
|
||||
|
|
|
|||
|
|
@ -13,9 +13,6 @@ from frappe.utils import cint
|
|||
|
||||
|
||||
class TestRateLimiter(FrappeTestCase):
|
||||
def setUp(self):
|
||||
pass
|
||||
|
||||
def test_apply_with_limit(self):
|
||||
frappe.conf.rate_limit = {"window": 86400, "limit": 1}
|
||||
frappe.rate_limiter.apply()
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ def make_logs(response=None):
|
|||
def json_handler(obj):
|
||||
"""serialize non-serializable data for json"""
|
||||
from collections.abc import Iterable
|
||||
from re import Match
|
||||
|
||||
if isinstance(obj, (datetime.date, datetime.datetime, datetime.time)):
|
||||
return str(obj)
|
||||
|
|
@ -179,6 +180,9 @@ def json_handler(obj):
|
|||
elif isinstance(obj, Iterable):
|
||||
return list(obj)
|
||||
|
||||
elif isinstance(obj, Match):
|
||||
return obj.string
|
||||
|
||||
elif type(obj) == type or isinstance(obj, Exception):
|
||||
return repr(obj)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
<time datetime="{{ published_on }}">{{ frappe.format_date(published_on) }}</time>
|
||||
{%- if read_time -%}
|
||||
·
|
||||
<span>{{ read_time }} min read</span>
|
||||
<span>{{ read_time }} {{ _('min read') }} </span>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
<a href="/blog?blogger={{ post.blogger }}">{{ post.full_name }}</a>
|
||||
<div class="small">
|
||||
{{ frappe.format_date(post.published_on) }}
|
||||
{% if post.read_time %} · {{ post.read_time }} min read {% endif %}
|
||||
{% if post.read_time %} · {{ post.read_time }} {{ _('min read') }} {% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
<div>
|
||||
<a href="{{ doc.route }}">{{ doc.title or doc.name }}</a>
|
||||
</div>
|
||||
<!-- this is a sample default list template -->
|
||||
|
|
@ -364,7 +364,7 @@
|
|||
"icon": "icon-edit",
|
||||
"is_published_field": "published",
|
||||
"links": [],
|
||||
"modified": "2022-12-15 17:14:44.939645",
|
||||
"modified": "2023-01-02 10:19:15.680960",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Website",
|
||||
"name": "Web Form",
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@
|
|||
<div class="login-content page-card">
|
||||
{{ logo_section() }}
|
||||
<form class="form-signin form-login" role="form">
|
||||
{%- if social_login -%}
|
||||
{%- if social_login or login_with_email_link -%}
|
||||
<div class="page-card-body">
|
||||
<form class="form-signin form-login" role="form">
|
||||
{{ email_login_body() }}
|
||||
|
|
@ -101,6 +101,15 @@
|
|||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if login_with_email_link %}
|
||||
<div class="login-with-email-link">
|
||||
<div class="login-button-wrapper">
|
||||
<a href="#login-with-email-link"
|
||||
class="btn btn-block btn-default btn-sm btn-login-option btn-login-with-email-link">
|
||||
{{ _("Login with Email Link") }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
|
|
@ -181,6 +190,38 @@
|
|||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class='for-login-with-email-link'>
|
||||
<div class="login-content page-card">
|
||||
<form class="form-signin form-login-with-email-link hide" role="form">
|
||||
<div class="page-card-head">
|
||||
<h4>{{ _('Login With Email Link') }}</h4>
|
||||
</div>
|
||||
<div class="page-card-body">
|
||||
<div class="email-field">
|
||||
<input type="email" id="login_with_email_link_email" class="form-control"
|
||||
placeholder="{{ _('Email Address') }}" required autofocus autocomplete="username">
|
||||
<svg class="field-icon email-icon" width="20" height="20" viewBox="0 0 20 20" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M2.5 7.65149V15.0757C2.5 15.4374 2.64367 15.7842 2.8994 16.04C3.15513 16.2957 3.50198 16.4394 3.86364 16.4394H16.1364C16.498 16.4394 16.8449 16.2957 17.1006 16.04C17.3563 15.7842 17.5 15.4374 17.5 15.0757V7.65149"
|
||||
stroke="#74808B" stroke-miterlimit="10" stroke-linecap="square" />
|
||||
<path
|
||||
d="M17.5 7.57572V5.53026C17.5 5.1686 17.3563 4.82176 17.1006 4.56603C16.8449 4.31029 16.498 4.16663 16.1364 4.16663H3.86364C3.50198 4.16663 3.15513 4.31029 2.8994 4.56603C2.64367 4.82176 2.5 5.1686 2.5 5.53026V7.57572L10 10.8333L17.5 7.57572Z"
|
||||
stroke="#74808B" stroke-miterlimit="10" stroke-linecap="square" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-card-actions">
|
||||
<button class="btn btn-sm btn-primary btn-block btn-login-with-email-link"
|
||||
type="submit">{{ _("Send login link") }}</button>
|
||||
<p class="text-center sign-up-message">
|
||||
<a href="#login">{{ _("Back to Login") }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ from frappe import _
|
|||
from frappe.auth import LoginManager
|
||||
from frappe.integrations.doctype.ldap_settings.ldap_settings import LDAPSettings
|
||||
from frappe.integrations.oauth2_logins import decoder_compat
|
||||
from frappe.utils import cint
|
||||
from frappe.rate_limiter import rate_limit
|
||||
from frappe.utils import cint, get_url
|
||||
from frappe.utils.html_utils import get_icon_html
|
||||
from frappe.utils.jinja import guess_is_path
|
||||
from frappe.utils.oauth import (
|
||||
|
|
@ -102,6 +103,8 @@ def get_context(context):
|
|||
|
||||
context["login_label"] = f" {_('or')} ".join(login_label)
|
||||
|
||||
context["login_with_email_link"] = frappe.get_system_settings("login_with_email_link")
|
||||
|
||||
return context
|
||||
|
||||
|
||||
|
|
@ -143,3 +146,61 @@ def login_via_token(login_token):
|
|||
redirect_post_login(
|
||||
desk_user=frappe.db.get_value("User", frappe.session.user, "user_type") == "System User"
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
@rate_limit(limit=5, seconds=60 * 60)
|
||||
def send_login_link(email: str):
|
||||
|
||||
expiry = frappe.get_system_settings("login_with_email_link_expiry") or 10
|
||||
link = _generate_temporary_login_link(email, expiry)
|
||||
|
||||
app_name = (
|
||||
frappe.get_website_settings("app_name") or frappe.get_system_settings("app_name") or _("Frappe")
|
||||
)
|
||||
|
||||
subject = _("Login To {0}").format(app_name)
|
||||
|
||||
frappe.sendmail(
|
||||
subject=subject,
|
||||
recipients=email,
|
||||
template="login_with_email_link",
|
||||
args={"link": link, "minutes": expiry, "app_name": app_name},
|
||||
now=True,
|
||||
)
|
||||
|
||||
|
||||
def _generate_temporary_login_link(email: str, expiry: int):
|
||||
assert isinstance(email, str)
|
||||
|
||||
if not frappe.db.exists("User", email):
|
||||
frappe.throw(
|
||||
_("User with email address {0} does not exist").format(email), frappe.DoesNotExistError
|
||||
)
|
||||
key = frappe.generate_hash()
|
||||
frappe.cache().set_value(f"one_time_login_key:{key}", email, expires_in_sec=expiry * 60)
|
||||
|
||||
return get_url(f"/api/method/frappe.www.login.login_via_key?key={key}")
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True, methods=["GET"])
|
||||
@rate_limit(limit=5, seconds=60 * 60)
|
||||
def login_via_key(key: str):
|
||||
cache_key = f"one_time_login_key:{key}"
|
||||
email = frappe.cache().get_value(cache_key)
|
||||
|
||||
if email:
|
||||
frappe.cache().delete_value(cache_key)
|
||||
|
||||
frappe.local.login_manager.login_as(email)
|
||||
|
||||
redirect_post_login(
|
||||
desk_user=frappe.db.get_value("User", frappe.session.user, "user_type") == "System User"
|
||||
)
|
||||
else:
|
||||
frappe.respond_as_web_page(
|
||||
_("Not Permitted"),
|
||||
_("The link you trying to login is invalid or expired."),
|
||||
http_status_code=403,
|
||||
indicator_color="red",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ dependencies = [
|
|||
"Babel~=2.9.0",
|
||||
"Click~=8.1.3",
|
||||
"filelock~=3.8.0",
|
||||
"GitPython~=3.1.14",
|
||||
"GitPython~=3.1.30",
|
||||
"Jinja2~=3.1.2",
|
||||
"Pillow~=9.3.0",
|
||||
"PyJWT~=2.4.0",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue