integration_topdesk — a dashboard widget showing the signed-in user's five most
recent TOPdesk tickets, a button to report a new one, and an admin page that connects
the server to one TOPdesk SaaS environment.
Version 1.0.0 · Nextcloud 33–35 · PHP 8.1–8.5 · AGPL-3.0-or-later
Dashboard widget — the user's five latest tickets, newest first, each with its TOPdesk number, processing status and age, linking into the Self-Service Portal. A "Report a ticket" button opens a modal with a short description, a details field and optional category/subcategory, and files a first-line incident without leaving Nextcloud.
The widget is rendered twice over, deliberately: a Vue component for the browser
(which the create modal needs), and getItemsV2() on the PHP side so the same five
tickets reach the mobile and desktop clients through
/apps/dashboard/api/v2/widget-items.
Admin settings (Administration settings → TOPdesk) — the environment URL, the credentials, the matching rule, and a "Save and test connection" button that makes a real authenticated call and explains the failure when there is one.
Personal settings (Personal settings → TOPdesk) — shows the user which TOPdesk person they are matched to and why, and lets them pick one by hand when the automatic match cannot settle it.
Two authentication modes; pick whichever the customer's TOPdesk supports.
- Modules → Supporting Files → Permission Groups → new group, with read on
API access > REST APIand write onAPI access > Use application tokens. The account also needs read on first/second-line incidents, read on Persons, and create permission on first-line incidents. - Link the group to the operator or API account the integration will use.
- Create the token: on the operator's own card via My Settings, or on an API
account card under Application tokens. Creating one on an API account card
additionally needs write on
Supporting Files > Login data, and read onSupporting Files > Operatorto see API accounts at all. - Copy the token immediately — it is shown once — and note the expiry date. The default is one year out, and nothing warns you when it passes.
- In Nextcloud, enter the URL, the login name and the token, then Save and test connection.
- Modules → Supporting Files → System integrations overview → Connected
applications → new application integration (the client-credentials kind;
the user-delegated kind is a different flow this version does not use).
Managing these needs the
Manage connected applications > Manage integration clientspermission. - Copy the client id and the one-time secret. A lost secret cannot be retrieved — delete the client and make a new one.
- In Nextcloud, switch the auth mode to OAuth, enter both, then Save and test connection.
Access tokens are short-lived (900 s) and minted on demand; there is no refresh token in this grant and nothing to rotate by hand.
The operator's category, branch and operator filters apply to everything this app reads. If the account is filtered to one branch, users outside it will show no tickets even when the person match is correct — which looks like a matching bug and is not one. Give the API account unfiltered read access to incidents, or expect that asymmetry.
TOPdesk's incident API has two endpoint families. GET /incidents is operator-only
and can read every ticket in the environment; GET /requester/incidents returns only
the caller's own tickets but requires the person's own credentials, which a
server-to-server integration does not have.
So this app authenticates as an operator and filters. Which means the filter is the only thing standing between a user and everybody else's ticket history — and the filter is only as good as the identity behind it.
The app stores a pair per user: the identifier it matched on, and the TOPdesk
person UUID that identifier resolved to. Tickets are then always read with
caller.id==<uuid> — never by email address.
That matters because addresses get reused. When somebody leaves and
j.doek@acme.nl is handed to their replacement, matching on the address alone shows
the newcomer the leaver's entire history. It is not an edge case; it happens on every
departure where the address is recycled.
So the pair is re-verified on every login, and whenever it goes stale (one hour by default):
| The identifier now resolves to… | What happens |
|---|---|
| the same UUID | nothing — carry on |
| a different UUID | the identifier changed hands. The stored UUID is replaced and only the new person's tickets are shown. Logged at warning level |
| nobody | the link is dropped. The widget shows "no TOPdesk person found", not somebody's tickets |
| several people | the link is dropped. The app never guesses between person cards |
| TOPdesk unreachable | the link is kept and marked unverified. A network blip is not evidence of an identity change |
That last row is why TopdeskException carries a transient flag: dropping links on
a timeout would blank every user's widget during a maintenance window and then
re-resolve them all at once afterwards.
A user (or an admin on their behalf) can pin a person by hand for the cases matching cannot settle — shared functional mailboxes, contractors, two cards with one address. A pinned link is an explicit decision and the resolver does not overwrite it, but it is shown in personal settings so it is never invisible.
On a stock Nextcloud, a user can change their own email address in personal settings. With email matching, an address they set is an address they get the tickets of — so a user who types a colleague's address into their own account, logs out and back in, sees that colleague's tickets.
Nothing in this app can prevent that, because from its side the change is indistinguishable from a legitimate correction. Choose one of:
- users provisioned from LDAP / Entra ID / SAML, where the address is not theirs to edit (the common case for a customer running TOPdesk, and the reason email is the default);
- prevent email changes on the instance;
- match on the Nextcloud username instead — it is not user-settable. Pair it with
the TOPdesk person field that carries the same value, usually
networkLoginNamewhen both systems are fed from the same directory.
The admin page says this on screen when email matching is selected.
occ topdesk:diagnose # connection check and the configured match rule
occ topdesk:diagnose <user> # full trace for one user
occ topdesk:diagnose --all # one line per user, plus a state summaryRead-only by default — looking at why a user does not match should not quietly
change what they are matched to. Pass --repair to write the result.
A trace for one user prints the Nextcloud field and the exact value taken from the account (quoted, with its length, and with leading or trailing whitespace called out by name — a stray space is the most common cause that is invisible in a log), the FIQL query actually sent, every candidate TOPdesk returned, and the decision made.
When nothing matched it then answers why, in the order these are usually the cause:
- An archived person card has the value. Archived cards are ignored on purpose — that is the whole reused-address defence — so this means un-archive the card if the person is still around.
- The value is on an operator card, not a person card. In TOPdesk those are separate, and only a person can be the caller on an incident. This is the usual reason the very first test fails: whoever sets the integration up is an operator — that is how they got an application token — and often has no person card at all.
- Active people whose value merely starts the same way, which catches a typo, a
.comwhere the directory says.nl, or a second account.
If a match succeeds but the person card does not have the Caller role ticked, the trace says so. That combination is easy to misread as a broken match: the identity is correct, but the person can never be a caller on an incident, so the widget stays empty and creating a ticket fails.
Also remember that the API operator's branch, category and operator filters apply to everything this app reads — a correctly matched user in a filtered-out branch shows no tickets.
The admin page has a Log matching details switch for the passive version: every
resolution, for every user, written to the Nextcloud log as one multi-line entry.
Search the log for TOPdesk match trace. It is off by default and should go back off
afterwards, because the trace contains the addresses being matched. Entries are
written at warning level deliberately — Nextcloud's default loglevel is 2, so
an info entry never reaches the file and the switch would appear to do nothing.
| Nextcloud side | TOPdesk person card |
|---|---|
| Email address (default) | Email address (default) |
| Username | Network login name · Employee number · TOPdesk login name |
Reading tasLoginName (TOPdesk login name) needs the
Settings > Login Settings permission on the API operator; without it every match
comes back empty.
npm install
npm run buildA production build takes about 7 seconds. Two things had to be worked around to get there, both recorded in the config so they are not rediscovered:
-
TypeScript must stay on 6.x (pinned in
devDependencies). TypeScript 7 removed an API thatvite-plugin-dtsstill calls through@volar/typescript, and the vite config then fails to load withCannot read properties of undefined (reading 'useCaseSensitiveFileNames'). -
extractLicenseInformation: falseinvite.config.mjs. WithcreateAppConfig's default REUSE license scanning on, the build never finishes here: it hangs in "rendering chunks" and eventually dies with "Ineffective mark-compacts near heap limit" — at an 8 GB heap limit, for three small entries. It is not the module graph. An entry containing nothing butconsole.log('probe')behaved identically, and the same entry with the plugin off built in 78 ms. It also survived pinning vite, rollup, TypeScript and@nextcloud/vueto the exact versions that build fine in the TeamHub app next door, so it is the plugin and not the dependency tree. The cost is the generated.licensesidecar files; the app's own licence is declared inappinfo/info.xmlandcomposer.json. Worth retrying on a future@nextcloud/vite-config.
Components are imported by subpath (@nextcloud/vue/components/NcButton) rather than
from the package root. The root is a barrel that re-exports all ~150 components;
going through it pulled 1793 modules into the graph where the subpaths pull 1272.
Vite's warning that build.outDir is the project root is expected —
createAppConfig defaults it there, and emptyOutDir: false keeps a build from
wiping the app directory.
CSS is extracted per entry into css/vite-*.chunk.css, and the PHP side loads it by
reading each entry's css/<entry>.css manifest stub rather than naming chunks by
hand — Rollup renames shared chunks whenever an import graph changes, and the symptom
of a stale hand-written name is an unstyled page with no error. See
lib/Service/ViteStyles.php and the comment block in vite.config.mjs.
npm run deploy:aioBuilds, then mirrors the shipped paths into the nextcloud-aio-nextcloud container's
custom_apps/integration_topdesk, chowns them to www-data, runs occ upgrade and
reloads php-fpm. Modelled on TeamHub's script of the same name.
| Flag | Effect |
|---|---|
--dry-run |
print every docker command, change nothing |
--enable |
also run occ app:enable — needed once, on a first deploy |
--restart |
restart the container instead of reloading php-fpm |
--skip-preflight |
deploy a bundle the checks reject |
Overridable with TOPDESK_AIO_CONTAINER and TOPDESK_AIO_APP_DIR.
Pre-flight refuses to deploy when appinfo/info.xml and package.json disagree on
the version, when src/ is newer than js/, when an entry's JS is missing, or when
a CSS manifest stub is missing or points at a chunk that is not there. That last one
is specific to this app: ViteStyles.php discovers stylesheets by reading
css/<entry>.css, so a missing stub renders the page completely unstyled with
nothing in any log to explain it. Don't reach for --skip-preflight to make a red
deploy green — an afternoon testing a stale bundle is an afternoon of conclusions
about code that is not running.
Two things worth knowing about the instance:
- PHP changes land within ~60 s (
opcache.validate_timestamps=On,revalidate_freq=60). If a change looks like it did not land, wait a minute before debugging it. Frontend changes are immediate, because Nextcloud cache-busts js/css URLs on the app version. - A changed
routes.phpdeployed under the same version number needs the php-fpm reload the script does at the end. Since NC 30 the router caches the compiled route table in APCu for an hour, keyed on the enabled apps' versions — a version bump invalidates it, a same-version redeploy does not, and the new route answers 404 until then whilephp -rinside the container matches it perfectly.
php tests/fiql-test.phpChecks FIQL quoting, which is the one place a crafted email address could otherwise append conditions to the persons query and widen which person card an account resolves to.
appinfo/info.xml manifest (validated against the App Store schema)
appinfo/routes.php
lib/Service/
ConnectionConfig.php admin connection; secrets via ICrypto
TopdeskClient.php HTTP; absorbs TOPdesk's 204-means-empty and 206-means-more
Fiql.php query building and escaping
PersonResolver.php the matching rules above
PersonLink.php one user's (match value, person UUID) pair
IncidentService.php list and create, always filtered on the UUID
Auth/ application token · OAuth client credentials
ViteStyles.php loads built CSS from the Vite manifest
lib/Dashboard/TicketsWidget.php
lib/Controller/ admin · personal · incident
lib/Listener/ re-verify at login · forget on account deletion
src/ Vue 3 components for widget, admin and personal pages
docs/topdesk-api-notes.md API research: spec URLs, endpoint permissions, quirks
docs/topdesk-api-notes.md is worth reading before extending this — it records which
endpoints an operator may call versus a person, the exact callerLookup semantics,
the FIQL operator table, and the download URLs for TOPdesk's OpenAPI specs (the
developer portal's HTML explorer is JS-rendered and cannot be fetched, but the specs
behind it can).
- Per-user OAuth (TOPdesk's user-delegated integration, authorization-code
grant). Each user would connect their own TOPdesk account once and the app would
read
/requester/incidentswith their token — TOPdesk enforces "own tickets" server-side, so no operator credential could over-read and the matching problem above disappears entirely. Costs the OAuth dance, per-user token storage, and a reconnect whenever the 60-day refresh token lapses.IncidentServiceis the only class that would need a second implementation. - Ticket detail inside Nextcloud, replies, attachments.
- Changes, reservations and assets — separate TOPdesk APIs.
- An admin overview of who is matched to whom. Links live in
oc_preferences(integration_topdesk/person_id,match_value,state, …); a listing page would want them promoted to a table of their own.