Separate professional and client login paths
This commit is contained in:
@@ -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 %}
|
||||||
@@ -1,23 +1,28 @@
|
|||||||
{% extends "ui/templates/base/layout.html" %}
|
{% extends "ui/templates/base/layout.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="max-w-md rounded-2xl bg-white border p-6">
|
<div class="max-w-md rounded-2xl bg-white border p-6">
|
||||||
<h1 class="text-2xl font-semibold">Login</h1>
|
<h1 class="text-2xl font-semibold">Professional Login</h1>
|
||||||
<p class="text-slate-600 mt-1 text-sm">Use bootstrap admin (first run). Lockout + CSRF are enabled.</p>
|
<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 }}" />
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||||
|
|
||||||
<label class="grid gap-1">
|
<label class="grid gap-1">
|
||||||
<span class="text-sm text-slate-600">Email or PAN</span>
|
<span class="text-sm text-slate-600">Email</span>
|
||||||
<input class="border rounded-xl px-3 py-2" name="email" type="text" autocomplete="username" required />
|
<input class="border rounded-xl px-3 py-2" name="email" type="email" autocomplete="username" required />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="grid gap-1">
|
<label class="grid gap-1">
|
||||||
<span class="text-sm text-slate-600">Password</span>
|
<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>
|
</label>
|
||||||
|
|
||||||
<button class="rounded-xl bg-slate-900 text-white px-4 py-2 text-sm mt-2" type="submit">Sign in</button>
|
<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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+123
-17
@@ -19,6 +19,7 @@ from app.core.templating import templates
|
|||||||
from app.core.settings import get_settings
|
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.invite_service import accept_invite, reset_password_with_token
|
||||||
from app.modules.core.iam.models import LoginAttempt, User
|
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.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.rbac.models import Permission, Role, RolePermission, UserRole
|
||||||
from app.modules.core.tenancy.models import Branch, FinancialYear, Tenant
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
PENDING_POST_LOGIN_REDIRECT_KEY = "pending_post_login_redirect"
|
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 = {
|
SAFE_POST_LOGIN_REDIRECTS = {
|
||||||
"/mobile/attendance",
|
"/mobile/attendance",
|
||||||
"/employee/attendance",
|
"/employee/attendance",
|
||||||
@@ -79,6 +92,7 @@ def _clear_login_session(request: Request) -> None:
|
|||||||
"must_change_password",
|
"must_change_password",
|
||||||
"post_login_redirect",
|
"post_login_redirect",
|
||||||
"otp_verified",
|
"otp_verified",
|
||||||
|
LOGIN_ENTRY_PATH_KEY,
|
||||||
):
|
):
|
||||||
request.session.pop(key, None)
|
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):
|
def _render_login(request: Request, flash: str | None = None, status_code: int = 200):
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"modules/core/iam/templates/login.html",
|
"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,
|
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):
|
def _render_otp(request: Request, flash: str | None = None, status_code: int = 200):
|
||||||
db = CommonSessionLocal()
|
db = CommonSessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -408,7 +448,7 @@ def invite_accept_submit(
|
|||||||
return _render_invite_accept(request, token=token_clean, flash=str(exc), status_code=400)
|
return _render_invite_accept(request, token=token_clean, flash=str(exc), status_code=400)
|
||||||
if not user:
|
if not user:
|
||||||
return _render_invite_accept(request, token=token_clean, flash="Invalid or expired invite link.", status_code=400)
|
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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -418,18 +458,34 @@ def login_page(request: Request):
|
|||||||
return _render_login(request)
|
return _render_login(request)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.get("/client/login")
|
||||||
def login_submit(
|
def client_login_page(request: Request):
|
||||||
|
return _render_client_login(request)
|
||||||
|
|
||||||
|
|
||||||
|
def _submit_login(
|
||||||
request: Request,
|
request: Request,
|
||||||
email: str = Form(...),
|
*,
|
||||||
password: str = Form(...),
|
identifier: str,
|
||||||
csrf_token: str = Form(...),
|
password: str,
|
||||||
|
csrf_token: str,
|
||||||
|
client_portal: bool,
|
||||||
):
|
):
|
||||||
validate_csrf(request, csrf_token)
|
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)
|
ip = _client_ip(request)
|
||||||
key = _attempt_key(email_clean, ip)
|
key = _attempt_key(identifier_clean, ip)
|
||||||
|
|
||||||
db = CommonSessionLocal()
|
db = CommonSessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -441,18 +497,30 @@ def login_submit(
|
|||||||
if la and la.locked_until_utc and la.locked_until_utc.replace(
|
if la and la.locked_until_utc and la.locked_until_utc.replace(
|
||||||
tzinfo=timezone.utc
|
tzinfo=timezone.utc
|
||||||
) > now:
|
) > now:
|
||||||
return _render_login(
|
return render_login(
|
||||||
request,
|
request,
|
||||||
flash=f"Account temporarily locked. Try again after {la.locked_until_utc}.",
|
flash=f"Account temporarily locked. Try again after {la.locked_until_utc}.",
|
||||||
status_code=429,
|
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)
|
can_login, blocked_reason = _is_user_login_allowed(user)
|
||||||
password_ok = bool(user and verify_password(password, user.password_hash))
|
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_attempts = 5
|
||||||
lock_minutes = 15
|
lock_minutes = 15
|
||||||
|
|
||||||
@@ -475,8 +543,15 @@ def login_submit(
|
|||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
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"
|
flash = blocked_reason or "Invalid credentials"
|
||||||
return _render_login(request, flash=flash, status_code=400)
|
return render_login(request, flash=flash, status_code=400)
|
||||||
|
|
||||||
if la:
|
if la:
|
||||||
la.attempts = 0
|
la.attempts = 0
|
||||||
@@ -490,19 +565,17 @@ def login_submit(
|
|||||||
branch_id = getattr(user, "branch_id", None)
|
branch_id = getattr(user, "branch_id", None)
|
||||||
must_change_password = bool(getattr(user, "must_change_password", False))
|
must_change_password = bool(getattr(user, "must_change_password", False))
|
||||||
|
|
||||||
roles = _user_roles(db, user_id)
|
|
||||||
permissions = _user_permissions(db, user_id)
|
permissions = _user_permissions(db, user_id)
|
||||||
bs = _get_branch_security_policy(db, user)
|
bs = _get_branch_security_policy(db, user)
|
||||||
|
|
||||||
# A verified active audit-firm domain is a hard tenant boundary.
|
# A verified active audit-firm domain is a hard tenant boundary.
|
||||||
# System Admin retains the existing platform-support capability, but all
|
# System Admin retains the existing platform-support capability, but all
|
||||||
# tenant users must belong to the tenant mapped to this hostname.
|
# 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 [])
|
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
|
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:
|
if bound_tenant_id is not None and not is_system_admin and user_tenant_id != bound_tenant_id:
|
||||||
_clear_login_session(request)
|
_clear_login_session(request)
|
||||||
return _render_login(
|
return render_login(
|
||||||
request,
|
request,
|
||||||
flash="This account does not belong to the firm associated with this domain. Please use your firm's login URL.",
|
flash="This account does not belong to the firm associated with this domain. Please use your firm's login URL.",
|
||||||
status_code=403,
|
status_code=403,
|
||||||
@@ -511,6 +584,7 @@ def login_submit(
|
|||||||
request.session[SESSION_USER_ID_KEY] = user_id
|
request.session[SESSION_USER_ID_KEY] = user_id
|
||||||
request.session[SESSION_LOGIN_AT_KEY] = now.isoformat()
|
request.session[SESSION_LOGIN_AT_KEY] = now.isoformat()
|
||||||
request.session["user_email"] = user_email
|
request.session["user_email"] = user_email
|
||||||
|
request.session[LOGIN_ENTRY_PATH_KEY] = login_path
|
||||||
tenant_code = _tenant_code(db, tenant_id)
|
tenant_code = _tenant_code(db, tenant_id)
|
||||||
branch_code = _branch_code(db, branch_id)
|
branch_code = _branch_code(db, branch_id)
|
||||||
|
|
||||||
@@ -562,6 +636,38 @@ def login_submit(
|
|||||||
db.close()
|
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")
|
@router.get("/otp")
|
||||||
def otp_page(request: Request):
|
def otp_page(request: Request):
|
||||||
if not request.session.get(SESSION_USER_ID_KEY):
|
if not request.session.get(SESSION_USER_ID_KEY):
|
||||||
@@ -897,7 +1003,7 @@ def password_reset_token_submit(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"[EMAIL PASSWORD CHANGED ERROR] user={getattr(user, 'email', '')} error={exc}")
|
print(f"[EMAIL PASSWORD CHANGED ERROR] user={getattr(user, 'email', '')} error={exc}")
|
||||||
db.commit()
|
db.commit()
|
||||||
return RedirectResponse(url="/login", status_code=303)
|
return RedirectResponse(url=_login_path_for_user(db, user), status_code=303)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user