Skip to content

Self Service Portal

Ed Mozley edited this page Jul 21, 2026 · 10 revisions

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.

Pages in this section

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

πŸ“ The files, at a glance

Colour key: πŸ–₯️ page Β· πŸ”Œ API Β· 🧩 shared chrome Β· βš™οΈ shared logic Β· 🎨 assets Β· 🌍 i18n

πŸ–₯️ Pages (self-service/)

🎨 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 ⚠️ Redirect only. Superseded by 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)

🧩 Shared chrome (self-service/includes/)

🎨 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

⚠️ $pageScripts is 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.

πŸ”Œ API (api/self-service/)

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

βš™οΈ Shared logic outside the portal folder

🎨 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

Authentication

  • 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 users rows 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.

Multi-company (MSP)

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.

Appearance

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.

Dashboard (index.php)

Four bands, top to bottom:

  1. 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.
  2. 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-fill so however many you have, they fit on one row at a readable size.
  3. 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.
  4. Popular articles β€” the six most-read articles published to customers, ordered by view_count. Clicking one opens it in help-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.

Compose (new-ticket.php)

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 is body.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.

My Tickets (tickets.php)

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 = 0 and inherits the thread's mailbox_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 existing get_attachment.php serves them unchanged. Downloads come from the portal's own get_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 serves nosniff β€” images, audio, video and PDFs inline, everything else as a download β€” with a realpath() 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 that sendReply() does β€” so sending a reply threw you back to your first ticket. Order is now ssSelected || window.PAGE.ticketId || first, and it is awaited. Class prefix. These classes are tk-, not ss-. user-menu.php emits its <style> block after the page's and owns the ss- prefix β€” an .ss-msg there won the cascade and rendered the whole thread blank.

Screen Recordings

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()).

πŸ“ One component, two screens

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 onClaimed callback: 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.

User flow

  1. 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
  2. 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
  3. Start β†’ pick a tab / window / screen
  4. Demonstrate the issue (max 5 minutes, live countdown)
  5. Stop (or the browser's own Stop sharing bar)
  6. Preview inline, re-record if needed
  7. Use this to upload β€” it appears as a πŸŽ₯ chip alongside any attached files
  8. Send

The two opposite behaviours, both deliberate

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().

Format negotiation

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.

Storage and lifecycle

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:

  1. Upload β†’ recordings/pending/{uuid}.{ext}, row with ticket_id = NULL and recorded_by_user_id
  2. Held by the page as a list of ids
  3. Claimed when the ticket or the reply is written β€” claimPendingRecordings() in includes/ticket_recordings.php, shared by create_ticket.php and reply_ticket.php

πŸ”‘ THE GUARD, and why it lives in exactly one function. A row is claimable only while ticket_id IS NULL AND recorded_by_user_id is 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.

Which message a recording belongs to

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() in inbox.js) still returns every recording on the ticket for every message, ignoring email_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.

Streaming + auth

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.

Knowledge, catalogue and privacy

Each has its own page β€” see the table at the top.

Help (help.php)

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:

  1. Welcome β€” what the portal is for
  2. 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
  3. Finding an answer yourself β€” searching Knowledge, and the articles offered while you type a subject
  4. Requesting something β€” the request catalogue
  5. Raising a ticket β€” mailbox, subject, priority, description, formatting, attachments
  6. Recording your screen β€” the walkthrough including the Use this / Discard guard and the iOS Safari gap
  7. Viewing your tickets β€” the two-pane view, the status filter, replying
  8. Account & security β€” preferred name, password, MFA, and a heads-up that a CSAT survey may arrive on close
  9. Tips for a fast resolution β€” one issue per ticket, reproduction steps, what you've already tried

End-user tone throughout, no analyst concepts.

⚠️ help.php builds its section list before header.php boots i18n. Store translation keys in that array and resolve them with t() inside the render loop β€” calling t() while building the array is a fatal.

User avatar menu and My Account

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)

Key tables

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

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally