-
-
Notifications
You must be signed in to change notification settings - Fork 28
Portal Sign In Routing Developer Guide
Shipped in 2.10.0 Β· Built for #147 Β· User-facing pages: Single Sign-On and LDAP & Active Directory
How the self-service portal decides where to send someone who types their email, and the three 2.10.0 changes that touched it:
- routing by email domain, with the option to hide a provider's button;
- the sign-out addresses shown on the settings page;
- Unlink in Tickets β Users.
They are one subject: each one either feeds the router or undoes what it remembers. If you are about to change any of the files below, read The traps first.
Benjamin's request in #147 was simple: on the portal, show only an email box, and send @hiscompany.com straight to Microsoft 365.
Most of that was already there. The portal had an email-first box, and it routed people it already knew:
- someone linked to a provider by an earlier sign-in;
- on a multi-company install, someone whose company owns a provider.
The gap was the first visit on a single-company install. A new member of staff typed their email, pressed Continue, and got a password box for an account that has no password. They had to know to click the "Sign in with Microsoft" button underneath instead.
The #156 write-up had already rejected one fix for this: "if nothing matches, fall back to the global provider". That would silently move every password user to SSO the moment someone added a provider. So the design rule here is: nothing changes until an administrator types a domain in.
api/auth/resolve_login.php answers one question: this email typed into this login page - where should it go? It returns one of three answers: sso (with a provider), choose (a short list), or local (show the password box).
For the portal it now checks four things, in this order:
| # | Check | Answer |
|---|---|---|
| 1 | Is the account already linked to an enabled OIDC provider? |
sso to that provider |
| 2 | New: is the account linked to nothing, and is the email's domain listed in a provider's Email domains? |
sso to that provider |
| 3 | Multi-company only: does the domain belong to a company that owns providers? |
sso (1 provider) or choose (2+) |
| 4 | None of the above | local |
The analyst login only ever runs check 1. Analysts are enrolled one by one or through their team (Team sign-in guide), never by domain.
// api/auth/resolve_login.php
} elseif ($portal === 'self-service') {
require_once '../../includes/sso_identity.php';
$pinned = $conn->prepare("SELECT 1 FROM users WHERE LOWER(email) = ? AND auth_provider_id IS NOT NULL LIMIT 1");
$pinned->execute([$email]);
$byDomain = $pinned->fetchColumn() ? null : ssoPortalProviderForEmail($conn, $email);
if ($byDomain) {
$resp = ['mode' => 'sso', 'provider_id' => $byDomain['id'], 'provider_name' => $byDomain['display_name']];
}
// (3) Otherwise, on a multi-tenant install, route by company
require_once '../../includes/tenancy.php';
if (!$byDomain && isMultiTenant($conn)) {
...In plain English: if this person is already tied to any provider, the domain list is not consulted at all. Only someone tied to nothing gets routed by domain. The first trap explains why "any" matters.
// includes/sso_identity.php
function ssoPortalProviderForEmail(PDO $conn, string $email): ?array {
if (!ssoPortalRoutingColumnsReady($conn)) return null;
$at = strrpos($email, '@');
if ($at === false) return null; // a bare username has no domain
$domain = strtolower(trim(substr($email, $at + 1)));
...
$rows = $conn->query(
"SELECT id, display_name, portal_email_domains FROM auth_providers
WHERE enabled = 1 AND protocol = 'oidc'
AND portal_email_domains IS NOT NULL AND portal_email_domains <> ''
ORDER BY sort_order, display_name"
)->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
if (in_array($domain, explode("\n", $r['portal_email_domains']), true)) {
return ['id' => (int)$r['id'], 'display_name' => $r['display_name']];
}
}
return null;
}Three things this deliberately does:
-
Whole domains only.
company.comdoes not matchuk.company.com. A suffix match would quietly route addresses nobody listed, so a subdomain needs its own line. - OIDC only. An LDAP directory has nowhere to redirect to. Directory users type their password into the ordinary box (LDAP guide).
- A bare username skips it. The portal accepts usernames for directory staff with no mailbox, and a username has no domain.
A provider has at most a handful of domains and an install a handful of providers, so it reads them and matches in PHP rather than building a LIKE pattern. ORDER BY is only a tie-break that can't happen in practice, because the save refuses a domain claimed twice.
Two columns on auth_providers, in both database/freeitsm.sql and includes/db_verify_schema.php:
`portal_email_domains` TEXT NULL, -- one domain per line, lowercase; NULL = no routing
`portal_show_button` TINYINT(1) NOT NULL DEFAULT 1, -- show "Sign in with ..." on the PORTAL loginDEFAULT 1 is the upgrade promise: every button stays where it was until someone unticks it.
TEXT and newline-separated, the same convention as sync_ou_includes and carddav_scope_value. It is a setting belonging to one provider, never joined to, so a child table would buy nothing.
api/system/save_sso_provider.php takes portal_email_domains and portal_show_button from the provider dialog.
// includes/sso_identity.php
function ssoParseEmailDomains($value): array {
// Lines, commas and semicolons - not spaces, so a typo such as "not a domain"
// is quoted back whole in the error rather than as three fragments.
$parts = is_array($value) ? $value : preg_split('/[\r\n,;]+/', (string)$value);
...
$d = strtolower(trim((string)$part));
$d = ltrim($d, '@');
if (strlen($d) > 253 || !preg_match('/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$/', $d)) {
$invalid[] = trim((string)$part);
...
return ['domains' => array_keys($domains), 'invalid' => $invalid];
}@Company.com, company.com becomes one entry, company.com. Anything that isn't a domain is refused with what was typed, rather than dropped. A silently dropped line is a domain the admin believes is routed and isn't.
// api/system/save_sso_provider.php - after the "keep what is stored" step
if (ssoPortalRoutingColumnsReady($conn)) {
$finalDomains = (string)($vals[array_search('portal_email_domains', $cols, true)] ?? '');
...
foreach ($finalDomains as $d) {
if (isFreemailDomain($conn, $d)) {
bail("$d is a public email domain, so it cannot be sent to one provider - everyone who uses it would be.");
}
}
// One provider per domain
$others = $conn->prepare("SELECT display_name, portal_email_domains FROM auth_providers
WHERE id <> ? AND portal_email_domains IS NOT NULL AND portal_email_domains <> ''");
...
bail(implode(', ', $clash) . ' is already sent to ' . $o['display_name'] . '. A domain can only go to one provider.');-
Public email domains use the same list System β Companies uses (
isFreemailDomain()), including any an admin has added. Mappinggmail.comto one provider would send every Gmail user on the portal to it. - One provider per domain. Otherwise which one wins would be decided by sort order, and nobody looking at the screen could see why.
Why "the final value"? This endpoint keeps a stored setting when a screen doesn't send it, so renaming a provider doesn't wipe its other settings (see the long comment in the file). The checks run after that step, so a save that never mentions the domains is still held to the same rules.
LDAP and CardDAV saves always store NULL and 1. They have no redirect and no button.
self-service/login.php decides two things before it draws anything: which buttons to show, and whether to show the email-first box at all ($ssoActive).
$portalRouting = ssoPortalRoutingColumnsReady($ssoConn);
$ssoProviders = $ssoConn->query("SELECT id, display_name FROM auth_providers WHERE enabled = 1 AND tenant_id IS NULL AND protocol = 'oidc'"
. ($portalRouting ? ' AND portal_show_button = 1' : '')
. " ORDER BY sort_order, display_name")->fetchAll(PDO::FETCH_ASSOC);
$routerNeeded = $portalRouting && (int)$ssoConn->query(
"SELECT COUNT(*) FROM auth_providers WHERE enabled = 1 AND protocol = 'oidc'
AND (tenant_id IS NULL OR (portal_email_domains IS NOT NULL AND portal_email_domains <> ''))"
)->fetchColumn() > 0;
...
$ssoActive = $ssoOn && ($multiTenant || !empty($ssoProviders) || $routerNeeded);Before 2.10.0, $ssoActive was $ssoOn && ($multiTenant || !empty($ssoProviders)): the box appeared because there were buttons. With buttons now optional that link had to be cut, which is the second trap.
Company-owned providers still never get a button on a multi-company portal. That is the privacy rule from #156 and is unchanged.
The analyst login (auth/login.php) does not read portal_show_button. It still shows every enabled global OIDC provider.
Single logout sends the browser to the provider's end_session_endpoint with a post_logout_redirect_uri, so that signing out of FreeITSM also signs out of the provider. Keycloak and Okta refuse that address unless it is registered exactly.
The two sign-outs ask for different addresses:
// auth/analyst_logout.php
$postLogout = $scheme . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . BASE_URL;
// self-service/logout.php
$postLogout = $scheme . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . BASE_URL . 'self-service/login.php';Until 2.10.0 the Keycloak guide listed only the first, and the settings page showed neither. So anyone who followed the guide got Keycloak's "Invalid redirect uri" page when they signed out of the portal. System β Single Sign-On now shows both under the redirect URI, each with a Copy button, built the same way:
// system/sso/index.php (and again in system/help/sso.php)
$signoutAnalystUri = $scheme . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . BASE_URL;
$signoutPortalUri = $signoutAnalystUri . 'self-service/login.php';All three addresses (sign-in, and the two sign-outs) are built from the host the browser used. Moving an install from http://localhost/freeitsm-app/ to an https name changes all three, and the provider has to be told the new ones. That is exactly how this gap was found.
What the router remembers lives in two places for a portal user:
| Where | Means |
|---|---|
users.auth_provider_id |
"this account signs in through provider N". Check 1 above reads it, and strict isolation enforces it. |
user_sso_identities |
the provider's own id for them (the sub claim, or a directory GUID) |
api/tickets/unlink_user_signin.php clears both, in one transaction, and logs it:
$conn->beginTransaction();
$del = $conn->prepare("DELETE FROM user_sso_identities WHERE user_id = ?");
$del->execute([$id]);
$conn->prepare("UPDATE users SET auth_provider_id = NULL WHERE id = ?")->execute([$id]);
$conn->prepare("INSERT INTO system_logs (log_type, analyst_id, details, created_datetime) VALUES ('signin_unlinked', ?, ?, UTC_TIMESTAMP())")
->execute([(int)$_SESSION['analyst_id'], json_encode([... 'provider_name' => ..., 'identities_removed' => $del->rowCount()])]);
$conn->commit();Afterwards the person is a first-time requester again. The email box no longer sends them to that provider. If they sign in through a provider anyway, the portal's auto-claim links them again by verified email. Their tickets and details are never touched.
The guards, in order:
-
requireModuleAccessJson('tickets'); -
analystCanAccessUser(), answering "User not found" for someone in a company the analyst can't reach, the same wording asdelete_user.php. A scoped analyst must not be able to tell "not yours" from "not there"; - refused for
is_managedpeople (see the fifth trap); - refused with "not linked" when there is nothing to remove.
On screen, tickets/users.php adds the link only to the Signs in with line, which is shown when a person is linked but not managed. A managed person's line says Details from and has no Unlink.
ποΈ schema Β· π routing Β· βοΈ write Β· π₯οΈ UI Β· β help Β· π§ͺ Feature Bingo
| π¨ | File | What it does here |
|---|---|---|
| ποΈ | database/freeitsm.sql |
portal_email_domains, portal_show_button for fresh installs |
| ποΈ | includes/db_verify_schema.php |
The same two for Database Verification |
| π | includes/sso_identity.php |
ssoPortalRoutingColumnsReady(), ssoParseEmailDomains(), ssoPortalProviderForEmail()
|
| π | api/auth/resolve_login.php |
Step 2, portal only, skipped for anyone already linked |
| π₯οΈ | self-service/login.php |
Hides unticked buttons; $routerNeeded keeps the email box |
| βοΈ | api/system/save_sso_provider.php |
Parses, refuses public and duplicate domains, keeps stored values |
| π₯οΈ | api/system/get_sso_providers.php |
Returns both columns, or their defaults before verification |
| π₯οΈ | system/sso/index.php |
Email domains + Show button in the provider dialog (OIDC only); the two sign-out addresses |
| βοΈ | api/tickets/unlink_user_signin.php |
New. Clears the link, logs signin_unlinked
|
| π₯οΈ | tickets/users.php |
Unlink beside Signs in with, with a confirm |
| β |
system/help/sso.php, system/help/_registry.php
|
Portal: route by email domain section; troubleshooting for sign-out and unlinking |
| π§ͺ | includes/feature_bingo/cards/system-core.php |
system.sso_portal_email_domains |
| π |
lang/en/system.php, lang/en/tickets.php
|
New English strings |
Not touched: api/auth/oidc_callback.php. Strict isolation, the auto-claim and JIT all work exactly as before. This change only decides where the browser is sent; what happens when it comes back is unchanged.
Check 1 only matches an account linked to an enabled OIDC provider. Two kinds of linked account fall straight through it:
- someone linked to an LDAP directory (OIDC only, so no match);
- someone linked to a provider that has since been switched off (
enabled = 1fails).
If step 2 then routed them by domain, they would land at Microsoft, sign in successfully, and be refused on the way back: the callback allows an account in only through the provider it is linked to. The person would be stranded, through no fault of their own. Hence auth_provider_id IS NOT NULL - any link - and not "an OIDC link".
The email box is the only way the router can help returning users. If the last visible button is hidden and $ssoActive turns false, the page falls back to the plain password form. Everyone linked to SSO is then asked for a password they don't have. $routerNeeded keeps the box whenever any global OIDC provider, or any provider with domains, is enabled.
Every upgraded install spends some time running new code against the old schema. A query that names a missing column fails, and several of these screens read a failed query as "nothing there". The provider list would say No providers yet, and the portal would lose its buttons. So everything asks ssoPortalRoutingColumnsReady() first:
- the resolver skips step 2;
- the portal page leaves
portal_show_buttonout of its query; - the provider list selects
NULL AS portal_email_domains, 1 AS portal_show_button; - the save leaves both columns out of its
INSERT/UPDATE.
Before verification the portal behaves exactly as 2.9.0. Same pattern and same reason as ssoJitColumnsReady() in the auto-create guide.
auth/analyst_logout.php, self-service/logout.php, system/sso/index.php and system/help/sso.php each build it. Change where a sign-out lands, and change all four. If the pages stop matching, admins register an address the provider is never asked for, and sign-out breaks again with nothing in FreeITSM to show why.
is_managed = 1 means a directory import or address book owns the record. Directory sync attaches its identity again on the next run (sync_on_conflict = 'adopt'), so unlinking here would appear to work and quietly undo itself. The endpoint refuses, and the page never offers it.
Both are enforced on save, not at sign-in. If you add another way to write portal_email_domains (the REST API, an import), it must make the same two checks, or the resolver's "first match wins" becomes a guess.
Ed's development database holds real data, so the routing was tested on a throwaway copy:
- a git worktree of the change, served by WAMP at its own address;
- a new database loaded from
database/freeitsm.sql; - a copy of
db_config.phppointing the worktree at it.
All three were removed afterwards. Requests went through the real endpoints with curl and a forged admin session.
| Area | Cases | Result |
|---|---|---|
| Resolver | baseline (no domains) matches 2.9.0; first-time user routed; mixed case; unlinked password user routed; listed subdomain routed, unlisted subdomain not; LDAP-linked user not routed; user linked to another provider keeps it; other domain gets local; bare username local; analyst login never routed |
10/10 |
| Save | malformed domain quoted back; gmail.com refused; @Company.com, company.com normalised; domain claimed by a second provider refused; rename without sending domains keeps them |
5/5 |
| Portal page | one button hidden; both hidden with domains; both hidden, no domains (box stays); defaults identical to today; SSO off; local login off and ?local=1
|
6/6 |
| Analyst login | still shows both buttons with both hidden on the portal | β |
| Before Database Verification | columns dropped: resolver, portal page, provider list, save and settings page all as 2.9.0, no warnings; verification then adds both columns with buttons defaulting to shown | β |
| Dialog (headless Chrome) | fields filled from the provider; shown for OIDC, hidden for LDAP and CardDAV; defaults for a new provider; saved through the page's own Save | β |
| Sign-out | Keycloak accepts the portal address once registered; an unregistered address is still refused (the control) | β |
| Unlink | through the endpoint: linked β unlinked and logged; again β "not linked"; directory-managed β refused, nothing written; unknown id; no session. Through the page: confirm wording, success message, row gone; a managed person shows no Unlink. On a temporary user, since deleted. | β |
| Existing suites |
sso-dangling-link 12/12, oidc-discovery 27/27, web-exposure-guard 12/12, config-not-load-bearing 13/13; Feature Bingo 589 cards, 0 malformed |
pass |
Not tested here: a complete round trip through Microsoft Entra's own sign-in screen. On Ed's install, a real Keycloak sign-in from the Choose how to sign in list linked the account (auth_provider_id and a user_sso_identities row both written). The resolver then returned sso to Keycloak for that address instead of the list.
- When checking whether a page scrolls, don't call
window.scrollTo(). A script can scroll a page that hasoverflow: hidden, even though a mouse wheel can't, so the test passes on a broken page. - On a multi-company install
get_users.phpfollows the session's company. A forged session withoutactive_tenant_idsees nobody in another company, and the page looks empty for a reason that has nothing to do with the code under test.
- Single Sign-On - the admin-facing reference, including Sending portal users straight to SSO and Seeing and undoing a requester's link
- LDAP & Active Directory - the full decision flowchart
- A global SSO provider was missing from the portal - the fallback this design avoids
- SSO auto-create and profile sync - Developer Guide - what happens when the browser comes back
-
Sign-in method per team - Developer Guide - the analyst side of
auth_provider_id - Setting up SSO with Keycloak
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