-
-
Notifications
You must be signed in to change notification settings - Fork 27
Contact Details Developer Guide
The seven person fields on users, the three lists that govern them, and every surface that reads or writes one. User-facing page: Contact details.
Read Directory sync β Developer Guide alongside this: the columns are its, and the policy layer that protects them is its too. This page is about who else may touch them.
Colour key: ποΈ schema Β· βοΈ engine Β· π API Β· π₯οΈ UI Β· π i18n
| π¨ | File | What it does |
|---|---|---|
| βοΈ | includes/users.php |
the three field lists, userPersonFieldValue(), userManagerIsSafe(); the portal settings (portalProfileEditableFields(), portalProfileAccess()); the Source helpers (userSourceFilter(), userSourcesInScope()); userSignsInElsewhere()
|
| ποΈ | database/freeitsm.sql |
the columns, under "the person, as opposed to the login" |
| ποΈ | includes/db_verify_schema.php |
the same columns for the upgrade path |
| π | api/tickets/save_user.php |
the only analyst writer. Both people screens post here |
| π | api/tickets/get_users.php |
reader for Tickets β Users |
| π | api/assets/get_people.php |
reader for Assets β Users |
| π | api/self-service/get_profile.php |
reader for the portal |
| π | api/self-service/update_profile.php |
the portal writer β reached by a customer |
| π | api/system/portal_profile.php |
System β Portal profile: which fields the portal offers, and whether address-book contacts may change theirs |
| π₯οΈ | system/portal-profile/index.php |
that screen |
| π | api/tickets/address_book_add.php |
Add to address book - see CardDAV write-back internals Β§17 |
| βοΈ | includes/directory_sync.php |
the sync writer; owns the values when is_managed = 1
|
| π₯οΈ | includes/person_editor.php |
the one analyst editor - personEditorRender() / PersonEditor.open(id, {onSaved}), opened from both people screens (#62) |
| π | api/tickets/get_person.php |
the editor's reader: one person, complete, with managed_fields and manager_name
|
| π₯οΈ | tickets/users.php |
list + detail pane; opens the shared editor; ?user_id=N deep link |
| π₯οΈ | asset-management/users.php |
list + what they hold; opens the shared editor; Mark as left / Reactivate stay here |
| π₯οΈ | includes/manager_access_page.php |
Manager access, the full-screen page for a person's portal manager lines, opened from both screens |
| π₯οΈ | self-service/includes/user-menu.php |
the portal My Account modal |
| π |
lang/en/tickets.php Β· lang/en/self-service.php
|
labels, hints, the managed notes |
All three live in includes/users.php, in one file, because several writers must agree and a list duplicated across them "is a list that will disagree with itself within a month".
USER_PERSON_FIELDS job_title department office phone mobile employee_id manager_id
USER_DIRECTORY_OWNED job_title department office phone mobile employee_id manager_id
USER_SELF_EDITABLE_FIELDS job_title office phone mobile
-
USER_PERSON_FIELDSβ everything a human may edit through any UI or API. Deliberately excludesis_managed,directory_username,last_seen_in_sourceandauth_provider_id: a new endpoint that loops this constant cannot accidentally expose the sync's own bookkeeping. -
USER_DIRECTORY_OWNEDβ of those, the ones a directory is the source of truth for: all seven for LDAP. On anis_managedrecord these are refused, not saved.β οΈ Which ones is decided per record byuserDirectoryOwnedFields($protocol, $writeBack): a CardDAV address book owns five (USER_CARDDAV_OWNED), and one with write-back on owns none. Readers send the answer asmanaged_fields; never test the constant directly. -
USER_SELF_EDITABLE_FIELDSβ of those, what a person may change about themselves in the portal. The narrowest list, and the three absences are the interesting part; the constant's own doc block explains each.
π The right mental model is a funnel, not three flags. A portal write must pass all three: in USER_SELF_EDITABLE_FIELDS, and not blocked by USER_DIRECTORY_OWNED on this record.
USER_DIRECTORY_OWNED must stay in step with what the sync actually maps. A field that syncs but is not listed becomes an edit that vanishes overnight with no explanation.
Both analyst screens post here. Three rules:
-
Absent means don't touch.
array_key_existsper field, never?? ''. This was learned the hard way: the UPDATE used to writeemail,display_nameandpreferred_nameunconditionally, so{"id":143,"display_name":"β¦"}deleted that person's email address β and for a portal account the address is how they sign in, so it deleted their access too. -
A managed record refuses the whole save if the body mentions any
USER_DIRECTORY_OWNEDkey. Not "ignores the field" β refuses, with a message naming it. A save that silently does nothing is worse than one that says no. -
manager_idis loop-checked byuserManagerIsSafe()before it is written. A manages B manages A happens whenever two people cover for each other, and anything walking the chain for an approver would walk it forever. The database cannot express this.
π΄ This endpoint is reached by a portal user, not an analyst, and it writes to the same table. The differences from the analyst writer are all consequences of that:
-
The field list comes from the server, never the body.
foreach (USER_SELF_EDITABLE_FIELDS β¦)andarray_key_existsβ soPOST {"manager_id":1,"employee_id":"X"}has those keys ignored, and the response is still a success because nothing was wrong with the request; it simply did not contain anything this endpoint writes. Verify by reading the row back, not by reading the response. -
An administrator can narrow the four on System β Portal profile.
portalProfileEditableFields()returns the intersection of the constant and theportal_profile_fieldssetting β never saved means all four (an upgrade must not take a field away), saved empty means none. It can only narrow: a name outside the constant is dropped, so manager and employee ID cannot be switched on. A field that is in the constant but switched off is refused as"not_offered"rather than ignored, so a form drawn before the change says so and redraws. - π
portalProfileAccess($conn, $userId)is the one answer both portal endpoints use:fields(offered),locked(offered but directory-owned),address_book(changes go to the address book) androw(current values). The form and the save cannot disagree. -
A managed record is refused for the fields in
locked, same rule, but the error is the token"managed"rather than prose β the portal is translated into 24 languages and the string belongs inlang/, not in an API response. - π΄ Unless its address book takes the change.
address_bookis true only when the record is managed by a CardDAV provider with write-back on and theportal_profile_address_booksetting is on - write-back alone is an answer about analysts' edits, not customers'. Then the change is sent first (cardDavPushPersonChanges(..., $fromPortal = true)) and saved here only if accepted; a refusal returns"address_book"(withconflict) and writes nothing. The analyst path does it the other way round on purpose: an analyst told "the address book refused this" can act; a customer cannot, and a local save the book refused would be reverted by the next import. Only fields that actually changed are sent, so a preferred-name edit does not fill the write log with "nothing to send". - Lengths are checked in PHP (150 for job title and office, 50 for the numbers). MySQL in non-strict mode truncates silently, and a value saved quietly shorter than the one typed is worse than a refusal.
-
preferred_namefollows "absent means don't touch" too. It used to be written unconditionally, which was harmless while the only caller always sent it and a live bug the moment partial payloads were accepted:POST {"phone":"β¦"}wiped the name the person is addressed by in every email. Found by posting only the contact fields and reading the row back β the response saidsuccess. -
$input = json_decode(β¦) ?? [].array_key_exists($f, null)is a TypeError in PHP 8, so an empty POST would have been a 500 in an endpoint a customer's browser reaches. -
A missing row is reported, not treated as blanks. The session outlives the record if an administrator deletes the account mid-session;
fetchColumn()returningfalsemeans no row, which is notis_managed = 0.
The analyst editor (and the portal form) must omit the directory-owned fields wholesale on a managed record, not send them and let the server refuse.
Send them and the refusal is correct but the message is absurd: "I changed their company" comes back as an error about a job title. So is_managed has to travel from the reader, and it is load-bearing rather than decoration.
ssEditableFields starts empty, is filled from fields minus locked, and is the only list Save posts; a failed get_profile.php locks the form and says why. Fields that are not offered are hidden (their .ss-form-group), and locked ones are disabled and styled as such (.ss-form-input:disabled) - without that style a locked field looked editable in the dark theme. The alternative is an empty editable form whose Save writes blanks over values the user was never shown. There is a harness case for exactly this.
π΄ A manager the analyst cannot see must be kept, never cleared. manager_id is not tenant-scoped, so a person in one company can report to somebody in another. The Manager field is a type-ahead on get_users.php?search=&limit=20 (already scoped), and the current manager's name comes from get_person.php, which returns manager_name only when analystCanAccessUser() allows it - never a bare LEFT JOIN users mgr, which would hand an analyst scoped to one company a name from another. No name means "somebody you cannot see": the id is parked on dataset.unresolved on the hidden #pe_manager_id, the box says so, the clear button is hidden, and the key is left out of the save. Posting it as null would clear a reporting line the analyst was never shown - and since portal managers, would also take away that manager's view of the person's tickets. Choosing somebody else is the only way to change it.
manager_id is never sent at all, so the test passes whether the guard works or not. The negative control (remove && !mgr.dataset.unresolved, save, watch the manager become NULL) was done on such a person.
β
Resolved (#62): one editor. Tickets β Users and Assets β Users each had their own editor and they had drifted - Assets had no preferred name, password or company, cleared a manager it could not see, hardcoded the seven field names twice, and posted to a Tickets-only endpoint (so an Assets-only analyst was offered Edit and refused on Save). Both now open includes/person_editor.php, whose field list is rendered from USER_PERSON_FIELDS, and save_user.php accepts either module. Delete (Tickets) and Mark as left / Reactivate (Assets) stay on their own screens.
No node on the dev box: drive the real pages in a same-origin iframe harness and read state back β the shape used by the multi-tenancy test harness. Two traps worth naming, because both cost a round here: a fresh iframe's contentDocument is about:blank with readyState already complete, so gate on the URL; and top-level let is not a property of window, so assert observable behaviour (disabled states, the actual payload keys) rather than reaching for page variables.
The cases that matter, all of which have been run:
| Case | Expected |
|---|---|
| Unmanaged analyst edit | all seven save |
| Managed analyst edit | refused, naming the field; non-owned fields still save |
| Unmanaged portal edit | the four save |
Portal posts manager_id / employee_id / department
|
ignored β confirm in the DB, not the response |
| Managed portal edit of a contact field | refused as "managed"
|
| Managed portal edit of preferred name | succeeds β it is not directory-owned |
Portal posts only phone
|
preferred name survives |
Portal posts preferred_name: ""
|
preferred name cleared β absent and empty differ |
| Over-length value | refused as "too_long" with the field and max |
| Empty / malformed body | no 500 |
get_profile.php fails |
form locked, error shown, Save posts no blanks |
Added with Portal profile, run end to end in the Docker clean room against BaΓ―kal (47 checks):
| Case | Expected |
|---|---|
| Setting never saved | all four offered |
Admin saves phone plus manager_id, employee_id
|
only phone stored |
| Portal posts a switched-off field |
"not_offered", nothing in that save written |
| Admin saves none | nothing offered; preferred name still saves |
| Address-book contact, write-back off | all four locked, whatever the portal setting |
| Write-back on, portal setting off | still locked |
| Both on, phone changed | card updated, then FreeITSM; write log ok, marked as from the portal |
| Save with nothing changed | success, no write-log row |
| Same detail changed on the card first |
"address_book" + conflict, nothing changed on either side |
| Address book unreachable |
"address_book", nothing saved |
| Non-admin posts the setting | refused |
Source. get_users.php and AssetsService::people() return source_id / source_name (the provider a person is linked to) alongside managed_protocol. userSourceFilter($source) turns the Source dropdown into SQL ('' everyone, 'local' = ap.id IS NULL, so a person pointing at a deleted provider counts as unlinked, or a provider id), and userSourcesInScope() lists the providers with a count inside the caller's company scope, so the list never reveals how many people another company has in a shared directory. Asked for only with include_sources=1, so the requester picker, which calls get_users.php per keystroke, does not pay for it. A linked person who is not managed (they only sign in through the provider) shows Signs in with rather than Details from.
π΄ Linked is not "signs in elsewhere". Five portal paths - sign-in, register, email confirmation, forgotten password, reset - used to treat any auth_provider_id as "this account signs in through its provider", which was true while only LDAP and OIDC could be linked. A CardDAV link is a source of contact details, not a sign-in method, so every imported contact was told "this account signs in with single sign-on" and could never use a password. userSignsInElsewhere($conn, $providerId) is now the question: only a non-CardDAV link counts, and an unknown or deleted provider keeps the old, safe answer. An LDAP-linked account is still refused a local password, reset link and registration.
- Contact details β the user-facing page
- Directory sync β Developer Guide β the columns and the policy layer
- Extending directory sync β Β§2 before estimating any new source
- Self-Service β Developer Guide
- CalDAV and CardDAV β where this came from, and what is still only explored
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
- β³ π’ 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