From 6cc3d355268f5ace8806611bb87763f56e9f427c Mon Sep 17 00:00:00 2001 From: Asaf Azulay Date: Sat, 15 Apr 2017 17:39:16 +0300 Subject: [PATCH] Asi 15 04 bug fixes (#304) * last commit * latest check * new db migrations and updated passport * login with drupal support * add the rest, and lint * GUI changes * not sure how does res reach here * that cache again * cleanup * remove code duplication * fix lint * adding error handling * pass the error handling on * fixed constants.js * tmp disable travis cache for node_modules * Update passport.js * update to latest * adding test special case * no yoda notation for me T_T * Update main_routes.test.js * dddd * update test-user and raise timeout * update test-user and omit some test logic to adjust to new bizlogic of login * changed the test user no need for mock anymore * remove underscore * updated imported csv to new members_camp * commit changes * d * fixed the validated sign * some fixes, adding the EVENT_ID * removed the campDetails from all places. fixed one insert and update. update on new camp the event_id * fix for eslint * fixes * fixed for eslint * local commit * added enabled status * refine * Merge branch 'master' of https://github.com/Midburn/Spark * camps route fix * Revert "camps route fix" This reverts commit d926484a77b981f08ec68501acf61992c1621d05. * basic members implementation * bug fix * add member form * removed unnecessary function call * fixed several small issues, with getUserCamps functions, moving the functionality from API to the User model. * fixed several small issues, with getUserCamps functions, moving the functionality from API to the User model. * manually loaded api_camps_route * test * fix * sdfsadf * change activity time to multi select * lilach location fixes * improve contact person dropdown display * facebook link size fix * data validation * lint * Changed the API commands for join & join_deliver to work with the model commands. * fix lint issues * added option to remove approve new members * lint * fixed some bugs with getUserCamps and fix security issues. * ui fixes * commit passport + approve * ddd * changes * join camp is according to lang * FB link doesn't get too long * translation fixes * added approve join request btn * added email templates * comit local changes * changes of field names for members * typo fix * updated the join camp flow, and tested * lint shit * lint shit * fixes the join system, and several bug fix * lint shit * finalized the join flow several bug was removed. still having angular issue, need to be found. * removed junk * lint * fixes issues before production, also import bugs. * show contact information good * d * changes for lint * small issues * fixed the cancel user request fixed some small security issues with users removed automatic fetch from camps_v2 added language string for all status code. * lint shit * Introduced the camp __prototype to use for other camp types. fixed the camp_location_area * fixing all hebrew titles fixed who am i introduced the camp_type schema * lint shit * changes * fixes split issue * template api_gate * fixed the members add, for admin show all camps * lint * fixed mail delivery to join request * lint * refactoring edit + new, to fix foreign key, and adding fields to edit only for admin * fixed add new camp, and adding default member. todo: after success update, forward to edit camp check if camp_name hebrew + english are not empty change selection of camp manager to be select2 input * lint * lint * lint * lint fixes * lint * latest fixes * Fixes: 1. Date format is changed to suit mysql format. 2. Now we receiving ticket information 3. Bug fix of join camp 4. spark link to support page fixed 5. added birth_date, and showing camp info on whoami only if approved * lint * trying to fix the tests * travis * Update admin_routes.test.js * ffff * remved problem tests * fixed a lot of GUI stuff: 1. Dispaly of camp desc will show also linking URL. 2. Display of camp desc will show in the formatted text. 3. Updated translation strings, where was default 4. on members list, changed buttons with description instead of just icons. * eslint * added camp_manager, camp information to email * fixed NPO form, to include english * Issue #278 Issue #277 * Add 1st sanity test * Rename npm target * Add pending test for 'forgot password' page * Fixed MIDBURN2017 Tickets type on constants * refactor emailValidate function to the common * Fixing of all emails, with the info inside. * API Gate route fixes * lint * fixing small bugs with gui --- knexfile.js | 49 ++++++++-- package.json | 12 ++- public/scripts/camps.js | 26 ++--- public/scripts/controllers/camp_edit.js | 20 +++- routes/api_camps_routes.js | 20 +++- routes/api_gate_routes.js | 121 ++++++++++++++++++------ routes/camps_routes.js | 49 ++++------ test/e2e/sanity.js | 66 +++++++++++++ views/pages/camps/edit.jade | 20 ++-- views/pages/login.jade | 2 +- views/pages/reset_password.jade | 2 +- 11 files changed, 285 insertions(+), 102 deletions(-) create mode 100644 test/e2e/sanity.js diff --git a/knexfile.js b/knexfile.js index 0a2137f8d..35e9b4890 100644 --- a/knexfile.js +++ b/knexfile.js @@ -1,9 +1,44 @@ -var config = require('config'); -var dbConfig = config.get('database'); +// Update with your config settings. module.exports = { - client: dbConfig.client, - connection: dbConfig, - debug: dbConfig.debug, - useNullAsDefault: true -}; \ No newline at end of file + + development: { + client: 'sqlite3', + connection: { + filename: './dev.sqlite3' + } + }, + + staging: { + client: 'postgresql', + connection: { + database: 'my_db', + user: 'username', + password: 'password' + }, + pool: { + min: 2, + max: 10 + }, + migrations: { + tableName: 'knex_migrations' + } + }, + + production: { + client: 'postgresql', + connection: { + database: 'my_db', + user: 'username', + password: 'password' + }, + pool: { + min: 2, + max: 10 + }, + migrations: { + tableName: 'knex_migrations' + } + } + +}; diff --git a/package.json b/package.json index 2a45fad49..a974d9bb9 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,13 @@ "postinstall": "bower install", "start": "node server.js", "pretest": "npm run lint", - "test": "cross-env SPARK_DB_FILENAME=test.sqlite3 npm run testcore", - "testcore": "rimraf test.sqlite3 && knex migrate:latest && mocha \"tests/**/*test.js\" ", + "test": "npm run unit && npm run e2e", + "unit": "cross-env SPARK_DB_FILENAME=test.sqlite3 npm run unittestcore", + "testclean": "rimraf test.sqlite3 && knex migrate:latest", + "preunittestcore": "npm run testclean", + "unittestcore": "mocha \"routes/*test.js\" ", + "pree2e": "npm run testclean", + "e2e": "mocha test/e2e -u bdd -R spec", "//devops": "Generic deployment / devops commands, used from .travis.yml, see /docs/development/releases-and-deployment.md", "//log": "send a generic log notification to slack", "log": "curl -X POST -g $npm_config_webhook --data-urlencode 'payload={\"channel\": \"'\"#${npm_config_channel:-sparksystem-log}\"'\", \"username\": \"'\"${npm_config_username:-bot}\"'\", \"text\": \"'\"${npm_config_text}\"'\", \"icon_emoji\": \"'\"${npm_config_emoji:-ghost}\"'\"}'", @@ -99,6 +104,7 @@ "mocha": "^3.2.0", "nock": "^9.0.13", "rimraf": "2.6.1", + "should": "^11.2.1", "supertest": "^2.0.1" } -} +} \ No newline at end of file diff --git a/public/scripts/camps.js b/public/scripts/camps.js index ae6916ae0..edd8ad437 100644 --- a/public/scripts/camps.js +++ b/public/scripts/camps.js @@ -104,19 +104,19 @@ $(function () { /** * Component: View camp details */ -function _fetchCampContactPersonDetails() { - $.get('/camps_contact_person/' + contact_person_id, function (res) { - $('span.contact_person_name').text([res.user.first_name, res.user.last_name].join(' ')); - $('span.contact_person_phone').text(res.user.cell_phone); - $('span.contact_person_email').text(res.user.email); - }); -} -if ($('.camps').hasClass('camp_details')) { - var contact_person_id = $('.contact-person').attr('data-camp-contact-person-id'); - if (contact_person_id !== "null") { - _fetchCampContactPersonDetails(); - } -} +// function _fetchCampContactPersonDetails() { +// $.get('/camps_contact_person/' + contact_person_id, function (res) { +// $('span.contact_person_name').text([res.user.first_name, res.user.last_name].join(' ')); +// $('span.contact_person_phone').text(res.user.cell_phone); +// $('span.contact_person_email').text(res.user.email); +// }); +// } +// if ($('.camps').hasClass('camp_details')) { +// var contact_person_id = $('.contact-person').attr('data-camp-contact-person-id'); +// if (contact_person_id !== "null") { +// _fetchCampContactPersonDetails(); +// } +// } function extractCampData() { var activity_time = fetchAllCheckboxValues('camp_activity_time'); diff --git a/public/scripts/controllers/camp_edit.js b/public/scripts/controllers/camp_edit.js index a42f85801..2b0dbf2dc 100644 --- a/public/scripts/controllers/camp_edit.js +++ b/public/scripts/controllers/camp_edit.js @@ -96,12 +96,26 @@ app.controller("campEditController", ($scope, $http, $filter) => { lang = 'he'; } if (lang === "he") { - $scope.status_options = ['מחנה פתוח למצטרפים חדשים', 'סגור למצטרפים חדשים']; - $scope.noise_level_options = ['שקט', 'בינוני', 'רועש', 'מאוד רועש']; + $scope.status_options = [ + {id:'open',value:'מחנה פתוח למצטרפים חדשים'}, + {id:'closed',value:'סגור למצטרפים חדשים'}]; + $scope.noise_level_options = [ + {id:'quiet',value:'שקט'}, + {id:'medium',value:'בינוני'}, + {id:'noisy',value:'רועש'}, + {id:'very noisy',value:'מאוד רועש'} ]; } else { $scope.status_options = ['Opened to new member', 'Closed to new members']; - $scope.noise_level_options = ['Quiet', 'Medium', 'Noisy', 'Very Noisy']; + $scope.status_options = [ + {id:'open',value:'Opened to new member'}, + {id:'closed',value:'Closed to new members'}]; + $scope.noise_level_options = [ + {id:'quiet',value:'Quiet'}, + {id:'medium',value:'Medium'}, + {id:'noisy',value:'Noisy'}, + {id:'very noisy',value:'Very Noisy'} ]; } + $scope.getMembers = () => { angular_getMembers($http, $scope, camp_id); setTimeout(() => { diff --git a/routes/api_camps_routes.js b/routes/api_camps_routes.js index 7be3b3521..5c701c6a9 100644 --- a/routes/api_camps_routes.js +++ b/routes/api_camps_routes.js @@ -208,7 +208,7 @@ module.exports = (app, passport) => { updated_at: (new Date()).toISOString().substring(0, 19).replace('T', ' '), camp_desc_he: req.body.camp_desc_he, camp_desc_en: req.body.camp_desc_en, - status: req.body.status, + // status: req.body.camp_status, type: req.body.type, facebook_page_url: req.body.facebook_page_url, contact_person_name: req.body.contact_person_name, @@ -217,7 +217,7 @@ module.exports = (app, passport) => { accept_families: req.body.accept_families, camp_activity_time: req.body.camp_activity_time, child_friendly: req.body.child_friendly, - noise_level: req.body.noise_level, + // noise_level: req.body.noise_level, support_art: req.body.support_art, } var __update_prop_foreign = function (propName) { @@ -225,9 +225,13 @@ module.exports = (app, passport) => { data[propName] = req.body[propName]; } } - var __update_prop = function (propName) { + var __update_prop = function (propName, options) { if (req.body[propName] !== undefined) { - data[propName] = req.body[propName]; + let value = req.body[propName]; + if (!options || (typeof options === 'array' && options.indexOf(value) > -1)) { + data[propName] = value; + } + return value; } } if (isNew) { @@ -237,12 +241,16 @@ module.exports = (app, passport) => { __update_prop('camp_name_en'); __update_prop('camp_name_he'); } + __update_prop('noise_level',constants.CAMP_NOISE_LEVELS); + // if (req.body.camp_status) __update_prop_foreign('main_contact_person_id'); __update_prop_foreign('main_contact'); __update_prop_foreign('moop_contact'); __update_prop_foreign('safety_contact'); + let camp_statuses = ['open', 'closed']; if (req.user.isAdmin) { + camp_statuses = constants.CAMP_STATUSES; __update_prop('public_activity_area_sqm'); __update_prop('public_activity_area_desc'); __update_prop('location_comments'); @@ -250,6 +258,10 @@ module.exports = (app, passport) => { __update_prop('camp_location_street_time'); __update_prop('camp_location_area'); } + if (camp_statuses.indexOf(req.body.camp_status) > -1) { + data.status = req.body.camp_status; + } + // console.log(data); return data; } diff --git a/routes/api_gate_routes.js b/routes/api_gate_routes.js index 16cf5c9f3..c8bf12266 100644 --- a/routes/api_gate_routes.js +++ b/routes/api_gate_routes.js @@ -1,36 +1,103 @@ -// var User = require('../models/user').User; +const common = require('../libs/common').common; +var User = require('../models/user').User; // var Camp = require('../models/camp').Camp; -// const constants = require('../models/constants.js'); +const constants = require('../models/constants.js'); // var config = require('config'); // const knex = require('../libs/db').knex; // const userRole = require('../libs/user_role'); // var mail = require('../libs/mail'), // mailConfig = config.get('mail'); // -// module.exports = (app, passport) => { +var __gate_change_status = function (user_id, status, req, res) { + if (req.user.isAdmin) { + if (['in', 'out'].indexOf(status) > -1) { + let _forge = {}; + if (typeof user_id === "number") { + _forge.user_id = user_id; + } else if (common.validateEmail(user_id)) { + _forge.email = user_id; + } else { + res.status(500).json({ error: true, data: { message: 'Unrecognized user id or email' } }); + return; + } + + User.forge({ _forge }).fetch().then((user) => { + let addinfo_json = {}; + if (user && (typeof user.attributes.addinfo_json) === 'string') { + addinfo_json = JSON.parse(user.attributes.addinfo_json); + } + if (typeof addinfo_json.current_status_log !== 'array') { + addinfo_json.current_status_log = []; + } + + let _newSettings = { + current_event_id: constants.CURRENT_EVENT_ID, + current_last_status: (new Date()).toISOString().substring(0, 19).replace('T', ' '), + current_status: status, + } + addinfo_json.current_status_log.push(_newSettings); + _newSettings.addinfo_json = JSON.stringify(addinfo_json); + user.save(_newStatus).then((user) => { + let _res_data = { + user_id: user.attributes.user_id, + email: user.attributes.email, + event_id: user.attributes.current_event_id, + current_status: user.attributes.current_status, + current_status_time: user.attributes.current_last_status, + }; + res.status(200).json({ error: false, data: _res_data }); + }); + }).catch((err) => { + res.status(500).json({ + error: true, + data: { + message: err.message + } + }); + }); + } else { + res.status(500).json({ error: true, data: { message: 'Unrecognized status IN and OUT' } }); + } + } else { + res.status(500).json({ error: true, data: { message: 'Not authorized to change user status' } }); + } +} + +module.exports = (app, passport) => { /** - * API: (GET) get user by id - * request => /users/:id + * API: (GET) Set the location of the profile of event_id current status to be inside event + * + * request => /gate/event_in/id/:user_id */ - // app.get('/gate/:action/:reference', -// [userRole.isLoggedIn(), userRole.isAllowToViewUser()], -// (req, res) => { -// User.forge({ user_id: req.params.id }).fetch({ columns: '*' }).then((user) => { -// if (user !== null) { -// res.json({ name: user.get('name'), email: user.get('email'), cell_phone: user.get('cell_phone') }) -// } else { -// res.status(404).json({ message: 'Not found' }) -// } - -// }).catch((err) => { -// res.status(500).json({ -// error: true, -// data: { -// message: err.message -// } -// }); -// }); - // }); -// -// } -// \ No newline at end of file + app.get('/gate/event_in/id/:user_id', userRole.isLoggedIn(), (req, res) => { + let user_id = req.params.user_id; + __gate_change_status(parseInt(user_id), 'in', req, res); + }); + /** + * API: (GET) Set the location of the profile of event_id current status to be inside event + * + * request => /gate/event_in/id/:user_id + */ + app.get('/gate/event_out/id/:user_id', userRole.isLoggedIn(), (req, res) => { + let user_id = req.params.user_id; + __gate_change_status(parseInt(user_id), 'out', req, res); + }); + /** + * API: (GET) Set the location of the profile of event_id current status to be inside event + * + * request => /gate/event_in/id/:user_id + */ + app.get('/gate/event_in/email/:email', userRole.isLoggedIn(), (req, res) => { + let email = req.params.email; + __gate_change_status(email, 'in', req, res); + }); + /** + * API: (GET) Set the location of the profile of event_id current status to be inside event + * + * request => /gate/event_in/id/:user_id + */ + app.get('/gate/event_out/email/:email', userRole.isLoggedIn(), (req, res) => { + let email = req.params.email; + __gate_change_status(email, 'in', req, res); + }); +} diff --git a/routes/camps_routes.js b/routes/camps_routes.js index 3ff55d8c2..036dec974 100644 --- a/routes/camps_routes.js +++ b/routes/camps_routes.js @@ -2,6 +2,18 @@ const userRole = require('../libs/user_role'); const constants = require('../models/constants.js'); var Camp = require('../models/camp').Camp; // var User = require('../models/user').User; +__camp_data_to_json = function (camp) { + let camp_data = camp.toJSON(); + let camp_check_null = [ + 'type', 'status', 'public_activity_area_desc', 'camp_activity_time', 'location_comments', + 'camp_location_street', 'camp_location_street_time', 'camp_location_area', 'contact_person_name', + 'contact_person_email', 'contact_person_phone']; + for (let i in camp_check_null) { + if (camp_data[camp_check_null[i]] === null) + camp_data[camp_check_null[i]] = ''; + } + return camp_data; +} var __render_camp = function (camp, req, res) { var camp_id; if (['int', 'string'].indexOf(typeof camp) > -1) { @@ -18,13 +30,14 @@ var __render_camp = function (camp, req, res) { camp.init_t(req.t); // if user is camp_member, we can show all // let _user = camp.isUserCampMember(req.user.id); + let camp_data=__camp_data_to_json(camp); let data = { user: req.user, // userLoggedIn: req.user.hasRole('logged in'), // id: camp_id, // - camp: camp.toJSON(), + camp: camp_data, breadcrumbs: req.breadcrumbs(), - details: camp.toJSON(), + details: camp_data, isUserCampMember: (camp.isUserCampMember(req.user.id) || req.user.isAdmin), isUserInCamp: (camp.isUserInCamp(req.user.id) || req.user.isAdmin), main_contact: camp.isUserInCamp(camp.attributes.main_contact), @@ -192,36 +205,7 @@ module.exports = function (app, passport) { name: 'camps:breadcrumbs.camp_stat', url: '/' + req.params.lng + '/camps/' + req.params.id }]); - __render_camp(req.params.id, req, res); - // Camp.forge({ - // id: req.params.id, - // event_id: constants.CURRENT_EVENT_ID, - // __prototype: constants.prototype_camps.THEME_CAMP.id, - // }).fetch({ - // // withRelated: ['details'] - // }).then((camp) => { - // camp.init_t(req.t); - // User.forge({ - // user_id: camp.toJSON().main_contact - // }).fetch().then((user) => { - // res.render('pages/camps/camp', { - // user: req.user, - // userLoggedIn: req.user.hasRole('logged in'), - // id: req.params.id, - // camp: camp.toJSON(), - // breadcrumbs: req.breadcrumbs(), - // details: camp.toJSON() - // }); - // }); - // }).catch((e) => { - // res.status(500).json({ - // error: true, - // data: { - // message: e.message - // } - // }); - // }); }); // Edit app.get('/:lng/camps/:id/edit', userRole.isLoggedIn(), (req, res) => { @@ -245,8 +229,7 @@ module.exports = function (app, passport) { }).then((camp) => { req.user.getUserCamps((camps) => { if (req.user.isManagerOfCamp(req.params.id) || req.user.isAdmin) { - var camp_data = camp.toJSON(); - camp_data.type = (camp_data.type === null) ? '' : camp_data.type; + let camp_data = __camp_data_to_json(camp); res.render('pages/camps/edit', { user: req.user, breadcrumbs: req.breadcrumbs(), diff --git a/test/e2e/sanity.js b/test/e2e/sanity.js new file mode 100644 index 000000000..8057aa48c --- /dev/null +++ b/test/e2e/sanity.js @@ -0,0 +1,66 @@ +process.env.NODE_ENV = 'testing' + +var should = require('should'); // eslint-disable-line no-unused-vars + +var Nightmare = require('nightmare'); + +// Set different ports so we can run tests while dev server is running +process.env.WEB_PORT = 3000 + +// Web tests +require('../../server'); + +console.log("~~~~ Webpack & API servers up, starting e2e tests ~~~~") + +var url = `http://localhost:${process.env.WEB_PORT}`; +var testTimeout = 30000 + +function newNightmare() { + return new Nightmare({ + show: true, + waitTimeout: testTimeout, + executionTimeout: testTimeout + }) +} + +describe('Home page', function () { + this.timeout(testTimeout); + + it('should show login form when loaded', function (done) { + newNightmare() + .goto(url) + .evaluate(function () { + return document.querySelectorAll('#login_email').length; + }) + .run(function (err, result) { + should.not.exist(err); + result.should.equal(1); + done(); + }) + .catch(done); + }); +}); + +describe('Forgot Password Page', function () { + var email = 'ron.gross@gmail.com'; + this.timeout(testTimeout); + + // TODO: Fix this test + xit('Should say email was sent when a valid email is entered', function (done) { + newNightmare() + .goto(url) + .click('#login_email') + .type('.form-group input', email) + .click('#reset-password-btn') + .wait('.alert-success') + .evaluate(function () { + return document.querySelectorAll('.alert-success"').length; + }) + .run(function (err, result) { + should.not.exist(err); + result.should.equal(1); + done(); + }) + .catch(done); + }); +}); diff --git a/views/pages/camps/edit.jade b/views/pages/camps/edit.jade index d63ee16db..865f29ee5 100644 --- a/views/pages/camps/edit.jade +++ b/views/pages/camps/edit.jade @@ -78,7 +78,7 @@ block content .col-xs-12 label(for='camp_status', data-camp-status='#{camp.status}') #{t('camps:edit.status')} select.form-control(id='camp_status', name='status') - option(ng-repeat="option in status_options", ng-selected="option == #{camp.status}", value="{{option}}") {{ option }} + option(ng-repeat="option in status_options", ng-selected="option.id == '#{camp.status}'", value="{{option.id}}") {{option.value}} .col-md-4 .col-xs-12 label(for='camp_accept_families')=t('camps:camps.accept_families') @@ -183,16 +183,16 @@ block content checkbox.checked = checkbox.attributes.checked.value == "1" ? true:false .col-md-4 .col-xs-12 - script. - var noise_level = { - "quiet": "#{t('camps:new.camp_noise_level_quiet')}", - "medium": "#{t('camps:new.camp_noise_level_medium')}", - "noisy": "#{t('camps:new.camp_noise_level_noisy')}", - "very noisy": "#{t('camps:new.camp_noise_level_very_noisy')}" - } - label(for='noise_level') #{t('camps:edit.camp_noise_level')}: #{camp.noise_level} + //- script. + //- var noise_level = { + //- "quiet": "#{t('camps:new.camp_noise_level_quiet')}", + //- "medium": "#{t('camps:new.camp_noise_level_medium')}", + //- "noisy": "#{t('camps:new.camp_noise_level_noisy')}", + //- "very noisy": "#{t('camps:new.camp_noise_level_very_noisy')}" + //- } + label(for='noise_level') #{t('camps:edit.camp_noise_level')} select(id='camp_noise_level', class="form-control", name="noise_level") - option(ng-repeat="option in noise_level_options", ng-selected="option == #{camp.noise_level}", value="{{option}}") {{ option }} + option(ng-repeat="option in noise_level_options", ng-selected="option.id == '#{camp.noise_level}'", value="{{option.id}}") {{option.value}} //------- Lilach requested to hide section -------------// //- h4=t('camps:edit.public_area_title') diff --git a/views/pages/login.jade b/views/pages/login.jade index b70b123a4..59f1d8e0f 100644 --- a/views/pages/login.jade +++ b/views/pages/login.jade @@ -49,7 +49,7 @@ block wrapper div //- ## we are using dropal login / signup. old spark links are comment out for future use ## //- a(href="/#{language}/reset_password")=t('forgot_password_question') - a(href="https://profile.midburn.org/#{language}/user/password")=t('forgot_password_question') + a(id='reset-password-link',href="https://profile.midburn.org/#{language}/user/password")=t('forgot_password_question') div span=t('need_account') //- ## we are using dropal login / signup. old spark links are comment out for future use ## diff --git a/views/pages/reset_password.jade b/views/pages/reset_password.jade index 01262c459..c23faaa99 100644 --- a/views/pages/reset_password.jade +++ b/views/pages/reset_password.jade @@ -20,7 +20,7 @@ block wrapper label=t('email') input( type="text", class="form-control", name="email", required ) .text-center - button( type="submit", class="btn btn-info" )=t('reset_password') + button( id="reset-password-btn", type="submit", class="btn btn-info" )=t('reset_password') .text-center hr