Separate professional and client login paths

This commit is contained in:
A R R R Associates
2026-08-02 09:27:06 +05:30
parent 181d4f2c2e
commit 4dda760ed4
3 changed files with 164 additions and 25 deletions
@@ -0,0 +1,28 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="max-w-md rounded-2xl bg-white border p-6">
<h1 class="text-2xl font-semibold">Client Login</h1>
<p class="text-slate-600 mt-1 text-sm">Use your PAN or registered client-portal email address.</p>
<form method="post" action="/client/login" class="mt-5 grid gap-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<label class="grid gap-1">
<span class="text-sm text-slate-600">PAN or Email</span>
<input class="border rounded-xl px-3 py-2" name="identifier" type="text" autocomplete="username" required />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Password</span>
<input class="border rounded-xl px-3 py-2" name="password" type="password" autocomplete="current-password" required />
</label>
<button class="rounded-xl bg-slate-900 text-white px-4 py-2 text-sm mt-2" type="submit">Sign in</button>
</form>
<div class="mt-4 flex flex-wrap items-center justify-between gap-3 text-sm">
<a class="text-brand-700 underline" href="/forgot-password">Forgot password?</a>
<a class="font-semibold text-brand-700 underline" href="/login">Professional Login</a>
</div>
</div>
{% endblock %}
+12 -7
View File
@@ -1,23 +1,28 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="max-w-md rounded-2xl bg-white border p-6">
<h1 class="text-2xl font-semibold">Login</h1>
<p class="text-slate-600 mt-1 text-sm">Use bootstrap admin (first run). Lockout + CSRF are enabled.</p>
<h1 class="text-2xl font-semibold">Professional Login</h1>
<p class="text-slate-600 mt-1 text-sm">For system administrators, firm administrators, partners, managers, staff and consultants.</p>
<form method="post" class="mt-5 grid gap-3">
<form method="post" action="/login" class="mt-5 grid gap-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<label class="grid gap-1">
<span class="text-sm text-slate-600">Email or PAN</span>
<input class="border rounded-xl px-3 py-2" name="email" type="text" autocomplete="username" required />
<span class="text-sm text-slate-600">Email</span>
<input class="border rounded-xl px-3 py-2" name="email" type="email" autocomplete="username" required />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Password</span>
<input class="border rounded-xl px-3 py-2" name="password" type="password" required />
<input class="border rounded-xl px-3 py-2" name="password" type="password" autocomplete="current-password" required />
</label>
<button class="rounded-xl bg-slate-900 text-white px-4 py-2 text-sm mt-2" type="submit">Sign in</button>
</form><div class="mt-4 text-sm"><a class="text-brand-700 underline" href="/forgot-password">Forgot password?</a></div>
</form>
<div class="mt-4 flex flex-wrap items-center justify-between gap-3 text-sm">
<a class="text-brand-700 underline" href="/forgot-password">Forgot password?</a>
<a class="font-semibold text-brand-700 underline" href="/client/login">Client Login</a>
</div>
</div>
{% endblock %}
+124 -18
View File
@@ -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()