init first commit, with login customize

This commit is contained in:
2026-03-16 06:29:53 +07:00
parent fc29c4014e
commit 778f82635d
8 changed files with 681 additions and 0 deletions

10
nexus/boot.py Normal file
View 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"

View File

@@ -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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 KiB

View 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

View 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
View 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
View 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()