From 4dda760ed463ee4a81bda7bef56ec0586ff7373e Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Sun, 2 Aug 2026 09:27:06 +0530 Subject: [PATCH] Separate professional and client login paths --- .../core/iam/templates/client_login.html | 28 ++++ app/modules/core/iam/templates/login.html | 19 ++- app/ui/routes/auth.py | 142 +++++++++++++++--- 3 files changed, 164 insertions(+), 25 deletions(-) create mode 100644 app/modules/core/iam/templates/client_login.html diff --git a/app/modules/core/iam/templates/client_login.html b/app/modules/core/iam/templates/client_login.html new file mode 100644 index 0000000..0e03467 --- /dev/null +++ b/app/modules/core/iam/templates/client_login.html @@ -0,0 +1,28 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+

Client Login

+

Use your PAN or registered client-portal email address.

+ +
+ + + + + + + +
+ + +
+{% endblock %} diff --git a/app/modules/core/iam/templates/login.html b/app/modules/core/iam/templates/login.html index d897f6d..6357a9a 100644 --- a/app/modules/core/iam/templates/login.html +++ b/app/modules/core/iam/templates/login.html @@ -1,23 +1,28 @@ {% extends "ui/templates/base/layout.html" %} {% block content %}
-

Login

-

Use bootstrap admin (first run). Lockout + CSRF are enabled.

+

Professional Login

+

For system administrators, firm administrators, partners, managers, staff and consultants.

-
+ -
+ + +
{% endblock %} diff --git a/app/ui/routes/auth.py b/app/ui/routes/auth.py index 3b615f7..69c5967 100644 --- a/app/ui/routes/auth.py +++ b/app/ui/routes/auth.py @@ -19,6 +19,7 @@ from app.core.templating import templates from app.core.settings import get_settings from app.modules.core.iam.invite_service import accept_invite, reset_password_with_token from app.modules.core.iam.models import LoginAttempt, User +from app.modules.client_identity.models import ClientPortalIdentity from app.modules.client_identity.service import mark_identity_activated, resolve_login_user from app.modules.core.rbac.models import Permission, Role, RolePermission, UserRole from app.modules.core.tenancy.models import Branch, FinancialYear, Tenant @@ -28,6 +29,18 @@ from app.modules.email_integration.services import send_auth_otp_email, send_pas router = APIRouter() PENDING_POST_LOGIN_REDIRECT_KEY = "pending_post_login_redirect" +LOGIN_ENTRY_PATH_KEY = "login_entry_path" +PROFESSIONAL_LOGIN_PATH = "/login" +CLIENT_LOGIN_PATH = "/client/login" +PROFESSIONAL_ROLE_NAMES = { + "System Admin", + "Firm Admin", + "Partner", + "Manager", + "Branch Manager", + "Staff", + "Consultant", +} SAFE_POST_LOGIN_REDIRECTS = { "/mobile/attendance", "/employee/attendance", @@ -79,6 +92,7 @@ def _clear_login_session(request: Request) -> None: "must_change_password", "post_login_redirect", "otp_verified", + LOGIN_ENTRY_PATH_KEY, ): request.session.pop(key, None) @@ -279,11 +293,37 @@ def _template_context( def _render_login(request: Request, flash: str | None = None, status_code: int = 200): return templates.TemplateResponse( "modules/core/iam/templates/login.html", - _template_context(request, title="Login", flash=flash), + _template_context(request, title="Professional Login", flash=flash), status_code=status_code, ) +def _render_client_login(request: Request, flash: str | None = None, status_code: int = 200): + return templates.TemplateResponse( + "modules/core/iam/templates/client_login.html", + _template_context(request, title="Client Login", flash=flash), + status_code=status_code, + ) + + +def _is_client_only_roles(roles: list[str]) -> bool: + role_set = set(roles or []) + return "Client" in role_set and not bool(role_set.intersection(PROFESSIONAL_ROLE_NAMES)) + + +def _client_identity_exists(db, user_id: int, bound_tenant_id: int | None) -> bool: + q = select(ClientPortalIdentity.id).where(ClientPortalIdentity.user_id == int(user_id)) + if bound_tenant_id is not None: + q = q.where(ClientPortalIdentity.tenant_id == int(bound_tenant_id)) + return db.execute(q).scalar_one_or_none() is not None + + +def _login_path_for_user(db, user: User | None) -> str: + if not user: + return PROFESSIONAL_LOGIN_PATH + return CLIENT_LOGIN_PATH if _is_client_only_roles(_user_roles(db, int(user.id))) else PROFESSIONAL_LOGIN_PATH + + def _render_otp(request: Request, flash: str | None = None, status_code: int = 200): db = CommonSessionLocal() try: @@ -408,7 +448,7 @@ def invite_accept_submit( return _render_invite_accept(request, token=token_clean, flash=str(exc), status_code=400) if not user: return _render_invite_accept(request, token=token_clean, flash="Invalid or expired invite link.", status_code=400) - return RedirectResponse(url="/login", status_code=303) + return RedirectResponse(url=_login_path_for_user(db, user), status_code=303) finally: db.close() @@ -418,18 +458,34 @@ def login_page(request: Request): return _render_login(request) -@router.post("/login") -def login_submit( +@router.get("/client/login") +def client_login_page(request: Request): + return _render_client_login(request) + + +def _submit_login( request: Request, - email: str = Form(...), - password: str = Form(...), - csrf_token: str = Form(...), + *, + identifier: str, + password: str, + csrf_token: str, + client_portal: bool, ): validate_csrf(request, csrf_token) - email_clean = email.strip().lower() + identifier_clean = identifier.strip().lower() + render_login = _render_client_login if client_portal else _render_login + login_path = CLIENT_LOGIN_PATH if client_portal else PROFESSIONAL_LOGIN_PATH + + if not client_portal and "@" not in identifier_clean: + return render_login( + request, + flash="Professional users must sign in with their email address. Clients should use Client Login.", + status_code=400, + ) + ip = _client_ip(request) - key = _attempt_key(email_clean, ip) + key = _attempt_key(identifier_clean, ip) db = CommonSessionLocal() try: @@ -441,18 +497,30 @@ def login_submit( if la and la.locked_until_utc and la.locked_until_utc.replace( tzinfo=timezone.utc ) > now: - return _render_login( + return render_login( request, flash=f"Account temporarily locked. Try again after {la.locked_until_utc}.", status_code=429, ) - user = resolve_login_user(db, email_clean, _bound_domain_tenant_id(request)) + bound_tenant_id = _bound_domain_tenant_id(request) + user = resolve_login_user(db, identifier_clean, bound_tenant_id) + roles = _user_roles(db, int(user.id)) if user else [] + is_client_only = _is_client_only_roles(roles) + has_client_identity = bool( + user and _client_identity_exists(db, int(user.id), bound_tenant_id) + ) + + portal_allowed = ( + is_client_only and has_client_identity + if client_portal + else bool(user and not is_client_only) + ) can_login, blocked_reason = _is_user_login_allowed(user) password_ok = bool(user and verify_password(password, user.password_hash)) - if not user or not can_login or not password_ok: + if not user or not portal_allowed or not can_login or not password_ok: lock_attempts = 5 lock_minutes = 15 @@ -475,8 +543,15 @@ def login_submit( db.commit() - flash = blocked_reason or "Invalid credentials" - return _render_login(request, flash=flash, status_code=400) + if user and can_login and password_ok and not portal_allowed: + flash = ( + "This login is for clients only. Please use Professional Login." + if client_portal + else "Client accounts must use the Client Login page." + ) + else: + flash = blocked_reason or "Invalid credentials" + return render_login(request, flash=flash, status_code=400) if la: la.attempts = 0 @@ -490,19 +565,17 @@ def login_submit( branch_id = getattr(user, "branch_id", None) must_change_password = bool(getattr(user, "must_change_password", False)) - roles = _user_roles(db, user_id) permissions = _user_permissions(db, user_id) bs = _get_branch_security_policy(db, user) # A verified active audit-firm domain is a hard tenant boundary. # System Admin retains the existing platform-support capability, but all # tenant users must belong to the tenant mapped to this hostname. - bound_tenant_id = _bound_domain_tenant_id(request) is_system_admin = "System Admin" in set(roles or []) user_tenant_id = int(tenant_id) if tenant_id not in (None, "", 0, "0") else None if bound_tenant_id is not None and not is_system_admin and user_tenant_id != bound_tenant_id: _clear_login_session(request) - return _render_login( + return render_login( request, flash="This account does not belong to the firm associated with this domain. Please use your firm's login URL.", status_code=403, @@ -511,6 +584,7 @@ def login_submit( request.session[SESSION_USER_ID_KEY] = user_id request.session[SESSION_LOGIN_AT_KEY] = now.isoformat() request.session["user_email"] = user_email + request.session[LOGIN_ENTRY_PATH_KEY] = login_path tenant_code = _tenant_code(db, tenant_id) branch_code = _branch_code(db, branch_id) @@ -562,6 +636,38 @@ def login_submit( db.close() +@router.post("/login") +def login_submit( + request: Request, + email: str = Form(...), + password: str = Form(...), + csrf_token: str = Form(...), +): + return _submit_login( + request, + identifier=email, + password=password, + csrf_token=csrf_token, + client_portal=False, + ) + + +@router.post("/client/login") +def client_login_submit( + request: Request, + identifier: str = Form(...), + password: str = Form(...), + csrf_token: str = Form(...), +): + return _submit_login( + request, + identifier=identifier, + password=password, + csrf_token=csrf_token, + client_portal=True, + ) + + @router.get("/otp") def otp_page(request: Request): if not request.session.get(SESSION_USER_ID_KEY): @@ -897,7 +1003,7 @@ def password_reset_token_submit( except Exception as exc: print(f"[EMAIL PASSWORD CHANGED ERROR] user={getattr(user, 'email', '')} error={exc}") db.commit() - return RedirectResponse(url="/login", status_code=303) + return RedirectResponse(url=_login_path_for_user(db, user), status_code=303) finally: db.close()