-
Notifications
You must be signed in to change notification settings - Fork 15
Self Service Portal
Folder: self-service/ Β· API: api/self-service/ (21 endpoints)
The portal your customers see. They raise tickets, follow and reply to them, download what you've sent, read your Knowledge base, and order from a request catalogue β without emailing anyone or phoning the desk.
The portal deliberately looks and behaves like the rest of FreeITSM: the same nav buttons (inbox.css primitives), the same theme tokens, the same toast. Someone moving between the portal and the analyst side should not feel they've changed product.
Four places to go β Dashboard, My Tickets, Knowledge, Help. Raising a ticket and requesting something are not in the nav: they're things you do, so they're buttons on the dashboard.
Requesters are a separate identity space from analysts: they live in users, sign in against $_SESSION['ss_user_id'], and the two sessions coexist in one browser without colliding.
The portal can be the front door. By default opening FreeITSM goes to the analyst login, but System β Branding β Landing page can point it here instead β with a per-analyst override so analysts do not pay an extra click. See Landing page.
| Page | What it covers |
|---|---|
| Portal Help Centre | Publishing knowledge articles to customers, the three visibility levels, ticket deflection, popular articles |
| Portal Request Catalogue | Offering forms as orderable requests, and what happens to a submission |
| Portal privacy | What customers see of their own ticket β forwards, CCs and third-party replies |
| Self-Service Developer Guide | π Every file, what it does, and the rules to follow when extending the portal |
Colour key: π₯οΈ page Β· π API Β· π§© shared chrome Β· βοΈ shared logic Β· π¨ assets Β· π i18n
| π¨ | File | Purpose |
|---|---|---|
| π₯οΈ | index.php |
Dashboard β action buttons, status cards, recent tickets, system status, popular articles |
| π₯οΈ | tickets.php |
My Tickets β the two-pane view: list left, conversation right, reply docked at the bottom |
| π₯οΈ | new-ticket.php |
The compose screen β full-height rich-text editor with a settings panel down the right |
| π₯οΈ | ticket.php |
tickets.php; kept because ?id= links are already out in the wild |
| π₯οΈ | catalogue.php |
Request Something β the request catalogue (reached from the dashboard, not the nav) |
| π₯οΈ | help-centre.php |
Knowledge β searchable articles; also the single article reader (?id=) |
| π₯οΈ | help.php |
Help β the guide to the portal itself (not the knowledge base) |
| π₯οΈ | login.php |
Sign in β email-first SSO router, provider buttons, local break-glass, MFA challenge |
| π₯οΈ | register.php |
Sign up β email-confirmed; can be switched off entirely |
| π₯οΈ | verify-email.php |
The confirmation-link landing page that actually sets the password |
| π₯οΈ | logout.php |
Ends the portal session (and the IdP session, for SSO) |
| π¨ | File | Purpose |
|---|---|---|
| π§© | header.php |
<head>, theme, brand bar and the nav β one array. Adding a page is one entry here |
| π§© | footer.php |
Closes the layout, ships i18n to JS, emits window.PAGE and the page's own script |
| π§© | auth.php |
The guard β every signed-in page includes it |
| π§© | user-menu.php |
Avatar, dropdown, My Account / MFA / password modals |
β οΈ $pageScriptsis a nowdoc. PHP tags inside it are not executed and reach the browser verbatim, killing the whole script block. Page-specific values go through$pageDataβwindow.PAGE. See the developer guide.
| Area | Files |
|---|---|
| Auth |
login.php, verify_login_otp.php, register.php
|
| Tickets |
create_ticket.php, get_tickets.php, get_ticket_detail.php, reply_ticket.php, get_attachment.php
|
| Dashboard |
get_dashboard.php, get_mailboxes.php
|
| Recordings |
upload_recording.php (pending upload), get_recording.php (range-aware streaming) |
| Knowledge |
get_knowledge_articles.php (list, search, sort=popular), get_knowledge_article.php (one article) |
| Catalogue |
get_catalogue.php, get_catalogue_form.php, submit_catalogue_form.php
|
| Account |
get_profile.php, update_profile.php, change_password.php, save_theme.php
|
| π¨ | File | Why the portal needs it |
|---|---|---|
| βοΈ | includes/portal_visibility.php |
Whether a message on the ticket was for the requester β see Portal privacy |
| βοΈ | includes/ticket_reply.php |
Reopening a finished ticket when the customer comes back (shared with email/WhatsApp/web chat) |
| βοΈ | includes/knowledge/portal_reader.php |
π The one definition of what a requester may read β portalKnowledgeScope(), portalUserTenantId(), portalArticlePreview(). Every portal surface that lists or opens an article goes through it |
| βοΈ | includes/knowledge/audience.php |
The internal / customer / public ladder that scope reads at |
| βοΈ | includes/tenancy.php |
resolveTenantForNewUser(), knowledgeTenantFilterForCompany()
|
| βοΈ | includes/html_sanitise.php |
Server-side allow-list for what a customer writes (the compose editor's HTML) |
| βοΈ | includes/ticket_recordings.php |
π claimPendingRecordings() β moving a pending screen recording onto a ticket, and the ownership guard. Shared by create + reply |
| π¨ | assets/js/screen-recorder.js |
The screen recorder itself, shared by the compose screen and the reply composer |
| π§© | self-service/includes/record-modal.php |
The recording dialog's markup (the script binds to its ids) |
| βοΈ | includes/theme.php |
Theme::active() β reads users.theme_preference for portal users |
| βοΈ | includes/services/forms.php |
FormsService::submitForm() β the catalogue submits through it |
| π¨ | assets/css/self-service.css |
All shared portal styling, fully theme-tokenised |
| π¨ | assets/js/safe-html.js |
Cleans untrusted HTML β email bodies and article bodies |
| π |
lang/en/self-service.php + lang/pt-BR/self-service.php
|
Every string. Both, in the same commit |
-
Registration is off by default and, when on, confirmed by email: submitting the form sets no password and signs nobody in β a one-time link is sent to the address, and the password is applied only when that link is opened. This closes an account-takeover hole: many
usersrows are auto-created without a password by inbound email, web chat or ticket creation, and the old "claim a passwordless account" flow let anyone who knew such an address take it over. The switch lives in System β Security (self_service_registration_enabled); while off, the register page redirects to login and the create an account link is hidden, so only accounts an admin creates can use the portal. Sending the confirmation email requires a configured mailbox β registration fails closed with a clear message if none is set β and a genuinely new account is likewise only created when the link is opened, never on form submit. -
Separate session key β
$_SESSION['ss_user_id'], so a requester and an analyst can be signed in at once. - MFA (TOTP) β same libraries as the analyst side, optional per user, secrets encrypted at rest.
- Single sign-on β the same identity providers as the analyst login, with an email-first router, provider buttons and a local break-glass. See Single Sign-On.
A provider can be owned by a client company, and on a multi-company install the login becomes email-first only (provider buttons up front would leak every client's IdP): email domain β company β that company's IdP(s). Invisible at N=1.
Requesters also belong to a company (users.tenant_id), which decides where their portal tickets are filed and which knowledge articles they see. Set it in Tickets β Users; it's pre-filled from the email domain where that's unambiguous, and freemail is never guessed. Without it a portal ticket goes to triage β exactly as an email from an unrecognised domain does.
New accounts are pre-filled by resolveTenantForNewUser() wherever a requester comes into being β registration, SSO JIT provisioning, admin create and REST POST /users. An SSO provider pinned to a company outranks the email domain, since the provider vouches for whoever it signs in. The company picker in Tickets β Users is hidden at N=1 and only offers companies the analyst can reach β and save_user.php re-checks server-side, so the dropdown is a convenience, not the guard. The company is deliberately not re-derived at ticket time, so editing a company's registered domains can never silently re-file people who already exist. The Database Verify run that first adds users.tenant_id also performs a one-time back-fill from each existing requester's email domain β only on that run, so a later deliberate choice is never overwritten.
Each requester picks Light or Dark from My Account β Appearance. It applies immediately and is stored on their own account (users.theme_preference) β analysts' preferences live in a table keyed by analyst id, which portal users don't have, so the portal needed its own.
The sign-in, register and confirmation pages are light only, deliberately: the palette lives on the user record, so before sign-in there is nothing to read it from.
Four bands, top to bottom:
- Action buttons β New ticket and Request. Moved out of the nav because they're actions, not destinations. The explanatory line is a tooltip, not body text: it was the only thing forcing these wider than a status card.
-
Status cards β one per active non-closed ticket status, plus a Total. Generated from your configured statuses, so the count varies per install; the grid is
auto-fillso however many you have, they fit on one row at a readable size. - Recent tickets and live system status, side by side. Status comes from Service Status, so someone can see their issue is a known outage before raising anything.
-
Popular articles β the six most-read articles published to customers, ordered by
view_count. Clicking one opens it inhelp-centre.php?id=; there is deliberately no second article viewer.
The article fetch is not awaited with the rest of the dashboard. Knowledge is a nice extra β a slow knowledge base must not hold up the tickets the page exists for.
All three grids (.portal-actions, .summary-cards, .article-grid) use auto-fill tracks so they line up down the page rather than each choosing its own column width.
Laid out like writing an email, not like filling in a form:
- Subject across the top
- A full-height TinyMCE editor filling the page β bold, italics, lists and links. The toolbar is deliberately short: what people need to describe a problem, not fonts and colours
- A settings panel down the right for everything you merely set β mailbox, priority, attachments, screen recording
Full height is a flex chain, not a calc(100vh - β¦) magic number β that was the first attempt and it was wrong at every window size. Every ancestor in the chain needs min-height: 0, or a flex child refuses to shrink below its content.
β οΈ The layout rule isbody.portal-app .portal-layout, not bare.portal-layout. The bare selector loses on specificity to the base rule and the padding silently comes back.
Article deflection: as the subject is typed, matching articles appear beneath it β the answer arrives before the ticket does. It only appears on a real match, never blocks Submit, and records nothing about what was typed. See Portal Help Centre.
Editor HTML is passed through sanitiseUserHtml() (includes/html_sanitise.php) server-side. <img> is deliberately not on the allow-list: a remote image in a customer-authored body would beacon the reading analyst's IP address and read time.
The ticket is filed against the requester's company; if they have none it goes to triage.
Two panes, close to an email client: the list stays on screen on the left, the conversation loads on the right, and the reply box is docked at the bottom of the conversation.
- A status filter dropdown built from the statuses actually present in that person's tickets β not a fixed Open/Closed pair
- The thread shows who said what, with the requester's own messages picked out. Internal notes are always hidden
-
Replying β with attachments and screen recordings (the same recorder the compose screen uses; see Screen Recordings). A recording is enough on its own: "here, look at what happens" is a complete answer, so no covering sentence is required. The reply is filed on the ticket and appears in the analyst's inbox. No email goes back to the requester for their own words, so this works with no mailbox configured at all. A portal reply is stored with
is_initial = 0and inherits the thread'smailbox_id, so the analyst's next outbound reply still goes out from the address the requester has been talking to β and because outbound replies quote the whole thread, a portal reply is carried into the analyst's next email automatically. A reply to a ticket the requester doesn't own β or a deleted one β gets the same flat not found as any other portal endpoint - Attach's explanatory line is the button's tooltip, not standing text β same treatment as the dashboard action buttons
-
Attachments both ways β files an analyst sent are downloadable; images and PDFs preview inline. Reply uploads use the same base64 payload shape and
tickets/attachments/{floor(id/1000)}/{email_id}/convention as inbound mail, so the analyst's existingget_attachment.phpserves them unchanged. Downloads come from the portal's ownget_attachment.phpβ the analyst endpoint's twin, differing only in the authorisation question (did this requester raise this ticket, resolved through the attachment's email rather than trusting the URL). It servesnosniffβ images, audio, video and PDFs inline, everything else as a download β with arealpath()containment check before reading, and inline attachments (embedded signature logos, pasted screenshots) are left out of the list so a spacer.gif doesn't appear under every message - Replying can reopen a finished ticket (Tickets β Settings β General)
- Confirmations use the app-wide toast (
assets/js/toast.js), the same one the analyst side uses - π A ticket can also hold correspondence that isn't the requester's β forwards to suppliers, copied-in colleagues, their replies. Portal privacy decides how much of that they see, and defaults to hiding it
πͺ€ Two traps live in this file. Selection precedence.
loadTickets()used to auto-select the first ticket un-awaited, racing the select thatsendReply()does β so sending a reply threw you back to your first ticket. Order is nowssSelected || window.PAGE.ticketId || first, and it is awaited. Class prefix. These classes aretk-, notss-.user-menu.phpemits its<style>block after the page's and owns thess-prefix β an.ss-msgthere won the cascade and rendered the whole thread blank.
End users record their screen with no plugin, using the browser's native getDisplayMedia() + MediaRecorder. Offered in two places:
-
Raising a ticket (
new-ticket.php) β in the settings panel -
Replying to one (
tickets.php) β beside Send and Attach
iOS Safari has no getDisplayMedia at all, so the button is hidden there rather than failing confusingly (ScreenRecorder.isSupported()).
Recording on reply needed the same ~250 lines as recording on create. Rather than copy them:
| π¨ | File | Role |
|---|---|---|
| π¨ | assets/js/screen-recorder.js |
The behaviour β capture, MIME negotiation, countdown, preview, upload. Exposes ScreenRecorder.{init,open,close,isSupported,hasUnclaimed,formatDuration}
|
| π§© | self-service/includes/record-modal.php |
The markup. The script binds to its element ids, so include it once per page |
| π¨ | assets/css/self-service.css |
.rec-modal and friends, plus .tk-recording for playback in a thread |
| βοΈ | includes/ticket_recordings.php |
Claiming a pending upload β see below |
| π§© | self-service/includes/footer.php |
Loads the script when the page sets $needsRecorder = true
|
The seam between module and page is a single
onClaimedcallback: the module owns the modal and the capture, the page owns what a claimed recording attaches to (a new ticket, or a reply). Everything else is identical, so it lives in one place.
ScreenRecorder.init() is called at parse time by the page block, so the script must load before it β which is why footer.php emits it above $pageScripts rather than the page loading it itself.
-
Record screen β a modal opens (
.rec-modal). It was an inline panel; expanding it shoved the rest of the form around and left the video preview no room. The modal has its own Cancel - Tick Include microphone audio to narrate (off by default for privacy). The mic is a separate browser permission β denying it falls back to no-mic rather than aborting a recording they already picked a screen for
- Start β pick a tab / window / screen
- Demonstrate the issue (max 5 minutes, live countdown)
- Stop (or the browser's own Stop sharing bar)
- Preview inline, re-record if needed
- Use this to upload β it appears as a π₯ chip alongside any attached files
- Send
| Action | What happens to an unclaimed recording | Why |
|---|---|---|
| Cancel / close the modal | Discarded | They explicitly said no; leaving something attached would be a surprise |
| Send / Submit | Blocked, modal reopens | They were heading onward and would have lost it silently |
Forgetting Use this used to drop the recording without a word (it lived in JS memory and never reached the server). Both screens now share the guard via ScreenRecorder.hasUnclaimed().
MediaRecorder.isTypeSupported() is probed in order: MP4/H.264 variants first, then WebM/VP9, VP8, plain WebM. Chrome/Edge 134+ produce real MP4 in-browser; Firefox and older Chrome fall back to WebM. The analyst's <video> plays either, so no server-side transcode is needed.
Files live on disk under recordings/{ticket_id}/ β never as DB blobs; ticket_recordings holds metadata only. 50MB cap, MIME whitelist with a magic-byte sniff.
A recording must be uploaded before the thing it belongs to exists β people record, watch it back, and re-record until happy, and every attempt has to leave the browser. So uploads are pending β claimed:
-
Upload β
recordings/pending/{uuid}.{ext}, row withticket_id = NULLandrecorded_by_user_id - Held by the page as a list of ids
-
Claimed when the ticket or the reply is written β
claimPendingRecordings()inincludes/ticket_recordings.php, shared bycreate_ticket.phpandreply_ticket.php
π THE GUARD, and why it lives in exactly one function. A row is claimable only while
ticket_id IS NULLANDrecorded_by_user_idis the same user. The first half stops a recording being lifted off an existing ticket; the second stops one user claiming another's pending upload. Recording ids are sequential and trivially guessable, so this is the only thing standing between them β and it is applied in the query, so someone else's id simply selects nothing.
Claiming runs after the commit in both callers: file moves are not transactional, and a recording that fails to move must never cost someone the reply they just wrote. A move that fails still claims the row β a broken recording attached to its ticket is visible and recoverable, whereas one orphaned in pending/ is never found again.
Orphaned pending rows sit there if a form is abandoned; a garbage-collect cron is a follow-up.
ticket_recordings.email_id β the message it was recorded with. NULL means the ticket's opening message, which is how every recording behaved before replies could carry one, so existing rows are already correct and there is no backfill.
The portal buckets recordings by email_id and renders each against its own message, so a video attached to reply #7 appears there rather than floating at the top of the thread as though it arrived with the original report.
The FK is ON DELETE SET NULL, not CASCADE: if a message is ever removed the video should fall back to the ticket, not be destroyed with it.
β οΈ The analyst reading pane (api/tickets/get_email_detail.phpβbuildRecordingsStrip()ininbox.js) still returns every recording on the ticket for every message, ignoringemail_id. Nothing is hidden from analysts β the safe direction β but a reply's video shows on the original message too. Making it precise is a small follow-up.
get_recording.php does dual auth (an analyst, or the ticket's owner) and supports HTTP Range, so <video> can seek without downloading the whole file. Direct access to recordings/ is blocked by .htaccess; everything goes through the PHP endpoint.
Each has its own page β see the table at the top.
The portal's own end-user guide β deliberately separate from the analyst-facing Tickets help page, and separate from Knowledge (which is your knowledge base, not instructions for the portal).
Laid out like every other help page in FreeITSM: full width, an On this page contents list down the side, and the main panel using the full width of its column. Nine sections:
- Welcome β what the portal is for
- Signing in β the three ways an account comes to exist (self-register, claiming an account created by an inbound email, IT pre-creating one), all confirmed by email, plus the forgotten-password flow
- Finding an answer yourself β searching Knowledge, and the articles offered while you type a subject
- Requesting something β the request catalogue
- Raising a ticket β mailbox, subject, priority, description, formatting, attachments
- Recording your screen β the walkthrough including the Use this / Discard guard and the iOS Safari gap
- Viewing your tickets β the two-pane view, the status filter, replying
- Account & security β preferred name, password, MFA, and a heads-up that a CSAT survey may arrive on close
- Tips for a fast resolution β one issue per ticket, reproduction steps, what you've already tried
End-user tone throughout, no analyst concepts.
β οΈ help.phpbuilds its section list beforeheader.phpboots i18n. Store translation keys in that array and resolve them witht()inside the render loop β callingt()while building the array is a fatal.
An initials circle in the header opens a dropdown: My Account, MFA setup, password change, sign out. My Account holds:
- Preferred name β "Ed" rather than "Ed Mozley", used when the system greets them
- Password change β validates the current one, minimum 8 characters
- MFA β enable/disable TOTP
- Appearance β Light or Dark (see above)
| Table | Purpose |
|---|---|
users |
Requesters β shared with Tickets. password_hash NULL = auto-created by inbound email and not yet claimed. Also carries tenant_id (their company) and theme_preference (their palette) |
user_verification_tokens |
Pending email-confirmed registrations: hashed token (SHA-256), hashed password, 24-hour single use |
user_sso_identities |
Requester β identity-provider links (the mirror of analyst_sso_identities) |
ticket_recordings |
Screen recording metadata; files on disk under recordings/{ticket_id}/. email_id says which message it came with (NULL = the opening one); ticket_id IS NULL means still pending |
tickets / emails
|
The portal writes tickets and messages here like any other channel β a portal reply is an emails row with direction = 'Portal'
|
form_submissions |
Catalogue submissions. submitted_by_user_id is the requester (separate from submitted_by, which is an analyst) |
knowledge_articles |
Knowledge reads these at the customer audience level; view_count orders the dashboard's popular list |
- Self-Service Developer Guide β the rules and traps when extending the portal
- Tickets Β· Knowledge Β· Forms Β· Single Sign-On Β· Service Status
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
- β³ π’ Ticket numbering
- β³ π 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)