Add phase 6 PAN login invitation and client identity

This commit is contained in:
A R R R Associates
2026-07-23 15:08:26 +05:30
parent 90345a2e1a
commit 76039d915d
13 changed files with 332 additions and 10 deletions
+127
View File
@@ -0,0 +1,127 @@
from __future__ import annotations
import re, secrets
from datetime import datetime, timezone, timedelta
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.security.passwords import hash_password
from app.core.settings import get_settings
from app.modules.clients.models import Client
from app.modules.core.iam.invite_service import issue_invite_token
from app.modules.core.iam.models import User
from app.modules.core.rbac.models import Role, UserRole
from app.modules.email_integration.services import send_user_invite_email
from .models import ClientIdentityAuditLog, ClientPortalIdentity, ClientPortalInvitation
PAN_RE = re.compile(r"^[A-Z]{5}[0-9]{4}[A-Z]$")
def normalize_pan(value: str | None) -> str:
pan = re.sub(r"\s+", "", (value or "").upper())
if not PAN_RE.fullmatch(pan):
raise ValueError("A valid 10-character PAN is required for client portal identity.")
return pan
def normalize_email(value: str | None) -> str:
email = (value or "").strip().lower()
if not email or "@" not in email or len(email) > 255:
raise ValueError("A valid email address is required.")
return email
def placeholder_email(tenant_id: int, pan: str) -> str:
return f"client+{tenant_id}.{pan.lower()}@invalid.local"
def _audit(db: Session, identity: ClientPortalIdentity, actor_user_id: int | None, action: str, summary: str):
db.add(ClientIdentityAuditLog(tenant_id=identity.tenant_id, client_id=identity.client_id, identity_id=identity.id, actor_user_id=actor_user_id, action=action, summary=summary))
def _ensure_client_role(db: Session, user_id: int):
role = db.execute(select(Role).where(Role.name == "Client")).scalar_one_or_none()
if not role:
raise ValueError("Client role is not configured in RBAC.")
exists = db.execute(select(UserRole).where(UserRole.user_id == user_id, UserRole.role_id == role.id)).scalar_one_or_none()
if not exists:
db.add(UserRole(user_id=user_id, role_id=role.id))
def get_identity(db: Session, client_id: int) -> ClientPortalIdentity | None:
return db.execute(select(ClientPortalIdentity).where(ClientPortalIdentity.client_id == client_id)).scalar_one_or_none()
def ensure_identity(db: Session, *, client: Client, actor_user_id: int | None, requested_email: str | None = None) -> ClientPortalIdentity:
pan = normalize_pan(client.pan)
existing_pan = db.execute(select(ClientPortalIdentity).where(ClientPortalIdentity.tenant_id == client.tenant_id, ClientPortalIdentity.normalized_pan == pan, ClientPortalIdentity.client_id != client.id)).scalar_one_or_none()
if existing_pan:
raise ValueError("This PAN is already linked to another client portal identity in the firm.")
identity = get_identity(db, client.id)
real_email = (requested_email or client.email or "").strip().lower()
is_placeholder = not bool(real_email)
email = placeholder_email(client.tenant_id, pan) if is_placeholder else normalize_email(real_email)
other_user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if other_user and (not identity or identity.user_id != other_user.id) and client.portal_user_id != other_user.id:
raise ValueError("This email address is already used by another ERP user.")
if identity:
identity.normalized_pan = pan
identity.updated_by_user_id = actor_user_id
if identity.user_id:
user = db.get(User, identity.user_id)
if user and user.email != email:
user.email = email
identity.login_email = email
identity.email_is_placeholder = is_placeholder
else:
user = db.get(User, client.portal_user_id) if client.portal_user_id else None
if not user:
user = User(email=email, full_name=client.client_name, password_hash=hash_password(secrets.token_urlsafe(48)), tenant_id=client.tenant_id, branch_id=client.branch_id, is_active=True, allow_login=False, must_change_password=True)
db.add(user); db.flush()
_ensure_client_role(db, user.id)
client.portal_user_id = user.id
identity = ClientPortalIdentity(tenant_id=client.tenant_id, branch_id=client.branch_id, client_id=client.id, user_id=user.id, normalized_pan=pan, login_email=email, email_is_placeholder=is_placeholder, status="not_invited", created_by_user_id=actor_user_id, updated_by_user_id=actor_user_id)
db.add(identity); db.flush()
_audit(db, identity, actor_user_id, "identity_created", "PAN-linked client portal identity created.")
db.commit(); db.refresh(identity)
return identity
def update_login_email(db: Session, *, identity: ClientPortalIdentity, email: str, actor_user_id: int | None):
new_email = normalize_email(email)
other = db.execute(select(User).where(User.email == new_email, User.id != identity.user_id)).scalar_one_or_none()
if other: raise ValueError("This email address is already used by another ERP user.")
client = db.get(Client, identity.client_id); user = db.get(User, identity.user_id) if identity.user_id else None
old = identity.login_email
identity.login_email = new_email; identity.email_is_placeholder = False; identity.updated_by_user_id = actor_user_id
if client: client.email = new_email
if user: user.email = new_email
_audit(db, identity, actor_user_id, "email_changed", f"Portal login email changed from {old} to {new_email}.")
db.commit()
def issue_client_invitation(db: Session, *, identity: ClientPortalIdentity, actor_user_id: int | None, send_email: bool = True) -> tuple[str, ClientPortalInvitation]:
user = db.get(User, identity.user_id) if identity.user_id else None
if not user: raise ValueError("Client portal user is not linked.")
token = issue_invite_token(db, user)
now = datetime.now(timezone.utc)
user.allow_login = False
identity.status = "invited"; identity.invited_at_utc = identity.invited_at_utc or now; identity.last_invited_at_utc = now; identity.updated_by_user_id = actor_user_id
mode = "manual_link" if identity.email_is_placeholder or not send_email else "email"
row = ClientPortalInvitation(identity_id=identity.id, user_id=user.id, delivery_mode=mode, recipient_email=None if identity.email_is_placeholder else identity.login_email, status="issued", issued_by_user_id=actor_user_id, issued_at_utc=now, expires_at_utc=now + timedelta(hours=get_settings().INVITE_TOKEN_HOURS))
db.add(row); _audit(db, identity, actor_user_id, "invite_issued", f"Client portal invitation issued using {mode}.")
if mode == "email":
try: send_user_invite_email(db, user=user, invite_token=token)
except Exception as exc: row.note = f"Email delivery failed: {exc}"; row.delivery_mode = "manual_link"
db.commit(); db.refresh(row)
return token, row
def resolve_login_user(db: Session, identifier: str, bound_tenant_id: int | None = None) -> User | None:
value = (identifier or "").strip()
if "@" in value:
return db.execute(select(User).where(User.email == value.lower())).scalar_one_or_none()
try: pan = normalize_pan(value)
except ValueError: return None
q = select(ClientPortalIdentity).where(ClientPortalIdentity.normalized_pan == pan)
if bound_tenant_id is not None: q = q.where(ClientPortalIdentity.tenant_id == bound_tenant_id)
rows = list(db.execute(q).scalars().all())
if len(rows) != 1: return None
return db.get(User, rows[0].user_id) if rows[0].user_id else None
def mark_identity_activated(db: Session, user_id: int):
identity = db.execute(select(ClientPortalIdentity).where(ClientPortalIdentity.user_id == user_id)).scalar_one_or_none()
if identity and identity.status != "active":
now = datetime.now(timezone.utc); identity.status="active"; identity.activated_at_utc=now
inv = db.execute(select(ClientPortalInvitation).where(ClientPortalInvitation.identity_id==identity.id, ClientPortalInvitation.status=="issued").order_by(ClientPortalInvitation.id.desc())).scalars().first()
if inv: inv.status="accepted"; inv.accepted_at_utc=now
_audit(db, identity, user_id, "identity_activated", "Client accepted invitation and activated portal identity.")
db.commit()