├── .idea ├── .name ├── copyright │ └── profiles_settings.xml ├── jsLibraryMappings.xml ├── modules.xml ├── asana-webhooks-manager.iml ├── libraries │ └── asana_webhooks_manager_node_modules.xml └── misc.xml ├── config ├── index.js ├── mongodb.js └── asana.js ├── tests ├── mocks │ ├── mongodb.js │ ├── io.js │ ├── Request.js │ └── Response.js ├── helpers │ ├── asana.test.js │ ├── config.test.js │ └── mongodb.test.js ├── controllers │ ├── RootController.test.js │ ├── OauthController.test.js │ ├── AWMController.test.js │ ├── EventsController.test.js │ └── AsanaController.test.js └── routes │ ├── root.test.js │ ├── events.test.js │ ├── oauth.test.js │ └── asana.test.js ├── public ├── client │ ├── app.module.js │ ├── models │ │ ├── AWM.js │ │ ├── AWMProject.js │ │ ├── AWMPhoto.js │ │ ├── AWMWorkspace.js │ │ ├── AWMWebhook.js │ │ ├── AWMEvent.js │ │ └── AWMUser.js │ ├── views │ │ ├── main.html │ │ ├── footer.html │ │ ├── events.html │ │ ├── header.html │ │ ├── index.html │ │ ├── manage.html │ │ ├── body.html │ │ └── docs.html │ ├── directives │ │ └── highlight.js │ ├── controllers │ │ ├── headerController.js │ │ ├── mainController.js │ │ ├── docsController.js │ │ ├── eventsController.js │ │ └── manageController.js │ ├── app.config.js │ ├── services │ │ ├── userService.js │ │ ├── navigationService.js │ │ ├── resolveService.js │ │ ├── eventsService.js │ │ └── asanaService.js │ └── app.routes.js ├── fonts │ ├── FontAwesome.otf │ ├── fontawesome-webfont.eot │ ├── fontawesome-webfont.ttf │ ├── fontawesome-webfont.woff │ └── fontawesome-webfont.woff2 ├── img │ └── documentation │ │ ├── create_app │ │ ├── step1.jpg │ │ ├── step2.jpg │ │ ├── step3.jpg │ │ ├── step4.jpg │ │ ├── step5.jpg │ │ └── step6.jpg │ │ ├── events │ │ └── live_view.jpg │ │ └── manage_webhooks │ │ ├── manage_step1.jpg │ │ ├── manage_step2.jpg │ │ └── manage_step3.jpg ├── libs │ ├── bootstrap │ │ ├── fonts │ │ │ ├── glyphicons-halflings-regular.eot │ │ │ ├── glyphicons-halflings-regular.ttf │ │ │ ├── glyphicons-halflings-regular.woff │ │ │ └── glyphicons-halflings-regular.woff2 │ │ ├── js │ │ │ └── npm.js │ │ └── css │ │ │ ├── bootstrap-theme.min.css │ │ │ └── bootstrap-theme.min.css.map │ ├── angular │ │ └── angular-cookies.1.5.6.min.js │ └── highlight │ │ └── highlight.pack.js └── css │ ├── sticky-footer.css │ ├── highlight.github.css │ └── style.css ├── .travis.yml ├── helpers ├── response.js ├── asanaClient.js ├── configHelper.js └── mongodbHelper.js ├── middlewares └── restrictedAccess.js ├── models ├── webhook.js └── event.js ├── .gitignore ├── controllers ├── RootController.js ├── OauthController.js ├── AWMController.js ├── EventsController.js └── AsanaController.js ├── routes ├── index.js ├── events.js ├── root.js ├── oauth.js └── asana.js ├── server.js ├── package.json └── README.md /.idea/.name: -------------------------------------------------------------------------------- 1 | asana-webhooks-manager -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | asana: require('./asana') 3 | }; 4 | -------------------------------------------------------------------------------- /tests/mocks/mongodb.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | getConnection : function(){} 3 | }; -------------------------------------------------------------------------------- /public/client/app.module.js: -------------------------------------------------------------------------------- 1 | var awmApp = angular.module('awmApp', ['ui.router','ngCookies']); 2 | 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "iojs" 4 | - "6" 5 | after_success: 'npm run coveralls' -------------------------------------------------------------------------------- /public/client/models/AWM.js: -------------------------------------------------------------------------------- 1 | /** 2 | * AWM - A general mamespace for all models 3 | * */ 4 | var AWM = AWM || {}; -------------------------------------------------------------------------------- /.idea/copyright/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/client/views/main.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
-------------------------------------------------------------------------------- /public/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EyalRonel/asana-webhooks-manager/HEAD/public/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /public/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EyalRonel/asana-webhooks-manager/HEAD/public/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /public/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EyalRonel/asana-webhooks-manager/HEAD/public/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /public/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EyalRonel/asana-webhooks-manager/HEAD/public/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /public/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EyalRonel/asana-webhooks-manager/HEAD/public/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /public/img/documentation/create_app/step1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EyalRonel/asana-webhooks-manager/HEAD/public/img/documentation/create_app/step1.jpg -------------------------------------------------------------------------------- /public/img/documentation/create_app/step2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EyalRonel/asana-webhooks-manager/HEAD/public/img/documentation/create_app/step2.jpg -------------------------------------------------------------------------------- /public/img/documentation/create_app/step3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EyalRonel/asana-webhooks-manager/HEAD/public/img/documentation/create_app/step3.jpg -------------------------------------------------------------------------------- /public/img/documentation/create_app/step4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EyalRonel/asana-webhooks-manager/HEAD/public/img/documentation/create_app/step4.jpg -------------------------------------------------------------------------------- /public/img/documentation/create_app/step5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EyalRonel/asana-webhooks-manager/HEAD/public/img/documentation/create_app/step5.jpg -------------------------------------------------------------------------------- /public/img/documentation/create_app/step6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EyalRonel/asana-webhooks-manager/HEAD/public/img/documentation/create_app/step6.jpg -------------------------------------------------------------------------------- /public/img/documentation/events/live_view.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EyalRonel/asana-webhooks-manager/HEAD/public/img/documentation/events/live_view.jpg -------------------------------------------------------------------------------- /helpers/response.js: -------------------------------------------------------------------------------- 1 | module.exports = function(res,code,data,msg){ 2 | return res.status(code).json({ 3 | code:code, 4 | data:data, 5 | msg:msg 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /public/img/documentation/manage_webhooks/manage_step1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EyalRonel/asana-webhooks-manager/HEAD/public/img/documentation/manage_webhooks/manage_step1.jpg -------------------------------------------------------------------------------- /public/img/documentation/manage_webhooks/manage_step2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EyalRonel/asana-webhooks-manager/HEAD/public/img/documentation/manage_webhooks/manage_step2.jpg -------------------------------------------------------------------------------- /public/img/documentation/manage_webhooks/manage_step3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EyalRonel/asana-webhooks-manager/HEAD/public/img/documentation/manage_webhooks/manage_step3.jpg -------------------------------------------------------------------------------- /middlewares/restrictedAccess.js: -------------------------------------------------------------------------------- 1 | module.exports = function (req, res, next) { 2 | if (!req.cookies.token) return res.status(400).json({msg:"Access denied"}); 3 | else next(); 4 | }; 5 | -------------------------------------------------------------------------------- /public/libs/bootstrap/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EyalRonel/asana-webhooks-manager/HEAD/public/libs/bootstrap/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /public/libs/bootstrap/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EyalRonel/asana-webhooks-manager/HEAD/public/libs/bootstrap/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /public/libs/bootstrap/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EyalRonel/asana-webhooks-manager/HEAD/public/libs/bootstrap/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /public/libs/bootstrap/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EyalRonel/asana-webhooks-manager/HEAD/public/libs/bootstrap/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /tests/mocks/io.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | _of:'', 3 | of:function(namepsapce){ 4 | this._of = namepsapce; 5 | return { 6 | emit: function(namespace,payload){ 7 | //Do nothing 8 | } 9 | } 10 | } 11 | }; -------------------------------------------------------------------------------- /public/client/directives/highlight.js: -------------------------------------------------------------------------------- 1 | awmApp.directive('codeHighlight', function () { 2 | 3 | return { 4 | restrict: 'AE', 5 | link: function ($scope, element) { 6 | hljs.highlightBlock(element[0]); 7 | } 8 | }; 9 | 10 | }); -------------------------------------------------------------------------------- /models/webhook.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | var schema = mongoose.Schema({ 4 | webhook_id: String, 5 | resource_id: String, 6 | secret: String 7 | }); 8 | 9 | var Webhook = mongoose.model('Webhook', schema); 10 | 11 | module.exports = Webhook; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .idea/jsLibraryMappings.xml 3 | 4 | .idea/jsLibraryMappings.xml 5 | 6 | .idea/misc.xml 7 | 8 | .idea/watcherTasks.xml 9 | 10 | .idea/jsLibraryMappings.xml 11 | 12 | .idea/misc.xml 13 | 14 | .idea/misc.xml 15 | 16 | .idea/jsLibraryMappings.xml 17 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /public/client/views/footer.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/mongodb.js: -------------------------------------------------------------------------------- 1 | /** 2 | * config/mongodb.js 3 | * 4 | * Define your MongoDB connection details 5 | * 6 | * */ 7 | 8 | module.exports = { 9 | username: null, //MongoDB user (optional) 10 | password: null, //MongoDB password (optional) 11 | host: "127.0.0.1",//Mongo hosts 12 | port: "27017", //Port 13 | database: "awm" //Database name 14 | }; -------------------------------------------------------------------------------- /config/asana.js: -------------------------------------------------------------------------------- 1 | /** 2 | * config/asana.js 3 | * 4 | * Define your Asana app credentials below, or define the following environment variables accordingly: 5 | * 6 | * asana_client_id 7 | * asana_client_secret 8 | * asana_redirect_uri 9 | * */ 10 | 11 | module.exports = { 12 | clientId: null, 13 | clientSecret: null, 14 | redirectUri: null, 15 | }; -------------------------------------------------------------------------------- /.idea/asana-webhooks-manager.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /models/event.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | //const mongodb = require('../helpers/mongodbHelper'); 3 | //var connection = mongodb.getConnection(); 4 | 5 | var schema = mongoose.Schema({ 6 | resource: String, 7 | user: String, 8 | type: String, 9 | action: String, 10 | created_at: String, 11 | parent: String 12 | }); 13 | 14 | var Event = mongoose.model('Event', schema); 15 | 16 | module.exports = Event; -------------------------------------------------------------------------------- /public/css/sticky-footer.css: -------------------------------------------------------------------------------- 1 | html { 2 | position: relative; 3 | min-height: 100%; 4 | } 5 | body { 6 | /* Margin bottom by footer height */ 7 | margin-bottom: 60px; 8 | } 9 | .footer { 10 | position: absolute; 11 | bottom: 0; 12 | width: 100%; 13 | /* Set the fixed height of the footer here */ 14 | height: 60px; 15 | background-color: #f5f5f5; 16 | line-height: 60px; 17 | } 18 | -------------------------------------------------------------------------------- /controllers/RootController.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const AWMController = require('./AWMController'); 4 | const path = require("path"); 5 | 6 | class RootController extends AWMController{ 7 | 8 | constructor(req,res){ 9 | super(req,res); 10 | } 11 | 12 | getApp(){ 13 | return this._response.sendFile(path.join(__dirname,'../public/client/views','index.html'));} 14 | } 15 | 16 | 17 | 18 | module.exports = RootController; -------------------------------------------------------------------------------- /tests/mocks/Request.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | headers:{}, 4 | cookies:{}, 5 | params:{}, 6 | body:{}, 7 | 8 | cookie:function(key,val,options){ 9 | this.cookies[key] = val; 10 | return this; 11 | }, 12 | 13 | get:function(key){ 14 | if (this.headers.hasOwnProperty(key)) return this.headers[key]; 15 | else return null; 16 | }, 17 | set:function(key,val){ 18 | this.headers[key] = val; 19 | return this; 20 | } 21 | 22 | }; -------------------------------------------------------------------------------- /public/client/controllers/headerController.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 3 | var headerController = function($scope,navigationService,userService,asanaService){ 4 | 5 | this.$scope = $scope; 6 | this.navigationService = navigationService; 7 | this.userService = userService; 8 | this.asanaService = asanaService; 9 | 10 | }; 11 | 12 | awmApp.controller('headerController', ['$scope', 'navigationService', 'userService', 'asanaService',headerController]); 13 | 14 | })(); -------------------------------------------------------------------------------- /helpers/asanaClient.js: -------------------------------------------------------------------------------- 1 | const asanaConfig = require('../helpers/configHelper'); 2 | const asana = require('asana'); 3 | 4 | var client = function(token){ 5 | 6 | var client = asana.Client.create({ 7 | clientId: asanaConfig.getClientId(), 8 | clientSecret: asanaConfig.getClientSecret(), 9 | redirectUri: asanaConfig.getRediectUri() 10 | }); 11 | 12 | if (token) client.useOauth({credentials: token}); 13 | 14 | return client; 15 | }; 16 | 17 | module.exports = client; 18 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Require dependencies 3 | * */ 4 | const filesinFolder = require('require-dir')('./'); 5 | 6 | /** 7 | * Iterate all files and set their exported router on the main application router 8 | * */ 9 | 10 | var registerRoutes = function(app,io){ 11 | Object.keys(filesinFolder).forEach(function(routeName) { 12 | require('./'+routeName)(app,io); 13 | }); 14 | 15 | }; 16 | 17 | /** 18 | * Export the complete router with all routes back 19 | * */ 20 | module.exports = registerRoutes; -------------------------------------------------------------------------------- /public/client/controllers/mainController.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | var mainController = function($rootScope, $scope, navigationService, userService,asanaService){ 3 | 4 | this.$rootScope = $rootScope; 5 | this.$scope = $scope; 6 | this.userService = userService; 7 | this.asanaService = asanaService; 8 | this.navigationService = navigationService; 9 | 10 | }; 11 | 12 | 13 | awmApp.controller('mainController', ['$rootScope', '$scope', 'navigationService', 'userService', 'asanaService', mainController]); 14 | 15 | })(); -------------------------------------------------------------------------------- /.idea/libraries/asana_webhooks_manager_node_modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/helpers/asana.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Libraries 3 | * */ 4 | const proxyquire = require('proxyquire'); 5 | const sinon = require('sinon'); 6 | const expect = require('expect'); 7 | 8 | const asana = require('asana'); 9 | const asanaClient = require('../../helpers/asanaClient'); 10 | 11 | /** 12 | * Tests 13 | * */ 14 | describe('Asana helper',function(){ 15 | 16 | it('Should return an instance of Asana client',function(){ 17 | expect(asanaClient('fakeToken') instanceof asana.Client).toBeTruthy(); 18 | }); 19 | 20 | }); 21 | -------------------------------------------------------------------------------- /public/libs/bootstrap/js/npm.js: -------------------------------------------------------------------------------- 1 | // This file is autogenerated via the `commonjs` Grunt task. You can require() this file in a CommonJS environment. 2 | require('../../js/transition.js') 3 | require('../../js/alert.js') 4 | require('../../js/button.js') 5 | require('../../js/carousel.js') 6 | require('../../js/collapse.js') 7 | require('../../js/dropdown.js') 8 | require('../../js/modal.js') 9 | require('../../js/tooltip.js') 10 | require('../../js/popover.js') 11 | require('../../js/scrollspy.js') 12 | require('../../js/tab.js') 13 | require('../../js/affix.js') -------------------------------------------------------------------------------- /public/client/app.config.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 3 | var config = {} 4 | 5 | config.ACCESS_COOKIE = "awm_login"; 6 | 7 | config.BASE_API_URL = ""; 8 | config.BASE_ASANA_API_URL = config.BASE_API_URL + "/asana"; 9 | config.ASANA_API_CURRENT_USER = config.BASE_ASANA_API_URL + "/me"; 10 | config.ASANA_API_WORKSPACES = config.BASE_ASANA_API_URL + "/workspaces"; 11 | config.ASANA_API_PROJECTS = config.BASE_ASANA_API_URL + "/projects"; 12 | config.ASANA_API_WEBHOOKS = config.BASE_ASANA_API_URL + "/webhooks"; 13 | 14 | awmApp.constant('config',config); 15 | 16 | })(); 17 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /routes/events.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const EventsController = require('../controllers/EventsController'); 3 | var eventsCtrl = null; 4 | 5 | var registerRoutes = function(app,io){ 6 | 7 | var router = express.Router(); 8 | 9 | router.all('/*',function(req,res,next){ 10 | 11 | eventsCtrl = new EventsController(req,res,io); 12 | 13 | if (eventsCtrl instanceof EventsController) { 14 | next(); 15 | } 16 | 17 | }); 18 | 19 | router.post('/incoming/:resourceId',function(req,res,next){ 20 | 21 | return eventsCtrl.onIncomingEvents(); 22 | 23 | }); 24 | 25 | app.use('/events', router); 26 | 27 | }; 28 | 29 | 30 | 31 | module.exports = registerRoutes; -------------------------------------------------------------------------------- /routes/root.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const RootController = require('../controllers/RootController'); 3 | var rootCtrl = null; 4 | 5 | var registerRoutes = function(app,io){ 6 | 7 | var router = express.Router(); 8 | 9 | router.all('/*',function(req,res,next){ 10 | 11 | rootCtrl = new RootController(req,res); 12 | 13 | if (rootCtrl instanceof RootController) { 14 | next(); 15 | } 16 | 17 | }); 18 | 19 | /** 20 | * / - Main entry point URL 21 | * 22 | * @returns Static client files (Angular App) 23 | * */ 24 | router.get('/',function(req,res){ 25 | 26 | return rootCtrl.getApp(); 27 | 28 | }); 29 | 30 | app.use('/', router); 31 | }; 32 | 33 | 34 | 35 | module.exports = registerRoutes; -------------------------------------------------------------------------------- /public/client/controllers/docsController.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 3 | var docsController = function($scope,$location,$anchorScroll){ 4 | 5 | this.$scope = $scope; 6 | this.$location = $location; 7 | this.$anchorScroll = $anchorScroll; 8 | this.$anchorScroll.yOffset = 10; 9 | 10 | this.temp = 25; 11 | 12 | $scope.$on('$destroy', function(){ 13 | 14 | }.bind(this)); 15 | 16 | }; 17 | 18 | docsController.prototype.scrollTo = function(elementId){ 19 | this.$location.hash(elementId); 20 | this.$anchorScroll(); 21 | }; 22 | 23 | docsController.prototype.tempIsLargerThan = function(int){ 24 | if (this.temp > int) return true; 25 | else return false; 26 | } 27 | 28 | awmApp.controller('docsController', ['$scope','$location', '$anchorScroll',docsController]); 29 | 30 | })(); -------------------------------------------------------------------------------- /tests/mocks/Response.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | _status:null, 4 | _json:null, 5 | _redirectUrl:null, 6 | _cookies:{}, 7 | _headers:{}, 8 | _sendFile:null, 9 | 10 | status:function(code){ 11 | this._status = code; 12 | return this; 13 | }, 14 | json:function(hash){ 15 | this._json = hash; 16 | return this; 17 | }, 18 | redirect:function(redirectUrl){ 19 | this._redirectUrl = redirectUrl; 20 | return this; 21 | }, 22 | cookie:function(key,val,options){ 23 | this._cookies[key] = val; 24 | return this; 25 | }, 26 | sendFile:function(pathToFile){ 27 | this._sendFile = pathToFile; 28 | return this; 29 | }, 30 | set:function(key,val){ 31 | this._headers[key] = val; 32 | return this; 33 | }, 34 | get:function(key){ 35 | if (this._headers.hasOwnProperty(key)) return this._headers[key]; 36 | else return null; 37 | } 38 | 39 | }; -------------------------------------------------------------------------------- /tests/controllers/RootController.test.js: -------------------------------------------------------------------------------- 1 | var proxyquire = require('proxyquire'); 2 | var sinon = require('sinon'); 3 | var supertest = require('supertest'); 4 | var expect = require('expect'); 5 | 6 | var mockResponse = require('../mocks/Response'); 7 | var mockRequest = require('../mocks/Request'); 8 | 9 | var RootController = require('../../controllers/RootController'); 10 | 11 | describe('Root Controller', function () { 12 | 13 | var RootCtrl; 14 | 15 | beforeEach(function(){}); 16 | 17 | afterEach(function(){ 18 | 19 | }); 20 | 21 | it('Should return the AWM client-side application main entry file (index.html)',function(done){ 22 | 23 | var appPath = "/public/client/views/index.html"; 24 | 25 | RootCtrl = new RootController(mockRequest,mockResponse); 26 | RootCtrl.getApp(); 27 | expect(RootCtrl.response()._sendFile.indexOf(appPath)).toBeGreaterThanOrEqualTo(0); 28 | done(); 29 | }); 30 | 31 | }); -------------------------------------------------------------------------------- /tests/routes/root.test.js: -------------------------------------------------------------------------------- 1 | var proxyquire = require('proxyquire'); 2 | var sinon = require('sinon'); 3 | var supertest = require('supertest'); 4 | var expect = require('expect'); 5 | var express = require('express'); 6 | 7 | describe('Root route', function () { 8 | 9 | describe('GET /', function () { 10 | 11 | var app, 12 | request, 13 | route, 14 | moduleAfnX, 15 | sandbox; 16 | 17 | beforeEach(function () { 18 | 19 | app = express(); 20 | route = proxyquire('../../routes/root', {})(app); 21 | request = supertest(app); 22 | 23 | }); 24 | 25 | afterEach(function(){ 26 | 27 | }); 28 | 29 | it('Should return the AWM angular App', function (done) { 30 | 31 | request 32 | .get('/') 33 | .expect(200, function (err, res) { 34 | expect(res.text.indexOf('ng-app="awmApp"')).toBeGreaterThanOrEqualTo(0); 35 | done(); 36 | }); 37 | }); 38 | 39 | }); 40 | 41 | }); -------------------------------------------------------------------------------- /public/client/controllers/eventsController.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 3 | var eventsController = function($rootScope, $scope, eventsService){ 4 | 5 | this.$rootScope = $rootScope; 6 | this.$scope = $scope; 7 | 8 | this.events = []; 9 | this.eventsListener = null; 10 | 11 | this.eventsService = eventsService; 12 | this.eventsService.listen(); 13 | 14 | this.eventsListener = this.$rootScope.$on('events', function (event, data) { 15 | this.events = this.eventsService.getEvents(); 16 | this.$scope.$apply(); 17 | }.bind(this)); 18 | 19 | $scope.$on('$destroy', function(){ 20 | 21 | //Stop listening to notifications from eventService 22 | this.eventsListener(); 23 | 24 | //Close websocket connection 25 | this.eventsService.close(); 26 | 27 | }.bind(this)); 28 | 29 | }; 30 | 31 | awmApp.controller('eventsController', ['$rootScope', '$scope', 'eventsService' ,eventsController]); 32 | 33 | })(); -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const bodyParser = require('body-parser'); 3 | const cookieParser = require('cookie-parser') 4 | const registerRoutes = require('./routes'); 5 | var app = express(); 6 | var server = null; 7 | var io; 8 | 9 | if(!module.parent){ 10 | 11 | server = app.listen(3000,function(){ console.log('AWM Server started!'); }); 12 | 13 | app.use( bodyParser.json()); // to support JSON-encoded bodies 14 | app.use(bodyParser.urlencoded({extended: true})); // to support URL-encoded bodies 15 | 16 | app.use(express.static(__dirname + '/public')); 17 | app.use(cookieParser()); 18 | 19 | //Listen on the "/event" namespace 20 | io = require('socket.io')(server); 21 | io.of('/events').on('connection', function(socket){ 22 | socket.on('disconnect', function() { 23 | socket.disconnect(); 24 | }); 25 | }); 26 | 27 | } 28 | registerRoutes(app,io); 29 | 30 | 31 | 32 | 33 | 34 | module.exports.app = app; 35 | 36 | -------------------------------------------------------------------------------- /public/client/services/userService.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 3 | var userService = function($cookies,config){ 4 | 5 | this.$cookies = $cookies; 6 | this.config = config; 7 | 8 | /** 9 | * _user {AWM.User} 10 | * */ 11 | this._user = null; 12 | 13 | }; 14 | 15 | 16 | /** 17 | * isLoggedIn - a utility method checking if a "login cookie" exists 18 | * 19 | * @returns {Boolean} 20 | * */ 21 | userService.prototype.isLoggedIn = function(){ 22 | return this.$cookies.get(this.config.ACCESS_COOKIE) ? true : false; 23 | }; 24 | 25 | /** 26 | * getUser 27 | * 28 | * @returns {AWM.User} 29 | * */ 30 | userService.prototype.getUser = function(){ 31 | return this._user; 32 | }; 33 | 34 | /** 35 | * setUser 36 | * 37 | * @param {AWM.User} userObj 38 | * @returns {userService} 39 | * */ 40 | userService.prototype.setUser = function(userObj){ 41 | this._user = userObj; 42 | return this; 43 | }; 44 | 45 | 46 | userService.prototype.logout = function(){ 47 | this.$cookies.remove(this.config.ACCESS_COOKIE); 48 | return this; 49 | }; 50 | 51 | 52 | awmApp.service("userService", ['$cookies','config',userService]); 53 | 54 | })(); -------------------------------------------------------------------------------- /routes/oauth.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const asanaClient = require('../helpers/asanaClient'); 3 | const oauthController = require('../controllers/OauthController'); 4 | 5 | var registerRoutes = function(app,io){ 6 | 7 | var router = express.Router(); 8 | 9 | /** 10 | * /oauth/login 11 | * 12 | * Redirects the user to Asana's redirect uri 13 | * */ 14 | router.get('/asana/login',function(req,res){ 15 | 16 | var oauthCtrl = new oauthController(req,res); 17 | oauthCtrl.loginWithAsana(); 18 | 19 | }); 20 | 21 | 22 | /** 23 | * /oauth/asana 24 | * 25 | * "Redirect URI endpoint" for Asana to redirect the user back to once authentication is completed 26 | * */ 27 | router.get('/asana',function(req,res){ 28 | 29 | var oauthCtrl = new oauthController(req,res); 30 | 31 | var code = req.query.code; 32 | if (code) 33 | { 34 | return oauthCtrl.accessTokenFromCode(code); 35 | } 36 | else 37 | { 38 | oauthCtrl.reply(400,{},"Unable to exchange code for access token"); 39 | } 40 | 41 | }); 42 | 43 | app.use('/oauth', router); 44 | 45 | }; 46 | 47 | 48 | 49 | 50 | module.exports = registerRoutes; -------------------------------------------------------------------------------- /public/client/services/navigationService.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 3 | var navigationService = function($state){ 4 | 5 | this.$state = $state; 6 | 7 | this.pages = [ 8 | { 9 | title: "Home", 10 | state: "root", 11 | active: true, 12 | auth: false 13 | }, 14 | { 15 | title: "Manage", 16 | state: "manage", 17 | active: false, 18 | auth: true 19 | }, 20 | { 21 | title: "Live view", 22 | state: "events", 23 | active: false, 24 | auth: false 25 | }, 26 | { 27 | title: "Docs", 28 | state: "docs", 29 | active: false, 30 | auth: false 31 | } 32 | ]; 33 | 34 | }; 35 | 36 | 37 | navigationService.prototype.goToState = function(stateName,params,options){ 38 | 39 | if (typeof params == "undefined") params = {}; 40 | if (typeof options == "undefined") options = {}; 41 | 42 | if (this.$state.current.name == stateName) return; 43 | 44 | for (var i=0;i 4096 bytes)!");k.cookie=e}}c.module("ngCookies",["ng"]).provider("$cookies",[function(){var b=this.defaults={};this.$get=["$$cookieReader","$$cookieWriter",function(a,g){return{get:function(d){return a()[d]},getObject:function(d){return(d=this.get(d))?c.fromJson(d):d},getAll:function(){return a()},put:function(d,a,m){g(d,a,m?c.extend({},b,m):b)},putObject:function(d,b,a){this.put(d,c.toJson(b),a)},remove:function(a,k){g(a,void 0,k?c.extend({},b,k):b)}}}]}]);c.module("ngCookies").factory("$cookieStore", 8 | ["$cookies",function(b){return{get:function(a){return b.getObject(a)},put:function(a,c){b.putObject(a,c)},remove:function(a){b.remove(a)}}}]);l.$inject=["$document","$log","$browser"];c.module("ngCookies").provider("$$cookieWriter",function(){this.$get=l})})(window,window.angular); 9 | //# sourceMappingURL=angular-cookies.min.js.map -------------------------------------------------------------------------------- /tests/routes/events.test.js: -------------------------------------------------------------------------------- 1 | var proxyquire = require('proxyquire'); 2 | var sinon = require('sinon'); 3 | var supertest = require('supertest'); 4 | var expect = require('expect'); 5 | var express = require('express'); 6 | const bodyParser = require('body-parser'); 7 | var io; 8 | 9 | const EventsController = require('../../controllers/EventsController'); 10 | 11 | describe('Events route', function () { 12 | 13 | var app, 14 | request, 15 | route, 16 | sandbox, 17 | onIncomingEventsStub; 18 | 19 | beforeEach(function () { 20 | 21 | sandbox = sinon.sandbox.create(); 22 | onIncomingEventsStub = sandbox.stub(EventsController.prototype,'onIncomingEvents'); 23 | 24 | app = express(); 25 | app.use( bodyParser.json()); 26 | app.use(bodyParser.urlencoded({extended: true})); 27 | request = supertest(app); 28 | io = require('socket.io')(request); 29 | route = proxyquire('../../routes/events', {'../controllers/EventsController':onIncomingEventsStub})(app,io); 30 | 31 | }); 32 | 33 | afterEach(function(){ 34 | 35 | sandbox.restore(); 36 | 37 | }); 38 | 39 | 40 | it('Should pass event handling to eventController', function (done) { 41 | 42 | var xHookSecretValue = "ABCDE"; 43 | 44 | request 45 | .post('/events/incoming',{resourceId:'123456'}) 46 | .set('X-Hook-Secret', xHookSecretValue) 47 | .field('events', []) 48 | .send({events:[]}) 49 | .end(function(err,res){ 50 | expect(onIncomingEventsStub.calledOnce).toBeTruthy(); 51 | done(); 52 | }); 53 | 54 | }); 55 | 56 | }); -------------------------------------------------------------------------------- /helpers/configHelper.js: -------------------------------------------------------------------------------- 1 | const asanaConfig = require('../config/asana'); 2 | 3 | var config = {}; 4 | 5 | //Asana App Credentials 6 | config.clientId = getConfigValue(asanaConfig.clientId,'asana_client_id'); 7 | config.clientSecret = getConfigValue(asanaConfig.clientSecret,'asana_client_secret'); 8 | config.redirectUri = getConfigValue(asanaConfig.redirectUri,'asana_redirect_uri'); 9 | 10 | /** 11 | * isDefined - Determines is a value is assigned to the pased variable 12 | * 13 | * @param {Any} 14 | * @returns {Boolean} 15 | * */ 16 | function isDefined(param){ 17 | if (param != null && param != "") return true; 18 | else return false; 19 | } 20 | 21 | /** 22 | * getConfigValue - Returns a config value as defined in an environment variable, or in a config file, if exists 23 | * @param configFileValue {Any} 24 | * @param environmentKeyName {String} - a string representing an environment variable name 25 | * 26 | * @returns {String} or null 27 | * */ 28 | function getConfigValue(configFileValue,environmentKeyName){ 29 | var retval = configFileValue == null ? process.env[environmentKeyName] : configFileValue; 30 | return retval; 31 | } 32 | 33 | module.exports = { 34 | //Asana Client 35 | getClientId: function(){ return config.clientId; }, 36 | getClientSecret: function(){return config.clientSecret; }, 37 | getRediectUri: function(){return config.redirectUri; }, 38 | isReady: function(){ 39 | if (isDefined(config.clientId) && isDefined(config.clientSecret) && isDefined(config.redirectUri)) return true; 40 | else return false; 41 | } 42 | }; 43 | 44 | -------------------------------------------------------------------------------- /public/client/services/resolveService.js: -------------------------------------------------------------------------------- 1 | /** 2 | * resolveService 3 | * */ 4 | (function(){ 5 | 6 | var resolveService = function(asanaService,userService,navigationService,$q){ 7 | 8 | this.asanaService = asanaService; 9 | this.userService = userService; 10 | this.navigationService = navigationService; 11 | this.$q = $q; 12 | 13 | }; 14 | 15 | resolveService.prototype.resolveUser = function(){ 16 | 17 | var deferred = this.$q.defer(); 18 | 19 | this.asanaService.getUser().then( 20 | function(payload){ 21 | if (payload == null){ 22 | this.navigationService.goToState('root'); 23 | return false; 24 | } 25 | //Init workspace models 26 | var workspaces = []; 27 | for (var i=0;i 4 | 5 | */ 6 | 7 | .hljs { 8 | display: block; 9 | overflow-x: auto; 10 | padding: 0.5em; 11 | color: #333; 12 | background: #f8f8f8; 13 | } 14 | 15 | .hljs-comment, 16 | .hljs-quote { 17 | color: #998; 18 | font-style: italic; 19 | } 20 | 21 | .hljs-keyword, 22 | .hljs-selector-tag, 23 | .hljs-subst { 24 | color: #333; 25 | font-weight: bold; 26 | } 27 | 28 | .hljs-number, 29 | .hljs-literal, 30 | .hljs-variable, 31 | .hljs-template-variable, 32 | .hljs-tag .hljs-attr { 33 | color: #008080; 34 | } 35 | 36 | .hljs-string, 37 | .hljs-doctag { 38 | color: #d14; 39 | } 40 | 41 | .hljs-title, 42 | .hljs-section, 43 | .hljs-selector-id { 44 | color: #900; 45 | font-weight: bold; 46 | } 47 | 48 | .hljs-subst { 49 | font-weight: normal; 50 | } 51 | 52 | .hljs-type, 53 | .hljs-class .hljs-title { 54 | color: #458; 55 | font-weight: bold; 56 | } 57 | 58 | .hljs-tag, 59 | .hljs-name, 60 | .hljs-attribute { 61 | color: #000080; 62 | font-weight: normal; 63 | } 64 | 65 | .hljs-regexp, 66 | .hljs-link { 67 | color: #009926; 68 | } 69 | 70 | .hljs-symbol, 71 | .hljs-bullet { 72 | color: #990073; 73 | } 74 | 75 | .hljs-built_in, 76 | .hljs-builtin-name { 77 | color: #0086b3; 78 | } 79 | 80 | .hljs-meta { 81 | color: #999; 82 | font-weight: bold; 83 | } 84 | 85 | .hljs-deletion { 86 | background: #fdd; 87 | } 88 | 89 | .hljs-addition { 90 | background: #dfd; 91 | } 92 | 93 | .hljs-emphasis { 94 | font-style: italic; 95 | } 96 | 97 | .hljs-strong { 98 | font-weight: bold; 99 | } 100 | -------------------------------------------------------------------------------- /public/client/views/events.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | 6 | 9 | 10 |
11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 |
#ResourceUserTypeActionCreated atParent
{{$index+1}}{{event.getResource()}}{{event.getUser()}}{{event.getType()}}{{event.getAction()}}{{event.getCreatedAt()}}{{event.getParent()}}
38 |
39 |
40 |
41 |
42 |
43 | -------------------------------------------------------------------------------- /tests/helpers/config.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Libraries 3 | * */ 4 | const proxyquire = require('proxyquire'); 5 | const sinon = require('sinon'); 6 | const expect = require('expect'); 7 | 8 | var fakeConfig={ 9 | clientId: "ABCDEFG", 10 | clientSecret: "1234567", 11 | redirectUri: "http://www.fakeurl.com" 12 | }; 13 | 14 | var incompleteConfig={ 15 | clientId: "ABCDEFG", 16 | clientSecret: "", 17 | redirectUri: null 18 | }; 19 | 20 | /** 21 | * Module under test, with stubs 22 | * */ 23 | var configHelper = proxyquire('../../helpers/configHelper', {'../config/asana':fakeConfig}); 24 | 25 | 26 | /** 27 | * Tests 28 | * */ 29 | describe('Config helper',function(){ 30 | 31 | beforeEach(function () { 32 | var configHelper = proxyquire('../../helpers/configHelper', {'../config/asana':fakeConfig}); 33 | }); 34 | 35 | afterEach(function(){ 36 | 37 | }); 38 | 39 | it('Should return a client id',function(){ 40 | expect(configHelper.getClientId()).toBe(fakeConfig.clientId); 41 | }); 42 | 43 | it('Should return a client secret',function(){ 44 | expect(configHelper.getClientSecret()).toBe(fakeConfig.clientSecret); 45 | }); 46 | 47 | it('Should return a redirect uri',function(){ 48 | expect(configHelper.getRediectUri()).toBe(fakeConfig.redirectUri); 49 | }); 50 | 51 | it('Should return a "ready" if all configurations were specified',function(){ 52 | var configHelper = proxyquire('../../helpers/configHelper', {'../config/asana':fakeConfig}); 53 | expect(configHelper.isReady()).toBe(true); 54 | }); 55 | 56 | it('Should return a "not ready" if configurations were not specified',function(){ 57 | var configHelper = proxyquire('../../helpers/configHelper', {'../config/asana':incompleteConfig}); 58 | expect(configHelper.isReady()).toBe(false); 59 | }); 60 | 61 | }); 62 | -------------------------------------------------------------------------------- /public/client/models/AWMProject.js: -------------------------------------------------------------------------------- 1 | /** 2 | * AWM.Project 3 | * 4 | * A slim representation of an Asana Project, as detailed in: https://asana.com/developers/api-reference/projects 5 | * */ 6 | AWM.Project = function(id,name){ 7 | 8 | 9 | /** 10 | * _id {String} - Asana Project ID 11 | * */ 12 | this._id = null; 13 | 14 | /** 15 | * _name {String} - Project's name 16 | * */ 17 | this._name = null; 18 | 19 | /** 20 | * _webhooks {AWM.Webhook} - Project's webhook (if exists) 21 | * */ 22 | this._webhook = null; 23 | 24 | /** 25 | * Init instance with passed arguments 26 | * */ 27 | if (id) this.setId(id); 28 | if (name) this.setName(name); 29 | 30 | 31 | 32 | }; 33 | 34 | 35 | /** 36 | * getId - get project id 37 | * 38 | * @returns {String} 39 | * */ 40 | AWM.Project.prototype.getId = function(){ 41 | return this._id; 42 | }; 43 | 44 | /** 45 | * setId - sets project id 46 | * 47 | * @param {String} id 48 | * @return {AWM.Project} 49 | * */ 50 | AWM.Project.prototype.setId = function(id){ 51 | this._id = id; 52 | return this; 53 | }; 54 | 55 | /** 56 | * getName - get project name 57 | * 58 | * @returns {String} 59 | * */ 60 | AWM.Project.prototype.getName = function(){ 61 | return this._name; 62 | }; 63 | 64 | /** 65 | * setName - sets project name 66 | * 67 | * @param {String} name 68 | * @return {AWM.Project} 69 | * */ 70 | AWM.Project.prototype.setName = function(name){ 71 | this._name = name; 72 | return this; 73 | }; 74 | 75 | 76 | /** 77 | * getWebhooks - get project webhooks 78 | * 79 | * @returns {AWM.Webhook} 80 | * */ 81 | AWM.Project.prototype.getWebhook = function(){ 82 | return this._webhook; 83 | }; 84 | 85 | /** 86 | * setWebhooks - sets project webooks value 87 | * 88 | * @param {AWM.Webhook} webhook object 89 | * @returns {AWM.Project} 90 | * */ 91 | AWM.Project.prototype.setWebhook = function(webhook){ 92 | this._webhook = webhook; 93 | return this; 94 | }; -------------------------------------------------------------------------------- /tests/routes/oauth.test.js: -------------------------------------------------------------------------------- 1 | var proxyquire = require('proxyquire'); 2 | var sinon = require('sinon'); 3 | var supertest = require('supertest'); 4 | var expect = require('expect'); 5 | var express = require('express'); 6 | 7 | const oauthController = require('../../controllers/OauthController'); 8 | 9 | describe('OAuth route', function () { 10 | 11 | var app, 12 | request, 13 | route, 14 | sandbox, 15 | accessTokenFromCodeStub; 16 | 17 | beforeEach(function () { 18 | 19 | sandbox = sinon.sandbox.create(); 20 | accessTokenFromCodeStub = sandbox.stub(oauthController.prototype,'accessTokenFromCode'); 21 | 22 | app = express(); 23 | route = proxyquire('../../routes/oauth', {'oauthController':accessTokenFromCodeStub})(app); 24 | request = supertest(app); 25 | 26 | }); 27 | 28 | afterEach(function(){ 29 | sandbox.restore(); 30 | }); 31 | 32 | it('Should redirect to asana login page', function (done) { 33 | 34 | request 35 | .get('/oauth/asana/login') 36 | .expect(0,function(err,res){ 37 | expect(res.statusCode).toBeGreaterThanOrEqualTo(301,'Expecting redirect code of 301'); 38 | expect(res.statusCode).toBeLessThanOrEqualTo(302,'Expecting redirect code of 302'); 39 | done(); 40 | }); 41 | 42 | }); 43 | 44 | it('Should return 400 if code is missing in URL query', function (done) { 45 | 46 | request 47 | .get('/oauth/asana') 48 | .expect(0,function(err,res){ 49 | expect(res.statusCode).toBe(400); 50 | done(); 51 | }); 52 | 53 | }); 54 | 55 | it('Should call convert code to token when code is present', function (done) { 56 | 57 | 58 | accessTokenFromCodeStub.callsFake(function(){this.reply(200,{});}); 59 | 60 | 61 | request 62 | .get('/oauth/asana') 63 | .query({code: '123445'}) 64 | .expect(0, function (err, res) { 65 | expect(accessTokenFromCodeStub.calledOnce).toBeTruthy(); 66 | done(); 67 | }); 68 | }); 69 | 70 | 71 | }); -------------------------------------------------------------------------------- /routes/asana.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const AsanaController = require('../controllers/AsanaController'); 3 | 4 | var asanaCtrl = null; 5 | 6 | var registerRoutes = function(app,io){ 7 | 8 | var router = express.Router(); 9 | 10 | router.all('/*',function(req,res,next){ 11 | 12 | asanaCtrl = new AsanaController(req,res); 13 | 14 | if (asanaCtrl instanceof AsanaController) { 15 | next(); 16 | } 17 | 18 | }); 19 | 20 | /** 21 | * GET /me - returns the currently logged in user 22 | * */ 23 | router.get('/me',function(req,res) { 24 | return asanaCtrl.getUser(); 25 | }); 26 | 27 | /** 28 | * GET /workspaces - returns a list of workspaces 29 | * */ 30 | router.get('/workspaces',function(req,res) { 31 | return asanaCtrl.getWorkspaces(); 32 | 33 | 34 | }); 35 | 36 | /** 37 | * GET /webhooks/ - Returns a list of all available webhooks for a workspace 38 | * */ 39 | router.get('/webhooks/:workspaceId',function(req,res) { 40 | 41 | asanaCtrl.getWebhooks(req.params.workspaceId) 42 | 43 | }); 44 | 45 | /** 46 | * POST /webhoooks/ - creates a webhook for a specific resource 47 | * */ 48 | router.post('/webhooks/:resourceId',function(req,res) { 49 | 50 | return asanaCtrl.createWebhook(req.params.resourceId); 51 | 52 | 53 | }); 54 | 55 | /** 56 | * DELETE /webhooks// - removes a webhook by it's Id and resourceId 57 | * */ 58 | router.delete('/webhooks/:webhookId/:resourceId',function(req,res){ 59 | 60 | return asanaCtrl.removeWebhook(req.params.webhookId.toString(),req.params.resourceId.toString()); 61 | 62 | 63 | }); 64 | 65 | /** 66 | * GET /projects - returns a list of projects for a given workspaceId and their webhookIds if available 67 | * */ 68 | router.get('/projects/:workspaceId',function(req,res) { 69 | 70 | return asanaCtrl.getProjectsWithWebhooks(req.params.workspaceId); 71 | 72 | }); 73 | 74 | app.use('/asana', router); 75 | 76 | }; 77 | 78 | 79 | 80 | module.exports = registerRoutes; -------------------------------------------------------------------------------- /public/client/models/AWMPhoto.js: -------------------------------------------------------------------------------- 1 | /** 2 | * AWM.Photo 3 | * 4 | * An Object wrapping Asana User photo dictionary 5 | * 6 | * */ 7 | AWM.Photo = function(){ 8 | 9 | /** 10 | * 21x21 image url 11 | * */ 12 | this.xs = null; 13 | 14 | /** 15 | * 27x27 image url 16 | * */ 17 | this.s = null; 18 | 19 | /** 20 | * 36x36 image url 21 | * */ 22 | this.m = null; 23 | 24 | /** 25 | * 60x60 image url 26 | * */ 27 | this.l = null; 28 | 29 | /** 30 | * 128x128 image url 31 | * */ 32 | this.xl = null; 33 | 34 | }; 35 | 36 | 37 | /** 38 | * get - returns the value of a specific image size 39 | * 40 | * @param {String} size - allowed values are "xs", "s", "m" , "l", "xl" 41 | * @returns {String} image url 42 | * */ 43 | AWM.Photo.prototype.get = function(size){ 44 | if (this.hasOwnProperty(size)) return this[size]; 45 | else return null; 46 | }; 47 | 48 | /** 49 | * set - assigns a url to a specific image size 50 | * 51 | * @param {String} size - allowed values are "xs", "s", "m" , "l", "xl" 52 | * @param {String} url - image full URL 53 | * @returns {AWM.Photo} 54 | * */ 55 | AWM.Photo.prototype.set = function(size,url){ 56 | if (this.hasOwnProperty(size)) this[size] = url; 57 | else throw new Error('AWM.Photo - unable to set url for size: {size}. No such size.'); 58 | 59 | return this; 60 | }; 61 | 62 | /** 63 | * initFromPayload - a helper method to set all instance properties directly from Asana response photo payload, as returned by the /me endpoint 64 | * 65 | * @param {Dictionary} payload - expected format: 66 | * { 67 | * image_21x21: "} 16 | * populated by incoming events from the server of the socket 17 | * */ 18 | this.events = []; 19 | 20 | }; 21 | 22 | /** 23 | * listen - Establishes a websocket connection to the server 24 | * @returns {void} 25 | * */ 26 | eventsService.prototype.listen = function(){ 27 | 28 | if (this.socket == null ) this.socket = io('/events'); 29 | 30 | this.socket.connect(); 31 | 32 | this.socket.on('events',function(data){ 33 | 34 | for(var i=0;i} 63 | * */ 64 | eventsService.prototype.getEvents = function(){ 65 | return this.events 66 | }; 67 | 68 | /** 69 | * clearEvents - clears out the events property 70 | * @returns {void} 71 | * */ 72 | eventsService.prototype.clear = function(){ 73 | this.events = []; 74 | }; 75 | 76 | /** 77 | * createEvent 78 | * @param {JSON} eventPayload - an individual event json payload as returned from asana 79 | * @returns {AWM.Event} 80 | * */ 81 | eventsService.prototype.createEvent = function(eventPayload){ 82 | return new AWM.Event() 83 | .setResource(eventPayload.resource) 84 | .setUser(eventPayload.resource) 85 | .setType(eventPayload.type) 86 | .setAction(eventPayload.action) 87 | .setCreatedAt(eventPayload.created_at) 88 | .setParent(eventPayload.parent); 89 | }; 90 | 91 | awmApp.service("eventsService", ['$rootScope', eventsService]); 92 | 93 | })(); -------------------------------------------------------------------------------- /tests/controllers/OauthController.test.js: -------------------------------------------------------------------------------- 1 | var proxyquire = require('proxyquire'); 2 | var sinon = require('sinon'); 3 | var supertest = require('supertest'); 4 | var expect = require('expect'); 5 | 6 | var mockResponse = require('../mocks/Response'); 7 | var mockRequest = require('../mocks/Request'); 8 | 9 | var asanaClient = require('../../helpers/asanaClient'); 10 | 11 | 12 | describe('OAuth Controller', function () { 13 | 14 | var OAuthCtrl; 15 | 16 | beforeEach(function(){}); 17 | 18 | afterEach(function(){}); 19 | 20 | it('Should redirect to asana Authorize Url',function(done){ 21 | 22 | var asanaAuthorizeUrlStub = "http://www.neverland.com"; 23 | 24 | var asanaClientStub = function(){ 25 | return { 26 | app: 27 | { 28 | asanaAuthorizeUrl:function(){ 29 | return asanaAuthorizeUrlStub 30 | } 31 | } 32 | }; 33 | }; 34 | 35 | var OAuthController = proxyquire('../../controllers/OauthController',{'../helpers/asanaClient':asanaClientStub}); 36 | OAuthCtrl = new OAuthController(mockRequest,mockResponse); 37 | 38 | OAuthCtrl.loginWithAsana(); 39 | expect(OAuthCtrl.response()._redirectUrl).toEqual(asanaAuthorizeUrlStub); 40 | done(); 41 | 42 | }); 43 | 44 | it('Should convert code to token and save it as a cookie, then redirect to main app route (/)',function(done){ 45 | 46 | var asanaClientStub = function(){ 47 | return { 48 | app: 49 | { 50 | accessTokenFromCode:function(){ 51 | return new Promise(function(resolve,reject){ 52 | setTimeout(function(){ 53 | resolve({access_token:"fakeToken"}); 54 | },0); 55 | }); 56 | } 57 | } 58 | }; 59 | }; 60 | 61 | var OAuthController = proxyquire('../../controllers/OauthController',{'../helpers/asanaClient':asanaClientStub}); 62 | OAuthCtrl = new OAuthController(mockRequest,mockResponse); 63 | 64 | OAuthCtrl.accessTokenFromCode("fakeCode") 65 | .then(function(response){ 66 | expect(OAuthCtrl.response()._cookies.hasOwnProperty('token')).toBeTruthy(); 67 | expect(OAuthCtrl.response()._cookies.hasOwnProperty('awm_login')).toBeTruthy("Missing awm_login cookie!"); 68 | expect(OAuthCtrl.response()._redirectUrl).toEqual("/"); 69 | done(); 70 | }) 71 | .catch(function(err){ 72 | throw new Error(err); 73 | done(); 74 | }); 75 | 76 | }); 77 | 78 | }); -------------------------------------------------------------------------------- /public/client/models/AWMWebhook.js: -------------------------------------------------------------------------------- 1 | AWM.Webhook = function(){ 2 | 3 | /** 4 | * id {String} - A Unique webhook ID 5 | * */ 6 | this._id = null; 7 | 8 | /** 9 | * active {Boolean} - The webhook status 10 | * */ 11 | this._active = null; 12 | 13 | /** 14 | * resource {AWM.Project} 15 | * */ 16 | this._resource = null; 17 | 18 | /** 19 | * target {String} - Webhook Target URL 20 | * */ 21 | this._target = null; 22 | 23 | }; 24 | 25 | /** 26 | * getId - get webhook id 27 | * 28 | * @returns {String} 29 | * */ 30 | AWM.Webhook.prototype.getId = function(){ 31 | return this._id; 32 | }; 33 | 34 | /** 35 | * setId - sets webhook id 36 | * 37 | * @param {String} id 38 | * @return {AWM.Project} 39 | * */ 40 | AWM.Webhook.prototype.setId = function(id){ 41 | this._id = id; 42 | return this; 43 | }; 44 | 45 | /** 46 | * getActice - get webhook status 47 | * 48 | * @returns {Boolean} 49 | * */ 50 | AWM.Webhook.prototype.getActive = function(){ 51 | return this._active; 52 | }; 53 | 54 | /** 55 | * setId - sets webhook status 56 | * 57 | * @param {Boolean} value 58 | * @returns {AWM.Webhook} 59 | * */ 60 | AWM.Webhook.prototype.setActive = function(value){ 61 | this._active = value; 62 | return this; 63 | }; 64 | 65 | /** 66 | * isActive - a convenience/alias method for getActive() 67 | * 68 | * @returns {Boolean} 69 | * */ 70 | AWM.Webhook.prototype.isActive = function(){ 71 | return this.getActive(); 72 | }; 73 | 74 | 75 | /** 76 | * getResource - returns the resource object associated with the webhook 77 | * 78 | * @returns {AWM.Project} 79 | * */ 80 | AWM.Webhook.prototype.getResource = function(){ 81 | return this._resource; 82 | }; 83 | 84 | /** 85 | * setResource 86 | * 87 | * @param {AWM.Project} 88 | * @returns {AWM.Webhook} 89 | * */ 90 | AWM.Webhook.prototype.setResource = function(resource){ 91 | this._resource = resource; 92 | return this; 93 | }; 94 | 95 | 96 | /** 97 | * getTarget 98 | * 99 | * @returns {String} targetUrl 100 | * */ 101 | AWM.Webhook.prototype.getTarget = function(){ 102 | return this._target; 103 | }; 104 | 105 | /** 106 | * setTarget 107 | * 108 | * @param {String} targetUrl 109 | * @returns {AWM.Webhook} 110 | * */ 111 | AWM.Webhook.prototype.setTarget = function(targetUrl){ 112 | this._target = targetUrl; 113 | return this; 114 | }; 115 | 116 | -------------------------------------------------------------------------------- /controllers/AWMController.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * AWMController - A Base controller with utility methods 5 | * Mainly used to validate auth, define a default JSON response structor and helper methods, etc 6 | * 7 | * @param {http.IncomingMessage} req 8 | * @param {http.ServerResponse} res 9 | * 10 | * @returns {AWMController} 11 | * */ 12 | class AWMController { 13 | 14 | constructor(req,res){ 15 | 16 | /** 17 | * request - {http.IncomingMessage} 18 | * */ 19 | this._request = req; 20 | 21 | /** 22 | * response - Express response Object 23 | * */ 24 | this._response = res; 25 | 26 | /** 27 | * defaultMessages {Dictionary} - default textual messages for commonly used response codes 28 | * */ 29 | this._defaultMessages = { 30 | 100: "Continue", 31 | 101: "Switching Protocols", 32 | 200: "OK", 33 | 201: "Created", 34 | 202: "Accepted", 35 | 400: "Bed Request", 36 | 401: "Unauthorized", 37 | 403: "Forbidden", 38 | 404: "Not Found", 39 | 405: "Method Not Allowed" 40 | } 41 | 42 | 43 | } 44 | 45 | /** 46 | * getDefaultMessageForCode 47 | * 48 | * @param {Integer} code 49 | * @return {String} Default HTTP Status code description 50 | * */ 51 | getDefaultMessageForCode(code) { 52 | if (this._defaultMessages.hasOwnProperty(code)) { return this._defaultMessages[code]; } 53 | else return ""; 54 | } 55 | 56 | /** 57 | * request 58 | * @rertuns {http.request} 59 | * */ 60 | request(){ 61 | return this._request; 62 | } 63 | 64 | /** 65 | * response 66 | * @returns {http.response} 67 | * */ 68 | response(){ 69 | return this._response; 70 | } 71 | 72 | 73 | 74 | /** 75 | * reply - a utility function to define a default JSON response structure 76 | * 77 | * @param {Integer} code - an HTTP response code 78 | * @param {} data - A response payload 79 | * @param {String} msg - An optional message string 80 | * */ 81 | reply(code,data,msg){ 82 | 83 | if (!code) throw new Error(this.constructor.name + " response must contain a status code"); 84 | if (!data) data = {}; 85 | if (!msg) msg = this.getDefaultMessageForCode(code); 86 | 87 | return this._response.status(code).json( 88 | { 89 | code: code, 90 | data: data, 91 | msg: msg 92 | } 93 | ); 94 | } 95 | 96 | 97 | } 98 | 99 | module.exports = AWMController; 100 | -------------------------------------------------------------------------------- /public/css/style.css: -------------------------------------------------------------------------------- 1 | .btn-primary-asana,.btn-primary-asana:visited {background-color:#6a67ce; color:#fff; } 2 | .btn-primary-asana:hover,.btn-primary-asana:active {color:#a177ff;box-shadow:0 0 0 3px #d0bbff; color:#fff;} 3 | 4 | .btn-info-asana,.btn-info-asana:visited {background-color:#1aafd0; color:#fff; } 5 | .btn-info-asana:hover {color:#a177ff;box-shadow:0 0 0 3px rgba(26,175,208,0.3); color:#fff;} 6 | 7 | .bold {font-weight: bold;} 8 | 9 | .nav-bar-user-image {float:left; position: relative; left: -7px; top: -3px; border-radius: 50%;} 10 | 11 | .awm-section { 12 | border-bottom: 1px solid #f1f1f1; 13 | padding-top: 50px; 14 | padding-bottom: 60px; 15 | } 16 | 17 | .awm-alert { 18 | margin-bottom: 0px; 19 | border-radius: 0px; 20 | }; 21 | 22 | .awm-sidebar { list-style: none; margin-top:65px; } 23 | .awm-sidebar li { padding: 3px 0px; margin-top: 10px; } 24 | .awm-sidebar li ul { padding-left: 10px;} 25 | .awm-sidebar li ul li { list-style:none; padding: 3px 0px; margin:0px } 26 | 27 | img.awm-img-full-width {width:100%; height:auto;} 28 | /** 29 | Manage webhooks 30 | */ 31 | .table-manage-webhooks tbody tr {cursor: pointer;} 32 | .table-manage-webhooks tbody tr td { line-height: 2 !important; } 33 | 34 | /** 35 | CSS Loader 36 | */ 37 | .loader, 38 | .loader:after { 39 | border-radius: 50%; 40 | width: 10em; 41 | height: 10em; 42 | } 43 | .loader { 44 | font-size: 10px; 45 | /*position: relative;*/ 46 | text-indent: -9999em; 47 | border-top: 1.1em solid rgba(106, 103, 206, 0.2); 48 | border-right: 1.1em solid rgba(106, 103, 206, 0.2); 49 | border-bottom: 1.1em solid rgba(106, 103, 206, 0.2); 50 | border-left: 1.1em solid #6a67ce; 51 | -webkit-transform: translateZ(0); 52 | -ms-transform: translateZ(0); 53 | transform: translateZ(0); 54 | -webkit-animation: load8 1.1s infinite linear; 55 | animation: load8 1.1s infinite linear; 56 | 57 | width:100px; 58 | height: 100px; 59 | position: fixed; 60 | top: 50%; 61 | left: 50%; 62 | margin-top:-50px; 63 | margin-left:-50px; 64 | 65 | } 66 | @-webkit-keyframes load8 { 67 | 0% { 68 | -webkit-transform: rotate(0deg); 69 | transform: rotate(0deg); 70 | } 71 | 100% { 72 | -webkit-transform: rotate(360deg); 73 | transform: rotate(360deg); 74 | } 75 | } 76 | @keyframes load8 { 77 | 0% { 78 | -webkit-transform: rotate(0deg); 79 | transform: rotate(0deg); 80 | } 81 | 100% { 82 | -webkit-transform: rotate(360deg); 83 | transform: rotate(360deg); 84 | } 85 | } -------------------------------------------------------------------------------- /controllers/EventsController.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const AWMController = require('./AWMController'); 4 | const AWMEvent = require('../models/event'); 5 | const AWMWebhook = require('../models/webhook'); 6 | const CryptoJS = require("crypto-js"); 7 | const mongodb = require('../helpers/mongodbHelper'); 8 | 9 | class EventsController extends AWMController { 10 | 11 | constructor(req, res,io) { 12 | super(req, res); 13 | 14 | this.socket = io.of('/events'); 15 | 16 | } 17 | 18 | onIncomingEvents(){ 19 | 20 | if (this.request().get('X-Hook-Secret') != null) return this.handshake(); 21 | else if (this.request().get('X-Hook-Signature') != null) return this.handle(); 22 | else return this.reply(403,{}); 23 | 24 | } 25 | 26 | handshake(){ 27 | 28 | //Get X-Hook-Secret form the request object 29 | var xHookSecret = this.request().get('X-Hook-Secret'); 30 | 31 | //Store webhook secret for validating incoming event request 32 | mongodb.getConnection(); 33 | return new AWMWebhook({ 34 | resource_id: this.request().params.resourceId, 35 | secret: xHookSecret 36 | }).save() 37 | .then(function(){ 38 | //Response to in-flight webhook creation request 39 | this.response().set('X-Hook-Secret',xHookSecret); 40 | return this.reply(200,{}); 41 | }.bind(this)) 42 | .catch(); 43 | 44 | 45 | 46 | } 47 | 48 | handle(){ 49 | 50 | //Verify signature header exists 51 | var xHookSignatureHeader = this.request().get('X-Hook-Signature'); 52 | if (xHookSignatureHeader == null) return this.reply(403,{},"Unauthorized request"); 53 | 54 | //Verify webhook is listed internally 55 | mongodb.getConnection(); 56 | return AWMWebhook.findOne({resource_id: this.request().params.resourceId.toString()}).exec().then(function(webhook){ 57 | 58 | //Webhook was not found, deny request 59 | if (typeof webhook == "undefined" || webhook == null || webhook.length == 0) return this.reply(400, {},"Unknown webhook"); 60 | 61 | //Match encrypted request payload against header header, using secret from original webhook handshake 62 | var encryptedRequestBody = CryptoJS.HmacSHA256(JSON.stringify(this.request().body),webhook.secret).toString(); 63 | if (xHookSignatureHeader !== encryptedRequestBody) return this.reply(403,{},"Unauthorized request"); 64 | else { 65 | 66 | //At this point the request is fully validated and can be processed 67 | var eventsArray = this.request().body.events; 68 | AWMEvent.insertMany(eventsArray, function(error, docs) {}); 69 | 70 | this.socket.emit('events', this.request().body.events); 71 | 72 | return this.reply(200,{}); 73 | } 74 | }.bind(this)); 75 | 76 | } 77 | 78 | } 79 | 80 | module.exports = EventsController; -------------------------------------------------------------------------------- /public/client/views/header.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /tests/helpers/mongodb.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Libraries 3 | * */ 4 | const proxyquire = require('proxyquire'); 5 | const sinon = require('sinon'); 6 | const expect = require('expect'); 7 | 8 | var mongoDBHelper; 9 | /** 10 | * Tests 11 | * */ 12 | describe('MongoDB helper',function(){ 13 | 14 | beforeEach(function () { 15 | }); 16 | 17 | afterEach(function(){ 18 | 19 | }); 20 | 21 | it('Should return a mongoose connection',function(){ 22 | 23 | var mongoose = require('mongoose'); 24 | var mongooseStub = sinon.stub(mongoose,'connect').callsFake(function(){return {}}); 25 | var mongoDBHelper = proxyquire('../../helpers/mongodbHelper',{'mongoose':mongooseStub}); 26 | 27 | var mongoConnection = mongoDBHelper.getConnection(); 28 | expect(mongooseStub.calledOnce).toBeTruthy(); 29 | mongooseStub.restore(); 30 | 31 | }); 32 | 33 | it('Should return a mongoDB connection string with username / password',function(){ 34 | var fakeConfig={ 35 | username: "someUser", 36 | password: "somePass", 37 | host: "127.0.0.1", 38 | port: "27017", 39 | database: "awm" 40 | }; 41 | mongoDBHelper = proxyquire('../../helpers/mongodbHelper', {'../config/mongodb':fakeConfig}); 42 | expect(mongoDBHelper.getMongoDBConnectionString()).toBe("mongodb://someUser:somePass@127.0.0.1:27017/awm"); 43 | }); 44 | 45 | it('Should return a mongoDB connection string without a user / password if values were not set for these keys',function(){ 46 | var fakeConfig={ 47 | username: null, 48 | password: null, 49 | host: "127.0.0.1", 50 | port: "27017", 51 | database: "awm" 52 | }; 53 | mongoDBHelper = proxyquire('../../helpers/mongodbHelper', {'../config/mongodb':fakeConfig}); 54 | expect(mongoDBHelper.getMongoDBConnectionString()).toBe("mongodb://127.0.0.1:27017/awm"); 55 | }); 56 | 57 | it('Should use default values for host and port if non were defined',function(){ 58 | var fakeConfig={ 59 | username: null, 60 | password: null, 61 | host: null, 62 | port: null, 63 | database: "awm" 64 | }; 65 | mongoDBHelper = proxyquire('../../helpers/mongodbHelper', {'../config/mongodb':fakeConfig}); 66 | expect(mongoDBHelper.getMongoDBConnectionString()).toBe("mongodb://127.0.0.1:27017/awm"); 67 | }); 68 | 69 | it('Should throw an error if now database name was not set',function(){ 70 | var fakeConfig={ 71 | username: null, 72 | password: null, 73 | host: null, 74 | port: null, 75 | database: null 76 | }; 77 | mongoDBHelper = proxyquire('../../helpers/mongodbHelper', {'../config/mongodb':fakeConfig}); 78 | 79 | var throwingMethod = function(){ 80 | mongoDBHelper.getMongoDBConnectionString(); 81 | }; 82 | 83 | expect(throwingMethod).toThrow(/Missing mongodb database configuration/); 84 | 85 | }); 86 | 87 | 88 | 89 | }); 90 | -------------------------------------------------------------------------------- /public/client/views/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 |
69 |
70 | 71 | -------------------------------------------------------------------------------- /tests/controllers/AWMController.test.js: -------------------------------------------------------------------------------- 1 | var proxyquire = require('proxyquire'); 2 | var sinon = require('sinon'); 3 | var supertest = require('supertest'); 4 | var expect = require('expect'); 5 | 6 | var mockResponse = require('../mocks/Response'); 7 | var mockRequest = require('../mocks/Request'); 8 | 9 | var AWMController = require('../../controllers/AWMController'); 10 | 11 | describe('AWM Controller', function () { 12 | 13 | var AWMCtrl; 14 | 15 | before(function(){ 16 | AWMCtrl = new AWMController(mockRequest,mockResponse); 17 | }); 18 | 19 | after(function(){}); 20 | 21 | it('Should expose the passed request and response objects via public methods)',function(done){ 22 | expect(AWMCtrl.request()).toBe(mockRequest); 23 | expect(AWMCtrl.response()).toBe(mockResponse); 24 | done(); 25 | }); 26 | 27 | it('should provide a reply method to easily return structured json responses',function(done){ 28 | 29 | var responseCode = 200; 30 | var responseData = {key:"value"}; 31 | var responseMsg = "Test string"; 32 | 33 | AWMCtrl.reply(responseCode,responseData,responseMsg); 34 | 35 | expect(mockResponse._status).toEqual(200); 36 | expect(mockResponse._json).toEqual({code: responseCode, data: responseData, msg: responseMsg}); 37 | 38 | done(); 39 | }); 40 | 41 | it('should return a default message per status code, if it was not provided when calling reply - test for code 200',function(done){ 42 | 43 | var responseCode = 200; 44 | var responseData = {key:"value"}; 45 | 46 | var expecetedResponseMsg = "OK"; 47 | 48 | AWMCtrl.reply(responseCode,responseData); 49 | 50 | expect(mockResponse._status).toEqual(responseCode); 51 | expect(mockResponse._json).toEqual({code: responseCode, data: responseData, msg: expecetedResponseMsg}); 52 | 53 | done(); 54 | }); 55 | 56 | it('should return a empty string, if no message was defined for the provided code - test for code 500',function(done){ 57 | 58 | var responseCode = 500; 59 | var responseData = {key:"value"}; 60 | 61 | var expecetedResponseMsg = ""; 62 | 63 | AWMCtrl.reply(responseCode,responseData); 64 | 65 | expect(mockResponse._status).toEqual(responseCode); 66 | expect(mockResponse._json).toEqual({code: responseCode, data: responseData, msg: expecetedResponseMsg}); 67 | 68 | done(); 69 | }); 70 | 71 | it('should support passing an undefined data payload',function(done){ 72 | 73 | var responseCode = 200; 74 | var responseData = undefined; 75 | 76 | var expecetedResponseMsg = "OK"; 77 | 78 | AWMCtrl.reply(responseCode,responseData); 79 | 80 | expect(mockResponse._status).toEqual(responseCode); 81 | expect(mockResponse._json).toEqual({code: responseCode, data: {}, msg: expecetedResponseMsg}); 82 | 83 | done(); 84 | }); 85 | 86 | it('should throw an exception when calling reply() without a code argument',function(done){ 87 | 88 | var throwingMethod = function(){ 89 | AWMCtrl.reply(); 90 | }; 91 | 92 | expect(throwingMethod).toThrow(/AWMController response must contain a status code/); 93 | done(); 94 | 95 | }); 96 | 97 | }); -------------------------------------------------------------------------------- /public/client/models/AWMEvent.js: -------------------------------------------------------------------------------- 1 | AWM.Event = function(){ 2 | 3 | /** 4 | * resource {Integer} - resource ID (project/task) 5 | * */ 6 | this._resource = null; 7 | 8 | /** 9 | * user {Integer} - user ID to trigger the event 10 | * */ 11 | this._user = null; 12 | 13 | /** 14 | * type {String} (story/task/...) 15 | * */ 16 | this._type = null; 17 | 18 | /** 19 | * action {String} - (changed/removed/added/...) 20 | * */ 21 | this._action = null; 22 | 23 | /** 24 | * created_at {String} - (ISO Date String) 25 | * */ 26 | this._created_at = null; 27 | 28 | /** 29 | * parent {Integer} 30 | * */ 31 | this._parent = null; 32 | 33 | 34 | }; 35 | 36 | /** 37 | * getResource 38 | * 39 | * @returns {Integer} 40 | * */ 41 | AWM.Event.prototype.getResource = function(){ 42 | return this._resource; 43 | }; 44 | 45 | /** 46 | * setResource 47 | * 48 | * @param {Integer} value 49 | * @return {AWM.Event} 50 | * */ 51 | AWM.Event.prototype.setResource = function(value){ 52 | this._resource = value; 53 | return this; 54 | }; 55 | 56 | /** 57 | * getUser 58 | * 59 | * @returns {Integer} 60 | * */ 61 | AWM.Event.prototype.getUser = function(){ 62 | return this._user; 63 | }; 64 | 65 | /** 66 | * setUser 67 | * 68 | * @param {Integer} value 69 | * @return {AWM.Event} 70 | * */ 71 | AWM.Event.prototype.setUser = function(value){ 72 | this._user = value; 73 | return this; 74 | }; 75 | 76 | /** 77 | * getType 78 | * 79 | * @returns {Integer} 80 | * */ 81 | AWM.Event.prototype.getType = function(){ 82 | return this._type; 83 | }; 84 | 85 | /** 86 | * setType 87 | * 88 | * @param {Integer} value 89 | * @return {AWM.Event} 90 | * */ 91 | AWM.Event.prototype.setType = function(value){ 92 | this._type = value; 93 | return this; 94 | }; 95 | 96 | /** 97 | * getAction 98 | * 99 | * @returns {String} 100 | * */ 101 | AWM.Event.prototype.getAction = function(){ 102 | return this._action; 103 | }; 104 | 105 | /** 106 | * setAction 107 | * 108 | * @param {String} value 109 | * @return {AWM.Event} 110 | * */ 111 | AWM.Event.prototype.setAction = function(value){ 112 | this._action = value; 113 | return this; 114 | }; 115 | 116 | /** 117 | * getCreatedAt 118 | * 119 | * @returns {String} 120 | * */ 121 | AWM.Event.prototype.getCreatedAt = function(){ 122 | return this._created_at; 123 | }; 124 | 125 | /** 126 | * setCreatedAt 127 | * 128 | * @param {String} value 129 | * @return {AWM.Event} 130 | * */ 131 | AWM.Event.prototype.setCreatedAt = function(value){ 132 | this._created_at = value; 133 | return this; 134 | }; 135 | 136 | 137 | /** 138 | * getParent 139 | * 140 | * @returns {Integer} 141 | * */ 142 | AWM.Event.prototype.getParent = function(){ 143 | return this._parent; 144 | }; 145 | 146 | /** 147 | * setParent 148 | * 149 | * @param {Integer} value 150 | * @return {AWM.Event} 151 | * */ 152 | AWM.Event.prototype.setParent = function(value){ 153 | this._parent = value; 154 | return this; 155 | }; 156 | -------------------------------------------------------------------------------- /public/client/models/AWMUser.js: -------------------------------------------------------------------------------- 1 | /** 2 | * AWM.User 3 | * 4 | * A full representation of an Asana User data structure as detailed in: 5 | * https://asana.com/developers/api-reference/users 6 | * */ 7 | AWM.User = function(id,name,email,photo,workspaces){ 8 | 9 | 10 | /** 11 | * _id {String} - A User's Asana ID 12 | * */ 13 | this._id = null; 14 | 15 | /** 16 | * _name {String} - User's full name 17 | * */ 18 | this._name = null; 19 | 20 | /** 21 | * _email {String} - User's email address 22 | * */ 23 | this._email = null; 24 | 25 | /** 26 | * _photo {AWM.Photo} 27 | * */ 28 | this._photo = null; 29 | 30 | /** 31 | * _workspaces {[AWM.Workspace]} 32 | * */ 33 | this._workspaces = null; 34 | 35 | 36 | /** 37 | * Init instance with passed arguments 38 | * */ 39 | if (id) this.setId(id); 40 | if (name) this.setName(name); 41 | if (email) this.setEmail(email); 42 | if (photo) this.setImage(photo); 43 | if (workspaces) this.setWorkspaces(workspaces); 44 | 45 | }; 46 | 47 | 48 | /** 49 | * getId - get user's id 50 | * 51 | * @returns {String} 52 | * */ 53 | AWM.User.prototype.getId = function(){ 54 | return this._id; 55 | }; 56 | 57 | /** 58 | * setId - sets user's id 59 | * 60 | * @param {String} id 61 | * @return {AWM.User} 62 | * */ 63 | AWM.User.prototype.setId = function(id){ 64 | this._id = id; 65 | return this; 66 | }; 67 | 68 | /** 69 | * getName - get user's name 70 | * 71 | * @returns {String} 72 | * */ 73 | AWM.User.prototype.getName = function(){ 74 | return this._name; 75 | }; 76 | 77 | /** 78 | * setName - sets users name 79 | * 80 | * @param {String} name 81 | * @return {AWM.User} 82 | * */ 83 | AWM.User.prototype.setName = function(name){ 84 | this._name = name; 85 | return this; 86 | }; 87 | 88 | 89 | /** 90 | * getEmail - get user's email 91 | * 92 | * @returns {String} 93 | * */ 94 | AWM.User.prototype.getEmail = function(){ 95 | return this._email; 96 | }; 97 | 98 | /** 99 | * setEmail - sets users email 100 | * 101 | * @param {String} email 102 | * @return {AWM.User} 103 | * */ 104 | AWM.User.prototype.setEmail = function(email){ 105 | this._email = email; 106 | return this; 107 | }; 108 | 109 | /** 110 | * getPhoto - get user's photo urls 111 | * 112 | * @returns {AWM.UserImage} 113 | * */ 114 | AWM.User.prototype.getPhoto = function(){ 115 | return this._photo; 116 | }; 117 | 118 | /** 119 | * setPhoto - sets users photo object 120 | * 121 | * @param {AWM.UserImage} photoObj 122 | * @return {AWM.User} 123 | * */ 124 | AWM.User.prototype.setImage = function(photoObj){ 125 | this._photo = photoObj; 126 | return this; 127 | }; 128 | 129 | /** 130 | * getWorkspaces - get user's workspaces 131 | * 132 | * @returns {[AWM.Workspace]} 133 | * */ 134 | AWM.User.prototype.getWorkspaces = function(){ 135 | return this._workspaces; 136 | }; 137 | 138 | /** 139 | * setWorkspaces - sets users workspaces array 140 | * 141 | * @param {[AWM.Workspace]} workspacesArray 142 | * @return {AWM.User} 143 | * */ 144 | AWM.User.prototype.setWorkspaces = function(workspacesArray){ 145 | this._workspaces = workspacesArray; 146 | return this; 147 | }; -------------------------------------------------------------------------------- /public/client/views/manage.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | 6 | 9 | 10 |
11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
#Workspace
{{$index+1}}{{workspace.getName()}}
30 |
31 |
32 |
33 |
34 | 35 |
36 |
37 | 64 |
65 |
66 |
67 | -------------------------------------------------------------------------------- /public/client/views/body.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Asana Webhooks Manager

5 |

6 | Asana webhooks manager (AWM) is a free and open source management and event handling server, written in JavaScript for Asana's webhooks API.
7 | Use AWM to manage webhooks subscriptions and accept event payloads from Asana in real-time. 8 |

9 |

10 | Consider AWM as your starting point for writing your own application logic, on top of Asana's webhook notifications mechanism. 11 |

12 |

13 | Quickly get started by watching a 5 minutes setup video or read the quick start guide 14 |

15 | 16 |

17 | 18 | Watch video 19 | Login with Asana 20 | 21 |

22 |
23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
33 |
34 |

Dive deeper

35 |

36 | Read the documentation to learn how you can modify and extend AWM functionality to better suit your needs. 37 |

38 |
39 |

Configuration

40 |

41 | Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, 42 | when an unknown printer took a galley of type and scrambled it to make a type 43 | specimen book. It has survived not only five centuries, 44 | but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in 45 |

46 |
47 |
48 |

Events handling

49 |

50 | Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, 51 | when an unknown printer took a galley of type and scrambled it to make a type 52 | specimen book. It has survived not only five centuries, 53 | but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in 54 |

55 |
56 |
57 |

Deployment and security

58 |

59 | Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, 60 | when an unknown printer took a galley of type and scrambled it to make a type 61 | specimen book. It has survived not only five centuries, 62 | but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in 63 |

64 |
65 |
66 | 67 |
68 |
-------------------------------------------------------------------------------- /public/client/services/asanaService.js: -------------------------------------------------------------------------------- 1 | /** 2 | * asanaService - A thin communication layer to ASANA API, via AWM RESTFul API Endpoints 3 | * 4 | * */ 5 | (function(){ 6 | 7 | var asanaService = function($http,$q,config){ 8 | 9 | this.$http = $http; 10 | this.$q = $q; 11 | this.config = config; 12 | 13 | /** 14 | * ready - a Boolean variable that represents that state of Asana Client Configuration on the server! 15 | * it's value is determined once the Promise for the parent route of the Angular app resolves or rejects 16 | * 17 | * This value remains false, a (bootstrap) alert will be present above the header, in the apps web interface, asking the user to update the server-side config file with their Asana App credentials 18 | * */ 19 | this.ready = true; 20 | 21 | }; 22 | 23 | /** 24 | * getUser - returns the current logged-in user from Asana. response is cached into the _user property 25 | * 26 | * @param {Boolean} refresh (optional) - if true, refreshes the user object by refecthing form Asana, otherwise returns the cached value 27 | * @returns {Promise} 28 | * */ 29 | asanaService.prototype.getUser = function(){ 30 | 31 | var deferred = this.$q.defer(); 32 | 33 | this.$http.get(this.config.ASANA_API_CURRENT_USER, {}) 34 | .then( 35 | 36 | //Success 37 | function (response) { 38 | deferred.resolve(response.data.data); 39 | }.bind(this), 40 | 41 | //Failure 42 | function (response) { 43 | deferred.reject(response); 44 | }.bind(this) 45 | 46 | ); 47 | 48 | return deferred.promise; 49 | }; 50 | 51 | 52 | asanaService.prototype.getProjects = function(workspaceId){ 53 | 54 | if (typeof workspaceId == "undefined") throw new Error("Must provide a workspaceId"); 55 | 56 | var deferred = this.$q.defer(); 57 | 58 | this.$http.get(this.config.ASANA_API_PROJECTS + '/' + workspaceId, {}) 59 | .then( 60 | 61 | //Success 62 | function (response) { 63 | deferred.resolve(response.data.data); 64 | }.bind(this), 65 | 66 | //Failure 67 | function (response) { 68 | deferred.reject(response); 69 | }.bind(this) 70 | 71 | ); 72 | 73 | return deferred.promise; 74 | }; 75 | 76 | //asanaService.prototype.getWebhooks = function(workspaceId){ 77 | // 78 | // if (typeof workspaceId == "undefined") throw new Error("Must provide a workspaceId"); 79 | // 80 | // var deferred = this.$q.defer(); 81 | // 82 | // this.$http.get(this.config.ASANA_API_WEBHOOKS+"/"+workspaceId) 83 | // .then( 84 | // //Success 85 | // function (response) { 86 | // deferred.resolve(response.data.data); 87 | // }.bind(this), 88 | // 89 | // //Failure 90 | // function (response) { 91 | // deferred.reject(response); 92 | // }.bind(this) 93 | // 94 | // ); 95 | // 96 | // return deferred.promise; 97 | //}; 98 | 99 | asanaService.prototype.subscribe = function(resourceId){ 100 | if (typeof resourceId == "undefined") throw new Error("Must provide a resourceId"); 101 | 102 | var deferred = this.$q.defer(); 103 | 104 | this.$http.post(this.config.ASANA_API_WEBHOOKS + '/' + resourceId, {}) 105 | .then( 106 | 107 | //Success 108 | function (response) { 109 | deferred.resolve(response.data); 110 | }.bind(this), 111 | 112 | //Failure 113 | function (response) { 114 | deferred.reject(response); 115 | }.bind(this) 116 | 117 | ); 118 | 119 | return deferred.promise; 120 | }; 121 | 122 | asanaService.prototype.unsubscribe = function(webhookId,resourceId){ 123 | 124 | if (typeof webhookId == "undefined") throw new Error("Must provide a webhookId"); 125 | if (typeof resourceId == "undefined") throw new Error("Must provide a resourceId"); 126 | 127 | var deferred = this.$q.defer(); 128 | 129 | this.$http.delete(this.config.ASANA_API_WEBHOOKS + '/' + webhookId + '/' + resourceId, {}) 130 | .then( 131 | 132 | //Success 133 | function (response) { 134 | deferred.resolve(response.data); 135 | }.bind(this), 136 | 137 | //Failure 138 | function (response) { 139 | deferred.reject(response); 140 | }.bind(this) 141 | 142 | ); 143 | 144 | return deferred.promise; 145 | }; 146 | 147 | 148 | 149 | 150 | 151 | 152 | awmApp.service("asanaService", ['$http','$q','config',asanaService]); 153 | 154 | })(); -------------------------------------------------------------------------------- /public/client/controllers/manageController.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 3 | var manageController = function($scope, $timeout, userService, asanaService, navigationService){ 4 | 5 | this.$scope = $scope; 6 | this.$timeout = $timeout; 7 | this.userService = userService; 8 | this.asanaService = asanaService; 9 | this.navigationService = navigationService; 10 | 11 | /** 12 | * workspaces - Reference to workspaces array in the AWM.User object 13 | * */ 14 | this.workspaces = []; 15 | 16 | /** 17 | * viewedWorkspaceIndex - Hold the last selected workspace position (index) within the workspaces array 18 | * */ 19 | this.viewedWorkspaceIndex = null; 20 | 21 | this.init(); 22 | 23 | }; 24 | 25 | /** 26 | * init - verify user access 27 | * */ 28 | manageController.prototype.init = function(){ 29 | if (!this.userService.isLoggedIn()){ 30 | this.navigationService.goToState('root'); 31 | return; 32 | } 33 | 34 | /** 35 | * workspaces - Workspaces array referenced from the user service 36 | * */ 37 | this.workspaces = this.userService.getUser().getWorkspaces(); 38 | 39 | /** 40 | * projects - A tree like structure to hold references to all projects by workspace id 41 | * 42 | * { 43 | * :{ 44 | * : {AWN.Project}, 45 | * ... 46 | * }, 47 | * ... 48 | * } 49 | * 50 | * */ 51 | this.projects = {}; 52 | }; 53 | 54 | /** 55 | * getProjects - returns a list of projects for the provided workspace 56 | * */ 57 | manageController.prototype.getProjects = function(workspaceIndex){ 58 | 59 | //Set the selected workspace as 'currently viewed', to determine what projects to display in the projects modal 60 | this.viewedWorkspaceIndex = workspaceIndex; 61 | 62 | //Retrieve workspace Id 63 | var workspaceId = this.userService.getUser().getWorkspaces()[workspaceIndex].getId(); 64 | 65 | //Fetch projects workspaces 66 | this.asanaService.getProjects(workspaceId).then( 67 | function(projects){ 68 | var AWMProjectsArray = []; 69 | for (var i=0;iwebhookIds 80 | var resourceWebhooksMap = {}; 81 | for (var i=0;i]/gm,function(e){return j[e]})}function t(e){return e.nodeName.toLowerCase()}function r(e,n){var t=e&&e.exec(n);return t&&0===t.index}function a(e){return k.test(e)}function i(e){var n,t,r,i,o=e.className+" ";if(o+=e.parentNode?e.parentNode.className:"",t=B.exec(o))return w(t[1])?t[1]:"no-highlight";for(o=o.split(/\s+/),n=0,r=o.length;r>n;n++)if(i=o[n],a(i)||w(i))return i}function o(e){var n,t={},r=Array.prototype.slice.call(arguments,1);for(n in e)t[n]=e[n];return r.forEach(function(e){for(n in e)t[n]=e[n]}),t}function u(e){var n=[];return function r(e,a){for(var i=e.firstChild;i;i=i.nextSibling)3===i.nodeType?a+=i.nodeValue.length:1===i.nodeType&&(n.push({event:"start",offset:a,node:i}),a=r(i,a),t(i).match(/br|hr|img|input/)||n.push({event:"stop",offset:a,node:i}));return a}(e,0),n}function c(e,r,a){function i(){return e.length&&r.length?e[0].offset!==r[0].offset?e[0].offset"}function u(e){l+=""}function c(e){("start"===e.event?o:u)(e.node)}for(var s=0,l="",f=[];e.length||r.length;){var g=i();if(l+=n(a.substring(s,g[0].offset)),s=g[0].offset,g===e){f.reverse().forEach(u);do c(g.splice(0,1)[0]),g=i();while(g===e&&g.length&&g[0].offset===s);f.reverse().forEach(o)}else"start"===g[0].event?f.push(g[0].node):f.pop(),c(g.splice(0,1)[0])}return l+n(a.substr(s))}function s(e){return e.v&&!e.cached_variants&&(e.cached_variants=e.v.map(function(n){return o(e,{v:null},n)})),e.cached_variants||e.eW&&[o(e)]||[e]}function l(e){function n(e){return e&&e.source||e}function t(t,r){return new RegExp(n(t),"m"+(e.cI?"i":"")+(r?"g":""))}function r(a,i){if(!a.compiled){if(a.compiled=!0,a.k=a.k||a.bK,a.k){var o={},u=function(n,t){e.cI&&(t=t.toLowerCase()),t.split(" ").forEach(function(e){var t=e.split("|");o[t[0]]=[n,t[1]?Number(t[1]):1]})};"string"==typeof a.k?u("keyword",a.k):x(a.k).forEach(function(e){u(e,a.k[e])}),a.k=o}a.lR=t(a.l||/\w+/,!0),i&&(a.bK&&(a.b="\\b("+a.bK.split(" ").join("|")+")\\b"),a.b||(a.b=/\B|\b/),a.bR=t(a.b),a.e||a.eW||(a.e=/\B|\b/),a.e&&(a.eR=t(a.e)),a.tE=n(a.e)||"",a.eW&&i.tE&&(a.tE+=(a.e?"|":"")+i.tE)),a.i&&(a.iR=t(a.i)),null==a.r&&(a.r=1),a.c||(a.c=[]),a.c=Array.prototype.concat.apply([],a.c.map(function(e){return s("self"===e?a:e)})),a.c.forEach(function(e){r(e,a)}),a.starts&&r(a.starts,i);var c=a.c.map(function(e){return e.bK?"\\.?("+e.b+")\\.?":e.b}).concat([a.tE,a.i]).map(n).filter(Boolean);a.t=c.length?t(c.join("|"),!0):{exec:function(){return null}}}}r(e)}function f(e,t,a,i){function o(e,n){var t,a;for(t=0,a=n.c.length;a>t;t++)if(r(n.c[t].bR,e))return n.c[t]}function u(e,n){if(r(e.eR,n)){for(;e.endsParent&&e.parent;)e=e.parent;return e}return e.eW?u(e.parent,n):void 0}function c(e,n){return!a&&r(n.iR,e)}function s(e,n){var t=N.cI?n[0].toLowerCase():n[0];return e.k.hasOwnProperty(t)&&e.k[t]}function p(e,n,t,r){var a=r?"":I.classPrefix,i='',i+n+o}function h(){var e,t,r,a;if(!E.k)return n(k);for(a="",t=0,E.lR.lastIndex=0,r=E.lR.exec(k);r;)a+=n(k.substring(t,r.index)),e=s(E,r),e?(B+=e[1],a+=p(e[0],n(r[0]))):a+=n(r[0]),t=E.lR.lastIndex,r=E.lR.exec(k);return a+n(k.substr(t))}function d(){var e="string"==typeof E.sL;if(e&&!L[E.sL])return n(k);var t=e?f(E.sL,k,!0,x[E.sL]):g(k,E.sL.length?E.sL:void 0);return E.r>0&&(B+=t.r),e&&(x[E.sL]=t.top),p(t.language,t.value,!1,!0)}function b(){y+=null!=E.sL?d():h(),k=""}function v(e){y+=e.cN?p(e.cN,"",!0):"",E=Object.create(e,{parent:{value:E}})}function m(e,n){if(k+=e,null==n)return b(),0;var t=o(n,E);if(t)return t.skip?k+=n:(t.eB&&(k+=n),b(),t.rB||t.eB||(k=n)),v(t,n),t.rB?0:n.length;var r=u(E,n);if(r){var a=E;a.skip?k+=n:(a.rE||a.eE||(k+=n),b(),a.eE&&(k=n));do E.cN&&(y+=C),E.skip||(B+=E.r),E=E.parent;while(E!==r.parent);return r.starts&&v(r.starts,""),a.rE?0:n.length}if(c(n,E))throw new Error('Illegal lexeme "'+n+'" for mode "'+(E.cN||"")+'"');return k+=n,n.length||1}var N=w(e);if(!N)throw new Error('Unknown language: "'+e+'"');l(N);var R,E=i||N,x={},y="";for(R=E;R!==N;R=R.parent)R.cN&&(y=p(R.cN,"",!0)+y);var k="",B=0;try{for(var M,j,O=0;;){if(E.t.lastIndex=O,M=E.t.exec(t),!M)break;j=m(t.substring(O,M.index),M[0]),O=M.index+j}for(m(t.substr(O)),R=E;R.parent;R=R.parent)R.cN&&(y+=C);return{r:B,value:y,language:e,top:E}}catch(T){if(T.message&&-1!==T.message.indexOf("Illegal"))return{r:0,value:n(t)};throw T}}function g(e,t){t=t||I.languages||x(L);var r={r:0,value:n(e)},a=r;return t.filter(w).forEach(function(n){var t=f(n,e,!1);t.language=n,t.r>a.r&&(a=t),t.r>r.r&&(a=r,r=t)}),a.language&&(r.second_best=a),r}function p(e){return I.tabReplace||I.useBR?e.replace(M,function(e,n){return I.useBR&&"\n"===e?"
":I.tabReplace?n.replace(/\t/g,I.tabReplace):""}):e}function h(e,n,t){var r=n?y[n]:t,a=[e.trim()];return e.match(/\bhljs\b/)||a.push("hljs"),-1===e.indexOf(r)&&a.push(r),a.join(" ").trim()}function d(e){var n,t,r,o,s,l=i(e);a(l)||(I.useBR?(n=document.createElementNS("http://www.w3.org/1999/xhtml","div"),n.innerHTML=e.innerHTML.replace(/\n/g,"").replace(//g,"\n")):n=e,s=n.textContent,r=l?f(l,s,!0):g(s),t=u(n),t.length&&(o=document.createElementNS("http://www.w3.org/1999/xhtml","div"),o.innerHTML=r.value,r.value=c(t,u(o),s)),r.value=p(r.value),e.innerHTML=r.value,e.className=h(e.className,l,r.language),e.result={language:r.language,re:r.r},r.second_best&&(e.second_best={language:r.second_best.language,re:r.second_best.r}))}function b(e){I=o(I,e)}function v(){if(!v.called){v.called=!0;var e=document.querySelectorAll("pre code");E.forEach.call(e,d)}}function m(){addEventListener("DOMContentLoaded",v,!1),addEventListener("load",v,!1)}function N(n,t){var r=L[n]=t(e);r.aliases&&r.aliases.forEach(function(e){y[e]=n})}function R(){return x(L)}function w(e){return e=(e||"").toLowerCase(),L[e]||L[y[e]]}var E=[],x=Object.keys,L={},y={},k=/^(no-?highlight|plain|text)$/i,B=/\blang(?:uage)?-([\w-]+)\b/i,M=/((^(<[^>]+>|\t|)+|(?:\n)))/gm,C="
",I={classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:void 0},j={"&":"&","<":"<",">":">"};return e.highlight=f,e.highlightAuto=g,e.fixMarkup=p,e.highlightBlock=d,e.configure=b,e.initHighlighting=v,e.initHighlightingOnLoad=m,e.registerLanguage=N,e.listLanguages=R,e.getLanguage=w,e.inherit=o,e.IR="[a-zA-Z]\\w*",e.UIR="[a-zA-Z_]\\w*",e.NR="\\b\\d+(\\.\\d+)?",e.CNR="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",e.BNR="\\b(0b[01]+)",e.RSR="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",e.BE={b:"\\\\[\\s\\S]",r:0},e.ASM={cN:"string",b:"'",e:"'",i:"\\n",c:[e.BE]},e.QSM={cN:"string",b:'"',e:'"',i:"\\n",c:[e.BE]},e.PWM={b:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|like)\b/},e.C=function(n,t,r){var a=e.inherit({cN:"comment",b:n,e:t,c:[]},r||{});return a.c.push(e.PWM),a.c.push({cN:"doctag",b:"(?:TODO|FIXME|NOTE|BUG|XXX):",r:0}),a},e.CLCM=e.C("//","$"),e.CBCM=e.C("/\\*","\\*/"),e.HCM=e.C("#","$"),e.NM={cN:"number",b:e.NR,r:0},e.CNM={cN:"number",b:e.CNR,r:0},e.BNM={cN:"number",b:e.BNR,r:0},e.CSSNM={cN:"number",b:e.NR+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",r:0},e.RM={cN:"regexp",b:/\//,e:/\/[gimuy]*/,i:/\n/,c:[e.BE,{b:/\[/,e:/\]/,r:0,c:[e.BE]}]},e.TM={cN:"title",b:e.IR,r:0},e.UTM={cN:"title",b:e.UIR,r:0},e.METHOD_GUARD={b:"\\.\\s*"+e.UIR,r:0},e});hljs.registerLanguage("xml",function(s){var e="[A-Za-z0-9\\._:-]+",t={eW:!0,i:/`]+/}]}]}]};return{aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist"],cI:!0,c:[{cN:"meta",b:"",r:10,c:[{b:"\\[",e:"\\]"}]},s.C("",{r:10}),{b:"<\\!\\[CDATA\\[",e:"\\]\\]>",r:10},{b:/<\?(php)?/,e:/\?>/,sL:"php",c:[{b:"/\\*",e:"\\*/",skip:!0}]},{cN:"tag",b:"|$)",e:">",k:{name:"style"},c:[t],starts:{e:"",rE:!0,sL:["css","xml"]}},{cN:"tag",b:"|$)",e:">",k:{name:"script"},c:[t],starts:{e:"",rE:!0,sL:["actionscript","javascript","handlebars","xml"]}},{cN:"meta",v:[{b:/<\?xml/,e:/\?>/,r:10},{b:/<\?\w+/,e:/\?>/}]},{cN:"tag",b:"",c:[{cN:"name",b:/[^\/><\s]+/,r:0},t]}]}});hljs.registerLanguage("javascript",function(e){var r="[A-Za-z$_][0-9A-Za-z$_]*",t={keyword:"in of if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as async await static import from as",literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document Symbol Set Map WeakSet WeakMap Proxy Reflect Promise"},a={cN:"number",v:[{b:"\\b(0[bB][01]+)"},{b:"\\b(0[oO][0-7]+)"},{b:e.CNR}],r:0},n={cN:"subst",b:"\\$\\{",e:"\\}",k:t,c:[]},c={cN:"string",b:"`",e:"`",c:[e.BE,n]};n.c=[e.ASM,e.QSM,c,a,e.RM];var s=n.c.concat([e.CBCM,e.CLCM]);return{aliases:["js","jsx"],k:t,c:[{cN:"meta",r:10,b:/^\s*['"]use (strict|asm)['"]/},{cN:"meta",b:/^#!/,e:/$/},e.ASM,e.QSM,c,e.CLCM,e.CBCM,a,{b:/[{,]\s*/,r:0,c:[{b:r+"\\s*:",rB:!0,r:0,c:[{cN:"attr",b:r,r:0}]}]},{b:"("+e.RSR+"|\\b(case|return|throw)\\b)\\s*",k:"return throw case",c:[e.CLCM,e.CBCM,e.RM,{cN:"function",b:"(\\(.*?\\)|"+r+")\\s*=>",rB:!0,e:"\\s*=>",c:[{cN:"params",v:[{b:r},{b:/\(\s*\)/},{b:/\(/,e:/\)/,eB:!0,eE:!0,k:t,c:s}]}]},{b://,sL:"xml",c:[{b:/<\w+\s*\/>/,skip:!0},{b:/<\w+/,e:/(\/\w+|\w+\/)>/,skip:!0,c:[{b:/<\w+\s*\/>/,skip:!0},"self"]}]}],r:0},{cN:"function",bK:"function",e:/\{/,eE:!0,c:[e.inherit(e.TM,{b:r}),{cN:"params",b:/\(/,e:/\)/,eB:!0,eE:!0,c:s}],i:/\[|%/},{b:/\$[(.]/},e.METHOD_GUARD,{cN:"class",bK:"class",e:/[{;=]/,eE:!0,i:/[:"\[\]]/,c:[{bK:"extends"},e.UTM]},{bK:"constructor",e:/\{/,eE:!0}],i:/#(?!!)/}}); -------------------------------------------------------------------------------- /public/client/views/docs.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | 47 |
48 | 49 |
50 |

Documentation

51 |

52 | No one likes reading the docs :) so why not watch a quick-start video instead and get up and running in no-time!
53 | If you're interested in going beyond the basics, like extending AWM functionality read on. 54 |

55 | 56 |

Setup

57 | 63 |

Creating an Asana App

64 |

65 | 1. Login into to your Asana account and Select My Profile Settings... for the top right user menu
66 | 67 |

68 |

69 | 2. Select the Apps tab from the top menu 70 | 71 |

72 |

73 | 3. Select Manage Developer Apps 74 | 75 |

76 |

77 | 4. Click on +Add New Application 78 | 79 |

80 |

81 | 5. Fill in all three fields, and mark the "agreement" checkbox then click Create
82 | The redirect url value must be your own server root address, appended with "/oauth/asana" 83 | 84 |

85 |

86 | 6. Congrats! you've just created an Asana Application. Before clicking Save, copy all values underlined below, You'll need to paste them into AWM Configuration File before you can start using AWM
87 | 88 | 89 |

90 | 91 |

Updating configuration

92 |
Asana app configurations mandatory
93 |

94 | AWM uses Asana's OAuth flow to allow you to "Login with Asana".
95 | Using your text editor of choice, please update the configuration file located at <"your awm directory">/config/asana.js
96 | Following the values from screenshots above, your modified configuration file should look very similar to this: 97 |


 98 | module.exports = {
 99 |     clientId: "304364573959829",
100 |     clientSecret: "392ab438bf979973d0cc12a0ed6da8b0",
101 |     redirectUri: "https://a2bd603f.ngrok.io/oauth/asana",
102 | };
103 |                     
104 |

105 | 106 | 107 |
MongoDB configurations mandatory
108 |

109 | AWM uses MongoDB to stores webhooks information, to later use for validating incoming event payloads against their "webhook secret".
110 | In order for AWM to function correctly, you'll need to edit another configuration file located at <"your awm directory">/config/mongodb.js with your MongoDB information.
111 |


112 | module.exports = {
113 |     username: null,    //MongoDB user (optional)
114 |     password: null,    //MongoDB password (optional)
115 |     host: "127.0.0.1", //Mongo hosts
116 |     port: "27017",     //Port
117 |     database: "awm"    //Database name
118 | };
119 |                     
120 |

121 | 122 |

HTTPS support

123 |

124 | The following example uses ngrok. 125 | Download it here, then open up your terminal and run "ngrok http 3000".
126 | If you're terminal is showing something similar to what's below, it means things are working OK, and that whatever is running at localhost:3000 is now available both via http and https to the world.
127 | Before you try accessing yout https endpoint, please read one more step below to start your AWM server 128 |


129 | Tunnel Status                 online
130 | Version                       2.0/2.0
131 | Web Interface                 http://127.0.0.1:4040
132 | Forwarding                    http://a2bd603f.ngrok.io -> localhost:3000
133 | Forwarding                    https://a2bd603f.ngrok.io -> localhost:3000
134 | 
135 | Connnections                  ttl     opn     rt1     rt5     p50     p90
136 | 0       0       0.00    0.00    0.00    0.00
137 |                     
138 |

139 |

140 | More documentation is available on the ngork website: https://ngrok.com/docs 141 |

142 | 143 |

Using AWM

144 |

Starting the server

145 |

146 | AWM requires NodeJS (v6.9 or higher) to run. You can download it from Node's official website 147 | Once you have node install, using another terminal session (to keep ngrok running) cd to your AWM folder and run "node server.js". You should see an "AWM Server started!" message: 148 |


149 | > node server.js
150 | AWM Server started!
151 |                     
152 | 153 | You should now be able to access the web interface for AWM, available at your https url as shown on screen in your terminal running the ngrok tunnel. 154 | 155 |

156 | 157 |

Logging in

158 |

159 | Easily login with Asana by using a built-in url in AWM, Simply point your browser /oauth/asana/login and have AWM automatically redirect to your Asana-app authentication page 160 |

161 |

162 | Please note you must have your configuration file updated with your app's details first. 163 |

164 | 165 | 166 |

Managing webhooks

167 |

168 | 1. AWM supports managing webhooks subscriptions for all projects, in all workspaces.
169 | Once logged in, use the Manage tab to view a list of all available workspaces:
170 | 171 |

172 | 173 |

174 | 2. Click View projects to list all available projects in a workspace
175 | then click Subscribe register a webhook for the selected project
176 | 177 |

178 | 179 |

180 | 3. Click Unsubscribe to remove an existing webhook subscription
181 | 182 |

183 | 184 |

Viewing events (real-time)

185 |

186 | Once your subscriptions are in place, you can watch incoming event in real-time, under the Live view tab
187 | If you'd like to extend the current basic implementation, please see read below on extending AWM
188 | 189 |

190 | 191 |

Deployment

192 |

Security considerations

193 |

194 | While AWM handles most common technicalities for working with Asana webhooks, it tries to be as slim as possible.
195 | Therefore, there are a few remaining security recommendations to consider when deploying to a live production server: 196 |

    197 |
  1. 198 | Restrict access to AWM server. 199 |
    This can be achieved by adding additional authentication logic, 200 | modifying AWM to only accept specific email address, or by deploying into a VPC. 201 |
  2. 202 |
  3. 203 | Verify Incoming events source. 204 |
    During a webhook creation process Asana sends a "handshake" request with a unique header ('X-Hook-Secret'). 205 | You should store this value to your storage engine of choice, and verify incoming events request against it.
    206 | Read more about securing incoming event request on the 207 | official Asana documentation. 208 |
  4. 209 |
210 |

211 | 212 |

Webhook target URI

213 |

214 | AWM creates and expects to receive event notifications to "https://hostname/events/incoming/resourceId".
215 | For increased security, AWM will support modifying this url in the next version. 216 |

217 | 218 |

Extending AWM

219 |

Coming soon

220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 |
236 |
237 | 238 |
-------------------------------------------------------------------------------- /public/libs/bootstrap/css/bootstrap-theme.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.3.7 (http://getbootstrap.com) 3 | * Copyright 2011-2016 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */.btn-danger,.btn-default,.btn-info,.btn-primary,.btn-success,.btn-warning{text-shadow:0 -1px 0 rgba(0,0,0,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075)}.btn-danger.active,.btn-danger:active,.btn-default.active,.btn-default:active,.btn-info.active,.btn-info:active,.btn-primary.active,.btn-primary:active,.btn-success.active,.btn-success:active,.btn-warning.active,.btn-warning:active{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-danger.disabled,.btn-danger[disabled],.btn-default.disabled,.btn-default[disabled],.btn-info.disabled,.btn-info[disabled],.btn-primary.disabled,.btn-primary[disabled],.btn-success.disabled,.btn-success[disabled],.btn-warning.disabled,.btn-warning[disabled],fieldset[disabled] .btn-danger,fieldset[disabled] .btn-default,fieldset[disabled] .btn-info,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-success,fieldset[disabled] .btn-warning{-webkit-box-shadow:none;box-shadow:none}.btn-danger .badge,.btn-default .badge,.btn-info .badge,.btn-primary .badge,.btn-success .badge,.btn-warning .badge{text-shadow:none}.btn.active,.btn:active{background-image:none}.btn-default{text-shadow:0 1px 0 #fff;background-image:-webkit-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-o-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#e0e0e0));background-image:linear-gradient(to bottom,#fff 0,#e0e0e0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#dbdbdb;border-color:#ccc}.btn-default:focus,.btn-default:hover{background-color:#e0e0e0;background-position:0 -15px}.btn-default.active,.btn-default:active{background-color:#e0e0e0;border-color:#dbdbdb}.btn-default.disabled,.btn-default.disabled.active,.btn-default.disabled.focus,.btn-default.disabled:active,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled],.btn-default[disabled].active,.btn-default[disabled].focus,.btn-default[disabled]:active,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default,fieldset[disabled] .btn-default.active,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:active,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#e0e0e0;background-image:none}.btn-primary{background-image:-webkit-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-o-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#265a88));background-image:linear-gradient(to bottom,#337ab7 0,#265a88 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#245580}.btn-primary:focus,.btn-primary:hover{background-color:#265a88;background-position:0 -15px}.btn-primary.active,.btn-primary:active{background-color:#265a88;border-color:#245580}.btn-primary.disabled,.btn-primary.disabled.active,.btn-primary.disabled.focus,.btn-primary.disabled:active,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled],.btn-primary[disabled].active,.btn-primary[disabled].focus,.btn-primary[disabled]:active,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-primary.active,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:active,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#265a88;background-image:none}.btn-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#419641));background-image:linear-gradient(to bottom,#5cb85c 0,#419641 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#3e8f3e}.btn-success:focus,.btn-success:hover{background-color:#419641;background-position:0 -15px}.btn-success.active,.btn-success:active{background-color:#419641;border-color:#3e8f3e}.btn-success.disabled,.btn-success.disabled.active,.btn-success.disabled.focus,.btn-success.disabled:active,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled],.btn-success[disabled].active,.btn-success[disabled].focus,.btn-success[disabled]:active,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success,fieldset[disabled] .btn-success.active,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:active,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#419641;background-image:none}.btn-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#2aabd2));background-image:linear-gradient(to bottom,#5bc0de 0,#2aabd2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#28a4c9}.btn-info:focus,.btn-info:hover{background-color:#2aabd2;background-position:0 -15px}.btn-info.active,.btn-info:active{background-color:#2aabd2;border-color:#28a4c9}.btn-info.disabled,.btn-info.disabled.active,.btn-info.disabled.focus,.btn-info.disabled:active,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled],.btn-info[disabled].active,.btn-info[disabled].focus,.btn-info[disabled]:active,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info,fieldset[disabled] .btn-info.active,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:active,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#2aabd2;background-image:none}.btn-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#eb9316));background-image:linear-gradient(to bottom,#f0ad4e 0,#eb9316 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#e38d13}.btn-warning:focus,.btn-warning:hover{background-color:#eb9316;background-position:0 -15px}.btn-warning.active,.btn-warning:active{background-color:#eb9316;border-color:#e38d13}.btn-warning.disabled,.btn-warning.disabled.active,.btn-warning.disabled.focus,.btn-warning.disabled:active,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled],.btn-warning[disabled].active,.btn-warning[disabled].focus,.btn-warning[disabled]:active,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning,fieldset[disabled] .btn-warning.active,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:active,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#eb9316;background-image:none}.btn-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c12e2a));background-image:linear-gradient(to bottom,#d9534f 0,#c12e2a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#b92c28}.btn-danger:focus,.btn-danger:hover{background-color:#c12e2a;background-position:0 -15px}.btn-danger.active,.btn-danger:active{background-color:#c12e2a;border-color:#b92c28}.btn-danger.disabled,.btn-danger.disabled.active,.btn-danger.disabled.focus,.btn-danger.disabled:active,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled],.btn-danger[disabled].active,.btn-danger[disabled].focus,.btn-danger[disabled]:active,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger,fieldset[disabled] .btn-danger.active,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:active,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#c12e2a;background-image:none}.img-thumbnail,.thumbnail{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{background-color:#e8e8e8;background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{background-color:#2e6da4;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.navbar-default{background-image:-webkit-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-o-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#f8f8f8));background-image:linear-gradient(to bottom,#fff 0,#f8f8f8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075)}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-o-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dbdbdb),to(#e2e2e2));background-image:linear-gradient(to bottom,#dbdbdb 0,#e2e2e2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.075);box-shadow:inset 0 3px 9px rgba(0,0,0,.075)}.navbar-brand,.navbar-nav>li>a{text-shadow:0 1px 0 rgba(255,255,255,.25)}.navbar-inverse{background-image:-webkit-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-o-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#3c3c3c),to(#222));background-image:linear-gradient(to bottom,#3c3c3c 0,#222 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-o-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#080808),to(#0f0f0f));background-image:linear-gradient(to bottom,#080808 0,#0f0f0f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.25);box-shadow:inset 0 3px 9px rgba(0,0,0,.25)}.navbar-inverse .navbar-brand,.navbar-inverse .navbar-nav>li>a{text-shadow:0 -1px 0 rgba(0,0,0,.25)}.navbar-fixed-bottom,.navbar-fixed-top,.navbar-static-top{border-radius:0}@media (max-width:767px){.navbar .navbar-nav .open .dropdown-menu>.active>a,.navbar .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}}.alert{text-shadow:0 1px 0 rgba(255,255,255,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05);box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05)}.alert-success{background-image:-webkit-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#c8e5bc));background-image:linear-gradient(to bottom,#dff0d8 0,#c8e5bc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);background-repeat:repeat-x;border-color:#b2dba1}.alert-info{background-image:-webkit-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#b9def0));background-image:linear-gradient(to bottom,#d9edf7 0,#b9def0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);background-repeat:repeat-x;border-color:#9acfea}.alert-warning{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#f8efc0));background-image:linear-gradient(to bottom,#fcf8e3 0,#f8efc0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);background-repeat:repeat-x;border-color:#f5e79e}.alert-danger{background-image:-webkit-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-o-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#e7c3c3));background-image:linear-gradient(to bottom,#f2dede 0,#e7c3c3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);background-repeat:repeat-x;border-color:#dca7a7}.progress{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#ebebeb),to(#f5f5f5));background-image:linear-gradient(to bottom,#ebebeb 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x}.progress-bar{background-image:-webkit-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-o-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#286090));background-image:linear-gradient(to bottom,#337ab7 0,#286090 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);background-repeat:repeat-x}.progress-bar-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#449d44));background-image:linear-gradient(to bottom,#5cb85c 0,#449d44 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);background-repeat:repeat-x}.progress-bar-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#31b0d5));background-image:linear-gradient(to bottom,#5bc0de 0,#31b0d5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);background-repeat:repeat-x}.progress-bar-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#ec971f));background-image:linear-gradient(to bottom,#f0ad4e 0,#ec971f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);background-repeat:repeat-x}.progress-bar-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c9302c));background-image:linear-gradient(to bottom,#d9534f 0,#c9302c 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);background-repeat:repeat-x}.progress-bar-striped{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.list-group{border-radius:4px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{text-shadow:0 -1px 0 #286090;background-image:-webkit-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2b669a));background-image:linear-gradient(to bottom,#337ab7 0,#2b669a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);background-repeat:repeat-x;border-color:#2b669a}.list-group-item.active .badge,.list-group-item.active:focus .badge,.list-group-item.active:hover .badge{text-shadow:none}.panel{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.05);box-shadow:0 1px 2px rgba(0,0,0,.05)}.panel-default>.panel-heading{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.panel-primary>.panel-heading{background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.panel-success>.panel-heading{background-image:-webkit-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#d0e9c6));background-image:linear-gradient(to bottom,#dff0d8 0,#d0e9c6 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);background-repeat:repeat-x}.panel-info>.panel-heading{background-image:-webkit-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#c4e3f3));background-image:linear-gradient(to bottom,#d9edf7 0,#c4e3f3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);background-repeat:repeat-x}.panel-warning>.panel-heading{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#faf2cc));background-image:linear-gradient(to bottom,#fcf8e3 0,#faf2cc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);background-repeat:repeat-x}.panel-danger>.panel-heading{background-image:-webkit-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-o-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#ebcccc));background-image:linear-gradient(to bottom,#f2dede 0,#ebcccc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);background-repeat:repeat-x}.well{background-image:-webkit-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#e8e8e8),to(#f5f5f5));background-image:linear-gradient(to bottom,#e8e8e8 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x;border-color:#dcdcdc;-webkit-box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1)} 6 | /*# sourceMappingURL=bootstrap-theme.min.css.map */ -------------------------------------------------------------------------------- /public/libs/bootstrap/css/bootstrap-theme.min.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["less/theme.less","less/mixins/vendor-prefixes.less","less/mixins/gradients.less","less/mixins/reset-filter.less"],"names":[],"mappings":";;;;AAmBA,YAAA,aAAA,UAAA,aAAA,aAAA,aAME,YAAA,EAAA,KAAA,EAAA,eC2CA,mBAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,iBDvCR,mBAAA,mBAAA,oBAAA,oBAAA,iBAAA,iBAAA,oBAAA,oBAAA,oBAAA,oBAAA,oBAAA,oBCsCA,mBAAA,MAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,MAAA,EAAA,IAAA,IAAA,iBDlCR,qBAAA,sBAAA,sBAAA,uBAAA,mBAAA,oBAAA,sBAAA,uBAAA,sBAAA,uBAAA,sBAAA,uBAAA,+BAAA,gCAAA,6BAAA,gCAAA,gCAAA,gCCiCA,mBAAA,KACQ,WAAA,KDlDV,mBAAA,oBAAA,iBAAA,oBAAA,oBAAA,oBAuBI,YAAA,KAyCF,YAAA,YAEE,iBAAA,KAKJ,aErEI,YAAA,EAAA,IAAA,EAAA,KACA,iBAAA,iDACA,iBAAA,4CAAA,iBAAA,qEAEA,iBAAA,+CCnBF,OAAA,+GH4CA,OAAA,0DACA,kBAAA,SAuC2C,aAAA,QAA2B,aAAA,KArCtE,mBAAA,mBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,oBAAA,oBAEE,iBAAA,QACA,aAAA,QAMA,sBAAA,6BAAA,4BAAA,6BAAA,4BAAA,4BAAA,uBAAA,8BAAA,6BAAA,8BAAA,6BAAA,6BAAA,gCAAA,uCAAA,sCAAA,uCAAA,sCAAA,sCAME,iBAAA,QACA,iBAAA,KAgBN,aEtEI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDAEA,OAAA,+GCnBF,OAAA,0DH4CA,kBAAA,SACA,aAAA,QAEA,mBAAA,mBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,oBAAA,oBAEE,iBAAA,QACA,aAAA,QAMA,sBAAA,6BAAA,4BAAA,6BAAA,4BAAA,4BAAA,uBAAA,8BAAA,6BAAA,8BAAA,6BAAA,6BAAA,gCAAA,uCAAA,sCAAA,uCAAA,sCAAA,sCAME,iBAAA,QACA,iBAAA,KAiBN,aEvEI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDAEA,OAAA,+GCnBF,OAAA,0DH4CA,kBAAA,SACA,aAAA,QAEA,mBAAA,mBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,oBAAA,oBAEE,iBAAA,QACA,aAAA,QAMA,sBAAA,6BAAA,4BAAA,6BAAA,4BAAA,4BAAA,uBAAA,8BAAA,6BAAA,8BAAA,6BAAA,6BAAA,gCAAA,uCAAA,sCAAA,uCAAA,sCAAA,sCAME,iBAAA,QACA,iBAAA,KAkBN,UExEI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDAEA,OAAA,+GCnBF,OAAA,0DH4CA,kBAAA,SACA,aAAA,QAEA,gBAAA,gBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,iBAAA,iBAEE,iBAAA,QACA,aAAA,QAMA,mBAAA,0BAAA,yBAAA,0BAAA,yBAAA,yBAAA,oBAAA,2BAAA,0BAAA,2BAAA,0BAAA,0BAAA,6BAAA,oCAAA,mCAAA,oCAAA,mCAAA,mCAME,iBAAA,QACA,iBAAA,KAmBN,aEzEI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDAEA,OAAA,+GCnBF,OAAA,0DH4CA,kBAAA,SACA,aAAA,QAEA,mBAAA,mBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,oBAAA,oBAEE,iBAAA,QACA,aAAA,QAMA,sBAAA,6BAAA,4BAAA,6BAAA,4BAAA,4BAAA,uBAAA,8BAAA,6BAAA,8BAAA,6BAAA,6BAAA,gCAAA,uCAAA,sCAAA,uCAAA,sCAAA,sCAME,iBAAA,QACA,iBAAA,KAoBN,YE1EI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDAEA,OAAA,+GCnBF,OAAA,0DH4CA,kBAAA,SACA,aAAA,QAEA,kBAAA,kBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,mBAAA,mBAEE,iBAAA,QACA,aAAA,QAMA,qBAAA,4BAAA,2BAAA,4BAAA,2BAAA,2BAAA,sBAAA,6BAAA,4BAAA,6BAAA,4BAAA,4BAAA,+BAAA,sCAAA,qCAAA,sCAAA,qCAAA,qCAME,iBAAA,QACA,iBAAA,KA2BN,eAAA,WClCE,mBAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,EAAA,IAAA,IAAA,iBD2CV,0BAAA,0BE3FI,iBAAA,QACA,iBAAA,oDACA,iBAAA,+CAAA,iBAAA,wEACA,iBAAA,kDACA,OAAA,+GF0FF,kBAAA,SAEF,yBAAA,+BAAA,+BEhGI,iBAAA,QACA,iBAAA,oDACA,iBAAA,+CAAA,iBAAA,wEACA,iBAAA,kDACA,OAAA,+GFgGF,kBAAA,SASF,gBE7GI,iBAAA,iDACA,iBAAA,4CACA,iBAAA,qEAAA,iBAAA,+CACA,OAAA,+GACA,OAAA,0DCnBF,kBAAA,SH+HA,cAAA,ICjEA,mBAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,iBD6DV,sCAAA,oCE7GI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SD2CF,mBAAA,MAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,MAAA,EAAA,IAAA,IAAA,iBD0EV,cAAA,iBAEE,YAAA,EAAA,IAAA,EAAA,sBAIF,gBEhII,iBAAA,iDACA,iBAAA,4CACA,iBAAA,qEAAA,iBAAA,+CACA,OAAA,+GACA,OAAA,0DCnBF,kBAAA,SHkJA,cAAA,IAHF,sCAAA,oCEhII,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SD2CF,mBAAA,MAAA,EAAA,IAAA,IAAA,gBACQ,WAAA,MAAA,EAAA,IAAA,IAAA,gBDgFV,8BAAA,iCAYI,YAAA,EAAA,KAAA,EAAA,gBAKJ,qBAAA,kBAAA,mBAGE,cAAA,EAqBF,yBAfI,mDAAA,yDAAA,yDAGE,MAAA,KE7JF,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,UFqKJ,OACE,YAAA,EAAA,IAAA,EAAA,qBC3HA,mBAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,gBACQ,WAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,gBDsIV,eEtLI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF8KF,aAAA,QAKF,YEvLI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF8KF,aAAA,QAMF,eExLI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF8KF,aAAA,QAOF,cEzLI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF8KF,aAAA,QAeF,UEjMI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFuMJ,cE3MI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFwMJ,sBE5MI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFyMJ,mBE7MI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF0MJ,sBE9MI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF2MJ,qBE/MI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF+MJ,sBElLI,iBAAA,yKACA,iBAAA,oKACA,iBAAA,iKFyLJ,YACE,cAAA,IC9KA,mBAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,EAAA,IAAA,IAAA,iBDgLV,wBAAA,8BAAA,8BAGE,YAAA,EAAA,KAAA,EAAA,QEnOE,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFiOF,aAAA,QALF,+BAAA,qCAAA,qCAQI,YAAA,KAUJ,OCnME,mBAAA,EAAA,IAAA,IAAA,gBACQ,WAAA,EAAA,IAAA,IAAA,gBD4MV,8BE5PI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFyPJ,8BE7PI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF0PJ,8BE9PI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF2PJ,2BE/PI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF4PJ,8BEhQI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF6PJ,6BEjQI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFoQJ,MExQI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFsQF,aAAA,QC3NA,mBAAA,MAAA,EAAA,IAAA,IAAA,gBAAA,EAAA,IAAA,EAAA,qBACQ,WAAA,MAAA,EAAA,IAAA,IAAA,gBAAA,EAAA,IAAA,EAAA","sourcesContent":["/*!\n * Bootstrap v3.3.7 (http://getbootstrap.com)\n * Copyright 2011-2016 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n */\n\n//\n// Load core variables and mixins\n// --------------------------------------------------\n\n@import \"variables.less\";\n@import \"mixins.less\";\n\n\n//\n// Buttons\n// --------------------------------------------------\n\n// Common styles\n.btn-default,\n.btn-primary,\n.btn-success,\n.btn-info,\n.btn-warning,\n.btn-danger {\n text-shadow: 0 -1px 0 rgba(0,0,0,.2);\n @shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 1px 1px rgba(0,0,0,.075);\n .box-shadow(@shadow);\n\n // Reset the shadow\n &:active,\n &.active {\n .box-shadow(inset 0 3px 5px rgba(0,0,0,.125));\n }\n\n &.disabled,\n &[disabled],\n fieldset[disabled] & {\n .box-shadow(none);\n }\n\n .badge {\n text-shadow: none;\n }\n}\n\n// Mixin for generating new styles\n.btn-styles(@btn-color: #555) {\n #gradient > .vertical(@start-color: @btn-color; @end-color: darken(@btn-color, 12%));\n .reset-filter(); // Disable gradients for IE9 because filter bleeds through rounded corners; see https://github.com/twbs/bootstrap/issues/10620\n background-repeat: repeat-x;\n border-color: darken(@btn-color, 14%);\n\n &:hover,\n &:focus {\n background-color: darken(@btn-color, 12%);\n background-position: 0 -15px;\n }\n\n &:active,\n &.active {\n background-color: darken(@btn-color, 12%);\n border-color: darken(@btn-color, 14%);\n }\n\n &.disabled,\n &[disabled],\n fieldset[disabled] & {\n &,\n &:hover,\n &:focus,\n &.focus,\n &:active,\n &.active {\n background-color: darken(@btn-color, 12%);\n background-image: none;\n }\n }\n}\n\n// Common styles\n.btn {\n // Remove the gradient for the pressed/active state\n &:active,\n &.active {\n background-image: none;\n }\n}\n\n// Apply the mixin to the buttons\n.btn-default { .btn-styles(@btn-default-bg); text-shadow: 0 1px 0 #fff; border-color: #ccc; }\n.btn-primary { .btn-styles(@btn-primary-bg); }\n.btn-success { .btn-styles(@btn-success-bg); }\n.btn-info { .btn-styles(@btn-info-bg); }\n.btn-warning { .btn-styles(@btn-warning-bg); }\n.btn-danger { .btn-styles(@btn-danger-bg); }\n\n\n//\n// Images\n// --------------------------------------------------\n\n.thumbnail,\n.img-thumbnail {\n .box-shadow(0 1px 2px rgba(0,0,0,.075));\n}\n\n\n//\n// Dropdowns\n// --------------------------------------------------\n\n.dropdown-menu > li > a:hover,\n.dropdown-menu > li > a:focus {\n #gradient > .vertical(@start-color: @dropdown-link-hover-bg; @end-color: darken(@dropdown-link-hover-bg, 5%));\n background-color: darken(@dropdown-link-hover-bg, 5%);\n}\n.dropdown-menu > .active > a,\n.dropdown-menu > .active > a:hover,\n.dropdown-menu > .active > a:focus {\n #gradient > .vertical(@start-color: @dropdown-link-active-bg; @end-color: darken(@dropdown-link-active-bg, 5%));\n background-color: darken(@dropdown-link-active-bg, 5%);\n}\n\n\n//\n// Navbar\n// --------------------------------------------------\n\n// Default navbar\n.navbar-default {\n #gradient > .vertical(@start-color: lighten(@navbar-default-bg, 10%); @end-color: @navbar-default-bg);\n .reset-filter(); // Remove gradient in IE<10 to fix bug where dropdowns don't get triggered\n border-radius: @navbar-border-radius;\n @shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 1px 5px rgba(0,0,0,.075);\n .box-shadow(@shadow);\n\n .navbar-nav > .open > a,\n .navbar-nav > .active > a {\n #gradient > .vertical(@start-color: darken(@navbar-default-link-active-bg, 5%); @end-color: darken(@navbar-default-link-active-bg, 2%));\n .box-shadow(inset 0 3px 9px rgba(0,0,0,.075));\n }\n}\n.navbar-brand,\n.navbar-nav > li > a {\n text-shadow: 0 1px 0 rgba(255,255,255,.25);\n}\n\n// Inverted navbar\n.navbar-inverse {\n #gradient > .vertical(@start-color: lighten(@navbar-inverse-bg, 10%); @end-color: @navbar-inverse-bg);\n .reset-filter(); // Remove gradient in IE<10 to fix bug where dropdowns don't get triggered; see https://github.com/twbs/bootstrap/issues/10257\n border-radius: @navbar-border-radius;\n .navbar-nav > .open > a,\n .navbar-nav > .active > a {\n #gradient > .vertical(@start-color: @navbar-inverse-link-active-bg; @end-color: lighten(@navbar-inverse-link-active-bg, 2.5%));\n .box-shadow(inset 0 3px 9px rgba(0,0,0,.25));\n }\n\n .navbar-brand,\n .navbar-nav > li > a {\n text-shadow: 0 -1px 0 rgba(0,0,0,.25);\n }\n}\n\n// Undo rounded corners in static and fixed navbars\n.navbar-static-top,\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n border-radius: 0;\n}\n\n// Fix active state of dropdown items in collapsed mode\n@media (max-width: @grid-float-breakpoint-max) {\n .navbar .navbar-nav .open .dropdown-menu > .active > a {\n &,\n &:hover,\n &:focus {\n color: #fff;\n #gradient > .vertical(@start-color: @dropdown-link-active-bg; @end-color: darken(@dropdown-link-active-bg, 5%));\n }\n }\n}\n\n\n//\n// Alerts\n// --------------------------------------------------\n\n// Common styles\n.alert {\n text-shadow: 0 1px 0 rgba(255,255,255,.2);\n @shadow: inset 0 1px 0 rgba(255,255,255,.25), 0 1px 2px rgba(0,0,0,.05);\n .box-shadow(@shadow);\n}\n\n// Mixin for generating new styles\n.alert-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 7.5%));\n border-color: darken(@color, 15%);\n}\n\n// Apply the mixin to the alerts\n.alert-success { .alert-styles(@alert-success-bg); }\n.alert-info { .alert-styles(@alert-info-bg); }\n.alert-warning { .alert-styles(@alert-warning-bg); }\n.alert-danger { .alert-styles(@alert-danger-bg); }\n\n\n//\n// Progress bars\n// --------------------------------------------------\n\n// Give the progress background some depth\n.progress {\n #gradient > .vertical(@start-color: darken(@progress-bg, 4%); @end-color: @progress-bg)\n}\n\n// Mixin for generating new styles\n.progress-bar-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 10%));\n}\n\n// Apply the mixin to the progress bars\n.progress-bar { .progress-bar-styles(@progress-bar-bg); }\n.progress-bar-success { .progress-bar-styles(@progress-bar-success-bg); }\n.progress-bar-info { .progress-bar-styles(@progress-bar-info-bg); }\n.progress-bar-warning { .progress-bar-styles(@progress-bar-warning-bg); }\n.progress-bar-danger { .progress-bar-styles(@progress-bar-danger-bg); }\n\n// Reset the striped class because our mixins don't do multiple gradients and\n// the above custom styles override the new `.progress-bar-striped` in v3.2.0.\n.progress-bar-striped {\n #gradient > .striped();\n}\n\n\n//\n// List groups\n// --------------------------------------------------\n\n.list-group {\n border-radius: @border-radius-base;\n .box-shadow(0 1px 2px rgba(0,0,0,.075));\n}\n.list-group-item.active,\n.list-group-item.active:hover,\n.list-group-item.active:focus {\n text-shadow: 0 -1px 0 darken(@list-group-active-bg, 10%);\n #gradient > .vertical(@start-color: @list-group-active-bg; @end-color: darken(@list-group-active-bg, 7.5%));\n border-color: darken(@list-group-active-border, 7.5%);\n\n .badge {\n text-shadow: none;\n }\n}\n\n\n//\n// Panels\n// --------------------------------------------------\n\n// Common styles\n.panel {\n .box-shadow(0 1px 2px rgba(0,0,0,.05));\n}\n\n// Mixin for generating new styles\n.panel-heading-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 5%));\n}\n\n// Apply the mixin to the panel headings only\n.panel-default > .panel-heading { .panel-heading-styles(@panel-default-heading-bg); }\n.panel-primary > .panel-heading { .panel-heading-styles(@panel-primary-heading-bg); }\n.panel-success > .panel-heading { .panel-heading-styles(@panel-success-heading-bg); }\n.panel-info > .panel-heading { .panel-heading-styles(@panel-info-heading-bg); }\n.panel-warning > .panel-heading { .panel-heading-styles(@panel-warning-heading-bg); }\n.panel-danger > .panel-heading { .panel-heading-styles(@panel-danger-heading-bg); }\n\n\n//\n// Wells\n// --------------------------------------------------\n\n.well {\n #gradient > .vertical(@start-color: darken(@well-bg, 5%); @end-color: @well-bg);\n border-color: darken(@well-bg, 10%);\n @shadow: inset 0 1px 3px rgba(0,0,0,.05), 0 1px 0 rgba(255,255,255,.1);\n .box-shadow(@shadow);\n}\n","// Vendor Prefixes\n//\n// All vendor mixins are deprecated as of v3.2.0 due to the introduction of\n// Autoprefixer in our Gruntfile. They have been removed in v4.\n\n// - Animations\n// - Backface visibility\n// - Box shadow\n// - Box sizing\n// - Content columns\n// - Hyphens\n// - Placeholder text\n// - Transformations\n// - Transitions\n// - User Select\n\n\n// Animations\n.animation(@animation) {\n -webkit-animation: @animation;\n -o-animation: @animation;\n animation: @animation;\n}\n.animation-name(@name) {\n -webkit-animation-name: @name;\n animation-name: @name;\n}\n.animation-duration(@duration) {\n -webkit-animation-duration: @duration;\n animation-duration: @duration;\n}\n.animation-timing-function(@timing-function) {\n -webkit-animation-timing-function: @timing-function;\n animation-timing-function: @timing-function;\n}\n.animation-delay(@delay) {\n -webkit-animation-delay: @delay;\n animation-delay: @delay;\n}\n.animation-iteration-count(@iteration-count) {\n -webkit-animation-iteration-count: @iteration-count;\n animation-iteration-count: @iteration-count;\n}\n.animation-direction(@direction) {\n -webkit-animation-direction: @direction;\n animation-direction: @direction;\n}\n.animation-fill-mode(@fill-mode) {\n -webkit-animation-fill-mode: @fill-mode;\n animation-fill-mode: @fill-mode;\n}\n\n// Backface visibility\n// Prevent browsers from flickering when using CSS 3D transforms.\n// Default value is `visible`, but can be changed to `hidden`\n\n.backface-visibility(@visibility) {\n -webkit-backface-visibility: @visibility;\n -moz-backface-visibility: @visibility;\n backface-visibility: @visibility;\n}\n\n// Drop shadows\n//\n// Note: Deprecated `.box-shadow()` as of v3.1.0 since all of Bootstrap's\n// supported browsers that have box shadow capabilities now support it.\n\n.box-shadow(@shadow) {\n -webkit-box-shadow: @shadow; // iOS <4.3 & Android <4.1\n box-shadow: @shadow;\n}\n\n// Box sizing\n.box-sizing(@boxmodel) {\n -webkit-box-sizing: @boxmodel;\n -moz-box-sizing: @boxmodel;\n box-sizing: @boxmodel;\n}\n\n// CSS3 Content Columns\n.content-columns(@column-count; @column-gap: @grid-gutter-width) {\n -webkit-column-count: @column-count;\n -moz-column-count: @column-count;\n column-count: @column-count;\n -webkit-column-gap: @column-gap;\n -moz-column-gap: @column-gap;\n column-gap: @column-gap;\n}\n\n// Optional hyphenation\n.hyphens(@mode: auto) {\n word-wrap: break-word;\n -webkit-hyphens: @mode;\n -moz-hyphens: @mode;\n -ms-hyphens: @mode; // IE10+\n -o-hyphens: @mode;\n hyphens: @mode;\n}\n\n// Placeholder text\n.placeholder(@color: @input-color-placeholder) {\n // Firefox\n &::-moz-placeholder {\n color: @color;\n opacity: 1; // Override Firefox's unusual default opacity; see https://github.com/twbs/bootstrap/pull/11526\n }\n &:-ms-input-placeholder { color: @color; } // Internet Explorer 10+\n &::-webkit-input-placeholder { color: @color; } // Safari and Chrome\n}\n\n// Transformations\n.scale(@ratio) {\n -webkit-transform: scale(@ratio);\n -ms-transform: scale(@ratio); // IE9 only\n -o-transform: scale(@ratio);\n transform: scale(@ratio);\n}\n.scale(@ratioX; @ratioY) {\n -webkit-transform: scale(@ratioX, @ratioY);\n -ms-transform: scale(@ratioX, @ratioY); // IE9 only\n -o-transform: scale(@ratioX, @ratioY);\n transform: scale(@ratioX, @ratioY);\n}\n.scaleX(@ratio) {\n -webkit-transform: scaleX(@ratio);\n -ms-transform: scaleX(@ratio); // IE9 only\n -o-transform: scaleX(@ratio);\n transform: scaleX(@ratio);\n}\n.scaleY(@ratio) {\n -webkit-transform: scaleY(@ratio);\n -ms-transform: scaleY(@ratio); // IE9 only\n -o-transform: scaleY(@ratio);\n transform: scaleY(@ratio);\n}\n.skew(@x; @y) {\n -webkit-transform: skewX(@x) skewY(@y);\n -ms-transform: skewX(@x) skewY(@y); // See https://github.com/twbs/bootstrap/issues/4885; IE9+\n -o-transform: skewX(@x) skewY(@y);\n transform: skewX(@x) skewY(@y);\n}\n.translate(@x; @y) {\n -webkit-transform: translate(@x, @y);\n -ms-transform: translate(@x, @y); // IE9 only\n -o-transform: translate(@x, @y);\n transform: translate(@x, @y);\n}\n.translate3d(@x; @y; @z) {\n -webkit-transform: translate3d(@x, @y, @z);\n transform: translate3d(@x, @y, @z);\n}\n.rotate(@degrees) {\n -webkit-transform: rotate(@degrees);\n -ms-transform: rotate(@degrees); // IE9 only\n -o-transform: rotate(@degrees);\n transform: rotate(@degrees);\n}\n.rotateX(@degrees) {\n -webkit-transform: rotateX(@degrees);\n -ms-transform: rotateX(@degrees); // IE9 only\n -o-transform: rotateX(@degrees);\n transform: rotateX(@degrees);\n}\n.rotateY(@degrees) {\n -webkit-transform: rotateY(@degrees);\n -ms-transform: rotateY(@degrees); // IE9 only\n -o-transform: rotateY(@degrees);\n transform: rotateY(@degrees);\n}\n.perspective(@perspective) {\n -webkit-perspective: @perspective;\n -moz-perspective: @perspective;\n perspective: @perspective;\n}\n.perspective-origin(@perspective) {\n -webkit-perspective-origin: @perspective;\n -moz-perspective-origin: @perspective;\n perspective-origin: @perspective;\n}\n.transform-origin(@origin) {\n -webkit-transform-origin: @origin;\n -moz-transform-origin: @origin;\n -ms-transform-origin: @origin; // IE9 only\n transform-origin: @origin;\n}\n\n\n// Transitions\n\n.transition(@transition) {\n -webkit-transition: @transition;\n -o-transition: @transition;\n transition: @transition;\n}\n.transition-property(@transition-property) {\n -webkit-transition-property: @transition-property;\n transition-property: @transition-property;\n}\n.transition-delay(@transition-delay) {\n -webkit-transition-delay: @transition-delay;\n transition-delay: @transition-delay;\n}\n.transition-duration(@transition-duration) {\n -webkit-transition-duration: @transition-duration;\n transition-duration: @transition-duration;\n}\n.transition-timing-function(@timing-function) {\n -webkit-transition-timing-function: @timing-function;\n transition-timing-function: @timing-function;\n}\n.transition-transform(@transition) {\n -webkit-transition: -webkit-transform @transition;\n -moz-transition: -moz-transform @transition;\n -o-transition: -o-transform @transition;\n transition: transform @transition;\n}\n\n\n// User select\n// For selecting text on the page\n\n.user-select(@select) {\n -webkit-user-select: @select;\n -moz-user-select: @select;\n -ms-user-select: @select; // IE10+\n user-select: @select;\n}\n","// Gradients\n\n#gradient {\n\n // Horizontal gradient, from left to right\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .horizontal(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to right, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n // Vertical gradient, from top to bottom\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .vertical(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to bottom, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n .directional(@start-color: #555; @end-color: #333; @deg: 45deg) {\n background-repeat: repeat-x;\n background-image: -webkit-linear-gradient(@deg, @start-color, @end-color); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(@deg, @start-color, @end-color); // Opera 12\n background-image: linear-gradient(@deg, @start-color, @end-color); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n }\n .horizontal-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(to right, @start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .vertical-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .radial(@inner-color: #555; @outer-color: #333) {\n background-image: -webkit-radial-gradient(circle, @inner-color, @outer-color);\n background-image: radial-gradient(circle, @inner-color, @outer-color);\n background-repeat: no-repeat;\n }\n .striped(@color: rgba(255,255,255,.15); @angle: 45deg) {\n background-image: -webkit-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n }\n}\n","// Reset filters for IE\n//\n// When you need to remove a gradient background, do not forget to use this to reset\n// the IE filter for IE9 and below.\n\n.reset-filter() {\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(enabled = false)\"));\n}\n"]} --------------------------------------------------------------------------------