├── .gitattributes ├── .gitignore ├── LICENSE.TXT ├── README.md ├── app.js ├── authHelper.js ├── package.json ├── pages.js └── static └── styles └── app.css /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | 7 | # Standard to msysgit 8 | *.doc diff=astextplain 9 | *.DOC diff=astextplain 10 | *.docx diff=astextplain 11 | *.DOCX diff=astextplain 12 | *.dot diff=astextplain 13 | *.DOT diff=astextplain 14 | *.pdf diff=astextplain 15 | *.PDF diff=astextplain 16 | *.rtf diff=astextplain 17 | *.RTF diff=astextplain 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Windows image file caches 2 | Thumbs.db 3 | ehthumbs.db 4 | 5 | # Folder config file 6 | Desktop.ini 7 | 8 | # Recycle Bin used on file shares 9 | $RECYCLE.BIN/ 10 | 11 | # Windows Installer files 12 | *.cab 13 | *.msi 14 | *.msm 15 | *.msp 16 | 17 | # Windows shortcuts 18 | *.lnk 19 | 20 | # ========================= 21 | # Operating System Files 22 | # ========================= 23 | 24 | # OSX 25 | # ========================= 26 | 27 | .DS_Store 28 | .AppleDouble 29 | .LSOverride 30 | 31 | # Thumbnails 32 | ._* 33 | 34 | # Files that might appear on external disk 35 | .Spotlight-V100 36 | .Trashes 37 | 38 | # Directories potentially created on remote AFP share 39 | .AppleDB 40 | .AppleDesktop 41 | Network Trash Folder 42 | Temporary Items 43 | .apdisk 44 | 45 | node_modules 46 | npm-debug.log -------------------------------------------------------------------------------- /LICENSE.TXT: -------------------------------------------------------------------------------- 1 | node-calendar-sync, https://github.com/jasonjoh/node-calendar-sync 2 | 3 | Copyright (c) Microsoft Corporation 4 | All rights reserved. 5 | 6 | MIT License: 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining 9 | a copy of this software and associated documentation files (the 10 | ""Software""), to deal in the Software without restriction, including 11 | without limitation the rights to use, copy, modify, merge, publish, 12 | distribute, sublicense, and/or sell copies of the Software, and to 13 | permit persons to whom the Software is furnished to do so, subject to 14 | the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be 17 | included in all copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, 20 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 21 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 22 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 23 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 24 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 25 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node.js Sample for Calendar Sync with Office 365 # 2 | 3 | ## Installation ## 4 | 5 | npm install 6 | 7 | ## Running the sample 8 | 9 | 1. Register a new app at https://apps.dev.microsoft.com 10 | 1. Copy the **Application Id** and paste this value for the `clientId` value in `authHelper.js`. 11 | 1. Click the **Generate New Password** button and copy the password. Paste this value for the `clientSecret` value in `authHelper.js`. 12 | 1. Click the **Add Platform** button and choose **Web**. Enter `http://localhost:3000/authorize` for the **Redirect URI**. 13 | 1. Click **Save**. 14 | 1. Save changes to `authHelper.js` and start the app: 15 | 16 | npm start 17 | 18 | 1. Open your browser and browse to http://localhost:3000. 19 | 20 | ## Copyright ## 21 | 22 | Copyright (c) Microsoft. All rights reserved. 23 | 24 | ---------- 25 | Connect with me on Twitter [@JasonJohMSFT](https://twitter.com/JasonJohMSFT) 26 | 27 | Follow the [Outlook Dev Blog](http://blogs.msdn.com/b/exchangedev/) -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var app = express(); 3 | 4 | var bodyParser = require('body-parser'); 5 | var cookieParser = require('cookie-parser'); 6 | var session = require('express-session'); 7 | var moment = require('moment'); 8 | var querystring = require('querystring'); 9 | var outlook = require('node-outlook'); 10 | 11 | // Very basic HTML templates 12 | var pages = require('./pages'); 13 | var authHelper = require('./authHelper'); 14 | 15 | // Configure express 16 | // Set up rendering of static files 17 | app.use(express.static('static')); 18 | // Need JSON body parser for most API responses 19 | app.use(bodyParser.json()); 20 | // Set up cookies and sessions to save tokens 21 | app.use(cookieParser()); 22 | app.use(session( 23 | { secret: '0dc529ba-5051-4cd6-8b67-c9a901bb8bdf', 24 | resave: false, 25 | saveUninitialized: false 26 | })); 27 | 28 | // Home page 29 | app.get('/', function(req, res) { 30 | res.send(pages.loginPage(authHelper.getAuthUrl())); 31 | }); 32 | 33 | app.get('/authorize', function(req, res) { 34 | var authCode = req.query.code; 35 | if (authCode) { 36 | console.log(''); 37 | console.log('Retrieved auth code in /authorize: ' + authCode); 38 | authHelper.getTokenFromCode(authCode, tokenReceived, req, res); 39 | } 40 | else { 41 | // redirect to home 42 | console.log('/authorize called without a code parameter, redirecting to login'); 43 | res.redirect('/'); 44 | } 45 | }); 46 | 47 | function tokenReceived(req, res, error, token) { 48 | if (error) { 49 | console.log('ERROR getting token:' + error); 50 | res.send('ERROR getting token: ' + error); 51 | } 52 | else { 53 | // save tokens in session 54 | req.session.access_token = token.token.access_token; 55 | req.session.refresh_token = token.token.refresh_token; 56 | req.session.email = authHelper.getEmailFromIdToken(token.token.id_token); 57 | res.redirect('/logincomplete'); 58 | } 59 | } 60 | 61 | app.get('/logincomplete', function(req, res) { 62 | var access_token = req.session.access_token; 63 | var refresh_token = req.session.access_token; 64 | var email = req.session.email; 65 | 66 | if (access_token === undefined || refresh_token === undefined) { 67 | console.log('/logincomplete called while not logged in'); 68 | res.redirect('/'); 69 | return; 70 | } 71 | 72 | res.send(pages.loginCompletePage(email)); 73 | }); 74 | 75 | app.get('/refreshtokens', function(req, res) { 76 | var refresh_token = req.session.refresh_token; 77 | if (refresh_token === undefined) { 78 | console.log('no refresh token in session'); 79 | res.redirect('/'); 80 | } 81 | else { 82 | authHelper.getTokenFromRefreshToken(refresh_token, tokenReceived, req, res); 83 | } 84 | }); 85 | 86 | app.get('/logout', function(req, res) { 87 | req.session.destroy(); 88 | res.redirect('/'); 89 | }); 90 | 91 | app.get('/sync', function(req, res) { 92 | var token = req.session.access_token; 93 | var email = req.session.email; 94 | if (token === undefined || email === undefined) { 95 | console.log('/sync called while not logged in'); 96 | res.redirect('/'); 97 | return; 98 | } 99 | 100 | // Set the endpoint to API v2 101 | outlook.base.setApiEndpoint('https://outlook.office.com/api/v2.0'); 102 | // Set the user's email as the anchor mailbox 103 | outlook.base.setAnchorMailbox(req.session.email); 104 | // Set the preferred time zone 105 | outlook.base.setPreferredTimeZone('Eastern Standard Time'); 106 | 107 | // Use the syncUrl if available 108 | var requestUrl = req.session.syncUrl; 109 | if (requestUrl === undefined) { 110 | // Calendar sync works on the CalendarView endpoint 111 | requestUrl = outlook.base.apiEndpoint() + '/Me/CalendarView'; 112 | } 113 | 114 | // Set up our sync window from midnight on the current day to 115 | // midnight 7 days from now. 116 | var startDate = moment().startOf('day'); 117 | var endDate = moment(startDate).add(7, 'days'); 118 | // The start and end date are passed as query parameters 119 | var params = { 120 | startDateTime: startDate.toISOString(), 121 | endDateTime: endDate.toISOString() 122 | }; 123 | 124 | // Set the required headers for sync 125 | var headers = { 126 | Prefer: [ 127 | // Enables sync functionality 128 | 'odata.track-changes', 129 | // Requests only 5 changes per response 130 | 'odata.maxpagesize=5' 131 | ] 132 | }; 133 | 134 | var apiOptions = { 135 | url: requestUrl, 136 | token: token, 137 | headers: headers, 138 | query: params 139 | }; 140 | 141 | outlook.base.makeApiCall(apiOptions, function(error, response) { 142 | if (error) { 143 | console.log(JSON.stringify(error)); 144 | res.send(JSON.stringify(error)); 145 | } 146 | else { 147 | if (response.statusCode !== 200) { 148 | console.log('API Call returned ' + response.statusCode); 149 | res.send('API Call returned ' + response.statusCode); 150 | } 151 | else { 152 | var nextLink = response.body['@odata.nextLink']; 153 | if (nextLink !== undefined) { 154 | req.session.syncUrl = nextLink; 155 | } 156 | var deltaLink = response.body['@odata.deltaLink']; 157 | if (deltaLink !== undefined) { 158 | req.session.syncUrl = deltaLink; 159 | } 160 | res.send(pages.syncPage(email, response.body.value)); 161 | } 162 | } 163 | }); 164 | }); 165 | 166 | app.get('/viewitem', function(req, res) { 167 | var itemId = req.query.id; 168 | var access_token = req.session.access_token; 169 | var email = req.session.email; 170 | 171 | if (itemId === undefined || access_token === undefined) { 172 | res.redirect('/'); 173 | return; 174 | } 175 | 176 | var select = { 177 | '$select': 'Subject,Attendees,Location,Start,End,IsReminderOn,ReminderMinutesBeforeStart' 178 | }; 179 | 180 | var getEventParameters = { 181 | token: access_token, 182 | eventId: itemId, 183 | odataParams: select 184 | }; 185 | 186 | outlook.calendar.getEvent(getEventParameters, function(error, event) { 187 | if (error) { 188 | console.log(error); 189 | res.send(error); 190 | } 191 | else { 192 | res.send(pages.itemDetailPage(email, event)); 193 | } 194 | }); 195 | }); 196 | 197 | app.get('/updateitem', function(req, res) { 198 | var itemId = req.query.eventId; 199 | var access_token = req.session.access_token; 200 | 201 | if (itemId === undefined || access_token === undefined) { 202 | res.redirect('/'); 203 | return; 204 | } 205 | 206 | var newSubject = req.query.subject; 207 | var newLocation = req.query.location; 208 | 209 | console.log('UPDATED SUBJECT: ', newSubject); 210 | console.log('UPDATED LOCATION: ', newLocation); 211 | 212 | var updatePayload = { 213 | Subject: newSubject, 214 | Location: { 215 | DisplayName: newLocation 216 | } 217 | }; 218 | 219 | var updateEventParameters = { 220 | token: access_token, 221 | eventId: itemId, 222 | update: updatePayload 223 | }; 224 | 225 | outlook.calendar.updateEvent(updateEventParameters, function(error, event) { 226 | if (error) { 227 | console.log(error); 228 | res.send(error); 229 | } 230 | else { 231 | res.redirect('/viewitem?' + querystring.stringify({ id: itemId })); 232 | } 233 | }); 234 | }); 235 | 236 | app.get('/deleteitem', function(req, res) { 237 | var itemId = req.query.id; 238 | var access_token = req.session.access_token; 239 | 240 | if (itemId === undefined || access_token === undefined) { 241 | res.redirect('/'); 242 | return; 243 | } 244 | 245 | var deleteEventParameters = { 246 | token: access_token, 247 | eventId: itemId 248 | }; 249 | 250 | outlook.calendar.deleteEvent(deleteEventParameters, function(error, event) { 251 | if (error) { 252 | console.log(error); 253 | res.send(error); 254 | } 255 | else { 256 | res.redirect('/sync'); 257 | } 258 | }); 259 | }); 260 | 261 | // Start the server 262 | var server = app.listen(3000, function() { 263 | var host = server.address().address; 264 | var port = server.address().port; 265 | 266 | console.log('Example app listening at http://%s:%s', host, port); 267 | }); -------------------------------------------------------------------------------- /authHelper.js: -------------------------------------------------------------------------------- 1 | var clientId = 'YOUR APP ID HERE'; 2 | var clientSecret = 'YOUR APP PASSWORD HERE'; 3 | var redirectUri = 'http://localhost:3000/authorize'; 4 | 5 | var scopes = [ 6 | 'openid', 7 | 'profile', 8 | 'offline_access', 9 | 'https://outlook.office.com/calendars.readwrite' 10 | ]; 11 | 12 | var credentials = { 13 | clientID: clientId, 14 | clientSecret: clientSecret, 15 | site: 'https://login.microsoftonline.com/common', 16 | authorizationPath: '/oauth2/v2.0/authorize', 17 | tokenPath: '/oauth2/v2.0/token' 18 | } 19 | var oauth2 = require('simple-oauth2')(credentials) 20 | 21 | module.exports = { 22 | getAuthUrl: function() { 23 | var returnVal = oauth2.authCode.authorizeURL({ 24 | redirect_uri: redirectUri, 25 | scope: scopes.join(' ') 26 | }); 27 | console.log(''); 28 | console.log('Generated auth url: ' + returnVal); 29 | return returnVal; 30 | }, 31 | 32 | getTokenFromCode: function(auth_code, callback, request, response) { 33 | oauth2.authCode.getToken({ 34 | code: auth_code, 35 | redirect_uri: redirectUri, 36 | scope: scopes.join(' ') 37 | }, function (error, result) { 38 | if (error) { 39 | console.log('Access token error: ', error.message); 40 | callback(request ,response, error, null); 41 | } 42 | else { 43 | var token = oauth2.accessToken.create(result); 44 | console.log(''); 45 | console.log('Token created: ', token.token); 46 | callback(request, response, null, token); 47 | } 48 | }); 49 | }, 50 | 51 | getEmailFromIdToken: function(id_token) { 52 | // JWT is in three parts, separated by a '.' 53 | var token_parts = id_token.split('.'); 54 | 55 | // Token content is in the second part, in urlsafe base64 56 | var encoded_token = new Buffer(token_parts[1].replace('-', '+').replace('_', '/'), 'base64'); 57 | 58 | var decoded_token = encoded_token.toString(); 59 | 60 | var jwt = JSON.parse(decoded_token); 61 | 62 | // Email is in the preferred_username field 63 | return jwt.preferred_username 64 | }, 65 | 66 | getTokenFromRefreshToken: function(refresh_token, callback, request, response) { 67 | var token = oauth2.accessToken.create({ refresh_token: refresh_token, expires_in: 0}); 68 | token.refresh(function(error, result) { 69 | if (error) { 70 | console.log('Refresh token error: ', error.message); 71 | callback(request, response, error, null); 72 | } 73 | else { 74 | console.log('New token: ', result.token); 75 | callback(request, response, null, result); 76 | } 77 | }); 78 | } 79 | }; 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-calendar-sync", 3 | "version": "1.0.0", 4 | "description": "Sample Node.js app that connects to Office 365 and syncs calendar data", 5 | "main": "app.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node app.js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/jasonjoh/node-calendar-sync.git" 13 | }, 14 | "keywords": [ 15 | "office365", 16 | "REST", 17 | "calendar", 18 | "files", 19 | "outlook", 20 | "onedrive", 21 | "node" 22 | ], 23 | "author": "Jason Johnston", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/jasonjoh/node-calendar-sync/issues" 27 | }, 28 | "homepage": "https://github.com/jasonjoh/node-calendar-sync#readme", 29 | "dependencies": { 30 | "body-parser": "^1.14.1", 31 | "cookie-parser": "^1.4.0", 32 | "express": "^4.13.3", 33 | "express-session": "^1.11.3", 34 | "moment": "^2.10.6", 35 | "node-outlook": "^1.1.3", 36 | "simple-oauth2": "^0.2.1" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pages.js: -------------------------------------------------------------------------------- 1 | var querystring = require('querystring'); 2 | 3 | var baseHtml = '' + 4 | '' + 5 | '' + 6 | '' + 7 | '%title%' + 8 | '' + 9 | '' + 10 | '' + 11 | '' + 12 | '' + 13 | '
' + 14 | '
' + 15 | '
Outlook Calendar Sync Demo
' + 16 | '
' + 17 | '
' + 18 | '%body%' + 19 | '
' + 20 | '
' + 21 | '' + 22 | ''; 23 | 24 | var buttonRow = '
' + 25 | '
Signed in as: %email%
' + 26 | '
' + 27 | '
' + 28 | '
' + 29 | 'Sync calendar' + 30 | '
' + 31 | '
' + 32 | 'Refresh tokens' + 33 | '
' + 34 | '
' + 35 | 'Logout' + 36 | '
' + 37 | '
'; 38 | 39 | function extractId(change) { 40 | return change.id.match(/'([^']+)'/)[1]; 41 | } 42 | 43 | function getViewItemLink(change) { 44 | if (change.reason && change.reason === 'deleted') { 45 | return ''; 46 | } 47 | 48 | var link = 'View Item'; 51 | return link; 52 | } 53 | 54 | function getAttendeesStrings(attendees) { 55 | var displayStrings = { 56 | required: '', 57 | optional: '', 58 | resources: '' 59 | }; 60 | 61 | attendees.forEach(function(attendee) { 62 | var attendeeName = (attendee.EmailAddress.Name === undefined) ? 63 | attendee.EmailAddress.Address : attendee.EmailAddress.Name; 64 | switch (attendee.Type) { 65 | // Required 66 | case "Required": 67 | if (displayStrings.required.length > 0) { 68 | displayStrings.required += '; ' + attendeeName; 69 | } 70 | else { 71 | displayStrings.required += attendeeName; 72 | } 73 | break; 74 | // Optional 75 | case "Optional": 76 | if (displayStrings.optional.length > 0) { 77 | displayStrings.optional += '; ' + attendeeName; 78 | } 79 | else { 80 | displayStrings.optional += attendeeName; 81 | } 82 | break; 83 | // Resources 84 | case "Resource": 85 | if (displayStrings.resources.length > 0) { 86 | displayStrings.resources += '; ' + attendeeName; 87 | } 88 | else { 89 | displayStrings.resources += attendeeName; 90 | } 91 | break; 92 | } 93 | }); 94 | 95 | return displayStrings; 96 | } 97 | 98 | module.exports = { 99 | loginPage: function(signinUrl) { 100 | var html = 'Click here to sign in'; 101 | 102 | return baseHtml.replace('%title%', 'Login').replace('%body%', html); 103 | }, 104 | 105 | loginCompletePage: function(userEmail) { 106 | var html = '
'; 107 | html += buttonRow.replace('%email%', userEmail); 108 | html += '
'; 109 | 110 | return baseHtml.replace('%title%', 'Main').replace('%body%', html); 111 | }, 112 | 113 | syncPage: function(userEmail, changes) { 114 | var html = '
'; 115 | html += buttonRow.replace('%email%', userEmail); 116 | 117 | html += '
'; 118 | html += '
Changes
'; 119 | html += '
'; 120 | html += '
'; 121 | html += '
Change type
'; 122 | html += '
Details
'; 123 | html += '
'; 124 | html += '
'; 125 | 126 | if (changes && changes.length > 0) { 127 | changes.forEach(function(change){ 128 | var changeType = (change.reason && change.reason === 'deleted') ? 'Delete' : 'Add/Update'; 129 | var detail = (changeType === 'Delete') ? extractId(change) : change.Subject; 130 | html += '
'; 131 | html += '
' + changeType + '
'; 132 | html += '
' + detail + '
'; 133 | html += '
' + getViewItemLink(change) + '
'; 134 | html += '
'; 135 | }); 136 | } 137 | else { 138 | html += '
-
No Changes
'; 139 | } 140 | 141 | html += '
'; 142 | html += '
'; 143 | 144 | html += '
' + JSON.stringify(changes, null, 2) + '
'; 145 | return baseHtml.replace('%title%', 'Sync').replace('%body%', html); 146 | }, 147 | 148 | itemDetailPage: function(userEmail, event) { 149 | var html = '
'; 150 | html += buttonRow.replace('%email%', userEmail); 151 | 152 | html += '
'; 153 | 154 | html += ''; 155 | 156 | html += '
'; 157 | html += '
'; 158 | html += '
'; 159 | html += ' '; 160 | html += ' '; 161 | html += '
'; 162 | html += '
'; 163 | html += '
'; 164 | 165 | html += '
'; 166 | html += '
'; 167 | html += '
'; 168 | html += ' '; 169 | html += ' '; 170 | html += '
'; 171 | html += '
'; 172 | html += '
'; 173 | 174 | if (event.IsReminderOn) { 175 | html += '
'; 176 | html += '
'; 177 | html += '
'; 178 | html += ' '; 179 | html += ' '; 180 | html += '
'; 181 | html += '
'; 182 | html += '
'; 183 | } 184 | 185 | var attendees = getAttendeesStrings(event.Attendees); 186 | 187 | if (attendees.required.length > 0) { 188 | html += '
'; 189 | html += '
'; 190 | html += '
'; 191 | html += ' '; 192 | html += ' '; 193 | html += '
'; 194 | html += '
'; 195 | html += '
'; 196 | } 197 | 198 | if (attendees.optional.length > 0) { 199 | html += '
'; 200 | html += '
'; 201 | html += '
'; 202 | html += ' '; 203 | html += ' '; 204 | html += '
'; 205 | html += '
'; 206 | html += '
'; 207 | } 208 | 209 | if (attendees.resources.length > 0) { 210 | html += '
'; 211 | html += '
'; 212 | html += '
'; 213 | html += ' '; 214 | html += ' '; 215 | html += '
'; 216 | html += '
'; 217 | html += '
'; 218 | } 219 | 220 | html += '
'; 221 | html += '
'; 222 | html += '
'; 223 | html += ' '; 224 | html += ' '; 225 | html += '
'; 226 | html += '
'; 227 | html += '
'; 228 | html += '
'; 229 | html += ' '; 230 | html += ' '; 231 | html += '
'; 232 | html += '
'; 233 | html += '
'; 234 | 235 | html += '
'; 236 | html += '
'; 237 | html += ' '; 238 | html += '
'; 239 | html += '
'; 240 | html += ' Delete item'; 241 | html += '
'; 242 | html += '
'; 243 | html += '
'; 244 | 245 | html += '
' + JSON.stringify(event, null, 2) + '
'; 246 | // end grid 247 | html += '
'; 248 | 249 | return baseHtml.replace('%title%', event.Subject).replace('%body%', html); 250 | } 251 | }; -------------------------------------------------------------------------------- /static/styles/app.css: -------------------------------------------------------------------------------- 1 | #main-content { 2 | margin: 20px; 3 | width: 750px; 4 | } 5 | 6 | #title-banner { 7 | margin: 20px 0; 8 | height: 64px; 9 | } 10 | 11 | #user-email { 12 | margin-bottom: 20px; 13 | } 14 | 15 | #table-row { 16 | margin: 20px 0; 17 | } 18 | 19 | #event-subject { 20 | margin-top: 20px; 21 | } 22 | 23 | #body-text { 24 | width: 100%; 25 | } 26 | 27 | #action-buttons { 28 | margin: 20px 0; 29 | } 30 | 31 | input.ms-Button--primary { 32 | color: #fff; 33 | } 34 | 35 | .ms-Table { 36 | margin: 20px 0; 37 | } 38 | 39 | .ms-Button { 40 | text-decoration: none; 41 | } --------------------------------------------------------------------------------