Merge pull request #385 from HeyPuter/eric/dry-middleware

Add password requirement to: disable 2FA, change email
This commit is contained in:
Eric Dubé 2024-05-13 16:38:59 -04:00 committed by GitHub
commit 7dc5929cfd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 691 additions and 128 deletions

View File

@ -0,0 +1,87 @@
# Puter Backend Boot Sequence
This document describes the boot sequence of Puter's backend.
**Constriction**
- Data structures are created
**Initialization**
- Registries are populated
- Services prepare for next phase
**Consolidation**
- Service event bus receives first event (`boot.consolidation`)
- Services perform coordinated setup behaviors
- Services prepare for next phase
**Activation**
- Blocking listeners of `boot.consolidation` have resolved
- HTTP servers start listening
**Ready**
- Services are informed that Puter is providing service
## Boot Phases
### Construction
Services implement a method called `construct` which initializes members
of an instance. Services do not override the class constructor of
**BaseService**. This makes it possible to use the `new` operator without
invoking a service's constructor behavior during debugging.
The first phase of the boot sequence, "construction", is simply a loop to
call `construct` on all registered services.
The `_construct` override should not:
- call other services
- emit events
### Initialization
At initialization, the `init()` method is called on all services.
The `_init` override can be used to:
- register information with other services, when services don't
need to register this information in a specific sequence.
An example of this is registering commands with CommandService.
- perform setup that is required before the consolidation phase starts.
### Consolidation
Consolidation is a phase where services should emit events that
are related to bringing up the system. For example, WebServerService
('web-server') emits an event telling services to install middlewares,
and later emits an event telling services to install routes.
Consolidation starts when Kernel emits `boot.consolidation` to the
services event bus, which happens after `init()` resolves for all
services.
### Activation
Activation is a phase where services begin listening on external
interfaces. For example, this is when the web server starts listening.
Activation starts when Kernel emits `boot.activation`.
### Ready
Ready is a phase where services are informed that everything is up.
Ready starts when Kernel emits `boot.ready`.
## Events and Asynchronous Execution
The services event bus is implemented so you can `await` a call to `.emit()`.
Event listeners can choose to have blocking behavior by returning a promise.
During emission of a particular event, listeners of this event will not
block each other, but all listeners must resolve before the call to
`.emit()` is resolved. (i.e. `emit` uses `Promise.all`)
## Legacy Services
Some services were implemented before the `BaseService` class - which
implements the `init` method - was created. These services are called
"legacy services" and they are instantiated _after_ initialization but
_before_ consolidation.

View File

@ -204,6 +204,9 @@ const install = async ({ services, app }) => {
const { OTPService } = require('./services/auth/OTPService');
services.registerService('otp', OTPService);
const { UserProtectedEndpointsService } = require("./services/web/UserProtectedEndpointsService");
services.registerService('__user-protected-endpoints', UserProtectedEndpointsService);
}
const install_legacy = async ({ services }) => {

View File

@ -152,17 +152,7 @@ class Kernel extends AdvancedBase {
const { services } = this;
await services.ready;
{
const app = services.get('web-server').app;
app.use(async (req, res, next) => {
req.services = services;
next();
});
await services.emit('boot.services-initialized');
await services.emit('install.middlewares.context-aware', { app });
await services.emit('install.routes', { app });
await services.emit('install.routes-gui', { app });
}
await services.emit('boot.consolidation');
// === END: Initialize Service Registry ===
@ -178,9 +168,8 @@ class Kernel extends AdvancedBase {
});
})();
await services.emit('start.webserver');
await services.emit('ready.webserver');
await services.emit('boot.activation');
await services.emit('boot.ready');
}
}

View File

@ -340,6 +340,28 @@ module.exports = class APIError {
message: '2FA is already enabled.',
},
// protected endpoints
'too_many_requests': {
status: 429,
message: 'Too many requests.',
},
'user_tokens_only': {
status: 403,
message: 'This endpoint must be requested with a user session',
},
'temporary_accounts_not_allowed': {
status: 403,
message: 'Temporary accounts cannot perform this action',
},
'password_required': {
status: 400,
message: 'Password is required.',
},
'password_mismatch': {
status: 403,
message: 'Password does not match.',
},
// Object Mapping
'field_not_allowed_for_create': {
status: 400,

View File

@ -107,22 +107,6 @@ module.exports = eggspress('/auth/configure-2fa/:action', {
return {};
};
actions.disable = async () => {
await db.write(
`UPDATE user SET otp_enabled = 0, otp_recovery_codes = '' WHERE uuid = ?`,
[user.uuid]
);
// update cached user
req.user.otp_enabled = 0;
const svc_email = req.services.get('email');
await svc_email.send_email({ email: user.email }, 'disabled_2fa', {
username: user.username,
});
return { success: true };
};
if ( ! actions[action] ) {
throw APIError.create('invalid_action', null, { action });
}

View File

@ -28,72 +28,6 @@ const config = require('../config.js');
const jwt = require('jsonwebtoken');
const { invalidate_cached_user_by_id } = require('../helpers.js');
const CHANGE_EMAIL_START = eggspress('/change_email/start', {
subdomain: 'api',
auth: true,
verified: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
const user = req.user;
const new_email = req.body.new_email;
// TODO: DRY: signup.js
// validation
if( ! new_email ) {
throw APIError.create('field_missing', null, { key: 'new_email' });
}
if ( typeof new_email !== 'string' ) {
throw APIError.create('field_invalid', null, {
key: 'new_email', expected: 'a valid email address' });
}
if ( ! validator.isEmail(new_email) ) {
throw APIError.create('field_invalid', null, {
key: 'new_email', expected: 'a valid email address' });
}
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('change-email-start') ) {
return res.status(429).send('Too many requests.');
}
// check if email is already in use
const db = req.services.get('database').get(DB_WRITE, 'auth');
const rows = await db.read(
'SELECT COUNT(*) AS `count` FROM `user` WHERE `email` = ?',
[new_email]
);
if ( rows[0].count > 0 ) {
throw APIError.create('email_already_in_use', null, { email: new_email });
}
// generate confirmation token
const token = crypto.randomBytes(4).toString('hex');
const jwt_token = jwt.sign({
user_id: user.id,
token,
}, config.jwt_secret, { expiresIn: '24h' });
// send confirmation email
const svc_email = req.services.get('email');
await svc_email.send_email({ email: new_email }, 'email_change_request', {
confirm_url: `${config.origin}/change_email/confirm?token=${jwt_token}`,
username: user.username,
});
const old_email = user.email;
// TODO: NotificationService
await svc_email.send_email({ email: old_email }, 'email_change_notification', {
new_email: new_email,
});
// update user
await db.write(
'UPDATE `user` SET `unconfirmed_change_email` = ?, `change_email_confirm_token` = ? WHERE `id` = ?',
[new_email, token, user.id]
);
res.send({ success: true });
});
const CHANGE_EMAIL_CONFIRM = eggspress('/change_email/confirm', {
allowedMethods: ['GET'],
}, async (req, res, next) => {
@ -137,6 +71,5 @@ const CHANGE_EMAIL_CONFIRM = eggspress('/change_email/confirm', {
});
module.exports = app => {
app.use(CHANGE_EMAIL_START);
app.use(CHANGE_EMAIL_CONFIRM);
}

View File

@ -0,0 +1,68 @@
const APIError = require("../../api/APIError");
const { DB_WRITE } = require("../../services/database/consts");
const jwt = require('jsonwebtoken');
const validator = require('validator');
const crypto = require('crypto');
const config = require("../../config");
module.exports = {
route: '/change-email',
methods: ['POST'],
handler: async (req, res, next) => {
const user = req.user;
const new_email = req.body.new_email;
console.log('DID REACH HERE');
// TODO: DRY: signup.js
// validation
if( ! new_email ) {
throw APIError.create('field_missing', null, { key: 'new_email' });
}
if ( typeof new_email !== 'string' ) {
throw APIError.create('field_invalid', null, {
key: 'new_email', expected: 'a valid email address' });
}
if ( ! validator.isEmail(new_email) ) {
throw APIError.create('field_invalid', null, {
key: 'new_email', expected: 'a valid email address' });
}
// check if email is already in use
const db = req.services.get('database').get(DB_WRITE, 'auth');
const rows = await db.read(
'SELECT COUNT(*) AS `count` FROM `user` WHERE `email` = ?',
[new_email]
);
if ( rows[0].count > 0 ) {
throw APIError.create('email_already_in_use', null, { email: new_email });
}
// generate confirmation token
const token = crypto.randomBytes(4).toString('hex');
const jwt_token = jwt.sign({
user_id: user.id,
token,
}, config.jwt_secret, { expiresIn: '24h' });
// send confirmation email
const svc_email = req.services.get('email');
await svc_email.send_email({ email: new_email }, 'email_change_request', {
confirm_url: `${config.origin}/change_email/confirm?token=${jwt_token}`,
username: user.username,
});
const old_email = user.email;
// TODO: NotificationService
await svc_email.send_email({ email: old_email }, 'email_change_notification', {
new_email: new_email,
});
// update user
await db.write(
'UPDATE `user` SET `unconfirmed_change_email` = ?, `change_email_confirm_token` = ? WHERE `id` = ?',
[new_email, token, user.id]
);
res.send({ success: true });
}
};

View File

@ -0,0 +1,85 @@
// TODO: DRY: This is the same function used by UIWindowChangePassword!
const { invalidate_cached_user } = require("../../helpers");
const { DB_WRITE } = require("../../services/database/consts");
// duplicate definition is in src/helpers.js (puter GUI)
const check_password_strength = (password) => {
// Define criteria for password strength
const criteria = {
minLength: 8,
hasUpperCase: /[A-Z]/.test(password),
hasLowerCase: /[a-z]/.test(password),
hasNumber: /\d/.test(password),
hasSpecialChar: /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)
};
let overallPass = true;
// Initialize report object
let criteria_report = {
minLength: {
message: `Password must be at least ${criteria.minLength} characters long`,
pass: password.length >= criteria.minLength,
},
hasUpperCase: {
message: 'Password must contain at least one uppercase letter',
pass: criteria.hasUpperCase,
},
hasLowerCase: {
message: 'Password must contain at least one lowercase letter',
pass: criteria.hasLowerCase,
},
hasNumber: {
message: 'Password must contain at least one number',
pass: criteria.hasNumber,
},
hasSpecialChar: {
message: 'Password must contain at least one special character',
pass: criteria.hasSpecialChar,
},
};
// Check overall pass status and add messages
for (let criterion in criteria) {
if (!criteria_report[criterion].pass) {
overallPass = false;
break;
}
}
return {
overallPass: overallPass,
report: criteria_report,
};
}
module.exports = {
route: '/change-password',
methods: ['POST'],
handler: async (req, res, next) => {
// Validate new password
const { new_pass } = req.body;
const { overallPass: strong } = check_password_strength(new_pass);
if ( ! strong ) {
req.status(400).send('Password does not meet requirements.');
}
// Update user
// TODO: DI for endpoint definitions like this one
const bcrypt = require('bcrypt');
const db = req.services.get('database').get(DB_WRITE, 'auth');
await db.write(
'UPDATE user SET password=?, `pass_recovery_token` = NULL, `change_email_confirm_token` = NULL WHERE `id` = ?',
[await bcrypt.hash(req.body.new_pass, 8), req.user.id]
);
invalidate_cached_user(req.user);
// Notify user about password change
// TODO: audit log for user in security tab
const svc_email = req.services.get('email');
svc_email.send_email({ email: req.user.email }, 'password_change_notification');
return res.send('Password successfully updated.')
}
};

View File

@ -0,0 +1,22 @@
const { DB_WRITE } = require("../../services/database/consts");
module.exports = {
route: '/disable-2fa',
methods: ['POST'],
handler: async (req, res, next) => {
const db = req.services.get('database').get(DB_WRITE, '2fa.disable');
await db.write(
`UPDATE user SET otp_enabled = 0, otp_recovery_codes = NULL, otp_secret = NULL WHERE uuid = ?`,
[req.user.uuid]
);
// update cached user
req.user.otp_enabled = 0;
const svc_email = req.services.get('email');
await svc_email.send_email({ email: req.user.email }, 'disabled_2fa', {
username: req.user.username,
});
res.send({ success: true });
}
};

View File

@ -20,7 +20,7 @@ const { Context } = require("../util/context");
const BaseService = require("./BaseService");
class RefreshAssociationsService extends BaseService {
async ['__on_boot.services-initialized'] () {
async ['__on_boot.consolidation'] () {
const { refresh_associations_cache } = require('../helpers');
await Context.allow_fallback(async () => {

View File

@ -42,6 +42,20 @@ class WebServerService extends BaseService {
morgan: require('morgan'),
};
async ['__on_boot.consolidation'] () {
const app = this.app;
const services = this.services;
await services.emit('install.middlewares.context-aware', { app });
await services.emit('install.routes', { app });
await services.emit('install.routes-gui', { app });
}
async ['__on_boot.activation'] () {
const services = this.services;
await services.emit('start.webserver');
await services.emit('ready.webserver');
}
async ['__on_start.webserver'] () {
await es_import_promise;

View File

@ -1,9 +1,16 @@
const { Context } = require("../../util/context");
const { asyncSafeSetInterval } = require("../../util/promise");
const { quot } = require("../../util/strutil");
const { MINUTE, HOUR } = require('../../util/time.js');
const BaseService = require("../BaseService");
/* INCREMENTAL CHANGES
The first scopes are of the form 'name-of-endpoint', but later it was
decided that they're of the form `/path/to/endpoint`. New scopes should
follow the latter form.
*/
class EdgeRateLimitService extends BaseService {
_construct () {
this.scopes = {
@ -55,6 +62,18 @@ class EdgeRateLimitService extends BaseService {
limit: 10,
window: HOUR,
},
['/user-protected/change-password']: {
limit: 10,
window: HOUR,
},
['/user-protected/change-email']: {
limit: 10,
window: HOUR,
},
['/user-protected/disable-2fa']: {
limit: 10,
window: HOUR,
},
['login-otp']: {
limit: 15,
window: 30 * MINUTE,
@ -77,6 +96,9 @@ class EdgeRateLimitService extends BaseService {
}
check (scope) {
if ( ! this.scopes.hasOwnProperty(scope) ) {
throw new Error(`unrecognized rate-limit scope: ${quot(scope)}`)
}
const { window, limit } = this.scopes[scope];
const requester = Context.get('requester');

View File

@ -0,0 +1,100 @@
const { get_user } = require("../../helpers");
const auth2 = require("../../middleware/auth2");
const { Context } = require("../../util/context");
const BaseService = require("../BaseService");
const { UserActorType } = require("../auth/Actor");
const { Endpoint } = require("../../util/expressutil");
const APIError = require("../../api/APIError.js");
/**
* This service registers endpoints that are protected by password authentication,
* excluding login. These endpoints are typically for actions that affect
* security settings on the user's account.
*/
class UserProtectedEndpointsService extends BaseService {
static MODULES = {
express: require('express'),
};
['__on_install.routes'] () {
const router = (() => {
const require = this.require;
const express = require('express');
return express.Router();
})()
const { app } = this.services.get('web-server');
app.use('/user-protected', router);
// Apply edge (unauthenticated) rate-limiting
router.use((req, res, next) => {
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check(req.baseUrl + req.path) ) {
return APIError.create('too_many_requests').write(res);
}
next();
})
// Require authenticated session
router.use(auth2);
// Only allow user sessions, not API tokens for apps
router.use((req, res, next) => {
const actor = Context.get('actor');
if ( ! (actor.type instanceof UserActorType) ) {
return APIError.create('user_tokens_only').write(res);
}
next();
});
// Prioritize consistency for user object
router.use(async (req, res, next) => {
const user = await get_user({ id: req.user.id, force: true });
req.user = user;
next();
});
// Do not allow temporary users
router.use(async (req, res, next) => {
if ( req.user.password === null ) {
return APIError.create('temporary_account').write(res);
}
next();
});
// Require password in request
router.use(async (req, res, next) => {
if ( ! req.body.password ) {
return (APIError.create('password_required')).write(res);
}
const bcrypt = (() => {
const require = this.require;
return require('bcrypt');
})();
const user = await get_user({ id: req.user.id, force: true });
const isMatch = await bcrypt.compare(req.body.password, user.password);
if ( ! isMatch ) {
return APIError.create('password_mismatch').write(res);
}
next();
});
Endpoint(
require('../../routers/user-protected/change-password.js'),
).attach(router);
Endpoint(
require('../../routers/user-protected/change-email.js'),
).attach(router);
Endpoint(
require('../../routers/user-protected/disable-2fa.js'),
).attach(router);
}
}
module.exports = {
UserProtectedEndpointsService
};

View File

@ -0,0 +1,21 @@
const eggspress = require("../api/eggspress");
const Endpoint = function Endpoint (spec) {
return {
attach (route) {
const eggspress_options = {
allowedMethods: spec.methods ?? ['GET'],
};
const eggspress_router = eggspress(
spec.route,
eggspress_options,
spec.handler,
);
route.use(eggspress_router);
}
};
}
module.exports = {
Endpoint,
};

View File

@ -0,0 +1,136 @@
import { Component, defineComponent } from "../../util/Component.js";
export default class PasswordEntry extends Component {
static PROPERTIES = {
spec: {},
value: {},
error: {},
on_submit: {},
show_password: {},
}
static CSS = /*css*/`
fieldset {
display: flex;
flex-direction: column;
}
input {
flex-grow: 1;
}
/* TODO: I'd rather not duplicate this */
.error {
display: none;
color: red;
border: 1px solid red;
border-radius: 4px;
padding: 9px;
margin-bottom: 15px;
text-align: center;
font-size: 13px;
}
.error-message {
display: none;
color: rgb(215 2 2);
font-size: 14px;
margin-top: 10px;
margin-bottom: 10px;
padding: 10px;
border-radius: 4px;
border: 1px solid rgb(215 2 2);
text-align: center;
}
.password-and-toggle {
display: flex;
align-items: center;
gap: 10px;
}
.password-and-toggle input {
flex-grow: 1;
}
/* TODO: DRY: This is from style.css */
input[type=text], input[type=password], input[type=email], select {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
outline: none;
-webkit-font-smoothing: antialiased;
color: #393f46;
font-size: 14px;
}
/* to prevent auto-zoom on input focus in mobile */
.device-phone input[type=text], .device-phone input[type=password], .device-phone input[type=email], .device-phone select {
font-size: 17px;
}
input[type=text]:focus, input[type=password]:focus, input[type=email]:focus, select:focus {
border: 2px solid #01a0fd;
padding: 7px;
}
`;
create_template ({ template }) {
$(template).html(/*html*/`
<form>
<div class="error"></div>
<div class="password-and-toggle">
<input type="password" class="value-input" id="password" placeholder="${i18n('password')}" required>
<img
id="toggle-show-password"
src="${this.get('show_password')
? window.icons["eye-closed.svg"]
: window.icons["eye-open.svg"]}"
width="20"
height="20">
</div>
</form>
`);
}
on_focus () {
$(this.dom_).find('input').focus();
}
on_ready ({ listen }) {
listen('error', (error) => {
if ( ! error ) return $(this.dom_).find('.error').hide();
$(this.dom_).find('.error').text(error).show();
});
listen('value', (value) => {
// clear input
if ( value === undefined ) {
$(this.dom_).find('input').val('');
}
});
const input = $(this.dom_).find('input');
input.on('input', () => {
this.set('value', input.val());
});
const on_submit = this.get('on_submit');
if ( on_submit ) {
$(this.dom_).find('input').on('keyup', (e) => {
if ( e.key === 'Enter' ) {
on_submit();
}
});
}
$(this.dom_).find("#toggle-show-password").on("click", () => {
this.set('show_password', !this.get('show_password'));
const show_password = this.get('show_password');
// hide/show password and update icon
$(this.dom_).find("input").attr("type", show_password ? "text" : "password");
$(this.dom_).find("#toggle-show-password").attr("src", show_password ? window.icons["eye-closed.svg"] : window.icons["eye-open.svg"])
});
}
}
defineComponent('c-password-entry', PasswordEntry);

View File

@ -1,4 +1,10 @@
import TeePromise from "../../util/TeePromise.js";
import Button from "../Components/Button.js";
import Flexer from "../Components/Flexer.js";
import JustHTML from "../Components/JustHTML.js";
import PasswordEntry from "../Components/PasswordEntry.js";
import UIAlert from "../UIAlert.js";
import UIComponentWindow from "../UIComponentWindow.js";
import UIWindow2FASetup from "../UIWindow2FASetup.js";
import UIWindowQR from "../UIWindowQR.js";
@ -64,35 +70,88 @@ export default {
});
$el_window.find('.disable-2fa').on('click', async function (e) {
const confirmation = i18n('disable_2fa_confirm');
const alert_resp = await UIAlert({
message: confirmation,
window_options: {
parent_uuid: $el_window.attr('data-element_uuid'),
disable_parent_window: true,
parent_center: true,
},
buttons:[
{
label: i18n('yes'),
value: true,
type: 'primary',
},
{
label: i18n('cancel'),
value: false,
},
]
})
if ( ! alert_resp ) return;
const resp = await fetch(`${window.api_origin}/auth/configure-2fa/disable`, {
let win, password_entry;
const password_confirm_promise = new TeePromise();
const try_password = async () => {
const value = password_entry.get('value');
const resp = await fetch(`${window.api_origin}/user-protected/disable-2fa`, {
method: 'POST',
headers: {
Authorization: `Bearer ${puter.authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
body: JSON.stringify({
password: value,
}),
});
if ( resp.status !== 200 ) {
/* eslint no-empty: ["error", { "allowEmptyCatch": true }] */
let message; try {
message = (await resp.json()).message;
} catch (e) {}
message = message || i18n('error_unknown_cause');
password_entry.set('error', message);
return;
}
password_confirm_promise.resolve(true);
$(win).close();
}
const password_confirm = new Flexer({
children: [
new JustHTML({
html: /*html*/`
<h3 style="text-align:center; font-weight: 500; font-size: 20px;">${
i18n('disable_2fa_confirm')
}</h3>
<p style="text-align:center; padding: 0 20px;">${
i18n('disable_2fa_instructions')
}</p>
`
}),
new Flexer({
gap: '5pt',
children: [
new PasswordEntry({
_ref: me => password_entry = me,
on_submit: async () => {
try_password();
}
}),
new Button({
label: i18n('disable_2fa'),
on_click: async () => {
try_password();
}
}),
new Button({
label: i18n('cancel'),
style: 'secondary',
on_click: async () => {
password_confirm_promise.resolve(false);
$(win).close();
}
})
]
}),
]
});
win = await UIComponentWindow({
component: password_confirm,
width: 500,
backdrop: true,
is_resizable: false,
body_css: {
width: 'initial',
height: '100%',
'background-color': 'rgb(245 247 249)',
'backdrop-filter': 'blur(3px)',
padding: '20px',
},
});
password_entry.focus();
const ok = await password_confirm_promise;
if ( ! ok ) return;
$el_window.find('.enable-2fa').show();
$el_window.find('.disable-2fa').hide();

View File

@ -17,11 +17,18 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Placeholder from '../../util/Placeholder.js';
import PasswordEntry from '../Components/PasswordEntry.js';
import UIWindow from '../UIWindow.js'
// TODO: DRY: We could specify a validator and endpoint instead of writing
// a DOM tree and event handlers for each of these. (low priority)
async function UIWindowChangeEmail(options){
options = options ?? {};
const password_entry = new PasswordEntry({});
const place_password_entry = Placeholder();
const internal_id = window.uuidv4();
let h = '';
h += `<div class="change-email" style="padding: 20px; border-bottom: 1px solid #ced7e1;">`;
@ -34,6 +41,11 @@ async function UIWindowChangeEmail(options){
h += `<label for="confirm-new-email-${internal_id}">${i18n('new_email')}</label>`;
h += `<input id="confirm-new-email-${internal_id}" type="text" name="new-email" class="new-email" autocomplete="off" />`;
h += `</div>`;
// password confirmation
h += `<div style="overflow: hidden; margin-top: 10px; margin-bottom: 30px;">`;
h += `<label>${i18n('account_password')}</label>`;
h += `${place_password_entry.html}`;
h += `</div>`;
// Change Email
h += `<button class="change-email-btn button button-primary button-block button-normal">${i18n('change_email')}</button>`;
@ -73,11 +85,14 @@ async function UIWindowChangeEmail(options){
...options.window_options
})
password_entry.attach(place_password_entry);
$(el_window).find('.change-email-btn').on('click', function(e){
// hide previous error/success msg
$(el_window).find('.form-success-msg, .form-success-msg').hide();
const new_email = $(el_window).find('.new-email').val();
const password = $(el_window).find('.password').val();
if(!new_email){
$(el_window).find('.form-error-msg').html(i18n('all_fields_required'));
@ -93,7 +108,7 @@ async function UIWindowChangeEmail(options){
$(el_window).find('.new-email').attr('disabled', true);
$.ajax({
url: window.api_origin + "/change_email/start",
url: window.api_origin + "/user-protected/change-email",
type: 'POST',
async: true,
headers: {
@ -102,6 +117,7 @@ async function UIWindowChangeEmail(options){
contentType: "application/json",
data: JSON.stringify({
new_email: new_email,
password: password_entry.get('value'),
}),
success: function (data){
$(el_window).find('.form-success-msg').html(i18n('email_change_confirmation_sent'));

View File

@ -114,7 +114,7 @@ async function UIWindowChangePassword(options){
$(el_window).find('.form-error-msg').hide();
$.ajax({
url: window.api_origin + "/passwd",
url: window.api_origin + "/user-protected/change-password",
type: 'POST',
async: true,
headers: {
@ -122,7 +122,7 @@ async function UIWindowChangePassword(options){
},
contentType: "application/json",
data: JSON.stringify({
old_pass: current_password,
password: current_password,
new_pass: new_password,
}),
success: function (data){

View File

@ -23,6 +23,7 @@ const en = {
dictionary: {
about: "About",
account: "Account",
account_password: "Verify Account Password",
access_granted_to: "Access Granted To",
add_existing_account: "Add Existing Account",
all_fields_required: 'All fields are required.',
@ -96,6 +97,7 @@ const en = {
dir_published_as_website: `%strong% has been published to:`,
disable_2fa: 'Disable 2FA',
disable_2fa_confirm: "Are you sure you want to disable 2FA?",
disable_2fa_instructions: "Enter your password to disable 2FA.",
disassociate_dir: "Disassociate Directory",
download: 'Download',
download_file: 'Download File',