Allow partners to add staff or branch managers

This commit is contained in:
A R R R Associates
2026-08-04 22:59:18 +05:30
parent da8f49fe0a
commit 9abe68d695
3 changed files with 47 additions and 14 deletions
+29 -7
View File
@@ -292,12 +292,16 @@ def create_employee(db: Session, actor: User, scope: EmployeeScope, data: dict[s
cleaned = _clean_payload(data) cleaned = _clean_payload(data)
partner_staff_mode = bool(scope.is_partner and not scope.is_system_admin and not scope.is_firm_admin) partner_staff_mode = bool(scope.is_partner and not scope.is_system_admin and not scope.is_firm_admin)
# Partners can create staff only in their own tenant and branch. Never trust # Partners can create Staff or Branch Manager users only in their own
# tenant, branch or role values posted by the browser for this workflow. # tenant and branch. Never trust tenant or branch values posted by the
# browser for this workflow, and validate the requested role server-side.
if partner_staff_mode: if partner_staff_mode:
cleaned["tenant_id"] = scope.tenant_id cleaned["tenant_id"] = scope.tenant_id
cleaned["branch_id"] = scope.branch_id or actor.branch_id cleaned["branch_id"] = scope.branch_id or actor.branch_id
cleaned["employee_role"] = "Staff" requested_role = (cleaned.get("employee_role") or "Staff").strip()
if requested_role not in {"Staff", "Branch Manager"}:
raise HTTPException(status_code=403, detail="Partners can create only Staff or Branch Manager users.")
cleaned["employee_role"] = requested_role
tenant_id = int(cleaned.get("tenant_id") or scope.tenant_id) tenant_id = int(cleaned.get("tenant_id") or scope.tenant_id)
branch_id = int(cleaned.get("branch_id") or actor.branch_id) branch_id = int(cleaned.get("branch_id") or actor.branch_id)
@@ -322,9 +326,10 @@ def create_employee(db: Session, actor: User, scope: EmployeeScope, data: dict[s
raise HTTPException(status_code=400, detail="Selected user must belong to the employee tenant and branch.") raise HTTPException(status_code=400, detail="Selected user must belong to the employee tenant and branch.")
if partner_staff_mode: if partner_staff_mode:
linked_roles = _role_set(db, linked_user) linked_roles = _role_set(db, linked_user)
elevated_roles = {"System Admin", "Firm Admin", "Partner", "Branch Manager"} allowed_roles = {"Staff", "Branch Manager"}
if "Staff" not in linked_roles or linked_roles.intersection(elevated_roles): prohibited_roles = {"System Admin", "Firm Admin", "Partner"}
raise HTTPException(status_code=403, detail="Partners can link only a Staff login user.") if not linked_roles.intersection(allowed_roles) or linked_roles.intersection(prohibited_roles):
raise HTTPException(status_code=403, detail="Partners can link only a Staff or Branch Manager login user.")
user_id = linked_user.id user_id = linked_user.id
elif cleaned.get("create_login_user"): elif cleaned.get("create_login_user"):
login_user = create_login_user_for_employee( login_user = create_login_user_for_employee(
@@ -505,7 +510,24 @@ def link_employee_to_user(db: Session, actor: User, emp: Employee, user_id: int
def list_reporting_managers(db: Session, scope: EmployeeScope) -> list[User]: def list_reporting_managers(db: Session, scope: EmployeeScope) -> list[User]:
stmt = select(User).where(User.tenant_id == scope.tenant_id, User.deleted_at.is_(None), User.is_active.is_(True)) """Return only active Partner and Branch Manager users in scope.
Client and other non-employee login roles must never appear in the
Reporting Manager dropdown.
"""
stmt = (
select(User)
.join(UserRole, UserRole.user_id == User.id)
.join(Role, Role.id == UserRole.role_id)
.where(
User.tenant_id == scope.tenant_id,
User.deleted_at.is_(None),
User.is_active.is_(True),
Role.is_active.is_(True),
Role.name.in_(("Partner", "Branch Manager")),
)
.distinct()
)
if scope.branch_id is not None: if scope.branch_id is not None:
stmt = stmt.where(User.branch_id == scope.branch_id) stmt = stmt.where(User.branch_id == scope.branch_id)
return db.execute(stmt.order_by(User.full_name, User.email)).scalars().all() return db.execute(stmt.order_by(User.full_name, User.email)).scalars().all()
@@ -44,9 +44,12 @@
<div> <div>
<label class="mb-1 block text-sm font-medium text-slate-700">Employee User Role</label> <label class="mb-1 block text-sm font-medium text-slate-700">Employee User Role</label>
{% if partner_staff_mode %} {% if partner_staff_mode %}
<input type="hidden" name="employee_role" value="Staff"> <select name="employee_role" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<input value="Staff" disabled class="w-full rounded-xl border border-slate-300 bg-slate-100 px-3 py-2 text-sm text-slate-700"> {% set selected_role = employee.get('employee_role', 'Staff') if employee and is_dict else 'Staff' %}
<p class="mt-1 text-xs text-slate-500">Partners can create Staff users only.</p> <option value="Staff" {% if selected_role == 'Staff' %}selected{% endif %}>Staff</option>
<option value="Branch Manager" {% if selected_role == 'Branch Manager' %}selected{% endif %}>Manager</option>
</select>
<p class="mt-1 text-xs text-slate-500">The user will be created for your branch as Staff or Manager.</p>
{% else %} {% else %}
<select name="employee_role" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{% for r in employee_role_names %}<option value="{{ r }}" {% if r == 'Staff' %}selected{% endif %}>{{ r }}</option>{% endfor %}</select> <select name="employee_role" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{% for r in employee_role_names %}<option value="{{ r }}" {% if r == 'Staff' %}selected{% endif %}>{{ r }}</option>{% endfor %}</select>
{% endif %} {% endif %}
+12 -4
View File
@@ -328,10 +328,17 @@ def _form_options(db, current_user, scope, *, include_user_id: int | None = None
users = list_linkable_users(db, scope, include_user_id=include_user_id) users = list_linkable_users(db, scope, include_user_id=include_user_id)
partner_staff_mode = bool(scope.is_partner and not scope.is_system_admin and not scope.is_firm_admin) partner_staff_mode = bool(scope.is_partner and not scope.is_system_admin and not scope.is_firm_admin)
# A Partner may onboard or link Staff users only. The service layer repeats # A Partner may onboard or link only Staff and Branch Manager users. The
# this rule so a forged POST cannot bypass the form restriction. # service layer repeats this rule so a forged POST cannot bypass the form.
if partner_staff_mode: if partner_staff_mode:
users = [user for user in users if "Staff" in set(get_user_roles(db, user.id))] allowed_roles = {"Staff", "Branch Manager"}
prohibited_roles = {"System Admin", "Firm Admin", "Partner"}
users = [
user
for user in users
if set(get_user_roles(db, user.id)).intersection(allowed_roles)
and not set(get_user_roles(db, user.id)).intersection(prohibited_roles)
]
return { return {
"tenants": visible_tenants(db, current_user), "tenants": visible_tenants(db, current_user),
@@ -601,7 +608,8 @@ async def employee_create_submit(request: Request):
scope = build_employee_scope(db, current_user, tenant_id=form.get("tenant_id"), branch_id=form.get("branch_id")) scope = build_employee_scope(db, current_user, tenant_id=form.get("tenant_id"), branch_id=form.get("branch_id"))
payload = _form_payload(form, include_context=True) payload = _form_payload(form, include_context=True)
if scope.is_partner and not scope.is_system_admin and not scope.is_firm_admin: if scope.is_partner and not scope.is_system_admin and not scope.is_firm_admin:
payload["employee_role"] = "Staff" requested_role = (payload.get("employee_role") or "Staff").strip()
payload["employee_role"] = requested_role if requested_role in {"Staff", "Branch Manager"} else "Staff"
payload["tenant_id"] = scope.tenant_id payload["tenant_id"] = scope.tenant_id
payload["branch_id"] = scope.branch_id or current_user.branch_id payload["branch_id"] = scope.branch_id or current_user.branch_id
try: try: