Allow partners to securely add staff
This commit is contained in:
@@ -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 %}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user