diff --git a/nexus/boot.py b/nexus/boot.py new file mode 100644 index 0000000..ce6e34b --- /dev/null +++ b/nexus/boot.py @@ -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" diff --git a/nexus/hooks.py b/nexus/hooks.py index 5de6be7..09e435b 100644 --- a/nexus/hooks.py +++ b/nexus/hooks.py @@ -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 # ------------------ diff --git a/nexus/public/css/login.css b/nexus/public/css/login.css new file mode 100644 index 0000000..96d0f62 --- /dev/null +++ b/nexus/public/css/login.css @@ -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 */ +} diff --git a/nexus/public/images/bg.png b/nexus/public/images/bg.png new file mode 100644 index 0000000..f09bea8 Binary files /dev/null and b/nexus/public/images/bg.png differ diff --git a/nexus/public/images/custom-favicon.svg b/nexus/public/images/custom-favicon.svg new file mode 100644 index 0000000..6940815 --- /dev/null +++ b/nexus/public/images/custom-favicon.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/nexus/public/images/custom-splash.svg b/nexus/public/images/custom-splash.svg new file mode 100644 index 0000000..2dc23ef --- /dev/null +++ b/nexus/public/images/custom-splash.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/nexus/www/login.html b/nexus/www/login.html new file mode 100644 index 0000000..bc2ac99 --- /dev/null +++ b/nexus/www/login.html @@ -0,0 +1,101 @@ +{% extends "templates/web.html" %} +{% block navbar %}{% endblock %} + +{% macro email_login_body() -%} +
+ +
+ + + +
+
+ +
+ +
+ + + + + + {{ _('Show') }} +
+
+ +{% if not disable_user_pass_login %} +
+ {{ _("Forgot Password?") }} +
+{% endif %} +{% endmacro %} + +{% block head_include %} + +{% endblock %} + +{% block page_content %} +
+ + + +
+ + + +
+
+

Welcome back

+

Please enter your credentials to access your account.

+
+ + +
+
+ + +
+
+ Abstract Placeholder +
+
+
+{% endblock %} + +{% block script %} + +{% endblock %} + +{% block sidebar %}{% endblock %} \ No newline at end of file diff --git a/nexus/www/login.py b/nexus/www/login.py new file mode 100644 index 0000000..6e4db24 --- /dev/null +++ b/nexus/www/login.py @@ -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"{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()