Portal Managers Developer Guide - edmozley/freeitsm GitHub Wiki
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.
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.
| 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 |
$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.
- Nothing if managers are off, not ready, or the manager has left.
- Each grant adds its people, within
managerReachSql(). - The Manager field adds direct reports, or the whole chain, when
managers_directoryis on. The chain walk is bounded (25 levels) with a visited set, because the database can't express "no cycles". - Exclusions remove their people last, over everything - the Manager field included.
"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.
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).
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_seeusesmanagerTeamPreviewIds(), so it's right before the switch is on. - Warnings:
left, andempty_department- judged throughmanagerReachSql(), so a department with people only in another company still warns. ⚠️ linesis a reserved word in MySQL 8. The columns areline_count/exclusion_count.
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.phpandasset-management/manager-access.php- thin wrappers, each with its own module header.?user_id=N;&from=systemsends 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(withvia, 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; areportsline carriesviatoo). Worked out as if managers were on, inside the same reach.on_team: falsefor someone an exclusion removed. -
search-kind=user|group|department, paged 25, always insidemanagerReachSql(), 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 byLOWER(TRIM()), the way the engine matches. -
add/remove- needmanagerLinesEditable(). 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 onereportsline 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.
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_editfalse): no left panel, two panels numbered 1 and 2. ⚠️ display:grid/display:flexbeat the[hidden]attribute -.ma-grid[hidden],.ma-card[hidden]and friends are there on purpose.
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.
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.
-
self-service/includes/header.php: nav capis_managershows Team tickets only whenmanagerHasTeam(). -
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).
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_seewith managers off, the department warning with a person in the other company (still warns) and in the same company (clears),needs_verifywith 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
reportsline;edit_byread-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 localpassword_hashon 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.
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: rolemanager, 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 frompublicAbsoluteUrl()- the one link builder that is right from cron. English, like the portal's other system emails. -
bell:NotificationsService::notify()withportal_user_id- eventticket.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 andticket.createdworkflows have.
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 ofincludes/waffle-menu.phpunchanged apart from the URLs. -
assets/css/notification-bell.cssandincludes/notification_bell.php(notificationBellRender()) - the look and the markup. -
NotificationsServiceserves both: pass an analyst id as always, orNotificationsService::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 ofnotifications, keyed onusers.id. Not a column onnotifications: itsanalyst_idisNOT NULLwith a foreign key toanalysts, 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')), whatevermanagers_notifysays - 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.
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.