React PnP

Uses MSAL 2.0 with PKCE (was upgraded from MSAL 1.0 / Implicit Flow in Decemeber 2020).
Uses @azure/msal-browser (2.8.0)
link - github.com/OfficeDev/PnP-OfficeAddins/tree/master/Samples/auth/Office-Add-in-Microsoft-Graph-React

npm install 
npm run build
npm run start
npm run sideload

Opens Excel and adds a new command to the Home tab "Open Add-in"
This displays a task pane with a welcome message and a button to "Connect to Office 365"
Uses displayDialogAsync to display a popup dialog box asking the user to login
Gets a single AccessToken


manifest.xml

<?xml version="1.0" encoding="UTF-8"?> 
<OfficeApp
          xmlns="http://schemas.microsoft.com/office/appforoffice/1.1"
          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0"
          xmlns:ov="http://schemas.microsoft.com/office/taskpaneappversionoverrides"
          xsi:type="TaskPaneApp">
  <Id> Unique GUID </Id>
  <Version>1.0.0.0</Version>
  <ProviderName>Office Developer Education Team</ProviderName>
  <DefaultLocale>en-US</DefaultLocale>
  <DisplayName DefaultValue="Microsoft Graph Data" />
  <Description DefaultValue="Gets an access token to Microsoft Graph and then gets some Graph data."/>
  <IconUrl DefaultValue="https://localhost:3000/assets/Onedrive_Charts_icon_32x32px.png" />
  <HighResolutionIconUrl DefaultValue="https://localhost:3000/assets/hi-res-icon.png"/>
  <AppDomains>
    <AppDomain>AppDomain1</AppDomain>
    <AppDomain>AppDomain2</AppDomain>
    <AppDomain>AppDomain3</AppDomain>
  </AppDomains>
  <Hosts>
    <Host Name="Workbook" />
  </Hosts>
  <DefaultSettings>
    <SourceLocation DefaultValue="https://localhost:3000/index.html" />
  </DefaultSettings>
  <Permissions>ReadWriteDocument</Permissions>
  <VersionOverrides xmlns="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="VersionOverridesV1_0">
    <Hosts>
      <Host xsi:type="Workbook">
        <DesktopFormFactor>
          <GetStarted>
            <Title resid="Contoso.GetStarted.Title"/>
            <Description resid="Contoso.GetStarted.Description"/>
            <LearnMoreUrl resid="Contoso.GetStarted.LearnMoreUrl"/>
          </GetStarted>
          <FunctionFile resid="Contoso.DesktopFunctionFile.Url" />
          <ExtensionPoint xsi:type="PrimaryCommandSurface">
            <OfficeTab id="TabHome">
              <Group id="Contoso.Group1">
                <Label resid="Contoso.Group1Label" />
                <Icon>
                  <bt:Image size="16" resid="Contoso.tpicon_16x16" />
                  <bt:Image size="32" resid="Contoso.tpicon_32x32" />
                  <bt:Image size="80" resid="Contoso.tpicon_80x80" />
                </Icon>
                <Control xsi:type="Button" id="Contoso.TaskpaneButton">
                  <Label resid="Contoso.TaskpaneButton.Label" />
                  <Supertip>
                    <Title resid="Contoso.TaskpaneButton.Label" />
                    <Description resid="Contoso.TaskpaneButton.Tooltip" />
                  </Supertip>
                  <Icon>
                    <bt:Image size="16" resid="Contoso.tpicon_16x16" />
                    <bt:Image size="32" resid="Contoso.tpicon_32x32" />
                    <bt:Image size="80" resid="Contoso.tpicon_80x80" />
                  </Icon>
                  <Action xsi:type="ShowTaskpane">
                    <TaskpaneId>ButtonId1</TaskpaneId>
                    <SourceLocation resid="Contoso.Taskpane.Url" />
                  </Action>
                </Control>
              </Group>
            </OfficeTab>
          </ExtensionPoint>
        </DesktopFormFactor>
      </Host>
    </Hosts>
    <Resources>
      <bt:Images>
        <bt:Image id="Contoso.tpicon_16x16" DefaultValue="https://localhost:3000/assets/Onedrive_Charts_icon_16x16px.png" />
        <bt:Image id="Contoso.tpicon_32x32" DefaultValue="https://localhost:3000/assets/Onedrive_Charts_icon_32x32px.png" />
        <bt:Image id="Contoso.tpicon_80x80" DefaultValue="https://localhost:3000/assets/Onedrive_Charts_icon_80x80px.png" />
      </bt:Images>
      <bt:Urls>
        <bt:Url id="Contoso.Taskpane.Url" DefaultValue="https://localhost:3000/index.html" />
        <bt:Url id="Contoso.GetStarted.LearnMoreUrl" DefaultValue="https://go.microsoft.com/fwlink/?LinkId=276812" />
        <bt:Url id="Contoso.DesktopFunctionFile.Url" DefaultValue="https://localhost:3000/function-file/function-file.html" />
      </bt:Urls>
      <bt:ShortStrings>
        <bt:String id="Contoso.TaskpaneButton.Label" DefaultValue="Open Add-in" />
        <bt:String id="Contoso.Group1Label" DefaultValue="OneDrive Files" />
        <bt:String id="Contoso.GetStarted.Title" DefaultValue="Microsoft Graph data add-in has loaded successfully." />
      </bt:ShortStrings>
      <bt:LongStrings>
        <bt:String id="Contoso.TaskpaneButton.Tooltip" DefaultValue="Get files stored on OneDrive" />
        <bt:String id="Contoso.GetStarted.Description" DefaultValue="Choose Open Add-in, then Connect to Office 365 to get started." />
      </bt:LongStrings>
    </Resources>
  </VersionOverrides>
</OfficeApp>

login/login.ts

import { PublicClientApplication } from "@azure/msal-browser"; 

(() => {
  Office.initialize = () => {

    const msalInstance = new PublicClientApplication({
        auth: {
          clientId: 'YOUR APP ID HERE',
          authority: 'https://login.microsoftonline.com/common',
          redirectUri: 'https://localhost:3000/login/login.html' // Must be registered as "spa" type
        },
        cache: {
          cacheLocation: 'localStorage', // needed to avoid "login required" error
          storeAuthStateInCookie: true // recommended to avoid certain IE/Edge issues
        }
      });

    // handleRedirectPromise should be invoked on every page load
    msalInstance.handleRedirectPromise()
        .then((response) => {
            // If response is non-null, it means page is returning from AAD with a successful response
            if (response) {
                Office.context.ui.messageParent( JSON.stringify({ status: 'success', result : response.accessToken }) );
            } else {
                // Otherwise, invoke login
                msalInstance.loginRedirect({
                    scopes: ['user.read', 'files.read.all']
                });
            }
        })
        .catch((error) => {
            const errorData: string = `errorMessage: ${error.errorCode}
                                   message: ${error.errorMessage}
                                   errorCode: ${error.stack}`;
            Office.context.ui.messageParent( JSON.stringify({ status: 'failure', result: errorData }));
        });
  };
})();

logout/logout.ts

import { PublicClientApplication } from '@azure/msal-browser';   

(() => {
  Office.initialize = () => {

    const msalInstance = new PublicClientApplication({
        auth: {
            clientId: 'fc19440a-334e-471e-af53-a1c1f53c9226',
            redirectUri: 'https://localhost:3000/logoutcomplete/logoutcomplete.html',
            postLogoutRedirectUri: 'https://localhost:3000/logoutcomplete/logoutcomplete.html'
        }
    });

    msalInstance.logout();
  };
})();

logoutcomplete/logoutcomplete.html

<!DOCTYPE html> 
<html>
<head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=Edge" />

    <!-- Office JavaScript API -->
    <script type="text/javascript" src="https://appsforoffice.microsoft.com/lib/1.1/hosted/office.debug.js"></script>

    <script>Office.initialize = () => {

// No particular message. Any message at all tells the parent page to close the dialog.
        Office.context.ui.messageParent('');
      };</script>
</head>
<body>
</body>
</html>

utilities/office-apis-helpers.ts

import { AppState } from '../src/components/App';  
import { AxiosResponse } from 'axios';

export const writeFileNamesToWorksheet = async (result: AxiosResponse,
                                         displayError: (x: string) => void) => {

  return Excel.run( (context: Excel.RequestContext) => {
      const sheet = context.workbook.worksheets.getActiveWorksheet();

      const data = [
         [result.data.value[0].name],
         [result.data.value[1].name],
         [result.data.value[2].name]];

      const range = sheet.getRange('B5:B7');
      range.values = data;
      range.format.autofitColumns();

      return context.sync();
  })
  .catch( (error) => {
    displayError(error.toString());
  });
};

let loginDialog: Office.Dialog;
const dialogLoginUrl: string = location.protocol + '//' + location.hostname + (location.port ? ':' + location.port : '') + '/login/login.html';

export const signInO365 = async (setState: (x: AppState) => void,
                                 setToken: (x: string) => void,
                                 displayError: (x: string) => void) => {

    setState({ authStatus: 'loginInProcess' });

    await Office.context.ui.displayDialogAsync(
            dialogLoginUrl,
            {height: 40, width: 30},
            (result) => {
                if (result.status === Office.AsyncResultStatus.Failed) {
                    displayError(`${result.error.code} ${result.error.message}`);
                }
                else {
                    loginDialog = result.value;
                    loginDialog.addEventHandler(Office.EventType.DialogMessageReceived, processLoginMessage);
                    loginDialog.addEventHandler(Office.EventType.DialogEventReceived, processLoginDialogEvent);
                }
            }
        );

    const processLoginMessage = (arg: {message: string, type: string}) => {

        let messageFromDialog = JSON.parse(arg.message);
        if (messageFromDialog.status === 'success') {

            // We now have a valid access token.
            loginDialog.close();
            setToken(messageFromDialog.result);
            setState( { authStatus: 'loggedIn',
                        headerMessage: 'Get Data' });
        }
        else {
            // Something went wrong with authentication or the authorization of the web application.
            loginDialog.close();
            displayError(messageFromDialog.result);
        }
    };

    const processLoginDialogEvent = (arg) => {
        processDialogEvent(arg, setState, displayError);
    };
};

let logoutDialog: Office.Dialog;
const dialogLogoutUrl: string = location.protocol + '//' + location.hostname + (location.port ? ':' + location.port : '') + '/logout/logout.html';

export const logoutFromO365 = async (setState: (x: AppState) => void,
                                     displayError: (x: string) => void) => {

    Office.context.ui.displayDialogAsync(dialogLogoutUrl,
            {height: 40, width: 30},
            (result) => {
                if (result.status === Office.AsyncResultStatus.Failed) {
                    displayError(`${result.error.code} ${result.error.message}`);
                }
                else {
                    logoutDialog = result.value;
                    logoutDialog.addEventHandler(Office.EventType.DialogMessageReceived, processLogoutMessage);
                    logoutDialog.addEventHandler(Office.EventType.DialogEventReceived, processLogoutDialogEvent);
                }
            }
        );

    const processLogoutMessage = () => {
        logoutDialog.close();
        setState({ authStatus: 'notLoggedIn',
                   headerMessage: 'Welcome' });
    };

    const processLogoutDialogEvent = (arg) => {
        processDialogEvent(arg, setState, displayError);
    };
};

const processDialogEvent = (arg: {error: number, type: string},
                            setState: (x: AppState) => void,
                            displayError: (x: string) => void) => {

    switch (arg.error) {
        case 12002:
            displayError('The dialog box has been directed to a page that it cannot find or load, or the URL syntax is invalid.');
            break;
        case 12003:
            displayError('The dialog box has been directed to a URL with the HTTP protocol. HTTPS is required.');
            break;
        case 12006:
            // 12006 means that the user closed the dialog instead of waiting for it to close.
            // It is not known if the user completed the login or logout, so assume the user is
            // logged out and revert to the app's starting state. It does no harm for a user to
            // press the login button again even if the user is logged in.
            setState({ authStatus: 'notLoggedIn',
                       headerMessage: 'Welcome' });
            break;
        default:
            displayError('Unknown error in dialog box.');
            break;
    }
};

utilities/microsoft-graph-helpers.ts

import axios from 'axios';  

export const getGraphData = async (url: string, accesstoken: string) => {
  const response = await axios({
    url: url,
    method: 'get',
    headers: {'Authorization': `Bearer ${accesstoken}`}
  });
  return response;
};

src/components/app.tsx

import * as React from 'react';  
import { Spinner, SpinnerType } from 'office-ui-fabric-react';
import Header from './Header';
import { HeroListItem } from './HeroList';
import Progress from './Progress';
import StartPageBody from './StartPageBody';
import GetDataPageBody from './GetDataPageBody';
import SuccessPageBody from './SuccessPageBody';
import OfficeAddinMessageBar from './OfficeAddinMessageBar';
import { getGraphData } from '../../utilities/microsoft-graph-helpers';
import { writeFileNamesToWorksheet, logoutFromO365, signInO365 } from '../../utilities/office-apis-helpers';

export interface AppProps {
  title: string;
  isOfficeInitialized: boolean;
}

export interface AppState {
  authStatus?: string;
  fileFetch?: string;
  headerMessage?: string;
  errorMessage?: string;
}

export default class App extends React.Component<AppProps, AppState> {
    constructor(props, context) {
        super(props, context);
        this.state = {
            authStatus: 'notLoggedIn',
            fileFetch: 'notFetched',
            headerMessage: 'Welcome',
            errorMessage: ''
        };

        // Bind the methods that we want to pass to, and call in, a separate
        // module to this component. And rename setState to boundSetState
        // so code that passes boundSetState is more self-documenting.
        this.boundSetState = this.setState.bind(this);
        this.setToken = this.setToken.bind(this);
        this.displayError = this.displayError.bind(this);
        this.login = this.login.bind(this);
    }

    // The access token is not part of state because React is all about the
    // UI and the token is not used to affect the UI in any way.
    accessToken: string;

    listItems: HeroListItem[] = [
        {
            icon: 'PlugConnected',
            primaryText: 'Connects to OneDrive for Business.'
        },
        {
            icon: 'ExcelDocument',
            primaryText: 'Gets the names of the first three workbooks in OneDrive for Business.'
        },
        {
            icon: 'AddNotes',
            primaryText: 'Adds the names to the current document.'
        }
    ];

    boundSetState: () => {};

    setToken = (accesstoken: string) => {
        this.accessToken = accesstoken;
    }

    displayError = (error: string) => {
        this.setState({ errorMessage: error });
    }

    // Runs when the user clicks the X to close the message bar where
    // the error appears.
    errorDismissed = () => {
        this.setState({ errorMessage: '' });

        // If the error occured during a "in process" phase (logging in or getting files),
        // the action didn't complete, so return the UI to the preceding state/view.
        this.setState((prevState) => {
            if (prevState.authStatus === 'loginInProcess') {
                return {authStatus: 'notLoggedIn'};
            }
            else if (prevState.fileFetch === 'fetchInProcess') {
                return {fileFetch: 'notFetched'};
            }
            return null;
        });
    }

    login = async () => {
        await signInO365(this.boundSetState, this.setToken, this.displayError);
    }

    logout = async () => {
        await logoutFromO365(this.boundSetState, this.displayError);
    }

    getFileNames = async () => {
        this.setState({ fileFetch: 'fetchInProcess' });
        getGraphData(

                // Get the `name` property of the first 3 Excel workbooks in the user's OneDrive.
                "https://graph.microsoft.com/v1.0/me/drive/root/microsoft.graph.search(q = '.xlsx')?$select=name&top=3",
                this.accessToken
            )
            .then( async (response) => {
                await writeFileNamesToWorksheet(response, this.displayError);
                this.setState({ fileFetch: 'fetched',
                                headerMessage: 'Success' });
            })
            .catch ( (requestError) => {
                // If this runs, then the `then` method did not run, so this error must be
                // from the Axios request in getGraphData, not the Office.js in
                // writeFileNamesToWorksheet
                this.displayError(requestError);
            });
    }

    render() {
        const { title, isOfficeInitialized } = this.props;

        if (!isOfficeInitialized) {
            return (
                <Progress
                    title={title}
                    logo='assets/Onedrive_Charts_icon_80x80px.png'
                    message='Please sideload your add-in to see app body.'
                />
            );
        }

        // Set the body of the page based on where the user is in the workflow.
        let body;

        if (this.state.authStatus === 'notLoggedIn') {
            body = ( <StartPageBody login={this.login} listItems={this.listItems}/> );
        }
        else if (this.state.authStatus === 'loginInProcess') {
            body = ( <Spinner className='spinner' type={SpinnerType.large} label='Please sign-in on the pop-up window.' /> );
        }
        else {
            if (this.state.fileFetch === 'notFetched') {
                body = ( <GetDataPageBody getFileNames={this.getFileNames} logout={this.logout}/> );
            }
            else if (this.state.fileFetch === 'fetchInProcess') {
                body = ( <Spinner className='spinner' type={SpinnerType.large} label='We are getting the data for you.' /> );
            }
            else {
                body = ( <SuccessPageBody getFileNames={this.getFileNames} logout={this.logout} /> );
            }
        }

        return (
            <div>
                { this.state.errorMessage ?
                  (<OfficeAddinMessageBar onDismiss={this.errorDismissed} message={this.state.errorMessage + ' '} />)
                : null }

                <div className='ms-welcome'>
                    <Header logo='assets/Onedrive_Charts_icon_80x80px.png' title={this.props.title} message={this.state.headerMessage} />
                    {body}
                </div>
            </div>
        );
    }
}

src/components/officeaddinmessagebar.tsx

This component is needed to wrap the Fabric React MessageBar when the latter appears at the top of the task pane in an Office Add-in, because the taskpane in an Office Add-in has a personality menu that covers a small rectangle of the upper right corner of the task pane.
This rectangle covers the "dismiss X" on the right end of the MessageBar unless extra padding is added.

import * as React from 'react';  
import { MessageBar, MessageBarType } from 'office-ui-fabric-react';

export interface OfficeAddinMessageBarProps {
    onDismiss: () => void;
    message: string;
}

export default class OfficeAddinMessageBar extends React.Component<OfficeAddinMessageBarProps> {

    constructor(props: OfficeAddinMessageBarProps) {
        super(props);
        this.officeAddinTaskpaneStyle = { paddingRight: '20px' };
      }

    private officeAddinTaskpaneStyle: any;

    render() {
        return (
            <div style={this.officeAddinTaskpaneStyle}>
                <MessageBar messageBarType={MessageBarType.error} isMultiline={true} onDismiss={this.props.onDismiss} dismissButtonAriaLabel='Close'>
                {this.props.message}.{' '}</MessageBar>
            </div>

        );
    }
}

src/components/progress.tsx

import * as React from 'react';  
import { Spinner, SpinnerType } from 'office-ui-fabric-react';

export interface ProgressProps {
    logo: string;
    message: string;
    title: string;
}

export default class Progress extends React.Component<ProgressProps> {
    render() {
        const { logo, message, title } = this.props;

        return (
            <section className='ms-welcome__progress ms-u-fadeIn500'>
                <img width='90' height='90' src={logo} alt={title} title={title} />
                <h1 className='ms-fontSize-su ms-fontWeight-light ms-fontColor-neutralPrimary'>{title}</h1>
                <Spinner type={SpinnerType.large} label={message} />
            </section>
        );
    }
}

© 2021 Better Solutions Limited. All Rights Reserved. © 2021 Better Solutions Limited TopPrevNext