diff --git a/alembic/env.py b/alembic/env.py index e12d48e..5608fe5 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -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.registrations import models as registration_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 diff --git a/alembic/versions/20260723_phase6_pan_login_client_identity.py b/alembic/versions/20260723_phase6_pan_login_client_identity.py new file mode 100644 index 0000000..d9c3294 --- /dev/null +++ b/alembic/versions/20260723_phase6_pan_login_client_identity.py @@ -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") diff --git a/app/modules/client_identity/__init__.py b/app/modules/client_identity/__init__.py new file mode 100644 index 0000000..95c4896 --- /dev/null +++ b/app/modules/client_identity/__init__.py @@ -0,0 +1 @@ +"""PAN-based client identity and invitation lifecycle.""" diff --git a/app/modules/client_identity/models.py b/app/modules/client_identity/models.py new file mode 100644 index 0000000..7b8926f --- /dev/null +++ b/app/modules/client_identity/models.py @@ -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) diff --git a/app/modules/client_identity/service.py b/app/modules/client_identity/service.py new file mode 100644 index 0000000..97fa43d --- /dev/null +++ b/app/modules/client_identity/service.py @@ -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() diff --git a/app/modules/client_identity/templates/client_identity/detail.html b/app/modules/client_identity/templates/client_identity/detail.html new file mode 100644 index 0000000..babca8d --- /dev/null +++ b/app/modules/client_identity/templates/client_identity/detail.html @@ -0,0 +1,9 @@ +{% extends "ui/templates/base/layout.html" %}{% block content %} +

Client Portal Identity

{{ client.client_name }} · PAN {{ client.pan or 'Not available' }}

Back to Client
+{% if flash %}
{{ flash }}
{% endif %} +{% if invite_url %}
Invitation link — copy now

This plain token is displayed only once.

{% endif %} +{% if not identity %}

Create PAN-linked identity

PAN is the permanent identity. Email may be added or changed later.

{% else %} +

Identity

PAN login
{{ identity.normalized_pan }}
Email login
{{ identity.login_email }}
Status
{{ identity.status|replace('_',' ')|title }}
{% if can_manage %}
{% endif %}
+

Invitation

Issue or reissue a single-use password setup link.

{% if can_manage %}
{% endif %}
+

Invitation history

{% for x in invites %}{% else %}{% endfor %}
IssuedModeRecipientStatusExpiry
{{ x.issued_at_utc }}{{ x.delivery_mode }}{{ x.recipient_email or '-' }}{{ x.status }}{{ x.expires_at_utc or '-' }}
No invitations issued.
{% endif %}
+{% endblock %} diff --git a/app/modules/client_identity/ui.py b/app/modules/client_identity/ui.py new file mode 100644 index 0000000..6ae3632 --- /dev/null +++ b/app/modules/client_identity/ui.py @@ -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() diff --git a/app/modules/clients/templates/clients/detail.html b/app/modules/clients/templates/clients/detail.html index 6d74ad4..111c293 100644 --- a/app/modules/clients/templates/clients/detail.html +++ b/app/modules/clients/templates/clients/detail.html @@ -9,6 +9,7 @@
{% if can_edit %} + Portal Identity Edit diff --git a/app/modules/core/iam/invite_service.py b/app/modules/core/iam/invite_service.py index 018fd3b..eb20be9 100644 --- a/app/modules/core/iam/invite_service.py +++ b/app/modules/core/iam/invite_service.py @@ -87,6 +87,11 @@ def accept_invite(db: Session, token: str, password: str) -> User | None: user.is_active = True record.used_at_utc = utcnow().replace(tzinfo=None) 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 diff --git a/app/modules/core/iam/templates/login.html b/app/modules/core/iam/templates/login.html index 84717b6..d897f6d 100644 --- a/app/modules/core/iam/templates/login.html +++ b/app/modules/core/iam/templates/login.html @@ -8,8 +8,8 @@