diff --git a/README.md b/README.md index 9043140..7ab3452 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,14 @@ By default, Violentmonkey will auto-update scripts from the original install loc - Bypass owners reminder: For PRs into branches requiring a passing `ni/owners-approved` status, hovering over the Approve button pops up a reminder to consider bypassing owners - Some tags/labels are colored (e.g. red if the label contains "Blocked") +### Reviewer out-of-office annotations + +> Works out of the box for reviewers signed in with an NI/Emerson account. Users on other tenants/orgs can enable it by supplying their own Azure AD app registration (see [below](#maintainers-out-of-office-app-registration)). + +Reviewers on a PR are annotated with an **Out of Office** label when their Microsoft 365 presence shows they are out of office. Hovering the label shows their Outlook auto-reply message (if any). + +This is looked up live per-reviewer via the Microsoft Graph [presence API](https://learn.microsoft.com/en-us/graph/api/presence-get), so it is always current. For NI/Emerson accounts it works with no setup β€” the script signs you in silently using a shared, single-tenant Azure AD app registration the first time you view a PR. Reviewers that can't be resolved simply show no label. + ### Overall - Scrollbars throughout the site will now match the current Azure DevOps color theme @@ -95,6 +103,15 @@ Trophies are awarded for notable PRs and shown in a trophies section on the Over - [Contributing to this project](CONTRIBUTING.md) - [GitHub repo](https://github.com/alejandro5042/azdo-userscripts) +## Maintainers: out-of-office app registration + +The [reviewer out-of-office annotations](#reviewer-out-of-office-annotations) use a shared Azure AD app registration so end users don't have to create their own. A few notes for maintainers: + +- **The client ID is committed in the script on purpose.** This is an OAuth 2.0 Authorization Code + PKCE public client, which has no client secret. The client ID is not sensitive; it is safe to ship in this public repo. It is defined as `OOO_DEFAULT_GRAPH_CLIENT_ID` in [src/azdo-pr-dashboard.user.js](src/azdo-pr-dashboard.user.js). +- **The app is single-tenant** (NI/Emerson) with delegated `Presence.Read.All`, admin-consented once by IT. Only tenant accounts can obtain a token, and only to read presence as the signed-in user. It also requests the standard `offline_access` scope so the script can silently refresh access tokens via a background request instead of a popup; because the redirect URIs are SPA-type, these refresh tokens last 24h, after which one silent re-auth popup is needed. +- **Redirect URIs are the one thing to manage.** Azure AD SPA redirect URIs cannot be wildcarded, and `https://dev.azure.com/` server-redirects to the org root (stripping the OAuth params), so the redirect URI must be the org-specific root. Register each AZDO org root your users use as a **Single-page application** redirect URI on the app registration, e.g. `https://dev.azure.com/ni/`. To support a new org later, add its root URL there β€” no code change is needed. +- **Users on another tenant/org** can point the script at their own app registration via the Tampermonkey menu command _"OOO Lookup: Configure Graph Client ID"_ without editing the code. + ## Privacy The update URL goes through a URL redirect service to get a rough idea of how many people are using this script. To opt-out, change the update URL to the original download URL your usescript dashboard (or disable updates). This redirect service may also help if the URL needs to change; e.g. if the file is moved or renamed. diff --git a/src/azdo-pr-dashboard.user.js b/src/azdo-pr-dashboard.user.js index 0da5872..6591899 100644 --- a/src/azdo-pr-dashboard.user.js +++ b/src/azdo-pr-dashboard.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name More Awesome Azure DevOps (userscript) -// @version 3.12.1 +// @version 3.13.0 // @author Alejandro Barreto (NI) // @description Makes general improvements to the Azure DevOps experience, particularly around pull requests. Also contains workflow improvements for NI engineers. // @license MIT @@ -37,17 +37,45 @@ // @grant GM_deleteValue // @grant GM_addValueChangeListener // @grant GM_registerMenuCommand +// @grant GM_xmlhttpRequest +// @connect graph.microsoft.com +// @connect login.microsoftonline.com // ==/UserScript== (function () { 'use strict'; + // Early check: are we in an OOO auth redirect popup from the PKCE OAuth flow? + // This runs before any other script logic and closes the popup after relaying the code. + // Uses GM_setValue (Tampermonkey IPC) β€” reliable across sandboxed script contexts. + { + const oooParams = new URLSearchParams(window.location.search); + const oooState = oooParams.get('state'); + if (oooState && oooState.startsWith('azdo-ooo-')) { + try { + // Key is state-specific to avoid collisions between concurrent attempts. + GM_setValue(`oooAuthResult-${oooState}`, JSON.stringify({ + code: oooParams.get('code') || null, + state: oooState, + error: oooParams.get('error_description') || null, + errorCode: oooParams.get('error') || null, + })); + } catch (e) { /* ignore */ } + setTimeout(() => window.close(), 200); + return; + } + } + // All REST API calls should fail after a timeout, instead of going on forever. $.ajaxSetup({ timeout: 5000 }); let currentUser; let azdoApiBaseUrl; + let oooAccessToken = null; + let oooAccessTokenExpiry = null; + let oooAuthPromise = null; // coalesces concurrent token acquisitions (refresh or popup) across callers + let oooSilentAuthAttempted = false; // limit the fallback popup to one attempt per page session // Some features only apply at National Instruments. const atNI = /^ni\./i.test(window.location.hostname) || /^\/ni\//i.test(window.location.pathname); @@ -76,14 +104,64 @@ 'agent-arbitration-status-off': 'Off', }); - eus.showTipOnce('release-2026-06-26', 'New in the AzDO userscript', ` -

Highlights from the 2026-06-26 update!

+ eus.showTipOnce('release-2026-07-21', 'New in the AzDO userscript', ` +

Highlights from the 2026-07-21 update!

Comments, bugs, suggestions? File an issue on GitHub 🧑

`); + + GM_registerMenuCommand('OOO Lookup: Configure Graph Client ID', async () => { + const current = GM_getValue('oooGraphClientId', ''); + // eslint-disable-next-line no-alert + const clientId = prompt( + 'OOO lookup works out of the box using a shared app registration – you normally do not ' + + 'need to set anything here.\n\n' + + 'Only override the Azure AD Application (Client) ID if you are on a non-NI/Emerson tenant/org ' + + 'and want to use your own app registration (SPA redirect URI ' + + `${getOooRedirectUri()}, delegated Presence.Read.All + offline_access).\n\n` + + 'Leave blank to use the shared default.', + current, + ); + if (clientId === null) { + return; + } + GM_setValue('oooGraphClientId', clientId.trim()); + oooAccessToken = null; + oooAccessTokenExpiry = null; + oooAuthPromise = null; + oooSilentAuthAttempted = false; + GM_deleteValue('oooAccessToken'); + GM_deleteValue('oooAccessTokenExpiry'); + // A new client ID means any stored refresh token is for a different app; drop it. + GM_deleteValue('oooRefreshToken'); + GM_deleteValue('oooRefreshTokenExpiry'); + + // Sign in immediately so the user consents once and sees any admin-approval message; + // after this, silent auth keeps the token fresh automatically. + try { + await acquireOooTokenInteractive(); + swal.fire({ icon: 'success', title: 'OOO Lookup Active', text: 'Reload a PR page to see live OOO annotations.' }); + } catch (e) { + if (e.code && /consent_required/i.test(e.code)) { + const tenantId = GM_getValue('oooTenantId', 'organizations'); + const adminConsentUrl = `https://login.microsoftonline.com/${tenantId}/v2.0/adminconsent` + + `?client_id=${encodeURIComponent(getOooClientId())}` + + `&redirect_uri=${encodeURIComponent(getOooRedirectUri())}`; + swal.fire({ + icon: 'warning', + title: 'Admin approval required', + html: 'Your organization requires an IT admin to approve this app before users can sign in.

' + + 'Share this URL with your IT admin (one-time setup, unlocks OOO for everyone):

' + + ``, + }); + } else { + swal.fire({ icon: 'error', title: 'Authentication failed', text: String(e.message) }); + } + } + }); } // Start modifying the page once the DOM is ready. @@ -213,6 +291,356 @@ return value; } + // ===== OOO Lookup via Microsoft Graph API ===== + // Looks up each reviewer's out-of-office status live at page load time. + // Uses a shared, single-tenant Azure AD app registration (Presence.Read.All, delegated). The + // client ID below is public by design: this is an Authorization Code + PKCE public client, so + // there is NO client secret to leak. The app is locked down by tenant + delegated scope + the + // registered SPA redirect URIs (one per AZDO org root, e.g. https://dev.azure.com/ni/). + // Power users on other orgs/tenants can override the client ID via the Tampermonkey menu. + + // offline_access is required for AAD to return a refresh token, which lets us mint new access + // tokens via a background POST (no popup) until the refresh token's own expiry. + const OOO_GRAPH_SCOPES = 'https://graph.microsoft.com/Presence.Read.All offline_access'; + const OOO_STATE_PREFIX = 'azdo-ooo-'; + // Shared app registration ("TM RD PR Metrics"). Safe to commit – public PKCE client, no secret. + const OOO_DEFAULT_GRAPH_CLIENT_ID = '968d0bcc-467a-4ec6-96be-3a1ab9e339e4'; + + function getOooClientId() { + // A per-user override (via the Tampermonkey menu) takes precedence over the shared default. + return GM_getValue('oooGraphClientId', '') || OOO_DEFAULT_GRAPH_CLIENT_ID; + } + + function getOooRedirectUri() { + // https://dev.azure.com/ does a server-side redirect to https://dev.azure.com/{org}/, + // which strips the OAuth code/state query params before the userscript can intercept them. + // Use the org-specific root URL instead β€” it doesn't redirect further. + if (window.location.hostname === 'dev.azure.com') { + const org = window.location.pathname.split('/').filter(Boolean)[0]; + if (org) return `${window.location.origin}/${org}/`; + } + return `${window.location.origin}/`; + } + + function getOooRandom(len = 43) { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + const buf = new Uint8Array(len); + crypto.getRandomValues(buf); + return Array.from(buf, b => chars[b % chars.length]).join(''); + } + + async function getOooPkceChallenge(verifier) { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); + return btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + } + + // Promisified GM_xmlhttpRequest β€” bypasses CORS for all OOO-related network calls. + function oooXhr(method, url, { headers = {}, body = null } = {}) { + return new Promise((resolve, reject) => { + GM_xmlhttpRequest({ + method, + url, + headers, + data: body, + onload: resp => resolve(resp), + onerror: () => reject(new Error(`Network error calling ${url}`)), + ontimeout: () => reject(new Error(`Timeout calling ${url}`)), + }); + }); + } + + // Auto-discovers the AAD tenant ID from the user's email domain via OIDC metadata. + // Single-tenant apps (the default after Oct 2018) reject the /common endpoint; + // they require a tenant-specific URL like /login.microsoftonline.com/{tenantId}/... + async function getOooTenantId() { + const cached = GM_getValue('oooTenantId', ''); + if (cached) return cached; + + const email = currentUser && currentUser.uniqueName; + const domain = email && email.split('@')[1]; + if (!domain) return 'organizations'; + + try { + const resp = await oooXhr( + 'GET', + `https://login.microsoftonline.com/${encodeURIComponent(domain)}/.well-known/openid-configuration`, + ); + if (resp.status >= 200 && resp.status < 300) { + const cfg = JSON.parse(resp.responseText); + // issuer is "https://login.microsoftonline.com/{tenantId}/v2.0" + const m = cfg.issuer && cfg.issuer.match(/\/([a-f0-9-]{36})\//i); + if (m) { + GM_setValue('oooTenantId', m[1]); + return m[1]; + } + } + } catch (e) { /* ignore */ } + + return 'organizations'; + } + + async function acquireOooTokenInteractive(silent = false) { + const clientId = getOooClientId(); + if (!clientId) throw new Error('OOO Graph Client ID not configured. Use the Tampermonkey menu.'); + + const tenantId = await getOooTenantId(); + const verifier = getOooRandom(); + const challenge = await getOooPkceChallenge(verifier); + const state = OOO_STATE_PREFIX + getOooRandom(16); + const redirectUri = getOooRedirectUri(); + + sessionStorage.setItem(`ooo-pkce-${state}`, verifier); + + function openAuthPopup(promptMode) { + const authUrl = new URL(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`); + authUrl.searchParams.set('client_id', clientId); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('redirect_uri', redirectUri); + authUrl.searchParams.set('scope', OOO_GRAPH_SCOPES); + authUrl.searchParams.set('state', state); + authUrl.searchParams.set('code_challenge', challenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + authUrl.searchParams.set('login_hint', currentUser.uniqueName); + authUrl.searchParams.set('prompt', promptMode); + + return new Promise((resolve, reject) => { + // Clear any stale result from a prior attempt with this same state. + GM_deleteValue(`oooAuthResult-${state}`); + + const popup = window.open(authUrl.toString(), 'ooo-auth', 'width=600,height=600,resizable=yes'); + if (!popup) { reject(new Error('Popup blocked - allow popups for this site')); return; } + + debug('OOO auth popup opened. Waiting for redirect back to', redirectUri); + + function handleResult(result) { + clearInterval(pollId); // eslint-disable-line no-use-before-define + clearTimeout(timer); // eslint-disable-line no-use-before-define + GM_deleteValue(`oooAuthResult-${state}`); + debug('OOO auth result received:', result.code ? 'code obtained' : `error: ${result.errorCode}`); + if (result.error || result.errorCode) { + const err = new Error(result.error || result.errorCode); + err.code = result.errorCode; + reject(err); + return; + } + resolve(result.code); + } + + // Poll GM storage every 200ms. GM_addValueChangeListener's 'remote' flag is unreliable + // for popupβ†’opener communication (may be false even across windows), so polling is safer. + const pollId = setInterval(() => { + const rawVal = GM_getValue(`oooAuthResult-${state}`, null); + if (rawVal === null) return; + try { handleResult(JSON.parse(rawVal)); } catch (e) { clearInterval(pollId); } + }, 200); + + const timer = setTimeout(() => { + clearInterval(pollId); + GM_deleteValue(`oooAuthResult-${state}`); + reject(new Error('Authentication timed out (2 min). Check that the redirect URI matches your Azure AD registration.')); + }, 2 * 60 * 1000); + }); + } + + // Try silent first (prompt=none); fall back to interactive if AAD requires user interaction. + debug('Starting OOO auth. Tenant:', tenantId, 'Redirect URI:', redirectUri); + let code; + try { + code = await openAuthPopup('none'); + } catch (e) { + debug('Silent OOO auth failed, errorCode:', e.code, 'β€” trying interactive'); + if (e.code && /interaction_required|consent_required|login_required/i.test(e.code)) { + if (silent) throw e; // don't fall back to interactive UI in silent mode + code = await openAuthPopup('select_account'); + } else { + throw e; + } + } + + const storedVerifier = sessionStorage.getItem(`ooo-pkce-${state}`) || verifier; + sessionStorage.removeItem(`ooo-pkce-${state}`); + + const tokenResp = await oooXhr('POST', `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, { + // SPA app registrations require an Origin header matching the redirect URI origin. + // GM_xmlhttpRequest doesn't send one automatically, so we add it explicitly. + headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: window.location.origin }, + body: new URLSearchParams([ + ['grant_type', 'authorization_code'], + ['client_id', clientId], + ['code', code], + ['redirect_uri', redirectUri], + ['code_verifier', storedVerifier], + ['scope', OOO_GRAPH_SCOPES], + ]).toString(), + }); + + if (tokenResp.status < 200 || tokenResp.status >= 300) { + const errData = JSON.parse(tokenResp.responseText || '{}'); + error('OOO token exchange failed. Status:', tokenResp.status, 'Body:', tokenResp.responseText); + throw new Error(errData.error_description || errData.error || `Token exchange failed: HTTP ${tokenResp.status}`); + } + + debug('OOO token exchange succeeded. Access token obtained.'); + storeOooTokens(JSON.parse(tokenResp.responseText), /* isFreshLogin= */ true); + return oooAccessToken; + } + + // Persists tokens returned by either the authorization_code or refresh_token grant. Refresh + // tokens are single-use and rotated, so we always store the latest one. For SPA redirect URIs + // the refresh token expires 24h after the original interactive login and that expiry carries + // over across rotations, so we only stamp the 24h clock on a fresh login. + function storeOooTokens(tokenData, isFreshLogin) { + oooAccessToken = tokenData.access_token; + oooAccessTokenExpiry = new Date(Date.now() + (tokenData.expires_in - 300) * 1000); + // Cache in GM storage so the token survives page navigations. + GM_setValue('oooAccessToken', oooAccessToken); + GM_setValue('oooAccessTokenExpiry', oooAccessTokenExpiry.getTime()); + + if (tokenData.refresh_token) { + GM_setValue('oooRefreshToken', tokenData.refresh_token); + if (isFreshLogin) { + GM_setValue('oooRefreshTokenExpiry', Date.now() + 24 * 60 * 60 * 1000); + } + } + } + + // Mints a new access token from the stored refresh token via a background POST β€” no popup. + // Returns null (and drops the refresh token) if there's none or AAD rejects it, so the caller + // falls back to an interactive/silent popup. + async function refreshOooAccessToken() { + const refreshToken = GM_getValue('oooRefreshToken', ''); + const refreshExpiry = GM_getValue('oooRefreshTokenExpiry', 0); + // SPA refresh tokens live only 24h; once past that a popup is unavoidable, so don't bother. + if (!refreshToken || Date.now() >= refreshExpiry) return null; + + const clientId = getOooClientId(); + if (!clientId) return null; + const tenantId = await getOooTenantId(); + + const tokenResp = await oooXhr('POST', `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: window.location.origin }, + body: new URLSearchParams([ + ['grant_type', 'refresh_token'], + ['client_id', clientId], + ['refresh_token', refreshToken], + ['scope', OOO_GRAPH_SCOPES], + ]).toString(), + }); + + if (tokenResp.status < 200 || tokenResp.status >= 300) { + // Expired or revoked β€” drop it so the next acquisition falls back to a popup. + GM_deleteValue('oooRefreshToken'); + GM_deleteValue('oooRefreshTokenExpiry'); + return null; + } + + storeOooTokens(JSON.parse(tokenResp.responseText), /* isFreshLogin= */ false); + return oooAccessToken; + } + + function getOooGraphAccessToken() { + // In-memory cache (fastest path, valid for current page session). + if (oooAccessToken && oooAccessTokenExpiry && new Date() < oooAccessTokenExpiry) { + return oooAccessToken; + } + // GM_setValue cache β€” survives SPA page navigation, avoids a popup on every page load. + const savedToken = GM_getValue('oooAccessToken', ''); + const savedExpiry = GM_getValue('oooAccessTokenExpiry', 0); + if (savedToken && Date.now() < savedExpiry) { + oooAccessToken = savedToken; + oooAccessTokenExpiry = new Date(savedExpiry); + return oooAccessToken; + } + // No valid cached access token. Acquire one, coalescing all concurrent callers (e.g. every + // reviewer on the page) onto a single attempt so we never fire duplicate single-use refresh + // requests or open multiple popups. Try the refresh token first (a background POST, no popup); + // only if that fails fall back to a silent popup, at most once per page session. + if (!oooAuthPromise && getOooClientId()) { + oooAuthPromise = (async () => { + const refreshed = await refreshOooAccessToken(); + if (refreshed) return refreshed; + + if (oooSilentAuthAttempted) return null; + oooSilentAuthAttempted = true; + return acquireOooTokenInteractive(/* silent= */ true).catch(() => null); + })().finally(() => { oooAuthPromise = null; }); + } + return oooAuthPromise; + } + + // Resolves an email to the user's AAD object ID (originId), which Graph's /presence endpoint + // requires. Neither the reviewer's identity.id nor its "aad." descriptor is the AAD object ID + // (they're AZDO-internal IMS/storage GUIDs), and the object ID isn't present anywhere on the page. + // AZDO's Identity Picker is served same-origin, so it authenticates via the existing session with + // no extra Microsoft Graph permission or admin consent. + async function resolveOooAadObjectId(email) { + const response = await fetch(`${azdoApiBaseUrl}_apis/IdentityPicker/Identities?api-version=5.0-preview.1`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + query: email, + identityTypes: ['user'], + operationScopes: ['ims', 'source'], + properties: ['DisplayName', 'Mail'], + options: { MinResults: 1, MaxResults: 20 }, + }), + }); + if (!response.ok) return null; + const data = await response.json(); + const identities = (data.results && data.results[0] && data.results[0].identities) || []; + const wanted = email.toLowerCase(); + const match = identities.find(i => (i.mail || i.signInAddress || '').toLowerCase() === wanted); + return (match && match.originId) || null; + } + + // Graph's /presence endpoint requires the user's AAD object ID (GUID), resolved from their email. + async function fetchOooForEmail(email) { + const cacheKey = `azdo-userscripts-ooo-${email.replace(/[^a-z0-9]/gi, '_')}`; + const cacheDurationMs = 2 * 60 * 60 * 1000; // 2 hours + + try { + const cached = JSON.parse(localStorage.getItem(cacheKey)); + if (cached && Date.now() < cached.expiry) return cached.data; + } catch (e) { /* ignore */ } + + const token = await getOooGraphAccessToken(); + if (!token) return null; + + const userGuid = await resolveOooAadObjectId(email); + if (!userGuid) return null; + + try { + const graphResp = await oooXhr( + 'GET', + `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userGuid)}/presence`, + { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } }, + ); + + let oooData = null; + + if (graphResp.status >= 200 && graphResp.status < 300) { + const presence = JSON.parse(graphResp.responseText); + const oof = presence.outOfOfficeSettings; + if (oof && oof.isOutOfOffice) { + oooData = { Text: oof.message || '' }; + } + localStorage.setItem(cacheKey, JSON.stringify({ expiry: Date.now() + cacheDurationMs, data: oooData })); + } else if (graphResp.status === 401) { + // Token rejected; clear both in-memory and GM storage so the next call re-authenticates. + oooAccessToken = null; + oooAccessTokenExpiry = null; + GM_deleteValue('oooAccessToken'); + GM_deleteValue('oooAccessTokenExpiry'); + } + + return oooData; + } catch (e) { + error(`OOO lookup failed for ${email}:`, e); + return null; + } + } + function watchForPipelinesPage(session, pageData) { addStyleOnce('agent-css', /* css */ ` .pipeline-status-icon { @@ -662,26 +1090,6 @@ const dataMaxAgeInDays = 3; - let oooInfo; - let oooByEmail; - try { - oooInfo = await fetchJsonAndCache('outOfOfficeLatest', 12 * 60 * 60, `${azdoApiBaseUrl}/_apis/git/repositories/3378df6b-8fc9-41dd-a9d9-16640f2392cb/items?api-version=6.0&path=/data/outOfOfficeLatest.json&version=main`); - if (oooInfo.version === 1) { - const dataDate = dateFns.parse(oooInfo.date); - if (dateFns.differenceInDays(new Date(), dataDate) >= dataMaxAgeInDays) { - // This data is too old. It hasn't been updated properly by the pipeline producing it. Avoid annotating. - throw new Error(`Data is too old (must be ${dataMaxAgeInDays} days old or less). Data date is: ${dataDate.toISOString()}`); - } - - oooByEmail = _.keyBy(oooInfo.value, 'Email'); - } else { - throw new Error(`Invalid version: ${oooInfo.version}`); - } - } catch (e) { - oooInfo = null; - error(`Cannot annotate out-of-office info on PRs: ${e}`); - } - let employeeInfo; let employeeByEmail; let me; @@ -787,32 +1195,22 @@ } } - if (oooInfo) { - const ooo = oooByEmail[email]; - if (ooo && dateFns.isFuture(ooo.End)) { - let label; + // OOO lookup via Microsoft Graph API – fires asynchronously so it doesn't block other annotations. + // The reviewer's AAD object ID is resolved from their email inside fetchOooForEmail. + fetchOooForEmail(email).then(ooo => { + if (!ooo) return; - if (dateFns.isFuture(ooo.Start)) { - if (dateFns.differenceInHours(ooo.Start, new Date()) <= 24) { - label = 'Unavailable in <24h'; - } else { - // Don't show a label. This person is leaving too far into the future. - label = null; - } - } else { - label = `Returns in ${dateFns.distanceInWordsToNow(ooo.End)}`; - } + // Strip HTML from the OOO message for safe display. DOMParser does not + // execute scripts or fire resource-loading events (unlike innerHTML on a live node). + const oooText = new DOMParser().parseFromString(ooo.Text, 'text/html').body.textContent || ''; - if (label) { - const tooltipHtml = ` -

Outlook Auto Response

-

Leaving ${dateFns.format(ooo.Start, 'ha ddd, MMM D, YYYY')}
Returning ${dateFns.format(ooo.End, 'ha ddd, MMM D, YYYY')}

-

${ooo.Text.replace(/\r?\n/ig, '

')}

`; + const tooltipHtml = oooText + ? `

This user is out of office:

+

${escapeStringForHtml(oooText).replace(/\r?\n/ig, '

')}

` + : '

This user is out of office but has not set an auto-reply message.

'; - annotateReviewer(nameElement, 'ooo', escapeStringForHtml(label), tooltipHtml); - } - } - } + annotateReviewer(nameElement, 'ooo', 'Out of Office', tooltipHtml); + }).catch(() => {}); // Silently ignore (e.g. Graph not configured, user not found) }); }