diff --git a/packages/backend/doc/contributors/boot-sequence.md b/packages/backend/doc/contributors/boot-sequence.md new file mode 100644 index 00000000..bdc111e1 --- /dev/null +++ b/packages/backend/doc/contributors/boot-sequence.md @@ -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. diff --git a/packages/backend/src/CoreModule.js b/packages/backend/src/CoreModule.js index 4d1f84e5..b8f23112 100644 --- a/packages/backend/src/CoreModule.js +++ b/packages/backend/src/CoreModule.js @@ -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 }) => { diff --git a/packages/backend/src/Kernel.js b/packages/backend/src/Kernel.js index 51746f56..b92ebb9b 100644 --- a/packages/backend/src/Kernel.js +++ b/packages/backend/src/Kernel.js @@ -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'); } } diff --git a/packages/backend/src/api/APIError.js b/packages/backend/src/api/APIError.js index b7591d97..0ec6ba96 100644 --- a/packages/backend/src/api/APIError.js +++ b/packages/backend/src/api/APIError.js @@ -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, diff --git a/packages/backend/src/routers/auth/configure-2fa.js b/packages/backend/src/routers/auth/configure-2fa.js index bb50379c..eda6bb75 100644 --- a/packages/backend/src/routers/auth/configure-2fa.js +++ b/packages/backend/src/routers/auth/configure-2fa.js @@ -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 }); } diff --git a/packages/backend/src/routers/change_email.js b/packages/backend/src/routers/change_email.js index aa8456ab..5a6f81ed 100644 --- a/packages/backend/src/routers/change_email.js +++ b/packages/backend/src/routers/change_email.js @@ -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); } diff --git a/packages/backend/src/routers/user-protected/change-email.js b/packages/backend/src/routers/user-protected/change-email.js new file mode 100644 index 00000000..c4195712 --- /dev/null +++ b/packages/backend/src/routers/user-protected/change-email.js @@ -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 }); + } +}; diff --git a/packages/backend/src/routers/user-protected/change-password.js b/packages/backend/src/routers/user-protected/change-password.js new file mode 100644 index 00000000..3c1f2a1d --- /dev/null +++ b/packages/backend/src/routers/user-protected/change-password.js @@ -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.') + } +}; diff --git a/packages/backend/src/routers/user-protected/disable-2fa.js b/packages/backend/src/routers/user-protected/disable-2fa.js new file mode 100644 index 00000000..5ada0451 --- /dev/null +++ b/packages/backend/src/routers/user-protected/disable-2fa.js @@ -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 }); + } +}; diff --git a/packages/backend/src/services/RefreshAssociationsService.js b/packages/backend/src/services/RefreshAssociationsService.js index 94c47ccb..5485dcea 100644 --- a/packages/backend/src/services/RefreshAssociationsService.js +++ b/packages/backend/src/services/RefreshAssociationsService.js @@ -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 () => { diff --git a/packages/backend/src/services/WebServerService.js b/packages/backend/src/services/WebServerService.js index 597c3fa7..73d95bb9 100644 --- a/packages/backend/src/services/WebServerService.js +++ b/packages/backend/src/services/WebServerService.js @@ -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; diff --git a/packages/backend/src/services/abuse-prevention/EdgeRateLimitService.js b/packages/backend/src/services/abuse-prevention/EdgeRateLimitService.js index e3214a63..01b8abe6 100644 --- a/packages/backend/src/services/abuse-prevention/EdgeRateLimitService.js +++ b/packages/backend/src/services/abuse-prevention/EdgeRateLimitService.js @@ -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'); diff --git a/packages/backend/src/services/web/UserProtectedEndpointsService.js b/packages/backend/src/services/web/UserProtectedEndpointsService.js new file mode 100644 index 00000000..73c40122 --- /dev/null +++ b/packages/backend/src/services/web/UserProtectedEndpointsService.js @@ -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 +}; diff --git a/packages/backend/src/util/expressutil.js b/packages/backend/src/util/expressutil.js new file mode 100644 index 00000000..a7863702 --- /dev/null +++ b/packages/backend/src/util/expressutil.js @@ -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, +}; diff --git a/src/UI/Components/PasswordEntry.js b/src/UI/Components/PasswordEntry.js new file mode 100644 index 00000000..cbacdffe --- /dev/null +++ b/src/UI/Components/PasswordEntry.js @@ -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*/` +
+ `); + } + + 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); diff --git a/src/UI/Settings/UITabSecurity.js b/src/UI/Settings/UITabSecurity.js index 7514b9ce..5d6564ad 100644 --- a/src/UI/Settings/UITabSecurity.js +++ b/src/UI/Settings/UITabSecurity.js @@ -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, + 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({ + 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*/` +${ + i18n('disable_2fa_instructions') + }
+ ` + }), + 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(); + } + }) + ] + }), ] - }) - if ( ! alert_resp ) return; - const resp = await fetch(`${window.api_origin}/auth/configure-2fa/disable`, { - method: 'POST', - headers: { - Authorization: `Bearer ${puter.authToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({}), }); + 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(); @@ -101,4 +160,4 @@ export default { $el_window.find('.settings-card-security').addClass('settings-card-warning'); }); } -} \ No newline at end of file +} diff --git a/src/UI/Settings/UIWindowChangeEmail.js b/src/UI/Settings/UIWindowChangeEmail.js index 1b28c0ae..787bcc89 100644 --- a/src/UI/Settings/UIWindowChangeEmail.js +++ b/src/UI/Settings/UIWindowChangeEmail.js @@ -17,11 +17,18 @@ * along with this program. If not, see