1/** 2 * Microsoft 365 Authentication â MSAL v5.5 (clean build). 3 * 4 * Uses the UMD build loaded via <script> tag (window.msal). 5 * Single PublicClientApplication instance, popup auth flow, 6 * incremental consent via per-feature scopes. 7 */ 8 9import { 10 getClientId, buildMsalConfig, loginScopes, allScopes, getPopupRedirectUri, 11} from './msal-config.js'; 12import { connectedServicesApi } from '../api/connected-services.js'; 13 14const { PublicClientApplication, InteractionRequiredAuthError, BrowserAuthError } = window.msal; 15 16let msalClient = null; 17let _initialized = false; 18let _account = null; 19 20export const msGraph = { 21 22 /** 23 * Initialize MSAL client. Safe to call multiple times. 24 * @returns {boolean} true if ready, false if MS 365 not configured for this tenant 25 */ 26 async init() { 27 if (_initialized && msalClient) return true; 28 29 const clientId = getClientId(); 30 if (!clientId) { 31 console.warn('[MS Graph] No client ID â Microsoft 365 not configured for this tenant'); 32 return false; 33 } 34 35 try { 36 msalClient = new PublicClientApplication(buildMsalConfig(clientId)); 37 await msalClient.initialize(); 38 39 // Process any stale redirect state (insurance for popup edge cases) 40 await msalClient.handleRedirectPromise(); 41 42 _initialized = true; 43 44 // Restore active account from cache (survives refresh via sessionStorage) 45 const accounts = msalClient.getAllAccounts(); 46 if (accounts.length > 0) { 47 _account = accounts[0]; 48 msalClient.setActiveAccount(_account); 49 } 50 51 return true; 52 } catch (err) { 53 console.error('[MS Graph] MSAL init failed:', err); 54 return false; 55 } 56 }, 57 58 /** 59 * Sign in via popup. Returns the user profile or null. 60 */ 61 async signIn() { 62 if (!await this.init()) return null; 63 64 try { 65 const result = await msalClient.loginPopup({ 66 scopes: loginScopes, 67 prompt: 'select_account', 68 redirectUri: getPopupRedirectUri(), 69 }); 70 71 _account = result.account; 72 msalClient.setActiveAccount(_account); 73 74 // Fetch profile from Graph 75 const user = await this._fetchUserProfile(); 76 77 // Save connection to SmartBuild backend 78 await connectedServicesApi.connectMicrosoft({ 79 serviceId: user.id, 80 principalName: user.userPrincipalName, 81 }); 82 83 return user; 84 } catch (err) { 85 if (err instanceof BrowserAuthError) { 86 if (err.errorCode === 'user_cancelled') return null; 87 if (err.errorCode === 'popup_window_error') { 88 throw new Error('Popup blocked â please allow popups for this site and try again'); 89 } 90 if (err.errorCode === 'interaction_in_progress') { 91 throw new Error('Another sign-in is already in progress'); 92 } 93 } 94 console.error('[MS Graph] Sign-in failed:', err); 95 throw err; 96 } 97 }, 98 99 /** 100 * Sign out â clear MSAL session + backend connection. 101 */ 102 async signOut() { 103 try { 104 if (msalClient && _account) { 105 await msalClient.logoutPopup({ 106 account: _account, 107 postLogoutRedirectUri: getPopupRedirectUri(), 108 }); 109 } 110 } catch (err) { 111 console.warn('[MS Graph] Popup logout failed, clearing locally:', err); 112 } 113 114 // Always clean up local state 115 _account = null; 116 if (msalClient) msalClient.setActiveAccount(null); 117 118 try { 119 await connectedServicesApi.disconnectMicrosoft(); 120 } catch { 121 // Best effort â don't break the UI 122 } 123 }, 124 125 /** 126 * Get an access token (silent first, popup fallback). 127 * Supports incremental consent â pass specific scopes per feature. 128 * @param {string[]} [scopes] â Defaults to allScopes 129 * @returns {string|null} Access token or null if not connected 130 */ 131 async getToken(scopes) { 132 if (!_initialized || !msalClient) return null; 133 134 // Resolve account from MSAL cache 135 if (!_account) { 136 _account = msalClient.getActiveAccount() || msalClient.getAllAccounts()[0] || null; 137 if (!_account) return null; 138 } 139 140 const requestScopes = scopes || allScopes; 141 142 try { 143 const result = await msalClient.acquireTokenSilent({ 144 scopes: requestScopes, 145 account: _account, 146 }); 147 return result.accessToken; 148 } catch (silentError) { 149 if (silentError instanceof InteractionRequiredAuthError) { 150 try { 151 const result = await msalClient.acquireTokenPopup({ 152 scopes: requestScopes, 153 account: _account, 154 redirectUri: getPopupRedirectUri(), 155 }); 156 return result.accessToken; 157 } catch (popupError) { 158 if (popupError instanceof BrowserAuthError) { 159 if (popupError.errorCode === 'user_cancelled') return null; 160 if (popupError.errorCode === 'popup_window_error') { 161 console.error('[MS Graph] Popup blocked during token acquisition'); 162 return null; 163 } 164 } 165 console.error('[MS Graph] Token popup failed:', popupError); 166 return null; 167 } 168 } 169 170 console.warn('[MS Graph] Silent token failed:', silentError.message); 171 return null; 172 } 173 }, 174 175 /** 176 * Get an access token SILENTLY â never shows a popup. 177 * 178 * Use this for anything the user didn't just click: background/event-driven work such as 179 * fetching a Teams message after a SignalR push, or renewing a Graph subscription on app 180 * load. getToken() falls back to acquireTokenPopup(), which would throw a login dialog at 181 * an unattended browser; callers here must degrade gracefully on null instead. 182 * 183 * @param {string[]} [scopes] â Defaults to allScopes 184 * @returns {Promise<string|null>} Access token, or null if it can't be had without interaction 185 */ 186 async getTokenSilent(scopes) { 187 if (!_initialized || !msalClient) return null; 188 189 if (!_account) { 190 _account = msalClient.getActiveAccount() || msalClient.getAllAccounts()[0] || null; 191 if (!_account) return null; 192 } 193 194 try { 195 const result = await msalClient.acquireTokenSilent({ 196 scopes: scopes || allScopes, 197 account: _account, 198 }); 199 return result.accessToken; 200 } catch (silentError) { 201 // Includes InteractionRequiredAuthError â deliberately NOT escalated to a popup. 202 console.warn('[MS Graph] Silent-only token failed:', silentError.message); 203 return null; 204 } 205 }, 206 207 /** 208 * Check if connected to Microsoft 365. 209 * No popups â checks local MSAL cache only. 210 * @returns {{ connected: boolean, email: string|null }} 211 */ 212 async checkConnection() { 213 await this.init(); 214 215 if (_initialized && msalClient) { 216 const account = msalClient.getActiveAccount() || msalClient.getAllAccounts()[0]; 217 if (account) { 218 _account = account; 219 return { connected: true, email: account.username }; 220 } 221 } 222 223 return { connected: false, email: null }; 224 }, 225 226 /** 227 * Get the connected email address (quick, no API call). 228 */ 229 getConnectedEmail() { 230 return _account?.username 231 || msalClient?.getActiveAccount()?.username 232 || null; 233 }, 234 235 /** 236 * Get the signed-in account's Entra tenant id (quick, no API call). 237 * Needed for the `teamworkUserIdentity` body Graph wants on chat read-state writes â 238 * see teams.markChatReadForUser. 239 */ 240 getTenantId() { 241 return _account?.tenantId 242 || msalClient?.getActiveAccount()?.tenantId 243 || null; 244 }, 245 246 /** 247 * Whether MSAL is initialized and a client ID is available. 248 */ 249 isAvailable() { 250 return _initialized && msalClient !== null; 251 }, 252 253 // âââ Internal ââââââââââââââââââââââââââââââââââââââââ 254 255 async _fetchUserProfile() { 256 const { graphGet } = await import('./graph-client.js'); 257 return graphGet('/me?$select=id,displayName,mail,userPrincipalName'); 258 }, 259};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.