Link the Entra ids on a PC to the Azure portal
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Successful in 4m23s

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
This commit is contained in:
cproudlock
2026-08-31 16:23:58 -04:00
parent 259fe0de8f
commit 1bf4efa9e1
3 changed files with 104 additions and 2 deletions

View File

@@ -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.

View File

@@ -196,13 +196,35 @@
>
<h3 class="section-title">Entra Device Info</h3>
<div class="info-list">
<!-- The link sits beside the id it is built from, not in a row
of its own at the top: the pairing is counterintuitive - the
DEVICE id opens Intune and the OBJECT id opens Entra - and a
separate action row would have to re-label which is which. -->
<div v-if="computer.computer?.azuredeviceid" class="info-row">
<span class="info-label">Device ID</span>
<span class="info-value">{{ computer.computer.azuredeviceid }}</span>
<span class="info-value">
{{ computer.computer.azuredeviceid }}
<a
v-if="intuneDeviceLink"
:href="intuneDeviceLink"
target="_blank"
rel="noopener noreferrer"
class="portal-link"
>Intune</a>
</span>
</div>
<div v-if="computer.computer?.azureobjectid" class="info-row">
<span class="info-label">Object ID</span>
<span class="info-value">{{ computer.computer.azureobjectid }}</span>
<span class="info-value">
{{ computer.computer.azureobjectid }}
<a
v-if="entraDeviceLink"
:href="entraDeviceLink"
target="_blank"
rel="noopener noreferrer"
class="portal-link"
>Entra</a>
</span>
</div>
<div v-if="computer.computer?.azuredevicecategory" class="info-row">
<span class="info-label">Device Category</span>
@@ -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() {
</script>
<style scoped>
.portal-link {
font-size: 0.78rem;
margin-left: 0.5rem;
padding: 0.05rem 0.4rem;
border: 1px solid var(--border);
border-radius: 3px;
color: var(--link, var(--primary));
text-decoration: none;
white-space: nowrap;
}
.portal-link:hover { border-color: var(--primary); }
/* PC-specific styles - shared styles are in global style.css */

View File

@@ -1085,6 +1085,35 @@ def build_default_settings():
'category': 'integrations',
'description': 'ServiceNow global-search URL template. {ticket} is the ticket number.'
},
{
# ADR-015-OK: the DEFAULT is empty, so no cloud is assumed. Which
# Azure portal a site uses is a tenant fact, not a product one -
# this site is on the US Government cloud (portal.azure.us), and a
# commercial-cloud default would be silently wrong for it. The link
# simply does not render until a site fills these in.
'key': 'entra_device_url',
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': (
'Entra device properties URL template. {objectid} is the '
'device object id. Example (US Gov): https://portal.azure.us/'
'?feature.msaljs=false#view/Microsoft_AAD_Devices/'
'DeviceDetailsMenuBlade/~/Properties/objectId/{objectid}/deviceId/'
)
},
{
'key': 'intune_device_url',
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': (
'Intune device URL template. {deviceid} is the Entra device '
'id. Example (US Gov): https://portal.azure.us/'
'?feature.msaljs=false#view/Microsoft_Intune_Devices/'
'DeviceSettingsMenuBlade/~/overview/aadDeviceId/{deviceid}'
)
},
{
'key': 'servicenow_ticket_prefixes',
'value': 'GEINC,GECHG,GERIT,GESCT',