diff --git a/dashboard/gulpfile.js b/dashboard/gulpfile.js index 596d0705f..8e55f5520 100644 --- a/dashboard/gulpfile.js +++ b/dashboard/gulpfile.js @@ -151,6 +151,15 @@ gulp.task('js-login', function () { .pipe(gulp.dest('dist/js')); }); +gulp.task('js-passwordreset', function () { + return gulp.src(['src/js/passwordreset.js', 'src/js/utils.js']) + .pipe(ejs({ apiOrigin: apiOrigin, revision: revision, appstore: appstore }, {}, { ext: '.js' })) + .pipe(sourcemaps.init()) + .pipe(concat('passwordreset.js', { newLine: ';' })) + .pipe(sourcemaps.write()) + .pipe(gulp.dest('dist/js')); +}); + gulp.task('js-setupaccount', function () { return gulp.src(['src/js/setupaccount.js', 'src/js/utils.js']) .pipe(ejs({ apiOrigin: apiOrigin, revision: revision, appstore: appstore }, {}, { ext: '.js' })) @@ -187,7 +196,7 @@ gulp.task('js-restore', function () { .pipe(gulp.dest('dist/js')); }); -gulp.task('js', gulp.series([ 'js-index', 'js-logs', 'js-filemanager', 'js-terminal', 'js-login', 'js-setupaccount', 'js-setup', 'js-setupdns', 'js-restore' ])); +gulp.task('js', gulp.series([ 'js-index', 'js-logs', 'js-filemanager', 'js-terminal', 'js-login', 'js-passwordreset', 'js-setupaccount', 'js-setup', 'js-setupdns', 'js-restore' ])); // -------------- // HTML @@ -266,6 +275,7 @@ gulp.task('watch', function (done) { gulp.watch(['src/js/filemanager.js', 'src/js/client.js', 'src/js/utils.js', 'src/components/*.js'], gulp.series(['js-filemanager'])); gulp.watch(['src/js/terminal.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-terminal'])); gulp.watch(['src/js/login.js', 'src/js/utils.js'], gulp.series(['js-login'])); + gulp.watch(['src/js/passwordreset.js', 'src/js/utils.js'], gulp.series(['js-passwordreset'])); gulp.watch(['src/js/setupaccount.js', 'src/js/utils.js'], gulp.series(['js-setupaccount'])); gulp.watch(['src/js/index.js', 'src/js/client.js', 'src/js/main.js', 'src/views/*.js', 'src/js/utils.js'], gulp.series(['js-index'])); gulp.watch(['src/3rdparty/**/*'], gulp.series(['3rdparty'])); diff --git a/dashboard/src/js/passwordreset.js b/dashboard/src/js/passwordreset.js new file mode 100644 index 000000000..b436b70a8 --- /dev/null +++ b/dashboard/src/js/passwordreset.js @@ -0,0 +1,158 @@ +'use strict'; + +/* global angular, $, showdown */ + +// create main application module +var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies']); + +app.filter('markdown2html', function () { + var converter = new showdown.Converter({ + simplifiedAutoLink: true, + strikethrough: true, + tables: true, + openLinksInNewWindow: true + }); + + return function (text) { + return converter.makeHtml(text); + }; +}); + +// disable sce for footer https://code.angularjs.org/1.5.8/docs/api/ng/service/$sce +app.config(function ($sceProvider) { + $sceProvider.enabled(false); +}); + +app.config(['$translateProvider', function ($translateProvider) { + $translateProvider.useStaticFilesLoader({ + prefix: 'translation/', + suffix: '.json' + }); + $translateProvider.preferredLanguage('en'); + $translateProvider.fallbackLanguage('en'); +}]); + +// Add shorthand "tr" filter to avoid having ot use "translate" +// This is a copy of the code at https://github.com/angular-translate/angular-translate/blob/master/src/filter/translate.js +// If we find out how to get that function handle somehow dynamically we can use that, otherwise the copy is required +function translateFilterFactory($parse, $translate) { + var translateFilter = function (translationId, interpolateParams, interpolation, forceLanguage) { + if (!angular.isObject(interpolateParams)) { + var ctx = this || { + '__SCOPE_IS_NOT_AVAILABLE': 'More info at https://github.com/angular/angular.js/commit/8863b9d04c722b278fa93c5d66ad1e578ad6eb1f' + }; + interpolateParams = $parse(interpolateParams)(ctx); + } + + return $translate.instant(translationId, interpolateParams, interpolation, forceLanguage); + }; + + if ($translate.statefulFilter()) { + translateFilter.$stateful = true; + } + + return translateFilter; +} +translateFilterFactory.displayName = 'translateFilterFactory'; +app.filter('tr', translateFilterFactory); + + +app.controller('PasswordResetController', ['$scope', '$translate', '$http', function ($scope, $translate, $http) { + // Stupid angular location provider either wants html5 location mode or not, do the query parsing on my own + var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.indexOf('=') === -1 ? [item, true] : [item.slice(0, item.indexOf('=')), item.slice(item.indexOf('=')+1)]; }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {}); + + $scope.initialized = false; + $scope.mode = ''; + $scope.busy = false; + $scope.error = false; + $scope.status = null; + $scope.username = ''; + $scope.password = ''; + $scope.totpToken = ''; + $scope.passwordResetIdentifier = ''; + $scope.newPassword = ''; + $scope.newPasswordRepeat = ''; + var API_ORIGIN = '<%= apiOrigin %>' || window.location.origin; + + $scope.onPasswordReset = function () { + $scope.busy = true; + + var data = { + identifier: $scope.passwordResetIdentifier + }; + + function done() { + $scope.busy = false; + $scope.mode = 'passwordResetDone'; + } + + $http.post(API_ORIGIN + '/api/v1/cloudron/password_reset_request', data).success(done).error(done); + }; + + $scope.onNewPassword = function () { + $scope.busy = true; + + var data = { + resetToken: search.resetToken, + password: $scope.newPassword, + totpToken: $scope.totpToken + }; + + function error(data, status) { + console.log('error', status); + $scope.busy = false; + + if (status === 401) $scope.error = data.message; + else if (status === 409) $scope.error = 'Ask your admin for an invite link first'; + else $scope.error = 'Unknown error'; + } + + $http.post(API_ORIGIN + '/api/v1/cloudron/password_reset', data).success(function (data, status) { + if (status !== 202) return error(data, status); + + // set token to autologin + localStorage.token = data.accessToken; + + $scope.mode = 'newPasswordDone'; + }).error(function (data, status) { + error(data, status); + }); + }; + + $scope.showPasswordReset = function () { + window.document.title = 'Password Reset Request'; + $scope.mode = 'passwordReset'; + $scope.passwordResetIdentifier = ''; + setTimeout(function () { $('#inputPasswordResetIdentifier').focus(); }, 200); + }; + + $scope.showNewPassword = function () { + window.document.title = 'Set New Password'; + $scope.mode = 'newPassword'; + setTimeout(function () { $('#inputNewPassword').focus(); }, 200); + }; + + $http.get(API_ORIGIN + '/api/v1/cloudron/status').success(function (data, status) { + $scope.initialized = true; + + if (status !== 200) return; + + if (data.language) $translate.use(data.language); + + $scope.status = data; + }).error(function () { + $scope.initialized = false; + }); + + // Init into the correct view + if (search.passwordReset) { + $scope.showPasswordReset(); + } else if (search.resetToken) { + $scope.showNewPassword(); + } else if (search.accessToken || search.access_token) { // auto-login feature + localStorage.token = search.accessToken || search.access_token; + window.location.href = '/'; + } else { + $scope.showPasswordReset(); + } +}]); diff --git a/dashboard/src/passwordreset.html b/dashboard/src/passwordreset.html new file mode 100644 index 000000000..ee703b6d6 --- /dev/null +++ b/dashboard/src/passwordreset.html @@ -0,0 +1,167 @@ + + + + + + + + + Cloudron Password Reset + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+ +
+

{{ 'passwordReset.title' | tr }}

+
+
+
+
+
+
+
+ + +
+
+ +
+ {{ 'passwordReset.backToLoginAction' | tr }} +
+
+
+
+ +
+
+
+
+ +
+

{{ 'passwordReset.emailSent.title' | tr }}

+
+ {{ 'passwordReset.backToLoginAction' | tr }} +
+
+
+
+ +
+
+
+
+ +
+

{{ 'passwordReset.newPassword.title' | tr }}

+
+
+
+
+
+

{{ error }}

+
+
+
+
+
+
+ +
+ +
+ {{ 'passwordReset.newPassword.errorLength' | tr }} +
+ +
+
+ +
+ {{ 'passwordReset.newPassword.errorMismatch' | tr }} +
+ +
+
+ + +
+
+ +
+ {{ 'passwordReset.backToLoginAction' | tr }} +
+
+
+
+ +
+
+
+
+ +
+

{{ 'passwordReset.success.title' | tr }}

+
+ {{ 'passwordReset.success.openDashboardAction' | tr }} +
+
+
+
+ + + +
+ + + + diff --git a/src/oidc_templates/login.ejs b/src/oidc_templates/login.ejs index 7ec88d19d..2f7f893e2 100644 --- a/src/oidc_templates/login.ejs +++ b/src/oidc_templates/login.ejs @@ -80,7 +80,7 @@

- Reset password + Reset password diff --git a/src/routes/cloudron.js b/src/routes/cloudron.js index 282ac8303..318b21f00 100644 --- a/src/routes/cloudron.js +++ b/src/routes/cloudron.js @@ -83,7 +83,7 @@ async function logout(req, res) { await eventlog.add(eventlog.ACTION_USER_LOGOUT, AuditSource.fromRequest(req), { userId: req.user.id, user: users.removePrivateFields(req.user) }); await safe(tokens.delByAccessToken(req.token.accessToken)); - res.redirect('/login.html'); + res.redirect('/'); } async function passwordResetRequest(req, res, next) { diff --git a/src/server.js b/src/server.js index 1d58d4fb0..46d44f381 100644 --- a/src/server.js +++ b/src/server.js @@ -102,7 +102,7 @@ async function initializeExpressSync() { // login/logout routes router.post('/api/v1/cloudron/login', json, password, routes.cloudron.login); - router.get ('/api/v1/cloudron/logout', token, routes.cloudron.logout); // this will invalidate the token if any and redirect to /login.html always + router.get ('/api/v1/cloudron/logout', token, routes.cloudron.logout); // this will invalidate the token if any and redirect to / always router.post('/api/v1/cloudron/password_reset_request', json, routes.cloudron.passwordResetRequest); router.post('/api/v1/cloudron/password_reset', json, routes.cloudron.passwordReset); router.post('/api/v1/cloudron/setup_account', json, routes.cloudron.setupAccount); diff --git a/src/users.js b/src/users.js index e73caef4c..de7c006a1 100644 --- a/src/users.js +++ b/src/users.js @@ -665,7 +665,7 @@ async function getPasswordResetLink(user, auditSource) { await update(user, { resetToken, resetTokenCreationTime }, auditSource); } - const resetLink = `${settings.dashboardOrigin()}/login.html?resetToken=${resetToken}`; + const resetLink = `${settings.dashboardOrigin()}/passwordreset.html?resetToken=${resetToken}`; return resetLink; }