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