September 29, 2026
Published by Jenovic Lumu
Imagine a retail chain with 50 stores. The regional manager for the North region is responsible for 5 of them. In the old version of BrightSafe, there was no way to scope that manager's create, read, update, delete, print and export operations to just those 5 sites. The permission model only understood two options: one site, or all of them.
In practice, that meant the manager had full permissions over operations on accident reports, risk assessments, employee records, etc… for all 50 stores; far more than their role needed, just so they could manage the 5 within their remit. For our bigger customers — the ones running dozens or hundreds of sites, that gap between what people actually needed to manage and what the system would grant was turning into a real blocker.
This post is about the architecture we built to fix the above problem: a single, reusable "picker" system that scopes site and employee selection and the permissions tied to them — across every feature in BrightSafe.
BrightSafe's site permission model was binary by design: a user's create, read, update, and delete operations – CRUD, for short – were scoped to a single site, or to every site the company has. That's a reasonable starting point for a small business with one or two locations (we will use the terms site and location interchangeably in this post). It falls apart the moment a company has a management layer in between — regional managers, area supervisors, anyone whose remit is "some of our sites, not all of them."
Every feature that touched site data — accident reporting, near‑miss logging, risk assessments, employee records and more — inherited this limitation because there was no shared concept of “a set of sites” to build on. Any fix therefore had to be made once, in one place, and adopted everywhere rather than patched feature by feature.
Site permission was only one dimension of the problem:
Before writing any code, we established several non-negotiable requirements:
We landed on a layered design: each feature talks to a small wrapper component (SiteMultiTypeaheadPicker, EmployeeSingleTypeaheadPicker, and so on), which sits on top of two shared core components, which in turn pull their data through a common set of hooks and services.

The point of this layering is that a new feature doesn't need to reinvent site or employee selection. It imports the wrapper component it needs, tells it which feature and operation it's being used for, and inherits filtering, pagination, search, and permission-awareness for free.
It is a fairly standard layered (or N-tier) architecture applied to a component tree, where each layer only talks to the one directly beneath it. The wrapper-over-core relationship specifically follows the headless component pattern — the two core components own all the hard, domain-agnostic logic (search, pagination, keyboard handling, selection state, etc…) with zero knowledge of sites or employees, and thin wrapper components layer the domain meaning on top. It's the same idea behind libraries like Radix UI, Headless UI, and TanStack Table: build the hard part once, with no opinions, then wrap it per use case.
The hardest problem wasn't the UI — it was making a single, generic picker safe to use across features that don't all support the same operations, some of which carry real authority rather than just labels. Accidents needs a "first-aider," a "reviewer," and a "responsible person" — that last one isn't just a display label, it's the resource's manager, with the final say over whether an accident gets archived, deleted, or printed. Hazards has its own equivalent, a "hazard notifier." A generic system that just accepted any string for these would compile fine and then blow up at runtime the first time someone passed an operation a feature didn't actually support.
So instead of one loose { feature: string, operation: string } shape, every picker call is typed against a lookup table that maps each feature to its own valid set of operations. In practice, that means the type system itself enforces: if the feature is "accidents," the operation can only be one of the operations accidents actually defines. Pass an operation that belongs to a different feature, and it won't compile. This pattern is sometimes called a type-safe registry: index a big discriminated union by a string key so invalid combinations fail to compile instead of failing at runtime. It's the same mechanism behind Redux Toolkit's typed action creators or a tRPC router definition.
A simplified version of the idea:
Loading Code...
This lookup table isn't hand-maintained twice, either. It's generated from a schema the backend shares with the frontend, so the two can't quietly drift apart the way two independently maintained lists eventually would.
The type system guarantees the operation is valid for the feature — but it can't know, at compile time, whether this user's site assignment matches this accident's site. That check runs server-side: a responsible-person or hazard-notifier operation only grants CRUD rights — plus archiving and printing — on a resource when the requesting user is assigned to the same site as the person who originally reported it. Everyone else (admins or users) on that site can still see the record; they just can't touch it.
That's a form of attribute-based access control — more specifically, relationship-based access control (ReBAC), the same category of model behind systems like Google's Zanzibar, which underpins sharing permissions in Docs and Drive. Whether a user can act on a resource depends on the relationship between an attribute of the user (ie.their site assignment) and an attribute of the resource (ie. the site it belongs to), not just a static role. Since a single customer can have thousands of accidents and employees, that comparison runs as an indexed query filter at read time, rather than fetching every candidate resource and filtering it in application code afterwards.
Every picker consumer builds on BasePickerParameters at its core, so any picker request always carries a consistent shape for pagination, sorting, and search — and every response comes back as a predictable list of items, each with an id, a name, and a small set of properties (selected, disabled, status, and so on) that the UI uses to render state consistently, whether it's showing a disabled site or a pending employee.
That consistency is what let us build one picker component that renders correctly everywhere, instead of every feature owning its own bespoke rendering logic for "what does a disabled option look like."
Once sites became granular, employee selection had to follow suit. If you're filing an accident report for one specific site, the employee selection should only show those employees who actually work there — not the company's entire headcount.
We solved this by having the employee picker pass the selected site ID through as a query parameter, merged server-side into the same request that handles search and pagination:
const { allEmployees } = useGetPickerEmployees({
feature,
operation,
searchValue,
queryParameters: selectedSiteId ? { site: [selectedSiteId] } : undefined,
selectedEmployees,
});
The filtering, sorting, and pagination all run server-side, so the picker stays fast even as a company’s employee list grows. A manager who can manage records across several sites sees employees from all of them; when a resource management is scoped to one site, the list narrows automatically.
Typeahead search against a large dataset is an easy place to accidentally hammer an API. We debounce every search input so a request only fires once someone pauses typing (or clears the field), and pair that with a minimum character number requirement and useInfiniteQuery-backed pagination so results load in pages rather than all at once. React Query keys are scoped per feature and operation, so caching for the Accidents site picker never collides with caching for the Employees site picker, even though they're built from the same underlying components.
Designing the picker system turned out to be only half of this project’s challenges. The other part was everything downstream of it:
Comparing the four months immediately before the March 2026 launch to the months since, the effect on site filter usage is clear:
The busiest destinations for site filtering are Tasks ("Assigned to me" and "All Tasks") and Risk Assessments — which lines up with the interconnected-features point earlier in this post: those are exactly the areas that inherit site data from the likes of Accidents, Hazards, and the Responsibility area rather than owning it themselves.
For engineering, it means every new feature that needs site or employee awareness gets it almost for free, by wiring into the same wrapper components rather than building selection logic from scratch.
The multisite picker system is a small piece of UI changes on the surface — a dropdown with search and pagination — but the type-safe contract underneath it is what let us roll it out across the whole product with confidence. If you're building something similar, the lesson we would pass on is: invest in the shared type layer before the second and third features start depending on it, not after.
Registered Office: Bright HR Limited, The Peninsula, Victoria Place, Manchester, M4 4FB. Registered in England and Wales No: 9283467. Tel: 0844 892 3928. I Copyright © 2024 BrightHR
type PickerOperationsByFeature = {
accidents: 'reviewer' | 'first-aider' | 'responsible-person' | 'filter';
hazards: 'hazard-notifier' | 'filter';
employees: 'filter';
riskAssessments: 'filter';
};
type PickerParams<Feature extends keyof PickerOperationsByFeature> = {
feature: Feature;
operation: PickerOperationsByFeature[Feature];
data: BasePickerParameters;
};