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:
|
def create_employee(db: Session, actor: User, scope: EmployeeScope, data: dict[str, Any]) -> Employee:
|
||||||
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)
|
||||||
|
|
||||||
|
# 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)
|
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)
|
||||||
|
|
||||||
@@ -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.")
|
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:
|
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.")
|
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
|
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(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-slate-900">{{ title }}</h2>
|
<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>
|
</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>
|
<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>
|
</div>
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
|
|
||||||
{% if not is_edit %}
|
{% if not is_edit %}
|
||||||
<div class="rounded-2xl border border-brand-100 bg-brand-50 p-4">
|
<div class="rounded-2xl border border-brand-100 bg-brand-50 p-4">
|
||||||
<h3 class="font-semibold text-slate-900">Employee Login Link</h3>
|
<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">Either link an existing unlinked login user or create a login user automatically for this employee.</p>
|
<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 class="mt-4 grid gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-sm font-medium text-slate-700">Existing user</label>
|
<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">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>
|
<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 %}
|
||||||
|
<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>
|
<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>
|
</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>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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):
|
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 {
|
return {
|
||||||
"tenants": visible_tenants(db, current_user),
|
"tenants": visible_tenants(db, current_user),
|
||||||
"branches": visible_branches(db, current_user, scope.tenant_id),
|
"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),
|
"managers": list_reporting_managers(db, scope),
|
||||||
"scope": scope,
|
"scope": scope,
|
||||||
|
"partner_staff_mode": partner_staff_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -563,7 +572,7 @@ def employee_new(request: Request):
|
|||||||
"modules/employees/templates/employees/form.html",
|
"modules/employees/templates/employees/form.html",
|
||||||
db,
|
db,
|
||||||
current_user,
|
current_user,
|
||||||
title="Add Employee",
|
title="Add Staff" if scope.is_partner else "Add Employee",
|
||||||
employee=None,
|
employee=None,
|
||||||
errors=[],
|
errors=[],
|
||||||
mode="create",
|
mode="create",
|
||||||
@@ -591,6 +600,10 @@ async def employee_create_submit(request: Request):
|
|||||||
return _redirect_denied()
|
return _redirect_denied()
|
||||||
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:
|
||||||
|
payload["employee_role"] = "Staff"
|
||||||
|
payload["tenant_id"] = scope.tenant_id
|
||||||
|
payload["branch_id"] = scope.branch_id or current_user.branch_id
|
||||||
try:
|
try:
|
||||||
emp = create_employee(db, current_user, scope, payload)
|
emp = create_employee(db, current_user, scope, payload)
|
||||||
return RedirectResponse(url=f"/employees/{emp.id}", status_code=303)
|
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",
|
"modules/employees/templates/employees/form.html",
|
||||||
db,
|
db,
|
||||||
current_user,
|
current_user,
|
||||||
title="Add Employee",
|
title="Add Staff" if scope.is_partner else "Add Employee",
|
||||||
employee=payload,
|
employee=payload,
|
||||||
errors=[getattr(exc, "detail", str(exc))],
|
errors=[getattr(exc, "detail", str(exc))],
|
||||||
mode="create",
|
mode="create",
|
||||||
|
|||||||
@@ -46,6 +46,15 @@
|
|||||||
{'label': 'Assign Service', 'url': '/services/engagements/new', 'active': _partner_path == '/services/engagements/new'}
|
{'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',
|
'label': 'Documents',
|
||||||
'url': '/documents',
|
'url': '/documents',
|
||||||
|
|||||||
Reference in New Issue
Block a user