Skip to content

Role management & employee search — frontend integration

This guide covers three related backend changes shipped together on v2:

  1. Superadmin role management — POST / PATCH / DELETE on /v2/admin/roles, so superadmins can now manage a company's custom roles on its behalf.
  2. System vs custom role semantics — why system roles remain immutable through the API, and how the UI should treat them.
  3. Free-text search on list endpoints — a new search query 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: 201 with 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).
  • ownershipOnly permissions (e.g. ownership.transfer) are rejected.
  • Permissions must match the role scope: a COMPANY role may take any permission; a BRANCH role only BRANCH/PROGRAM permissions; a PROGRAM role only PROGRAM permissions.
  • 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):
{
  "name": "Branch Reviewer",
  "description": null,
  "permissionIds": ["perm_branch_read"]
}
  • permissionIds replaces the full permission set — send the complete new list, not a delta.
  • description accepts a string or explicit null to clear it.
  • The owning company is derived from the role server-side; never send companyId.

  • Success: 200 with 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: 200 with { "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 when assignmentCount > 0 and show the member count instead.
  • Audit: role.deleted_by_admin with the deleted role snapshot.

2.5 Suggested UI flow

  1. List roles via GET /v2/admin/roles?companyId=<id> (see §4.4.1 of the main guide for all filters/sorting).
  2. Render isSystem: true rows read-only (badge "System", no edit/delete).
  3. For custom rows, enable Edit and Delete; gate Delete on assignmentCount === 0.
  4. Create/Edit forms use the permission catalog scoped by scopeType (fetch it once via GET /v2/admin/roles/permissions, see §2.6); filter out ownershipOnly permissions 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 respond 404. They include ownershipOnly permissions 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, companyId set): created by companies (POST /v2/companies/roles, permission role.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 isSystem as the single source of truth for mutability. Never rely on systemRole === null alone (it correlates, but isSystem is 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: roleName is an exact case-insensitive match (not substring); roleId + roleIds are merged when both are sent; roleIds accepts 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 pageNumber to 1 on every search change — search narrows the result set, and a stale page number can exceed totalPages.
  • 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: search is a plain query param and works with the buildListQuery module from §7.4 of the main guide — pass it through filters: { 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 count differ.

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; 409 on role delete explains the assignments guard.