Add platform SMTP and firm wizard invite email

This commit is contained in:
A R R R Associates
2026-07-02 23:43:24 +05:30
parent b4b8dc97fd
commit b84090d8c9
12 changed files with 821 additions and 45 deletions
@@ -0,0 +1,60 @@
"""Phase v2.0.4-F - Platform SMTP settings
Revision ID: 20260620_phase_204f_platform_smtp
Revises: 20260619_phase_204e_year_lock_backup
Create Date: 2026-06-20
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260620_phase_204f_platform_smtp"
down_revision = "20260619_phase_204e_year_lock_backup"
branch_labels = None
depends_on = None
def _has_table(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _has_index(bind, table_name: str, index_name: str) -> bool:
try:
return any(idx.get("name") == index_name for idx in sa.inspect(bind).get_indexes(table_name))
except Exception:
return False
def upgrade() -> None:
bind = op.get_bind()
if not _has_table(bind, "platform_email_settings"):
op.create_table(
"platform_email_settings",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("smtp_host", sa.String(length=255), nullable=True),
sa.Column("smtp_port", sa.Integer(), nullable=True),
sa.Column("smtp_username", sa.String(length=255), nullable=True),
sa.Column("smtp_password", sa.String(length=500), nullable=True),
sa.Column("smtp_security", sa.String(length=20), nullable=False, server_default="SSL"),
sa.Column("smtp_timeout_seconds", sa.Integer(), nullable=False, server_default="20"),
sa.Column("from_email", sa.String(length=255), nullable=True),
sa.Column("from_name", sa.String(length=255), nullable=True),
sa.Column("reply_to_email", sa.String(length=255), nullable=True),
sa.Column("send_auth_emails", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
if not _has_index(bind, "platform_email_settings", "ix_platform_email_settings_is_active"):
op.create_index("ix_platform_email_settings_is_active", "platform_email_settings", ["is_active"])
def downgrade() -> None:
bind = op.get_bind()
if _has_table(bind, "platform_email_settings"):
if _has_index(bind, "platform_email_settings", "ix_platform_email_settings_is_active"):
op.drop_index("ix_platform_email_settings_is_active", table_name="platform_email_settings")
op.drop_table("platform_email_settings")
+32
View File
@@ -68,6 +68,38 @@ class EmailSetting(CommonBase):
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 PlatformEmailSetting(CommonBase):
"""Platform-level SMTP settings controlled by System Admin.
Used before a tenant/firm is operational, for example Firm Creation
Wizard invites, platform password/reset notices and system security emails.
Firm/branch SMTP remains in EmailSetting and is not changed by this table.
"""
__tablename__ = "platform_email_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
smtp_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
smtp_port: Mapped[int | None] = mapped_column(Integer, nullable=True)
smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
smtp_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
smtp_security: Mapped[str] = mapped_column(String(20), nullable=False, default="SSL") # SSL|STARTTLS|NONE
smtp_timeout_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=20)
from_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
from_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
reply_to_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
send_auth_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=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 EmailTemplate(CommonBase):
__tablename__ = "email_templates"
__table_args__ = (
+150 -1
View File
@@ -11,7 +11,7 @@ from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.modules.email_integration.models import EmailLog, EmailSetting, EmailTemplate
from app.modules.email_integration.models import EmailLog, EmailSetting, EmailTemplate, PlatformEmailSetting
from app.modules.email_integration.attachment_service import EmailAttachment
from app.modules.core.iam.models import User
from app.core.settings import get_settings
@@ -232,6 +232,38 @@ def seed_default_email_templates(db: Session, tenant_id: int | None = None, bran
existing_codes.add(code)
def get_platform_email_setting(db: Session) -> PlatformEmailSetting | None:
return db.execute(
select(PlatformEmailSetting).order_by(PlatformEmailSetting.id.asc())
).scalars().first()
def get_or_create_platform_email_setting(db: Session, actor_user_id: int | None = None) -> PlatformEmailSetting:
row = get_platform_email_setting(db)
if row:
return row
settings = get_settings()
row = PlatformEmailSetting(
smtp_host=getattr(settings, "SMTP_HOST", None) or "mail.vavalam.com",
smtp_port=int(getattr(settings, "SMTP_PORT", 465) or 465),
smtp_security="SSL",
smtp_username=getattr(settings, "SMTP_USERNAME", None) or "no-reply@vavalam.com",
smtp_password=getattr(settings, "SMTP_PASSWORD", None),
from_email=getattr(settings, "SMTP_FROM_EMAIL", None) or "no-reply@vavalam.com",
from_name=getattr(settings, "SMTP_FROM_NAME", None) or "ERP Platform",
reply_to_email=getattr(settings, "SMTP_FROM_EMAIL", None) or "no-reply@vavalam.com",
send_auth_emails=True,
is_active=True,
created_by_user_id=actor_user_id,
updated_by_user_id=actor_user_id,
)
db.add(row)
db.flush()
return row
def get_email_setting(db: Session, tenant_id: int | None, branch_id: int | None) -> EmailSetting | None:
if tenant_id is None:
return None
@@ -585,6 +617,123 @@ def process_pending_email_queue(
db.flush()
return result
def send_platform_email_log_now(db: Session, log: EmailLog) -> EmailLog:
"""Attempt immediate send through System Admin platform SMTP."""
setting = get_platform_email_setting(db)
now = datetime.now(timezone.utc)
log.processing_started_at = now
log.last_attempt_at = now
log.attempt_count = int(getattr(log, "attempt_count", 0) or 0) + 1
if not setting or not setting.is_active:
log.status = "SKIPPED"
log.error_message = "Platform SMTP settings not configured or inactive."
log.is_retryable = False
log.processing_started_at = None
db.flush()
return log
try:
_send_smtp(setting, log.recipient_email, log.subject, log.body or "", is_html=False)
log.status = "SENT"
log.sent_at = datetime.now(timezone.utc)
log.error_message = None
log.next_retry_at = None
log.processing_started_at = None
log.is_retryable = False
except Exception as exc:
log.status = "FAILED"
log.error_message = str(exc)
log.processing_started_at = None
log.is_retryable = False
log.next_retry_at = None
db.flush()
return log
def send_platform_template_email(
db: Session,
*,
recipient_email: str,
template_code: str,
context: dict[str, Any] | None = None,
related_module: str | None = None,
related_id: int | None = None,
send_immediately: bool = True,
queue_priority: int = 10,
) -> EmailLog:
"""Queue/send a platform email without tenant SMTP dependency."""
context = dict(context or {})
context.setdefault("firm_name", "ERP Platform")
context.setdefault("support_email", "")
template = _get_template(db, None, None, template_code)
if not template:
subject = template_code
body = ""
else:
subject = render_template_text(template.subject_template, context)
body = render_template_text(template.body_template, context)
log = EmailLog(
tenant_id=None,
branch_id=None,
recipient_email=recipient_email,
subject=subject[:500],
body=body,
status="PENDING",
related_module=related_module,
related_id=related_id,
template_code=template_code,
queued_at=datetime.now(timezone.utc),
max_attempts=1,
queue_priority=int(queue_priority or 10),
is_retryable=False,
)
db.add(log)
db.flush()
setting = get_platform_email_setting(db)
if not setting or not setting.is_active:
log.status = "SKIPPED"
log.error_message = "Platform SMTP settings not configured or inactive."
db.flush()
return log
if template_code.upper().startswith("AUTH_") and not getattr(setting, "send_auth_emails", True):
log.status = "SKIPPED"
log.error_message = "Platform authentication/invite emails are disabled."
db.flush()
return log
if send_immediately:
send_platform_email_log_now(db, log)
return log
def send_platform_user_invite_email(db: Session, *, user: User, invite_token: str, firm_name: str | None = None) -> EmailLog | None:
if not getattr(user, "email", None):
return None
invite_link = f"{_public_base_url()}/invite/accept?token={invite_token}"
return send_platform_template_email(
db,
recipient_email=str(user.email),
template_code="AUTH_USER_INVITE",
context={
"firm_name": firm_name or "ERP Platform",
"user_name": _user_display_name(user),
"user_email": str(user.email),
"invite_link": invite_link,
"expiry_hours": str(get_settings().INVITE_TOKEN_HOURS),
"support_email": "",
},
related_module="wizard_firm_invite",
related_id=int(user.id),
queue_priority=5,
)
def send_template_email(
db: Session,
*,
@@ -0,0 +1,78 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-xl font-semibold text-slate-900">Platform SMTP Settings</h2>
<p class="mt-1 text-sm text-slate-500">System Admin SMTP used for firm creation invites, primary Firm Admin onboarding, password/reset emails and platform notices before a firm configures its own SMTP.</p>
</div>
<div class="flex gap-2">
<a href="/wizards/system/firm/new" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Create Firm</a>
<a href="/email/logs" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Email Logs</a>
</div>
</div>
{% if flash %}
<div class="mt-4 rounded-2xl border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">{{ flash }}</div>
{% endif %}
</div>
<form method="post" action="/email/platform-smtp" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<section class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
<h3 class="text-base font-semibold text-slate-900">Outgoing SMTP</h3>
<div class="mt-4 grid gap-4 md:grid-cols-3">
<label class="block"><span class="text-sm font-medium text-slate-700">SMTP Host</span><input name="smtp_host" value="{{ setting.smtp_host or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" placeholder="mail.vavalam.com"></label>
<label class="block"><span class="text-sm font-medium text-slate-700">SMTP Port</span><input type="number" name="smtp_port" value="{{ setting.smtp_port or 465 }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
<label class="block"><span class="text-sm font-medium text-slate-700">Security</span><select name="smtp_security" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"><option value="SSL" {% if setting.smtp_security=='SSL' %}selected{% endif %}>SSL</option><option value="STARTTLS" {% if setting.smtp_security=='STARTTLS' %}selected{% endif %}>STARTTLS / TLS</option><option value="NONE" {% if setting.smtp_security=='NONE' %}selected{% endif %}>None</option></select></label>
<label class="block"><span class="text-sm font-medium text-slate-700">SMTP Username</span><input name="smtp_username" value="{{ setting.smtp_username or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" placeholder="no-reply@vavalam.com"></label>
<label class="block"><span class="text-sm font-medium text-slate-700">SMTP Password / App Password</span><input type="password" name="smtp_password" value="" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" placeholder="Leave blank to keep existing"></label>
<label class="block"><span class="text-sm font-medium text-slate-700">Timeout Seconds</span><input type="number" min="5" max="120" name="smtp_timeout_seconds" value="{{ setting.smtp_timeout_seconds or 20 }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
<label class="block"><span class="text-sm font-medium text-slate-700">From Email</span><input name="from_email" value="{{ setting.from_email or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" placeholder="no-reply@vavalam.com"></label>
<label class="block"><span class="text-sm font-medium text-slate-700">From Name</span><input name="from_name" value="{{ setting.from_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" placeholder="ERP Platform"></label>
<label class="block"><span class="text-sm font-medium text-slate-700">Reply-to Email</span><input name="reply_to_email" value="{{ setting.reply_to_email or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" placeholder="support@vavalam.com"></label>
</div>
</section>
<section class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
<h3 class="text-base font-semibold text-slate-900">Platform Email Controls</h3>
<div class="mt-4 grid gap-3 md:grid-cols-2">
<label class="flex items-start gap-3 rounded-xl border border-slate-200 p-3">
<input type="checkbox" name="is_active" value="1" class="mt-1" {% if setting.is_active %}checked{% endif %}>
<span><span class="block font-medium text-slate-800">Platform SMTP active</span><span class="text-xs text-slate-500">Master switch for System Admin platform emails.</span></span>
</label>
<label class="flex items-start gap-3 rounded-xl border border-slate-200 p-3">
<input type="checkbox" name="send_auth_emails" value="1" class="mt-1" {% if setting.send_auth_emails %}checked{% endif %}>
<span><span class="block font-medium text-slate-800">Send invite/auth emails</span><span class="text-xs text-slate-500">Firm Creation Wizard invite emails use this switch.</span></span>
</label>
</div>
<div class="mt-6 flex justify-end"><button class="rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-brand-700">Save Platform SMTP</button></div>
</section>
</form>
<section class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
<h3 class="text-base font-semibold text-slate-900">Send Test Email</h3>
<form method="post" action="/email/platform-smtp/test" class="mt-4 flex flex-col gap-3 sm:flex-row">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input name="test_email" value="{{ current_user.email }}" class="min-w-0 flex-1 rounded-xl border border-slate-300 px-3 py-2">
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Send Test</button>
</form>
</section>
<section class="rounded-2xl bg-white p-6 shadow-card ring-1 ring-slate-200">
<h3 class="text-base font-semibold text-slate-900">Recent Platform Email Attempts</h3>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead><tr class="text-left text-slate-500"><th class="py-2 pr-4">Time</th><th class="py-2 pr-4">Recipient</th><th class="py-2 pr-4">Subject</th><th class="py-2 pr-4">Status</th><th class="py-2 pr-4">Error</th></tr></thead>
<tbody class="divide-y divide-slate-100">
{% for log in recent_logs %}
<tr><td class="py-2 pr-4 whitespace-nowrap">{{ log.created_at_utc }}</td><td class="py-2 pr-4">{{ log.recipient_email }}</td><td class="py-2 pr-4">{{ log.subject }}</td><td class="py-2 pr-4">{{ log.status }}</td><td class="py-2 pr-4 text-red-700">{{ log.error_message or '' }}</td></tr>
{% else %}
<tr><td colspan="5" class="py-4 text-slate-500">No platform email attempts yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</div>
{% endblock %}
+110 -1
View File
@@ -12,7 +12,7 @@ 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.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.email_integration.models import EmailIncomingAttachment, EmailIncomingMessage, EmailLog, EmailSetting, EmailTemplate
from app.modules.email_integration.models import EmailIncomingAttachment, EmailIncomingMessage, EmailLog, EmailSetting, EmailTemplate, PlatformEmailSetting
from app.modules.email_integration.imap_service import fetch_incoming_emails
from app.modules.email_integration.mapping_service import apply_email_mapping, auto_map_unmapped_emails
from app.modules.billing.models import BillingInvoice
@@ -23,6 +23,8 @@ from app.modules.email_integration.services import (
seed_default_email_templates,
send_template_email,
process_pending_email_queue,
get_or_create_platform_email_setting,
send_platform_template_email,
)
router = APIRouter(prefix="/email", tags=["email-integration-ui"])
@@ -32,6 +34,11 @@ def _redirect_login():
return RedirectResponse(url="/login", status_code=303)
def _user_is_system_admin(db, user) -> bool:
return "System Admin" in set(get_user_roles(db, int(user.id)))
def _user_can_manage_email(db, user) -> bool:
roles = set(get_user_roles(db, int(user.id)))
perms = set(get_user_permissions(db, int(user.id)))
@@ -58,6 +65,108 @@ def _ctx(request: Request, db, user, **extra):
return ctx
@router.get("/platform-smtp")
def platform_smtp_page(request: Request, flash: str | None = None):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return _redirect_login()
if not _user_is_system_admin(db, user):
return forbidden_response(request, "Access denied: platform SMTP is restricted to System Admin")
setting = get_or_create_platform_email_setting(db, actor_user_id=int(user.id))
db.commit()
recent_logs = db.execute(
select(EmailLog)
.where(EmailLog.tenant_id.is_(None), EmailLog.related_module.in_(["platform_smtp_test", "wizard_firm_invite"]))
.order_by(desc(EmailLog.created_at_utc))
.limit(10)
).scalars().all()
return templates.TemplateResponse(
"modules/email_integration/templates/email_integration/platform_smtp.html",
_ctx(request, db, user, title="Platform SMTP Settings", setting=setting, recent_logs=recent_logs, flash=flash),
)
finally:
db.close()
@router.post("/platform-smtp")
def save_platform_smtp(
request: Request,
csrf_token: str = Form(...),
smtp_host: str = Form(""),
smtp_port: int = Form(465),
smtp_username: str = Form(""),
smtp_password: str = Form(""),
smtp_security: str = Form("SSL"),
smtp_timeout_seconds: int = Form(20),
from_email: str = Form(""),
from_name: str = Form(""),
reply_to_email: str = Form(""),
send_auth_emails: str | None = Form(None),
is_active: str | None = Form(None),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return _redirect_login()
if not _user_is_system_admin(db, user):
return forbidden_response(request, "Access denied: platform SMTP is restricted to System Admin")
setting = get_or_create_platform_email_setting(db, actor_user_id=int(user.id))
setting.smtp_host = smtp_host.strip() or None
setting.smtp_port = int(smtp_port or 465)
setting.smtp_username = smtp_username.strip() or None
if smtp_password.strip():
setting.smtp_password = smtp_password.strip()
setting.smtp_security = (smtp_security or "SSL").upper()
setting.smtp_timeout_seconds = max(5, min(int(smtp_timeout_seconds or 20), 120))
setting.from_email = from_email.strip() or None
setting.from_name = from_name.strip() or None
setting.reply_to_email = reply_to_email.strip() or None
setting.send_auth_emails = bool(send_auth_emails)
setting.is_active = bool(is_active)
setting.updated_by_user_id = int(user.id)
db.commit()
return RedirectResponse(url="/email/platform-smtp?flash=Platform SMTP settings saved.", status_code=303)
finally:
db.close()
@router.post("/platform-smtp/test")
def test_platform_smtp(request: Request, csrf_token: str = Form(...), test_email: str = Form("")):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return _redirect_login()
if not _user_is_system_admin(db, user):
return forbidden_response(request, "Access denied: platform SMTP is restricted to System Admin")
recipient = (test_email or getattr(user, "email", "")).strip()
send_platform_template_email(
db,
recipient_email=recipient,
template_code="AUTH_USER_INVITE",
context={
"firm_name": "ERP Platform",
"user_name": getattr(user, "full_name", None) or recipient,
"invite_link": "This is a platform SMTP test email. No action is required.",
"expiry_hours": "0",
},
related_module="platform_smtp_test",
related_id=int(user.id),
queue_priority=1,
)
db.commit()
return RedirectResponse(url="/email/platform-smtp?flash=Test email attempted. Check recent logs below.", status_code=303)
finally:
db.close()
@router.get("/audit")
+1 -2
View File
@@ -1,4 +1,4 @@
from __future__ import annotations
from __future__ import annotations
from datetime import date, datetime, time, timezone
from pathlib import Path
@@ -1492,4 +1492,3 @@ def switch_active_branch(request: Request, branch_id: int):
return _redirect_back(request)
finally:
db.close()
+37 -6
View File
@@ -1,4 +1,4 @@
from __future__ import annotations
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timedelta
@@ -19,6 +19,7 @@ from app.modules.core.rbac.models import Role, UserRole
from app.modules.core.tenancy.models import Branch, FinancialYear, Tenant
from app.modules.core.tenancy.settings_models import BranchSettings
from app.modules.employees.models import Employee
from app.modules.email_integration.services import send_platform_user_invite_email
class FirmWizardError(ValueError):
@@ -33,6 +34,8 @@ class FirmWizardResult:
financial_year: FinancialYear | None
invite_url: str
invite_token: str
invite_email_status: str | None = None
invite_email_error: str | None = None
def normalize_code(value: str, *, upper: bool = True) -> str:
@@ -202,6 +205,19 @@ def validate_firm_wizard_payload(db: Session, payload: dict[str, Any]) -> list[s
return errors
def safe_employee_code_for_firm_admin(db: Session, tenant_id: int, tenant_code: str, user_id: int) -> str:
base = normalize_code(f"{tenant_code}_ADMIN_{user_id}")[:50]
employee_code = base
suffix = 1
while db.execute(
select(Employee).where(Employee.tenant_id == tenant_id, Employee.employee_code == employee_code)
).scalar_one_or_none():
suffix += 1
employee_code = f"{base[:44]}_{suffix}"[:50]
return employee_code
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
@@ -295,14 +311,11 @@ def create_firm_from_wizard(db: Session, payload: dict[str, Any]) -> FirmWizardR
db.flush()
db.add(UserRole(user_id=firm_admin.id, role_id=role.id))
# Auto-create and link Employee Master for the primary Firm Admin.
# Employee Portal requires employees.user_id to match the logged-in user.
employee_code = normalize_code(f"{payload['tenant_code']}_ADMIN")[:50]
firm_admin_employee = Employee(
tenant_id=tenant.id,
branch_id=branch.id,
user_id=firm_admin.id,
employee_code=employee_code,
employee_code=safe_employee_code_for_firm_admin(db, tenant.id, tenant.code, firm_admin.id),
full_name=payload["admin_full_name"],
email=payload["admin_email"],
mobile=payload["admin_mobile"] or None,
@@ -338,6 +351,23 @@ def create_firm_from_wizard(db: Session, payload: dict[str, Any]) -> FirmWizardR
invite_token = create_invite_token_without_commit(db, firm_admin)
invite_url = public_invite_url(invite_token)
invite_email_status = None
invite_email_error = None
try:
invite_email_log = send_platform_user_invite_email(
db,
user=firm_admin,
invite_token=invite_token,
firm_name=tenant.display_name or tenant.name,
)
if invite_email_log:
invite_email_status = invite_email_log.status
invite_email_error = invite_email_log.error_message
except Exception as exc:
# Firm creation must not fail only because SMTP is unavailable.
invite_email_status = "FAILED"
invite_email_error = str(exc)
return FirmWizardResult(
tenant=tenant,
branch=branch,
@@ -345,5 +375,6 @@ def create_firm_from_wizard(db: Session, payload: dict[str, Any]) -> FirmWizardR
financial_year=financial_year,
invite_url=invite_url,
invite_token=invite_token,
invite_email_status=invite_email_status,
invite_email_error=invite_email_error,
)
@@ -0,0 +1,323 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timedelta
import hashlib
import re
import secrets
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.security.jwt_tokens import utcnow
from app.core.security.passwords import hash_password
from app.core.settings import get_settings
from app.modules.core.iam.models import User
from app.modules.core.iam.password_flows_models import InviteToken
from app.modules.core.rbac.models import Role, UserRole
from app.modules.core.tenancy.models import Branch, FinancialYear, Tenant
from app.modules.core.tenancy.settings_models import BranchSettings
class FirmWizardError(ValueError):
"""Raised when the firm creation wizard receives invalid data."""
@dataclass(slots=True)
class FirmWizardResult:
tenant: Tenant
branch: Branch
firm_admin: User
financial_year: FinancialYear | None
invite_url: str
invite_token: str
def normalize_code(value: str, *, upper: bool = True) -> str:
value = (value or "").strip()
value = re.sub(r"\s+", "_", value)
value = re.sub(r"[^A-Za-z0-9_\-]", "", value)
return value.upper() if upper else value
def clean_text(value: str | None) -> str:
return (value or "").strip()
def parse_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
return str(value or "").strip().lower() in {"1", "true", "yes", "on", "y"}
def parse_int(value: Any, default: int) -> int:
try:
return int(str(value).strip())
except Exception:
return default
def parse_iso_date(value: str | None) -> date | None:
value = clean_text(value)
if not value:
return None
return date.fromisoformat(value)
def default_ay_from_fy(year_code: str) -> str:
year_code = clean_text(year_code)
try:
start_year = int(year_code.split("-", 1)[0])
except Exception:
return ""
ay_start = start_year + 1
return f"{ay_start}-{str(ay_start + 1)[-2:]}"
def default_dates_from_fy(year_code: str) -> tuple[date | None, date | None]:
year_code = clean_text(year_code)
try:
start_year = int(year_code.split("-", 1)[0])
except Exception:
return None, None
return date(start_year, 4, 1), date(start_year + 1, 3, 31)
def public_invite_url(invite_token: str) -> str:
base = (get_settings().ERP_PUBLIC_BASE_URL or "").strip().rstrip("/") or "http://localhost:8000"
return f"{base}/invite/accept?token={invite_token}"
def build_firm_wizard_payload(form: dict[str, Any]) -> dict[str, Any]:
"""Return a normalized payload used by preview and confirm pages."""
tenant_code = normalize_code(str(form.get("tenant_code") or form.get("firm_code") or ""))
branch_code = normalize_code(str(form.get("branch_code") or "HO"))
admin_email = clean_text(str(form.get("admin_email") or "")).lower()
fy_enabled = parse_bool(form.get("create_financial_year"))
fy_code = clean_text(str(form.get("fy_year_code") or ""))
ay_code = clean_text(str(form.get("fy_assessment_year") or ""))
fy_start_date = clean_text(str(form.get("fy_start_date") or ""))
fy_end_date = clean_text(str(form.get("fy_end_date") or ""))
if fy_enabled and fy_code:
if not ay_code:
ay_code = default_ay_from_fy(fy_code)
if not fy_start_date or not fy_end_date:
start, end = default_dates_from_fy(fy_code)
fy_start_date = fy_start_date or (start.isoformat() if start else "")
fy_end_date = fy_end_date or (end.isoformat() if end else "")
return {
"tenant_code": tenant_code,
"tenant_name": clean_text(str(form.get("tenant_name") or form.get("firm_name") or "")),
"firm_type": clean_text(str(form.get("firm_type") or "partnership")) or "partnership",
"default_timezone": clean_text(str(form.get("default_timezone") or "Asia/Kolkata")) or "Asia/Kolkata",
"default_session_duration_minutes": parse_int(form.get("default_session_duration_minutes"), 480),
"default_otp_required_roles_csv": clean_text(str(form.get("default_otp_required_roles_csv") or "Partner,System Admin")) or "Partner,System Admin",
"default_storage_mode": clean_text(str(form.get("default_storage_mode") or "local_only")) or "local_only",
"branch_code": branch_code,
"branch_name": clean_text(str(form.get("branch_name") or "Head Office")) or "Head Office",
"branch_timezone": clean_text(str(form.get("branch_timezone") or form.get("default_timezone") or "Asia/Kolkata")) or "Asia/Kolkata",
"branch_address_line1": clean_text(str(form.get("branch_address_line1") or "")),
"branch_address_line2": clean_text(str(form.get("branch_address_line2") or "")),
"branch_city": clean_text(str(form.get("branch_city") or "")),
"branch_state": clean_text(str(form.get("branch_state") or "")),
"branch_pin_code": clean_text(str(form.get("branch_pin_code") or "")),
"branch_gstin": clean_text(str(form.get("branch_gstin") or "")).upper(),
"branch_pan": clean_text(str(form.get("branch_pan") or "")).upper(),
"admin_full_name": clean_text(str(form.get("admin_full_name") or "")),
"admin_email": admin_email,
"admin_mobile": clean_text(str(form.get("admin_mobile") or "")),
"admin_designation": clean_text(str(form.get("admin_designation") or "Firm Admin")) or "Firm Admin",
"create_financial_year": fy_enabled,
"fy_year_code": fy_code,
"fy_assessment_year": ay_code,
"fy_start_date": fy_start_date,
"fy_end_date": fy_end_date,
"fy_is_current": parse_bool(form.get("fy_is_current")) if fy_enabled else False,
}
def validate_firm_wizard_payload(db: Session, payload: dict[str, Any]) -> list[str]:
errors: list[str] = []
if not payload["tenant_code"]:
errors.append("Firm code is required.")
if not payload["tenant_name"]:
errors.append("Firm name is required.")
if not payload["branch_code"]:
errors.append("Primary branch code is required.")
if not payload["branch_name"]:
errors.append("Primary branch name is required.")
if not payload["admin_full_name"]:
errors.append("Primary Firm Admin full name is required.")
if not payload["admin_email"]:
errors.append("Primary Firm Admin email is required.")
elif "@" not in payload["admin_email"]:
errors.append("Primary Firm Admin email is invalid.")
if payload["default_session_duration_minutes"] < 15:
errors.append("Session duration must be at least 15 minutes.")
allowed_firm_types = {"partnership", "proprietorship", "individual"}
if payload["firm_type"] not in allowed_firm_types:
errors.append("Invalid firm type.")
allowed_storage_modes = {"local_only", "cloud_only", "hybrid"}
if payload["default_storage_mode"] not in allowed_storage_modes:
errors.append("Invalid default storage mode.")
if payload["tenant_code"]:
existing_tenant = db.execute(select(Tenant).where(Tenant.code == payload["tenant_code"])).scalar_one_or_none()
if existing_tenant:
errors.append("Firm code already exists.")
if payload["admin_email"]:
existing_user = db.execute(select(User).where(User.email == payload["admin_email"])).scalar_one_or_none()
if existing_user:
errors.append("Primary Firm Admin email already exists as a user.")
role = db.execute(select(Role).where(Role.name == "Firm Admin", Role.is_active.is_(True))).scalar_one_or_none()
if not role:
errors.append("Firm Admin role is missing or inactive. Please seed roles before using this wizard.")
if payload["create_financial_year"]:
if not payload["fy_year_code"]:
errors.append("Financial year code is required when default FY is enabled.")
if not payload["fy_assessment_year"]:
errors.append("Assessment year is required when default FY is enabled.")
try:
start = parse_iso_date(payload["fy_start_date"])
end = parse_iso_date(payload["fy_end_date"])
if not start or not end:
errors.append("Financial year start and end date are required.")
elif end <= start:
errors.append("Financial year end date must be after start date.")
except Exception:
errors.append("Financial year dates must be valid ISO dates, for example 2026-04-01.")
return errors
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def create_invite_token_without_commit(db: Session, user: User) -> str:
plain = secrets.token_urlsafe(32)
now = utcnow()
db.add(
InviteToken(
user_id=user.id,
token_hash=_hash_token(plain),
created_at_utc=now.replace(tzinfo=None),
expires_at_utc=(now + timedelta(hours=get_settings().INVITE_TOKEN_HOURS)).replace(tzinfo=None),
used_at_utc=None,
)
)
user.must_change_password = True
return plain
def create_firm_from_wizard(db: Session, payload: dict[str, Any]) -> FirmWizardResult:
errors = validate_firm_wizard_payload(db, payload)
if errors:
raise FirmWizardError(" ".join(errors))
role = db.execute(select(Role).where(Role.name == "Firm Admin", Role.is_active.is_(True))).scalar_one()
temp_password = secrets.token_urlsafe(18)
tenant = Tenant(
code=payload["tenant_code"],
name=payload["tenant_name"],
display_name=payload["tenant_name"],
is_active=True,
firm_type=payload["firm_type"],
default_timezone=payload["default_timezone"],
default_session_duration_minutes=payload["default_session_duration_minutes"],
default_otp_required_roles_csv=payload["default_otp_required_roles_csv"],
default_storage_mode=payload["default_storage_mode"],
contact_email=payload["admin_email"],
contact_mobile=payload["admin_mobile"] or None,
)
db.add(tenant)
db.flush()
branch = Branch(
tenant_id=tenant.id,
code=payload["branch_code"],
name=payload["branch_name"],
is_active=True,
timezone=payload["branch_timezone"],
allow_login=True,
allow_new_assignments=True,
is_head_office=True,
smtp_use_tls=True,
)
db.add(branch)
db.flush()
branch_settings = BranchSettings(
branch_id=branch.id,
address_line1=payload["branch_address_line1"] or None,
address_line2=payload["branch_address_line2"] or None,
city=payload["branch_city"] or None,
state=payload["branch_state"] or None,
pin_code=payload["branch_pin_code"] or None,
gstin=payload["branch_gstin"] or None,
pan=payload["branch_pan"] or None,
storage_mode=payload["default_storage_mode"],
otp_required_roles_csv=payload["default_otp_required_roles_csv"],
session_duration_minutes=payload["default_session_duration_minutes"],
)
db.add(branch_settings)
db.flush()
firm_admin = User(
email=payload["admin_email"],
full_name=payload["admin_full_name"],
password_hash=hash_password(temp_password),
tenant_id=tenant.id,
branch_id=branch.id,
is_active=True,
allow_login=True,
is_locked=False,
deleted_at=None,
must_change_password=True,
password_changed_at_utc=None,
mobile=payload["admin_mobile"] or None,
designation=payload["admin_designation"] or "Firm Admin",
)
db.add(firm_admin)
db.flush()
db.add(UserRole(user_id=firm_admin.id, role_id=role.id))
financial_year = None
if payload["create_financial_year"]:
financial_year = FinancialYear(
tenant_id=tenant.id,
year_code=payload["fy_year_code"],
assessment_year=payload["fy_assessment_year"],
start_date=parse_iso_date(payload["fy_start_date"]),
end_date=parse_iso_date(payload["fy_end_date"]),
is_current=payload["fy_is_current"],
is_locked=False,
created_at_utc=datetime.utcnow(),
updated_at_utc=datetime.utcnow(),
)
db.add(financial_year)
db.flush()
invite_token = create_invite_token_without_commit(db, firm_admin)
invite_url = public_invite_url(invite_token)
return FirmWizardResult(
tenant=tenant,
branch=branch,
firm_admin=firm_admin,
financial_year=financial_year,
invite_url=invite_url,
invite_token=invite_token,
)
@@ -35,8 +35,14 @@
</div>
<div class="mt-6 rounded-2xl border border-brand-200 bg-brand-50 p-4">
<div class="text-sm font-semibold text-brand-900">Firm Admin invite link</div>
<p class="mt-1 text-xs text-brand-800">Copy and share this link with the Firm Admin. The user will set their password through the invite acceptance page.</p>
<div class="text-sm font-semibold text-brand-900">Firm Admin invite</div>
{% if invite_email_status == 'SENT' %}
<p class="mt-1 text-xs text-emerald-800">Invite email was sent through Platform SMTP. The link is also shown below as backup.</p>
{% elif invite_email_status in ['FAILED', 'SKIPPED'] %}
<p class="mt-1 text-xs text-amber-800">Invite email status: {{ invite_email_status }}{% if invite_email_error %} - {{ invite_email_error }}{% endif %}. Copy and share this link manually, or configure Platform SMTP and resend invite from Users.</p>
{% else %}
<p class="mt-1 text-xs text-brand-800">Copy and share this link with the Firm Admin. Configure Platform SMTP to send this automatically for future firms.</p>
{% endif %}
<div class="mt-3 break-all rounded-xl border border-brand-200 bg-white p-3 text-sm text-brand-900">{{ invite_url }}</div>
</div>
+4
View File
@@ -231,6 +231,8 @@ def system_firm_confirm(
branch_id = result.branch.id
admin_id = result.firm_admin.id
fy_id = result.financial_year.id if result.financial_year else None
invite_email_status = result.invite_email_status
invite_email_error = result.invite_email_error
db.commit()
except FirmWizardError as exc:
db.rollback()
@@ -282,6 +284,8 @@ def system_firm_confirm(
firm_admin=firm_admin,
financial_year=financial_year,
invite_url=result.invite_url,
invite_email_status=invite_email_status,
invite_email_error=invite_email_error,
),
)
finally:
+1 -2
View File
@@ -1,4 +1,4 @@
from fastapi import FastAPI
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.modules.clients.ui import router as clients_ui_router, portal_router as client_portal_router
@@ -56,4 +56,3 @@ def mount_ui(app: FastAPI) -> None:
app.include_router(engagements_ui_router)
app.include_router(client_portal_router)
app.include_router(consultant_portal_router)
+17 -31
View File
@@ -1,4 +1,4 @@
<!doctype html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
@@ -108,7 +108,7 @@
{% if firm_branding.logo_url %}<img src="{{ firm_branding.logo_url }}" alt="{{ current_firm_name }} logo" class="h-11 w-11 rounded-2xl bg-white object-contain p-1 shadow-soft" />{% else %}<div class="flex h-11 w-11 items-center justify-center rounded-2xl bg-brand-500 font-bold text-white shadow-soft">{{ (current_firm_name[:2] if current_firm_name else 'AF')|upper }}</div>{% endif %}
<div class="min-w-0">
<div class="truncate text-sm font-semibold tracking-wide">{{ current_firm_name }}</div>
<div class="truncate text-xs text-slate-400">{% if full_auth %}{{ current_branch_name }}{% if current_branch_name and current_branch_name != "-" %} Branch{% endif %} | Workspace{% elif domain_context.is_resolved %}{{ current_branch_name or 'Domain Workspace' }}{% else %}Secure Practice Workspace{% endif %}</div>
<div class="truncate text-xs text-slate-400">{% if full_auth %}{{ current_branch_name }}{% if current_branch_name and current_branch_name != "-" %} Branch{% endif %} Workspace{% elif domain_context.is_resolved %}{{ current_branch_name or 'Domain Workspace' }}{% else %}Secure Practice Workspace{% endif %}</div>
</div>
</div>
@@ -153,7 +153,7 @@
<details class="group rounded-xl" {% if current_path.startswith('/employee') or current_path.startswith('/alerts') %}open{% endif %}>
<summary class="flex cursor-pointer list-none items-center justify-between rounded-xl px-3 py-2 text-sm font-semibold transition hover:bg-slate-800">
<span>My Workspace</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90">&gt;</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90"></span>
</summary>
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
{% if can_view_employee_portal(current_user, ui_perms, ui_roles) %}
@@ -186,7 +186,7 @@
<details class="group rounded-xl" {% if current_path.startswith('/partner') %}open{% endif %}>
<summary class="flex cursor-pointer list-none items-center justify-between rounded-xl px-3 py-2 text-sm font-semibold transition hover:bg-slate-800">
<span>Partner Workspace</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90">&gt;</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90"></span>
</summary>
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
<a href="/partner/dashboard" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path == '/partner/dashboard' %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Overview</a>
@@ -201,7 +201,7 @@
<details class="group rounded-xl" {% if current_path.startswith('/manager') or current_path.startswith('/employees/work') or current_path.startswith('/employees/progress') or current_path.startswith('/employees/attendance') or current_path.startswith('/employees/leave') %}open{% endif %}>
<summary class="flex cursor-pointer list-none items-center justify-between rounded-xl px-3 py-2 text-sm font-semibold transition hover:bg-slate-800">
<span>Team Workspace</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90">&gt;</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90"></span>
</summary>
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
{% if can_manage_employee_work(current_user, ui_perms, ui_roles) %}
@@ -226,7 +226,7 @@
<details class="group rounded-xl" {% if current_path.startswith('/billing') %}open{% endif %}>
<summary class="flex cursor-pointer list-none items-center justify-between rounded-xl px-3 py-2 text-sm font-semibold transition hover:bg-slate-800">
<span>Billing</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90">&gt;</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90"></span>
</summary>
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
<a href="/billing" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path == '/billing' %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Invoices</a>
@@ -244,7 +244,7 @@
<details class="group rounded-xl" {% if current_path.startswith('/employees') and not (current_path.startswith('/employees/work') or current_path.startswith('/employees/progress') or current_path.startswith('/employees/attendance') or current_path == '/employees/leave') %}open{% endif %}>
<summary class="flex cursor-pointer list-none items-center justify-between rounded-xl px-3 py-2 text-sm font-semibold transition hover:bg-slate-800">
<span>Team Administration</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90">&gt;</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90"></span>
</summary>
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
{% if can_view_employee_dashboard(current_user, ui_perms, ui_roles) %}<a href="/employees/dashboard" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">HR Dashboard</a>{% endif %}
@@ -282,7 +282,7 @@
<details class="group rounded-xl" {% if current_path.startswith('/system-settings/users') or current_path.startswith('/system-settings/rbac') or current_path.startswith('/services') or current_path.startswith('/clients') or current_path.startswith('/consultants') or current_path.startswith('/email') or current_path.startswith('/notice-cases') %}open{% endif %}>
<summary class="flex cursor-pointer list-none items-center justify-between rounded-xl px-3 py-2 text-sm font-semibold transition hover:bg-slate-800">
<span>Firm Administration</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90">&gt;</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90"></span>
</summary>
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
{% if "Firm Admin" in ui_roles or is_system_admin_user %}
@@ -338,7 +338,7 @@
<details class="group rounded-xl" {% if current_path.startswith('/domains') or current_path.startswith('/platform-billing') or current_path.startswith('/marketplace') %}open{% endif %}>
<summary class="flex cursor-pointer list-none items-center justify-between rounded-xl px-3 py-2 text-sm font-semibold transition hover:bg-slate-800">
<span>Platform</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90">&gt;</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90"></span>
</summary>
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
<div class="pt-3 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Domain Configuration</div>
@@ -348,11 +348,11 @@
<a href="/domains/consultant-domains" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/domains/consultant-domains') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Consultant Domains</a>
<a href="/domains/verification" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/domains/verification') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">DNS Verification</a>
<a href="/domains/ssl" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/domains/ssl') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">SSL Status</a>
{% if can_view_platform_billing(current_user, ui_perms, ui_roles) or can_view_marketplace_leads(current_user, ui_perms, ui_roles) %}
<div class="pt-3 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Platform Operations</div>
<a href="/email/platform-smtp" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/email/platform-smtp') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Platform SMTP</a>
<a href="/wizards/system/firm/new" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/wizards/system/firm') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Create Firm Wizard</a>
{% if can_view_platform_billing(current_user, ui_perms, ui_roles) %}<a href="/platform-billing" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Platform Billing</a>{% endif %}
{% if can_view_marketplace_leads(current_user, ui_perms, ui_roles) %}<a href="/marketplace" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Marketplace</a>{% endif %}
{% endif %}
</div>
</details>
{% endif %}
@@ -360,7 +360,7 @@
<details class="group rounded-xl" {% if current_path.startswith('/system-settings') %}open{% endif %}>
<summary class="flex cursor-pointer list-none items-center justify-between rounded-xl px-3 py-2 text-sm font-semibold transition hover:bg-slate-800">
<span>Core Setup</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90">&gt;</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90"></span>
</summary>
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
{% if can_view_settings(current_user, ui_perms, ui_roles) %}
@@ -400,11 +400,11 @@
<div class="font-medium text-slate-800">{{ current_user.full_name or current_user.email }}</div>
<div class="text-slate-500">{{ current_user.email }}</div>
{% if current_user.qualification or current_user.designation %}
<div class="text-xs text-slate-500">{{ current_user.qualification or '' }}{% if current_user.qualification and current_user.designation %} | {% endif %}{{ current_user.designation or '' }}</div>
<div class="text-xs text-slate-500">{{ current_user.qualification or '' }}{% if current_user.qualification and current_user.designation %} {% endif %}{{ current_user.designation or '' }}</div>
{% endif %}
<div class="text-xs text-slate-400">
Your Firm: {{ current_firm_name }}
&gt; Branch: {{ current_branch_name }}{% if active_financial_year %} &gt; FY: {{ active_financial_year }}{% endif %}
Branch: {{ current_branch_name }}{% if active_financial_year %} FY: {{ active_financial_year }}{% endif %}
</div>
<div class="mt-2 flex justify-end gap-2">
{% if "Consultant" in ui_roles %}
@@ -426,7 +426,7 @@
</div>
{% else %}
{% if domain_context.is_resolved %}
<div class="mb-2 text-xs text-slate-500">{{ current_firm_name }}{% if current_branch_name %} | {{ current_branch_name }}{% endif %}</div>
<div class="mb-2 text-xs text-slate-500">{{ current_firm_name }}{% if current_branch_name %} {{ current_branch_name }}{% endif %}</div>
{% endif %}
<a class="inline-flex rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-700" href="/login">Login</a>
{% endif %}
@@ -472,20 +472,10 @@
</div>
{% endif %}
{% if is_system_admin_user %}
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">System Wizard</label>
<a href="/wizards/system/firm/new"
class="inline-flex items-center rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft transition hover:bg-brand-700">
+ Create Firm
</a>
</div>
{% endif %}
<div class="text-xs text-slate-500">
Active Scope:
{{ current_firm_name }}
| {{ current_branch_name if active_branch_id else "All Branches" }}{% if active_financial_year %} | FY {{ active_financial_year }}{% endif %}
{{ current_branch_name if active_branch_id else "All Branches" }}{% if active_financial_year %} FY {{ active_financial_year }}{% endif %}
</div>
</div>
{% endif %}
@@ -557,7 +547,7 @@
wrapper.className = "overflow-hidden rounded-2xl border shadow-soft " + priorityClasses(alert.priority);
wrapper.innerHTML = `
<div class="flex items-start gap-3 p-4">
<div class="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white/80 text-base">🔔</div>
<div class="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white/80 text-base">🔔</div>
<div class="min-w-0 flex-1">
<div class="text-xs font-semibold uppercase tracking-wide opacity-75">New Alert</div>
<div class="mt-0.5 line-clamp-2 text-sm font-semibold"></div>
@@ -625,7 +615,3 @@
</body>
</html>