init first commit, with login customize
This commit is contained in:
10
nexus/boot.py
Normal file
10
nexus/boot.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import frappe
|
||||
|
||||
def override_app_labels(bootinfo):
|
||||
"""Override app titles in bootinfo before they reach the frontend"""
|
||||
if bootinfo.get("app_data"):
|
||||
for app in bootinfo.app_data:
|
||||
if app.get("app_name") == "erpnext":
|
||||
app["app_title"] = "Nexus"
|
||||
elif app.get("app_name") == "frappe":
|
||||
app["app_title"] = "CMA"
|
||||
@@ -5,6 +5,17 @@ app_description = "ERPNexus"
|
||||
app_email = "admin@nexus.com"
|
||||
app_license = "mit"
|
||||
|
||||
extend_bootinfo = ["nexus.boot.override_app_labels"]
|
||||
|
||||
fixtures = [
|
||||
{"doctype": "Workspace", "filters": [["name", "=", "ERPNext"]]}
|
||||
]
|
||||
|
||||
website_context = {
|
||||
"favicon": "/assets/nexus/images/custom-favicon.svg",
|
||||
"splash_image": "/assets/nexus/images/custom-splash.svg",
|
||||
}
|
||||
|
||||
# Apps
|
||||
# ------------------
|
||||
|
||||
|
||||
321
nexus/public/css/login.css
Normal file
321
nexus/public/css/login.css
Normal file
@@ -0,0 +1,321 @@
|
||||
:root {
|
||||
--bg-color: #ffffff;
|
||||
--bg-secondary: #f9fafb; /* Light gray for the right side */
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #6b7280;
|
||||
--border-color: #e5e7eb;
|
||||
--input-bg: #ffffff;
|
||||
--accent-color: #000000;
|
||||
--ring-color: rgba(0, 0, 0, 0.05);
|
||||
--radius: 0.5rem; /* Modern, slightly rounded corners */
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* --- Main Layout: 50/50 Split --- */
|
||||
.main-container {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* --- Left Panel (Form) --- */
|
||||
.left-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
|
||||
/* Brand Logo (Absolute Position Top Left) */
|
||||
.brand {
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
left: 2rem;
|
||||
}
|
||||
|
||||
.brand-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--accent-color);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand-icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* Form Wrapper */
|
||||
.form-container {
|
||||
width: 100%;
|
||||
max-width: 360px; /* Optimal width for readability */
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
margin-bottom: 2rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.form-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.form-header p {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Input Groups */
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
display: block;
|
||||
margin-bottom: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.input-field {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
background-color: var(--input-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-field::-webkit-input-placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.input-field::-moz-placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.input-field:-ms-input-placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Field Icons - Position and style SVG icons */
|
||||
.field-icon {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
fill: currentColor; /* Inherit from text color for visibility */
|
||||
pointer-events: none;
|
||||
transition: fill 0.2s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.email-field .field-icon,
|
||||
.password-field .field-icon {
|
||||
left: 0.75rem; /* Adjust position inside input fields */
|
||||
}
|
||||
|
||||
.email-field .input-field {
|
||||
padding-left: 2.5rem;
|
||||
}
|
||||
|
||||
.email-field .field-icon {
|
||||
top: 46%;
|
||||
transform: translateY(50%);
|
||||
}
|
||||
|
||||
.password-field .input-field {
|
||||
padding-left: 2.5rem;
|
||||
padding-right: 3.5rem;
|
||||
}
|
||||
|
||||
.password-field {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toggle-password {
|
||||
padding: 0.1rem 0.5rem;
|
||||
background: rgb(0 0 0 / 2%);
|
||||
border: solid #0000001f;
|
||||
border-radius: 20px;
|
||||
position: absolute;
|
||||
top: 21%;
|
||||
right: 0;
|
||||
transform: translateX(-10%);
|
||||
z-index: 2;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
/* Modern Focus State */
|
||||
.input-field:focus {
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 3px var(--ring-color);
|
||||
}
|
||||
|
||||
.input-field::-ms-input-placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.input-field::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Modern Focus State */
|
||||
.input-field:focus {
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 3px var(--ring-color);
|
||||
}
|
||||
|
||||
/* Actions Row (Forgot Password) */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Main Login Button */
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background-color: var(--accent-color);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* --- Right Panel (Image) --- */
|
||||
.right-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.frame-panel {
|
||||
border-radius: 2rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
-o-object-fit: cover;
|
||||
object-fit: cover;
|
||||
/* Slight darkening filter to match modern aesthetics */
|
||||
-webkit-filter: brightness(0.95);
|
||||
filter: brightness(0.95);
|
||||
border-radius: 2rem;
|
||||
}
|
||||
|
||||
/* --- Responsive --- */
|
||||
@media (max-width: 768px) {
|
||||
.right-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.brand {
|
||||
position: relative;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin-bottom: 2rem;
|
||||
align-self: flex-start; /* Align brand to start of form container */
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
justify-content: flex-start; /* Push content down on mobile */
|
||||
padding-top: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Footer Styling Override --- */
|
||||
|
||||
/* Reduce footer padding */
|
||||
.web-footer {
|
||||
display: none;
|
||||
padding: 0 !important; /* Was: 3rem 0 = 48px, now: 8px */
|
||||
min-height: 0px !important; /* Was: 140px, now: 80px */
|
||||
}
|
||||
|
||||
/* Reduce footer column padding */
|
||||
.footer-col-left,
|
||||
.footer-col-right {
|
||||
padding-top: 0 !important; /* Was: 0.8rem = ~13px */
|
||||
padding-bottom: 0 !important; /* Was: 1rem = 16px */
|
||||
}
|
||||
|
||||
/* Reduce footer group margins */
|
||||
.footer-group {
|
||||
margin-top: 0 !important; /* Was: 2rem = 32px */
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
/* Reduce footer logo height */
|
||||
.footer-logo {
|
||||
height: 0 !important; /* Was: 1.5rem */
|
||||
}
|
||||
BIN
nexus/public/images/bg.png
Normal file
BIN
nexus/public/images/bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 851 KiB |
7
nexus/public/images/custom-favicon.svg
Normal file
7
nexus/public/images/custom-favicon.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="6" fill="#067EFB"/>
|
||||
<g transform="translate(8, 8) scale(0.5)">
|
||||
<path d="m7 7 10 10" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M17 7v10H7" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 407 B |
7
nexus/public/images/custom-splash.svg
Normal file
7
nexus/public/images/custom-splash.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="118" height="118" viewBox="0 0 118 118" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="118" height="118" rx="20" fill="#067EFB"/>
|
||||
<g transform="translate(35, 35) scale(2)">
|
||||
<path d="m7 7 10 10" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M17 7v10H7" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 414 B |
101
nexus/www/login.html
Normal file
101
nexus/www/login.html
Normal file
@@ -0,0 +1,101 @@
|
||||
{% extends "templates/web.html" %}
|
||||
{% block navbar %}{% endblock %}
|
||||
|
||||
{% macro email_login_body() -%}
|
||||
<div class="input-group">
|
||||
<label class="input-label" for="login_email">{{ login_label or _("Email") }}</label>
|
||||
<div class="email-field">
|
||||
<input type="text" id="login_email" class="input-field"
|
||||
placeholder="{% if login_name_placeholder %}{{ login_name_placeholder }}{% else %}{{ _('jane@example.com') }}{% endif %}"
|
||||
required autofocus autocomplete="username">
|
||||
|
||||
<svg class="field-icon email-icon" width="16" height="16" viewBox="0 0 16 16" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<use class="es-lock" href="#es-line-email"></use>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label class="input-label" for="login_password">{{ _("Password") }}</label>
|
||||
<div class="password-field">
|
||||
<input type="password" id="login_password" class="input-field" placeholder="•••••"
|
||||
autocomplete="current-password" required>
|
||||
|
||||
<svg class="field-icon password-icon" width="16" height="16" viewBox="0 0 16 16" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<use class="es-lock" href="#es-line-lock"></use>
|
||||
</svg>
|
||||
<span toggle="#login_password" class="toggle-password">{{ _('Show') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not disable_user_pass_login %}
|
||||
<div class="form-actions">
|
||||
<a href="#forgot" class="link-btn">{{ _("Forgot Password?") }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% block head_include %}
|
||||
<link rel="stylesheet" href="/assets/nexus/css/login.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block page_content %}
|
||||
<div class="main-container">
|
||||
<noscript>
|
||||
<div class="text-center my-5">
|
||||
<h4>{{ _("Javascript is disabled on your browser") }}</h4>
|
||||
<p class="text-muted">
|
||||
{{ _("You need to enable JavaScript for your app to work.") }}<br>{{ _("To enable it follow the
|
||||
instructions in the following link: {0}").format("<a
|
||||
href='https://enable-javascript.com/'>enable-javascript.com</a></p>") }}
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
<!-- Left Side: Form -->
|
||||
<div class="left-panel">
|
||||
<div class="brand">
|
||||
<a href="#" class="brand-link">
|
||||
<div class="brand-icon">
|
||||
<!-- Your Custom Icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m7 7 10 10" />
|
||||
<path d="M17 7v10H7" />
|
||||
</svg>
|
||||
</div>
|
||||
Nexus
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Centered Form Area -->
|
||||
<div class="form-container">
|
||||
<div class="form-header">
|
||||
<h1>Welcome back</h1>
|
||||
<p>Please enter your credentials to access your account.</p>
|
||||
</div>
|
||||
|
||||
<form class="form-signin form-login" role="form">
|
||||
{{ email_login_body() }}
|
||||
<button class="btn-primary btn-login" type="submit">
|
||||
{{ _("Login") }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Side: Image -->
|
||||
<div class="right-panel">
|
||||
<div class="frame-panel">
|
||||
<img src="/assets/nexus/images/bg.png" alt="Abstract Placeholder" class="image-placeholder">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>{% include "templates/includes/login/login.js" %}</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}{% endblock %}
|
||||
224
nexus/www/login.py
Normal file
224
nexus/www/login.py
Normal file
@@ -0,0 +1,224 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: MIT. See LICENSE
|
||||
|
||||
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import frappe
|
||||
import frappe.utils
|
||||
from frappe import _
|
||||
from frappe.apps import get_default_path
|
||||
from frappe.auth import LoginManager
|
||||
from frappe.core.doctype.navbar_settings.navbar_settings import get_app_logo
|
||||
from frappe.rate_limiter import rate_limit
|
||||
from frappe.utils import cint, get_url
|
||||
from frappe.utils.data import escape_html
|
||||
from frappe.utils.html_utils import get_icon_html
|
||||
from frappe.utils.jinja import guess_is_path
|
||||
from frappe.utils.oauth import get_oauth2_authorize_url, get_oauth_keys, redirect_post_login
|
||||
from frappe.utils.password import get_decrypted_password
|
||||
from frappe.website.utils import get_home_page
|
||||
|
||||
no_cache = True
|
||||
|
||||
|
||||
def get_context(context):
|
||||
from frappe.integrations.frappe_providers.frappecloud_billing import get_site_login_url
|
||||
from frappe.utils.frappecloud import on_frappecloud
|
||||
|
||||
redirect_to = frappe.local.request.args.get("redirect-to")
|
||||
redirect_to = sanitize_redirect(redirect_to)
|
||||
|
||||
if frappe.session.user != "Guest":
|
||||
if not redirect_to:
|
||||
if frappe.session.data.user_type == "Website User":
|
||||
redirect_to = get_default_path() or get_home_page()
|
||||
else:
|
||||
redirect_to = get_default_path() or "/desk"
|
||||
|
||||
if redirect_to != "login":
|
||||
frappe.local.flags.redirect_location = redirect_to
|
||||
raise frappe.Redirect
|
||||
|
||||
context.no_header = True
|
||||
context.for_test = "login.html"
|
||||
context["title"] = "Login"
|
||||
context["hide_login"] = True # dont show login link on login page again.
|
||||
context["provider_logins"] = []
|
||||
context["disable_signup"] = cint(frappe.get_website_settings("disable_signup"))
|
||||
context["show_footer_on_login"] = cint(frappe.get_website_settings("show_footer_on_login"))
|
||||
context["disable_user_pass_login"] = cint(frappe.get_system_settings("disable_user_pass_login"))
|
||||
context["logo"] = get_app_logo()
|
||||
context["app_name"] = (
|
||||
frappe.get_website_settings("app_name") or frappe.get_system_settings("app_name") or _("Frappe")
|
||||
)
|
||||
|
||||
signup_form_template = frappe.get_hooks("signup_form_template")
|
||||
if signup_form_template and len(signup_form_template):
|
||||
path = signup_form_template[-1]
|
||||
if not guess_is_path(path):
|
||||
path = frappe.get_attr(signup_form_template[-1])()
|
||||
else:
|
||||
path = "frappe/templates/signup.html"
|
||||
|
||||
if path:
|
||||
context["signup_form_template"] = frappe.get_template(path).render()
|
||||
|
||||
providers = frappe.get_all(
|
||||
"Social Login Key",
|
||||
filters={"enable_social_login": 1},
|
||||
fields=["name", "client_id", "base_url", "provider_name", "icon"],
|
||||
order_by="name",
|
||||
)
|
||||
|
||||
for provider in providers:
|
||||
client_secret = get_decrypted_password(
|
||||
"Social Login Key", provider.name, "client_secret", raise_exception=False
|
||||
)
|
||||
if not client_secret:
|
||||
continue
|
||||
|
||||
icon = None
|
||||
if provider.icon:
|
||||
if provider.provider_name == "Custom":
|
||||
icon = get_icon_html(provider.icon, small=True)
|
||||
else:
|
||||
icon = f"<img src={escape_html(provider.icon)!r} alt={escape_html(provider.provider_name)!r}>"
|
||||
|
||||
if provider.client_id and provider.base_url and get_oauth_keys(provider.name):
|
||||
context.provider_logins.append(
|
||||
{
|
||||
"name": provider.name,
|
||||
"provider_name": provider.provider_name,
|
||||
"auth_url": get_oauth2_authorize_url(provider.name, redirect_to),
|
||||
"icon": icon,
|
||||
}
|
||||
)
|
||||
context["social_login"] = True
|
||||
|
||||
if cint(frappe.db.get_value("LDAP Settings", "LDAP Settings", "enabled")):
|
||||
from frappe.integrations.doctype.ldap_settings.ldap_settings import LDAPSettings
|
||||
|
||||
context["ldap_settings"] = LDAPSettings.get_ldap_client_settings()
|
||||
|
||||
login_label = [_("Email")]
|
||||
|
||||
if frappe.utils.cint(frappe.get_system_settings("allow_login_using_mobile_number")):
|
||||
login_label.append(_("Mobile"))
|
||||
|
||||
if frappe.utils.cint(frappe.get_system_settings("allow_login_using_user_name")):
|
||||
login_label.append(_("Username"))
|
||||
|
||||
context["login_label"] = f" {_('or')} ".join(login_label)
|
||||
|
||||
context["login_with_email_link"] = frappe.get_system_settings("login_with_email_link")
|
||||
context["login_with_frappe_cloud_url"] = (
|
||||
f"{get_site_login_url()}?site={frappe.local.site}"
|
||||
if on_frappecloud() and frappe.conf.get("fc_communication_secret")
|
||||
else None
|
||||
)
|
||||
|
||||
context["full_width"] = True
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def login_via_token(login_token: str):
|
||||
sid = frappe.cache.get_value(f"login_token:{login_token}", expires=True)
|
||||
if not sid:
|
||||
frappe.respond_as_web_page(_("Invalid Request"), _("Invalid Login Token"), http_status_code=417)
|
||||
return
|
||||
|
||||
frappe.local.form_dict.sid = sid
|
||||
frappe.local.login_manager = LoginManager()
|
||||
|
||||
redirect_post_login(
|
||||
desk_user=frappe.db.get_value("User", frappe.session.user, "user_type") == "System User"
|
||||
)
|
||||
|
||||
|
||||
def get_login_with_email_link_ratelimit() -> int:
|
||||
return frappe.get_system_settings("rate_limit_email_link_login") or 5
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True, methods=["POST"])
|
||||
@rate_limit(limit=get_login_with_email_link_ratelimit, seconds=60 * 60)
|
||||
def send_login_link(email: str):
|
||||
if not frappe.get_system_settings("login_with_email_link"):
|
||||
return
|
||||
|
||||
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=get_login_with_email_link_ratelimit, 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",
|
||||
)
|
||||
|
||||
|
||||
def sanitize_redirect(redirect: str | None) -> str | None:
|
||||
"""Only allow redirect on same domain.
|
||||
|
||||
Allowed redirects:
|
||||
- Same host e.g. https://frappe.localhost/path
|
||||
- Just path e.g. /app gets converted to https://frappe.localhost/app
|
||||
"""
|
||||
if not redirect:
|
||||
return redirect
|
||||
|
||||
parsed_redirect = urlparse(redirect)
|
||||
|
||||
parsed_request_host = urlparse(frappe.local.request.url)
|
||||
output_parsed_url = parsed_redirect._replace(
|
||||
netloc=parsed_request_host.netloc, scheme=parsed_request_host.scheme
|
||||
)
|
||||
if parsed_redirect.netloc:
|
||||
if parsed_request_host.netloc != parsed_redirect.netloc:
|
||||
output_parsed_url = output_parsed_url._replace(path="/desk")
|
||||
else:
|
||||
output_parsed_url = output_parsed_url._replace(path=parsed_redirect.path)
|
||||
|
||||
return output_parsed_url.geturl()
|
||||
Reference in New Issue
Block a user