Allow partners to securely add staff

This commit is contained in:
A R R R Associates
2026-08-04 20:15:17 +05:30
parent 1b5f90700a
commit da8f49fe0a
4 changed files with 50 additions and 8 deletions
+14
View File
@@ -290,6 +290,15 @@ def create_login_user_for_employee(
def create_employee(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> Employee:
cleaned = _clean_payload(data)
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
# tenant, branch or role values posted by the browser for this workflow.
if partner_staff_mode:
cleaned["tenant_id"] = scope.tenant_id
cleaned["branch_id"] = scope.branch_id or actor.branch_id
cleaned["employee_role"] = "Staff"
tenant_id = int(cleaned.get("tenant_id") or scope.tenant_id)
branch_id = int(cleaned.get("branch_id") or actor.branch_id)
@@ -311,6 +320,11 @@ def create_employee(db: Session, actor: User, scope: EmployeeScope, data: dict[s
raise HTTPException(status_code=404, detail="Selected user was not found.")
if linked_user.tenant_id != tenant_id or linked_user.branch_id != branch_id:
raise HTTPException(status_code=400, detail="Selected user must belong to the employee tenant and branch.")
if partner_staff_mode:
linked_roles = _role_set(db, linked_user)
elevated_roles = {"System Admin", "Firm Admin", "Partner", "Branch Manager"}
if "Staff" not in linked_roles or linked_roles.intersection(elevated_roles):
raise HTTPException(status_code=403, detail="Partners can link only a Staff login user.")
user_id = linked_user.id
elif cleaned.get("create_login_user"):
login_user = create_login_user_for_employee(
@@ -11,7 +11,7 @@
<div class="flex items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-slate-900">{{ title }}</h2>
<p class="text-sm text-slate-500">Create or update the employee master. Attendance, leave, payroll and ESS will be added in later phases.</p>
<p class="text-sm text-slate-500">{% if partner_staff_mode and not is_edit %}Create a Staff employee in your assigned branch and optionally create the Staff login account.{% else %}Create or update the employee master. Attendance, leave, payroll and ESS will be added in later phases.{% endif %}</p>
</div>
<a href="/employees" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back</a>
</div>
@@ -25,8 +25,8 @@
{% if not is_edit %}
<div class="rounded-2xl border border-brand-100 bg-brand-50 p-4">
<h3 class="font-semibold text-slate-900">Employee Login Link</h3>
<p class="mt-1 text-sm text-slate-600">Either link an existing unlinked login user or create a login user automatically for this employee.</p>
<h3 class="font-semibold text-slate-900">{% if partner_staff_mode %}Staff Login Link{% else %}Employee Login Link{% endif %}</h3>
<p class="mt-1 text-sm text-slate-600">{% if partner_staff_mode %}Link an existing unlinked Staff login or create a Staff login automatically for this employee.{% else %}Either link an existing unlinked login user or create a login user automatically for this employee.{% endif %}</p>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Existing user</label>
@@ -43,7 +43,13 @@
<div><label class="mb-1 block text-sm font-medium text-slate-700">Temporary Password</label><input type="password" name="temporary_password" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"><p class="mt-1 text-xs text-slate-500">Minimum 8 characters. User must change password after login.</p></div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Employee User Role</label>
{% if partner_staff_mode %}
<input type="hidden" name="employee_role" value="Staff">
<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">
<p class="mt-1 text-xs text-slate-500">Partners can create Staff users only.</p>
{% 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>
{% endif %}
</div>
</div>
</div>
@@ -101,7 +107,7 @@
<label class="inline-flex items-center gap-2 text-sm text-slate-700"><input type="checkbox" name="is_active" {% if not employee or val('status','active') == 'active' %}checked{% endif %}> Active employee</label>
<div class="flex justify-end gap-3"><a href="/employees" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Cancel</a><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save Employee</button></div>
<div class="flex justify-end gap-3"><a href="/employees" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Cancel</a><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">{% if partner_staff_mode and not is_edit %}Save Staff{% else %}Save Employee{% endif %}</button></div>
</form>
</div>
{% endblock %}
+16 -3
View File
@@ -325,12 +325,21 @@ def _form_payload(form, *, include_context: bool = False):
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)
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
# this rule so a forged POST cannot bypass the form restriction.
if partner_staff_mode:
users = [user for user in users if "Staff" in set(get_user_roles(db, user.id))]
return {
"tenants": visible_tenants(db, current_user),
"branches": visible_branches(db, current_user, scope.tenant_id),
"users": list_linkable_users(db, scope, include_user_id=include_user_id),
"users": users,
"managers": list_reporting_managers(db, scope),
"scope": scope,
"partner_staff_mode": partner_staff_mode,
}
@@ -563,7 +572,7 @@ def employee_new(request: Request):
"modules/employees/templates/employees/form.html",
db,
current_user,
title="Add Employee",
title="Add Staff" if scope.is_partner else "Add Employee",
employee=None,
errors=[],
mode="create",
@@ -591,6 +600,10 @@ async def employee_create_submit(request: Request):
return _redirect_denied()
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)
if scope.is_partner and not scope.is_system_admin and not scope.is_firm_admin:
payload["employee_role"] = "Staff"
payload["tenant_id"] = scope.tenant_id
payload["branch_id"] = scope.branch_id or current_user.branch_id
try:
emp = create_employee(db, current_user, scope, payload)
return RedirectResponse(url=f"/employees/{emp.id}", status_code=303)
@@ -600,7 +613,7 @@ async def employee_create_submit(request: Request):
"modules/employees/templates/employees/form.html",
db,
current_user,
title="Add Employee",
title="Add Staff" if scope.is_partner else "Add Employee",
employee=payload,
errors=[getattr(exc, "detail", str(exc))],
mode="create",
@@ -46,6 +46,15 @@
{'label': 'Assign Service', 'url': '/services/engagements/new', 'active': _partner_path == '/services/engagements/new'}
]
},
{
'label': 'Staff',
'visible': true,
'active': _partner_path.startswith('/employees'),
'children': [
{'label': 'Staff List', 'url': '/employees', 'active': _partner_path == '/employees'},
{'label': 'Add Staff', 'url': '/employees/new', 'active': _partner_path == '/employees/new'}
]
},
{
'label': 'Documents',
'url': '/documents',