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
link - learn.microsoft.com/en-us/azure/active-directory/develop/tutorial-v2-react
link - github.com/Azure-Samples/ms-identity-javascript-react-spa
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>
);
}
}
© 2023 Better Solutions Limited. All Rights Reserved. © 2023 Better Solutions Limited TopPrevNext