Add platform SMTP and firm wizard invite email
This commit is contained in:
@@ -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__ = (
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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,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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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">></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">></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">></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">></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">></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">></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">></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">></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 }}
|
||||
> 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 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>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user