diff --git a/backend/api/v1/employees/serializers/employee.py b/backend/api/v1/employees/serializers/employee.py index 6b46e5cf..bba84928 100644 --- a/backend/api/v1/employees/serializers/employee.py +++ b/backend/api/v1/employees/serializers/employee.py @@ -407,6 +407,8 @@ class EmployeeSerializer(serializers.ModelSerializer): u = req.user perms = sorted(u.get_all_permissions()) + from finance.payroll.access import can_access_payroll_management + by_app = defaultdict(list) for p in perms: app, code = p.split(".", 1) @@ -418,6 +420,7 @@ class EmployeeSerializer(serializers.ModelSerializer): "email": u.email or "", "is_staff": u.is_staff, "is_superuser": u.is_superuser, + "can_manage_payroll": can_access_payroll_management(u), "groups": list(u.groups.values_list("name", flat=True)), "permissions": perms, "permissions_by_app": perms_by_app, diff --git a/backend/api/v1/finance/admin_views.py b/backend/api/v1/finance/admin_views.py index 853022f7..540c2fd1 100644 --- a/backend/api/v1/finance/admin_views.py +++ b/backend/api/v1/finance/admin_views.py @@ -32,6 +32,7 @@ from finance.enums import ( ) from finance.models import ( EmployeePayRate, + FinanceSettings, PayrollAuditEvent, PayrollCarryover, PayrollCellCommentThread, @@ -53,7 +54,11 @@ from finance.payroll.carryovers import ( create_carryover, materialize_pending_carryovers, ) -from finance.payroll.access import has_payroll_permission, has_simple_admin_access +from finance.payroll.access import ( + can_access_payroll_management, + has_payroll_permission, + has_simple_admin_access, +) from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionDenied from finance.payroll.services import ( approve_input_line, @@ -354,6 +359,8 @@ class PayrollAdminAPIView(NoStorePayrollResponseMixin, APIView): def initial(self, request, *args, **kwargs): super().initial(request, *args, **kwargs) + if not can_access_payroll_management(request.user): + _permission_error("finance.payroll_management_access") if not any( has_payroll_permission(request.user, code) for code in self.allowed_permissions @@ -400,6 +407,7 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView): """One bounded bootstrap response for the native payroll screen.""" def get(self, request): + finance_settings = FinanceSettings.get_solo() permissions_payload = { key: has_payroll_permission(request.user, permission) for key, permission in PAYROLL_PERMISSIONS.items() @@ -671,6 +679,14 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView): return Response( { "permissions": permissions_payload, + "access_settings": { + "restricted": finance_settings.management_access_is_restricted(), + "management_user_count": finance_settings.management_users.count(), + "can_configure": ( + request.user.is_superuser + or request.user.has_perm("finance.change_financesettings") + ), + }, "employees": PayrollEmployeeMiniSerializer(employees, many=True).data, "rate_employees": PayrollEmployeeMiniSerializer( rate_employees, @@ -705,6 +721,77 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView): ) +class PayrollManagementAccessSettingsView(NoStorePayrollResponseMixin, APIView): + """Read and update the allow-list shown in the native finance workspace.""" + + permission_classes = [permissions.IsAuthenticated] + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + if not ( + request.user.is_superuser + or request.user.has_perm("finance.change_financesettings") + ): + _permission_error("finance.change_financesettings") + + def _payload(self): + from ..employees.serializers import EmployeeBriefSerializer + + finance_settings = FinanceSettings.get_solo() + selected_ids = list( + finance_settings.management_users.values_list("id", flat=True) + ) + candidates = Employee.objects.filter( + Q(is_active=True) & (Q(is_staff=True) | Q(is_superuser=True)) + | Q(id__in=selected_ids) + ).order_by("last_name", "first_name", "id") + return { + "restricted": bool(selected_ids), + "management_user_ids": selected_ids, + "management_user_count": len(selected_ids), + "candidates": EmployeeBriefSerializer(candidates, many=True).data, + } + + def get(self, request): + return Response(self._payload()) + + def patch(self, request): + raw_ids = request.data.get("management_user_ids") + if not isinstance(raw_ids, list): + raise ValidationError( + {"management_user_ids": "Передайте список администраторов."} + ) + try: + requested_ids = {int(user_id) for user_id in raw_ids} + except (TypeError, ValueError): + raise ValidationError( + {"management_user_ids": "Идентификаторы должны быть числами."} + ) + if any(user_id <= 0 for user_id in requested_ids): + raise ValidationError( + {"management_user_ids": "Некорректный идентификатор пользователя."} + ) + + eligible_users = Employee.objects.filter( + id__in=requested_ids, + is_active=True, + ).filter(Q(is_staff=True) | Q(is_superuser=True)) + eligible_ids = set(eligible_users.values_list("id", flat=True)) + invalid_ids = requested_ids - eligible_ids + if invalid_ids: + raise ValidationError( + { + "management_user_ids": ( + "Выбирать можно только активных администраторов." + ) + } + ) + + finance_settings = FinanceSettings.get_solo() + finance_settings.management_users.set(eligible_users) + return Response(self._payload()) + + class PayrollAdminEmployeeListView(PayrollAdminAPIView): def get(self, request): employees = ( diff --git a/backend/api/v1/finance/urls.py b/backend/api/v1/finance/urls.py index 5d564f6f..57f0c992 100644 --- a/backend/api/v1/finance/urls.py +++ b/backend/api/v1/finance/urls.py @@ -23,6 +23,7 @@ from .admin_views import ( PayrollAdminPeriodDetailView, PayrollAdminPeriodListCreateView, PayrollAdminWorkspaceView, + PayrollManagementAccessSettingsView, PayrollAttendanceWorkRecordsView, PayrollInputLineApproveView, PayrollInputLineDetailView, @@ -50,6 +51,11 @@ from .admin_views import ( app_name = "finance-payroll" urlpatterns = [ + path( + "payroll/management-access/", + PayrollManagementAccessSettingsView.as_view(), + name="management-access-settings", + ), path( "payroll/admin/workspace/", PayrollAdminWorkspaceView.as_view(), diff --git a/backend/finance/admin.py b/backend/finance/admin.py index 183dbb01..0adbd676 100644 --- a/backend/finance/admin.py +++ b/backend/finance/admin.py @@ -10,6 +10,7 @@ from django.template.response import TemplateResponse from .enums import ApprovalStatus from .models import ( EmployeePayRate, + FinanceSettings, PayrollAuditEvent, PayrollComponent, PayrollDailyWorkEntry, @@ -36,6 +37,29 @@ from .payroll.services import ( ) +@admin.register(FinanceSettings) +class FinanceSettingsAdmin(admin.ModelAdmin): + """Singleton allow-list for the native finance management workspace.""" + + list_display = ("name", "management_users_count", "updated_at") + fields = ("name", "management_users", "updated_at") + readonly_fields = ("updated_at",) + autocomplete_fields = ("management_users",) + + def has_add_permission(self, request): + if FinanceSettings.objects.exists(): + return False + return super().has_add_permission(request) + + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser + + @admin.display(description="Доступ к управлению") + def management_users_count(self, obj): + count = obj.management_users.count() + return count or "Все администраторы" + + def payroll_reason_action_response( model_admin, request, diff --git a/backend/finance/migrations/0018_financesettings.py b/backend/finance/migrations/0018_financesettings.py new file mode 100644 index 00000000..719549aa --- /dev/null +++ b/backend/finance/migrations/0018_financesettings.py @@ -0,0 +1,55 @@ +# Generated by Django 5.2.4 on 2026-08-10 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("finance", "0017_payrollcarryover"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="FinanceSettings", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + default="Основные настройки", + max_length=100, + verbose_name="Название", + ), + ), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Обновлено")), + ( + "management_users", + models.ManyToManyField( + blank=True, + help_text=( + "Если список пуст, блок доступен всем администраторам и сотрудникам " + "с финансовыми правами. Заполните список, чтобы ограничить доступ." + ), + related_name="finance_management_settings", + to=settings.AUTH_USER_MODEL, + verbose_name="Доступ к блоку «Управление»", + ), + ), + ], + options={ + "verbose_name": "Настройки финансов", + "verbose_name_plural": "Настройки финансов", + }, + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index 686d5c16..6a3840f6 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -41,6 +41,49 @@ payroll_cell_column_validator = RegexValidator( DEFAULT_DAILY_TARGET_POINTS = Decimal("5") +class FinanceSettings(models.Model): + """Global access settings for the native finance management workspace.""" + + name = models.CharField( + "Название", + max_length=100, + default="Основные настройки", + ) + management_users = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="finance_management_settings", + verbose_name="Доступ к блоку «Управление»", + help_text=( + "Если список пуст, блок доступен всем администраторам и сотрудникам " + "с финансовыми правами. Заполните список, чтобы ограничить доступ." + ), + ) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Настройки финансов" + verbose_name_plural = "Настройки финансов" + + def __str__(self): + return self.name + + @classmethod + def get_solo(cls): + instance = cls.objects.first() + return instance if instance else cls.objects.create() + + def management_access_is_restricted(self) -> bool: + return self.management_users.exists() + + def allows_management_user(self, user) -> bool: + if user is None or not getattr(user, "is_authenticated", False): + return False + if not self.management_access_is_restricted(): + return True + return self.management_users.filter(pk=user.pk, is_active=True).exists() + + class PayrollPeriod(models.Model): """A calculation and publication period controlled by one active run.""" diff --git a/backend/finance/payroll/access.py b/backend/finance/payroll/access.py index 8052f60f..7e204710 100644 --- a/backend/finance/payroll/access.py +++ b/backend/finance/payroll/access.py @@ -10,6 +10,14 @@ from __future__ import annotations from .config import simple_admin_access_enabled SELF_APPROVAL_OVERRIDE_PERMISSION = "finance.override_payroll_approval" +PAYROLL_MANAGEMENT_PERMISSIONS = ( + "finance.manage_payroll_inputs", + "finance.approve_payroll_inputs", + "finance.calculate_payroll", + "finance.approve_payroll", + "finance.publish_payroll", + "finance.view_all_payroll", +) def has_simple_admin_access(user) -> bool: @@ -34,6 +42,24 @@ def has_payroll_permission(user, permission: str) -> bool: return user.has_perm(permission) +def can_access_payroll_management(user) -> bool: + """Check the global allow-list and the user's underlying payroll access.""" + + if user is None or not getattr(user, "is_authenticated", False): + return False + if not getattr(user, "is_active", False): + return False + + # Imported lazily so model imports never depend on the payroll adapter. + from finance.models import FinanceSettings + + if not FinanceSettings.get_solo().allows_management_user(user): + return False + return has_simple_admin_access(user) or any( + user.has_perm(permission) for permission in PAYROLL_MANAGEMENT_PERMISSIONS + ) + + def can_self_approve_payroll(user) -> bool: """Allow admins to approve directly while retaining the old override role.""" diff --git a/backend/tests/finance/test_payroll_admin_permissions.py b/backend/tests/finance/test_payroll_admin_permissions.py index 26921e2b..0b1fa4f9 100644 --- a/backend/tests/finance/test_payroll_admin_permissions.py +++ b/backend/tests/finance/test_payroll_admin_permissions.py @@ -14,6 +14,7 @@ from finance.admin import ( ) from finance.models import ( EmployeePayRate, + FinanceSettings, PayrollAuditEvent, PayrollDailyWorkEntry, PayrollPeriod, @@ -208,6 +209,109 @@ def test_simple_mode_staff_can_open_and_edit_any_payroll_draft( assert "amount" not in model_admin.get_readonly_fields(request, rate) +def test_finance_management_allow_list_restricts_native_admin_api( + settings, + user_factory, + api_client, +): + settings.FINANCE_PAYROLL = {"SIMPLE_ADMIN_ACCESS": True} + allowed_admin = user_factory(email="allowed.finance@example.test", staff=True) + denied_admin = user_factory(email="denied.finance@example.test", staff=True) + FinanceSettings.get_solo().management_users.set([allowed_admin]) + + workspace_url = reverse("api:v1:finance-payroll:admin-workspace") + + api_client.force_authenticate(user=denied_admin) + denied_response = api_client.get(workspace_url) + assert denied_response.status_code == 403 + + api_client.force_authenticate(user=allowed_admin) + allowed_response = api_client.get(workspace_url) + assert allowed_response.status_code == 200 + assert allowed_response.data["access_settings"] == { + "restricted": True, + "management_user_count": 1, + "can_configure": False, + } + + +def test_empty_finance_management_allow_list_keeps_all_admins_enabled( + settings, + user_factory, +): + from finance.payroll.access import can_access_payroll_management + + settings.FINANCE_PAYROLL = {"SIMPLE_ADMIN_ACCESS": True} + administrator = user_factory(email="all.finance@example.test", staff=True) + + assert FinanceSettings.get_solo().management_users.count() == 0 + assert can_access_payroll_management(administrator) is True + + +def test_superuser_can_update_finance_management_allow_list_from_api( + settings, + user_factory, + api_client, +): + settings.FINANCE_PAYROLL = {"SIMPLE_ADMIN_ACCESS": True} + superuser = user_factory( + email="settings.finance@example.test", + staff=True, + superuser=True, + ) + selected_admin = user_factory( + email="selected.finance@example.test", + staff=True, + ) + regular_employee = user_factory(email="regular.finance@example.test") + settings_url = reverse("api:v1:finance-payroll:management-access-settings") + api_client.force_authenticate(user=superuser) + + response = api_client.patch( + settings_url, + {"management_user_ids": [selected_admin.id]}, + format="json", + ) + + assert response.status_code == 200 + assert response.data["restricted"] is True + assert response.data["management_user_ids"] == [selected_admin.id] + assert FinanceSettings.get_solo().management_users.filter( + id=selected_admin.id + ).exists() + + invalid_response = api_client.patch( + settings_url, + {"management_user_ids": [regular_employee.id]}, + format="json", + ) + assert invalid_response.status_code == 400 + + unrestricted_response = api_client.patch( + settings_url, + {"management_user_ids": []}, + format="json", + ) + assert unrestricted_response.status_code == 200 + assert unrestricted_response.data["restricted"] is False + + +def test_regular_payroll_admin_cannot_change_management_allow_list( + settings, + user_factory, + api_client, +): + settings.FINANCE_PAYROLL = {"SIMPLE_ADMIN_ACCESS": True} + administrator = user_factory(email="readonly.finance@example.test", staff=True) + api_client.force_authenticate(user=administrator) + + response = api_client.get( + reverse("api:v1:finance-payroll:management-access-settings") + ) + + assert response.status_code == 403 + + def test_standard_model_permission_does_not_expose_all_statements(user_factory): staff = user_factory(email="generic.admin@example.test", staff=True) grant(staff, "view_payrollstatement") diff --git a/frontend/src/app/finances/page.tsx b/frontend/src/app/finances/page.tsx index 6b31a7a5..cb50ece6 100644 --- a/frontend/src/app/finances/page.tsx +++ b/frontend/src/app/finances/page.tsx @@ -166,11 +166,15 @@ export default function FinancesPage() { Управление ) : null} + {activeTab === "management" && showPayrollAdmin ? ( +
+ ) : null}
{activeTab === "management" && showPayrollAdmin ? ( (null); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); @@ -424,6 +427,7 @@ export function PayrollAdminWorkspace({ const [inputModalOpen, setInputModalOpen] = useState(false); const [editingInput, setEditingInput] = useState(null); const [pendingAction, setPendingAction] = useState(null); + const [accessModalOpen, setAccessModalOpen] = useState(false); const createdPeriodIdRef = useRef(null); const selectedPeriod = workspace?.selected_period || null; @@ -925,12 +929,37 @@ export function PayrollAdminWorkspace({ {permissions.manage_inputs ? : null} ); + const accessLabel = workspace.access_settings.restricted + ? `Доступ: выбрано ${workspace.access_settings.management_user_count}` + : "Доступ: все администраторы"; + const managementAccess = workspace.access_settings.can_configure ? ( + + ) : ( + + + {accessLabel} + + ); const headerTarget = headerTargetId && typeof document !== "undefined" ? document.getElementById(headerTargetId) : null; const actionsTarget = actionsTargetId && typeof document !== "undefined" ? document.getElementById(actionsTargetId) : null; + const accessTarget = accessTargetId && typeof document !== "undefined" + ? document.getElementById(accessTargetId) + : null; return (
@@ -938,10 +967,12 @@ export function PayrollAdminWorkspace({ <> {headerTarget ? createPortal(managementInfo, headerTarget) : null} {actionsTarget ? createPortal(managementActions, actionsTarget) : null} + {accessTarget ? createPortal(managementAccess, accessTarget) : null} ) : (
{managementInfo}{managementActions}
+
{managementAccess}
)} @@ -1148,6 +1179,24 @@ export function PayrollAdminWorkspace({ )} {periodModalOpen ? setPeriodModalOpen(false)} onSubmit={savePeriod} onSaved={async () => { setPeriodModalOpen(false); const createdPeriodId = createdPeriodIdRef.current; createdPeriodIdRef.current = null; if (createdPeriodId) { setNotice("Период создан и выбран."); await loadWorkspace(createdPeriodId, true); setTableVersion((value) => value + 1); } else { await refreshView("Параметры периода сохранены."); } }} onStale={handleStale} /> : null} + {workspace.access_settings.can_configure ? ( + setAccessModalOpen(false)} + onSaved={async (accessSettings) => { + setWorkspace((current) => current ? { + ...current, + access_settings: { + ...current.access_settings, + restricted: accessSettings.restricted, + management_user_count: accessSettings.management_user_count, + }, + } : current); + setNotice("Настройки доступа сохранены."); + await refreshUser(); + }} + /> + ) : null} {selectedPeriod && rateModalOpen ? setRateModalOpen(false)} onSubmit={saveRate} onSaved={async () => { setRateModalOpen(false); await refreshView("Черновик ставки сохранён."); }} onStale={handleStale} /> : null} {selectedPeriod && bulkPayRateModalOpen ? setBulkPayRateModalOpen(false)} onSubmit={(payload) => apiClient.bulkCreatePayrollAdminPayRates(selectedPeriod.id, payload)} onSaved={async (result: PayrollBulkPayRateResult) => { setBulkPayRateModalOpen(false); const changed = result.summary.created_drafts + result.summary.updated_drafts + result.summary.created_revisions; const details = [result.summary.unchanged ? `без изменений: ${result.summary.unchanged}` : "", result.summary.skipped ? `пропущено: ${result.summary.skipped}` : ""].filter(Boolean).join(", "); await refreshView(`Ставка сохранена для ${changed} сотрудников${details ? ` (${details})` : ""}.`); }} onStale={handleStale} /> : null} {selectedPeriod && bulkPointRateModalOpen ? setBulkPointRateModalOpen(false)} onSubmit={(payload) => apiClient.bulkSetPayrollAdminPointRate(selectedPeriod.id, payload)} onSaved={async (result: PayrollBulkPointRateResult) => { setBulkPointRateModalOpen(false); const changed = result.summary.updated_drafts + result.summary.created_revisions; const details = [result.summary.unchanged ? `без изменений: ${result.summary.unchanged}` : "", result.summary.skipped ? `пропущено: ${result.summary.skipped}` : ""].filter(Boolean).join(", "); await refreshView(`Цена балла сверх нормы сохранена для ${changed} ставок${details ? ` (${details})` : ""}.`); }} onStale={handleStale} /> : null} diff --git a/frontend/src/components/finance/admin/PayrollManagementAccessModal.tsx b/frontend/src/components/finance/admin/PayrollManagementAccessModal.tsx new file mode 100644 index 00000000..a7a18243 --- /dev/null +++ b/frontend/src/components/finance/admin/PayrollManagementAccessModal.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { Check, Loader2, Search, ShieldCheck, UsersRound } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { RequestAvatar } from "@/components/requests/RequestAvatar"; +import { Modal } from "@/components/ui"; +import { apiClient } from "@/lib/api"; +import type { + PayrollManagementAccessCandidate, + PayrollManagementAccessSettings, +} from "@/lib/api/finance"; + +type AccessMode = "all" | "selected"; + +type PayrollManagementAccessModalProps = { + isOpen: boolean; + onClose: () => void; + onSaved: (settings: PayrollManagementAccessSettings) => void | Promise; +}; + +function initials(candidate: PayrollManagementAccessCandidate): string { + const parts = [candidate.first_name, candidate.last_name].filter(Boolean); + if (parts.length) return parts.map((part) => part[0]).join("").slice(0, 2).toUpperCase(); + return candidate.email.slice(0, 2).toUpperCase() || "A"; +} + +export function PayrollManagementAccessModal({ + isOpen, + onClose, + onSaved, +}: PayrollManagementAccessModalProps) { + const [settings, setSettings] = useState(null); + const [mode, setMode] = useState("all"); + const [selectedIds, setSelectedIds] = useState([]); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!isOpen) return; + let cancelled = false; + setLoading(true); + setError(null); + setSearch(""); + void apiClient.getPayrollManagementAccessSettings() + .then((payload) => { + if (cancelled) return; + setSettings(payload); + setMode(payload.restricted ? "selected" : "all"); + setSelectedIds(payload.management_user_ids); + }) + .catch(() => { + if (!cancelled) setError("Не удалось загрузить настройки доступа."); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, [isOpen]); + + const filteredCandidates = useMemo(() => { + const needle = search.trim().toLocaleLowerCase("ru-RU"); + if (!needle) return settings?.candidates || []; + return (settings?.candidates || []).filter((candidate) => ( + `${candidate.display_name} ${candidate.email}`.toLocaleLowerCase("ru-RU").includes(needle) + )); + }, [search, settings?.candidates]); + + const toggleCandidate = (candidateId: number) => { + setSelectedIds((current) => ( + current.includes(candidateId) + ? current.filter((id) => id !== candidateId) + : [...current, candidateId] + )); + }; + + const save = async () => { + if (mode === "selected" && selectedIds.length === 0) return; + setSaving(true); + setError(null); + try { + const payload = await apiClient.updatePayrollManagementAccessSettings( + mode === "all" ? [] : selectedIds, + ); + await onSaved(payload); + onClose(); + } catch { + setError("Не удалось сохранить настройки доступа."); + } finally { + setSaving(false); + } + }; + + return ( + + + +
+ )} + > + {loading ? ( +
+ +
+ ) : error && !settings ? ( +
{error}
+ ) : settings ? ( +
+

+ Ограничение действует и для вкладки, и для прямых запросов к финансовому API. +

+ +
+ + + +
+ + {mode === "selected" ? ( +
+ +
+ {filteredCandidates.length ? filteredCandidates.map((candidate) => { + const selected = selectedIds.includes(candidate.id); + return ( + + ); + }) : ( +

Администраторы не найдены

+ )} +
+ {selectedIds.length === 0 ? ( +

Выберите хотя бы одного администратора.

+ ) : null} +
+ ) : null} + + {error ?
{error}
: null} +
+ ) : null} + + ); +} diff --git a/frontend/src/lib/api/finance.ts b/frontend/src/lib/api/finance.ts index c7039ee0..4a3d7177 100644 --- a/frontend/src/lib/api/finance.ts +++ b/frontend/src/lib/api/finance.ts @@ -140,6 +140,23 @@ export type PayrollAdminEmployee = PayrollAdminUserRef & { department?: string | null; }; +export type PayrollManagementAccessCandidate = { + id: number; + first_name: string; + last_name: string; + patronymic: string; + email: string; + display_name: string; + avatar: string | null; +}; + +export type PayrollManagementAccessSettings = { + restricted: boolean; + management_user_ids: number[]; + management_user_count: number; + candidates: PayrollManagementAccessCandidate[]; +}; + export type PayrollAdminPeriod = PayrollPeriodSummary & { id: number; currency: string; @@ -198,6 +215,11 @@ export type PayrollCalculationBlocker = { export type PayrollAdminWorkspace = { permissions: PayrollAdminPermissions; + access_settings: { + restricted: boolean; + management_user_count: number; + can_configure: boolean; + }; employees: PayrollAdminEmployee[]; rate_employees: PayrollAdminEmployee[]; components: PayrollComponent[]; @@ -844,6 +866,15 @@ export function createFinanceApi(request: RequestFn) { "/api/v1/finance/payroll/me/work-records/", jsonOptions("POST", payload), ), + getPayrollManagementAccessSettings: (): Promise => + request("/api/v1/finance/payroll/management-access/"), + updatePayrollManagementAccessSettings: ( + managementUserIds: number[], + ): Promise => + request( + "/api/v1/finance/payroll/management-access/", + jsonOptions("PATCH", { management_user_ids: managementUserIds }), + ), getPayrollAdminWorkspace: (periodId?: number): Promise => request(`${adminPrefix}/workspace/${buildQuery({ period_id: periodId })}`), getPayrollAdminPeriods: (): Promise> => diff --git a/frontend/src/lib/permissions-payroll.test.js b/frontend/src/lib/permissions-payroll.test.js index 390f4b33..e3bbd202 100644 --- a/frontend/src/lib/permissions-payroll.test.js +++ b/frontend/src/lib/permissions-payroll.test.js @@ -26,6 +26,17 @@ test("staff administrators can open payroll management without extra roles", () ); }); +test("server-side finance allow-list overrides the legacy staff shortcut", () => { + assert.equal( + canOpenPayrollAdmin({ auth: { is_staff: true, can_manage_payroll: false } }), + false, + ); + assert.equal( + canOpenPayrollAdmin({ auth: { is_staff: false, can_manage_payroll: true } }), + true, + ); +}); + test("custom payroll roles do not need Django staff access", () => { assert.equal( canOpenPayrollAdmin({ diff --git a/frontend/src/lib/permissions.ts b/frontend/src/lib/permissions.ts index 9f5207c3..336618aa 100644 --- a/frontend/src/lib/permissions.ts +++ b/frontend/src/lib/permissions.ts @@ -13,6 +13,9 @@ const PAYROLL_ADMIN_PERMISSIONS = [ export function canOpenPayrollAdmin(user?: User | null): boolean { const auth = user?.auth; if (!auth) return false; + if (typeof auth.can_manage_payroll === "boolean") { + return auth.can_manage_payroll; + } // TODO(payroll-access-hardening): when SIMPLE_ADMIN_ACCESS is disabled on // the backend, remove this staff shortcut in the same deployment and use // the granular Finance permissions below again. diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 79c4f842..f4e2c9be 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -101,6 +101,7 @@ export interface User { email?: string; is_staff?: boolean; is_superuser?: boolean; + can_manage_payroll?: boolean; groups?: string[]; permissions?: string[]; permissions_by_app?: Record;