Skip to content

Contact Details Developer Guide

Ed Mozley edited this page Sep 27, 2026 · 4 revisions

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.


1. πŸ“ The files involved

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

2. πŸ”΄ Three lists, and they are not the same list

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 excludes is_managed, directory_username, last_seen_in_source and auth_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 an is_managed record these are refused, not saved. ⚠️ Which ones is decided per record by userDirectoryOwnedFields($protocol, $writeBack): a CardDAV address book owns five (USER_CARDDAV_OWNED), and one with write-back on owns none. Readers send the answer as managed_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.


3. πŸ”Œ The two writers, and why they differ

api/tickets/save_user.php β€” analysts

Both analyst screens post here. Three rules:

  1. Absent means don't touch. array_key_exists per field, never ?? ''. This was learned the hard way: the UPDATE used to write email, display_name and preferred_name unconditionally, 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.
  2. A managed record refuses the whole save if the body mentions any USER_DIRECTORY_OWNED key. Not "ignores the field" β€” refuses, with a message naming it. A save that silently does nothing is worse than one that says no.
  3. manager_id is loop-checked by userManagerIsSafe() 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.

api/self-service/update_profile.php β€” the customer

πŸ”΄ 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 …) and array_key_exists β€” so POST {"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 the portal_profile_fields setting β€” 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) and row (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 in lang/, not in an API response.
  • πŸ”΄ Unless its address book takes the change. address_book is true only when the record is managed by a CardDAV provider with write-back on and the portal_profile_address_book setting 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" (with conflict) 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_name follows "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 said success.
  • $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() returning false means no row, which is not is_managed = 0.

4. πŸ–₯️ The UI rule that is easy to get wrong

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.

⚠️ An unloaded list looks exactly like "everything". In the portal, 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.

⚠️ Test the guard on a person whose Manager field is NOT directory-owned. On an LDAP person 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.


5. πŸ§ͺ Testing it

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

⚠️ The dev database is real data. Capture the rows first, scope every UPDATE to the ids you captured, and restore afterwards.


6. Where a person comes from, and portal sign-in

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.


See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally