Skip to content

Portal Managers Developer Guide

Ed Mozley edited this page Sep 27, 2026 · 5 revisions

Portal managers β€” developer guide

Discussion #62, step 3 of 3 Β· Ships in 2.8.0 Β· User page: Portal managers

Lets a self-service portal user read the tickets of the people they manage. Steps 1 and 2 (confidential tickets, ticket views) exist so this one could ship safely. This page covers the one rule, where it's asked, and the screens that set it up.


πŸ”΄ The one rule

Every portal endpoint that shows or changes a ticket asks portalTicketAccess() and nothing else.

$access = portalTicketAccess($conn, $userId, $ticketId);
if (!$access) { /* answer "not found" - never "forbidden" */ }
// $access['role']      'requester' | 'manager'
// $access['stub']      true = confidential, show that it exists and nothing more
// $access['can_reply'] / ['can_close']

An attachment, a document or a recording is a way into a ticket too. If one of them checked the requester on its own, a manager could read the ticket page but not its files, or - the dangerous way round - fetch the files of a confidential ticket they were only allowed a stub of. So they all ask the same function:

Endpoint Uses
api/self-service/get_ticket_detail.php role, stub; records a manager's view - except for a stub, since they saw nothing of it; seen_by only for the requester
api/self-service/get_attachment.php Β· api/self-service/get_document.php Β· api/self-service/get_recording.php refused for a stub. A document linked to several tickets is served if any of them is visible
api/self-service/reply_ticket.php can_reply (a stub is never repliable)
api/self-service/close_ticket.php can_close; audit says Closed by manager
api/self-service/get_team_tickets.php the Team tickets list - paged (50), ?person= and ?status= filters, and people / statuses with counts for the dropdowns. The list rules (company, confidential hidden) are ALSO in its SQL so a page is full and the counts are true; each row still goes through portalTicketAccess() - if the two disagreed, a page would be a row short, never a row too long
api/self-service/mark_confidential.php requester only, deliberately. A manager must not be able to hide a ticket from other managers - or not - on the requester's behalf

api/self-service/upload_recording.php stores a recording with no ticket; it's claimed by api/self-service/reply_ticket.php, which does the check.

Privacy is measured against the ticket's requester, not the viewer. A manager sees no more than the requester would: shared notes yes, internal notes no.


The engine: includes/managers.php

Function What it's for
managersSettings() the managers_* rows in system_settings, with defaults
managersActive() switched on and every table present. Fails closed before Database Verification
managerTeamUserIds($conn, $id) the people a manager can see. Memoised per request
managerGrantMembers() the people one line refers to - used for grants and exclusions
managerReachSql($conn, $m) πŸ”‘ the one definition of who a line can reach: same company, not the manager, leavers per the setting. Returns [$sql, $args] on users u
managerCompany() NULL company = the Default company
portalTicketAccess() the rule above
managerTeamPreviewIds() the team as if managers were on, for the admin screens. Puts the memo back afterwards
managerLinesEditable() admin, or edit_by = people_editors
managersResetMemo() clears the memo - tests and settings saves need it

⚠️ The memo lives in $GLOBALS['managers_memo'], not static. A test that changes a setting halfway through, or a screen that saves settings and reads the result back, has to be able to clear it.

How a team is worked out

  1. Nothing if managers are off, not ready, or the manager has left.
  2. Each grant adds its people, within managerReachSql().
  3. The Manager field adds direct reports, or the whole chain, when managers_directory is on. The chain walk is bounded (25 levels) with a visited set, because the database can't express "no cycles".
  4. Exclusions remove their people last, over everything - the Manager field included.

Companies

"Same company" means the manager's company, where NULL means Default, and for the Default company people with no company count as its own. That's the convention for every company-owned row (Multi-Tenancy Isolation), and it's what lets an install that never set up companies use managers with nothing extra to do (Ed: "if you don't have companies set up you should NOT have to do something extra to make managers work"). A manager in a real client company still never reaches unfiled people.

portalTicketAccess() checks the ticket's company as well, because a ticket can be moved.


Schema

manager_grants - one row per line or exclusion:

Column
manager_user_id the manager (users.id)
grant_type everyone Β· user Β· group Β· department Β· reports
target_id the user or group id; 0 otherwise
target_value the department name; '' or 'all' for reports
is_exclusion 1 = exclusion. Only user, group, department can be exclusions
created_by_analyst_id, created_datetime shown on the Manager access page. Written as UTC_TIMESTAMP()

Settings: managers_enabled, managers_directory, managers_directory_depth (direct/all), managers_leavers, managers_confidential (none/stub/all), managers_can_reply, managers_can_close, managers_edit_by (admins/people_editors).

Groups are knowledge_user_groups - the shared people groups - with the same membership rule Knowledge and Training use (group active, membership not expired).


The screens

System β†’ Managers

system/managers/index.php + api/system/managers.php (admins only).

  • ?action=settings, save (unknown values are refused, not coerced), overview.
  • The overview is paged (25) and searched on the server. "Every manager" on a large directory is every line manager, and each row's count is several queries.
  • can_see uses managerTeamPreviewIds(), so it's right before the switch is on.
  • Warnings: left, and empty_department - judged through managerReachSql(), so a department with people only in another company still warns.
  • ⚠️ lines is a reserved word in MySQL 8. The columns are line_count / exclusion_count.

Manager access (full screen)

Ed: "some people will work with large organisations … this should be full screen not in a modal."

  • includes/manager_access_page.php - one body, managerAccessRender($managerId, $backUrl).
  • tickets/manager-access.php and asset-management/manager-access.php - thin wrappers, each with its own module header. ?user_id=N; &from=system sends Back to System β†’ Managers.
  • api/tickets/manager_access.php:
    • get - lines with head counts, the Manager-field count, the team (first 200 names) and its total.
    • why&person=N - every reason this manager can see that person: directory (with via, the names between them on the reporting line, walked up from the person, bounded and loop-safe), and each grant that includes them (line_id, name; a reports line carries via too). Worked out as if managers were on, inside the same reach. on_team: false for someone an exclusion removed.
    • search - kind=user|group|department, paged 25, always inside managerReachSql(), so the picker can only offer somebody the line could reach, and a department's count is what the manager would really get. Departments are grouped by LOWER(TRIM()), the way the engine matches.
    • add / remove - need managerLinesEditable(). A picked person is re-checked against the same reach; a scoped list is not a check. Adding a line twice is a no-op. Only one reports line at a time.
  • Guard: Tickets or Assets, then analystCanAccessUser() on the manager. A refusal reads like a missing id.
  • Strings are resolved server-side into the page (tickets.manager_access.*), because Assets pages don't export the tickets namespace.

The page, piece by piece

Ed shaped this by using it, so the reasons are worth keeping:

  • Three full-height panels, left to right: find β†’ what gives access β†’ who they can see. Each scrolls on its own (.ma-fill); the page itself doesn't scroll on a desktop. Adding a line used to push everything below it down. Stacks into one column below 1100px.
  • After Add, Exclude or Remove the search results are re-marked in place, never fetched again (renderResults(lastSearch)). A refetch replaced the list under the pointer and lost its scroll position - the "jumping" Ed reported. A new search, page or tab does reset the scroll to the top.
  • The header carries two cards: what the Manager field alone gives (reports), and the total. The Manager field is not a line and can't be changed here, so it sits with the totals rather than in the list of lines.
  • The middle panel has tabs - People / Groups / Departments, and Everyone only when a line of that kind exists - each with its count, a search box, and its lines alphabetical, with that kind's exclusions beneath. Inside a tab the kind is already said, so a department shows as just its name. It follows the left panel's tab, and after an Add it shows the tab the new line landed in.
  • All three search boxes sit at the same height. The right panel has a single, inert "People (N)" tab to make that true; .ma-tab { line-height } is pinned because a <span> tab and a <button> tab otherwise differ by 2px.
  • Read-only (can_edit false): no left panel, two panels numbered 1 and 2.
  • ⚠️ display:grid / display:flex beat the [hidden] attribute - .ma-grid[hidden], .ma-card[hidden] and friends are there on purpose.

"Why can they see this person?"

Click anyone under Who they can see (or Enter on a focused row). Deliberately the same visual language as the Sign-in conflict explanation on System β†’ Analysts (GH #41), which Ed singled out: a head with a round icon, a picture, an info box, then numbered ways out.

  • The picture runs left to right - manager, the reasons stacked in the middle, the person - built as a grid (.mw-grid, one row per reason) so each connector cell sits on its card's row and the lines meet whatever the cards' heights. Laid out horizontally so the whole thing fits without scrolling (Ed); one column on a phone.
  • Each reason is a card with a coloured tag (Manager field, Department, Group, Named, Everyone, Reporting line) and, underneath, what it is: "Via Emma Staff", "Night shift", "Everyone in Default".
  • What the manager can see and do (reply, close, confidential) and, beside it, a warning when managers are switched off or the person has left.
  • One numbered way out per reason - Remove for a line, Open for the Manager field (it is changed on the person, so it links to users.php?user_id=N) - then Exclude, always last, because it beats them all. Remove and Exclude work in the modal and the explanation is fetched again straight after: one reason fewer, or "no longer on the team".
  • Every button in it (.mw-fix .btn, #mwClose) is one fixed size, and Close is inset 13px to line up with the step buttons above it.

The person editor's Manager field

includes/person_editor.php. It used to be a <select> filled from api/tickets/get_users.php with everybody - a full download on every open, and thousands of options on a large organisation. It's now a type-ahead on api/tickets/get_users.php?search=&limit=20 (already scoped to the analyst's companies).

πŸ”΄ A manager the analyst can't see is still kept. api/tickets/get_person.php returns manager_name only when analystCanAccessUser() allows it. No name means "somebody you can't see": the id is parked on dataset.unresolved, the box says so, and the key is left out of the save. The only way to change it is to choose somebody else. See Contact details β€” Developer Guide Β§4.

The portal

  • self-service/includes/header.php: nav cap is_manager shows Team tickets only when managerHasTeam().
  • self-service/tickets.php?view=team: the list, a banner on each team ticket saying whose it is and what the manager may do, and stub rendering.
  • self-service/help.php: a Team tickets section, drawn only for managers (same test as the tab).

How it was tested

On docker/proxy-test (throwaway data), through the real endpoints:

  • Engine: tests/managers-engine.php - 25 checks, all rolled back: grants, exclusions over the Manager field, chains, leavers both ways, the company rules (NULL = Default; a client-company manager never reaches unfiled people), fail-closed before Verification.
  • Portal: a manager opened a team ticket, attachments, documents and recordings; a confidential ticket appeared as a stub and its files were refused; reply and close followed the settings; somebody outside the team got not found on every endpoint.
  • System β†’ Managers: save/load, bad values refused, search, paging, can_see with managers off, the department warning with a person in the other company (still warns) and in the same company (clears), needs_verify with the table hidden, non-admin refused.
  • Manager access: search scoped (other company and the manager themselves never offered); adding a person from another company and the manager themselves refused; dedupe; one reports line; edit_by read-only vs editable for a non-admin; a company-restricted analyst refused a manager in another company. Remove and Add driven by clicking in headless Chrome.
  • Manager access layout: with 30 extra people so the results scroll: scrolled to 400px, clicked Add lower down - scroll still 400, the only request was the add itself, the row became Added, the page did not scroll. The three search boxes measured at the same height; all four modal buttons 96Γ—34 on one right edge.
  • Why: a two-step chain (Pre β†’ Emma β†’ Megan), three reasons at once (everyone, group, named), and Exclude from the modal (the modal said "no longer on the team", the list dropped to 34).
  • On Ed's own install, with demo data: a directory manager signed in to the portal and saw 15 team tickets, two as confidential stubs; the requester of one saw him under Who has seen this ticket. ⚠️ A directory account signs in with its DIRECTORY password - a local password_hash on an LDAP-linked user is never checked (api/self-service/login.php), and the portal also needs the provider's portal group (ldap_user_group).
  • Person editor: type-ahead pick and save from both screens. The unresolved-manager guard on a non-directory person (Emma): kept with the guard; wiped with the guard deliberately removed - the negative control. ⚠️ A directory-owned person proves nothing here: their Manager field is never sent at all.

Telling managers (managers_notify)

managersNotifyNewTicket() (includes/managers.php), called at the end of ticketDispatchCreated() (includes/ticket_events.php) in its own try, so a manager's email can never cost the workflow or the ticket.

  • πŸ”΄ Order matters: every caller of ticketDispatchCreated() applies the confidential defaults (mailbox, department) first, so an HR-mailbox ticket is confidential before anybody is told. A new creation path must keep that order.
  • Who: candidates worked out backwards from the requester - everyone above them on the reporting line, plus every owner of a management line - then portalTicketAccess() decides for each: role manager, not a stub. A too-generous candidate list costs a query, never a leak.
  • email: managersEmailNewTicket() sends through the portal's own sender (ssSendSystemEmail(), the ticket mailbox) with the link from publicAbsoluteUrl() - the one link builder that is right from cron. English, like the portal's other system emails.
  • bell: NotificationsService::notify() with portal_user_id - event ticket.created, so the bell's existing "Raised by {actor}" wording is reused.
  • Not announced: web chat, WhatsApp, the request catalogue, split and merge don't call ticketDispatchCreated() - the same gap the analyst bell and ticket.created workflows have.

The portal's bell - the SAME bell

Asked for: "can we avoid massive code duplication with the analyst bell?" (Ed). So there is one bell:

  • assets/js/notification-bell.js - NotificationBell.init({ list, markRead, clear, linkPrefix, seenKey }). Moved out of includes/waffle-menu.php unchanged apart from the URLs.
  • assets/css/notification-bell.css and includes/notification_bell.php (notificationBellRender()) - the look and the markup.
  • NotificationsService serves both: pass an analyst id as always, or NotificationsService::portalUser($id). who() is the one place the table is chosen. Coalescing, the unread-survives-Clear-all catch and the scoping are shared; only the per-analyst type preferences are analyst-only.
  • portal_notifications - a sibling of notifications, keyed on users.id. Not a column on notifications: its analyst_id is NOT NULL with a foreign key to analysts, and one table keyed two ways would let an analyst and a portal user with the same id read each other's bell.
  • api/self-service/notifications.php - ?action=list|mark_read|clear, the analyst endpoints' shapes exactly.
  • In the portal header for every manager ($portalNavCap('is_manager')), whatever managers_notify says - the bell belongs to being a manager and the settings decide what is sent to it, so later manager events (a P1, say) need no new switch to be seen (Ed). Anyone else only once they have a notification.

Tested: the analyst bell after the move (badge, panel, link unchanged); Emma raised a normal and a confidential ticket through the real portal endpoint - Megan's bell got the first and not the second; the email path run from the CLI (as the mailbox cron would) with the sender swapped for a recorder - one email to Megan, none for the confidential ticket, and with no public web address configured the link came out as a bare path, which is why the setting's text says so. Team tickets: 73 tickets paged 50 + 23, person and status filters and their counts, a person not on the team falls back to everyone, confidential = none removes them from list and counts, Load more keeps the scroll.

Ideas noted, not built

Now on their own page: Portal managers: ideas not yet built (Blue sky thinking). Keeping confidential tickets away from AI, chat posts and trackers was built - see Confidential tickets β€” Developer Guide.


See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally