128 lines
8.1 KiB
Python
128 lines
8.1 KiB
Python
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()
|