Add firm admin onboarding for partner manager and staff

This commit is contained in:
A R R R Associates
2026-07-06 16:03:09 +05:30
parent 40bb48fb6d
commit 623d21bc3b
6 changed files with 579 additions and 19 deletions
+276 -3
View File
@@ -1,16 +1,21 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
from datetime import date, datetime, timezone
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.security.passwords import hash_password
from app.core.settings import get_settings
from app.modules.core.iam.invite_service import issue_invite_token
from app.modules.email_integration.services import send_user_invite_email
from app.modules.core.iam.models import User
from app.modules.core.rbac.models import Role, UserRole
from app.modules.core.tenancy.models import Branch, FinancialYear, Tenant
from app.modules.clients.models import Client
from app.modules.employees.models import Employee
from app.modules.services.models import (
FirmServiceSelection,
FirmServiceTaskTemplate,
@@ -150,6 +155,9 @@ def _user_rows(db: Session, tenant_id: int | None) -> list[dict[str, Any]]:
).scalars().all()
rows: list[dict[str, Any]] = []
for user in users:
employee = db.execute(
select(Employee).where(Employee.tenant_id == tenant_id, Employee.user_id == user.id)
).scalar_one_or_none()
rows.append(
{
"id": user.id,
@@ -159,6 +167,9 @@ def _user_rows(db: Session, tenant_id: int | None) -> list[dict[str, Any]]:
"mobile": user.mobile,
"branch_id": user.branch_id,
"roles": get_user_role_names(db, user.id),
"employee_id": employee.id if employee else None,
"employee_linked": bool(employee),
"employee_code": employee.employee_code if employee else None,
"is_active": user.is_active,
"allow_login": user.allow_login,
"is_locked": user.is_locked,
@@ -247,6 +258,268 @@ def _smtp_status(db: Session, tenant_id: int | None) -> dict[str, Any]:
return {"available": False, "configured": False, "label": "Check settings", "message": "Open email settings to verify SMTP."}
ONBOARDING_ROLE_CONFIG: dict[str, dict[str, str]] = {
"partner": {
"role_name": "Partner",
"label": "Partner",
"default_designation": "Partner",
"default_department": "Management",
"default_employment_type": "full_time",
},
"manager": {
"role_name": "Branch Manager",
"label": "Manager",
"default_designation": "Branch Manager",
"default_department": "Operations",
"default_employment_type": "full_time",
},
"staff": {
"role_name": "Staff",
"label": "Staff",
"default_designation": "Staff",
"default_department": "Operations",
"default_employment_type": "full_time",
},
}
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 _parse_optional_date(value: str | None) -> date | None:
value = (value or "").strip()
if not value:
return None
return date.fromisoformat(value)
def _next_employee_code(db: Session, tenant_id: int) -> str:
prefix = "EMP"
rows = db.execute(
select(Employee.employee_code)
.where(Employee.tenant_id == int(tenant_id), Employee.employee_code.ilike(f"{prefix}%"))
.order_by(Employee.employee_code.desc())
).all()
max_no = 0
for (code,) in rows:
suffix = "".join(ch for ch in str(code or "") if ch.isdigit())
if suffix:
max_no = max(max_no, int(suffix))
return f"{prefix}{max_no + 1:05d}"
def _ensure_employee_for_onboarded_user(
db: Session,
*,
actor: User,
user_obj: User,
employee_code: str | None,
mobile: str | None,
department: str | None,
designation: str | None,
date_of_joining: str | None,
employment_type: str | None,
) -> dict[str, Any]:
if not user_obj.tenant_id or not user_obj.branch_id:
raise ValueError("Tenant and Branch are mandatory for internal firm users.")
existing_linked = db.execute(
select(Employee).where(Employee.tenant_id == int(user_obj.tenant_id), Employee.user_id == int(user_obj.id))
).scalar_one_or_none()
if existing_linked:
return {"required": True, "created": False, "linked_existing": True, "employee_id": existing_linked.id}
email = (user_obj.email or "").strip().lower()
existing_unlinked = None
if email:
existing_unlinked = db.execute(
select(Employee).where(
Employee.tenant_id == int(user_obj.tenant_id),
Employee.user_id.is_(None),
Employee.email == email,
)
).scalar_one_or_none()
if existing_unlinked:
existing_unlinked.user_id = user_obj.id
existing_unlinked.branch_id = user_obj.branch_id
existing_unlinked.full_name = user_obj.full_name or existing_unlinked.full_name
existing_unlinked.mobile = (mobile or "").strip() or existing_unlinked.mobile
existing_unlinked.department = (department or "").strip() or existing_unlinked.department
existing_unlinked.designation = (designation or "").strip() or existing_unlinked.designation
existing_unlinked.updated_by_user_id = actor.id
existing_unlinked.updated_at_utc = datetime.now(timezone.utc)
return {"required": True, "created": False, "linked_existing": True, "employee_id": existing_unlinked.id}
code = (employee_code or "").strip() or _next_employee_code(db, int(user_obj.tenant_id))
duplicate = db.execute(
select(Employee).where(Employee.tenant_id == int(user_obj.tenant_id), Employee.employee_code == code)
).scalar_one_or_none()
if duplicate:
raise ValueError(f"Employee code '{code}' already exists in this firm.")
emp_type = (employment_type or "full_time").strip() or "full_time"
if emp_type not in {"full_time", "part_time", "article_assistant", "intern", "consultant", "contract"}:
emp_type = "full_time"
employee = Employee(
tenant_id=int(user_obj.tenant_id),
branch_id=int(user_obj.branch_id),
user_id=int(user_obj.id),
employee_code=code,
full_name=(user_obj.full_name or email or code).strip(),
email=email or None,
mobile=(mobile or "").strip() or None,
date_of_joining=_parse_optional_date(date_of_joining),
employment_type=emp_type,
status="active",
is_active=True,
department=(department or "").strip() or None,
designation=(designation or "").strip() or None,
created_by_user_id=actor.id,
updated_by_user_id=actor.id,
)
db.add(employee)
db.flush()
return {"required": True, "created": True, "linked_existing": False, "employee_id": employee.id}
def get_onboarding_role_config(role_key: str) -> dict[str, str] | None:
return ONBOARDING_ROLE_CONFIG.get((role_key or "").strip().lower())
def build_user_onboarding_payload(db: Session, request, current_user, role_key: str) -> dict[str, Any]:
roles = set(get_user_role_names(db, current_user.id))
tenant_id = _active_tenant_id(request, current_user, roles)
tenant = _tenant(db, tenant_id)
config = get_onboarding_role_config(role_key)
branches = _branch_rows(db, tenant_id)
return {
"tenant": tenant,
"tenant_id": tenant_id,
"role_key": (role_key or "").strip().lower(),
"role_config": config,
"branches": branches,
"employee_code_suggestion": _next_employee_code(db, int(tenant_id)) if tenant_id else "",
}
def create_firm_internal_user(
db: Session,
*,
request,
actor: User,
role_key: str,
email: str,
full_name: str,
branch_id: int,
employee_code: str | None = None,
mobile: str | None = None,
department: str | None = None,
designation: str | None = None,
date_of_joining: str | None = None,
employment_type: str | None = None,
) -> dict[str, Any]:
actor_roles = set(get_user_role_names(db, actor.id))
if not actor_roles.intersection(FIRM_ADMIN_ROLES):
raise PermissionError("Only Firm Admin or System Admin can add firm users.")
config = get_onboarding_role_config(role_key)
if not config:
raise ValueError("Invalid onboarding role.")
tenant_id = _active_tenant_id(request, actor, actor_roles)
if not tenant_id:
raise ValueError("Active firm context is required before adding users.")
branch = db.get(Branch, int(branch_id)) if branch_id else None
if not branch or int(branch.tenant_id) != int(tenant_id):
raise ValueError("Please select a valid branch for the active firm.")
if not branch.is_active:
raise ValueError("Selected branch is inactive.")
role = db.execute(
select(Role).where(Role.name == config["role_name"], Role.is_active.is_(True))
).scalar_one_or_none()
if not role:
raise ValueError(f"Role '{config['role_name']}' is not available. Please seed startup roles first.")
email_clean = (email or "").strip().lower()
name_clean = (full_name or "").strip()
if not email_clean or "@" not in email_clean:
raise ValueError("A valid email is required.")
if not name_clean:
raise ValueError("Full name is required.")
if db.execute(select(User).where(User.email == email_clean)).scalar_one_or_none():
raise ValueError("Email already exists.")
temp_password = hash_password(__import__("secrets").token_urlsafe(18))
user = User(
email=email_clean,
full_name=name_clean,
password_hash=temp_password,
tenant_id=int(tenant_id),
branch_id=int(branch.id),
is_active=True,
allow_login=True,
is_locked=False,
deleted_at=None,
must_change_password=True,
password_changed_at_utc=None,
designation=(designation or config.get("default_designation") or "").strip() or None,
mobile=(mobile or "").strip() or None,
)
db.add(user)
db.flush()
db.add(UserRole(user_id=user.id, role_id=role.id))
employee_link_result = _ensure_employee_for_onboarded_user(
db,
actor=actor,
user_obj=user,
employee_code=employee_code,
mobile=mobile,
department=(department or config.get("default_department") or ""),
designation=(designation or config.get("default_designation") or ""),
date_of_joining=date_of_joining,
employment_type=(employment_type or config.get("default_employment_type") or "full_time"),
)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise ValueError("Could not create user due to duplicate or invalid data.") from exc
db.refresh(user)
invite_token = issue_invite_token(db, user)
invite_url = _public_invite_url(invite_token)
email_status = "not_attempted"
email_error = None
try:
email_log = send_user_invite_email(db, user=user, invite_token=invite_token)
db.commit()
email_status = getattr(email_log, "status", None) or "attempted"
email_error = getattr(email_log, "error_message", None)
except Exception as exc: # invite link fallback remains available
db.rollback()
email_status = "failed"
email_error = str(exc)
return {
"user": user,
"role_name": role.name,
"role_label": config["label"],
"branch": branch,
"employee_link_result": employee_link_result,
"invite_url": invite_url,
"email_status": email_status,
"email_error": email_error,
}
def build_dashboard_payload(db: Session, request, current_user) -> dict[str, Any]:
roles = set(get_user_role_names(db, current_user.id))
tenant_id = _active_tenant_id(request, current_user, roles)
@@ -346,7 +619,7 @@ def _report_cards() -> list[dict[str, str]]:
def _wizard_cards() -> list[dict[str, str]]:
return [
{"title": "Add / Manage Branches", "desc": "Create branch, office timing, login control and local storage path.", "href": "/system-settings/branches"},
{"title": "Invite Users", "desc": "Create Firm Admin, Partner, Manager, Staff and role mapping.", "href": "/system-settings/users"},
{"title": "Invite Users", "desc": "Create Partner, Manager and Staff directly from Firm Admin.", "href": "/firm-admin/dashboard?tab=users"},
{"title": "Firm Profile & Branding", "desc": "Display name, logo, colours and contact details.", "href": "/system-settings/branding"},
{"title": "Select Firm Services", "desc": "Enable services from system catalogue for this firm.", "href": "/services"},
{"title": "Firm Task Templates", "desc": "Customize task list and document requirements for enabled services.", "href": "/services/templates"},
@@ -1,22 +1,47 @@
<div class="rounded-3xl border border-slate-200 bg-white shadow-soft">
<div class="flex flex-col gap-3 border-b border-slate-100 p-5 md:flex-row md:items-center md:justify-between">
<div><h2 class="text-lg font-bold text-slate-900">Users & Roles</h2><p class="text-sm text-slate-500">Firm Admin controls user invitations, branch mapping and role mapping.</p></div>
<a href="/system-settings/users" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Manage Users</a>
<div class="flex flex-col gap-3 border-b border-slate-100 p-5 xl:flex-row xl:items-center xl:justify-between">
<div>
<h2 class="text-lg font-bold text-slate-900">Users & Roles</h2>
<p class="text-sm text-slate-500">Firm Admin controls user invitations, branch mapping, employee linking and role mapping.</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/firm-admin/users/new?role=partner" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">+ Add Partner</a>
<a href="/firm-admin/users/new?role=manager" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">+ Add Manager</a>
<a href="/firm-admin/users/new?role=staff" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">+ Add Staff</a>
<a href="/system-settings/users" class="rounded-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Manage Users</a>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-100 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><tr><th class="px-5 py-3">User</th><th class="px-5 py-3">Roles</th><th class="px-5 py-3">Login Status</th><th class="px-5 py-3">Designation</th><th class="px-5 py-3">Mobile</th></tr></thead>
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th class="px-5 py-3">User</th>
<th class="px-5 py-3">Roles</th>
<th class="px-5 py-3">Employee Link</th>
<th class="px-5 py-3">Login Status</th>
<th class="px-5 py-3">Designation</th>
<th class="px-5 py-3">Mobile</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for user in users %}
<tr class="hover:bg-slate-50">
<td class="px-5 py-3"><div class="font-semibold text-slate-900">{{ user.name }}</div><div class="text-xs text-slate-500">{{ user.email }}</div></td>
<td class="px-5 py-3"><div class="flex flex-wrap gap-1">{% for role in user.roles %}<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-700">{{ role }}</span>{% else %}<span class="text-xs text-amber-700">No role</span>{% endfor %}</div></td>
<td class="px-5 py-3 text-xs">
{% if user.employee_linked %}
<span class="rounded-full bg-emerald-50 px-2 py-1 font-semibold text-emerald-700">Linked</span>
<div class="mt-1 text-slate-500">{{ user.employee_code or ('#' ~ user.employee_id) }}</div>
{% else %}
<span class="rounded-full bg-amber-50 px-2 py-1 font-semibold text-amber-700">Not linked</span>
{% endif %}
</td>
<td class="px-5 py-3 text-xs"><span class="rounded-full px-2 py-1 font-semibold {% if user.is_active and user.allow_login and not user.is_locked %}bg-emerald-50 text-emerald-700{% else %}bg-red-50 text-red-700{% endif %}">{% if user.is_locked %}Locked{% elif not user.is_active %}Inactive{% elif not user.allow_login %}Login Blocked{% else %}Active{% endif %}</span>{% if user.must_change_password %}<div class="mt-1 font-semibold text-amber-700">Invite/password pending</div>{% endif %}</td>
<td class="px-5 py-3 text-slate-600">{{ user.designation or '-' }}</td>
<td class="px-5 py-3 text-slate-600">{{ user.mobile or '-' }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="px-5 py-8 text-center text-slate-500">No users found.</td></tr>
<tr><td colspan="6" class="px-5 py-8 text-center text-slate-500">No users found.</td></tr>
{% endfor %}
</tbody>
</table>
@@ -1,7 +1,10 @@
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-soft">
<h2 class="text-lg font-bold text-slate-900">Firm Admin Wizards & Setup Shortcuts</h2>
<p class="mt-1 text-sm text-slate-500">These cards link to existing setup screens. They do not duplicate existing features.</p>
<p class="mt-1 text-sm text-slate-500">These cards link to existing setup screens and focused Firm Admin onboarding flows. They do not remove the existing system settings screens.</p>
<div class="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<a href="/firm-admin/users/new?role=partner" class="rounded-2xl border border-brand-200 bg-brand-50 p-4 hover:bg-brand-100"><div class="font-semibold text-brand-900">Add Partner</div><p class="mt-1 text-sm text-brand-700">Create partner user, link employee master and generate invite.</p><div class="mt-3 text-xs font-semibold text-brand-700">Open</div></a>
<a href="/firm-admin/users/new?role=manager" class="rounded-2xl border border-brand-200 bg-brand-50 p-4 hover:bg-brand-100"><div class="font-semibold text-brand-900">Add Manager</div><p class="mt-1 text-sm text-brand-700">Creates user with Branch Manager role.</p><div class="mt-3 text-xs font-semibold text-brand-700">Open</div></a>
<a href="/firm-admin/users/new?role=staff" class="rounded-2xl border border-brand-200 bg-brand-50 p-4 hover:bg-brand-100"><div class="font-semibold text-brand-900">Add Staff</div><p class="mt-1 text-sm text-brand-700">Create staff user, employee master and invite link.</p><div class="mt-3 text-xs font-semibold text-brand-700">Open</div></a>
{% for wizard in wizards %}
<a href="{{ wizard.href }}" class="rounded-2xl border border-slate-200 p-4 hover:border-brand-200 hover:bg-brand-50/40"><div class="font-semibold text-slate-900">{{ wizard.title }}</div><p class="mt-1 text-sm text-slate-500">{{ wizard.desc }}</p><div class="mt-3 text-xs font-semibold text-brand-700">Open</div></a>
{% endfor %}
@@ -0,0 +1,108 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% set data = form_data if form_data is defined and form_data else {} %}
<section class="mx-auto max-w-4xl space-y-6">
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-soft">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Firm Admin User Onboarding</p>
<h1 class="mt-2 text-2xl font-bold text-slate-900">Add {{ role_config.label if role_config else 'Firm User' }}</h1>
<p class="mt-1 text-sm text-slate-500">Create the user account, assign branch and role, create/link employee master, generate invite link and attempt invite email.</p>
</div>
<a href="/firm-admin/dashboard?tab=users" class="rounded-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to Users</a>
</div>
</div>
{% if not tenant_id %}
<div class="rounded-3xl border border-amber-200 bg-amber-50 p-5 text-sm font-semibold text-amber-900 shadow-soft">
Active firm context was not found. Please select/open a firm context before adding users.
</div>
{% elif not branches %}
<div class="rounded-3xl border border-amber-200 bg-amber-50 p-5 text-sm font-semibold text-amber-900 shadow-soft">
No active branch is available for this firm. Create a branch first, then add Partner / Manager / Staff.
</div>
{% elif not role_config %}
<div class="rounded-3xl border border-red-200 bg-red-50 p-5 text-sm font-semibold text-red-800 shadow-soft">
Invalid onboarding role.
</div>
{% else %}
{% if flash %}
<div class="rounded-3xl border border-red-200 bg-red-50 p-5 text-sm font-semibold text-red-800 shadow-soft">{{ flash }}</div>
{% endif %}
<form method="post" action="/firm-admin/users/new" class="rounded-3xl border border-slate-200 bg-white p-6 shadow-soft">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="role" value="{{ role_key }}" />
<div class="grid gap-5 md:grid-cols-2">
<label class="block">
<span class="text-sm font-semibold text-slate-700">Firm</span>
<input value="{{ tenant.display_name or tenant.name if tenant else '' }}" disabled class="mt-1 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-600" />
</label>
<label class="block">
<span class="text-sm font-semibold text-slate-700">Role</span>
<input value="{{ role_config.role_name }}" disabled class="mt-1 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-600" />
</label>
<label class="block">
<span class="text-sm font-semibold text-slate-700">Full Name <span class="text-red-600">*</span></span>
<input name="full_name" value="{{ data.full_name or '' }}" required class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100" />
</label>
<label class="block">
<span class="text-sm font-semibold text-slate-700">Email <span class="text-red-600">*</span></span>
<input type="email" name="email" value="{{ data.email or '' }}" required class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100" />
</label>
<label class="block">
<span class="text-sm font-semibold text-slate-700">Branch <span class="text-red-600">*</span></span>
<select name="branch_id" required class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100">
<option value="">-- Select Branch --</option>
{% for branch in branches %}
{% if branch.is_active %}
<option value="{{ branch.id }}" {% if data.branch_id|string == branch.id|string %}selected{% endif %}>{{ branch.name }}{% if branch.is_head_office %} / Head Office{% endif %}</option>
{% endif %}
{% endfor %}
</select>
</label>
<label class="block">
<span class="text-sm font-semibold text-slate-700">Mobile</span>
<input name="mobile" value="{{ data.mobile or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100" />
</label>
<label class="block">
<span class="text-sm font-semibold text-slate-700">Employee Code</span>
<input name="employee_code" value="{{ data.employee_code or employee_code_suggestion }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100" />
<span class="mt-1 block text-xs text-slate-500">Leave as-is or edit. Must be unique inside the firm.</span>
</label>
<label class="block">
<span class="text-sm font-semibold text-slate-700">Date of Joining</span>
<input type="date" name="date_of_joining" value="{{ data.date_of_joining or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100" />
</label>
<label class="block">
<span class="text-sm font-semibold text-slate-700">Department</span>
<input name="department" value="{{ data.department or role_config.default_department }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100" />
</label>
<label class="block">
<span class="text-sm font-semibold text-slate-700">Designation</span>
<input name="designation" value="{{ data.designation or role_config.default_designation }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100" />
</label>
<label class="block md:col-span-2">
<span class="text-sm font-semibold text-slate-700">Employment Type</span>
<select name="employment_type" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100">
{% set selected_emp_type = data.employment_type or role_config.default_employment_type %}
{% for code, label in [('full_time','Full time'),('part_time','Part time'),('article_assistant','Article Assistant'),('intern','Intern'),('contract','Contract')] %}
<option value="{{ code }}" {% if selected_emp_type == code %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
</div>
<div class="mt-6 rounded-2xl border border-brand-100 bg-brand-50 p-4 text-sm text-brand-900">
The system will generate an invite token and show the fallback invite link after saving. The user will set their own password from that link.
</div>
<div class="mt-6 flex flex-wrap justify-end gap-3">
<a href="/firm-admin/dashboard?tab=users" class="rounded-xl border border-slate-300 px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50">Cancel</a>
<button class="rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Create {{ role_config.label }}</button>
</div>
</form>
{% endif %}
</section>
{% endblock %}
@@ -0,0 +1,32 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
{% set result = onboarding_result %}
<section class="mx-auto max-w-3xl space-y-6">
<div class="rounded-3xl border border-emerald-200 bg-emerald-50 p-6 shadow-soft">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-emerald-700">User Created</p>
<h1 class="mt-2 text-2xl font-bold text-emerald-950">{{ result.role_label }} invite is ready</h1>
<p class="mt-1 text-sm text-emerald-800">The user account, role mapping, branch assignment and employee master link have been completed.</p>
</div>
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-soft">
<div class="grid gap-4 text-sm md:grid-cols-2">
<div><div class="text-xs font-semibold uppercase tracking-wide text-slate-400">User</div><div class="mt-1 font-semibold text-slate-900">{{ result.user.full_name or result.user.email }}</div><div class="text-slate-500">{{ result.user.email }}</div></div>
<div><div class="text-xs font-semibold uppercase tracking-wide text-slate-400">Role</div><div class="mt-1 font-semibold text-slate-900">{{ result.role_name }}</div></div>
<div><div class="text-xs font-semibold uppercase tracking-wide text-slate-400">Branch</div><div class="mt-1 font-semibold text-slate-900">{{ result.branch.name }}</div></div>
<div><div class="text-xs font-semibold uppercase tracking-wide text-slate-400">Employee Link</div><div class="mt-1 font-semibold text-slate-900">{% if result.employee_link_result.employee_id %}Linked #{{ result.employee_link_result.employee_id }}{% else %}Not linked{% endif %}</div></div>
<div class="md:col-span-2"><div class="text-xs font-semibold uppercase tracking-wide text-slate-400">Invite Email</div><div class="mt-1 font-semibold text-slate-900">{{ result.email_status }}</div>{% if result.email_error %}<div class="mt-1 text-xs text-amber-700">{{ result.email_error }}</div>{% endif %}</div>
</div>
<div class="mt-6">
<div class="text-sm font-semibold text-slate-700">Fallback Invite Link</div>
<div class="mt-2 break-all rounded-2xl border border-brand-200 bg-brand-50 p-4 text-sm text-brand-900">{{ result.invite_url }}</div>
<p class="mt-2 text-xs text-slate-500">Copy and share this link manually if the email is not delivered.</p>
</div>
<div class="mt-6 flex flex-wrap gap-3">
<a href="/firm-admin/dashboard?tab=users" class="rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-700">Back to Users & Roles</a>
<a href="/firm-admin/users/new?role={{ result.role_label|lower }}" class="rounded-xl border border-slate-300 px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50">Add another {{ result.role_label }}</a>
</div>
</div>
</section>
{% endblock %}
+129 -10
View File
@@ -1,14 +1,21 @@
from __future__ import annotations
from __future__ import annotations
from fastapi import APIRouter, Request
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
from app.core.db.common import CommonSessionLocal
from app.core.http_responses import ui_access_denied
from app.core.security.csrf import get_or_create_csrf_token
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.firm_admin_dashboard.service import build_dashboard_payload, can_access_firm_admin_dashboard
from app.modules.firm_admin_dashboard.service import (
build_dashboard_payload,
build_user_onboarding_payload,
can_access_firm_admin_dashboard,
create_firm_internal_user,
get_onboarding_role_config,
)
router = APIRouter(prefix="/firm-admin", tags=["firm-admin-dashboard-ui"])
@@ -25,9 +32,13 @@ VALID_TABS = {
}
def _ctx(request: Request, db, current_user, *, active_tab: str = "overview"):
def _redirect_login():
return RedirectResponse(url="/login", status_code=303)
def _ctx(request: Request, db, current_user, *, active_tab: str = "overview", **extra):
payload = build_dashboard_payload(db, request, current_user)
return {
ctx = {
"request": request,
"current_user": current_user,
"current_user_roles": get_user_roles(db, current_user.id),
@@ -37,6 +48,23 @@ def _ctx(request: Request, db, current_user, *, active_tab: str = "overview"):
"active_tab": active_tab,
**payload,
}
ctx.update(extra)
return ctx
def _onboarding_ctx(request: Request, db, current_user, role: str, **extra):
payload = build_user_onboarding_payload(db, request, current_user, role)
ctx = {
"request": request,
"current_user": current_user,
"current_user_roles": get_user_roles(db, current_user.id),
"current_user_permissions": get_user_permissions(db, current_user.id),
"csrf_token": get_or_create_csrf_token(request),
"title": "Add Firm User",
**payload,
}
ctx.update(extra)
return ctx
@router.get("/dashboard")
@@ -45,8 +73,7 @@ def dashboard(request: Request, tab: str = "overview"):
try:
current_user = get_current_user(request, db=db)
if not current_user:
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/login", status_code=303)
return _redirect_login()
if not can_access_firm_admin_dashboard(db, current_user):
return ui_access_denied()
active_tab = tab if tab in VALID_TABS else "overview"
@@ -64,8 +91,7 @@ def dashboard_tab(request: Request, tab_name: str):
try:
current_user = get_current_user(request, db=db)
if not current_user:
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/login", status_code=303)
return _redirect_login()
if not can_access_firm_admin_dashboard(db, current_user):
return ui_access_denied()
active_tab = tab_name if tab_name in VALID_TABS else "overview"
@@ -73,3 +99,96 @@ def dashboard_tab(request: Request, tab_name: str):
finally:
db.close()
@router.get("/users/new")
def user_onboarding_form(request: Request, role: str = "staff"):
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return _redirect_login()
if not can_access_firm_admin_dashboard(db, current_user):
return ui_access_denied()
if not get_onboarding_role_config(role):
return RedirectResponse(url="/firm-admin/dashboard?tab=users", status_code=303)
return templates.TemplateResponse(
"modules/firm_admin_dashboard/templates/firm_admin_dashboard/user_onboarding.html",
_onboarding_ctx(request, db, current_user, role),
)
finally:
db.close()
@router.post("/users/new")
def user_onboarding_submit(
request: Request,
role: str = Form(...),
email: str = Form(...),
full_name: str = Form(...),
branch_id: int = Form(...),
employee_code: str = Form(""),
mobile: str = Form(""),
department: str = Form(""),
designation: str = Form(""),
date_of_joining: str = Form(""),
employment_type: str = Form("full_time"),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
current_user = get_current_user(request, db=db)
if not current_user:
return _redirect_login()
if not can_access_firm_admin_dashboard(db, current_user):
return ui_access_denied()
if not get_onboarding_role_config(role):
return RedirectResponse(url="/firm-admin/dashboard?tab=users", status_code=303)
try:
result = create_firm_internal_user(
db,
request=request,
actor=current_user,
role_key=role,
email=email,
full_name=full_name,
branch_id=branch_id,
employee_code=employee_code,
mobile=mobile,
department=department,
designation=designation,
date_of_joining=date_of_joining,
employment_type=employment_type,
)
except Exception as exc:
db.rollback()
return templates.TemplateResponse(
"modules/firm_admin_dashboard/templates/firm_admin_dashboard/user_onboarding.html",
_onboarding_ctx(
request,
db,
current_user,
role,
flash=str(exc),
form_data={
"email": email,
"full_name": full_name,
"branch_id": branch_id,
"employee_code": employee_code,
"mobile": mobile,
"department": department,
"designation": designation,
"date_of_joining": date_of_joining,
"employment_type": employment_type,
},
),
status_code=400,
)
return templates.TemplateResponse(
"modules/firm_admin_dashboard/templates/firm_admin_dashboard/user_onboarding_done.html",
_ctx(request, db, current_user, active_tab="users", title="Invite Link", onboarding_result=result),
)
finally:
db.close()