From 7918a90748f1d97c2cfd9af33bf0303b8910aff8 Mon Sep 17 00:00:00 2001 From: Kyle Roeschley Date: Mon, 13 Jul 2026 12:56:57 -0400 Subject: [PATCH 01/14] Copilot: fetch OOO data directly --- src/azdo-pr-dashboard.user.js | 458 ++++++++++++++++++++++++++++++---- 1 file changed, 413 insertions(+), 45 deletions(-) diff --git a/src/azdo-pr-dashboard.user.js b/src/azdo-pr-dashboard.user.js index 0da5872..a4373de 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,12 +37,36 @@ // @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 }); @@ -76,14 +100,69 @@ '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', () => { + const current = GM_getValue('oooGraphClientId', ''); + // eslint-disable-next-line no-alert + const clientId = prompt( + 'Enter your Azure AD Application (Client) ID to enable live OOO lookup.\n\n' + + 'One-time Azure AD setup:\n' + + ' 1. portal.azure.com β†’ Azure Active Directory β†’ App registrations β†’ New registration\n' + + ` 2. Redirect URIs β†’ Single-page application (SPA): add ${_oooGetRedirectUri()}\n` + + ' 3. API Permissions: Microsoft Graph β†’ Delegated β†’ Presence.Read.All\n' + + ' 4. Paste the Application (Client) ID below.\n\n' + + 'Leave blank to disable OOO lookup.', + current, + ); + if (clientId !== null) { + GM_setValue('oooGraphClientId', clientId.trim()); + _oooAccessToken = null; + _oooAccessTokenExpiry = null; + GM_deleteValue('oooAccessToken'); + GM_deleteValue('oooAccessTokenExpiry'); + GM_deleteValue('oooRefreshToken'); // clean up any legacy refresh tokens + swal.fire({ + icon: clientId.trim() ? 'success' : 'info', + title: clientId.trim() ? 'Client ID saved' : 'OOO lookup disabled', + text: clientId.trim() ? 'Use "OOO Lookup: Authenticate" from the menu to sign in.' : 'OOO annotations will not appear.', + }); + } + }); + + GM_registerMenuCommand('OOO Lookup: Authenticate', async () => { + if (!_oooGetClientId()) { + swal.fire({ icon: 'warning', title: 'Not configured', text: 'Set the Graph Client ID first via the Tampermonkey menu.' }); + return; + } + try { + await _oooAcquireTokenInteractive(); + 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(_oooGetClientId())}` + + `&redirect_uri=${encodeURIComponent(_oooGetRedirectUri())}`; + 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 +292,308 @@ return value; } + // ===== OOO Lookup via Microsoft Graph API ===== + // Looks up each reviewer's out-of-office status live at page load time. + // Requires a one-time Azure AD app registration with Presence.Read.All (delegated) + // and a SPA redirect URI of https://dev.azure.com/. Configure via Tampermonkey menu. + + const OOO_GRAPH_SCOPES = 'https://graph.microsoft.com/Presence.Read.All'; + const OOO_STATE_PREFIX = 'azdo-ooo-'; + + let _oooAccessToken = null; + let _oooAccessTokenExpiry = null; + let _oooSilentAuthAttempted = false; // one silent-auth attempt per page session + + function _oooGetClientId() { + return GM_getValue('oooGraphClientId', ''); + } + + function _oooGetRedirectUri() { + // 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 _oooRandom(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 _oooPkceChallenge(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 _oooGetTenantId() { + 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 _oooAcquireTokenInteractive(silent = false) { + const clientId = _oooGetClientId(); + if (!clientId) throw new Error('OOO Graph Client ID not configured. Use the Tampermonkey menu.'); + + const tenantId = await _oooGetTenantId(); + const verifier = _oooRandom(); + const challenge = await _oooPkceChallenge(verifier); + const state = OOO_STATE_PREFIX + _oooRandom(16); + const redirectUri = _oooGetRedirectUri(); + + 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; } + + // eslint-disable-next-line no-console + console.log('[azdo-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}`); + // eslint-disable-next-line no-console + console.log('[azdo-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. + // eslint-disable-next-line no-console + console.log('[azdo-ooo] Starting auth. Tenant:', tenantId, 'Redirect URI:', redirectUri); + let code; + try { + code = await openAuthPopup('none'); + } catch (e) { + // eslint-disable-next-line no-console + console.log('[azdo-ooo] Silent 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, + redirect_uri: redirectUri, + code_verifier: storedVerifier, + scope: OOO_GRAPH_SCOPES, + }).toString(), + }); + + if (tokenResp.status < 200 || tokenResp.status >= 300) { + const errData = JSON.parse(tokenResp.responseText || '{}'); + // eslint-disable-next-line no-console + console.error('[azdo-ooo] Token exchange failed. Status:', tokenResp.status, 'Body:', tokenResp.responseText); + throw new Error(errData.error_description || errData.error || `Token exchange failed: HTTP ${tokenResp.status}`); + } + + // eslint-disable-next-line no-console + console.log('[azdo-ooo] Token exchange succeeded. Access token obtained.'); + const tokenData = JSON.parse(tokenResp.responseText); + _oooAccessToken = tokenData.access_token; + _oooAccessTokenExpiry = new Date(Date.now() + (tokenData.expires_in - 300) * 1000); + // Cache in GM storage so the token survives page navigations (no refresh token needed). + GM_setValue('oooAccessToken', _oooAccessToken); + GM_setValue('oooAccessTokenExpiry', _oooAccessTokenExpiry.getTime()); + return _oooAccessToken; + } + + async 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; + } + // One silent (prompt=none) auth attempt per page session. Works automatically once + // admin consent has been granted; silently does nothing if consent is still pending. + if (!_oooSilentAuthAttempted && _oooGetClientId()) { + _oooSilentAuthAttempted = true; + return _oooAcquireTokenInteractive(/* silent= */ true).catch(() => null); + } + return null; + } + + // 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 _oooResolveAadObjectId(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 _oooResolveAadObjectId(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) { + // Presence API doesn't expose start/end dates; treat as currently active OOO. + oooData = { + Start: new Date(Date.now() - 86400000).toISOString(), + End: new Date(Date.now() + 365 * 86400000).toISOString(), + Text: oof.message || '', + alwaysEnabled: true, + }; + } + } 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'); + } + + localStorage.setItem(cacheKey, JSON.stringify({ expiry: Date.now() + cacheDurationMs, data: oooData })); + 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 +1043,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 +1148,39 @@ } } - 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 || !dateFns.isFuture(ooo.End)) 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; - } + let label; + if (ooo.alwaysEnabled) { + label = 'Auto-Reply On'; + } else if (dateFns.isFuture(ooo.Start)) { + if (dateFns.differenceInHours(ooo.Start, new Date()) <= 24) { + label = 'Unavailable in <24h'; } else { - label = `Returns in ${dateFns.distanceInWordsToNow(ooo.End)}`; + // Don't show a label. This person is leaving too far into the future. + label = null; } + } else { + label = `Returns in ${dateFns.distanceInWordsToNow(ooo.End)}`; + } - 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, '

')}

`; + if (label) { + // Strip HTML from Outlook auto-reply message for safe display. + const oooMsgDiv = document.createElement('div'); + oooMsgDiv.innerHTML = ooo.Text; + const oooText = oooMsgDiv.textContent || ''; - annotateReviewer(nameElement, 'ooo', escapeStringForHtml(label), tooltipHtml); - } + const tooltipHtml = ` +

Outlook Auto Response

+ ${!ooo.alwaysEnabled ? `

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

` : ''} +

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

')}

`; + + annotateReviewer(nameElement, 'ooo', escapeStringForHtml(label), tooltipHtml); } - } + }).catch(() => {}); // Silently ignore (e.g. Graph not configured, user not found) }); } From fd776e725a185b7b7e56bd9bdf1c53f99326c7f4 Mon Sep 17 00:00:00 2001 From: Kyle Roeschley Date: Mon, 13 Jul 2026 14:47:25 -0400 Subject: [PATCH 02/14] Drop OOO start/end because we don't get that anymore --- src/azdo-pr-dashboard.user.js | 43 ++++++++--------------------------- 1 file changed, 10 insertions(+), 33 deletions(-) diff --git a/src/azdo-pr-dashboard.user.js b/src/azdo-pr-dashboard.user.js index a4373de..eb59af5 100644 --- a/src/azdo-pr-dashboard.user.js +++ b/src/azdo-pr-dashboard.user.js @@ -570,13 +570,7 @@ const presence = JSON.parse(graphResp.responseText); const oof = presence.outOfOfficeSettings; if (oof && oof.isOutOfOffice) { - // Presence API doesn't expose start/end dates; treat as currently active OOO. - oooData = { - Start: new Date(Date.now() - 86400000).toISOString(), - End: new Date(Date.now() + 365 * 86400000).toISOString(), - Text: oof.message || '', - alwaysEnabled: true, - }; + oooData = { Text: oof.message || '' }; } } else if (graphResp.status === 401) { // Token rejected; clear both in-memory and GM storage so the next call re-authenticates. @@ -1151,35 +1145,18 @@ // 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 || !dateFns.isFuture(ooo.End)) return; - - let label; - if (ooo.alwaysEnabled) { - label = 'Auto-Reply On'; - } else 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)}`; - } + if (!ooo) return; - if (label) { - // Strip HTML from Outlook auto-reply message for safe display. - const oooMsgDiv = document.createElement('div'); - oooMsgDiv.innerHTML = ooo.Text; - const oooText = oooMsgDiv.textContent || ''; + // Strip HTML from Outlook auto-reply message for safe display. + const oooMsgDiv = document.createElement('div'); + oooMsgDiv.innerHTML = ooo.Text; + const oooText = oooMsgDiv.textContent || ''; - const tooltipHtml = ` -

Outlook Auto Response

- ${!ooo.alwaysEnabled ? `

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

` : ''} -

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

')}

`; + const tooltipHtml = ` +

Outlook Auto Response

+

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

')}

`; - annotateReviewer(nameElement, 'ooo', escapeStringForHtml(label), tooltipHtml); - } + annotateReviewer(nameElement, 'ooo', 'Auto-Reply On', tooltipHtml); }).catch(() => {}); // Silently ignore (e.g. Graph not configured, user not found) }); } From 13141eaff8810718507d145470298a6746af96c8 Mon Sep 17 00:00:00 2001 From: Kyle Roeschley Date: Mon, 13 Jul 2026 15:05:06 -0400 Subject: [PATCH 03/14] Change annotation from "Auto-Reply On" to "Out of Office" --- src/azdo-pr-dashboard.user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/azdo-pr-dashboard.user.js b/src/azdo-pr-dashboard.user.js index eb59af5..b47016f 100644 --- a/src/azdo-pr-dashboard.user.js +++ b/src/azdo-pr-dashboard.user.js @@ -1156,7 +1156,7 @@

Outlook Auto Response

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

')}

`; - annotateReviewer(nameElement, 'ooo', 'Auto-Reply On', tooltipHtml); + annotateReviewer(nameElement, 'ooo', 'Out of Office', tooltipHtml); }).catch(() => {}); // Silently ignore (e.g. Graph not configured, user not found) }); } From f970e78f88141f5751dcb1db02d68d7c062070da Mon Sep 17 00:00:00 2001 From: Kyle Roeschley Date: Mon, 13 Jul 2026 15:05:30 -0400 Subject: [PATCH 04/14] Clarify that we now have OOO info, not Outlook auto-reply --- src/azdo-pr-dashboard.user.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/azdo-pr-dashboard.user.js b/src/azdo-pr-dashboard.user.js index b47016f..10d282b 100644 --- a/src/azdo-pr-dashboard.user.js +++ b/src/azdo-pr-dashboard.user.js @@ -1147,14 +1147,15 @@ fetchOooForEmail(email).then(ooo => { if (!ooo) return; - // Strip HTML from Outlook auto-reply message for safe display. + // Strip HTML from the OOO message for safe display. const oooMsgDiv = document.createElement('div'); oooMsgDiv.innerHTML = ooo.Text; const oooText = oooMsgDiv.textContent || ''; - const tooltipHtml = ` -

Outlook Auto Response

-

${escapeStringForHtml(oooText).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', 'Out of Office', tooltipHtml); }).catch(() => {}); // Silently ignore (e.g. Graph not configured, user not found) From fc7c5173344642229baff304c7858d554b1aaae1 Mon Sep 17 00:00:00 2001 From: Kyle Roeschley Date: Mon, 13 Jul 2026 15:22:51 -0400 Subject: [PATCH 05/14] Merge auth into client ID configure menu item --- src/azdo-pr-dashboard.user.js | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/azdo-pr-dashboard.user.js b/src/azdo-pr-dashboard.user.js index 10d282b..7b0d9bf 100644 --- a/src/azdo-pr-dashboard.user.js +++ b/src/azdo-pr-dashboard.user.js @@ -109,7 +109,7 @@

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

`); - GM_registerMenuCommand('OOO Lookup: Configure Graph Client ID', () => { + GM_registerMenuCommand('OOO Lookup: Configure Graph Client ID', async () => { const current = GM_getValue('oooGraphClientId', ''); // eslint-disable-next-line no-alert const clientId = prompt( @@ -122,26 +122,23 @@ + 'Leave blank to disable OOO lookup.', current, ); - if (clientId !== null) { - GM_setValue('oooGraphClientId', clientId.trim()); - _oooAccessToken = null; - _oooAccessTokenExpiry = null; - GM_deleteValue('oooAccessToken'); - GM_deleteValue('oooAccessTokenExpiry'); - GM_deleteValue('oooRefreshToken'); // clean up any legacy refresh tokens - swal.fire({ - icon: clientId.trim() ? 'success' : 'info', - title: clientId.trim() ? 'Client ID saved' : 'OOO lookup disabled', - text: clientId.trim() ? 'Use "OOO Lookup: Authenticate" from the menu to sign in.' : 'OOO annotations will not appear.', - }); + if (clientId === null) { + return; } - }); + GM_setValue('oooGraphClientId', clientId.trim()); + _oooAccessToken = null; + _oooAccessTokenExpiry = null; + GM_deleteValue('oooAccessToken'); + GM_deleteValue('oooAccessTokenExpiry'); + GM_deleteValue('oooRefreshToken'); // clean up any legacy refresh tokens - GM_registerMenuCommand('OOO Lookup: Authenticate', async () => { - if (!_oooGetClientId()) { - swal.fire({ icon: 'warning', title: 'Not configured', text: 'Set the Graph Client ID first via the Tampermonkey menu.' }); + if (!clientId.trim()) { + swal.fire({ icon: 'info', title: 'OOO lookup disabled', text: 'OOO annotations will not appear.' }); return; } + + // 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 _oooAcquireTokenInteractive(); swal.fire({ icon: 'success', title: 'OOO Lookup Active', text: 'Reload a PR page to see live OOO annotations.' }); From 298c3f0f3fa9b64618439310ce63c3eec9afc6c6 Mon Sep 17 00:00:00 2001 From: Kyle Roeschley Date: Mon, 13 Jul 2026 15:53:04 -0400 Subject: [PATCH 06/14] Default to Emerson T&M client ID so OOO lookup works out of the box --- src/azdo-pr-dashboard.user.js | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/azdo-pr-dashboard.user.js b/src/azdo-pr-dashboard.user.js index 7b0d9bf..3f0f682 100644 --- a/src/azdo-pr-dashboard.user.js +++ b/src/azdo-pr-dashboard.user.js @@ -104,7 +104,7 @@

Highlights from the 2026-07-21 update!

  • OOO info is now looked up live per-reviewer via Microsoft Graph (no more stale data!)
  • -
  • Use Tampermonkey menu β†’ OOO Lookup: Configure Graph Client ID to set up
  • +
  • For non-NI/Emerson users, use script menu β†’ OOO Lookup: Configure Graph Client ID to set up

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

`); @@ -113,13 +113,12 @@ const current = GM_getValue('oooGraphClientId', ''); // eslint-disable-next-line no-alert const clientId = prompt( - 'Enter your Azure AD Application (Client) ID to enable live OOO lookup.\n\n' - + 'One-time Azure AD setup:\n' - + ' 1. portal.azure.com β†’ Azure Active Directory β†’ App registrations β†’ New registration\n' - + ` 2. Redirect URIs β†’ Single-page application (SPA): add ${_oooGetRedirectUri()}\n` - + ' 3. API Permissions: Microsoft Graph β†’ Delegated β†’ Presence.Read.All\n' - + ' 4. Paste the Application (Client) ID below.\n\n' - + 'Leave blank to disable OOO lookup.', + '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 ' + + `${_oooGetRedirectUri()}, delegated Presence.Read.All).\n\n` + + 'Leave blank to use the shared default.', current, ); if (clientId === null) { @@ -132,11 +131,6 @@ GM_deleteValue('oooAccessTokenExpiry'); GM_deleteValue('oooRefreshToken'); // clean up any legacy refresh tokens - if (!clientId.trim()) { - swal.fire({ icon: 'info', title: 'OOO lookup disabled', text: 'OOO annotations will not appear.' }); - return; - } - // Sign in immediately so the user consents once and sees any admin-approval message; // after this, silent auth keeps the token fresh automatically. try { @@ -291,18 +285,24 @@ // ===== OOO Lookup via Microsoft Graph API ===== // Looks up each reviewer's out-of-office status live at page load time. - // Requires a one-time Azure AD app registration with Presence.Read.All (delegated) - // and a SPA redirect URI of https://dev.azure.com/. Configure via Tampermonkey menu. + // 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. const OOO_GRAPH_SCOPES = 'https://graph.microsoft.com/Presence.Read.All'; 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'; let _oooAccessToken = null; let _oooAccessTokenExpiry = null; let _oooSilentAuthAttempted = false; // one silent-auth attempt per page session function _oooGetClientId() { - return GM_getValue('oooGraphClientId', ''); + // A per-user override (via the Tampermonkey menu) takes precedence over the shared default. + return GM_getValue('oooGraphClientId', '') || OOO_DEFAULT_GRAPH_CLIENT_ID; } function _oooGetRedirectUri() { From eb57ba580cf00b102bf025357b55e227e647a1f5 Mon Sep 17 00:00:00 2001 From: Kyle Roeschley Date: Mon, 13 Jul 2026 15:53:29 -0400 Subject: [PATCH 07/14] Document OOO annotation --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 9043140..9299eb4 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. +- **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. From 4885f7682b42647b9e13bab3cc1c46718fcf7711 Mon Sep 17 00:00:00 2001 From: Kyle Roeschley Date: Mon, 13 Jul 2026 16:30:13 -0400 Subject: [PATCH 08/14] Use DOMParser instead of innerHTML to strip OOO message HTML Assigning untrusted HTML to innerHTML (even on a detached node) parses it and fires resource-loading events (e.g. ). DOMParser gives the same tag-stripping without ever creating live elements. --- src/azdo-pr-dashboard.user.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/azdo-pr-dashboard.user.js b/src/azdo-pr-dashboard.user.js index 3f0f682..c8e47c5 100644 --- a/src/azdo-pr-dashboard.user.js +++ b/src/azdo-pr-dashboard.user.js @@ -1144,10 +1144,9 @@ fetchOooForEmail(email).then(ooo => { if (!ooo) return; - // Strip HTML from the OOO message for safe display. - const oooMsgDiv = document.createElement('div'); - oooMsgDiv.innerHTML = ooo.Text; - const oooText = oooMsgDiv.textContent || ''; + // 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 || ''; const tooltipHtml = oooText ? `

This user is out of office:

From 950a085e0ada524cb59cc44d31e4c26f003c78c9 Mon Sep 17 00:00:00 2001 From: Kyle Roeschley Date: Mon, 13 Jul 2026 17:09:51 -0400 Subject: [PATCH 09/14] Coalesce concurrent silent-auth attempts onto one shared promise Each reviewer on a PR calls getOooGraphAccessToken concurrently. The old boolean flag was set before the token resolved, so only the first reviewer triggered auth while the rest got null and showed no annotation until a reload found the cached token. Caching the in-flight promise lets every reviewer resolve from a single attempt, while still limiting it to one attempt per page session (a resolved-null promise does not retry). --- src/azdo-pr-dashboard.user.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/azdo-pr-dashboard.user.js b/src/azdo-pr-dashboard.user.js index c8e47c5..8227eeb 100644 --- a/src/azdo-pr-dashboard.user.js +++ b/src/azdo-pr-dashboard.user.js @@ -298,7 +298,7 @@ let _oooAccessToken = null; let _oooAccessTokenExpiry = null; - let _oooSilentAuthAttempted = false; // one silent-auth attempt per page session + let _oooSilentAuthPromise = null; // shared silent-auth attempt (one per page session), coalesced across callers function _oooGetClientId() { // A per-user override (via the Tampermonkey menu) takes precedence over the shared default. @@ -504,13 +504,14 @@ _oooAccessTokenExpiry = new Date(savedExpiry); return _oooAccessToken; } - // One silent (prompt=none) auth attempt per page session. Works automatically once - // admin consent has been granted; silently does nothing if consent is still pending. - if (!_oooSilentAuthAttempted && _oooGetClientId()) { - _oooSilentAuthAttempted = true; - return _oooAcquireTokenInteractive(/* silent= */ true).catch(() => null); + // One silent (prompt=none) auth attempt per page session, shared across all callers so every + // reviewer on the page resolves from a single attempt (rather than only the first one + // triggering auth while the rest get null). Works automatically once admin consent has been + // granted; resolves to null (without retrying) if consent is still pending. + if (!_oooSilentAuthPromise && _oooGetClientId()) { + _oooSilentAuthPromise = _oooAcquireTokenInteractive(/* silent= */ true).catch(() => null); } - return null; + return _oooSilentAuthPromise; } // Resolves an email to the user's AAD object ID (originId), which Graph's /presence endpoint From 4c5081e99106888417e51ee5094e8009780eee99 Mon Sep 17 00:00:00 2001 From: Kyle Roeschley Date: Tue, 14 Jul 2026 13:28:30 -0400 Subject: [PATCH 10/14] Restructure OOO token coalescing to reset after each acquisition Separate the concurrency-coalescing from the once-per-session popup guard: _oooAuthPromise now only coalesces concurrent callers and resets when the acquisition settles (via .finally), while a dedicated _oooSilentAuthAttempted boolean keeps the fallback popup to one attempt per page session. This stops the shared promise from pinning the first resolved token for the whole page session and makes room for alternative acquisition paths. --- src/azdo-pr-dashboard.user.js | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/azdo-pr-dashboard.user.js b/src/azdo-pr-dashboard.user.js index 8227eeb..b696e32 100644 --- a/src/azdo-pr-dashboard.user.js +++ b/src/azdo-pr-dashboard.user.js @@ -127,6 +127,8 @@ GM_setValue('oooGraphClientId', clientId.trim()); _oooAccessToken = null; _oooAccessTokenExpiry = null; + _oooAuthPromise = null; + _oooSilentAuthAttempted = false; GM_deleteValue('oooAccessToken'); GM_deleteValue('oooAccessTokenExpiry'); GM_deleteValue('oooRefreshToken'); // clean up any legacy refresh tokens @@ -298,7 +300,8 @@ let _oooAccessToken = null; let _oooAccessTokenExpiry = null; - let _oooSilentAuthPromise = null; // shared silent-auth attempt (one per page session), coalesced across callers + 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 function _oooGetClientId() { // A per-user override (via the Tampermonkey menu) takes precedence over the shared default. @@ -504,14 +507,17 @@ _oooAccessTokenExpiry = new Date(savedExpiry); return _oooAccessToken; } - // One silent (prompt=none) auth attempt per page session, shared across all callers so every - // reviewer on the page resolves from a single attempt (rather than only the first one - // triggering auth while the rest get null). Works automatically once admin consent has been - // granted; resolves to null (without retrying) if consent is still pending. - if (!_oooSilentAuthPromise && _oooGetClientId()) { - _oooSilentAuthPromise = _oooAcquireTokenInteractive(/* silent= */ true).catch(() => null); - } - return _oooSilentAuthPromise; + // No valid cached access token. Acquire one via a silent (prompt=none) popup, coalescing all + // concurrent callers (e.g. every reviewer on the page) onto a single attempt so we don't open + // multiple popups at once, and limiting the fallback popup to one attempt per page session. + if (!_oooAuthPromise && _oooGetClientId()) { + _oooAuthPromise = (async () => { + if (_oooSilentAuthAttempted) return null; + _oooSilentAuthAttempted = true; + return _oooAcquireTokenInteractive(/* 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 From 0d0f6c07a02070cbfa89a3ddc64995ef43b7087f Mon Sep 17 00:00:00 2001 From: Kyle Roeschley Date: Tue, 14 Jul 2026 13:52:41 -0400 Subject: [PATCH 11/14] Use refresh tokens to avoid the auth popup on access-token expiry Request the offline_access scope so AAD returns a refresh token, then mint new access tokens via a background POST (grant_type=refresh_token) instead of a popup. Because the redirect URIs are SPA-type, refresh tokens expire 24h after the original interactive login and that expiry carries over across rotations, so a silent popup is still needed at most once per day rather than ~hourly. - _oooStoreTokens centralizes access/refresh token persistence and stamps the 24h refresh-token expiry only on a fresh login (not on rotation). - _oooRefreshAccessToken does the no-popup refresh and drops the stored refresh token if AAD rejects it, falling back to the silent popup. - The coalesced acquisition now tries the refresh token before the popup. - Reconfiguring the client ID clears the stored refresh token (different app). --- README.md | 2 +- src/azdo-pr-dashboard.user.js | 72 +++++++++++++++++++++++++++++++---- 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9299eb4..7ab3452 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Trophies are awarded for notable PRs and shown in a trophies section on the Over 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. +- **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. diff --git a/src/azdo-pr-dashboard.user.js b/src/azdo-pr-dashboard.user.js index b696e32..5d4c11a 100644 --- a/src/azdo-pr-dashboard.user.js +++ b/src/azdo-pr-dashboard.user.js @@ -117,7 +117,7 @@ + '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 ' - + `${_oooGetRedirectUri()}, delegated Presence.Read.All).\n\n` + + `${_oooGetRedirectUri()}, delegated Presence.Read.All + offline_access).\n\n` + 'Leave blank to use the shared default.', current, ); @@ -131,7 +131,9 @@ _oooSilentAuthAttempted = false; GM_deleteValue('oooAccessToken'); GM_deleteValue('oooAccessTokenExpiry'); - GM_deleteValue('oooRefreshToken'); // clean up any legacy refresh tokens + // 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. @@ -293,7 +295,9 @@ // 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. - const OOO_GRAPH_SCOPES = 'https://graph.microsoft.com/Presence.Read.All'; + // 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'; @@ -485,12 +489,60 @@ // eslint-disable-next-line no-console console.log('[azdo-ooo] Token exchange succeeded. Access token obtained.'); - const tokenData = JSON.parse(tokenResp.responseText); + _oooStoreTokens(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 _oooStoreTokens(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 (no refresh token needed). + // 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 _oooRefreshAccessToken() { + 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 = _oooGetClientId(); + if (!clientId) return null; + const tenantId = await _oooGetTenantId(); + + 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; + } + + _oooStoreTokens(JSON.parse(tokenResp.responseText), /* isFreshLogin= */ false); return _oooAccessToken; } @@ -507,11 +559,15 @@ _oooAccessTokenExpiry = new Date(savedExpiry); return _oooAccessToken; } - // No valid cached access token. Acquire one via a silent (prompt=none) popup, coalescing all - // concurrent callers (e.g. every reviewer on the page) onto a single attempt so we don't open - // multiple popups at once, and limiting the fallback popup to one attempt per page session. + // 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 && _oooGetClientId()) { _oooAuthPromise = (async () => { + const refreshed = await _oooRefreshAccessToken(); + if (refreshed) return refreshed; + if (_oooSilentAuthAttempted) return null; _oooSilentAuthAttempted = true; return _oooAcquireTokenInteractive(/* silent= */ true).catch(() => null); From 6ed730d229533a0631a7b519ba4148d93346aaec Mon Sep 17 00:00:00 2001 From: Kyle Roeschley Date: Tue, 14 Jul 2026 14:17:28 -0400 Subject: [PATCH 12/14] Fix linting errors --- src/azdo-pr-dashboard.user.js | 163 +++++++++++++++++----------------- 1 file changed, 81 insertions(+), 82 deletions(-) diff --git a/src/azdo-pr-dashboard.user.js b/src/azdo-pr-dashboard.user.js index 5d4c11a..9d11752 100644 --- a/src/azdo-pr-dashboard.user.js +++ b/src/azdo-pr-dashboard.user.js @@ -50,16 +50,16 @@ // 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-')) { + 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, + 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); @@ -72,6 +72,10 @@ 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); @@ -117,7 +121,7 @@ + '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 ' - + `${_oooGetRedirectUri()}, delegated Presence.Read.All + offline_access).\n\n` + + `${getOooRedirectUri()}, delegated Presence.Read.All + offline_access).\n\n` + 'Leave blank to use the shared default.', current, ); @@ -125,10 +129,10 @@ return; } GM_setValue('oooGraphClientId', clientId.trim()); - _oooAccessToken = null; - _oooAccessTokenExpiry = null; - _oooAuthPromise = null; - _oooSilentAuthAttempted = false; + 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. @@ -138,14 +142,14 @@ // 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 _oooAcquireTokenInteractive(); + 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(_oooGetClientId())}` - + `&redirect_uri=${encodeURIComponent(_oooGetRedirectUri())}`; + + `?client_id=${encodeURIComponent(getOooClientId())}` + + `&redirect_uri=${encodeURIComponent(getOooRedirectUri())}`; swal.fire({ icon: 'warning', title: 'Admin approval required', @@ -302,17 +306,12 @@ // 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'; - 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 - - function _oooGetClientId() { + 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 _oooGetRedirectUri() { + 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. @@ -323,21 +322,21 @@ return `${window.location.origin}/`; } - function _oooRandom(len = 43) { + 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 _oooPkceChallenge(verifier) { + 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 } = {}) { + function oooXhr(method, url, { headers = {}, body = null } = {}) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method, @@ -354,7 +353,7 @@ // 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 _oooGetTenantId() { + async function getOooTenantId() { const cached = GM_getValue('oooTenantId', ''); if (cached) return cached; @@ -363,7 +362,7 @@ if (!domain) return 'organizations'; try { - const resp = await _oooXhr( + const resp = await oooXhr( 'GET', `https://login.microsoftonline.com/${encodeURIComponent(domain)}/.well-known/openid-configuration`, ); @@ -381,15 +380,15 @@ return 'organizations'; } - async function _oooAcquireTokenInteractive(silent = false) { - const clientId = _oooGetClientId(); + 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 _oooGetTenantId(); - const verifier = _oooRandom(); - const challenge = await _oooPkceChallenge(verifier); - const state = OOO_STATE_PREFIX + _oooRandom(16); - const redirectUri = _oooGetRedirectUri(); + 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); @@ -466,18 +465,18 @@ 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`, { + 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, - redirect_uri: redirectUri, - code_verifier: storedVerifier, - scope: OOO_GRAPH_SCOPES, - }).toString(), + 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) { @@ -489,20 +488,20 @@ // eslint-disable-next-line no-console console.log('[azdo-ooo] Token exchange succeeded. Access token obtained.'); - _oooStoreTokens(JSON.parse(tokenResp.responseText), /* isFreshLogin= */ true); - return _oooAccessToken; + 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 _oooStoreTokens(tokenData, isFreshLogin) { - _oooAccessToken = tokenData.access_token; - _oooAccessTokenExpiry = new Date(Date.now() + (tokenData.expires_in - 300) * 1000); + 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()); + GM_setValue('oooAccessToken', oooAccessToken); + GM_setValue('oooAccessTokenExpiry', oooAccessTokenExpiry.getTime()); if (tokenData.refresh_token) { GM_setValue('oooRefreshToken', tokenData.refresh_token); @@ -515,24 +514,24 @@ // 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 _oooRefreshAccessToken() { + 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 = _oooGetClientId(); + const clientId = getOooClientId(); if (!clientId) return null; - const tenantId = await _oooGetTenantId(); + const tenantId = await getOooTenantId(); - const tokenResp = await _oooXhr('POST', `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, { + 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(), + 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) { @@ -542,38 +541,38 @@ return null; } - _oooStoreTokens(JSON.parse(tokenResp.responseText), /* isFreshLogin= */ false); - return _oooAccessToken; + storeOooTokens(JSON.parse(tokenResp.responseText), /* isFreshLogin= */ false); + return oooAccessToken; } - async function getOooGraphAccessToken() { + function getOooGraphAccessToken() { // In-memory cache (fastest path, valid for current page session). - if (_oooAccessToken && _oooAccessTokenExpiry && new Date() < _oooAccessTokenExpiry) { - return _oooAccessToken; + 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; + 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 && _oooGetClientId()) { - _oooAuthPromise = (async () => { - const refreshed = await _oooRefreshAccessToken(); + if (!oooAuthPromise && getOooClientId()) { + oooAuthPromise = (async () => { + const refreshed = await refreshOooAccessToken(); if (refreshed) return refreshed; - if (_oooSilentAuthAttempted) return null; - _oooSilentAuthAttempted = true; - return _oooAcquireTokenInteractive(/* silent= */ true).catch(() => null); - })().finally(() => { _oooAuthPromise = null; }); + if (oooSilentAuthAttempted) return null; + oooSilentAuthAttempted = true; + return acquireOooTokenInteractive(/* silent= */ true).catch(() => null); + })().finally(() => { oooAuthPromise = null; }); } - return _oooAuthPromise; + return oooAuthPromise; } // Resolves an email to the user's AAD object ID (originId), which Graph's /presence endpoint @@ -581,7 +580,7 @@ // (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 _oooResolveAadObjectId(email) { + 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' }, @@ -614,11 +613,11 @@ const token = await getOooGraphAccessToken(); if (!token) return null; - const userGuid = await _oooResolveAadObjectId(email); + const userGuid = await resolveOooAadObjectId(email); if (!userGuid) return null; try { - const graphResp = await _oooXhr( + const graphResp = await oooXhr( 'GET', `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userGuid)}/presence`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } }, @@ -634,8 +633,8 @@ } } else if (graphResp.status === 401) { // Token rejected; clear both in-memory and GM storage so the next call re-authenticates. - _oooAccessToken = null; - _oooAccessTokenExpiry = null; + oooAccessToken = null; + oooAccessTokenExpiry = null; GM_deleteValue('oooAccessToken'); GM_deleteValue('oooAccessTokenExpiry'); } From 35b3236a2f2b06b1444c1a6c2108ef9bf1c720dd Mon Sep 17 00:00:00 2001 From: Kyle Roeschley Date: Tue, 14 Jul 2026 15:51:24 -0400 Subject: [PATCH 13/14] Use existing logging helpers --- src/azdo-pr-dashboard.user.js | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/azdo-pr-dashboard.user.js b/src/azdo-pr-dashboard.user.js index 9d11752..08e8e1a 100644 --- a/src/azdo-pr-dashboard.user.js +++ b/src/azdo-pr-dashboard.user.js @@ -411,15 +411,13 @@ 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; } - // eslint-disable-next-line no-console - console.log('[azdo-ooo] Auth popup opened. Waiting for redirect back to', redirectUri); + 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}`); - // eslint-disable-next-line no-console - console.log('[azdo-ooo] Auth result received:', result.code ? 'code obtained' : `error: ${result.errorCode}`); + 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; @@ -446,14 +444,12 @@ } // Try silent first (prompt=none); fall back to interactive if AAD requires user interaction. - // eslint-disable-next-line no-console - console.log('[azdo-ooo] Starting auth. Tenant:', tenantId, 'Redirect URI:', redirectUri); + debug('Starting OOO auth. Tenant:', tenantId, 'Redirect URI:', redirectUri); let code; try { code = await openAuthPopup('none'); } catch (e) { - // eslint-disable-next-line no-console - console.log('[azdo-ooo] Silent auth failed, errorCode:', e.code, 'β€” trying interactive'); + 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'); @@ -481,13 +477,11 @@ if (tokenResp.status < 200 || tokenResp.status >= 300) { const errData = JSON.parse(tokenResp.responseText || '{}'); - // eslint-disable-next-line no-console - console.error('[azdo-ooo] Token exchange failed. Status:', tokenResp.status, 'Body:', 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}`); } - // eslint-disable-next-line no-console - console.log('[azdo-ooo] Token exchange succeeded. Access token obtained.'); + debug('OOO token exchange succeeded. Access token obtained.'); storeOooTokens(JSON.parse(tokenResp.responseText), /* isFreshLogin= */ true); return oooAccessToken; } From 31dd13e71eb49b574bc9c76872a7a69c7066d109 Mon Sep 17 00:00:00 2001 From: Kyle Roeschley Date: Tue, 14 Jul 2026 17:22:19 -0400 Subject: [PATCH 14/14] Only cache OOO data from a success response --- src/azdo-pr-dashboard.user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/azdo-pr-dashboard.user.js b/src/azdo-pr-dashboard.user.js index 08e8e1a..6591899 100644 --- a/src/azdo-pr-dashboard.user.js +++ b/src/azdo-pr-dashboard.user.js @@ -625,6 +625,7 @@ 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; @@ -633,7 +634,6 @@ GM_deleteValue('oooAccessTokenExpiry'); } - localStorage.setItem(cacheKey, JSON.stringify({ expiry: Date.now() + cacheDurationMs, data: oooData })); return oooData; } catch (e) { error(`OOO lookup failed for ${email}:`, e);