-
Notifications
You must be signed in to change notification settings - Fork 15
Self Service Developer Guide
How to extend the portal without breaking it. Every trap below is one that actually bit β several of them shipped.
Adding a page to the portal touches the same handful of places every time.
Colour key: π₯οΈ page Β· π§© chrome Β· π API Β· βοΈ shared logic Β· ποΈ schema Β· π i18n Β· π docs
| π¨ | File | What you do there | Skippable? |
|---|---|---|---|
| π₯οΈ | self-service/<page>.php |
the page: $pageTitleKey, $activeNav, $pageData, $pageStyles, $pageScripts, then include header + footer |
Never |
| π§© | self-service/includes/header.php |
one entry in the nav array | Never β it's the only place nav exists |
| π | api/self-service/<verb>_<thing>.php |
the endpoint(s), gated on $_SESSION['ss_user_id']
|
Never |
| βοΈ | includes/<shared>.php |
any rule shared with the analyst side β put it here, not in both | Only if the rule is genuinely portal-only |
| βοΈ | includes/knowledge/portal_reader.php |
use it if your page touches knowledge articles β never re-derive the scope | Never, if you list or open articles |
| βοΈ | includes/ticket_recordings.php |
use it to attach a pending screen recording β never re-implement the ownership guard | Never, if you claim recordings |
| π¨ |
assets/js/screen-recorder.js + self-service/includes/record-modal.php
|
offer screen recording: set $needsRecorder = true, include the partial, call ScreenRecorder.init({onClaimed})
|
Only if the page has nothing to record for |
| ποΈ |
database/freeitsm.sql + includes/db_verify_schema.php
|
new columns, in both | Never β a drift guard fails loudly if they disagree |
| ποΈ | includes/db_verify_indexes.php |
regenerate with scripts/gen_db_verify_indexes.php β don't hand-edit |
Only if you added an index |
| π |
lang/en/self-service.php + lang/pt-BR/self-service.php
|
every string, in the same commit | Never β EN-only keys drift silently |
| π¨ | assets/css/self-service.css |
anything shared between pages | Only if it's genuinely page-specific |
| π |
CHANGELOG.local.md, README.md, this wiki |
log it | Never |
The nav. It's one array in header.php. If you find yourself editing four pages to add a link, stop β that's the pre-#885 layout, and the whole point of the shared chrome was to end it.
They are different id spaces. users.id and analysts.id overlap numerically and mean entirely different people.
| Analyst | Requester | |
|---|---|---|
| Table | analysts |
users |
| Session | $_SESSION['analyst_id'] |
$_SESSION['ss_user_id'] |
| Context | ActorContext::fromSession() |
not usable β it reads the analyst session |
π΄ The one that nearly shipped.
form_submissions.submitted_byhas no foreign key and every readerLEFT JOINs it toanalysts. Writing a requester's id there would have silently attributed a customer's request to whichever analyst shared that number. The fix was a separate column (submitted_by_user_id), not a shared one.Before you store "who did this", ask which id space the column means. If a reader joins it to
analysts, a requester's id does not belong in it.
Two things decide what a requester may see, and neither is a parameter:
| Scope | Where it comes from |
|---|---|
| Audience (knowledge) | hard-coded to Audience::CUSTOMER inside portalKnowledgeScope()
|
| Company | looked up from users.tenant_id (the session holds only ss_user_id) |
Whoever is asking does not get to declare how trusted they are. The web chat reader (includes/webchat/ai.php) set this rule; the portal follows it.
Reading an article in the portal means satisfying three conditions at the same time:
published AND not archived β the article is live
AND audience >= customer β it is meant for customers
AND (their company OR shared) β it belongs to them
That was copy-pasted across two endpoints. Adding a third copy for the dashboard's popular list is what forced it out into includes/knowledge/portal_reader.php:
require_once '../../includes/knowledge/portal_reader.php';
$userTenantId = portalUserTenantId($conn, $userId);
[$where, $params] = portalKnowledgeScope($conn, $userTenantId, 'a');
// then append your own conditions, in the same param orderπ Duplicated scope is the dangerous kind of duplication. Nothing breaks when the copies drift β one of them just quietly starts showing more than it should. There is no failing test for that; there's a support ticket six months later.
portalKnowledgeScope() exists to make the next two traps unreachable rather than merely documented.
| Module |
tenant_id IS NULL means |
|---|---|
| tickets, assets, changes | unassigned β treat as the Default company's |
| knowledge_articles | shared with EVERY company |
// β
correct for knowledge
[$sql, $params] = knowledgeTenantFilterForCompany($conn, $userTenantId, 'a');
// β makes every shared article vanish for a non-Default company
[$sql, $params] = activeTenantFilter(...);Archiving an article does not unpublish it. Check both:
is_published = 1 AND (is_archived = 0 OR is_archived IS NULL)Omitting the archive half is exactly how deleted articles once reached anonymous web chat visitors.
Audience::CUSTOMER is hard-coded inside portalKnowledgeScope(), not passed in. Whoever is asking does not get to declare how trusted they are β the same rule the web chat reader (includes/webchat/ai.php) follows.
Which means anything user-supplied that reaches it is injection. Sort options are a fixed allow-list, matched to a literal:
$order = ($sort === 'popular') ? 'a.view_count DESC, a.title ASC' : 'a.title ASC';Not a whitelist of characters, not an escape β a switch over strings you wrote.
2c is about visibility. The identical argument holds for any rule where a drifted copy fails silently in the permissive direction β and the second one in the portal is claiming a screen recording:
// includes/ticket_recordings.php β the ONLY place this is written
WHERE id IN (β¦) AND ticket_id IS NULL AND recorded_by_user_id = ?Both halves matter: ticket_id IS NULL stops a recording being lifted off an existing ticket, recorded_by_user_id stops one user claiming another's pending upload. Recording ids are sequential and guessable, so that clause is the access control.
A second copy of it in the reply endpoint would not have failed a test when it drifted. It would just have started letting a little more through.
Ask of any new shared rule: if the two copies disagree, does anything break? If the answer is "no, one of them just permits more", it must be written once.
Apply the scope in the query, so an item the requester may not see is indistinguishable from one that doesn't exist. Every portal endpoint returns the same flat not found β no existence oracle.
If a thing has a URL, the URL must enforce the rule β not just the listing that links to it.
Attachment ids are sequential and trivially guessable. get_attachment.php therefore applies the privacy policy independently of get_ticket_detail.php. Omitting a link while the URL still serves the file is decoration.
These pull in opposite directions and both are deliberate:
-
Identity / permission β unknown means no. A new form isn't in the catalogue; a new article is
internal. - Message visibility β genuinely undecidable means show it. A message with no recipients recorded stays visible, because hiding an analyst's real reply leaves the customer believing nobody answered, which is worse than showing them a forward.
The strict direction is something an admin chooses, never something the code guesses.
$pageScripts is built with a nowdoc (<<<'JS') so JS template literals (${...}) survive PHP. The cost: PHP tags inside it are not executed and reach the browser verbatim.
$pageScripts = <<<'JS'
const TICKET_ID = <?php echo $ticketId; ?>; // β emitted literally
JS;That stray < is a syntax error at the top of the block, so the entire page's JavaScript fails to parse β no functions defined, nothing runs. It shipped like that for two releases on ticket.php, showing only "Loading ticketβ¦" and a lone SyntaxError.
$pageData = ['ticketId' => $ticketId]; // β
PHP side
// β¦then in the nowdoc:
const TICKET_ID = window.PAGE.ticketId;footer.php now emits a console.error if a raw PHP tag ever appears in $pageScripts again.
Rendered markup is not proof the JavaScript ran. Grepping the HTML for your new function names finds them happily inside a script block the browser never parsed.
Parse-check what the browser actually receives:
-
curlthe rendered page with a portal session - Extract the real inline
<script>(don't retype it β extract it) - Wrap it in a harness with stubs (
window.PAGE,window.t,API_BASE) andwindow.onerror - Run headless Chrome
--dump-dom, assert no error and that each function istypeof === 'function' - Add a negative control β re-break it and confirm the harness fails. A test never proven to fail proves nothing
self-service.css being tokenised does not mean the portal is themed. Each page carries its own $pageStyles, and those are separate β which is how the portal shipped with a dark background and white cards sitting on it.
Use the tokens (--surface, --surface-hover, --text, --text-muted, --border, --ss-accent, --danger-*, --success-*) and check every var(--x) exists in theme.css. A phantom token fails silently to its fallback and looks like it worked.
Pre-auth pages (login, register, verify-email) are light-only by necessity β the palette lives on the user record.
Someone else's stylesheet may own your prefix. user-menu.php emits its <style> block after the page's, and it owns ss-. A page that reused that prefix got its rules overridden β an .ss-msg { display: none } from the menu rendered a whole ticket thread blank. tickets.php uses tk-.
Found by measuring
getComputedStyle()in the browser, not by reading CSS. Reading tells you what a rule says; only the browser tells you which rule won.
Full height is a flex chain, not arithmetic. calc(100vh - 330px) is a guess that's wrong at every window size but one. Use display: flex end to end and put min-height: 0 on every ancestor β without it a flex child will not shrink below its content and the chain silently breaks at that link.
Specificity beats intent. The base rule is body.portal-app .portal-layout. A page overriding with bare .portal-layout loses, and the symptom is subtle β padding quietly returning β rather than obvious.
Anything a customer can see that someone else wrote goes through safeHtmlFragment() (assets/js/safe-html.js) β email bodies and knowledge articles alike.
Stripping
<script>is not enough. Scripts genuinely don't execute viainnerHTML, but<img src=x onerror=β¦>fires the moment it's inserted, and Shadow DOM isolates CSS, not script execution.
Never write a second copy. One cleaner serves the inbox and the portal, because a security control kept in two places drifts β and it already had, leaving the inbox exposed after the portal was fixed.
How it cleans: the HTML is parsed in an inert DOMParser document (nothing executes during parsing), then dangerous tags, every inline event handler and javascript: / data:text/html URLs are stripped before the result is inserted. Callers fail closed to inert escaped text if the cleaner isn't loaded.
The other direction β what a requester writes β is handled on the way in: the compose editor's HTML goes through the server-side allow-list (sanitiseUserHtml()), while a portal reply is stored escaped (nl2br(htmlspecialchars()) in reply_ticket.php), because it is echoed into the analyst's reading pane and quoted into outbound email.
Chat channels store the sender's message verbatim as 'text'. Escaping it beats cleaning it: there's no cleaner to outsmart when nothing is parsed as markup. Any new endpoint returning a message body must select body_type, or the protection is inert.
Twice now, something was built correctly and was unusable:
-
Dark mode read a preferences table keyed by
analyst_id. Portal users have none, so they could only ever get the default β a dark mode nobody could turn on. -
Portal knowledge launched against an audience rung where every article defaults to
internal, so it was an empty page until a bulk publish tool existed.
Before calling it done: can a real user actually get to this, with the data they actually have?
The customer audience rung was documented β in audience.php and in the editor UI β as "nothing reads this yet". Both became false the moment the portal started reading it.
If you write "nothing does X yet", you've made a promise to update it when something does.
# stray PHP tags in a nowdoc (must be 0)
curl -s -b "PHPSESSID=$SID" "http://localhost/freeitsm-app/self-service/<page>.php" | grep -c '<?php'
# every var(--x) used in the portal exists in theme.css
grep -ohE 'var\(\s*--[a-z0-9-]+' self-service/*.php assets/css/self-service.css | sort -u
# i18n parity
php -r '$en=require "lang/en/self-service.php"; $pt=require "lang/pt-BR/self-service.php";
var_dump(array_diff_key($en, $pt));'And always pair a "cannot do X" assertion with a positive control β proving the guard refuses is worthless if the same call would have failed anyway.
- Self-Service Portal Β· Portal Help Centre Β· Portal Request Catalogue Β· Portal privacy
- Multi-Tenancy Developer Guide β the company-scoping recipe
- Database Verification Developer Guide β schema changes
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
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
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- 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: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- 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
- β³ 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)