Add phase 6 PAN login invitation and client identity
This commit is contained in:
@@ -47,6 +47,7 @@ from app.modules.domain_management import models as domain_management_models #
|
|||||||
from app.modules.notice_cases import models as notice_cases_models # noqa: F401
|
from app.modules.notice_cases import models as notice_cases_models # noqa: F401
|
||||||
from app.modules.registrations import models as registration_models # noqa: F401
|
from app.modules.registrations import models as registration_models # noqa: F401
|
||||||
from app.modules.credential_vault import models as credential_vault_models # noqa: F401
|
from app.modules.credential_vault import models as credential_vault_models # noqa: F401
|
||||||
|
from app.modules.client_identity import models as client_identity_models # noqa: F401
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Phase 6 PAN login, invitation and client identity.
|
||||||
|
|
||||||
|
Revision ID: 20260723_phase6_pan_login_client_identity
|
||||||
|
Revises: 20260723_phase5_secure_credential_vault
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
revision="20260723_phase6_pan_login_client_identity"
|
||||||
|
down_revision="20260723_phase5_secure_credential_vault"
|
||||||
|
branch_labels=None
|
||||||
|
depends_on=None
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table("client_portal_identities",
|
||||||
|
sa.Column("id",sa.Integer(),primary_key=True),
|
||||||
|
sa.Column("tenant_id",sa.Integer(),sa.ForeignKey("tenants.id",ondelete="CASCADE"),nullable=False),
|
||||||
|
sa.Column("branch_id",sa.Integer(),sa.ForeignKey("branches.id",ondelete="CASCADE"),nullable=False),
|
||||||
|
sa.Column("client_id",sa.Integer(),sa.ForeignKey("clients.id",ondelete="CASCADE"),nullable=False),
|
||||||
|
sa.Column("user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL")),
|
||||||
|
sa.Column("normalized_pan",sa.String(10),nullable=False),
|
||||||
|
sa.Column("login_email",sa.String(255),nullable=False),
|
||||||
|
sa.Column("email_is_placeholder",sa.Boolean(),nullable=False,server_default=sa.false()),
|
||||||
|
sa.Column("status",sa.String(30),nullable=False,server_default="not_invited"),
|
||||||
|
sa.Column("invited_at_utc",sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("activated_at_utc",sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("last_invited_at_utc",sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("created_by_user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL")),
|
||||||
|
sa.Column("updated_by_user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL")),
|
||||||
|
sa.Column("created_at_utc",sa.DateTime(timezone=True),nullable=False),
|
||||||
|
sa.Column("updated_at_utc",sa.DateTime(timezone=True),nullable=False),
|
||||||
|
sa.UniqueConstraint("client_id",name="uq_client_portal_identity_client"),
|
||||||
|
sa.UniqueConstraint("tenant_id","normalized_pan",name="uq_client_portal_identity_tenant_pan"))
|
||||||
|
for c in ["tenant_id","branch_id","client_id","user_id","normalized_pan","login_email","status"]: op.create_index(f"ix_client_portal_identities_{c}","client_portal_identities",[c])
|
||||||
|
op.create_table("client_portal_invitations",
|
||||||
|
sa.Column("id",sa.Integer(),primary_key=True),sa.Column("identity_id",sa.Integer(),sa.ForeignKey("client_portal_identities.id",ondelete="CASCADE"),nullable=False),sa.Column("user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="CASCADE"),nullable=False),sa.Column("delivery_mode",sa.String(30),nullable=False,server_default="email"),sa.Column("recipient_email",sa.String(255)),sa.Column("status",sa.String(30),nullable=False,server_default="issued"),sa.Column("issued_by_user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL")),sa.Column("issued_at_utc",sa.DateTime(timezone=True),nullable=False),sa.Column("expires_at_utc",sa.DateTime(timezone=True)),sa.Column("accepted_at_utc",sa.DateTime(timezone=True)),sa.Column("note",sa.Text()))
|
||||||
|
for c in ["identity_id","user_id","status"]: op.create_index(f"ix_client_portal_invitations_{c}","client_portal_invitations",[c])
|
||||||
|
op.create_table("client_identity_audit_logs",
|
||||||
|
sa.Column("id",sa.Integer(),primary_key=True),sa.Column("tenant_id",sa.Integer(),sa.ForeignKey("tenants.id",ondelete="CASCADE"),nullable=False),sa.Column("client_id",sa.Integer(),sa.ForeignKey("clients.id",ondelete="CASCADE"),nullable=False),sa.Column("identity_id",sa.Integer(),sa.ForeignKey("client_portal_identities.id",ondelete="SET NULL")),sa.Column("actor_user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL")),sa.Column("action",sa.String(50),nullable=False),sa.Column("summary",sa.String(255),nullable=False),sa.Column("created_at_utc",sa.DateTime(timezone=True),nullable=False))
|
||||||
|
for c in ["tenant_id","client_id","identity_id","action"]: op.create_index(f"ix_client_identity_audit_logs_{c}","client_identity_audit_logs",[c])
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table("client_identity_audit_logs")
|
||||||
|
op.drop_table("client_portal_invitations")
|
||||||
|
op.drop_table("client_portal_identities")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""PAN-based client identity and invitation lifecycle."""
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.core.db.common import CommonBase
|
||||||
|
|
||||||
|
class ClientPortalIdentity(CommonBase):
|
||||||
|
__tablename__ = "client_portal_identities"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("client_id", name="uq_client_portal_identity_client"),
|
||||||
|
UniqueConstraint("tenant_id", "normalized_pan", name="uq_client_portal_identity_tenant_pan"),
|
||||||
|
)
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
normalized_pan: Mapped[str] = mapped_column(String(10), nullable=False, index=True)
|
||||||
|
login_email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
email_is_placeholder: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_invited", index=True)
|
||||||
|
invited_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
activated_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_invited_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||||
|
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||||
|
|
||||||
|
class ClientPortalInvitation(CommonBase):
|
||||||
|
__tablename__ = "client_portal_invitations"
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
identity_id: Mapped[int] = mapped_column(ForeignKey("client_portal_identities.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
delivery_mode: Mapped[str] = mapped_column(String(30), nullable=False, default="email")
|
||||||
|
recipient_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), nullable=False, default="issued", index=True)
|
||||||
|
issued_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
issued_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||||
|
expires_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
accepted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
class ClientIdentityAuditLog(CommonBase):
|
||||||
|
__tablename__ = "client_identity_audit_logs"
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
identity_id: Mapped[int | None] = mapped_column(ForeignKey("client_portal_identities.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
summary: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{% extends "ui/templates/base/layout.html" %}{% block content %}
|
||||||
|
<div class="space-y-6"><div class="flex items-center justify-between"><div><h1 class="text-2xl font-semibold">Client Portal Identity</h1><p class="text-sm text-slate-500">{{ client.client_name }} · PAN {{ client.pan or 'Not available' }}</p></div><a href="/clients/{{ client.id }}" class="rounded-xl border px-4 py-2 text-sm">Back to Client</a></div>
|
||||||
|
{% if flash %}<div class="rounded-xl border border-sky-200 bg-sky-50 p-4 text-sm text-sky-900">{{ flash }}</div>{% endif %}
|
||||||
|
{% if invite_url %}<div class="rounded-xl border border-amber-300 bg-amber-50 p-4"><div class="font-semibold">Invitation link — copy now</div><input class="mt-2 w-full rounded-lg border px-3 py-2 font-mono text-xs" readonly value="{{ invite_url }}"><p class="mt-2 text-xs text-amber-800">This plain token is displayed only once.</p></div>{% endif %}
|
||||||
|
{% if not identity %}<section class="rounded-2xl bg-white p-6 shadow-soft"><h2 class="font-semibold">Create PAN-linked identity</h2><p class="mt-1 text-sm text-slate-500">PAN is the permanent identity. Email may be added or changed later.</p><form method="post" action="/client-identity/clients/{{ client.id }}/create" class="mt-4 grid gap-3 max-w-xl"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><label class="text-sm">Initial login email (optional)<input name="email" type="email" value="{{ client.email or '' }}" class="mt-1 w-full rounded-xl border px-3 py-2"></label><button class="rounded-xl bg-slate-900 px-4 py-2 text-white">Create identity</button></form></section>{% else %}
|
||||||
|
<div class="grid gap-6 lg:grid-cols-2"><section class="rounded-2xl bg-white p-6 shadow-soft"><h2 class="font-semibold">Identity</h2><dl class="mt-4 grid gap-3 text-sm"><div><dt class="text-slate-500">PAN login</dt><dd class="font-mono">{{ identity.normalized_pan }}</dd></div><div><dt class="text-slate-500">Email login</dt><dd>{{ identity.login_email }}</dd></div><div><dt class="text-slate-500">Status</dt><dd>{{ identity.status|replace('_',' ')|title }}</dd></div></dl>{% if can_manage %}<form method="post" action="/client-identity/clients/{{ client.id }}/email" class="mt-5 grid gap-3"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><label class="text-sm">Change email<input name="email" required type="email" value="{{ '' if identity.email_is_placeholder else identity.login_email }}" class="mt-1 w-full rounded-xl border px-3 py-2"></label><button class="rounded-xl border px-4 py-2">Update email</button></form>{% endif %}</section>
|
||||||
|
<section class="rounded-2xl bg-white p-6 shadow-soft"><h2 class="font-semibold">Invitation</h2><p class="mt-1 text-sm text-slate-500">Issue or reissue a single-use password setup link.</p>{% if can_manage %}<form method="post" action="/client-identity/clients/{{ client.id }}/invite" class="mt-4 grid gap-3"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><label class="text-sm"><select name="send_email" class="w-full rounded-xl border px-3 py-2"><option value="1">Email and show link</option><option value="0">Show link only</option></select></label><button class="rounded-xl bg-brand-600 px-4 py-2 text-white">Issue invitation</button></form>{% endif %}</section></div>
|
||||||
|
<section class="rounded-2xl bg-white p-6 shadow-soft"><h2 class="font-semibold">Invitation history</h2><div class="mt-3 overflow-x-auto"><table class="w-full text-sm"><thead><tr class="text-left text-slate-500"><th class="py-2">Issued</th><th>Mode</th><th>Recipient</th><th>Status</th><th>Expiry</th></tr></thead><tbody>{% for x in invites %}<tr class="border-t"><td class="py-2">{{ x.issued_at_utc }}</td><td>{{ x.delivery_mode }}</td><td>{{ x.recipient_email or '-' }}</td><td>{{ x.status }}</td><td>{{ x.expires_at_utc or '-' }}</td></tr>{% else %}<tr><td colspan="5" class="py-4 text-slate-500">No invitations issued.</td></tr>{% endfor %}</tbody></table></div></section>{% endif %}</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from fastapi import APIRouter, Form, Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.core.db.common import CommonSessionLocal
|
||||||
|
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
|
||||||
|
from app.core.security.session_auth import get_current_user
|
||||||
|
from app.core.templating import templates
|
||||||
|
from app.modules.clients.models import Client
|
||||||
|
from app.modules.core.rbac.models import Permission, RolePermission, UserRole
|
||||||
|
from .models import ClientIdentityAuditLog, ClientPortalInvitation
|
||||||
|
from .service import ensure_identity, get_identity, issue_client_invitation, update_login_email
|
||||||
|
router=APIRouter(prefix="/client-identity", tags=["client-identity"])
|
||||||
|
|
||||||
|
def _has(db,user,code):
|
||||||
|
return db.execute(select(Permission.id).join(RolePermission,RolePermission.permission_id==Permission.id).join(UserRole,UserRole.role_id==RolePermission.role_id).where(UserRole.user_id==user.id,Permission.code==code,Permission.is_active.is_(True))).first() is not None
|
||||||
|
|
||||||
|
def _load(db,user,client_id):
|
||||||
|
c=db.get(Client,client_id)
|
||||||
|
if not c or c.tenant_id!=user.tenant_id: return None
|
||||||
|
if user.branch_id and c.branch_id!=user.branch_id and not _has(db,user,"clients.view_all_branches"): return None
|
||||||
|
return c
|
||||||
|
|
||||||
|
@router.get("/clients/{client_id}")
|
||||||
|
def identity_page(request:Request,client_id:int):
|
||||||
|
db=CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user=get_current_user(request,db=db)
|
||||||
|
if not user:return RedirectResponse('/login',303)
|
||||||
|
if not _has(db,user,'clients.view'):return RedirectResponse('/clients?error=Access+denied',303)
|
||||||
|
client=_load(db,user,client_id)
|
||||||
|
if not client:return RedirectResponse('/clients?error=Client+not+found',303)
|
||||||
|
identity=get_identity(db,client.id)
|
||||||
|
invites=[]; audits=[]
|
||||||
|
if identity:
|
||||||
|
invites=list(db.execute(select(ClientPortalInvitation).where(ClientPortalInvitation.identity_id==identity.id).order_by(ClientPortalInvitation.id.desc())).scalars().all())
|
||||||
|
audits=list(db.execute(select(ClientIdentityAuditLog).where(ClientIdentityAuditLog.identity_id==identity.id).order_by(ClientIdentityAuditLog.id.desc()).limit(30)).scalars().all())
|
||||||
|
return templates.TemplateResponse('client_identity/detail.html',{'request':request,'current_user':user,'csrf_token':get_or_create_csrf_token(request),'client':client,'identity':identity,'invites':invites,'audits':audits,'can_manage':_has(db,user,'clients.edit'),'invite_url':request.session.pop('client_invite_url',None),'flash':request.session.pop('client_identity_flash',None)})
|
||||||
|
finally:db.close()
|
||||||
|
|
||||||
|
@router.post("/clients/{client_id}/create")
|
||||||
|
def create_identity(request:Request,client_id:int,email:str=Form(''),csrf_token:str=Form(...)):
|
||||||
|
validate_csrf(request,csrf_token);db=CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user=get_current_user(request,db=db)
|
||||||
|
if not user or not _has(db,user,'clients.edit'):return RedirectResponse('/clients?error=Access+denied',303)
|
||||||
|
client=_load(db,user,client_id)
|
||||||
|
if not client:return RedirectResponse('/clients?error=Client+not+found',303)
|
||||||
|
try: ensure_identity(db,client=client,actor_user_id=user.id,requested_email=email or None);request.session['client_identity_flash']='Client portal identity created.'
|
||||||
|
except Exception as exc: request.session['client_identity_flash']=str(exc)
|
||||||
|
return RedirectResponse(f'/client-identity/clients/{client_id}',303)
|
||||||
|
finally:db.close()
|
||||||
|
|
||||||
|
@router.post("/clients/{client_id}/email")
|
||||||
|
def change_email(request:Request,client_id:int,email:str=Form(...),csrf_token:str=Form(...)):
|
||||||
|
validate_csrf(request,csrf_token);db=CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user=get_current_user(request,db=db)
|
||||||
|
if not user or not _has(db,user,'clients.edit'):return RedirectResponse('/clients?error=Access+denied',303)
|
||||||
|
client=_load(db,user,client_id); identity=get_identity(db,client_id) if client else None
|
||||||
|
try:
|
||||||
|
if not identity: raise ValueError('Create the PAN identity first.')
|
||||||
|
update_login_email(db,identity=identity,email=email,actor_user_id=user.id);request.session['client_identity_flash']='Client portal email updated.'
|
||||||
|
except Exception as exc: request.session['client_identity_flash']=str(exc)
|
||||||
|
return RedirectResponse(f'/client-identity/clients/{client_id}',303)
|
||||||
|
finally:db.close()
|
||||||
|
|
||||||
|
@router.post("/clients/{client_id}/invite")
|
||||||
|
def invite(request:Request,client_id:int,send_email:str=Form('1'),csrf_token:str=Form(...)):
|
||||||
|
validate_csrf(request,csrf_token);db=CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user=get_current_user(request,db=db)
|
||||||
|
if not user or not _has(db,user,'clients.edit'):return RedirectResponse('/clients?error=Access+denied',303)
|
||||||
|
client=_load(db,user,client_id); identity=get_identity(db,client_id) if client else None
|
||||||
|
try:
|
||||||
|
if not identity: identity=ensure_identity(db,client=client,actor_user_id=user.id)
|
||||||
|
token,row=issue_client_invitation(db,identity=identity,actor_user_id=user.id,send_email=send_email=='1')
|
||||||
|
request.session['client_invite_url']=f"{str(request.base_url).rstrip('/')}/invite/accept?token={token}"
|
||||||
|
request.session['client_identity_flash']='Invitation issued. Copy the link now; the token is not stored in plain text.'
|
||||||
|
except Exception as exc: request.session['client_identity_flash']=str(exc)
|
||||||
|
return RedirectResponse(f'/client-identity/clients/{client_id}',303)
|
||||||
|
finally:db.close()
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
|
<a href="/client-identity/clients/{{ row.id }}" class="rounded-xl border border-indigo-300 px-4 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-50">Portal Identity</a>
|
||||||
<a href="/clients/{{ row.id }}/edit"
|
<a href="/clients/{{ row.id }}/edit"
|
||||||
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||||
Edit
|
Edit
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ def accept_invite(db: Session, token: str, password: str) -> User | None:
|
|||||||
user.is_active = True
|
user.is_active = True
|
||||||
record.used_at_utc = utcnow().replace(tzinfo=None)
|
record.used_at_utc = utcnow().replace(tzinfo=None)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
try:
|
||||||
|
from app.modules.client_identity.service import mark_identity_activated
|
||||||
|
mark_identity_activated(db, user.id)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[CLIENT IDENTITY ACTIVATION WARNING] user={user.id} error={exc}")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
<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</span>
|
<span class="text-sm text-slate-600">Email or PAN</span>
|
||||||
<input class="border rounded-xl px-3 py-2" name="email" type="email" required />
|
<input class="border rounded-xl px-3 py-2" name="email" type="text" autocomplete="username" required />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="grid gap-1">
|
<label class="grid gap-1">
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from app.modules.peer_review_export.ui import router as peer_review_export_route
|
|||||||
from app.modules.bank_statement_analyzer.ui import router as bank_statement_analyzer_router
|
from app.modules.bank_statement_analyzer.ui import router as bank_statement_analyzer_router
|
||||||
from app.modules.registrations.ui import router as registrations_ui_router
|
from app.modules.registrations.ui import router as registrations_ui_router
|
||||||
from app.modules.credential_vault.ui import router as credential_vault_ui_router
|
from app.modules.credential_vault.ui import router as credential_vault_ui_router
|
||||||
|
from app.modules.client_identity.ui import router as client_identity_ui_router
|
||||||
|
|
||||||
|
|
||||||
def mount_ui(app: FastAPI) -> None:
|
def mount_ui(app: FastAPI) -> None:
|
||||||
@@ -68,6 +69,7 @@ def mount_ui(app: FastAPI) -> None:
|
|||||||
app.include_router(clients_ui_router)
|
app.include_router(clients_ui_router)
|
||||||
app.include_router(registrations_ui_router)
|
app.include_router(registrations_ui_router)
|
||||||
app.include_router(credential_vault_ui_router)
|
app.include_router(credential_vault_ui_router)
|
||||||
|
app.include_router(client_identity_ui_router)
|
||||||
app.include_router(employees_ui_router)
|
app.include_router(employees_ui_router)
|
||||||
app.include_router(manager_dashboard_router)
|
app.include_router(manager_dashboard_router)
|
||||||
app.include_router(managers_ui_router)
|
app.include_router(managers_ui_router)
|
||||||
|
|||||||
@@ -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.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
|
||||||
from app.modules.core.tenancy.settings_models import BranchSettings
|
from app.modules.core.tenancy.settings_models import BranchSettings
|
||||||
@@ -446,9 +447,7 @@ def login_submit(
|
|||||||
status_code=429,
|
status_code=429,
|
||||||
)
|
)
|
||||||
|
|
||||||
user = db.execute(
|
user = resolve_login_user(db, email_clean, _bound_domain_tenant_id(request))
|
||||||
select(User).where(User.email == email_clean)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
|
|
||||||
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))
|
||||||
@@ -744,9 +743,7 @@ def forgot_password_submit(
|
|||||||
|
|
||||||
db = CommonSessionLocal()
|
db = CommonSessionLocal()
|
||||||
try:
|
try:
|
||||||
user = db.execute(
|
user = resolve_login_user(db, email_clean, _bound_domain_tenant_id(request))
|
||||||
select(User).where(User.email == email_clean)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
|
|
||||||
request.session.pop("password_reset_user_id", None)
|
request.session.pop("password_reset_user_id", None)
|
||||||
request.session.pop("password_reset_email", None)
|
request.session.pop("password_reset_email", None)
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
<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</span>
|
<span class="text-sm text-slate-600">Email or PAN</span>
|
||||||
<input class="border rounded-xl px-3 py-2" name="email" type="email" required />
|
<input class="border rounded-xl px-3 py-2" name="email" type="text" autocomplete="username" required />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="grid gap-1">
|
<label class="grid gap-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user