Add platform SMTP and firm wizard invite email
This commit is contained in:
@@ -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")
|
||||||
@@ -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)
|
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):
|
class EmailTemplate(CommonBase):
|
||||||
__tablename__ = "email_templates"
|
__tablename__ = "email_templates"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from typing import Any
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
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.email_integration.attachment_service import EmailAttachment
|
||||||
from app.modules.core.iam.models import User
|
from app.modules.core.iam.models import User
|
||||||
from app.core.settings import get_settings
|
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)
|
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:
|
def get_email_setting(db: Session, tenant_id: int | None, branch_id: int | None) -> EmailSetting | None:
|
||||||
if tenant_id is None:
|
if tenant_id is None:
|
||||||
return None
|
return None
|
||||||
@@ -585,6 +617,123 @@ def process_pending_email_queue(
|
|||||||
db.flush()
|
db.flush()
|
||||||
return result
|
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(
|
def send_template_email(
|
||||||
db: Session,
|
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 %}
|
||||||
@@ -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.security.session_auth import get_current_user
|
||||||
from app.core.templating import templates
|
from app.core.templating import templates
|
||||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
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.imap_service import fetch_incoming_emails
|
||||||
from app.modules.email_integration.mapping_service import apply_email_mapping, auto_map_unmapped_emails
|
from app.modules.email_integration.mapping_service import apply_email_mapping, auto_map_unmapped_emails
|
||||||
from app.modules.billing.models import BillingInvoice
|
from app.modules.billing.models import BillingInvoice
|
||||||
@@ -23,6 +23,8 @@ from app.modules.email_integration.services import (
|
|||||||
seed_default_email_templates,
|
seed_default_email_templates,
|
||||||
send_template_email,
|
send_template_email,
|
||||||
process_pending_email_queue,
|
process_pending_email_queue,
|
||||||
|
get_or_create_platform_email_setting,
|
||||||
|
send_platform_template_email,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/email", tags=["email-integration-ui"])
|
router = APIRouter(prefix="/email", tags=["email-integration-ui"])
|
||||||
@@ -32,6 +34,11 @@ def _redirect_login():
|
|||||||
return RedirectResponse(url="/login", status_code=303)
|
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:
|
def _user_can_manage_email(db, user) -> bool:
|
||||||
roles = set(get_user_roles(db, int(user.id)))
|
roles = set(get_user_roles(db, int(user.id)))
|
||||||
perms = set(get_user_permissions(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
|
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")
|
@router.get("/audit")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date, datetime, time, timezone
|
from datetime import date, datetime, time, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -1492,4 +1492,3 @@ def switch_active_branch(request: Request, branch_id: int):
|
|||||||
return _redirect_back(request)
|
return _redirect_back(request)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date, datetime, timedelta
|
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.models import Branch, FinancialYear, Tenant
|
||||||
from app.modules.core.tenancy.settings_models import BranchSettings
|
from app.modules.core.tenancy.settings_models import BranchSettings
|
||||||
from app.modules.employees.models import Employee
|
from app.modules.employees.models import Employee
|
||||||
|
from app.modules.email_integration.services import send_platform_user_invite_email
|
||||||
|
|
||||||
|
|
||||||
class FirmWizardError(ValueError):
|
class FirmWizardError(ValueError):
|
||||||
@@ -33,6 +34,8 @@ class FirmWizardResult:
|
|||||||
financial_year: FinancialYear | None
|
financial_year: FinancialYear | None
|
||||||
invite_url: str
|
invite_url: str
|
||||||
invite_token: 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:
|
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
|
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:
|
def _hash_token(token: str) -> str:
|
||||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
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.flush()
|
||||||
db.add(UserRole(user_id=firm_admin.id, role_id=role.id))
|
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(
|
firm_admin_employee = Employee(
|
||||||
tenant_id=tenant.id,
|
tenant_id=tenant.id,
|
||||||
branch_id=branch.id,
|
branch_id=branch.id,
|
||||||
user_id=firm_admin.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"],
|
full_name=payload["admin_full_name"],
|
||||||
email=payload["admin_email"],
|
email=payload["admin_email"],
|
||||||
mobile=payload["admin_mobile"] or None,
|
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_token = create_invite_token_without_commit(db, firm_admin)
|
||||||
invite_url = public_invite_url(invite_token)
|
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(
|
return FirmWizardResult(
|
||||||
tenant=tenant,
|
tenant=tenant,
|
||||||
branch=branch,
|
branch=branch,
|
||||||
@@ -345,5 +375,6 @@ def create_firm_from_wizard(db: Session, payload: dict[str, Any]) -> FirmWizardR
|
|||||||
financial_year=financial_year,
|
financial_year=financial_year,
|
||||||
invite_url=invite_url,
|
invite_url=invite_url,
|
||||||
invite_token=invite_token,
|
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>
|
||||||
|
|
||||||
<div class="mt-6 rounded-2xl border border-brand-200 bg-brand-50 p-4">
|
<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>
|
<div class="text-sm font-semibold text-brand-900">Firm Admin invite</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>
|
{% 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 class="mt-3 break-all rounded-xl border border-brand-200 bg-white p-3 text-sm text-brand-900">{{ invite_url }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -231,6 +231,8 @@ def system_firm_confirm(
|
|||||||
branch_id = result.branch.id
|
branch_id = result.branch.id
|
||||||
admin_id = result.firm_admin.id
|
admin_id = result.firm_admin.id
|
||||||
fy_id = result.financial_year.id if result.financial_year else None
|
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()
|
db.commit()
|
||||||
except FirmWizardError as exc:
|
except FirmWizardError as exc:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
@@ -282,6 +284,8 @@ def system_firm_confirm(
|
|||||||
firm_admin=firm_admin,
|
firm_admin=firm_admin,
|
||||||
financial_year=financial_year,
|
financial_year=financial_year,
|
||||||
invite_url=result.invite_url,
|
invite_url=result.invite_url,
|
||||||
|
invite_email_status=invite_email_status,
|
||||||
|
invite_email_error=invite_email_error,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
+1
-2
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from app.modules.clients.ui import router as clients_ui_router, portal_router as client_portal_router
|
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(engagements_ui_router)
|
||||||
app.include_router(client_portal_router)
|
app.include_router(client_portal_router)
|
||||||
app.include_router(consultant_portal_router)
|
app.include_router(consultant_portal_router)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<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 %}
|
{% 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="min-w-0">
|
||||||
<div class="truncate text-sm font-semibold tracking-wide">{{ current_firm_name }}</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@
|
|||||||
<details class="group rounded-xl" {% if current_path.startswith('/employee') or current_path.startswith('/alerts') %}open{% endif %}>
|
<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">
|
<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>My Workspace</span>
|
||||||
<span class="text-xs text-slate-400 transition group-open:rotate-90">></span>
|
<span class="text-xs text-slate-400 transition group-open:rotate-90">›</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
|
<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) %}
|
{% 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 %}>
|
<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">
|
<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>Partner Workspace</span>
|
||||||
<span class="text-xs text-slate-400 transition group-open:rotate-90">></span>
|
<span class="text-xs text-slate-400 transition group-open:rotate-90">›</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
|
<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>
|
<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 %}>
|
<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">
|
<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>Team Workspace</span>
|
||||||
<span class="text-xs text-slate-400 transition group-open:rotate-90">></span>
|
<span class="text-xs text-slate-400 transition group-open:rotate-90">›</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
|
<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) %}
|
{% 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 %}>
|
<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">
|
<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>Billing</span>
|
||||||
<span class="text-xs text-slate-400 transition group-open:rotate-90">></span>
|
<span class="text-xs text-slate-400 transition group-open:rotate-90">›</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
|
<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>
|
<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 %}>
|
<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">
|
<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>Team Administration</span>
|
||||||
<span class="text-xs text-slate-400 transition group-open:rotate-90">></span>
|
<span class="text-xs text-slate-400 transition group-open:rotate-90">›</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
|
<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 %}
|
{% 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 %}>
|
<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">
|
<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>Firm Administration</span>
|
||||||
<span class="text-xs text-slate-400 transition group-open:rotate-90">></span>
|
<span class="text-xs text-slate-400 transition group-open:rotate-90">›</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
|
<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 %}
|
{% 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 %}>
|
<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">
|
<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>Platform</span>
|
||||||
<span class="text-xs text-slate-400 transition group-open:rotate-90">></span>
|
<span class="text-xs text-slate-400 transition group-open:rotate-90">›</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
|
<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>
|
<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/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/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>
|
<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>
|
<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_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 %}
|
{% 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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -360,7 +360,7 @@
|
|||||||
<details class="group rounded-xl" {% if current_path.startswith('/system-settings') %}open{% endif %}>
|
<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">
|
<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>Core Setup</span>
|
||||||
<span class="text-xs text-slate-400 transition group-open:rotate-90">></span>
|
<span class="text-xs text-slate-400 transition group-open:rotate-90">›</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
|
<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) %}
|
{% 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="font-medium text-slate-800">{{ current_user.full_name or current_user.email }}</div>
|
||||||
<div class="text-slate-500">{{ current_user.email }}</div>
|
<div class="text-slate-500">{{ current_user.email }}</div>
|
||||||
{% if current_user.qualification or current_user.designation %}
|
{% 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 %}
|
{% endif %}
|
||||||
<div class="text-xs text-slate-400">
|
<div class="text-xs text-slate-400">
|
||||||
Your Firm: {{ current_firm_name }}
|
Your Firm: {{ current_firm_name }}
|
||||||
> Branch: {{ current_branch_name }}{% if active_financial_year %} > FY: {{ active_financial_year }}{% endif %}
|
• Branch: {{ current_branch_name }}{% if active_financial_year %} • FY: {{ active_financial_year }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex justify-end gap-2">
|
<div class="mt-2 flex justify-end gap-2">
|
||||||
{% if "Consultant" in ui_roles %}
|
{% if "Consultant" in ui_roles %}
|
||||||
@@ -426,7 +426,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if domain_context.is_resolved %}
|
{% 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 %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
@@ -472,20 +472,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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">
|
<div class="text-xs text-slate-500">
|
||||||
Active Scope:
|
Active Scope:
|
||||||
{{ current_firm_name }}
|
{{ 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>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -557,7 +547,7 @@
|
|||||||
wrapper.className = "overflow-hidden rounded-2xl border shadow-soft " + priorityClasses(alert.priority);
|
wrapper.className = "overflow-hidden rounded-2xl border shadow-soft " + priorityClasses(alert.priority);
|
||||||
wrapper.innerHTML = `
|
wrapper.innerHTML = `
|
||||||
<div class="flex items-start gap-3 p-4">
|
<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="min-w-0 flex-1">
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide opacity-75">New Alert</div>
|
<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>
|
<div class="mt-0.5 line-clamp-2 text-sm font-semibold"></div>
|
||||||
@@ -625,7 +615,3 @@
|
|||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user