Role management & employee search — frontend integration¶
This guide covers three related backend changes shipped together on v2:
- Superadmin role management —
POST/PATCH/DELETEon/v2/admin/roles, so superadmins can now manage a company's custom roles on its behalf. - System vs custom role semantics — why system roles remain immutable through the API, and how the UI should treat them.
- Free-text
searchon list endpoints — a newsearchquery parameter on three list APIs: the company-portal employee list, the company-portal invitation list, and the superadmin company invitation list.
These changes extend the surfaces documented in Staff, branch & role management. That document remains the source of truth for envelopes, pagination, sorting, and the invitation acceptance flow; this page covers only the deltas.
1. TL;DR¶
| Change | Endpoints | Portal | Action for the frontend |
|---|---|---|---|
| Create custom role as superadmin | POST /v2/admin/roles | Superadmin | New form + submit; requires a target companyId |
| Edit custom role as superadmin | PATCH /v2/admin/roles/:roleId | Superadmin | New edit form on company custom roles only |
| Delete custom role as superadmin | DELETE /v2/admin/roles/:roleId | Superadmin | New delete action with assignment guard (409) |
| List permission catalog as superadmin | GET /v2/admin/roles/permissions | Superadmin | New source for role create/edit permission pickers |
| System roles are immutable (unchanged behavior, now API-guaranteed) | any mutation on a system role → 404 | Both | Hide/disable edit & delete on isSystem: true rows |
| Employee search (company portal) | GET /v2/companies/employees?search= | Company | Add search box; debounce and send trimmed input |
| Invitation search (company portal) | GET /v2/companies/employees/invitations?search= | Company | Add search box over email/mobile |
| Invitation search (superadmin) | GET /v2/admin/companies/:companyId/invitations?search= | Superadmin | Add search box over email/mobile |
2. Superadmin role management¶
All three endpoints live under the existing superadmin guard (Authorization: Bearer <superadmin-token>, role SUPERADMIN). Envelope, pagination, and error conventions are the standard ones documented in §2 of the main guide.
2.1 Role object¶
Identical to the role shape returned by GET /v2/admin/roles (§4.4.1 of the main guide). Key fields for the UI:
| Field | Type | Notes |
|---|---|---|
id | string | Role ID used by PATCH/DELETE and assignment flows |
companyId | string | null | null for system roles; owning company for custom roles |
name | string | 1–100 chars |
description | string | null | max 255 chars |
scopeType | COMPANY | BRANCH | PROGRAM | |
systemRole | enum | null | Set only on system roles |
isSystem | boolean | true = immutable, hide mutations |
permissions | Permission[] | Current permission set |
assignmentCount | integer | Members currently holding the role; gates deletion |
2.2 Create a custom role¶
POST /v2/admin/roles
Roles are always company-scoped. There is no platform-level custom role: the superadmin creates a role on behalf of one company, and the role is immediately assignable to that company's members.
- Request body:
{
"companyId": "cuid_comp_123",
"name": "Branch Viewer",
"description": "Read-only access to a branch",
"scopeType": "BRANCH",
"permissionIds": ["perm_branch_read", "perm_auction_read"]
}
| Field | Required | Validation |
|---|---|---|
companyId | yes | Must be an existing company (else 404) |
name | yes | 1–100 chars, trimmed |
description | no | max 255 chars |
scopeType | yes | COMPANY | BRANCH | PROGRAM |
permissionIds | yes | min 1; see permission rules below |
- Success:
201with the created role object (same shape as §2.1). - Permission rules (enforced server-side; surface them in the picker):
- Every ID must exist in the permission catalog (
GET /v2/companies/permissions). ownershipOnlypermissions (e.g.ownership.transfer) are rejected.- Permissions must match the role scope: a
COMPANYrole may take any permission; aBRANCHrole onlyBRANCH/PROGRAMpermissions; aPROGRAMrole onlyPROGRAMpermissions. - Errors:
| Status | When | UI remediation |
|---|---|---|
400 | Bad shape, empty/duplicate permission IDs, scope mismatch | Inline field error |
404 | companyId does not exist | Refresh company selector |
409 | A role with this name + scope already exists in that company | Suggest a different name |
- Audit: recorded server-side as
role.created_by_admin— nothing to do client-side.
2.3 Update a custom role¶
PATCH /v2/admin/roles/:roleId
- Request body (at least one field required):
permissionIdsreplaces the full permission set — send the complete new list, not a delta.descriptionaccepts a string or explicitnullto clear it.-
The owning company is derived from the role server-side; never send
companyId. -
Success:
200with the updated role. - Errors:
400(validation, empty body),404(role missing or it is a system role — system roles are immutable),409(renamed into an existing name + scope in the same company). - Access propagation: when permissions change, the target company's authorization cache is invalidated server-side, so members gain/lose access immediately. A company-portal user does not need to re-login.
2.4 Delete a custom role¶
DELETE /v2/admin/roles/:roleId
- Success:
200with{ "roleId": "..." }. - Errors:
404— role missing or a system role.409— the role still has assignments. The message asks to remove the assignments first. Do not retry: disable Delete whenassignmentCount > 0and show the member count instead.- Audit:
role.deleted_by_adminwith the deleted role snapshot.
2.5 Suggested UI flow¶
- List roles via
GET /v2/admin/roles?companyId=<id>(see §4.4.1 of the main guide for all filters/sorting). - Render
isSystem: truerows read-only (badge "System", no edit/delete). - For custom rows, enable Edit and Delete; gate Delete on
assignmentCount === 0. - Create/Edit forms use the permission catalog scoped by
scopeType(fetch it once viaGET /v2/admin/roles/permissions, see §2.6); filter outownershipOnlypermissions client-side for early feedback, but rely on the server rules as the source of truth.
2.6 Permission catalog¶
GET /v2/admin/roles/permissions
Returns the full immutable permission catalog (no pagination) used to build the permissionIds payloads for create/edit. Ordered by scopeType then code. Same permission shape as the permissions array on role objects (§2.1). The company portal equivalent is GET /v2/companies/roles/permissions.
3. System roles vs custom roles¶
The backend guarantees the following contract. It was always true in practice; the new endpoints make it explicit, and the UI should rely on it.
- System roles (
isSystem: true,companyId: null): shipped with the platform (OWNER,COMPANY_ADMIN,BRANCH_MANAGER, …). They encode product decisions and are immutable through the API — create/update/delete attempts respond404. They includeownershipOnlypermissions and are special-cased by ownership-transfer and last-owner-protection logic. A new system role can only appear through a backend release (seed/migration), never through any API call. - Custom roles (
isSystem: false,companyIdset): created by companies (POST /v2/companies/roles, permissionrole.manage) or now by superadmins on their behalf. Fully editable and deletable (when unassigned), subject to the permission rules in §2.2. - Practical UI rules:
- Treat
isSystemas the single source of truth for mutability. Never rely onsystemRole === nullalone (it correlates, butisSystemis the flag). - Superadmin role management always operates on one company's custom roles; keep the company selector explicit in the superadmin UI.
- Deleting a role never cascades: the API refuses while
assignmentCount > 0.
4. Free-text search on list endpoints¶
A new optional search query parameter is available on three list endpoints. The semantics are identical everywhere:
- Case-insensitive partial match (SQL
contains, insensitive mode). - Trimmed server-side; an empty value is treated as "no filter" (so a cleared search box can send
search=or omit the param — both are fine). - Max 150 characters; longer values fail fast with
400 VALIDATION_ERROR. - Combinable with every other parameter of the endpoint:
status, role filters (roleId/roleIds/roleName/systemRole/scopeType/branchId), and sorting (sortBy/sortOrder) — filters are ANDed. Note:roleNameis an exact case-insensitive match (not substring);roleId+roleIdsare merged when both are sent;roleIdsaccepts comma-separated or repeated params.
| Endpoint | Searches over | Portal |
|---|---|---|
GET /v2/companies/employees | employee name, email, mobile number | Company |
GET /v2/companies/employees/invitations | invitee email, mobile number | Company |
GET /v2/admin/companies/:companyId/invitations | invitee email, mobile number | Superadmin |
4.1 Examples¶
GET /v2/companies/employees?pageNumber=1&pageSize=10&search=jane
GET /v2/companies/employees?roleId=role_001&search=jane&sortBy=name&sortOrder=asc
GET /v2/companies/employees/invitations?status=PENDING&search=9876
GET /v2/admin/companies/cuid_comp_123/invitations?search=vance@&sortBy=createdAt
4.2 Frontend implementation notes¶
- Debounce input (300–400 ms) and reset
pageNumberto 1 on every search change — search narrows the result set, and a stale page number can exceedtotalPages. - Trim client-side for request hygiene; the server trims anyway. Send the raw box contents; there is no need to pre-lowercase.
- Combine with the shared sort helper:
searchis a plain query param and works with thebuildListQuerymodule from §7.4 of the main guide — pass it throughfilters: { search: term }. - The invitations search covers email and mobile number only (invitation records carry no user profile). Do not render a "search by name" placeholder on invitation lists.
- The response shape is unchanged for all three endpoints — only the filtered rows and
countdiffer.
5. Copy-ready TypeScript¶
type SortOrder = 'asc' | 'desc';
type AccessScopeType = 'COMPANY' | 'BRANCH' | 'PROGRAM';
interface Permission {
readonly id: string;
readonly code: string;
readonly description: string | null;
readonly scopeType: AccessScopeType;
readonly ownershipOnly: boolean;
readonly createdAt: string;
}
interface AccessRole {
readonly id: string;
readonly companyId: string | null;
readonly name: string;
readonly description: string | null;
readonly scopeType: AccessScopeType;
readonly systemRole:
| 'OWNER'
| 'COMPANY_ADMIN'
| 'BRANCH_MANAGER'
| 'PROGRAM_MANAGER'
| 'AUCTION_OPERATOR'
| 'SUBSCRIPTION_MANAGER'
| 'VIEWER'
| null;
readonly isSystem: boolean;
readonly createdAt: string;
readonly updatedAt: string;
readonly permissions: readonly Permission[];
readonly assignmentCount: number;
}
interface CreateRolePayload {
readonly companyId: string;
readonly name: string;
readonly description?: string;
readonly scopeType: AccessScopeType;
readonly permissionIds: readonly string[];
}
interface UpdateRolePayload {
readonly name?: string;
readonly description?: string | null;
readonly permissionIds?: readonly string[];
}
interface RoleListQuery {
readonly pageNumber?: number;
readonly pageSize?: number;
readonly companyId?: string;
readonly scopeType?: AccessScopeType;
readonly isSystem?: boolean;
readonly search?: string;
readonly sortBy?: 'name' | 'createdAt' | 'scopeType' | 'isSystem';
readonly sortOrder?: SortOrder;
}
type ListSearchEndpoint =
| '/v2/companies/employees'
| '/v2/companies/employees/invitations'
| '/v2/admin/companies/:companyId/invitations';
function buildSearchQuery(
base: Record<string, string | number | undefined>,
search: string
): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(base)) {
if (value !== undefined && value !== '') params.set(key, String(value));
}
const term = search.trim();
if (term !== '') params.set('search', term);
return params.toString();
}
// Usage:
// const qs = buildSearchQuery({ pageNumber: 1, pageSize: 10, status: 'PENDING' }, ' robert ');
// // → "pageNumber=1&pageSize=10&status=PENDING&search=robert"
6. Error matrix (deltas)¶
| HTTP | Scenario | Frontend handling |
|---|---|---|
400 | Malformed role payload, invalid permission set/scope, search > 150 chars | Inline field error; do not retry unchanged |
404 | Role not found or system role targeted by PATCH/DELETE; unknown companyId | Refresh list; hide mutations on system roles |
409 | Duplicate role name + scope in the company; deleting an assigned role | Suggest new name / disable delete until unassigned |
7. Rollout checklist¶
- Superadmin: role create/edit/delete forms wired to §2, gated on
isSystem/assignmentCount. - Company portal: no change required to existing role screens (company-portal CRUD is unchanged).
- Search boxes added to the three list screens (§4) with debouncing and page reset.
- Placeholders on invitation search say email/mobile (not name).
- Error toasts mapped per §6;
409on role delete explains the assignments guard.