├── .gitignore ├── README.md ├── app.js ├── config ├── config.example.js └── locale.js ├── controllers ├── index.js ├── message.js ├── ticket.js └── user.js ├── gulpfile.js ├── libs └── i18next.js ├── models ├── file.js ├── index.js ├── mail.js ├── message.js ├── project.js ├── ticket.js └── user.js ├── package.json ├── public ├── css │ ├── .gitignore │ ├── _adaptive.less │ ├── _common.less │ ├── _reset.less │ ├── _var.less │ ├── pages │ │ ├── _include.less │ │ ├── add-ticket.less │ │ ├── text-page.less │ │ └── ticket-list.less │ ├── react-select.less │ ├── style.css │ └── style.less ├── favicon.ico ├── images │ ├── base64 │ │ ├── arr.png │ │ ├── arrow-up.png │ │ ├── search-active.png │ │ ├── search.png │ │ └── select-plus.png │ ├── bg.jpg │ ├── bg.png │ ├── space.jpg │ ├── sprite.png │ └── sprite.psd ├── js │ ├── actions.js │ ├── actions │ │ ├── root.js │ │ └── tickets.js │ ├── application.js │ ├── bundle.js │ ├── components │ │ ├── copyright.js │ │ ├── file-drop.js │ │ ├── home.js │ │ ├── home │ │ │ ├── add-ticket.js │ │ │ ├── add-ticket │ │ │ │ └── add-form.js │ │ │ └── login.js │ │ ├── ie-check.js │ │ ├── language-switcher.js │ │ ├── root.js │ │ ├── tickets.js │ │ └── tickets │ │ │ ├── detail.js │ │ │ ├── detail │ │ │ ├── header.js │ │ │ ├── messages.js │ │ │ ├── messages │ │ │ │ ├── answer-form.js │ │ │ │ └── message.js │ │ │ └── tags.js │ │ │ ├── list.js │ │ │ ├── list │ │ │ ├── content.js │ │ │ ├── content │ │ │ │ └── ticket.js │ │ │ ├── header.js │ │ │ └── header │ │ │ │ ├── sort-item.js │ │ │ │ └── tags-filter.js │ │ │ ├── sidebar.js │ │ │ └── sidebar │ │ │ ├── folders.js │ │ │ ├── folders │ │ │ └── index.js │ │ │ ├── projects.js │ │ │ └── projects │ │ │ └── index.js │ ├── constants │ │ ├── action-types.js │ │ └── folder-type.js │ ├── i18n.js │ ├── locale.js │ ├── mixins │ │ └── form-mixin.js │ ├── reducers │ │ ├── index.js │ │ ├── root.js │ │ └── tickets.js │ └── store │ │ └── configure-store.js ├── locales │ ├── _lang_.example.json │ ├── en.json │ └── ru.json ├── pictures │ ├── logo-big.jpg │ └── logo.png └── tickets │ └── .gitignore ├── services ├── config.js ├── i18n.js └── template-string.js ├── temp └── .gitignore └── views ├── _layout ├── footer.ejs └── header.ejs ├── agreement-en.ejs ├── agreement.ejs ├── error.ejs └── index.ejs /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /node_modules 3 | /config/config.js 4 | 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HelpDesk 2 | 3 | ### Лицензия 4 | GPL v3 http://www.gnu.org/licenses/gpl-3.0.ru.html 5 | 6 | ### Установка 7 | ```sh 8 | mkdir helpdesk 9 | cd ./helpdesk 10 | git clone https://github.com/SibirixScrum/HelpDesk.git ./ 11 | npm i 12 | ``` 13 | 14 | ### Настройка 15 | ```sh 16 | cd ./config 17 | cp ./config.example.js ./config.js 18 | mcedit ./config.js 19 | ``` 20 | 21 | ### Основые опции 22 | - connectString: 'mongodb://localhost/helpdesk' — база в mongo. Будет создана при первом запуске, если еще не существует. 23 | - exports.projects — настройки проектов. 24 | - responsible: 'tester@example.com' — почта администратора проекта. Аккаунт создается автоматически. Пароль отправляется на почту. 25 | - exports.socketIo: 'SECRETKEY' — секретный ключ для шифрования куков. 26 | - exports.session.secret: 'SECRETKEY' — секретный ключ для шифрования сессий. 27 | - exports.locales=['ru', 'en'] — доступные языки: английский и русский. 28 | - en.json — английский язык. 29 | - ru.json — русский язык. 30 | 31 | ### Запуск 32 | ```sh 33 | node app.js 34 | ``` 35 | 36 | --- 37 | 38 | # HelpDesk 39 | 40 | ### Licence 41 | GPL v3 http://www.gnu.org/licenses/gpl-3.0.en.html 42 | 43 | ### Installation 44 | ```sh 45 | mkdir helpdesk 46 | cd ./helpdesk 47 | git clone https://github.com/SibirixScrum/HelpDesk.git ./ 48 | npm i 49 | ``` 50 | 51 | ### Configuring 52 | ```sh 53 | cd ./config 54 | cp ./config.example.js ./config.js 55 | mcedit ./config.js 56 | ``` 57 | 58 | ### Basic configurations 59 | - connectString: 'mongodb://localhost/helpdesk' — MongoDB database will be created (in case there isn’t any done yet by the time the program is launched for the first time). 60 | - exports.projects — projects settings. 61 | - responsible: 'tester@example.com' — email of project Administrator. The account is created automatically, the password is sent to the email. 62 | - exports.socketIo: 'SECRETKEY' — a private key for cookies encryption. 63 | - exports.session.secret: 'SECRETKEY' — a private key for sessions encryption. 64 | - exports.locales=['ru', 'en'] — available languages: English and Russian. 65 | - en.json — English. 66 | - ru.json — Russian. 67 | 68 | 69 | ### Launch 70 | ```sh 71 | node app.js 72 | ``` 73 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | // Node modules 2 | var bodyParser = require('body-parser'); 3 | var cookieParser = require('cookie-parser'); 4 | var express = require('express'); 5 | var favicon = require('serve-favicon'); 6 | var fs = require('fs'); 7 | var http = require('http'); 8 | var https = require('https'); 9 | var logger = require('morgan'); 10 | var mongoose = require('mongoose'); 11 | var mongoStore = require('connect-mongodb'); 12 | var path = require('path'); 13 | var session = require('express-session'); 14 | var jwt = require('jsonwebtoken'); 15 | var socketIo = require('socket.io'); 16 | var socketioJwt = require('socketio-jwt'); 17 | 18 | const i18next = require('./libs/i18next'); 19 | const middleware = require('i18next-express-middleware'); 20 | const i18nFsBackend = require('i18next-node-fs-backend'); 21 | const intervalPlural = require('i18next-intervalplural-postprocessor'); 22 | 23 | // Читаем конфиг 24 | var extend = require('extend'); 25 | var configExample = require('./config/config.example'); 26 | 27 | let lang = require('./config/locale'); 28 | 29 | try { 30 | var config = require('./config/config'); 31 | } catch (e) { 32 | throw new Error('Not found config file! Copy ./config/config.example.js to ./config/config.js and edit it.'); 33 | } 34 | 35 | global.config = config = extend(true, configExample, config, { 36 | get: function(name, def) { 37 | var namePath = name.split('.'); 38 | var result = this; 39 | 40 | namePath.forEach(function(key){ 41 | if (undefined === result) { 42 | return false; 43 | } 44 | 45 | result = result[key]; 46 | return true; 47 | }); 48 | 49 | if (undefined === result) { 50 | return def; 51 | } 52 | 53 | return result; 54 | } 55 | }); 56 | 57 | // инициализация i18n 58 | i18next 59 | .use(i18nFsBackend) 60 | .use(middleware.LanguageDetector) 61 | .use(intervalPlural) 62 | .init({ 63 | backend: { 64 | loadPath: path.join(__dirname, 'public') + '/locales/{{lng}}.json', 65 | }, 66 | fallbackLng: lang.fallbackLng, 67 | lowerCaseLng: true, 68 | preload: lang.locales, 69 | interpolation: { 70 | escapeValue: false 71 | }, 72 | 73 | parseMissingKeyHandler: function(key, options) { 74 | const regex = /^(\w+\.\w+\.)/; 75 | 76 | if (regex.exec(key) !== null) { 77 | let resultKey = key.replace(regex, ''); 78 | return i18next.t(resultKey, options); 79 | } 80 | 81 | return key; 82 | 83 | }, 84 | 85 | detection: config.detection 86 | }); 87 | 88 | global.defaultTranslator = i18next.getFixedT(lang.defaultLanguage); 89 | 90 | var models = require('./models/'); 91 | 92 | models.setCallback(function() { 93 | 94 | models.project.validateProjectsConfig(); 95 | models.project.checkResponsible(); 96 | 97 | var indexController = require('./controllers/index'); 98 | var ticketController = require('./controllers/ticket'); 99 | var messageController = require('./controllers/message'); 100 | var userController = require('./controllers/user'); 101 | 102 | var app = express(); 103 | 104 | app.set('port', config.port || 3000); 105 | app.set('views', path.join(__dirname, 'views')); 106 | app.set('view engine', 'ejs'); 107 | 108 | // Использование сессий 109 | app.use(session({ 110 | cookie: {maxAge: config.session.maxAge}, 111 | secret: config.session.secret, 112 | store: new mongoStore({db: mongoose.connection.db}), 113 | resave: true, 114 | saveUninitialized: true 115 | })); 116 | 117 | app.use( 118 | middleware.handle(i18next, { 119 | removeLngFromUrl: false 120 | }) 121 | ); 122 | 123 | //app.use(favicon(__dirname + '/public/favicon.ico')); 124 | app.use(logger('dev')); 125 | app.use(bodyParser.json({ limit: '10mb' })); 126 | app.use(bodyParser.urlencoded()); 127 | app.use(cookieParser()); 128 | app.use(express.static(path.join(__dirname, 'public'))); 129 | 130 | app.use('/ticket', ticketController); 131 | app.use('/message', messageController); 132 | app.use('/user', userController); 133 | app.use('/', indexController); 134 | 135 | /// catch 404 and forwarding to error handler 136 | app.use(function(req, res, next) { 137 | var err = new Error('Not Found'); 138 | err.status = 404; 139 | next(err); 140 | }); 141 | 142 | /// error handlers 143 | 144 | // development error handler 145 | // will print stacktrace 146 | if (app.get('env') === 'development') { 147 | app.use(function(err, req, res, next) { 148 | res.status(err.status || 500); 149 | 150 | if (req.headers['accept'] == 'application/json' || req.headers['x-requested-with'] && (req.headers['x-requested-with'].toLowerCase() == 'xmlhttprequest')) { 151 | res.end(JSON.stringify({result: false, message: err.message, error: err})); 152 | } else { 153 | res.render('error', { 154 | message: err.message, 155 | error: err 156 | }); 157 | } 158 | }); 159 | } 160 | 161 | // production error handler 162 | // no stacktraces leaked to user 163 | app.use(function(err, req, res, next) { 164 | res.status(err.status || 500); 165 | 166 | if (req.headers['x-requested-with'] && (req.headers['x-requested-with'].toLowerCase() == 'xmlhttprequest')) { 167 | res.end(JSON.stringify({result: false, message: err.message, error: err})); 168 | } else { 169 | res.render('error', { 170 | message: err.message, 171 | error: err 172 | }); 173 | } 174 | }); 175 | 176 | var httpServer; 177 | 178 | if (config.ssl && config.ssl.enabled) { 179 | var privateKey = fs.readFileSync(config.ssl.key); 180 | var certificate = fs.readFileSync(config.ssl.cert); 181 | var credentials = {key: privateKey, cert: certificate}; 182 | httpServer = https.createServer(credentials, app); 183 | } else { 184 | httpServer = http.createServer(app); 185 | } 186 | 187 | var io = socketIo.listen(httpServer); 188 | io.sockets 189 | .on('connection', socketioJwt.authorize({ 190 | secret: global.config.socketIo.secret, 191 | timeout: global.config.socketIo.timeout 192 | })).on('authenticated', function(socket) { 193 | var userEmail = socket.decoded_token; 194 | var projectsList = models.project.getResponsibleProjectsList(userEmail); 195 | 196 | if (projectsList.length) { 197 | // ТП 198 | for (var i = 0; i < projectsList.length; i++) { 199 | socket.join(projectsList[i].code); 200 | } 201 | } else { 202 | // Клиент 203 | socket.join(userEmail); 204 | } 205 | } 206 | ); 207 | 208 | global.io = io; 209 | 210 | models.mail.startCheckTimeout(); 211 | 212 | httpServer.listen(app.get('port'), function () { 213 | console.log('Express server listening on port ' + app.get('port')); 214 | }); 215 | }); 216 | -------------------------------------------------------------------------------- /config/config.example.js: -------------------------------------------------------------------------------- 1 | exports.port = '3000'; 2 | exports.serverName = 'localhost:' + exports.port; 3 | 4 | exports.appRoot = __dirname + '/../'; // Relative to this file. Usually no need to change this 5 | exports.tmpDir = exports.appRoot + 'temp/'; // Uploaded files temp folder 6 | 7 | /** 8 | * Database, mongodb 9 | */ 10 | exports.db = { 11 | connectString: 'mongodb://localhost/helpdesk' 12 | }; 13 | 14 | /** 15 | * SocketIO auth timeouts 16 | */ 17 | exports.socketIo = { 18 | expire: 60 * 5, // in seconds 19 | timeout: 15000, // in milliseconds 20 | secret: 'SECRET' // todo Fill secret key 21 | }; 22 | 23 | /** 24 | * Session storage (in mongodb) 25 | */ 26 | exports.session = { 27 | maxAge: 30 * 60 * 1000, // in seconds 28 | secret: 'SECRET' // todo Fill secret key 29 | }; 30 | 31 | /** 32 | * Project list 33 | */ 34 | exports.projects = [ 35 | { 36 | code: 'HELPDESK', // Project code 37 | domain: 'localhost:3000', // Project domain (each project should have it's own domain) 38 | name: 'projects.helpdesk.name', // Project name 39 | title: 'projects.helpdesk.formTitle', // Title for form 40 | letters: 'HD', // Short project code, used in ticket numbers 41 | responsible: 'example@example.com', // Email of responsible. Auto-creates user account with this email and sends password to it 42 | email: { // Mail parsing service 43 | login: "", // todo Fill email login 44 | password: "", // todo Fill email password 45 | host: 'imap.gmail.com', 46 | port: 993, 47 | tls: true, 48 | sign: 'Helpdesk', 49 | checkInterval: 30, // in seconds 50 | smtpHost: 'smtp.gmail.com', 51 | smtpPort: 465, 52 | smtpSecure: true, 53 | keepAlive: false // Persistent connection 54 | }, 55 | files: [ // Array of files for project, use for documentation 56 | /*{ 57 | name: 'Displayed text', 58 | path: '/docs/manual.pdf' // File path, relative to www/public/ 59 | }, { 60 | name: '', 61 | path: '' 62 | },*/ 63 | ], 64 | filesPos: 'right', // show project files on right or bottom of ticket form 65 | footer: true, // bool 66 | bodyClass: false // false or string 67 | }/* 68 | , { 69 | code: 'PROJECT2', 70 | ... 71 | }*/ 72 | ]; 73 | 74 | exports.projectColors = [ 75 | 'ff0036', 'ffae00', 'ffe400', '96ff00', '00a2ff', '005aff', '8400ff', 76 | 'ff0078', 'ff9600', 'f6ff00', 'ccf801', '00ccff', '065cec', 'a800ff', 77 | 'ff00c6', 'ff7e00', 'd2ff00', 'e3eb00', '00eaff', '0957d9', 'b716fa' 78 | ]; 79 | 80 | // Time to mark tickets with red color 81 | exports.markDateRed = 60 * 60 * 24 * 3; // 3 days 82 | exports.files = { 83 | maxSize: 5 * 1024 * 1024, // In bytes 84 | maxCount: 10, 85 | extensions: [ 86 | 'jpg', 'jpeg', 'gif', 'png', 'bmp', // images 87 | 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'ods', 'odt', 'txt', // Documents 88 | 'rar', 'zip', '7z', 'tgz', 'gz', 'bz2' // archives 89 | ] 90 | }; 91 | 92 | exports.tickets = { 93 | editor: { 94 | allowedTags: "
$t(projects.example.user.createResponsible.forProject_interval, {'postProcess': 'interval', 'count': {{projectCount}}}) {{projectNames}} new admin is assigned. Ticket list is avaliable by the link Text: Text: $t(projects.example.user.createResponsible.forProject_interval, {'postProcess': 'interval', 'count': {{projectCount}}}) {{projectNames}} создан новый администратор. Список тикетов доступен по ссылке Сообщение: Сообщение:"
95 | },
96 | page: {
97 | limit: 100 // Load tickets in one page
98 | }
99 | };
100 |
101 | // Create tickets from new emails (without existent ticket number)
102 | exports.createTicketFromEmail = true;
103 | // Default project to add new tickets created from emails
104 | exports.ticketFromEmailProject = 'HD';
105 |
106 | exports.ssl = {
107 | enabled: false,
108 | cert: __dirname + '/ssl/server.key',
109 | key: __dirname + '/ssl/server.crt',
110 | };
111 |
112 | exports.detection = {
113 | caches: false, //['cookie']
114 | // optional expire and domain for set cookie
115 | //cookieExpirationDate: new Date(),
116 | //cookieDomain: 'myDomain',
117 | //cookieSecure: true // if need secure cookie
118 | };
119 |
--------------------------------------------------------------------------------
/config/locale.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Язык по умолчанию
3 | * @type {string}
4 | */
5 | exports.defaultLanguage = 'ru-ru';
6 | exports.fallbackLng = 'ru';
7 | exports.locales = ['ru', 'en'];
--------------------------------------------------------------------------------
/controllers/index.js:
--------------------------------------------------------------------------------
1 | var express = require('express');
2 | var router = express.Router();
3 |
4 | var models = require('../models/');
5 | let templateString = require('../services/template-string');
6 | const i18nService = require('../services/i18n');
7 | let i18nHelper = new i18nService.i18n();
8 |
9 | function renderHome(req, res, countTickets) {
10 | let projects = i18nHelper.translateProjects(models.project.getAll(req.session.user));
11 | let curProject = models.project.getProjectByDomain(req.get('host'));
12 | models.ticket.getTagsReference(function(tags) {
13 | res.render('index', {
14 | title: req.i18n.t(templateString.getTitleCode()),
15 | files: global.config.files,
16 | passChanged: req.body.passChanged,
17 | user: req.session.user ? req.session.user : false,
18 | countTickets: countTickets,
19 | projects: projects,
20 | tagsReference: tags,
21 | lngData: req.i18n.store.data,
22 | translateStartCode: templateString.getStartCode(),
23 | className: curProject.bodyClass
24 | });
25 | });
26 | }
27 |
28 | router.get('/tickets', function (req, res) {
29 | templateString.setCurrentProjectByDomain(req.get('host'));
30 | i18nHelper.setConfig(req);
31 |
32 | if (req.session.user) {
33 | models.project.getTicketCount(req.session.user.email, function(err, countTickets) {
34 | renderHome(req, res, err ? false : countTickets);
35 | });
36 | } else {
37 | res.redirect('/');
38 | }
39 | });
40 |
41 | router.get('/agreement/', function (req, res) {
42 | templateString.setCurrentProjectByDomain(req.get('host'));
43 | i18nHelper.setConfig(req);
44 | let curProject = models.project.getProjectByDomain(req.get('host'));
45 |
46 | res.render(i18nHelper.translator('agreement'), {
47 | title: req.i18n.t(templateString.getTitleCode()),
48 | lngData: req.i18n.store.data,
49 | translateStartCode: templateString.getStartCode(),
50 | className: curProject.bodyClass
51 | });
52 | });
53 |
54 | /* GET home page. */
55 | router.get('*', function (req, res) {
56 | templateString.setCurrentProjectByDomain(req.get('host'));
57 | i18nHelper.setConfig(req);
58 |
59 | if (req.session.user) {
60 | models.project.getTicketCount(req.session.user.email, function(err, countTickets) {
61 | renderHome(req, res, err ? false : countTickets);
62 | });
63 | } else {
64 | if (req.query.reset && req.query.login) {
65 | models.user.resetPassword(req.query.login, req.query.reset, function(success) {
66 | req.body.passChanged = success;
67 | renderHome(req, res, false);
68 | });
69 |
70 | return;
71 | }
72 |
73 | renderHome(req, res, false);
74 | }
75 | });
76 |
77 | module.exports = router;
78 |
--------------------------------------------------------------------------------
/controllers/message.js:
--------------------------------------------------------------------------------
1 | var express = require('express');
2 | var striptags = require('striptags');
3 | var multer = require('multer');
4 | var fs = require('fs');
5 | var router = express.Router();
6 |
7 | var models = require('../models/');
8 | var projectModel = models.project;
9 | var ticketModel = models.ticket;
10 | var messageModel = models.message;
11 | var fileModel = models.file;
12 | var mailModel = models.mail;
13 |
14 | let templateString = require('../services/template-string');
15 | const i18nService = require('../services/i18n');
16 | let i18nHelper = new i18nService.i18n();
17 |
18 | /**
19 | * Добавление сообщения в тикет
20 | */
21 | var storage = multer.memoryStorage();
22 | var upload = multer({ storage: storage, fileFilter: fileModel.fileFilter, limits: { fileSize: global.config.files.maxSize } });
23 | var cpUpload = upload.fields([{ name: 'files[]', maxCount: global.config.files.maxCount }]);
24 |
25 | router.post('/add', cpUpload, function (req, res) {
26 | var projectCode = req.body.projectCode;
27 | var number = req.body.number;
28 | var text = req.body.text ? req.body.text.trim() : '';
29 | text = striptags(text, global.config.tickets.editor.allowedTags);
30 |
31 | if (!req.files) req.files = {};
32 |
33 | if (!text) {
34 | fileModel.cleanupFiles(req.files['files[]']);
35 | res.json({ result: false, error: 'no text' });
36 | return;
37 | }
38 |
39 | if (!req.session.user) {
40 | fileModel.cleanupFiles(req.files['files[]']);
41 | res.json({ result: false, error: 'no auth' });
42 | return;
43 | }
44 |
45 | templateString.setCurrentProjectByDomain(req.get('host'));
46 | i18nHelper.setConfig(req);
47 |
48 | ticketModel.setI18nHelper(i18nHelper);
49 | ticketModel.setTemplateBuilder(templateString);
50 |
51 | ticketModel.findTicket(projectCode, number, function(err, ticket) {
52 | if (err) {
53 | fileModel.cleanupFiles(req.files['files[]']);
54 | res.json({result: false, error: 'no ticket'});
55 | return;
56 | }
57 |
58 | if (!ticketModel.hasRightToWrite(ticket, req.session.user)) {
59 | fileModel.cleanupFiles(req.files['files[]']);
60 | res.json({ result: false, error: 'no rights' });
61 | return;
62 | }
63 |
64 | // загрузка файлов
65 | var files = fileModel.proceedUploads(ticket.project + '-' + ticket.number, req.files, 'files[]');
66 | var project = projectModel.getProjectByCode(projectCode);
67 |
68 | // Добавить сообщение
69 | var message = new messageModel.model({
70 | date: new Date(),
71 | author: req.session.user.email,
72 | text: text,
73 | files: files
74 | });
75 |
76 | if (message.author != project.responsible) {
77 | ticket.lastDate = new Date();
78 | }
79 |
80 | ticket.messages.push(message);
81 | ticket.save();
82 |
83 | ticketModel.sendMailOnMessageAdd(project, ticket, message);
84 |
85 | // Отправить ПУШ-оповещение
86 | ticketModel.prepareTicketsForClient([ticket], function(err, data) {
87 | var ticket = data[0];
88 | global.io.to(projectCode ).emit('ticketMessage', { ticket: ticket, source: req.session.user ? req.session.user.email : false });
89 | global.io.to(ticket.author.email).emit('ticketMessage', { ticket: ticket, source: req.session.user ? req.session.user.email : false });
90 | });
91 |
92 | res.json({ result: true });
93 | });
94 | });
95 |
96 | module.exports = router;
97 |
--------------------------------------------------------------------------------
/controllers/user.js:
--------------------------------------------------------------------------------
1 | var express = require('express');
2 | var jsonwebtoken = require('jsonwebtoken');
3 | var router = express.Router();
4 |
5 | var models = require('../models/');
6 | var userModel = models.user;
7 | var projectModel = models.project;
8 |
9 | let templateString = require('../services/template-string');
10 | const i18nService = require('../services/i18n');
11 | let i18nHelper = new i18nService.i18n();
12 |
13 | /**
14 | * Авторизация
15 | */
16 | router.post('/login', function (req, res) {
17 | var email = req.body.email.trim();
18 | var pass = req.body.password.trim();
19 |
20 | if (!email.length || !pass.length) {
21 | res.json({ result: false, error: 'no auth data' });
22 | return;
23 | }
24 |
25 | templateString.setCurrentProjectByDomain(req.get('host'));
26 | i18nHelper.setConfig(req);
27 |
28 | userModel.setI18nHelper(i18nHelper);
29 | userModel.setTemplateBuilder(templateString);
30 |
31 | projectModel.setI18nHelper(i18nHelper);
32 | projectModel.setTemplateBuilder(templateString);
33 |
34 | // Ищем пользователя
35 | userModel.model.find({ email: email }, function(err, data) {
36 | if (err || !data || !data.length) {
37 | res.json({ result: false, error: 'no user' });
38 | return;
39 | }
40 |
41 | var user = data[0];
42 | // Проверяем пароль
43 | if (userModel.validatePassword(user.password, pass)) {
44 | var token = jsonwebtoken.sign(email, global.config.socketIo.secret, { expiresIn: global.config.socketIo.expire * 60 });
45 |
46 | req.session.user = {
47 | email: email,
48 | name: user.name,
49 | token: token,
50 | lng: user.lng || i18nHelper.language
51 | };
52 |
53 | var answer = {
54 | result: true,
55 | user: {
56 | name: user.name,
57 | email: email,
58 | lng: user.lng || i18nHelper.language
59 | },
60 | token: token,
61 | countTickets: false
62 | };
63 |
64 | projectModel.getTicketCount(email, function(err, countTickets) {
65 | if (!err && countTickets) {
66 | answer.countTickets = countTickets;
67 | }
68 |
69 | res.json(answer);
70 | });
71 |
72 | } else {
73 | res.json({ result: false, error: 'wrong pass' });
74 | }
75 | });
76 | });
77 |
78 | router.post('/reset', function(req, res) {
79 | var email = req.body.email.trim();
80 |
81 | templateString.setCurrentProjectByDomain(req.get('host'));
82 | i18nHelper.setConfig(req);
83 |
84 | userModel.setI18nHelper(i18nHelper);
85 | userModel.setTemplateBuilder(templateString);
86 |
87 | userModel.sendResetEmail(email, function(response) {
88 | res.json(response);
89 | })
90 | });
91 |
92 | /**
93 | * Логаут
94 | */
95 | router.all('/logout', function (req, res) {
96 | req.session.user = null;
97 | res.json({ result: true });
98 | });
99 |
100 | router.post('/change-lang', function(req, res) {
101 | if (!req.session.user) {
102 | res.json({ result: false, error: 'no auth' });
103 | return;
104 | }
105 |
106 | templateString.setCurrentProjectByDomain(req.get('host'));
107 | i18nHelper.setConfig(req);
108 |
109 | userModel.changeLng(req.session.user.email, req.body.lng);
110 |
111 | res.json({ result: true});
112 |
113 | });
114 |
115 | module.exports = router;
116 |
--------------------------------------------------------------------------------
/gulpfile.js:
--------------------------------------------------------------------------------
1 | var browserify = require('browserify');
2 | var gulp = require('gulp');
3 | var source = require('vinyl-source-stream');
4 | var reload = require('gulp-livereload');
5 | var notify = require('gulp-notify');
6 | var gaze = require('gaze');
7 | var babelify = require('babelify');
8 | var reactify = require('reactify');
9 | var envify = require('envify');
10 | var minify = require('minify-stream');
11 |
12 | const prefix = require('gulp-autoprefixer');
13 | const csso = require('gulp-csso');
14 | const less = require('gulp-less');
15 |
16 | gulp.task('browserify', function() {
17 | var b = browserify();
18 |
19 | b.transform(envify, {'global': true, '_': 'purge', NODE_ENV: 'production'});
20 | b.transform(reactify);
21 | b.transform(babelify);
22 | b.add('./public/js/application.js');
23 | return b.bundle()
24 | .on('error', notify.onError(function(err) {
25 | return err.toString()
26 | }))
27 | .pipe(source('bundle.js'))
28 | .pipe(gulp.dest('./public/js/'));
29 | });
30 |
31 | gulp.task('less', function () {
32 | return gulp.src('./public/css/style.less')
33 | .pipe(less({
34 | javascriptEnabled: true
35 | }))
36 | .pipe(prefix('last 3 versions'))
37 | .pipe(csso({
38 | restructure: false
39 | }))
40 | .pipe(gulp.dest('./public/css/'))
41 | });
42 |
43 | gulp.task('watch', ['browserify', 'less'], function() {
44 | gaze(['public/js/**/*.js', '!public/js/bundle.js'], function() {
45 | this.on('added', function() {
46 | gulp.start('browserify');
47 | });
48 | this.on('changed', function() {
49 | gulp.start('browserify');
50 | })
51 | });
52 |
53 | gaze('public/css/**/*.less', function() {
54 | this.on('all', function(event, filepath) {
55 | gulp.start('less');
56 | });
57 | });
58 | });
59 |
60 | gulp.task('default', ['watch']);
61 |
--------------------------------------------------------------------------------
/models/file.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Модель файлов
3 | */
4 |
5 | var mongoose = require('mongoose');
6 | var crypto = require('crypto');
7 | var fs = require('fs');
8 | var pathModule = require('path');
9 | var collectionName = 'file';
10 |
11 | var fileSchema = new mongoose.Schema({
12 | name: String, // Выводимое имя
13 | path: String // Путь на сервере для скачивания
14 | });
15 |
16 | fileSchema.methods.toAttach = function() {
17 | return {
18 | filename: this.name,
19 | content: fs.readFileSync(global.config.appRoot + 'public' + this.path)
20 | };
21 | };
22 |
23 | exports.scheme = fileSchema;
24 | exports.model = mongoose.model(collectionName, fileSchema);
25 |
26 | /**
27 | * Почистить загруженные файлы за собой
28 | * @param fileArray
29 | */
30 | exports.cleanupFiles = function(fileArray) {
31 | if (!(typeof fileArray == 'object') || (!fileArray.length)) return;
32 | for (var i = 0; i < fileArray.length; i++) {
33 | if (fileArray[i].path) fs.unlink(fileArray[i].path);
34 | }
35 | };
36 |
37 | /**
38 | * Фильтрация входящих файлов по расширению
39 | * @param req
40 | * @param file
41 | * @param cb
42 | */
43 | exports.fileFilter = function(req, file, cb) {
44 | var exts = global.config.files.extensions;
45 | var ext = pathModule.extname(file.originalname).toLowerCase();
46 | if (ext[0] == '.') {
47 | ext = ext.substr(1);
48 | }
49 |
50 | if (exts.indexOf(ext) > -1) {
51 | cb(null, true);
52 | } else {
53 | cb(new Error('Wrong extension, ' + file.originalname));
54 | }
55 | };
56 |
57 | /**
58 | *
59 | * @param ticketNumber
60 | * @param reqFiles
61 | * @param key
62 | * @returns {Array}
63 | */
64 | exports.proceedUploads = function(ticketNumber, reqFiles, key) {
65 | var shasum = crypto.createHash('sha1');
66 | shasum.update(ticketNumber);
67 | var shortPath = '/tickets/' + shasum.digest('hex') + '/';
68 | var path = global.config.appRoot + 'public' + shortPath;
69 |
70 | try { fs.mkdirSync(path); } catch(e) { }
71 |
72 | var files = [];
73 | if (reqFiles && reqFiles[key] && reqFiles[key].length) {
74 | for (var i = 0; i < reqFiles[key].length; i++) {
75 | var file = reqFiles[key][i];
76 | var ext = pathModule.extname(file.originalname);
77 | var newName = Math.random().toString(36).slice(-8) + ext;
78 |
79 | if (file.path) {
80 | fs.renameSync(file.path, path + newName);
81 | } else if (file.buffer) {
82 | fs.writeFileSync(path + newName, file.buffer);
83 | }
84 |
85 | var fileObj = new this.model({
86 | name: file.originalname,
87 | path: shortPath + newName
88 | });
89 | files.push(fileObj);
90 | }
91 | }
92 |
93 | return files;
94 | };
95 |
96 | exports.proceedMailAttachments = function(ticketNumber, attachments) {
97 | var shasum = crypto.createHash('sha1');
98 | shasum.update(ticketNumber);
99 | var shortPath = '/tickets/' + shasum.digest('hex') + '/';
100 | var path = global.config.appRoot + 'public' + shortPath;
101 |
102 | try { fs.mkdirSync(path); } catch(e) { }
103 |
104 | var files = [];
105 | if (attachments && attachments.length) {
106 | var added = 0;
107 | for (var i = 0; i < attachments.length && added < global.config.files.maxCount; i++) {
108 | var file = attachments[i];
109 |
110 | if (file.length > global.config.files.maxSize) {
111 | continue;
112 | }
113 |
114 | added++;
115 | var ext = pathModule.extname(file.fileName);
116 | var newName = Math.random().toString(36).slice(-8) + ext;
117 |
118 | fs.writeFile(path + newName, file.content);
119 |
120 | var fileObj = new this.model({
121 | name: file.fileName,
122 | path: shortPath + newName
123 | });
124 | files.push(fileObj);
125 | }
126 | }
127 |
128 | return files;
129 | };
130 |
--------------------------------------------------------------------------------
/models/index.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Модель моделей
3 | */
4 |
5 | var mongoose = require('mongoose');
6 | mongoose.connect(global.config.db.connectString);
7 |
8 | var initCallback = null;
9 | var inited = false;
10 |
11 | var db = mongoose.connection;
12 | global.mongooseConnection = db;
13 | db.on('error', function(err) {
14 | throw 'connection error: ' + err;
15 | });
16 |
17 | db.once('open', function callback () {
18 | exports.project = require('./project.js');
19 | exports.ticket = require('./ticket.js');
20 | exports.user = require('./user.js');
21 | exports.message = require('./message.js');
22 | exports.file = require('./file.js');
23 | exports.mail = require('./mail.js');
24 |
25 | if (typeof initCallback === 'function') {
26 | initCallback();
27 | inited = true;
28 | initCallback = null;
29 | }
30 | });
31 |
32 | /**
33 | *
34 | * @param cb
35 | */
36 | exports.setCallback = function(cb) {
37 | if (inited) {
38 | cb();
39 | } else {
40 | initCallback = cb;
41 | }
42 | };
43 |
--------------------------------------------------------------------------------
/models/message.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Модель сообщения
3 | */
4 |
5 | var mongoose = require('mongoose');
6 | var collectionName = 'message';
7 | var fileScheme = require('./file').scheme;
8 |
9 | var messageSchema = new mongoose.Schema({
10 | date: Date, // Дата создания сообщения
11 | created: { type: Date, default: Date.now }, // Дата добавления сообщения в базу
12 | author: String, // Email автора
13 | text: String, // Текст сообщения
14 | files: [fileScheme] // Массив прикрепленных файлов
15 | });
16 |
17 | messageSchema.methods.getFilesForMail = function() {
18 | var attachments = [];
19 |
20 | if (this.files && this.files.length) {
21 | for (var i = 0; i < this.files.length; i++) {
22 | attachments.push(this.files[i].toAttach());
23 | }
24 | }
25 |
26 | return attachments;
27 | };
28 |
29 | exports.scheme = messageSchema;
30 | exports.model = mongoose.model(collectionName, messageSchema);
--------------------------------------------------------------------------------
/models/project.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Модель проектов
3 | */
4 |
5 | var projectsList = global.config.projects;
6 | var ticketModel = require('./ticket');
7 | var userModel = require('./user');
8 | var extend = require('extend');
9 |
10 | let templateString = require('../services/template-string');
11 | const i18nService = require('../services/i18n');
12 | let i18nHelper = new i18nService.i18n();
13 |
14 | let templateBuilder = templateString;
15 |
16 | exports.setI18nHelper = (helper) => {
17 | i18nHelper = helper;
18 | };
19 |
20 | exports.setTemplateBuilder = (builder) => {
21 | templateBuilder = builder;
22 | };
23 |
24 | /**
25 | * Валидация конфига проектов
26 | * @returns {boolean}
27 | */
28 | function validateConfig() {
29 | if (!projectsList || !projectsList.length) {
30 | throw "No projects in config";
31 | }
32 |
33 | var i;
34 | for (i = 0; i < projectsList.length; i++) {
35 | var p = projectsList[i];
36 | if (!p.code || !p.name || !p.domain || !p.responsible) {
37 | throw "Projects config are invalid! Required fields: code, name, domain, responsible.";
38 | }
39 | }
40 |
41 | return true;
42 | }
43 |
44 | exports.validateProjectsConfig = validateConfig;
45 |
46 | exports.checkResponsible = () => {
47 | let responsibles = {};
48 | let colorLength = global.config.projectColors.length;
49 |
50 | projectsList.forEach((current, index) => {
51 | current.color = '#' + global.config.projectColors[index % colorLength];
52 | if (!responsibles[current.responsible]) responsibles[current.responsible] = [];
53 | responsibles[current.responsible].push(current);
54 | });
55 |
56 | // Проверить, что ответственные созданы
57 | for (var email in responsibles) {
58 | if (!responsibles.hasOwnProperty(email)) continue;
59 | userModel.createResponsible(email, responsibles[email]);
60 | }
61 | };
62 |
63 | /**
64 | *
65 | * @param x
66 | * @returns {*}
67 | */
68 | exports.getBigUniqueNumber = function(x) {
69 | var prime = 9999667;
70 | var gap = 100;
71 | var n = x + gap;
72 | if (n >= prime) return n;
73 | var residue = (n * n) % prime;
74 | return (n <= prime / 2) ? residue : prime - residue;
75 | };
76 |
77 | /**
78 | * Получить проект по его коду
79 | * @param code
80 | * @returns {*}
81 | */
82 | exports.getProjectByCode = function(code) {
83 | for (var i = 0; i < projectsList.length; i++) {
84 | if (projectsList[i].code == code) {
85 | return projectsList[i];
86 | }
87 | }
88 |
89 | return false;
90 | };
91 |
92 | /**
93 | * Получить проект по его домену
94 | * @param domain
95 | * @returns {*}
96 | */
97 | exports.getProjectByDomain = function(domain) {
98 | for (var i = 0; i < projectsList.length; i++) {
99 | if (projectsList[i].domain == domain) {
100 | return projectsList[i];
101 | }
102 | }
103 |
104 | return false;
105 | };
106 |
107 | /**
108 | *
109 | * @param letters
110 | * @returns {*}
111 | */
112 | exports.getProjectByLetters = function(letters) {
113 | for (var i = 0; i < projectsList.length; i++) {
114 | if (projectsList[i].letters == letters) {
115 | return projectsList[i];
116 | }
117 | }
118 |
119 | return false;
120 | };
121 |
122 | /**
123 | *
124 | * @param responsible
125 | * @returns {Array}
126 | */
127 | exports.getResponsibleProjectsList = function(responsible) {
128 | var projects = [];
129 |
130 | for (var i = 0; i < projectsList.length; i++) {
131 | if (projectsList[i].responsible == responsible) {
132 | projects.push(projectsList[i]);
133 | }
134 | }
135 |
136 | return projects;
137 | };
138 |
139 | /**
140 | * Количество тикетов по статусам по доступным проектам
141 | * @param email
142 | * @param callback
143 | */
144 | exports.getTicketCount = function(email, callback) {
145 | var projectList = this.getResponsibleProjectsList(email);
146 |
147 | var count = {};
148 | if (projectList.length) {
149 | // ТП
150 | var projectCodes = [];
151 |
152 | for (var i = 0; i < projectList.length; i++) {
153 | projectCodes.push(projectList[i].code);
154 | count[projectList[i].code] = {
155 | opened: 0,
156 | closed: 0
157 | };
158 | }
159 |
160 | ticketModel.model.aggregate([
161 | {
162 | $match: { project: { $in: projectCodes } }
163 | },
164 | {
165 | $group : {
166 | _id : { project: "$project", opened: "$opened" },
167 | count: { $sum: 1 }
168 | }
169 | }
170 | ], function (err, result) {
171 | if (result && result.length) {
172 | for (var i = 0; i < result.length; i++) {
173 | var key = result[i]._id.opened ? 'opened' : 'closed';
174 | count[result[i]._id.project][key] = result[i].count;
175 | }
176 | }
177 | callback(err, count);
178 | });
179 |
180 | } else {
181 | // Клиент
182 | ticketModel.model.aggregate([
183 | {
184 | $match: { author: email }
185 | },
186 | {
187 | $group : {
188 | _id : { project: "$project", opened: "$opened" },
189 | count: { $sum: 1 }
190 | }
191 | }
192 | ], function (err, result) {
193 | for (var i = 0; i < result.length; i++) {
194 | var project = result[i]._id.project;
195 | var key = result[i]._id.opened ? 'opened' : 'closed';
196 |
197 | if (!count[project]) {
198 | count[project] = {
199 | opened: 0,
200 | closed: 0
201 | };
202 | }
203 |
204 | count[project][key] = result[i].count;
205 | }
206 | callback(err, count);
207 | });
208 | }
209 | };
210 |
211 | /**
212 | * Получить проект по его коду
213 | * @returns {*}
214 | */
215 | exports.getAll = function(user) {
216 | return projectsList.map(function(project){
217 |
218 | var pr = extend({}, project);
219 | pr.canSupport = user && pr.responsible == user.email;
220 | delete pr.responsible;
221 | delete pr.email;
222 |
223 | return pr;
224 | });
225 | };
226 |
227 |
228 |
229 |
--------------------------------------------------------------------------------
/models/user.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Модель проектов
3 | */
4 |
5 | var mongoose = require('mongoose');
6 | var crypto = require('crypto');
7 | var mailModel = require('./mail');
8 | var collectionName = 'user';
9 | var ticketModel = require('./ticket');
10 |
11 | let templateString = require('../services/template-string');
12 | const i18nService = require('../services/i18n');
13 | let i18nHelper = new i18nService.i18n();
14 |
15 | var userSchema = new mongoose.Schema({
16 | name: String, // Выводимое имя
17 | email: String, // Email
18 | resetHash: String,
19 | password: String, // Хеш пароля
20 | lng: String // язык пользователя
21 | });
22 |
23 | let templateBuilder = templateString;
24 |
25 | exports.setI18nHelper = (helper) => {
26 | i18nHelper = helper;
27 | };
28 |
29 | exports.setTemplateBuilder = (builder) => {
30 | templateBuilder = builder;
31 | };
32 |
33 | exports.scheme = userSchema;
34 | exports.model = mongoose.model(collectionName, userSchema);
35 |
36 | /**
37 | * Создать ответственного
38 | * @param email
39 | * @param projects
40 | * @param callback
41 | */
42 | exports.createResponsible = function(email, projects, callback) {
43 | var userModel = this.model;
44 |
45 | userModel.find({email: email}, function(err, data) {
46 | if (err) {
47 | callback && callback(err);
48 | return;
49 | }
50 |
51 | if (data && data.length) {
52 | callback && callback(null, data);
53 |
54 | } else {
55 | var pass = this.generatePassword();
56 |
57 | var user = new userModel({
58 | name: i18nHelper.translator(`${templateBuilder.getStartCode()}.user.createResponsible.name`),
59 | email: email,
60 | password: this.hashPassword(pass),
61 | lng: i18nHelper.language
62 | });
63 |
64 | user.save(function() {
65 | let projectNames = projects.map(function(obj) { return obj.name; } );
66 |
67 | console.log('TP account created', email, pass, projectNames);
68 |
69 | let subject = i18nHelper.translator(`${templateBuilder.getStartCode()}.user.createResponsible.subject`);
70 | let text = i18nHelper.translator(
71 | `${templateBuilder.getStartCode()}.user.createResponsible.text`,
72 | {
73 | projectCount: projectNames.length,
74 | projectNames: i18nHelper.translator(projectNames.join(', ')),
75 | link: 'http://' + projects[0].domain + '/?login=' + encodeURIComponent(email),
76 | login: email,
77 | pass: pass
78 | }
79 | );
80 |
81 | mailModel.sendMail(email, subject, text, true, global.config.projects[0]);
82 | });
83 | }
84 | }.bind(this));
85 | };
86 |
87 | /**
88 | * Найти/создать пользователя
89 | * @param email
90 | * @param name
91 | * @param callback
92 | */
93 | exports.createGetUser = function(email, name, callback) {
94 | var userModel = this.model;
95 |
96 | userModel.find({email: email}, function(err, data) {
97 | if (err) { callback(err); return; }
98 |
99 | if (data && data.length) {
100 | callback && callback(null, data[0], false);
101 |
102 | } else {
103 | var pass = this.generatePassword();
104 | console.log(email, pass);
105 | var user = new userModel({
106 | name: name,
107 | email: email,
108 | password: this.hashPassword(pass),
109 | lng: i18nHelper.language
110 | });
111 |
112 | user.save(function() {
113 | callback && callback(null, user, pass);
114 | });
115 | }
116 | }.bind(this));
117 | };
118 |
119 | /**
120 | * Сгенерировать пароль
121 | * @returns {string}
122 | */
123 | exports.generatePassword = function() {
124 | return Math.random().toString(36).slice(-8);
125 | };
126 |
127 | /**
128 | * Хешировать пароль
129 | * @param pass
130 | * @param salt
131 | * @returns {string}
132 | */
133 | exports.hashPassword = function(pass, salt) {
134 | if (typeof salt === 'undefined') {
135 | salt = this.generatePassword();
136 | }
137 |
138 | var shasum = crypto.createHash('sha1');
139 | shasum.update(pass);
140 | shasum.update(salt);
141 | var text = shasum.digest('hex');
142 |
143 | return salt + text;
144 | };
145 |
146 | /**
147 | * Проверить пароль по хешу
148 | * @param hash
149 | * @param pass
150 | * @returns {boolean}
151 | */
152 | exports.validatePassword = function(hash, pass) {
153 | var salt = hash.substr(0, 8);
154 | var shasum = crypto.createHash('sha1');
155 | shasum.update(pass);
156 | shasum.update(salt);
157 | var newHash = salt + shasum.digest('hex');
158 |
159 | return (hash == newHash);
160 | };
161 |
162 | exports.sendResetEmail = function(email, cb) {
163 | var userModel = this.model;
164 |
165 | userModel.find({email: email}, function(err, data) {
166 | if (err || !data || !data.length) {
167 | cb({ result: false, error: 'no user' })
168 | return;
169 | }
170 |
171 | var user = data[0];
172 |
173 | var hash = crypto.createHash('sha1').update(Date.now().toString()).digest('hex');
174 |
175 | ticketModel.getTickets({author: email}, 0, 'date desc', function(err, tickets) {
176 | if (err || !tickets.length) {
177 | cb({result: false, error: 'no user'});
178 | return;
179 | }
180 |
181 | var project = global.config.projects.filter(function(p) {return p.code === tickets[0].project})[0];
182 |
183 | if (!project) {
184 | cb({result: false, error: 'no user'});
185 | return;
186 |
187 | }
188 |
189 | userModel.update({email: email}, {resetHash: hash}, function(err, raw) {
190 | if (err) {
191 | cb({result: false, error: 'no user'});
192 | return;
193 | }
194 |
195 | var to = email;
196 | var subject = i18nHelper.translator(`${templateBuilder.getStartCode()}.user.sendResetEmail.subject`);
197 |
198 | var text = i18nHelper.translator(
199 | `${templateBuilder.getStartCode()}.user.sendResetEmail.text`,
200 | {
201 | link: `http://${project.domain}/?reset=${hash}&login=${encodeURIComponent(email)}`
202 | });
203 |
204 | mailModel.sendMail(to, subject, text, true, project);
205 |
206 | cb({result: true});
207 | })
208 | });
209 | });
210 | }
211 |
212 | exports.resetPassword = function(email, hash, cb) {
213 | this.model.find({email: email}, function(err, data) {
214 | if (err || !data || !data.length) {
215 | cb(false)
216 | return;
217 | }
218 |
219 | var user = data[0];
220 |
221 | if (!user.resetHash || hash !== user.resetHash) {
222 | cb(false);
223 | return;
224 | }
225 |
226 | var pass = this.generatePassword();
227 |
228 | this.model.update({email: email}, {resetHash: '', password: this.hashPassword(pass)}, function(err) {
229 | if (err) {
230 | cb(false);
231 | return;
232 | }
233 |
234 | ticketModel.getTickets({author: email}, 0, 'date desc', function(err, tickets) {
235 | if (err) {
236 | cb(false);
237 | return;
238 | }
239 |
240 | var project = global.config.projects.filter(function(p) {return p.code === tickets[0].project})[0];
241 |
242 | var to = email;
243 | var subject = i18nHelper.translator(`${templateBuilder.getStartCode()}.user.resetPassword.subject`);
244 | var text = i18nHelper.translator(`${templateBuilder.getStartCode()}.user.resetPassword.text`, {pass: pass});
245 |
246 | mailModel.sendMail(to, subject, text, true, project);
247 |
248 | cb(true);
249 | });
250 | });
251 | }.bind(this));
252 | }
253 |
254 | exports.changeLng = (userEmail, lng) => {
255 | let userModel = this.model;
256 |
257 | userModel.find({email: userEmail}, (err, data) => {
258 | if (err) { return; }
259 |
260 | if (data && data.length) {
261 | userModel.update({email: userEmail}, {lng: lng}, () => {});
262 | }
263 | });
264 | };
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sibirix-helpdesk",
3 | "version": "0.7.0",
4 | "private": true,
5 | "dependencies": {
6 | "async": "1.4.2",
7 | "body-parser": "~1.0.0",
8 | "boron": "0.1.2",
9 | "connect-mongodb": "*",
10 | "cookie-parser": "~1.0.1",
11 | "debug": "~0.7.4",
12 | "ejs": "~0.8.5",
13 | "envify": "3.4.0",
14 | "express": "~4.0.0",
15 | "express-session": "1.11.3",
16 | "extend": "3.0.0",
17 | "gulp-autoprefixer": "^6.0.0",
18 | "gulp-csso": "^3.0.1",
19 | "gulp-less": "^4.0.1",
20 | "i18next": "^14.0.1",
21 | "i18next-browser-languagedetector": "^2.2.4",
22 | "i18next-express-middleware": "^1.7.1",
23 | "i18next-intervalplural-postprocessor": "^1.0.2",
24 | "i18next-node-fs-backend": "^2.1.1",
25 | "i18next-xhr-backend": "^1.5.1",
26 | "imap": "0.8.15",
27 | "jsonwebtoken": "5.0.5",
28 | "less": "^3.9.0",
29 | "mailparser": "0.5.2",
30 | "minify-stream": "^1.2.0",
31 | "moment": "2.10.6",
32 | "mongodb": "*",
33 | "mongoose": "~4.0.0",
34 | "mongoose-auto-increment": "4.0.0",
35 | "morgan": "~1.0.0",
36 | "multer": "1.0.3",
37 | "nodemailer": "1.4.0",
38 | "react": "0.13.3",
39 | "react-dropzone": "2.0.1",
40 | "react-i18next": "^9.0.10",
41 | "react-redux": "~2.1.0",
42 | "react-router": "0.13.3",
43 | "react-select": "0.6.12",
44 | "redux": "2.0.0",
45 | "redux-thunk": "0.1.0",
46 | "serve-favicon": "*",
47 | "socket.io": "1.3.6",
48 | "socket.io-emitter": "0.2.0",
49 | "socketio-jwt": "4.3.1",
50 | "striptags": "2.0.3",
51 | "superagent": "*",
52 | "terser": "^3.16.1",
53 | "uglify-es": "^3.3.9",
54 | "uglify-js": "^3.4.9",
55 | "underscore": "^1.8.3"
56 | },
57 | "devDependencies": {
58 | "babelify": "^6.3.0",
59 | "browserify": "^11.0.1",
60 | "gaze": "^0.5.1",
61 | "gulp": "^3.9.0",
62 | "gulp-livereload": "^3.8.0",
63 | "gulp-notify": "^2.2.0",
64 | "lorem-ipsum": "^1.0.3",
65 | "reactify": "^1.1.1",
66 | "superagent": "^1.4.0",
67 | "uglifyify": "^3.0.1",
68 | "vinyl-source-stream": "^1.1.0"
69 | },
70 | "scripts": {
71 | "start": "node app.js",
72 | "start-with-debug": "set DEBUG=* && node app.js ",
73 | "build-front": "./node_modules/.bin/gulp -- browserify"
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/public/css/.gitignore:
--------------------------------------------------------------------------------
1 | *.css
2 |
--------------------------------------------------------------------------------
/public/css/_adaptive.less:
--------------------------------------------------------------------------------
1 | @import "_var";
2 |
3 | /**
4 | * Адаптивные стили
5 | **/
6 |
7 | @media screen and (max-width: 1820px) {
8 | .tickets-list-page {
9 | .tickets-list-column {
10 | width: auto;
11 | flex-grow: 1;
12 | }
13 |
14 | .one-ticket {
15 | position: absolute;
16 | right: 0;
17 | bottom: 0;
18 | top: 55px;
19 | width: 672px;
20 | flex-grow: 0;
21 | }
22 | }
23 | }
24 |
25 |
--------------------------------------------------------------------------------
/public/css/_reset.less:
--------------------------------------------------------------------------------
1 | @import "_var";
2 |
3 | /* normalize.css v1.1.2 | MIT License | git.io/normalize */
4 |
5 | /* ==========================================================================
6 | HTML5 display definitions
7 | ========================================================================== */
8 |
9 | /**
10 | * Correct `block` display not defined in IE 6/7/8/9 and Firefox 3.
11 | */
12 |
13 | article,
14 | aside,
15 | details,
16 | figcaption,
17 | figure,
18 | footer,
19 | header,
20 | main,
21 | nav,
22 | section,
23 | summary {
24 | display: block;
25 | }
26 |
27 | /**
28 | * Correct `inline-block` display not defined in IE 6/7/8/9 and Firefox 3.
29 | */
30 |
31 | audio,
32 | canvas,
33 | video {
34 | display: inline-block;
35 | }
36 |
37 | /**
38 | * Prevent modern browsers from displaying `audio` without controls.
39 | * Remove excess height in iOS 5 devices.
40 | */
41 |
42 | audio:not([controls]) {
43 | display: none;
44 | height: 0;
45 | }
46 |
47 | /**
48 | * Address styling not present in IE 7/8/9, Firefox 3, and Safari 4.
49 | * Known issue: no IE 6 support.
50 | */
51 |
52 | [hidden] {
53 | display: none;
54 | }
55 |
56 | /* ==========================================================================
57 | Base
58 | ========================================================================== */
59 |
60 | /**
61 | * Prevent iOS text size adjust after orientation change, without disabling
62 | * user zoom.
63 | */
64 |
65 | html {
66 | -ms-text-size-adjust: 100%;
67 | -webkit-text-size-adjust: 100%;
68 | }
69 |
70 | /**
71 | * Address `font-family` inconsistency between `textarea` and other form
72 | * elements.
73 | */
74 |
75 | html,
76 | button,
77 | input,
78 | select,
79 | textarea {
80 | font-family: sans-serif;
81 | }
82 |
83 | /**
84 | * Address margins handled incorrectly in IE 6/7.
85 | */
86 | body {
87 | margin: 0;
88 | }
89 |
90 | /* ==========================================================================
91 | Links
92 | ========================================================================== */
93 |
94 | /**
95 | * Address `outline` inconsistency between Chrome and other browsers.
96 | */
97 |
98 | a:focus {
99 | outline: none;
100 | }
101 |
102 | /**
103 | * Improve readability when focused and also mouse hovered in all browsers.
104 | */
105 |
106 | a:active,
107 | a:hover {
108 | outline: 0;
109 | }
110 |
111 | /* ==========================================================================
112 | Typography
113 | ========================================================================== */
114 |
115 | /**
116 | * Address font sizes and margins set differently in IE 6/7.
117 | * Address font sizes within `section` and `article` in Firefox 4+, Safari 5,
118 | * and Chrome.
119 | */
120 |
121 | h1 {
122 | font-size: 2em;
123 | margin: 0.67em 0;
124 | }
125 |
126 | h2 {
127 | font-size: 1.5em;
128 | margin: 0.83em 0;
129 | }
130 |
131 | h3 {
132 | font-size: 1.17em;
133 | margin: 1em 0;
134 | }
135 |
136 | h4 {
137 | font-size: 1em;
138 | margin: 1.33em 0;
139 | }
140 |
141 | h5 {
142 | font-size: 0.83em;
143 | margin: 1.67em 0;
144 | }
145 |
146 | h6 {
147 | font-size: 0.67em;
148 | margin: 2.33em 0;
149 | }
150 |
151 | /**
152 | * Address styling not present in IE 7/8/9, Safari 5, and Chrome.
153 | */
154 |
155 | abbr[title] {
156 | border-bottom: 1px dotted;
157 | }
158 |
159 | /**
160 | * Address style set to `bolder` in Firefox 3+, Safari 4/5, and Chrome.
161 | */
162 |
163 | b,
164 | strong {
165 | font-weight: bold;
166 | }
167 |
168 | blockquote {
169 | margin: 1em 40px;
170 | }
171 |
172 | /**
173 | * Address styling not present in Safari 5 and Chrome.
174 | */
175 |
176 | dfn {
177 | font-style: italic;
178 | }
179 |
180 | /**
181 | * Address differences between Firefox and other browsers.
182 | * Known issue: no IE 6/7 normalization.
183 | */
184 |
185 | hr {
186 | -moz-box-sizing: content-box;
187 | box-sizing: content-box;
188 | height: 0;
189 | }
190 |
191 | /**
192 | * Address styling not present in IE 6/7/8/9.
193 | */
194 |
195 | mark {
196 | background: #ff0;
197 | color: #000;
198 | }
199 |
200 | /**
201 | * Address margins set differently in IE 6/7.
202 | */
203 |
204 | p,
205 | pre {
206 | margin: 1em 0;
207 | }
208 |
209 | /**
210 | * Correct font family set oddly in IE 6, Safari 4/5, and Chrome.
211 | */
212 |
213 | code,
214 | kbd,
215 | pre,
216 | samp {
217 | font-family: monospace, serif;
218 | font-size: 1em;
219 | }
220 |
221 | /**
222 | * Improve readability of pre-formatted text in all browsers.
223 | */
224 |
225 | pre {
226 | white-space: pre-wrap;
227 | word-wrap: break-word;
228 | }
229 |
230 | /**
231 | * Address inconsistent and variable font size in all browsers.
232 | */
233 |
234 | small {
235 | font-size: 80%;
236 | }
237 |
238 | /**
239 | * Prevent `sub` and `sup` affecting `line-height` in all browsers.
240 | */
241 |
242 | sub,
243 | sup {
244 | font-size: 75%;
245 | line-height: 0;
246 | position: relative;
247 | vertical-align: baseline;
248 | }
249 |
250 | sup {
251 | top: -0.5em;
252 | }
253 |
254 | sub {
255 | bottom: -0.25em;
256 | }
257 |
258 | /* ==========================================================================
259 | Lists
260 | ========================================================================== */
261 |
262 | /**
263 | * Address margins set differently in IE 6/7.
264 | */
265 |
266 | dl,
267 | menu,
268 | ol,
269 | ul {
270 | margin: 1em 0;
271 | }
272 |
273 | dd {
274 | margin: 0 0 0 40px;
275 | }
276 |
277 | /**
278 | * Address paddings set differently in IE 6/7.
279 | */
280 |
281 | menu,
282 | ol,
283 | ul {
284 | padding: 0 0 0 40px;
285 | }
286 |
287 | /* ==========================================================================
288 | Embedded content
289 | ========================================================================== */
290 |
291 | /**
292 | * Remove border when inside `a` element in IE 6/7/8/9 and Firefox 3.
293 | */
294 |
295 | img {
296 | border: 0; /* 1 */
297 | }
298 |
299 | /**
300 | * Correct overflow displayed oddly in IE 9.
301 | */
302 |
303 | svg:not(:root) {
304 | overflow: hidden;
305 | }
306 |
307 | /* ==========================================================================
308 | Figures
309 | ========================================================================== */
310 |
311 | /**
312 | * Address margin not present in IE 6/7/8/9, Safari 5, and Opera 11.
313 | */
314 |
315 | figure {
316 | margin: 0;
317 | }
318 |
319 | /* ==========================================================================
320 | Forms
321 | ========================================================================== */
322 |
323 | /**
324 | * Define consistent border, margin, and padding.
325 | */
326 |
327 | fieldset {
328 | border: 1px solid #c0c0c0;
329 | margin: 0 2px;
330 | padding: 0.35em 0.625em 0.75em;
331 | }
332 |
333 | /**
334 | * 1. Correct color not being inherited in IE 6/7/8/9.
335 | * 2. Correct text not wrapping in Firefox 3.
336 | */
337 |
338 | legend {
339 | border: 0; /* 1 */
340 | padding: 0;
341 | white-space: normal; /* 2 */
342 | }
343 |
344 | /**
345 | * 1. Correct font size not being inherited in all browsers.
346 | * 2. Address margins set differently in IE 6/7, Firefox 3+, Safari 5,
347 | * and Chrome.
348 | * 3. Improve appearance and consistency in all browsers.
349 | */
350 |
351 | button,
352 | input,
353 | select,
354 | textarea {
355 | font-size: 100%; /* 1 */
356 | margin: 0; /* 2 */
357 | vertical-align: baseline; /* 3 */
358 | }
359 |
360 | /**
361 | * Address Firefox 3+ setting `line-height` on `input` using `!important` in
362 | * the UA stylesheet.
363 | */
364 |
365 | button,
366 | input {
367 | line-height: normal;
368 | }
369 |
370 | /**
371 | * Address inconsistent `text-transform` inheritance for `button` and `select`.
372 | * All other form control elements do not inherit `text-transform` values.
373 | * Correct `button` style inheritance in Chrome, Safari 5+, and IE 6+.
374 | * Correct `select` style inheritance in Firefox 4+ and Opera.
375 | */
376 |
377 | button,
378 | select {
379 | text-transform: none;
380 | }
381 |
382 | /**
383 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
384 | * and `video` controls.
385 | * 2. Correct inability to style clickable `input` types in iOS.
386 | * 3. Improve usability and consistency of cursor style between image-type
387 | * `input` and others.
388 | */
389 |
390 | button,
391 | html input[type="button"], /* 1 */
392 | input[type="reset"],
393 | input[type="submit"] {
394 | -webkit-appearance: button; /* 2 */
395 | cursor: pointer; /* 3 */
396 | }
397 |
398 | /**
399 | * Re-set default cursor for disabled elements.
400 | */
401 |
402 | button[disabled],
403 | html input[disabled] {
404 | cursor: default;
405 | }
406 |
407 | /**
408 | * 1. Address box sizing set to content-box in IE 8/9.
409 | * 2. Remove excess padding in IE 8/9.
410 | */
411 |
412 | input[type="checkbox"],
413 | input[type="radio"] {
414 | box-sizing: border-box; /* 1 */
415 | padding: 0; /* 2 */
416 | }
417 |
418 | /**
419 | * 1. Address `appearance` set to `searchfield` in Safari 5 and Chrome.
420 | */
421 |
422 | input[type="search"] {
423 | -webkit-appearance: textfield; /* 1 */
424 | -moz-box-sizing: content-box;
425 | box-sizing: content-box;
426 | }
427 |
428 | /**
429 | * Remove inner padding and search cancel button in Safari 5 and Chrome
430 | * on OS X.
431 | */
432 |
433 | input[type="search"]::-webkit-search-cancel-button,
434 | input[type="search"]::-webkit-search-decoration {
435 | -webkit-appearance: none;
436 | }
437 |
438 | /**
439 | * Remove inner padding and border in Firefox 3+.
440 | */
441 |
442 | button::-moz-focus-inner,
443 | input::-moz-focus-inner {
444 | border: 0;
445 | padding: 0;
446 | }
447 |
448 | /**
449 | * 1. Remove default vertical scrollbar in IE 6/7/8/9.
450 | * 2. Improve readability and alignment in all browsers.
451 | */
452 |
453 | textarea {
454 | overflow: auto; /* 1 */
455 | vertical-align: top; /* 2 */
456 | }
457 |
458 | /* ==========================================================================
459 | Tables
460 | ========================================================================== */
461 |
462 | /**
463 | * Remove most spacing between table cells.
464 | */
465 |
466 | table {
467 | border-collapse: collapse;
468 | border-spacing: 0;
469 | }
470 |
--------------------------------------------------------------------------------
/public/css/_var.less:
--------------------------------------------------------------------------------
1 | /**
2 | * Переменные
3 | **/
4 |
5 | @path-images: '../images/';
6 | @path-base64-images: '../images/base64/';
7 |
8 | @font: 'Roboto', sans-serif;
9 | @transitTime: .3s;
10 | @lineHeight: 1.2px;
11 |
12 |
13 | /**
14 | * Цвета
15 | */
16 |
17 | @cWhite: #fff;
18 | @cBlack: #010209;
19 |
20 |
21 | /****
22 | * Миксины, разное
23 | ****/
24 | .size(@width, @height: @width) {
25 | width: @width;
26 | height: @height;
27 | }
28 |
29 | .pseudo() {
30 | content: '';
31 | position: absolute;
32 | }
33 |
34 | .sprite (@left, @top, @width, @height: @width) {
35 | .size(@width, @height);
36 | background: url('@{path-images}sprite.png') (-1 * @left) (-1 * @top) no-repeat;
37 | }
38 |
39 | @sprite-icons-20-top: 0;
40 | .sprite-icon-20 (@num, @width, @height: @width) {
41 | .size(@width, @height);
42 | background: url('@{path-images}sprite.png') (-20px * @num) @sprite-icons-20-top no-repeat;
43 | }
44 |
45 | @sprite-icons-30-top: -30px;
46 | .sprite-icon-30 (@num, @width, @height: @width) {
47 | .size(@width, @height);
48 | background: url('@{path-images}sprite.png') (-30px * @num) @sprite-icons-30-top no-repeat;
49 | }
50 |
51 | .hover-opacity(@opacity: 0.75) {
52 | cursor: pointer;
53 |
54 | &:hover {
55 | opacity: @opacity;
56 | }
57 | }
58 |
59 | .rotate(@degree) {
60 | transform: rotate(@degree);
61 | }
62 |
63 | // helpers
64 | .clearfix() {
65 | &:before,
66 | &:after {
67 | content: "";
68 | display: table;
69 | }
70 |
71 | &:after {
72 | clear: both;
73 | }
74 | }
75 |
76 | .transit(@prop: all, @time: @transitTime, @ease: ease) {
77 | transition: @prop @time @ease;
78 | }
79 |
80 | .text(@fontSize: 16px, @lineHeight: @lineHeight, @fontWeight: 300, @font: @font) {
81 | font: @fontWeight @fontSize/@lineHeight @font;
82 | }
83 |
84 |
--------------------------------------------------------------------------------
/public/css/pages/_include.less:
--------------------------------------------------------------------------------
1 | @import "add-ticket";
2 | @import "ticket-list";
3 | @import "text-page";
4 |
--------------------------------------------------------------------------------
/public/css/pages/add-ticket.less:
--------------------------------------------------------------------------------
1 | @import "../_var";
2 |
3 | .add-ticket-page {
4 | &.wrapper {
5 | justify-content: center;
6 | -ms-flex-pack: center;
7 | align-items: center;
8 | flex-direction: column;
9 | box-sizing: border-box;
10 | padding: 30px 0;
11 | color: #fff;
12 | }
13 |
14 | .login.btn {
15 | transition: all .2s;
16 | display: block;
17 | position: relative;
18 | color: #fff;
19 | font-family: @font;
20 | padding: 10px 20px 10px 40px;
21 | border-radius: 30px;
22 | background-color: rgba(0,0,0,.3);
23 | opacity: .6;
24 |
25 | &:hover {
26 | opacity: 1;
27 | }
28 |
29 | &:before {
30 | .pseudo();
31 | .sprite(40px, 0px, 17px, 16px);
32 | left: 15px;
33 | top: 11px;
34 | }
35 | }
36 |
37 | .sibirix {
38 | left: 30px;
39 | bottom: 26px;
40 | }
41 |
42 | h1 {
43 | font: bold 30px/30px @font;
44 | margin: 6px 0;
45 | }
46 |
47 | h2 {
48 | margin: 0 0 40px;
49 | font: 13px/30px @font;
50 | text-transform: uppercase;
51 | }
52 |
53 | .vacation {
54 | border: 1px solid #fff;
55 | margin: -30px 0 40px;
56 | padding: 20px;
57 | font-size: 12px;
58 | line-height: 16px;
59 | }
60 |
61 | .form {
62 | width: 560px;
63 | margin: 0 0 71px;
64 | padding: 22px 40px;
65 | background: #fff;
66 | color: #000;
67 | position: relative;
68 |
69 | form {
70 | position: relative;
71 | }
72 |
73 | .btn {
74 | transition: all .5s ease;
75 |
76 | }
77 |
78 | .success-text {
79 | transition: all .4s ease;
80 | position: absolute;
81 | font-size: 16px;
82 | width: 100%;
83 | bottom: 200px;
84 | left: 0;
85 | text-align: center;
86 | font-weight: 300;
87 |
88 | opacity: 0;
89 | transform: translate(0, 20px);
90 | }
91 |
92 | form.success {
93 | .success-text {
94 | transition: all .4s ease .4s;
95 | opacity: 1;
96 | transform: translate(0, 0);
97 | }
98 | .row {
99 | transition: all .3s ease;
100 | opacity: 0;
101 | visibility: hidden;
102 | }
103 | .file-label {
104 | transition: all 0s ease;
105 | opacity: 0;
106 | visibility: hidden;
107 | }
108 | .row-submit {
109 | transform: translate(207px,-280px);
110 |
111 | &:before,
112 | &:after {
113 | transition: all .3s ease .3s;
114 | opacity: 1;
115 | }
116 |
117 | &:after {
118 | transform: rotate(40deg);
119 | }
120 | &:before {
121 | transform: rotate(-50deg);
122 | }
123 | }
124 |
125 | .btn {
126 | .size(150px, 150px);
127 | box-shadow: 0 0 5px 2px rgba(56, 150, 255, 0);
128 | //transition: all .5s ease;
129 | padding: 0;
130 | border-radius: 50%;
131 | background: none;
132 | color: #fff;
133 | font-size: 0;
134 | }
135 | }
136 |
137 | .added-file {
138 | transition: all, .2s;
139 | max-width: 210px;
140 | width: auto;
141 | display: inline-block;
142 | text-overflow: ellipsis;
143 | white-space: nowrap;
144 | overflow: hidden;
145 | color: #777;
146 | float: right;
147 | line-height: 24px;
148 | cursor: pointer;
149 | opacity: .6;
150 |
151 | &:hover {
152 | opacity: 1;
153 | }
154 | }
155 |
156 | &.login {
157 | width: 360px;
158 | }
159 |
160 | .row {
161 | .clearfix();
162 | transition: all .3s ease .3s;
163 | margin: 0 0 10px;
164 | }
165 |
166 | .reset-pass-wrap {
167 | a {
168 | text-decoration: none;
169 | color: #3896ff;
170 | font: 400 13px/1 @font;
171 | }
172 |
173 | &.back {
174 | position: absolute;
175 | right: 0;
176 | bottom: 19px;
177 | }
178 | }
179 |
180 | .eula {
181 | span {
182 | display: inline;
183 | }
184 | .error-text {
185 | display: none;
186 | }
187 | &.error {
188 | .error-text {
189 | display: block;
190 | width: 100%;
191 | text-align: right;
192 | }
193 | }
194 | input {
195 | margin-right: 10px;
196 | vertical-align: middle;
197 | }
198 | a {
199 | font: 13px/28px Roboto,sans-serif;
200 | }
201 | }
202 |
203 | .row-submit {
204 | .clearfix();
205 | display: inline-block;
206 | line-height: 1;
207 | position: relative;
208 | transition: all .4s;
209 | margin: 20px 0 8px;
210 |
211 | .btn {
212 | transition: all .7s;
213 | //left: 0;
214 | //top: 0;
215 | }
216 |
217 | &:before,
218 | &:after {
219 | .pseudo();
220 | .size(70px, 1px);
221 | transition: all .3s ease;
222 | opacity: 0;
223 | transform: translate(0, 20px);
224 | background-color: #3896ff;
225 | }
226 |
227 | .file-label {
228 | transition: all .3s ease .5s;
229 | }
230 |
231 | &:after {
232 | width: 45px;
233 | top: 88px;
234 | left: 31px;
235 | transform: rotate(40deg);
236 | }
237 |
238 | &:before {
239 | top: 75px;
240 | left: 58px;
241 | transform: rotate(-50deg);
242 | }
243 | }
244 |
245 | .col {
246 | width: 270px;
247 | float: left;
248 |
249 | + .col {
250 | margin-left: 20px;
251 | }
252 | }
253 |
254 | .file-label {
255 | overflow: visible;
256 | float: right;
257 | text-align: right;
258 |
259 | &:before {
260 | transition: all .2s ease;
261 | }
262 |
263 | span {
264 | transition: all .2s ease;
265 | }
266 | }
267 |
268 | textarea {
269 | height: 120px;
270 | }
271 | }
272 |
273 | .documents {
274 | font-size: 0;
275 | text-align: center;
276 |
277 | &.to-right {
278 | position: absolute;
279 | right: 0;
280 | top: 0;
281 | bottom: 0;
282 | margin: auto;
283 | height: 166px;
284 | left: 0;
285 | width: 200px;
286 | transform: translateX(450px);
287 | .doc {
288 | margin: 0 0 20px;
289 | }
290 | @media (max-width: 1100px) {
291 | position: static;
292 | width: auto;
293 | height: auto;
294 | transform: none;
295 | .doc {
296 | margin: 0 20px 0;
297 | }
298 | }
299 | }
300 |
301 | .doc {
302 | position: relative;
303 | display: inline-block;
304 | width: 193px;
305 | margin-right: 20px;
306 | padding-top: 39px;
307 | vertical-align: top;
308 |
309 | text-decoration: none;
310 | font: 14px/22px @font;
311 | color: #9d9d9d;
312 |
313 | text-align: left;
314 |
315 | &:before {
316 | .pseudo();
317 | .sprite-icon-30(2, 21px, 26px);
318 | top: 0;
319 | }
320 |
321 | &:hover {
322 | color: #fff;
323 | }
324 | }
325 | }
326 | }
327 |
--------------------------------------------------------------------------------
/public/css/pages/text-page.less:
--------------------------------------------------------------------------------
1 | .wrapper.text-page {
2 | justify-content: center;
3 | .text-content {
4 | width: 800px;
5 | background: #fff;
6 | padding: 0 20px;
7 | margin: 20px 0;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/public/css/react-select.less:
--------------------------------------------------------------------------------
1 | /**
2 | * React Select
3 | * ============
4 | * Created by Jed Watson and Joss Mackison for KeystoneJS, http://www.keystonejs.com/
5 | * https://twitter.com/jedwatson https://twitter.com/jossmackison https://twitter.com/keystonejs
6 | * MIT License: https://github.com/keystonejs/react-select
7 | */
8 | .Select {
9 | position: relative;
10 | }
11 | .Select-control {
12 | position: relative;
13 | overflow: hidden;
14 | background-color: #ffffff;
15 | box-sizing: border-box;
16 | color: #333333;
17 | cursor: default;
18 | outline: none;
19 | padding: 8px 52px 8px 10px;
20 | border-bottom: 3px solid #3896ff;
21 | }
22 | .Select-control:hover {
23 | box-shadow: 0 1px 0 rgba(0, 0, 0, 0.06);
24 | }
25 | .is-searchable.is-open > .Select-control {
26 | cursor: text;
27 | }
28 | .is-open > .Select-control {
29 | border-bottom-right-radius: 0;
30 | border-bottom-left-radius: 0;
31 | background: #ffffff;
32 | border-color: #b3b3b3 #cccccc #d9d9d9;
33 | }
34 | .is-open > .Select-control > .Select-arrow {
35 | border-color: transparent transparent #999999;
36 | border-width: 0 5px 5px;
37 | }
38 | .is-searchable.is-focused:not(.is-open) > .Select-control {
39 | cursor: text;
40 | }
41 | .is-focused:not(.is-open) > .Select-control {
42 | }
43 | .Select-placeholder {
44 | color: #999;
45 | font: italic 13px/18px @font;
46 | padding: 8px 52px 8px 10px;
47 | position: absolute;
48 | top: 0;
49 | left: 0;
50 | right: -15px;
51 | max-width: 100%;
52 | overflow: hidden;
53 | text-overflow: ellipsis;
54 | white-space: nowrap;
55 | }
56 | .has-value > .Select-control > .Select-placeholder {
57 | color: #333333;
58 | }
59 | .Select-value {
60 | color: #aaaaaa;
61 | padding: 8px 52px 8px 10px;
62 | position: absolute;
63 | top: 0;
64 | left: 0;
65 | right: -15px;
66 | max-width: 100%;
67 | overflow: hidden;
68 | text-overflow: ellipsis;
69 | white-space: nowrap;
70 | }
71 | .has-value > .Select-control > .Select-value {
72 | color: #333333;
73 | }
74 | .Select-input > input {
75 | cursor: default;
76 | background: none transparent;
77 | box-shadow: none;
78 | height: auto;
79 | border: 0 none;
80 | font-family: inherit;
81 | font-size: inherit;
82 | margin: 0;
83 | padding: 0;
84 | outline: none;
85 | display: inline-block;
86 | -webkit-appearance: none;
87 | }
88 | .is-focused .Select-input > input {
89 | cursor: text;
90 | }
91 | .Select-control:not(.is-searchable) > .Select-input {
92 | outline: none;
93 | }
94 | .Select-loading {
95 | -webkit-animation: Select-animation-spin 400ms infinite linear;
96 | -o-animation: Select-animation-spin 400ms infinite linear;
97 | animation: Select-animation-spin 400ms infinite linear;
98 | width: 16px;
99 | height: 16px;
100 | box-sizing: border-box;
101 | border-radius: 50%;
102 | border: 2px solid #cccccc;
103 | border-right-color: #333333;
104 | display: inline-block;
105 | position: relative;
106 | margin-top: -8px;
107 | position: absolute;
108 | right: 30px;
109 | top: 50%;
110 | }
111 | .has-value > .Select-control > .Select-loading {
112 | right: 46px;
113 | }
114 | .Select-clear {
115 | color: #999999;
116 | cursor: pointer;
117 | display: inline-block;
118 | font-size: 16px;
119 | padding: 6px 10px;
120 | position: absolute;
121 | right: 17px;
122 | top: 0;
123 | }
124 | .Select-clear:hover {
125 | color: #c0392b;
126 | }
127 | .Select-clear > span {
128 | font-size: 1.1em;
129 | }
130 | .Select-arrow-zone {
131 | content: " ";
132 | position: absolute;
133 | right: 0;
134 | top: 0;
135 | bottom: 0;
136 | width: 30px;
137 | cursor: pointer;
138 | }
139 | .Select-arrow {
140 | content: " ";
141 | display: block;
142 | height: 21px;
143 | width: 21px;
144 | background: data-uri('@{path-base64-images}select-plus.png');
145 | margin-top: -ceil(2.5px);
146 | position: absolute;
147 | right: 0;
148 | top: 6px;
149 | cursor: pointer;
150 | }
151 | .Select-menu-outer {
152 | border-bottom-right-radius: 4px;
153 | border-bottom-left-radius: 4px;
154 | background-color: #ffffff;
155 | border: 1px solid #cccccc;
156 | border-top-color: #e6e6e6;
157 | box-shadow: 0 1px 0 rgba(0, 0, 0, 0.06);
158 | box-sizing: border-box;
159 | margin-top: -1px;
160 | max-height: 200px;
161 | position: absolute;
162 | top: 100%;
163 | width: 100%;
164 | z-index: 1000;
165 | -webkit-overflow-scrolling: touch;
166 | }
167 | .Select-menu {
168 | max-height: 198px;
169 | overflow-y: auto;
170 | }
171 | .Select-option {
172 | box-sizing: border-box;
173 | color: #666666;
174 | cursor: pointer;
175 | display: block;
176 | padding: 8px 10px;
177 | }
178 | .Select-option:last-child {
179 | border-bottom-right-radius: 4px;
180 | border-bottom-left-radius: 4px;
181 | }
182 | .Select-option.is-focused {
183 | background-color: #f2f9fc;
184 | color: #333333;
185 | }
186 | .Select-option.is-disabled {
187 | color: #cccccc;
188 | cursor: not-allowed;
189 | }
190 | .Select-noresults,
191 | .Select-search-prompt,
192 | .Select-searching {
193 | box-sizing: border-box;
194 | color: #999999;
195 | cursor: default;
196 | display: block;
197 | padding: 8px 10px;
198 | }
199 | .Select.is-multi .Select-control {
200 | padding: 2px 52px 2px 3px;
201 | }
202 | .Select.is-multi .Select-input {
203 | vertical-align: middle;
204 | border: 1px solid transparent;
205 | margin: 2px;
206 | padding: 3px 0;
207 | }
208 | .Select-item {
209 | background-color: #f2f9fc;
210 | border-radius: 2px;
211 | border: 1px solid #c9e6f2;
212 | color: #0088cc;
213 | display: inline-block;
214 | font-size: 1em;
215 | margin: 2px;
216 | }
217 | .Select-item-icon,
218 | .Select-item-label {
219 | display: inline-block;
220 | vertical-align: middle;
221 | }
222 | .Select-item-label {
223 | cursor: default;
224 | border-bottom-right-radius: 2px;
225 | border-top-right-radius: 2px;
226 | padding: 3px 5px;
227 | }
228 | .Select-item-label .Select-item-label__a {
229 | color: #0088cc;
230 | cursor: pointer;
231 | }
232 | .Select-item-icon {
233 | cursor: pointer;
234 | border-bottom-left-radius: 2px;
235 | border-top-left-radius: 2px;
236 | border-right: 1px solid #c9e6f2;
237 | padding: 2px 5px 4px;
238 | }
239 | .Select-item-icon:hover,
240 | .Select-item-icon:focus {
241 | background-color: #ddeff7;
242 | color: #0077b3;
243 | }
244 | .Select-item-icon:active {
245 | background-color: #c9e6f2;
246 | }
247 | .Select.is-multi.is-disabled .Select-item {
248 | background-color: #f2f2f2;
249 | border: 1px solid #d9d9d9;
250 | color: #888888;
251 | }
252 | .Select.is-multi.is-disabled .Select-item-icon {
253 | cursor: not-allowed;
254 | border-right: 1px solid #d9d9d9;
255 | }
256 | .Select.is-multi.is-disabled .Select-item-icon:hover,
257 | .Select.is-multi.is-disabled .Select-item-icon:focus,
258 | .Select.is-multi.is-disabled .Select-item-icon:active {
259 | background-color: #f2f2f2;
260 | }
261 | @keyframes Select-animation-spin {
262 | to {
263 | transform: rotate(1turn);
264 | }
265 | }
266 | @-webkit-keyframes Select-animation-spin {
267 | to {
268 | -webkit-transform: rotate(1turn);
269 | }
270 | }
271 |
--------------------------------------------------------------------------------
/public/css/style.less:
--------------------------------------------------------------------------------
1 | @import "_var";
2 |
3 | /****
4 | * Внешние либы, ресеты
5 | ****/
6 | @import "_reset";
7 | @import 'react-select';
8 |
9 | /****
10 | * Основные стили
11 | ****/
12 |
13 | @import "_common";
14 | @import "pages/_include";
15 |
16 | /****
17 | * Адаптивный дизайн
18 | ****/
19 |
20 |
21 | @import "_adaptive";
22 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SibirixScrum/HelpDesk/a96382f73c364705f0e935a745bb09425fb163f8/public/favicon.ico
--------------------------------------------------------------------------------
/public/images/base64/arr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SibirixScrum/HelpDesk/a96382f73c364705f0e935a745bb09425fb163f8/public/images/base64/arr.png
--------------------------------------------------------------------------------
/public/images/base64/arrow-up.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SibirixScrum/HelpDesk/a96382f73c364705f0e935a745bb09425fb163f8/public/images/base64/arrow-up.png
--------------------------------------------------------------------------------
/public/images/base64/search-active.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SibirixScrum/HelpDesk/a96382f73c364705f0e935a745bb09425fb163f8/public/images/base64/search-active.png
--------------------------------------------------------------------------------
/public/images/base64/search.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SibirixScrum/HelpDesk/a96382f73c364705f0e935a745bb09425fb163f8/public/images/base64/search.png
--------------------------------------------------------------------------------
/public/images/base64/select-plus.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SibirixScrum/HelpDesk/a96382f73c364705f0e935a745bb09425fb163f8/public/images/base64/select-plus.png
--------------------------------------------------------------------------------
/public/images/bg.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SibirixScrum/HelpDesk/a96382f73c364705f0e935a745bb09425fb163f8/public/images/bg.jpg
--------------------------------------------------------------------------------
/public/images/bg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SibirixScrum/HelpDesk/a96382f73c364705f0e935a745bb09425fb163f8/public/images/bg.png
--------------------------------------------------------------------------------
/public/images/space.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SibirixScrum/HelpDesk/a96382f73c364705f0e935a745bb09425fb163f8/public/images/space.jpg
--------------------------------------------------------------------------------
/public/images/sprite.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SibirixScrum/HelpDesk/a96382f73c364705f0e935a745bb09425fb163f8/public/images/sprite.png
--------------------------------------------------------------------------------
/public/images/sprite.psd:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SibirixScrum/HelpDesk/a96382f73c364705f0e935a745bb09425fb163f8/public/images/sprite.psd
--------------------------------------------------------------------------------
/public/js/actions.js:
--------------------------------------------------------------------------------
1 | const tickets = require('./actions/tickets');
2 | const root = require('./actions/root');
3 |
4 | module.exports = {tickets, root};
5 |
6 |
--------------------------------------------------------------------------------
/public/js/actions/root.js:
--------------------------------------------------------------------------------
1 | const extend = require('extend');
2 |
3 | const ActionTypes = require('../constants/action-types');
4 | const ActionTickets = require('./tickets');
5 | const {translate, getCurLang, translateWithoutCode, i18n} = require('../i18n');
6 | const request = require('superagent');
7 |
8 | function logoutSuccess() {
9 | return dispatch => {
10 | dispatch({
11 | type: ActionTypes.LOGOUT_SUCCESS
12 | });
13 |
14 | dispatch(ActionTickets.resetState());
15 | }
16 | }
17 |
18 | function showModal(data) {
19 | return dispatch => {
20 | dispatch({
21 | type: ActionTypes.SHOW_MODAL,
22 | content: {header: data.header, text: data.text}
23 | });
24 | }
25 | }
26 |
27 | function hideModal(data) {
28 | return dispatch => {
29 | dispatch({
30 | type: ActionTypes.HIDE_MODAL
31 | });
32 | }
33 | }
34 |
35 | function updateCounters(ticket, isNew = false) {
36 | return {
37 | type: ActionTypes.UPDATE_COUNTERS,
38 | isNew,
39 | ticket
40 | };
41 | }
42 |
43 | function loginSuccess(data) {
44 | return dispatch => {
45 | dispatch({
46 | type: ActionTypes.LOGIN_SUCCESS,
47 | user: extend({}, data.user, {token: data.token}),
48 | countTickets: data.countTickets,
49 | isLoading: false
50 | });
51 |
52 | dispatch(ActionTickets.activeProjects(data.countTickets));
53 | }
54 | }
55 |
56 | function changeLang(lng) {
57 | i18n.changeLanguage(lng);
58 |
59 | return dispatch => {
60 | dispatch({
61 | type: ActionTypes.CHANGE_LANG,
62 | lng
63 | });
64 |
65 | request.post('/user/change-lang/')
66 | .set('Accept', 'application/json')
67 | .type('json')
68 | .send({
69 | lng: lng
70 | })
71 | .query({
72 | nocache: Date.now()
73 | })
74 | .end(function(err, resp) {
75 |
76 | });
77 | };
78 | }
79 |
80 | module.exports = {
81 | loginSuccess,
82 | showModal,
83 | hideModal,
84 | logoutSuccess,
85 | updateCounters,
86 | changeLang
87 | };
88 |
--------------------------------------------------------------------------------
/public/js/actions/tickets.js:
--------------------------------------------------------------------------------
1 | const ActionTypes = require('../constants/action-types');
2 |
3 | const request = require('superagent');
4 | const _ = require('underscore');
5 |
6 | function addTicket(ticket) {
7 | return {
8 | type: ActionTypes.ADD_TICKET,
9 | ticket: ticket
10 | };
11 | }
12 |
13 | function updateTicket(ticket) {
14 | return {
15 | type: ActionTypes.UPDATE_TICKET,
16 | ticket: ticket
17 | };
18 | }
19 |
20 | function activeProjects(projects) {
21 | return {
22 | type: ActionTypes.SET_ACTIVE_PROJECTS,
23 | projects: projects
24 | };
25 | }
26 |
27 | function setState(filter) {
28 | return {
29 | type: ActionTypes.SET_STATE,
30 | filter
31 | };
32 | }
33 |
34 | function setFilter(sort, filter) {
35 | return {
36 | type: ActionTypes.SET_FILTER,
37 | sort,
38 | filter
39 | };
40 | }
41 |
42 | function beforeFetchItems(sort, filter) {
43 | return {
44 | type: ActionTypes.START_FETCH_ITEMS,
45 | sort,
46 | filter,
47 | isLoading: true
48 | };
49 | }
50 |
51 | function afterFetchItems(items, clear) {
52 | return {
53 | type: ActionTypes.END_FETCH_ITEMS,
54 | items,
55 | isLoading: false,
56 | clear
57 | };
58 | }
59 |
60 | function tagAdd(projectCode, ticketNumber, tag) {
61 | return dispatch => {
62 | request.post('/ticket/tag-add/')
63 | .set('Accept', 'application/json')
64 | .type('json')
65 | .send({
66 | projectCode: projectCode,
67 | number: ticketNumber,
68 | tag: tag
69 | })
70 | .query({
71 | nocache: Date.now()
72 | })
73 | .end(function(err, resp) {
74 |
75 | });
76 | }
77 | }
78 |
79 | function tagRemove(projectCode, ticketNumber, index) {
80 | return dispatch => {
81 | request.post('/ticket/tag-remove/')
82 | .set('Accept', 'application/json')
83 | .type('json')
84 | .send({
85 | projectCode: projectCode,
86 | number: ticketNumber,
87 | index: index
88 | })
89 | .query({
90 | nocache: Date.now()
91 | })
92 | .end(function(err, resp) {
93 |
94 | });
95 | }
96 | }
97 |
98 | function fetchItems(offset, sortType, filter, clear = true) {
99 | return dispatch => {
100 | dispatch(beforeFetchItems(sortType, filter));
101 |
102 | var query = {
103 | offset,
104 | //filter: JSON.stringify(filter),
105 | sort: sortType,
106 | nocache: Date.now()
107 | };
108 |
109 | Object.keys(filter).map(field => {
110 | query[field] = _.isArray(filter[field]) ? filter[field].join(',') : filter[field];
111 | });
112 |
113 | request.get('/ticket/list/')
114 | .set('Accept', 'application/json')
115 | .query(query)
116 | .end(function(err, resp) {
117 | const answer = JSON.parse(resp.text);
118 | dispatch(afterFetchItems(answer.list, clear))
119 | });
120 | };
121 | }
122 |
123 | function toggleProject(code) {
124 | return {
125 | type: ActionTypes.TOGGLE_PROJECT,
126 | code
127 | };
128 | }
129 |
130 | function setTicketListSort(sort) {
131 | return {
132 | type: ActionTypes.SET_TICKET_LIST_SORT,
133 | sort
134 | };
135 | }
136 |
137 | function setTicketListFilter(filter) {
138 | return {
139 | type: ActionTypes.SET_TICKET_LIST_FILTER,
140 | filter
141 | };
142 | }
143 |
144 | function beforeOpenDetail(ticket) {
145 | return {
146 | type: ActionTypes.START_OPEN_DETAIL,
147 | ticket
148 | }
149 | }
150 |
151 | function afterOpenDetail(ticket) {
152 | return {
153 | type: ActionTypes.END_OPEN_DETAIL,
154 | ticket
155 | }
156 | }
157 |
158 | function closeDetail() {
159 | return {
160 | type: ActionTypes.CLOSE_DETAIL
161 | }
162 | }
163 |
164 | function setDetailTicket(ticket) {
165 | return dispatch => {
166 | dispatch(beforeOpenDetail(ticket));
167 |
168 | request.get(`/ticket/detail/${ticket.project}/${ticket.number}/`)
169 | .set('Accept', 'application/json')
170 | .query({
171 | nocache: Date.now()
172 | })
173 | .end(function(err, resp) {
174 | const answer = JSON.parse(resp.text);
175 |
176 | if (answer.result) {
177 | dispatch(afterOpenDetail(answer.ticket));
178 | }
179 | });
180 | };
181 | }
182 |
183 | function loadMore() {
184 | return {
185 | type: ActionTypes.SET_NEW_PAGE
186 | };
187 | }
188 |
189 | function resetState() {
190 | return {
191 | type: ActionTypes.RESET_TICKETS_STATE
192 | };
193 | }
194 |
195 | module.exports = {
196 | setState,
197 | beforeFetchItems,
198 | afterFetchItems,
199 | beforeOpenDetail,
200 | afterOpenDetail,
201 | fetchItems,
202 | toggleProject,
203 | activeProjects,
204 | setTicketListSort,
205 | setTicketListFilter,
206 | setDetailTicket,
207 | loadMore,
208 | updateTicket,
209 | resetState,
210 | addTicket,
211 | closeDetail,
212 | setFilter,
213 | tagAdd,
214 | tagRemove
215 | };
216 |
--------------------------------------------------------------------------------
/public/js/application.js:
--------------------------------------------------------------------------------
1 | const React = require('react');
2 | const ReactRouter = require('react-router');
3 | const Router = ReactRouter;
4 |
5 | const Root = require('./components/root');
6 | const Tickets = require('./components/tickets');
7 | const Home = require('./components/home');
8 |
9 | const DefaultRoute = Router.DefaultRoute;
10 | const Route = Router.Route;
11 | const NotFoundRoute = Router.NotFoundRoute;
12 |
13 | const {Provider} = require('react-redux');
14 |
15 | const i18n = require('./i18n');
16 |
17 |
18 | const app = document.getElementById('app');
19 |
20 | const NotFound = React.createClass({
21 | mixins: [Router.Navigation],
22 | componentDidMount() {
23 | this.transitionTo('home');
24 | },
25 | render() {
26 | return false;
27 | }
28 | });
29 |
30 | const routes = (
31 |
{translateWithoutCode(project.name)}
111 |
112 | {translateWithoutCode(project.title)}
113 |
114 |
43 |
30 |
34 | )
35 | }
36 | });
37 |
38 | module.exports = Folders;
39 |
40 |
--------------------------------------------------------------------------------
/public/js/components/tickets/sidebar/folders/index.js:
--------------------------------------------------------------------------------
1 | const React = require('react');
2 |
3 | const Folder = React.createClass({
4 | handleClick() {
5 | this.props.onStateClick(this.props.folder.code);
6 | },
7 |
8 | render() {
9 | let active = this.props.activeFolder == this.props.folder.code ? 'active' : '';
10 | return (
11 |
23 | {allowedProjectList.map((project, key) =>
28 |
Login: {{login}}
Password: {{pass}}
\n Click on the link to change the password."
21 | },
22 |
23 | "resetPassword": {
24 | "subject": "Helpdesk you password was changed",
25 | "text": "Hello! \n You password was changed. New password: — {{pass}}"
26 | }
27 | },
28 | "mail": {
29 | "sendMail": {
30 | "additionalText": "The message contained attached files. You may look through them in your Helpdesk account",
31 | "text": "{{text}} \n\n------------\n {{sing}} \n {{footerText}}",
32 | "text_html": "{{text}}
\n
\n------------
\n {{sing}}
\n {{footerText}}"
33 | },
34 | "processMailObject": {
35 | "subject": "New ticket from the mail"
36 | },
37 |
38 | "endText": "Best regards, \n {{projectName}} Team",
39 | "endText_html": "Best regards,
{{projectName}} Team"
40 | },
41 | "ticket": {
42 | "addMessageFromMail": {
43 | "attachmentsText": "
Pay attention! Some of the files did not pass data validation, so they are not attached to the message."
44 | },
45 | "sendMailOnTicketAdd": {
46 | "subjectForResponsible": "Helpdesk new ticket: \"{{title}}\" [#{{ticketNumber}}]",
47 | "subjectForAuthor": "Helpdesk: ticket \"{{title}}\" [#{{ticketNumber}}]",
48 | "textForResponsible": "From: {{authorName}}
Subject: {{title}}
{{text}}",
49 | "textForAuthor": "Hello! You have created a new ticket in {{projectName}} Helpdesk.
You may check the status of the ticket using your Helpdesk account or answering this message (please, save the ticket number in the subject)."
50 | },
51 | "sendMailOnTicketClose": {
52 | "subject": "Helpdesk ticket \"{{title}}\" is closed: [#{{ticketNumber}}]",
53 | "textForResponsible": "Ticket \"{{title}}\" is closed.",
54 | "textForAuthor": "Hello! Your ticket in {{projectName}} Helpdesk is closed.
You may re-open the ticket using your Helpdesk account."
55 | },
56 | "sendMailOnTicketOpen": {
57 | "subject": "Helpdesk ticket \"{{title}}\" is re-opened: [#{{ticketNumber}}]",
58 | "textForResponsible": "Ticket \"{{title}}\" is re-opened.",
59 | "textForAuthor": "Hello! Your ticket in {{projectName}} Helpdesk is re-opened.
You may re-open the ticket using your Helpdesk account or answering this message (please, save the ticket number in the subject)."
60 | },
61 | "sendMailOnTicketAddUserCreate": {
62 | "subjectForResponsible": "Helpdesk new ticket: \"{{title}}\" [#{{ticketNumber}}]",
63 | "subjectForAuthor": "Helpdesk: ticket \"{{title}}\" [#{{ticketNumber}}]",
64 | "textForResponsible": "
{{text}}",
65 | "textForAuthor": "Hello! You have created a new ticket in {{projectName}} Helpdesk.
You may re-open the ticket using your Helpdesk account, or answering this message (please, save the ticket number in the subject).
Login: {{login}}
Password: {{pass}}
"
66 | },
67 | "sendMailOnMessageAdd": {
68 | "subject": "Helpdesk ticket \"{{title}}\" [#{{ticketNumber}}]: new message",
69 | "textForResponsible": "From: {{authorName}}
Subject: {{title}}
New message in the ticket:
{{text}}",
70 | "textForAuthor": "Subject: {{title}}
New message in the ticket:
{{text}}"
71 | }
72 | },
73 | "front": {
74 | "modal": {
75 | "header": "Error",
76 | "text" : "Whoops! something went wrong",
77 | "close": "Close"
78 | },
79 |
80 | "topLink": {
81 | "isAuth": "Ticket list",
82 | "isLogin": "Create a ticket",
83 | "notAuth": "Sign in"
84 | },
85 |
86 | "errors": {
87 | "email": {
88 | "empty": "Please, enter correct email"
89 | },
90 | "file": {
91 | "length": "Please, attach not more than {{count}} $t(projects.example.front.errors.file.lengthFile, {'count': {{count}}}).",
92 | "lengthFile": "file",
93 | "lengthFile_plural": "files",
94 | "size": "Please, attach files not larger than {{count}} Mb .",
95 | "extension": "Sorry, you can attach only these file extensions: {{extension}}"
96 | }
97 | },
98 |
99 | "login": {
100 | "passChanged": {
101 | "header": "Password Reset",
102 | "text": "You password is changed. Now, you may sign in using it. Check the new password in your email box."
103 | },
104 |
105 | "reset": {
106 | "header": "Password Reset",
107 | "text": "Password reset instruction is sent at your email!"
108 | },
109 |
110 | "form": {
111 | "email": {
112 | "title": "Email",
113 | "errors": {
114 | "wrong": "User is not found",
115 | "empty": "$t(projects.example.front.errors.email.empty)"
116 | }
117 | },
118 |
119 | "password": {
120 | "title": "Password",
121 | "errors": {
122 | "wrong": "Password is incorrect",
123 | "empty": "Enter your password"
124 | }
125 | },
126 |
127 | "reset": {
128 | "title": "Reset password"
129 | },
130 |
131 | "return": {
132 | "title": "Back to sign in form"
133 | },
134 |
135 | "submit": {
136 | "title": "Send"
137 | },
138 |
139 | "submitReset": {
140 | "title": "Reset"
141 | }
142 | }
143 | },
144 |
145 | "addTicket": {
146 | "form": {
147 | "name": {
148 | "title": "Your name",
149 | "errors": {
150 | "empty": "Please, enter your name"
151 | }
152 | },
153 |
154 | "email": {
155 | "title": "Email",
156 | "errors": {
157 | "empty": "$t(projects.example.front.errors.email.empty)"
158 | }
159 | },
160 |
161 | "title": {
162 | "title": "Subject",
163 | "errors": {
164 | "empty": "Please, enter the subject"
165 | }
166 | },
167 |
168 | "text": {
169 | "title": "Your message",
170 | "errors": {
171 | "empty": "Please, enter your message"
172 | }
173 | },
174 |
175 | "agreement": {
176 | "text": "I agree to the Terms of processing my personal data",
177 | "errors": {
178 | "empty": "Please, confirm your agreement"
179 | }
180 | },
181 |
182 | "file": {
183 | "title": "Attach a file"
184 | },
185 |
186 | "submit": {
187 | "title": "Send"
188 | }
189 | },
190 |
191 | "popup": {
192 | "isUser": "Thank you for your request.
We will contact you as soon as possible!",
193 | "notAuthUser": "Congratulations! We are glad you are with us.
To check the status of the ticket use your Helpdesk account.",
194 | "newUser": "We have sent you a new password via email,
use it to check the status of the ticket."
195 |
196 | }
197 | },
198 |
199 | "tickets": {
200 | "btnAdd": {
201 | "title": "Create a ticket"
202 | },
203 | "status": {
204 | "open": "Opened",
205 | "close": "Closed"
206 | },
207 |
208 | "do": {
209 | "open": "Open the ticket",
210 | "close": "Close the ticket",
211 | "addTag": "Add a tag"
212 | },
213 |
214 | "header": {
215 | "filter": {
216 | "tag": {
217 | "title": "Search by tag"
218 | },
219 |
220 | "email": {
221 | "title": "Search by email"
222 | }
223 | }
224 | },
225 |
226 | "sortTypes": {
227 | "dateAsc": {
228 | "name": "By last response",
229 | "dir": "(new at the top)"
230 | },
231 |
232 | "dateDesc": {
233 | "name": "$t(projects.example.front.tickets.sortTypes.dateAsc.name)",
234 | "dir": "(old at the top)"
235 | },
236 |
237 | "openedAsc": {
238 | "name": "By status",
239 | "dir": "(closed at the top)"
240 | },
241 |
242 | "openedDesc": {
243 | "name": "$t(projects.example.front.tickets.sortTypes.openedAsc.name)",
244 | "dir": "(opened at the top)"
245 | }
246 | }
247 | },
248 |
249 | "messages": {
250 | "file": {
251 | "title": "Attached files:"
252 | },
253 |
254 | "form": {
255 | "answer": {
256 | "errors": {
257 | "empty": "Please, enter your message"
258 | }
259 | },
260 |
261 | "submit": {
262 | "title": "Send",
263 | "sub": "or press Ctrl+Enter"
264 | },
265 |
266 | "file": {
267 | "title": "Attach a file"
268 | }
269 | }
270 | },
271 |
272 | "sidebar": {
273 | "group": {
274 | "title": "Groups"
275 | },
276 |
277 | "folder": {
278 | "allMessage": "All requests",
279 | "openMessage": "Opened",
280 | "closeMessage": "Closed"
281 | }
282 | },
283 |
284 | "copyright": " — scrum studio"
285 | },
286 |
287 | "projects": {
288 | "example": {
289 | "name": "exampleApp",
290 | "title": "exampleApp"
291 | }
292 | },
293 | "agreement": "agreement-en"
294 | }
295 |
--------------------------------------------------------------------------------
/public/locales/ru.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Helpdesk",
3 | "title": "Helpdesk",
4 | "formTitle": "ЦЕНТР ПОДДЕРЖКИ",
5 | "files": {
6 | "manual1": "Руководство пользователя"
7 | },
8 | "user": {
9 | "createResponsible": {
10 | "name": "Техническая поддержка",
11 | "subject": "Helpdesk — новый пароль администратора",
12 | "text": "
Логин: {{login}}
Пароль: {{pass}}
\n Чтобы поменять пароль, перейдите по этой ссылке."
19 | },
20 |
21 | "resetPassword": {
22 | "subject": "Helpdesk ваш пароль был изменен",
23 | "text": "Добрый день. \n Ваш пароль был успешно изменен. Новый пароль — {{pass}}"
24 | }
25 | },
26 | "mail": {
27 | "sendMail": {
28 | "additionalText": "В ОТВЕТЕ БЫЛИ ПРИКРЕПЛЕНЫ ФАЙЛЫ, ПРОСМОТРЕТЬ ИХ ВЫ СМОЖЕТЕ НА ПОРТАЛЕ HELPDESK",
29 | "text": "{{text}} \n\n------------\n {{sing}} \n {{footerText}}",
30 | "text_html": "{{text}}
\n
\n------------
\n {{sing}}
\n {{footerText}}"
31 | },
32 |
33 | "processMailObject": {
34 | "subject": "Новый тикет из почты"
35 | },
36 |
37 | "endText": "С уважением, \n команда {{projectName}}",
38 | "endText_html": "С уважением,
команда {{projectName}}"
39 | },
40 | "ticket": {
41 | "addMessageFromMail": {
42 | "attachmentsText": "
Внимание! Некоторые файлы не прошли валидацию и не будут отображены в сообщении."
43 | },
44 |
45 | "sendMailOnTicketAdd": {
46 | "subjectForResponsible": "Helpdesk создан новый тикет: \"{{title}}\" [#{{ticketNumber}}]",
47 | "subjectForAuthor": "Helpdesk: тикет \"{{title}}\" [#{{ticketNumber}}]",
48 | "textForResponsible": "От кого: {{authorName}}
Тема обращения: {{title}}
{{text}}",
49 | "textForAuthor": "Добрый день. Вы подавали обращение в систему поддержки проекта {{projectName}}.
Вы можете отслеживать статус вашего обращения из личного кабинета, либо общаясь в этой цепочке писем (пожалуйста, не удаляйте номер тикета из темы письма при переписке)."
50 | },
51 |
52 | "sendMailOnTicketClose": {
53 | "subject": "Helpdesk тикет \"{{title}}\" закрыт: [#{{ticketNumber}}]",
54 | "textForResponsible": "Тикет \"{{title}}\" закрыт.",
55 | "textForAuthor": "Добрый день. Ваше обращение в систему поддержки проекта {{projectName}} было закрыто.
Вы можете переоткрыть ваше обращение из личного кабинета."
56 | },
57 |
58 | "sendMailOnTicketOpen": {
59 | "subject": "Helpdesk тикет \"{{title}}\" переоткрыт: [#{{ticketNumber}}]",
60 | "textForResponsible": "Тикет \"{{title}}\" переоткрыт.",
61 | "textForAuthor": "Добрый день. Ваше обращение в систему поддержки проекта {{projectName}} было переоткрыто.
Вы можете переоткрыть ваше обращение из личного кабинета, либо общаясь в этой цепочке писем (пожалуйста, не удаляйте номер тикета из темы письма при переписке)."
62 | },
63 |
64 | "sendMailOnTicketAddUserCreate": {
65 | "subjectForResponsible": "Helpdesk создан новый тикет: \"{{title}}\" [#{{ticketNumber}}]",
66 | "subjectForAuthor": "Helpdesk: тикет \"{{title}}\" [#{{ticketNumber}}]",
67 | "textForResponsible": "
{{text}}",
68 | "textForAuthor": "Добрый день. Вы подавали обращение в систему поддержки проекта {{projectName}}
Вы можете переоткрыть ваше обращение из личного кабинета, либо общаясь в этой цепочке писем (пожалуйста, не удаляйте номер тикета из темы письма при переписке).
Логин для доступа: {{login}}
Пароль: {{pass}}
"
69 | },
70 |
71 | "sendMailOnMessageAdd": {
72 | "subject": "Helpdesk тикет \"{{title}}\" [#{{ticketNumber}}]: новое сообщение",
73 | "textForResponsible": "От кого: {{authorName}}
Тема обращения: {{title}}
Новое сообщение в тикете:
{{text}}",
74 | "textForAuthor": "Тема обращения: {{title}}
Новое сообщение в тикете:
{{text}}"
75 | }
76 | },
77 | "front": {
78 | "modal": {
79 | "header": "Ошибка",
80 | "text" : "Что-то пошло не так!",
81 | "close": "Закрыть"
82 | },
83 |
84 | "topLink": {
85 | "isAuth": "Список тикетов",
86 | "isLogin": "Добавить тикет",
87 | "notAuth": "Войти"
88 | },
89 |
90 | "errors": {
91 | "email": {
92 | "empty": "Необходимо указать валидный Email"
93 | },
94 | "file": {
95 | "length": "Пожалуйста, не более {{count}} $t(projects.example.front.errors.file.lengthFile, {'count': {{count}}}).",
96 | "lengthFile_0": "файл",
97 | "lengthFile_1": "файла",
98 | "lengthFile_2": "файлов",
99 | "size": "Пожалуйста, файлы не более {{count}} МБ.",
100 | "extension": "Извините, но я понимаю только следующие расширения файлов: {{extension}}"
101 | }
102 | },
103 |
104 | "login": {
105 | "passChanged": {
106 | "header": "Восстановление пароля",
107 | "text": "Ваш пароль был успешно изменен и выслан вам на почту! Теперь вы можете авторизоваться, используя пароль из письма."
108 | },
109 |
110 | "reset": {
111 | "header": "Восстановление пароля",
112 | "text": "Инструкция по восстановлению пароля отправлена вам на почту!"
113 | },
114 |
115 | "form": {
116 | "email": {
117 | "title": "Email",
118 | "errors": {
119 | "wrong": "Пользователь не найден",
120 | "empty": "$t(projects.example.front.errors.email.empty)"
121 | }
122 | },
123 |
124 | "password": {
125 | "title": "Пароль",
126 | "errors": {
127 | "wrong": "Неверный пароль",
128 | "empty": "Необходимо ввести пароль"
129 | }
130 | },
131 |
132 | "reset": {
133 | "title": "Восстановить пароль"
134 | },
135 |
136 | "return": {
137 | "title": "Назад к форме входа"
138 | },
139 |
140 | "submit": {
141 | "title": "Отправить"
142 | },
143 |
144 | "submitReset": {
145 | "title": "Восстановить"
146 | }
147 | }
148 | },
149 |
150 | "addTicket": {
151 | "form": {
152 | "name": {
153 | "title": "Как к вам обратиться?",
154 | "errors": {
155 | "empty": "Необходимо указать имя"
156 | }
157 | },
158 |
159 | "email": {
160 | "title": "Email",
161 | "errors": {
162 | "empty": "$t(projects.example.front.errors.email.empty)"
163 | }
164 | },
165 |
166 | "title": {
167 | "title": "Тема обращения",
168 | "errors": {
169 | "empty": "Необходимо указать тему"
170 | }
171 | },
172 |
173 | "text": {
174 | "title": "Описание проблемы",
175 | "errors": {
176 | "empty": "Необходимо описать проблему"
177 | }
178 | },
179 |
180 | "agreement": {
181 | "text": "Я согласен на обработку персональных данных",
182 | "errors": {
183 | "empty": "Необходимо дать свое согласие на обработку персональных данных"
184 | }
185 | },
186 |
187 | "file": {
188 | "title": "Прикрепить файл"
189 | },
190 |
191 | "submit": {
192 | "title": "Отправить"
193 | }
194 | },
195 |
196 | "popup": {
197 | "isUser": "Спасибо за ваше обращение.
Мы уже его получили и работаем!",
198 | "notAuthUser": "О! Вы уже с нами.
Для того, чтобы отслеживать тикеты — войдите в личный кабинет.",
199 | "newUser": "Мы создали вам пароль и отправили его вам на почту,
чтобы вы могли отcлеживать ваш тикет."
200 |
201 | }
202 | },
203 |
204 | "tickets": {
205 | "btnAdd": {
206 | "title": "Добавить тикет"
207 | },
208 | "status": {
209 | "open": "Открыт",
210 | "close": "Закрыт"
211 | },
212 |
213 | "do": {
214 | "open": "Открыть тикет",
215 | "close": "Закрыть тикет",
216 | "addTag": "Добавить тег"
217 | },
218 |
219 | "header": {
220 | "filter": {
221 | "tag": {
222 | "title": "Поиск по тегу"
223 | },
224 |
225 | "email": {
226 | "title": "Поиск по email"
227 | }
228 | }
229 | },
230 |
231 | "sortTypes": {
232 | "dateAsc": {
233 | "name": "По последнему ответу",
234 | "dir": "(новые в начале)"
235 | },
236 |
237 | "dateDesc": {
238 | "name": "$t(projects.example.front.tickets.sortTypes.dateAsc.name)",
239 | "dir": "(старые в начале)"
240 | },
241 |
242 | "openedAsc": {
243 | "name": "Статусу",
244 | "dir": "(закрытые в начале)"
245 | },
246 |
247 | "openedDesc": {
248 | "name": "$t(projects.example.front.tickets.sortTypes.openedAsc.name)",
249 | "dir": "(открытые в начале)"
250 | }
251 | }
252 | },
253 |
254 | "messages": {
255 | "file": {
256 | "title": "Прикрепленные файлы:"
257 | },
258 |
259 | "form": {
260 | "answer": {
261 | "errors": {
262 | "empty": "Необходимо написать ответ"
263 | }
264 | },
265 |
266 | "submit": {
267 | "title": "Отправить",
268 | "sub": "или нажмите Ctrl+Enter"
269 | },
270 |
271 | "file": {
272 | "title": "Прикрепить файл"
273 | }
274 | }
275 | },
276 |
277 | "sidebar": {
278 | "group": {
279 | "title": "Показывать группы"
280 | },
281 |
282 | "folder": {
283 | "allMessage": "Все обращения",
284 | "openMessage": "Открытые",
285 | "closeMessage": "Закрытые"
286 | }
287 | },
288 |
289 | "copyright": " — разработка сайта"
290 | },
291 |
292 | "projects": {
293 | "example": {
294 | "name": "exampleApp",
295 | "title": "exampleApp"
296 | }
297 | },
298 | "agreement": "agreement"
299 | }
--------------------------------------------------------------------------------
/public/pictures/logo-big.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SibirixScrum/HelpDesk/a96382f73c364705f0e935a745bb09425fb163f8/public/pictures/logo-big.jpg
--------------------------------------------------------------------------------
/public/pictures/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SibirixScrum/HelpDesk/a96382f73c364705f0e935a745bb09425fb163f8/public/pictures/logo.png
--------------------------------------------------------------------------------
/public/tickets/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 |
3 | !.gitignore
--------------------------------------------------------------------------------
/services/config.js:
--------------------------------------------------------------------------------
1 |
2 | class Config {
3 | constructor() {
4 | this.config = global.config;
5 | this.projectsList = this.config.projects;
6 | }
7 |
8 | /**
9 | * Валидация конфига
10 | * @returns {boolean}
11 | */
12 | validate() {
13 | if (!this.projectsList || !this.projectsList.length) {
14 | throw "No projects in config";
15 | }
16 |
17 | let responsibles = {};
18 | const colorLength = this.config.projectColors.length;
19 |
20 | projectsList.forEach((current, index) => {
21 | current.color = '#' + this.config.projectColors[index % colorLength];
22 |
23 | if (!current.code || !current.name || !current.domain || !current.responsible) {
24 | throw "Projects config are invalid! Required fields: code, name, domain, responsible.";
25 | }
26 | });
27 |
28 | return true;
29 | }
30 | }
--------------------------------------------------------------------------------
/services/i18n.js:
--------------------------------------------------------------------------------
1 | const locale = require('../config/locale');
2 | const userModel = require('../models/user');
3 |
4 | /**
5 | * Internationalization
6 | */
7 | let i18nObj = {};
8 |
9 | class i18n {
10 | constructor() {
11 | this.language = locale.defaultLanguage;
12 | this.translator = global.defaultTranslator;
13 | this.i18n = {};
14 | }
15 |
16 | setConfig(params) {
17 | this.language = params.language;
18 | this.i18n = params.i18n;
19 | this.translator = this.getFixedT(params.language);
20 | }
21 |
22 | getTranslatorForUser(user) {
23 | return this.getFixedT(user.lng);
24 | }
25 |
26 | getTranslatorForEmail(email) {
27 | let userTranslator = this.translator;
28 |
29 | userModel.model.find({email: email}, (err, data) => {
30 | if (data && data.length) {
31 | userTranslator = this.getTranslatorForUser(data[0]);
32 | }
33 | });
34 |
35 | return userTranslator;
36 | }
37 |
38 | getFixedT(lng) {
39 | return this.i18n.getFixedT(lng);
40 | }
41 |
42 |
43 | /**
44 | *
45 | * @param projects
46 | * @returns {*}
47 | */
48 | translateProjects(projects) {
49 | return projects;
50 | }
51 | }
52 |
53 | exports.i18n = i18n;
54 |
--------------------------------------------------------------------------------
/services/template-string.js:
--------------------------------------------------------------------------------
1 | let projectModel = require('../models/project');
2 |
3 | let currentProjectCode = 'default';
4 |
5 | /**
6 | * Получение стартовой строки для проекта
7 | * @returns {string}
8 | */
9 | exports.getStartCode = () => `projects.${currentProjectCode}`;
10 | /**
11 | * Получение строки заголовка для перевода
12 | * @returns {string}
13 | */
14 | exports.getTitleCode = () => `${this.getStartCode()}.title`;
15 |
16 | /**
17 | * Устанавливаем код текущего проекта
18 | * @param projectCode
19 | * @returns {string}
20 | */
21 | exports.setCurrentProjectCode = (projectCode) => currentProjectCode = projectCode.toLowerCase();
22 |
23 | /**
24 | * Устанавливаем код текущего проекта по домену
25 | * @param domain
26 | */
27 | exports.setCurrentProjectByDomain = (domain) => {
28 | const project = projectModel.getProjectByDomain(domain);
29 |
30 | if (project.code !== void 0) {
31 | this.setCurrentProjectCode(project.code);
32 | }
33 | };
34 |
--------------------------------------------------------------------------------
/temp/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 |
3 | !.gitignore
--------------------------------------------------------------------------------
/views/_layout/footer.ejs:
--------------------------------------------------------------------------------
1 |
2 |
17 |
18 |
19 |
20 |
21 |
22 |