-
-
Notifications
You must be signed in to change notification settings - Fork 29
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.
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.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96