From 1bf4efa9e1a2d04a59ba5c4ff32dc09bd36a78b0 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Mon, 31 Aug 2026 16:23:58 -0400 Subject: [PATCH] Link the Entra ids on a PC to the Azure portal The Entra Device Info card showed two GUIDs and nothing to do with them. Copying one into the portal by hand is the kind of errand a detail page exists to remove. Each link sits BESIDE the id it is built from rather than in a row at the top of the card, because the pairing is counterintuitive: the DEVICE id opens Intune and the OBJECT id opens Entra. A separate action row would have to re-label which is which, and would still leave the reader matching them up. BOTH URLS ARE SETTINGS WITH AN EMPTY DEFAULT, and that is the whole design. The host is a tenant fact, not a product one - this site is on the US Government cloud, portal.azure.us, where a commercial-cloud default would send people to a page that does not exist. Microsoft also moves these blade paths, and a site that hits that can fix it by editing a setting rather than waiting for a release. Empty means no link and the id still renders as text, so a site that configures nothing is unchanged. The example values live in each setting's description, per cloud. Same shape as the ServiceNow URL templates already in integrations, down to the {token} placeholder, so there is one idiom for "deep link into a system we do not own" rather than two. Scope: product --- frontend/src/utils/siteSettings.js | 15 +++++ plugins/computers/frontend/views/PCDetail.vue | 62 ++++++++++++++++++- shopdb/core/api/settings.py | 29 +++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/frontend/src/utils/siteSettings.js b/frontend/src/utils/siteSettings.js index a333a950..7edf5914 100644 --- a/frontend/src/utils/siteSettings.js +++ b/frontend/src/utils/siteSettings.js @@ -115,6 +115,21 @@ export async function getServicenowUrls() { return { enabled, incidentUrl, changeUrl, searchUrl } } +// Deep links into the Azure portal for a device ShopDB knows the Entra ids of. +// +// Both are TEMPLATES held in settings rather than built here, because the host +// is a tenant fact: this site is on the US Government cloud (portal.azure.us) +// and the commercial cloud is portal.azure.com. Microsoft also moves these +// blade paths from time to time, and a site can fix that itself by editing a +// setting instead of waiting for a release. +// +// Empty is the default and means "no link" - the id still renders as text. +export async function getEntraDeviceUrls() { + const entraUrl = await getSetting('entra_device_url', '') + const intuneUrl = await getSetting('intune_device_url', '') + return { entraUrl, intuneUrl } +} + // Printer hostname template. {ip} is replaced with the dash-separated IP. export async function getPrinterHostnameTemplate() { return getSetting('printer_hostname_template', 'Printer-{ip}.printer.geaerospace.net') // ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override. diff --git a/plugins/computers/frontend/views/PCDetail.vue b/plugins/computers/frontend/views/PCDetail.vue index 044691dc..820aa606 100644 --- a/plugins/computers/frontend/views/PCDetail.vue +++ b/plugins/computers/frontend/views/PCDetail.vue @@ -196,13 +196,35 @@ >

Entra Device Info

+
Device ID - {{ computer.computer.azuredeviceid }} + + {{ computer.computer.azuredeviceid }} + Intune +
Object ID - {{ computer.computer.azureobjectid }} + + {{ computer.computer.azureobjectid }} + Entra +
Device Category @@ -342,6 +364,7 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue' import AssetDetailTabs from '@/components/AssetDetailTabs.vue' import AssetRelationships from '@/components/AssetRelationships.vue' import { useIdentifierFlags } from '@/composables/identifierSettings' +import { getEntraDeviceUrls } from '@/utils/siteSettings' import { setRecordTitle } from '@/composables/pageTitle' import { useToast } from '@/composables/toast' import { apiError } from '@/utils/apiError' @@ -374,6 +397,22 @@ const assetPanels = ref(null) // which is the answer somebody arriving from a search or a link came for. const activegroup = ref('details') +// Azure portal deep links, built from templates a site configures. Empty +// templates mean no link at all, which is the default: the host differs per +// tenant (this site is on the US Government cloud) and Microsoft moves these +// blade paths, so guessing one would send people to a dead page. +const entraUrlTemplate = ref('') +const intuneUrlTemplate = ref('') + +function fillTemplate(template, token, value) { + if (!template || !value) return null + return template.split(token).join(encodeURIComponent(value)) +} +const entraDeviceLink = computed(() => fillTemplate( + entraUrlTemplate.value, '{objectid}', computer.value?.computer?.azureobjectid)) +const intuneDeviceLink = computed(() => fillTemplate( + intuneUrlTemplate.value, '{deviceid}', computer.value?.computer?.azuredeviceid)) + // Only offer a tab that has something in it. An empty tab is worse than an // absent one - it reads as broken rather than as "this site does not collect // that". Details always shows because core fills it regardless of plugins. @@ -451,6 +490,13 @@ function relativeAge(value) { } onMounted(async () => { + // Non-critical: a site that has not configured these just gets no link, so a + // failure here must not stop the page loading. + getEntraDeviceUrls().then(({ entraUrl, intuneUrl }) => { + entraUrlTemplate.value = entraUrl + intuneUrlTemplate.value = intuneUrl + }).catch(() => {}) + try { const response = await computersApi.get(route.params.id) computer.value = response.data.data @@ -606,6 +652,18 @@ async function confirmRetire() {