Merge pull request #370 from HeyPuter/eric/2fa

2FA
This commit is contained in:
Eric Dubé 2024-05-06 19:33:50 -04:00 committed by GitHub
commit 0e16a3f3b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 2346 additions and 97 deletions

26
package-lock.json generated
View File

@ -6878,6 +6878,11 @@
"node": ">=16.0.0"
}
},
"node_modules/hi-base32": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/hi-base32/-/hi-base32-0.5.1.tgz",
"integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA=="
},
"node_modules/html-entities": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz",
@ -7634,6 +7639,14 @@
"npm": ">=6"
}
},
"node_modules/jssha": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.1.tgz",
"integrity": "sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==",
"engines": {
"node": "*"
}
},
"node_modules/just-extend": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz",
@ -8825,6 +8838,17 @@
"node": ">= 0.8.0"
}
},
"node_modules/otpauth": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.2.4.tgz",
"integrity": "sha512-t0Nioq2Up2ZaT5AbpXZLTjrsNtLc/g/rVSaEThmKLErAuT9mrnAKJryiPOKc3rCH+3ycWBgKpRHYn+DHqfaPiQ==",
"dependencies": {
"jssha": "~3.3.1"
},
"funding": {
"url": "https://github.com/hectorm/otpauth?sponsor=1"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@ -11472,6 +11496,7 @@
"form-data": "^4.0.0",
"handlebars": "^4.7.8",
"helmet": "^7.0.0",
"hi-base32": "^0.5.1",
"html-entities": "^2.3.3",
"is-glob": "^4.0.3",
"isbot": "^3.7.1",
@ -11491,6 +11516,7 @@
"nodemailer": "^6.9.3",
"on-finished": "^2.4.1",
"openai": "^4.20.1",
"otpauth": "9.2.4",
"prompt-sync": "^4.2.0",
"recursive-readdir": "^2.2.3",
"response-time": "^2.3.2",

View File

@ -34,6 +34,7 @@
"form-data": "^4.0.0",
"handlebars": "^4.7.8",
"helmet": "^7.0.0",
"hi-base32": "^0.5.1",
"html-entities": "^2.3.3",
"is-glob": "^4.0.3",
"isbot": "^3.7.1",
@ -53,6 +54,7 @@
"nodemailer": "^6.9.3",
"on-finished": "^2.4.1",
"openai": "^4.20.1",
"otpauth": "9.2.4",
"prompt-sync": "^4.2.0",
"recursive-readdir": "^2.2.3",
"response-time": "^2.3.2",

View File

@ -201,6 +201,9 @@ const install = async ({ services, app }) => {
const { TokenService } = require('./services/auth/TokenService');
services.registerService('token', TokenService);
const { OTPService } = require('./services/auth/OTPService');
services.registerService('otp', OTPService);
}
const install_legacy = async ({ services }) => {

View File

@ -331,6 +331,10 @@ module.exports = class APIError {
status: 403,
message: 'Attempted to create an access token with no permissions.',
},
'invalid_action': {
status: 400,
message: ({ action }) => `Invalid action: ${quot(action)}.`,
},
// Object Mapping
'field_not_allowed_for_create': {

View File

@ -0,0 +1,106 @@
const APIError = require("../../api/APIError");
const eggspress = require("../../api/eggspress");
const { UserActorType } = require("../../services/auth/Actor");
const { DB_WRITE } = require("../../services/database/consts");
const { Context } = require("../../util/context");
module.exports = eggspress('/auth/configure-2fa/:action', {
subdomain: 'api',
auth2: true,
allowedMethods: ['POST'],
}, async (req, res, next) => {
const action = req.params.action;
const x = Context.get();
// Only users can configure 2FA
const actor = Context.get('actor');
if ( ! (actor.type instanceof UserActorType) ) {
throw APIError.create('forbidden');
}
const user = actor.type.user;
const actions = {};
const db = await x.get('services').get('database').get(DB_WRITE, '2fa');
actions.setup = async () => {
const svc_otp = x.get('services').get('otp');
// generate secret
const result = svc_otp.create_secret(user.username);
// generate recovery codes
result.codes = [];
for ( let i = 0; i < 10; i++ ) {
result.codes.push(svc_otp.create_recovery_code());
}
const hashed_recovery_codes = result.codes.map(code => {
const crypto = require('crypto');
const hash = crypto
.createHash('sha256')
.update(code)
.digest('base64')
// We're truncating the hash for easier storage, so we have 128
// bits of entropy instead of 256. This is plenty for recovery
// codes, which have only 48 bits of entropy to begin with.
.slice(0, 22);
return hash;
});
// update user
await db.write(
`UPDATE user SET otp_secret = ?, otp_recovery_codes = ? WHERE uuid = ?`,
[result.secret, hashed_recovery_codes.join(','), user.uuid]
);
req.user.otp_secret = result.secret;
req.user.otp_recovery_codes = hashed_recovery_codes.join(',');
return result;
};
// IMPORTANT: only use to verify the user's 2FA setup;
// this should never be used to verify the user's 2FA code
// for authentication purposes.
actions.test = async () => {
const user = req.user;
const svc_otp = x.get('services').get('otp');
const code = req.body.code;
const ok = svc_otp.verify(user.username, user.otp_secret, code);
return { ok };
};
actions.enable = async () => {
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('enable-2fa') ) {
return res.status(429).send('Too many requests.');
}
await db.write(
`UPDATE user SET otp_enabled = 1 WHERE uuid = ?`,
[user.uuid]
);
// update cached user
req.user.otp_enabled = 1;
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;
return { success: true };
};
if ( ! actions[action] ) {
throw APIError.create('invalid_action', null, { action });
}
const result = await actions[action]();
res.json(result);
});

View File

@ -21,6 +21,36 @@ const express = require('express');
const router = new express.Router();
const { get_user, body_parser_error_handler } = require('../helpers');
const config = require('../config');
const { DB_WRITE } = require('../services/database/consts');
const complete_ = async ({ req, res, user }) => {
const svc_auth = req.services.get('auth');
const { token } = await svc_auth.create_session_token(user, { req });
//set cookie
// res.cookie(config.cookie_name, token);
res.cookie(config.cookie_name, token, {
sameSite: 'none',
secure: true,
httpOnly: true,
});
// send response
console.log('200 response?');
return res.send({
proceed: true,
next_step: 'complete',
token: token,
user:{
username: user.username,
uuid: user.uuid,
email: user.email,
email_confirmed: user.email_confirmed,
is_temp: (user.password === null && user.email === null),
}
})
};
// -----------------------------------------------------------------------//
// POST /file
@ -32,7 +62,6 @@ router.post('/login', express.json(), body_parser_error_handler, async (req, res
// modules
const bcrypt = require('bcrypt')
const jwt = require('jsonwebtoken')
const validator = require('validator')
// either username or email must be provided
@ -88,34 +117,156 @@ router.post('/login', express.json(), body_parser_error_handler, async (req, res
return res.status(400).send('Incorrect password.')
// check password
if(await bcrypt.compare(req.body.password, user.password)){
const svc_auth = req.services.get('auth');
const { token } = await svc_auth.create_session_token(user, { req });
//set cookie
// res.cookie(config.cookie_name, token);
res.cookie(config.cookie_name, token, {
sameSite: 'none',
secure: true,
httpOnly: true,
});
// We create a JWT that can ONLY be used on the endpoint that
// accepts the OTP code.
if ( user.otp_enabled ) {
const svc_token = req.services.get('token');
const otp_jwt_token = svc_token.sign('otp', {
user_uid: user.uuid,
}, { expiresIn: '5m' });
// send response
return res.send({
token: token,
user:{
username: user.username,
uuid: user.uuid,
email: user.email,
email_confirmed: user.email_confirmed,
is_temp: (user.password === null && user.email === null),
}
})
return res.status(202).send({
proceed: true,
next_step: 'otp',
otp_jwt_token: otp_jwt_token,
});
}
console.log('UMM?');
return await complete_({ req, res, user });
}else{
return res.status(400).send('Incorrect password.')
}
}catch(e){
console.error(e);
return res.status(400).send(e);
}
})
module.exports = router
router.post('/login/otp', express.json(), body_parser_error_handler, async (req, res, next) => {
// either api. subdomain or no subdomain
if(require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '')
next();
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('login-otp') ) {
return res.status(429).send('Too many requests.');
}
if ( ! req.body.token ) {
return res.status(400).send('token is required.');
}
if ( ! req.body.code ) {
return res.status(400).send('code is required.');
}
const svc_token = req.services.get('token');
let decoded; try {
decoded = svc_token.verify('otp', req.body.token);
} catch ( e ) {
return res.status(400).send('Invalid token.');
}
if ( ! decoded.user_uid ) {
return res.status(400).send('Invalid token.');
}
const user = await get_user({ uuid: decoded.user_uid, cached: false });
if ( ! user ) {
return res.status(400).send('User not found.');
}
const svc_otp = req.services.get('otp');
if ( ! svc_otp.verify(user.username, user.otp_secret, req.body.code) ) {
// THIS MAY BE COUNTER-INTUITIVE
//
// A successfully handled request, with the correct format,
// but incorrect credentials when NOT using the HTTP
// authentication framework provided by RFC 7235, SHOULD
// return status 200.
//
// Source: I asked Julian Reschke in an email, and then he
// contributed to this discussion:
// https://stackoverflow.com/questions/32752578
return res.status(200).send({
proceed: false,
});
}
return await complete_({ req, res, user });
});
router.post('/login/recovery-code', express.json(), body_parser_error_handler, async (req, res, next) => {
// either api. subdomain or no subdomain
if(require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '')
next();
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('login-recovery') ) {
return res.status(429).send('Too many requests.');
}
if ( ! req.body.token ) {
return res.status(400).send('token is required.');
}
if ( ! req.body.code ) {
return res.status(400).send('code is required.');
}
const svc_token = req.services.get('token');
let decoded; try {
decoded = svc_token.verify('otp', req.body.token);
} catch ( e ) {
return res.status(400).send('Invalid token.');
}
if ( ! decoded.user_uid ) {
return res.status(400).send('Invalid token.');
}
const user = await get_user({ uuid: decoded.user_uid, cached: false });
if ( ! user ) {
return res.status(400).send('User not found.');
}
const code = req.body.code;
const crypto = require('crypto');
const codes = user.otp_recovery_codes.split(',');
const hashed_code = crypto
.createHash('sha256')
.update(code)
.digest('base64')
// We're truncating the hash for easier storage, so we have 128
// bits of entropy instead of 256. This is plenty for recovery
// codes, which have only 48 bits of entropy to begin with.
.slice(0, 22);
if ( ! codes.includes(hashed_code) ) {
return res.status(200).send({
proceed: false,
});
}
// Remove the code from the list
const index = codes.indexOf(hashed_code);
codes.splice(index, 1);
// update user
const db = req.services.get('database').get(DB_WRITE, '2fa');
await db.write(
`UPDATE user SET otp_recovery_codes = ? WHERE uuid = ?`,
[codes.join(','), user.uuid]
);
user.otp_recovery_codes = codes.join(',');
return await complete_({ req, res, user });
});
module.exports = router;

View File

@ -41,6 +41,8 @@ const WHOAMI_GET = eggspress('/whoami', {
const is_user = actor.type instanceof UserActorType;
console.log('user?', req.user);
// send user object
const details = {
username: req.user.username,
@ -54,6 +56,7 @@ const WHOAMI_GET = eggspress('/whoami', {
is_temp: (req.user.password === null && req.user.email === null),
taskbar_items: await get_taskbar_items(req.user),
referral_code: req.user.referral_code,
otp: !! req.user.otp_enabled,
...(req.new_token ? { token: req.token } : {})
};

View File

@ -38,6 +38,7 @@ class PuterAPIService extends BaseService {
app.use(require('../routers/auth/app-uid-from-origin'))
app.use(require('../routers/auth/create-access-token'))
app.use(require('../routers/auth/delete-own-user'))
app.use(require('../routers/auth/configure-2fa'))
app.use(require('../routers/drivers/call'))
app.use(require('../routers/drivers/list-interfaces'))
app.use(require('../routers/drivers/usage'))

View File

@ -55,6 +55,19 @@ class EdgeRateLimitService extends BaseService {
limit: 10,
window: HOUR,
},
['login-otp']: {
limit: 15,
window: 30 * MINUTE,
},
['login-recovery']: {
limit: 10,
window: HOUR,
},
['enable-2fa']: {
limit: 10,
window: HOUR,
}
};
this.requests = new Map();
}

View File

@ -0,0 +1,70 @@
const BaseService = require("../BaseService");
class OTPService extends BaseService {
static MODULES = {
otpauth: require('otpauth'),
crypto: require('crypto'),
['hi-base32']: require('hi-base32'),
}
create_secret (label) {
const require = this.require;
const otpauth = require('otpauth');
const secret = this.gen_otp_secret_();
const totp = new otpauth.TOTP({
issuer: 'puter.com',
label,
algorithm: 'SHA1',
digits: 6,
secret,
});
return {
url: totp.toString(),
secret,
};
}
create_recovery_code () {
const require = this.require;
const crypto = require('crypto');
const { encode } = require('hi-base32');
const buffer = crypto.randomBytes(6);
const code = encode(buffer).replace(/=/g, "").substring(0, 8);
return code;
}
verify (label, secret, code) {
const require = this.require;
const otpauth = require('otpauth');
const totp = new otpauth.TOTP({
issuer: 'puter.com',
label,
algorithm: 'SHA1',
digits: 6,
secret,
});
const allowed = [-1, 0, 1];
const delta = totp.validate({ token: code });
if ( delta === null ) return false;
if ( ! allowed.includes(delta) ) return false;
return true;
}
gen_otp_secret_ () {
const require = this.require;
const crypto = require('crypto');
const { encode } = require('hi-base32');
const buffer = crypto.randomBytes(15);
const base32 = encode(buffer).replace(/=/g, "").substring(0, 24);
return base32;
};
};
module.exports = { OTPService };

View File

@ -104,7 +104,6 @@ class TokenService extends BaseService {
const secret = this.secret;
const context = this.compression[scope];
console.log('original payload', payload)
const compressed_payload = this._compress_payload(context, payload);
return jwt.sign(compressed_payload, secret, options);
@ -119,14 +118,13 @@ class TokenService extends BaseService {
const context = this.compression[scope];
const payload = jwt.verify(token, secret);
console.log('payload', payload)
const decoded = this._decompress_payload(context, payload);
console.log('decoded', decoded);
return decoded;
}
_compress_payload (context, payload) {
if ( ! context ) return payload;
const fullkey_to_info = context.fullkey_to_info;
const compressed = {};
@ -154,6 +152,8 @@ class TokenService extends BaseService {
}
_decompress_payload (context, payload) {
if ( ! context ) return payload;
const fullkey_to_info = context.fullkey_to_info;
const short_to_fullkey = context.short_to_fullkey;

View File

@ -42,7 +42,7 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService {
this.db = new Database(this.config.path);
// Database upgrade logic
const TARGET_VERSION = 5;
const TARGET_VERSION = 6;
if ( do_setup ) {
this.log.noticeme(`SETUP: creating database at ${this.config.path}`);
@ -54,6 +54,7 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService {
'0005_background-apps.sql',
'0006_update-apps.sql',
'0007_sessions.sql',
'0008_otp.sql',
].map(p => path_.join(__dirname, 'sqlite_setup', p));
const fs = require('fs');
for ( const filename of sql_files ) {
@ -90,6 +91,10 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService {
upgrade_files.push('0007_sessions.sql');
}
if ( user_version <= 5 ) {
upgrade_files.push('0008_otp.sql');
}
if ( upgrade_files.length > 0 ) {
this.log.noticeme(`Database out of date: ${this.config.path}`);
this.log.noticeme(`UPGRADING DATABASE: ${user_version} -> ${TARGET_VERSION}`);

View File

@ -0,0 +1,3 @@
ALTER TABLE user ADD COLUMN "otp_secret" TEXT DEFAULT NULL;
ALTER TABLE user ADD COLUMN "otp_enabled" TINYINT(1) DEFAULT '0';
ALTER TABLE user ADD COLUMN "otp_recovery_codes" TEXT DEFAULT NULL;

View File

@ -56,6 +56,8 @@ const generate_puter_page_html = ({
? '/src' : '/dist' ;
// const asset_dir = '/dist';
gui_params.asset_dir = asset_dir;
const bundled = env != 'dev' || use_bundled_gui;
return `<!DOCTYPE html>

View File

@ -0,0 +1,73 @@
import { Component } from "../../util/Component.js";
export default class Button extends Component {
static PROPERTIES = {
label: { value: 'Test Label' },
on_click: { value: null },
enabled: { value: true },
style: { value: 'primary' }
}
static RENDER_MODE = Component.NO_SHADOW;
static CSS = /*css*/`
button {
margin: 0;
color: hsl(220, 25%, 31%);
}
.link-button {
background: none;
border: none;
color: #3b4863;
text-decoration: none;
cursor: pointer;
text-align: center;
display: block;
width: 100%;
}
.link-button:hover {
text-decoration: underline;
}
`;
create_template ({ template }) {
if ( this.get('style') === 'link' ) {
$(template).html(/*html*/`
<button type="submit" class="link-button code-confirm-btn" style="margin-top:10px;" disabled>${
html_encode(this.get('label'))
}</button>
`);
return;
}
$(template).html(/*html*/`
<button type="submit" class="button button-block button-${this.get('style')} code-confirm-btn" style="margin-top:10px;" disabled>${
html_encode(this.get('label'))
}</button>
`);
}
on_ready ({ listen }) {
if ( this.get('on_click') ) {
const $button = $(this.dom_).find('button');
$button.on('click', async () => {
$button.html(`<svg style="width:20px; margin-top: 5px;" xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24"><title>circle anim</title><g fill="#fff" class="nc-icon-wrapper"><g class="nc-loop-circle-24-icon-f"><path d="M12 24a12 12 0 1 1 12-12 12.013 12.013 0 0 1-12 12zm0-22a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2z" fill="#eee" opacity=".4"></path><path d="M24 12h-2A10.011 10.011 0 0 0 12 2V0a12.013 12.013 0 0 1 12 12z" data-color="color-2"></path></g><style>.nc-loop-circle-24-icon-f{--animation-duration:0.5s;transform-origin:12px 12px;animation:nc-loop-circle-anim var(--animation-duration) infinite linear}@keyframes nc-loop-circle-anim{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}</style></g></svg>`);
const on_click = this.get('on_click');
await on_click();
$button.html(this.get('label'));
});
}
listen('enabled', enabled => {
$(this.dom_).find('button').prop('disabled', ! enabled);
});
}
}
// TODO: This is necessary because files can be loaded from
// both `/src/UI` and `/UI` in the URL; we need to fix that
if ( ! window.__component_button ) {
window.__component_button = true;
customElements.define('c-button', Button);
}

View File

@ -0,0 +1,224 @@
import { Component } from "../../util/Component.js";
export default class CodeEntryView extends Component {
static PROPERTIES = {
value: {},
error: {},
is_checking_code: {},
}
static RENDER_MODE = Component.NO_SHADOW;
static CSS = /*css*/`
.wrapper {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #3e5362;
}
fieldset[name=number-code] {
display: flex;
justify-content: space-between;
gap: 5px;
}
.digit-input {
box-sizing: border-box;
flex-grow: 1;
height: 50px;
font-size: 25px;
text-align: center;
border-radius: 0.5rem;
-moz-appearance: textfield;
border: 2px solid #9b9b9b;
color: #485660;
}
.digit-input::-webkit-outer-spin-button,
.digit-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.confirm-code-hyphen {
display: inline-block;
flex-grow: 2;
text-align: center;
font-size: 40px;
font-weight: 300;
}
`
create_template ({ template }) {
// TODO: static member for strings
const submit_btn_txt = i18n('confirm_code_generic_submit');
$(template).html(/*html*/`
<div class="wrapper">
<form>
<div class="error"></div>
<fieldset name="number-code" style="border: none; padding:0;" data-number-code-form>
<input class="digit-input" type="number" min='0' max='9' name='number-code-0' data-number-code-input='0' required />
<input class="digit-input" type="number" min='0' max='9' name='number-code-1' data-number-code-input='1' required />
<input class="digit-input" type="number" min='0' max='9' name='number-code-2' data-number-code-input='2' required />
<span class="confirm-code-hyphen">-</span>
<input class="digit-input" type="number" min='0' max='9' name='number-code-3' data-number-code-input='3' required />
<input class="digit-input" type="number" min='0' max='9' name='number-code-4' data-number-code-input='4' required />
<input class="digit-input" type="number" min='0' max='9' name='number-code-5' data-number-code-input='5' required />
</fieldset>
<button type="submit" class="button button-block button-primary code-confirm-btn" style="margin-top:10px;" disabled>${
submit_btn_txt
}</button>
</form>
</div>
`);
}
on_focus () {
$(this.dom_).find('.digit-input').first().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 the inputs
if ( value === undefined ) {
$(this.dom_).find('.digit-input').val('');
return;
}
})
listen('is_checking_code', (is_checking_code, { old_value }) => {
if ( old_value === is_checking_code ) return;
if ( old_value === undefined ) return;
const $button = $(this.dom_).find('.code-confirm-btn');
if ( is_checking_code ) {
// set animation
$button.prop('disabled', true);
$button.html(`<svg style="width:20px; margin-top: 5px;" xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24"><title>circle anim</title><g fill="#fff" class="nc-icon-wrapper"><g class="nc-loop-circle-24-icon-f"><path d="M12 24a12 12 0 1 1 12-12 12.013 12.013 0 0 1-12 12zm0-22a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2z" fill="#eee" opacity=".4"></path><path d="M24 12h-2A10.011 10.011 0 0 0 12 2V0a12.013 12.013 0 0 1 12 12z" data-color="color-2"></path></g><style>.nc-loop-circle-24-icon-f{--animation-duration:0.5s;transform-origin:12px 12px;animation:nc-loop-circle-anim var(--animation-duration) infinite linear}@keyframes nc-loop-circle-anim{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}</style></g></svg>`);
return;
}
const submit_btn_txt = i18n('confirm_code_generic_try_again');
$button.html(submit_btn_txt);
$button.prop('disabled', false);
});
const that = this;
$(this.dom_).find('.code-confirm-btn').on('click submit', function(e){
e.preventDefault();
e.stopPropagation();
const $button = $(this);
$button.prop('disabled', true);
$button.closest('.error').hide();
that.set('is_checking_code', true);
// force update to trigger the listener
that.set('value', that.get('value'));
})
// Elements
const numberCodeForm = this.dom_.querySelector('[data-number-code-form]');
const numberCodeInputs = [...numberCodeForm.querySelectorAll('[data-number-code-input]')];
// Event listeners
numberCodeForm.addEventListener('input', ({ target }) => {
const inputLength = target.value.length || 0;
let currentIndex = Number(target.dataset.numberCodeInput);
if(inputLength === 2){
const inputValues = target.value.split('');
target.value = inputValues[0];
}
else if (inputLength > 1) {
const inputValues = target.value.split('');
inputValues.forEach((value, valueIndex) => {
const nextValueIndex = currentIndex + valueIndex;
if (nextValueIndex >= numberCodeInputs.length) { return; }
numberCodeInputs[nextValueIndex].value = value;
});
currentIndex += inputValues.length - 2;
}
const nextIndex = currentIndex + 1;
if (nextIndex < numberCodeInputs.length) {
numberCodeInputs[nextIndex].focus();
}
// Concatenate all inputs into one string to create the final code
let current_code = '';
for(let i=0; i< numberCodeInputs.length; i++){
current_code += numberCodeInputs[i].value;
}
const submit_btn_txt = i18n('confirm_code_generic_submit');
$(this.dom_).find('.code-confirm-btn').html(submit_btn_txt);
// Automatically submit if 6 digits entered
if(current_code.length === 6){
$(this.dom_).find('.code-confirm-btn').prop('disabled', false);
this.set('value', current_code);
this.set('is_checking_code', true);
} else {
$(this.dom_).find('.code-confirm-btn').prop('disabled', true);
}
});
numberCodeForm.addEventListener('keydown', (e) => {
const { code, target } = e;
const currentIndex = Number(target.dataset.numberCodeInput);
const previousIndex = currentIndex - 1;
const nextIndex = currentIndex + 1;
const hasPreviousIndex = previousIndex >= 0;
const hasNextIndex = nextIndex <= numberCodeInputs.length - 1
switch (code) {
case 'ArrowLeft':
case 'ArrowUp':
if (hasPreviousIndex) {
numberCodeInputs[previousIndex].focus();
}
e.preventDefault();
break;
case 'ArrowRight':
case 'ArrowDown':
if (hasNextIndex) {
numberCodeInputs[nextIndex].focus();
}
e.preventDefault();
break;
case 'Backspace':
if (!e.target.value.length && hasPreviousIndex) {
numberCodeInputs[previousIndex].value = null;
numberCodeInputs[previousIndex].focus();
}
break;
default:
break;
}
});
}
}
// TODO: This is necessary because files can be loaded from
// both `/src/UI` and `/UI` in the URL; we need to fix that
if ( ! window.__component_codeEntryView ) {
window.__component_codeEntryView = true;
customElements.define('c-code-entry-view', CodeEntryView);
}

View File

@ -0,0 +1,67 @@
import { Component } from "../../util/Component.js";
/**
* Display a list of checkboxes for the user to confirm.
*/
export default class ConfirmationsView extends Component {
static PROPERTIES = {
confirmations: {
description: 'The list of confirmations to display',
},
confirmed: {
description: 'True iff all confirmations are checked',
},
}
static CSS = /*css*/`
.confirmations {
display: flex;
flex-direction: column;
}
.looks-good {
margin-top: 20px;
color: hsl(220, 25%, 31%);
font-size: 20px;
font-weight: 700;
display: none;
}
`
create_template ({ template }) {
$(template).html(/*html*/`
<div class="confirmations">
${
this.get('confirmations').map((confirmation, index) => {
return /*html*/`
<div>
<input type="checkbox" id="confirmation-${index}" name="confirmation-${index}">
<label for="confirmation-${index}">${confirmation}</label>
</div>
`;
}).join('')
}
<span class="looks-good">${i18n('looks_good')}</span>
</div>
`);
}
on_ready ({ listen }) {
// update `confirmed` property when checkboxes are checked
$(this.dom_).find('input').on('change', () => {
this.set('confirmed', $(this.dom_).find('input').toArray().every(input => input.checked));
if ( this.get('confirmed') ) {
$(this.dom_).find('.looks-good').show();
} else {
$(this.dom_).find('.looks-good').hide();
}
});
}
}
// TODO: This is necessary because files can be loaded from
// both `/src/UI` and `/UI` in the URL; we need to fix that
if ( ! window.__component_confirmationsView ) {
window.__component_confirmationsView = true;
customElements.define('c-confirmations-view', ConfirmationsView);
}

View File

@ -0,0 +1,47 @@
import { Component } from "../../util/Component.js";
/**
* Allows a flex layout of composed components to be
* treated as a component.
*/
export default class Flexer extends Component {
static PROPERTIES = {
children: {},
gap: { value: '20pt' },
}
static CSS = `
:host > div {
display: flex;
flex-direction: column;
justify-content: center;
}
`;
create_template ({ template }) {
// TODO: The way we handle loading assets doesn't work well
// with web components, so for now it goes in the template.
$(template).html(`
<div><slot name="inside"></slot></div>
`);
}
on_ready ({ listen }) {
for ( const child of this.get('children') ) {
child.setAttribute('slot', 'inside');
child.attach(this);
}
listen('gap', gap => {
$(this.dom_).find('div').first().css('gap', gap);
});
}
}
// TODO: This is necessary because files can be loaded from
// both `/src/UI` and `/UI` in the URL; we need to fix that
if ( ! window.__component_flexer ) {
window.__component_flexer = true;
customElements.define('c-flexer', Flexer);
}

View File

@ -0,0 +1,24 @@
import { Component } from "../../util/Component.js";
/**
* Allows using an HTML string as a component.
*/
export default class JustHTML extends Component {
static PROPERTIES = { html: { value: '' } };
create_template ({ template }) {
$(template).html(`<span></span>`);
}
on_ready ({ listen }) {
listen('html', html => {
$(this.dom_).find('span').html(html);
});
}
}
// TODO: This is necessary because files can be loaded from
// both `/src/UI` and `/UI` in the URL; we need to fix that
if ( ! window.__component_justHTML ) {
window.__component_justHTML = true;
customElements.define('c-just-html', JustHTML);
}

View File

@ -0,0 +1,87 @@
import { Component } from "../../util/Component.js";
import UIComponentWindow from "../UIComponentWindow.js";
export default class QRCodeView extends Component {
static PROPERTIES = {
value: {
description: 'The text to encode in the QR code',
},
size: {
value: 150,
},
enlarge_option: {
value: true,
}
}
static CSS = /*css*/`
.qr-code {
width: 100%;
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
}
.qr-code img {
margin-bottom: 20px;
}
.has-enlarge-option {
cursor: -moz-zoom-in;
cursor: -webkit-zoom-in;
cursor: zoom-in
}
`
create_template ({ template }) {
$(template).html(`
<div class="qr-code opt-qr-code">
</div>
`);
}
on_ready ({ listen }) {
listen('value', value => {
// $(this.dom_).find('.qr-code').empty();
new QRCode($(this.dom_).find('.qr-code').get(0), {
text: value,
// TODO: dynamic size
width: this.get('size'),
height: this.get('size'),
currectLevel: QRCode.CorrectLevel.H,
});
if ( this.get('enlarge_option') ) {
$(this.dom_).find('.qr-code img').addClass('has-enlarge-option');
$(this.dom_).find('.qr-code img').on('click', async () => {
UIComponentWindow({
component: new QRCodeView({
value: value,
size: 400,
enlarge_option: false,
}),
title: i18n('enlarged_qr_code'),
backdrop: true,
dominant: true,
width: 550,
height: 'auto',
body_css: {
width: 'initial',
height: '100%',
'background-color': 'rgb(245 247 249)',
'backdrop-filter': 'blur(3px)',
padding: '20px',
},
})
});
}
});
}
}
// TODO: This is necessary because files can be loaded from
// both `/src/UI` and `/UI` in the URL; we need to fix that
if ( ! window.__component_qr_code ) {
window.__component_qr_code = true;
customElements.define('c-qr-code', QRCodeView);
}

View File

@ -0,0 +1,87 @@
import { Component } from "../../util/Component.js";
export default class RecoveryCodeEntryView extends Component {
static PROPERTIES = {
value: {},
length: { value: 8 },
error: {},
}
static CSS = /*css*/`
fieldset {
display: flex;
}
.recovery-code-input {
flex-grow: 1;
box-sizing: border-box;
height: 50px;
font-size: 25px;
text-align: center;
border-radius: 0.5rem;
font-family: 'Courier New', Courier, monospace;
}
/* 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;
}
`;
create_template ({ template }) {
$(template).html(/*html*/`
<div class="recovery-code-entry">
<form>
<div class="error"></div>
<fieldset name="recovery-code" style="border: none; padding:0;" data-recovery-code-form>
<input type="text" class="recovery-code-input" placeholder="${i18n('login2fa_recovery_placeholder')}" maxlength="${this.get('length')}" required>
</fieldset>
</form>
</div>
`);
}
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', () => {
if ( input.val().length === this.get('length') ) {
this.set('value', input.val());
}
});
}
}
customElements.define('c-recovery-code-entry', RecoveryCodeEntryView);

View File

@ -0,0 +1,100 @@
import { Component } from "../../util/Component.js";
export default class RecoveryCodesView extends Component {
static PROPERTIES = {
values: {
description: 'The recovery codes to display',
}
}
static CSS = /*css*/`
.recovery-codes {
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid #ccc;
padding: 20px;
margin: 20px auto;
width: 90%;
max-width: 600px;
background-color: #f9f9f9;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.recovery-codes h2 {
text-align: center;
font-size: 18px;
color: #333;
margin-bottom: 15px;
}
.recovery-codes-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 10px; /* Adds space between grid items */
padding: 0;
}
.recovery-code {
background-color: #fff;
border: 1px solid #ddd;
padding: 10px;
text-align: center;
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
letter-spacing: 1px;
}
.actions {
flex-direction: row-reverse;
display: flex;
gap: 10px;
}
`
create_template ({ template }) {
$(template).html(`
<iframe name="print_frame" width="0" height="0" frameborder="0" src="about:blank"></iframe>
<div class="recovery-codes">
<div class="recovery-codes-list">
</div>
<div class="actions">
<button class="button" data-action="copy">${i18n('copy')}</button>
<button class="button" data-action="print">${i18n('print')}</button>
</div>
</div>
`);
}
on_ready ({ listen }) {
listen('values', values => {
for ( const value of values ) {
$(this.dom_).find('.recovery-codes-list').append(`
<div class="recovery-code">${html_encode(value)}</div>
`);
}
});
$(this.dom_).find('[data-action="copy"]').on('click', () => {
const codes = this.get('values').join('\n');
navigator.clipboard.writeText(codes);
});
$(this.dom_).find('[data-action="print"]').on('click', () => {
const target = $(this.dom_).find('.recovery-codes-list')[0];
const print_frame = $(this.dom_).find('iframe[name="print_frame"]')[0];
print_frame.contentWindow.document.body.innerHTML = target.outerHTML;
print_frame.contentWindow.window.focus();
print_frame.contentWindow.window.print();
});
}
}
// TODO: This is necessary because files can be loaded from
// both `/src/UI` and `/UI` in the URL; we need to fix that
if ( ! window.__component_recoveryCodesView ) {
window.__component_recoveryCodesView = true;
customElements.define('c-recovery-codes-view', RecoveryCodesView);
}

View File

@ -0,0 +1,67 @@
import { Component } from "../../util/Component.js";
/**
* StepHeading renders a heading with a leading symbol.
* The leading symbol is styled inside a cricle and is
* optimized for single-digit numbers.
*/
export default class StepHeading extends Component {
static PROPERTIES = {
symbol: {
description: 'The symbol to display',
value: '1',
},
text: {
description: 'The heading to display',
value: 'Heading',
},
}
static CSS = /*css*/`
.heading {
display: flex;
align-items: center;
}
.circle {
display: flex;
justify-content: center;
align-items: center;
width: 25px;
height: 25px;
border-radius: 50%;
background-color: #3e5362;
color: #FFFFFF;
font-size: 15px;
font-weight: 700;
}
.text {
margin-left: 10px;
font-size: 18px;
color: hsl(220, 25%, 31%);
font-weight: 500;
}
`
create_template ({ template }) {
$(template).html(/*html*/`
<div class="heading">
<div class="circle">
${html_encode(this.get('symbol'))}
</div>
<div class="text">
${html_encode(this.get('text'))}
</div>
</div>
`);
}
}
// TODO: This is necessary because files can be loaded from
// both `/src/UI` and `/UI` in the URL; we need to fix that
if ( ! window.__component_stepHeading ) {
window.__component_stepHeading = true;
customElements.define('c-step-heading', StepHeading);
}

View File

@ -0,0 +1,73 @@
import { Component } from "../../util/Component.js";
export default class StepView extends Component {
static PROPERTIES = {
children: {},
done: { value: false },
position: { value: 0 },
}
static CSS = `
#wrapper { display: none }
* { -webkit-font-smoothing: antialiased;}
`;
create_template ({ template }) {
$(template).html(`
<div id="wrapper">
<slot name="inside"></slot>
</div>
`);
}
on_focus () {
this.children[this.get('position')].focus();
}
on_ready ({ listen }) {
for ( const child of this.get('children') ) {
child.setAttribute('slot', 'inside');
child.attach(this);
$(child).hide();
}
// show the first child
$(this.children[0]).show();
// listen for changes to the current step
listen('position', position => {
// hide all children
for ( const child of this.children ) {
$(child).hide();
}
// show the child at the current position
$(this.children[position]).show();
this.children[position].focus();
});
// now that we're ready, show the wrapper
$(this.dom_).find('#wrapper').show();
}
back () {
if ( this.get('position') === 0 ) return;
this.set('position', this.get('position') - 1);
}
next () {
if ( this.get('position') === this.children.length - 1 ) {
this.set('done', true);
return;
}
this.set('position', this.get('position') + 1);
}
}
// TODO: This is necessary because files can be loaded from
// both `/src/UI` and `/UI` in the URL; we need to fix that
if ( ! window.__component_stepView ) {
window.__component_stepView = true;
customElements.define('c-step-view', StepView);
}

View File

@ -0,0 +1,51 @@
import { Component } from "../../util/Component.js";
/**
* A simple component that displays a string in the
* specified style.
*/
export default class StringView extends Component {
static PROPERTIES = {
text: { value: '' },
heading: { value: 0 },
no_html_encode: { value: false },
}
static CSS = /*css*/`
h2 {
margin: 0;
color: hsl(220, 25%, 31%);
}
span {
color: #3b4863;
}
`;
create_template ({ template }) {
$(template).html(`<span></span>`);
}
on_ready ({ listen }) {
// TODO: listener composition, to avoid this
const either = ({ heading, text }) => {
const wrapper_nodeName = heading ? 'h' + heading : 'span';
$(this.dom_).find('span').html(`<${wrapper_nodeName}>${
this.get('no_html_encode') ? text : html_encode(text)
}</${wrapper_nodeName}>`);
};
listen('heading', heading => {
either({ heading, text: this.get('text') });
});
listen('text', text => {
either({ heading: this.get('heading'), text });
});
}
}
// TODO: This is necessary because files can be loaded from
// both `/src/UI` and `/UI` in the URL; we need to fix that
if ( ! window.__component_stringView ) {
window.__component_stringView = true;
customElements.define('c-string-view', StringView);
}

View File

@ -0,0 +1,28 @@
import { Component } from "../../util/Component.js";
/**
* A simple component when you just need to test something.
*/
export default class TestView extends Component {
static CSS = `
div {
background-color: lightblue;
padding: 1em;
border-radius: 0.5em;
}
`;
create_template ({ template }) {
$(template).html(`
<div>I am a test view</div>
`);
}
}
// TODO: This is necessary because files can be loaded from
// both `/src/UI` and `/UI` in the URL; we need to fix that
if ( ! window.__component_testView ) {
window.__component_testView = true;
customElements.define('c-test-view', TestView);
}

View File

@ -64,14 +64,6 @@ export default {
h += `</div>`;
}
// session manager
h += `<div class="settings-card">`;
h += `<strong>${i18n('sessions')}</strong>`;
h += `<div style="flex-grow:1;">`;
h += `<button class="button manage-sessions" style="float:right;">${i18n('manage_sessions')}</button>`;
h += `</div>`;
h += `</div>`;
// 'Delete Account' button
h += `<div class="settings-card settings-card-danger">`;
h += `<strong style="display: inline-block;">${i18n("delete_account")}</strong>`;

View File

@ -0,0 +1,103 @@
import UIAlert from "../UIAlert.js";
import UIWindow2FASetup from "../UIWindow2FASetup.js";
import UIWindowQR from "../UIWindowQR.js";
export default {
id: 'security',
title_i18n_key: 'security',
icon: 'shield.svg',
html: () => {
let h = `<h1>${i18n('security')}</h1>`;
// change password button
if(!user.is_temp){
h += `<div class="settings-card">`;
h += `<strong>${i18n('password')}</strong>`;
h += `<div style="flex-grow:1;">`;
h += `<button class="button change-password" style="float:right;">${i18n('change_password')}</button>`;
h += `</div>`;
h += `</div>`;
}
// session manager
h += `<div class="settings-card">`;
h += `<strong>${i18n('sessions')}</strong>`;
h += `<div style="flex-grow:1;">`;
h += `<button class="button manage-sessions" style="float:right;">${i18n('manage_sessions')}</button>`;
h += `</div>`;
h += `</div>`;
// configure 2FA
if(!user.is_temp){
h += `<div class="settings-card settings-card-security ${user.otp ? 'settings-card-success' : 'settings-card-warning'}">`;
h += `<div>`;
h += `<strong style="display:block;">${i18n('two_factor')}</strong>`;
h += `<span class="user-otp-state" style="display:block; margin-top:5px;">${
i18n(user.otp ? 'two_factor_enabled' : 'two_factor_disabled')
}</span>`;
h += `</div>`;
h += `<div style="flex-grow:1;">`;
h += `<button class="button enable-2fa" style="float:right;${user.otp ? 'display:none;' : ''}">${i18n('enable_2fa')}</button>`;
h += `<button class="button disable-2fa" style="float:right;${user.otp ? '' : 'display:none;'}">${i18n('disable_2fa')}</button>`;
h += `</div>`;
h += `</div>`;
}
return h;
},
init: ($el_window) => {
$el_window.find('.enable-2fa').on('click', async function (e) {
const { promise } = await UIWindow2FASetup();
const tfa_was_enabled = await promise;
if ( tfa_was_enabled ) {
$el_window.find('.enable-2fa').hide();
$el_window.find('.disable-2fa').show();
$el_window.find('.user-otp-state').text(i18n('two_factor_enabled'));
$el_window.find('.settings-card-security').removeClass('settings-card-warning');
$el_window.find('.settings-card-security').addClass('settings-card-success');
}
return;
});
$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(`${api_origin}/auth/configure-2fa/disable`, {
method: 'POST',
headers: {
Authorization: `Bearer ${puter.authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
$el_window.find('.enable-2fa').show();
$el_window.find('.disable-2fa').hide();
$el_window.find('.user-otp-state').text(i18n('two_factor_disabled'));
$el_window.find('.settings-card-security').removeClass('settings-card-success');
$el_window.find('.settings-card-security').addClass('settings-card-warning');
});
}
}

View File

@ -21,6 +21,7 @@ import UIWindow from '../UIWindow.js'
import AboutTab from './UITabAbout.js';
import UsageTab from './UITabUsage.js';
import AccountTab from './UITabAccount.js';
import SecurityTab from './UITabSecurity.js';
import PersonalizationTab from './UITabPersonalization.js';
import LanguageTab from './UITabLanguage.js';
import ClockTab from './UITabClock.js';
@ -33,6 +34,7 @@ async function UIWindowSettings(options){
AboutTab,
UsageTab,
AccountTab,
SecurityTab,
PersonalizationTab,
LanguageTab,
ClockTab,

View File

@ -0,0 +1,26 @@
import UIWindow from './UIWindow.js'
import Placeholder from "../util/Placeholder.js"
/**
* @typedef {Object} UIComponentWindowOptions
* @property {Component} A component to render in the window
*/
/**
* Render a UIWindow that contains an instance of Component
* @param {UIComponentWindowOptions} options
*/
export default async function UIComponentWindow (options) {
const placeholder = Placeholder();
const win = await UIWindow({
...options,
body_content: placeholder.html,
});
options.component.attach(placeholder);
options.component.focus();
return win;
}

View File

@ -477,6 +477,9 @@ async function UIWindow(options) {
const el_openfiledialog_open_btn = document.querySelector(`#window-${win_id} .openfiledialog-open-btn`);
const el_directorypicker_select_btn = document.querySelector(`#window-${win_id} .directorypicker-select-btn`);
// attach optional event listeners
el_window.on_before_exit = options.on_before_exit;
// disable menubar by default
$(el_window).find('.window-menubar').hide();
@ -2813,6 +2816,10 @@ $.fn.close = async function(options) {
}
}
if ( this.on_before_exit ) {
if ( ! await this.on_before_exit() ) return false;
}
// Process window close if this is a window
if($(this).hasClass('window')){
const win_id = parseInt($(this).attr('data-id'));

224
src/UI/UIWindow2FASetup.js Normal file
View File

@ -0,0 +1,224 @@
/*
Plan:
Components: OneAtATimeView < ... >
Screen 1: QR code and entry box for testing
Components: Flexer < QRCodeView, CodeEntryView, ActionsView >
Logic:
- when CodeEntryView has a value, check it against the QR code value...
... then go to the next screen
- CodeEntryView will have callbacks: `verify`, `on_verified`
- cancel action
Screen 2: Recovery codes
Components: Flexer < RecoveryCodesView, ConfirmationsView, ActionsView >
Logic:
- done action
- cancel action
- when done action is clicked, call /auth/configure-2fa/enable
*/
import TeePromise from "../util/TeePromise.js";
import ValueHolder from "../util/ValueHolder.js";
import Button from "./Components/Button.js";
import CodeEntryView from "./Components/CodeEntryView.js";
import ConfirmationsView from "./Components/ConfirmationsView.js";
import Flexer from "./Components/Flexer.js";
import QRCodeView from "./Components/QRCode.js";
import RecoveryCodesView from "./Components/RecoveryCodesView.js";
import StepHeading from "./Components/StepHeading.js";
import StepView from "./Components/StepView.js";
import StringView from "./Components/StringView.js";
import TestView from "./Components/TestView.js";
import UIAlert from "./UIAlert.js";
import UIComponentWindow from "./UIComponentWindow.js";
const UIWindow2FASetup = async function UIWindow2FASetup () {
// FIRST REQUEST :: Generate the QR code and recovery codes
const resp = await fetch(`${api_origin}/auth/configure-2fa/setup`, {
method: 'POST',
headers: {
Authorization: `Bearer ${puter.authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
const data = await resp.json();
// SECOND REQUEST :: Verify the code [first wizard screen]
const check_code_ = async function check_code_ (value) {
const resp = await fetch(`${api_origin}/auth/configure-2fa/test`, {
method: 'POST',
headers: {
Authorization: `Bearer ${puter.authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
code: value,
}),
});
const data = await resp.json();
return data.ok;
};
// FINAL REQUEST :: Enable 2FA [second wizard screen]
const enable_2fa_ = async function check_code_ (value) {
const resp = await fetch(`${api_origin}/auth/configure-2fa/enable`, {
method: 'POST',
headers: {
Authorization: `Bearer ${puter.authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
const data = await resp.json();
return data.ok;
};
let stepper;
let code_entry;
let win;
let done_enabled = new ValueHolder(false);
const promise = new TeePromise();
const component =
new StepView({
_ref: me => stepper = me,
children: [
new Flexer({
children: [
new StepHeading({
symbol: '1',
text: i18n('setup2fa_1_step_heading'),
}),
new StringView({
text: i18n('setup2fa_1_instructions', [], false),
no_html_encode: true,
}),
new StepHeading({
symbol: '2',
text: i18n('setup2fa_2_step_heading')
}),
new QRCodeView({
value: data.url,
}),
new StepHeading({
symbol: '3',
text: i18n('setup2fa_3_step_heading')
}),
new CodeEntryView({
_ref: me => code_entry = me,
async [`property.value`] (value, { component }) {
if ( ! await check_code_(value) ) {
component.set('error', 'Invalid code');
component.set('is_checking_code', false);
return;
}
component.set('is_checking_code', false);
stepper.next();
}
}),
],
['event.focus'] () {
code_entry.focus();
}
}),
new Flexer({
children: [
new StepHeading({
symbol: '4',
text: i18n('setup2fa_4_step_heading')
}),
new StringView({
text: i18n('setup2fa_4_instructions', [], false)
}),
new RecoveryCodesView({
values: data.codes,
}),
new StepHeading({
symbol: '5',
text: i18n('setup2fa_5_step_heading')
}),
new ConfirmationsView({
confirmations: [
i18n('setup2fa_5_confirmation_1'),
i18n('setup2fa_5_confirmation_2'),
],
confirmed: done_enabled,
}),
new Button({
enabled: done_enabled,
label: i18n('setup2fa_5_button'),
on_click: async () => {
await enable_2fa_();
stepper.next();
},
}),
]
}),
]
})
;
stepper.values_['done'].sub(value => {
if ( ! value ) return;
$(win).close();
// Write "2FA enabled" in green in the console
console.log('%c2FA enabled', 'color: green');
promise.resolve(true);
})
win = await UIComponentWindow({
component,
on_before_exit: async () => {
if ( ! stepper.get('done') ) {
promise.resolve(false);
}
return true
},
title: '2FA Setup',
app: 'instant-login',
single_instance: true,
icon: null,
uid: null,
is_dir: false,
// has_head: false,
selectable_body: true,
// selectable_body: false,
allow_context_menu: false,
is_resizable: false,
is_droppable: false,
init_center: true,
allow_native_ctxmenu: false,
allow_user_select: true,
// backdrop: true,
width: 550,
height: 'auto',
dominant: true,
show_in_taskbar: false,
draggable_body: false,
center: true,
onAppend: function(this_window){
},
window_class: 'window-qr',
body_css: {
width: 'initial',
height: '100%',
'background-color': 'rgb(245 247 249)',
'backdrop-filter': 'blur(3px)',
padding: '20px',
},
});
return { promise };
}
export default UIWindow2FASetup;

View File

@ -28,7 +28,7 @@ async function UIWindowDownloadDirProg(options){
h += `<p style="text-align:left; padding-left:20px; padding-right:20px; overflow:hidden; width: 310px; text-overflow: ellipsis; white-space: nowrap; float:left; font-size:14px;" class="dir-dl-status">${options.defaultText ?? i18n('preparing')}</p>`;
const el_window = await UIWindow({
title: 'Instant Login!',
title: 'Download Directory Progress',
app: 'instant-login',
single_instance: true,
icon: null,

View File

@ -26,7 +26,8 @@ function UIWindowEmailConfirmationRequired(options){
let final_code = '';
let is_checking_code = false;
const submit_btn_txt = 'Confirm Email'
const submit_btn_txt = 'Confirm Email';
let h = '';
h += `<div class="qr-code-window-close-btn generic-close-window-button"> &times; </div>`;
h += `<div style="-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; color: #3e5362;">`;

View File

@ -20,6 +20,16 @@
import UIWindow from './UIWindow.js'
import UIWindowSignup from './UIWindowSignup.js'
import UIWindowRecoverPassword from './UIWindowRecoverPassword.js'
import TeePromise from '../util/TeePromise.js';
import UIAlert from './UIAlert.js';
import UIComponentWindow from './UIComponentWindow.js';
import Flexer from './Components/Flexer.js';
import CodeEntryView from './Components/CodeEntryView.js';
import JustHTML from './Components/JustHTML.js';
import StepView from './Components/StepView.js';
import TestView from './Components/TestView.js';
import Button from './Components/Button.js';
import RecoveryCodeEntryView from './Components/RecoveryCodeEntryView.js';
async function UIWindowLogin(options){
options = options ?? {};
@ -163,7 +173,168 @@ async function UIWindowLogin(options){
headers: headers,
contentType: "application/json",
data: data,
success: function (data){
success: async function (data){
let p = Promise.resolve();
if ( data.next_step === 'otp' ) {
p = new TeePromise();
let code_entry;
let recovery_entry;
let win;
let stepper;
const otp_option = new Flexer({
children: [
new JustHTML({
html: /*html*/`
<h3 style="text-align:center; font-weight: 500; font-size: 20px;">${
i18n('login2fa_otp_title')
}</h3>
<p style="text-align:center; padding: 0 20px;">${
i18n('login2fa_otp_instructions')
}</p>
`
}),
new CodeEntryView({
_ref: me => code_entry = me,
async [`property.value`] (value, { component }) {
let error_i18n_key = 'something_went_wrong';
if ( ! value ) return;
try {
const resp = await fetch(`${api_origin}/login/otp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: data.otp_jwt_token,
code: value,
}),
});
if ( resp.status === 429 ) {
error_i18n_key = 'confirm_code_generic_too_many_requests';
throw new Error('expected error');
}
const next_data = await resp.json();
if ( ! next_data.proceed ) {
error_i18n_key = 'confirm_code_generic_incorrect';
throw new Error('expected error');
}
component.set('is_checking_code', false);
data = next_data;
$(win).close();
p.resolve();
} catch (e) {
// keeping this log; useful in screenshots
console.log('2FA Login Error', e);
component.set('error', i18n(error_i18n_key));
component.set('is_checking_code', false);
}
}
}),
new Button({
label: i18n('login2fa_use_recovery_code'),
style: 'link',
on_click: async () => {
stepper.next();
code_entry.set('value', undefined);
code_entry.set('error', undefined);
}
})
],
['event.focus'] () {
code_entry.focus();
}
});
const recovery_option = new Flexer({
children: [
new JustHTML({
html: /*html*/`
<h3 style="text-align:center; font-weight: 500; font-size: 20px;">${
i18n('login2fa_recovery_title')
}</h3>
<p style="text-align:center; padding: 0 20px;">${
i18n('login2fa_recovery_instructions')
}</p>
`
}),
new RecoveryCodeEntryView({
_ref: me => recovery_entry = me,
async [`property.value`] (value, { component }) {
let error_i18n_key = 'something_went_wrong';
if ( ! value ) return;
try {
const resp = await fetch(`${api_origin}/login/recovery-code`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: data.otp_jwt_token,
code: value,
}),
});
if ( resp.status === 429 ) {
error_i18n_key = 'confirm_code_generic_too_many_requests';
throw new Error('expected error');
}
const next_data = await resp.json();
if ( ! next_data.proceed ) {
error_i18n_key = 'confirm_code_generic_incorrect';
throw new Error('expected error');
}
data = next_data;
$(win).close();
p.resolve();
} catch (e) {
// keeping this log; useful in screenshots
console.log('2FA Recovery Error', e);
component.set('error', i18n(error_i18n_key));
}
}
}),
new Button({
label: i18n('login2fa_recovery_back'),
style: 'link',
on_click: async () => {
stepper.back();
recovery_entry.set('value', undefined);
recovery_entry.set('error', undefined);
}
})
]
});
const component = stepper = new StepView({
children: [otp_option, recovery_option],
});
win = await UIComponentWindow({
component,
width: 500,
height: 410,
backdrop: true,
is_resizable: false,
body_css: {
width: 'initial',
height: '100%',
'background-color': 'rgb(245 247 249)',
'backdrop-filter': 'blur(3px)',
padding: '20px',
},
});
component.focus();
}
await p;
window.update_auth_data(data.token, data.user);
if(options.reload_on_success){
@ -234,4 +405,4 @@ async function UIWindowLogin(options){
})
}
export default UIWindowLogin
export default UIWindowLogin

View File

@ -35,7 +35,7 @@ async function UIWindowLoginInProgress(options){
h += `</div>`;
const el_window = await UIWindow({
title: 'Instant Login!',
title: 'Authenticating...',
app: 'change-passowrd',
single_instance: true,
icon: null,

View File

@ -17,64 +17,85 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Placeholder from '../util/Placeholder.js';
import TeePromise from '../util/TeePromise.js';
import Flexer from './Components/Flexer.js';
import QRCodeView from './Components/QRCode.js';
import UIWindow from './UIWindow.js'
let checkbox_id_ = 0;
async function UIWindowQR(options){
return new Promise(async (resolve) => {
options = options ?? {};
const confirmations = options.confirmations || [];
let h = '';
// close button containing the multiplication sign
h += `<div class="qr-code-window-close-btn generic-close-window-button"> &times; </div>`;
h += `<div class="otp-qr-code">`;
h += `<h1 style="text-align: center; font-size: 16px; padding: 10px; font-weight: 400; margin: -10px 10px 20px 10px; -webkit-font-smoothing: antialiased; color: #5f626d;">${
i18n(options.message_i18n_key || 'scan_qr_generic')
}</h1>`;
h += `</div>`;
options = options ?? {};
const el_window = await UIWindow({
title: 'Instant Login!',
app: 'instant-login',
single_instance: true,
icon: null,
uid: null,
is_dir: false,
body_content: h,
has_head: false,
selectable_body: false,
allow_context_menu: false,
is_resizable: false,
is_droppable: false,
init_center: true,
allow_native_ctxmenu: false,
allow_user_select: false,
backdrop: true,
width: 550,
height: 'auto',
dominant: true,
show_in_taskbar: false,
draggable_body: true,
onAppend: function(this_window){
},
window_class: 'window-qr',
body_css: {
width: 'initial',
height: '100%',
'background-color': 'rgb(245 247 249)',
'backdrop-filter': 'blur(3px)',
}
})
const placeholder_qr = Placeholder();
// generate auth token QR code
new QRCode($(el_window).find('.otp-qr-code').get(0), {
text: options.text,
width: 455,
height: 455,
colorDark : "#000000",
colorLight : "#ffffff",
correctLevel : QRCode.CorrectLevel.H
});
let h = '';
// close button containing the multiplication sign
h += `<div class="qr-code-window-close-btn generic-close-window-button"> &times; </div>`;
h += `<div class="otp-qr-code">`;
h += `<h1 style="text-align: center; font-size: 16px; padding: 10px; font-weight: 400; margin: -10px 10px 20px 10px; -webkit-font-smoothing: antialiased; color: #5f626d;">${
i18n(options.message_i18n_key || 'scan_qr_generic')
}</h1>`;
h += `</div>`;
h += placeholder_qr.html;
const el_window = await UIWindow({
title: 'Instant Login!',
app: 'instant-login',
single_instance: true,
icon: null,
uid: null,
is_dir: false,
body_content: h,
has_head: false,
selectable_body: false,
allow_context_menu: false,
is_resizable: false,
is_droppable: false,
init_center: true,
allow_native_ctxmenu: false,
allow_user_select: false,
backdrop: true,
width: 450,
height: 'auto',
dominant: true,
show_in_taskbar: false,
draggable_body: true,
onAppend: function(this_window){
},
window_class: 'window-qr',
body_css: {
width: 'initial',
height: '100%',
'background-color': 'rgb(245 247 249)',
'backdrop-filter': 'blur(3px)',
padding: '50px 20px',
},
})
const component_qr = new QRCodeView({
value: options.text,
size: 250,
});
const component_flexer = new Flexer({
children: [
component_qr,
]
});
// component_qr.attach(placeholder_qr);
component_flexer.attach(placeholder_qr);
// placeholder_qr.replaceWith($(`<h1>test</h1>`).get(0));
$(el_window).find('.qr-code-checkbox input').on('change', () => {
const all_checked = $(el_window).find('.qr-code-checkbox input').toArray().every(el => el.checked);
$(el_window).find('.code-confirm-btn').prop('disabled', !all_checked);
});
}
export default UIWindowQR

View File

@ -1946,9 +1946,21 @@ label {
cursor: pointer;
}
.send-conf-code {
cursor: pointer;
}
.email-confirm-code-hyphen {
display: inline-block;
width: 14%;
flex-grow: 1;
text-align: center;
font-size: 40px;
font-weight: 300;
}
.confirm-code-hyphen {
display: inline-block;
flex-grow: 1;
text-align: center;
font-size: 40px;
font-weight: 300;
@ -1958,6 +1970,10 @@ label {
text-decoration: underline;
}
.send-conf-code:hover {
text-decoration: underline;
}
.remove-permission-link, .disassociate-website-link {
cursor: pointer;
color: red;
@ -2509,9 +2525,17 @@ label {
}
/* UIWindowEmailConfirmationRequired */
fieldset[name=number-code] {
min-width: 0; /* Fix for Firefox */
display: flex;
justify-content: space-between;
gap: 5px;
}
.digit-input {
min-width: 0; /* Fix for Firefox */
box-sizing: border-box;
width: 12.89%;
flex-grow: 1;
height: 50px;
font-size: 25px;
text-align: center;
@ -2590,11 +2614,25 @@ label {
justify-content: center;
flex-direction: column;
align-items: center;
height: 520px;
}
.otp-qr-code img {
width: 355px;
margin-bottom: 20px;
}
.otp-as-text {
margin: 20px 0;
}
.qr-code-checkbox {
display: flex;
gap: 10px;
align-items: center;
}
.qr-code-checkbox input[type=checkbox] {
margin: 0;
}
.perm-title {
@ -3687,6 +3725,17 @@ label {
background: #ffecec;
color: rgb(215 2 2);
}
.settings-card-success{
border-color: #08bf4e;
background: #e6ffed;
color: #03933a;
}
.settings-card-warning {
border-color: #f0a500;
background: #fff7e6;
color: #c98900;
}
.error-message {
display: none;

View File

@ -44,7 +44,11 @@ window.i18n = function (key, replacements = [], encode_html = true) {
str = key;
}
str = ReplacePlaceholders(str);
str = encode_html ? html_encode(str) : str;
if ( encode_html ) {
str = html_encode(str);
// html_encode doesn't render line breaks
str = str.replace(/\n/g, '<br />');
}
// replace %% occurrences with the values in replacements
// %% is for simple text replacements
// %strong% is for <strong> tags

View File

@ -46,7 +46,17 @@ const en = {
change_always_open_with: "Do you want to always open this type of file with",
color: 'Color',
hue: 'Hue',
confirm_2fa_setup: 'I have added the code to my authenticator app',
confirm_2fa_recovery: 'I have saved my recovery codes in a secure location',
confirm_account_for_free_referral_storage_c2a: 'Create an account and confirm your email address to receive 1 GB of free storage. Your friend will get 1 GB of free storage too.',
confirm_code_generic_incorrect: "Incorrect Code.",
confirm_code_generic_too_many_requests: "Too many requests. Please wait a few minutes.",
confirm_code_generic_submit: "Submit Code",
confirm_code_generic_try_again: "Try Again",
confirm_code_generic_title: "Enter Confirmation Code",
confirm_code_2fa_instruction: "Enter the 6-digit code from your authenticator app.",
confirm_code_2fa_submit_btn: "Submit",
confirm_code_2fa_title: "Enter 2FA Code",
confirm_delete_multiple_items: 'Are you sure you want to permanently delete these items?',
confirm_delete_single_item: 'Do you want to permanently delete this item?',
confirm_open_apps_log_out: 'You have open apps. Are you sure you want to log out?',
@ -81,6 +91,8 @@ const en = {
desktop_background_fit: "Fit",
developers: "Developers",
dir_published_as_website: `%strong% has been published to:`,
disable_2fa: 'Disable 2FA',
disable_2fa_confirm: "Are you sure you want to disable 2FA?",
disassociate_dir: "Disassociate Directory",
download: 'Download',
download_file: 'Download File',
@ -93,10 +105,13 @@ const en = {
empty_trash: 'Empty Trash',
empty_trash_confirmation: `Are you sure you want to permanently delete the items in Trash?`,
emptying_trash: 'Emptying Trash…',
enable_2fa: 'Enable 2FA',
end_hard: "End Hard",
end_process_force_confirm: "Are you sure you want to force-quit this process?",
end_soft: "End Soft",
enlarged_qr_code: "Enlarged QR Code",
enter_password_to_confirm_delete_user: "Enter your password to confirm account deletion",
error_unknown_cause: "An unknown error occurred.",
feedback: "Feedback",
feedback_c2a: "Please use the form below to send us your feedback, comments, and bug reports.",
feedback_sent_confirmation: "Thank you for contacting us. If you have an email associated with your account, you will hear back from us as soon as possible.",
@ -124,6 +139,7 @@ const en = {
log_in: "Log In",
log_into_another_account_anyway: 'Log into another account anyway',
log_out: 'Log Out',
looks_good: "Looks good!",
manage_sessions: "Manage Sessions",
move: 'Move',
moving: "Moving",
@ -166,6 +182,7 @@ const en = {
powered_by_puter_js: `Powered by {{link=docs}}Puter.js{{/link}}`,
preparing: "Preparing...",
preparing_for_upload: "Preparing for upload...",
print: 'Print',
privacy: "Privacy",
proceed_to_login: 'Proceed to login',
proceed_with_account_deletion: "Proceed with Account Deletion",
@ -201,9 +218,11 @@ const en = {
save_account_to_publish: 'Please create an account to proceed.',
save_session: 'Save session',
save_session_c2a: 'Create an account to save your current session and avoid losing your work.',
scan_qr_c2a: 'Scan the code below to log into this session from other devices',
scan_qr_c2a: 'Scan the code below\nto log into this session from other devices',
scan_qr_2fa: 'Scan the QR code with your authenticator app',
scan_qr_generic: 'Scan this QR code using your phone or another device',
seconds: 'seconds',
security: "Security",
select: "Select",
selected: 'selected',
select_color: 'Select color…',
@ -221,6 +240,7 @@ const en = {
signing_in: "Signing in…",
size: 'Size',
skip: 'Skip',
something_went_wrong: "Something went wrong.",
sort_by: 'Sort by',
start: 'Start',
status: "Status",
@ -236,6 +256,9 @@ const en = {
tos_fineprint: `By clicking 'Create Free Account' you agree to Puter's {{link=terms}}Terms of Service{{/link}} and {{link=privacy}}Privacy Policy{{/link}}.`,
transparency: "Transparency",
trash: 'Trash',
two_factor: 'Two Factor Authentication',
two_factor_disabled: '2FA Disabled',
two_factor_enabled: '2FA Enabled',
type: 'Type',
type_confirm_to_delete_account: "Type 'confirm' to delete your account.",
ui_colors: "UI Colors",
@ -256,6 +279,35 @@ const en = {
yes_release_it: 'Yes, Release It',
you_have_been_referred_to_puter_by_a_friend: "You have been referred to Puter by a friend!",
zip: "Zip",
// === 2FA Setup ===
setup2fa_1_step_heading: 'Open your authenticator app',
setup2fa_1_instructions: `
You can use any authenticator app that supports the Time-based One-Time Password (TOTP) protocol.
There are many to choose from, but if you're unsure
<a target="_blank" href="https://authy.com/download">Authy</a>
is a solid choice for Android and iOS.
`,
setup2fa_2_step_heading: 'Scan the QR code',
setup2fa_3_step_heading: 'Enter the 6-digit code',
setup2fa_4_step_heading: 'Copy your recovery codes',
setup2fa_4_instructions: `
These recovery codes are the only way to access your account if you lose your phone or can't use your authenticator app.
Make sure to store them in a safe place.
`,
setup2fa_5_step_heading: 'Confirm 2FA setup',
setup2fa_5_confirmation_1: 'I have saved my recovery codes in a secure location',
setup2fa_5_confirmation_2: 'I am ready to enable 2FA',
setup2fa_5_button: 'Enable 2FA',
// === 2FA Login ===
login2fa_otp_title: 'Enter 2FA Code',
login2fa_otp_instructions: 'Enter the 6-digit code from your authenticator app.',
login2fa_recovery_title: 'Enter a recovery code',
login2fa_recovery_instructions: 'Enter one of your recovery codes to access your account.',
login2fa_use_recovery_code: 'Use a recovery code',
login2fa_recovery_back: 'Back',
login2fa_recovery_placeholder: 'XXXXXXXX',
}
};

3
src/icons/shield.svg Normal file
View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-shield-shaded" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 14.933a1 1 0 0 0 .1-.025q.114-.034.294-.118c.24-.113.547-.29.893-.533a10.7 10.7 0 0 0 2.287-2.233c1.527-1.997 2.807-5.031 2.253-9.188a.48.48 0 0 0-.328-.39c-.651-.213-1.75-.56-2.837-.855C9.552 1.29 8.531 1.067 8 1.067zM5.072.56C6.157.265 7.31 0 8 0s1.843.265 2.928.56c1.11.3 2.229.655 2.887.87a1.54 1.54 0 0 1 1.044 1.262c.596 4.477-.787 7.795-2.465 9.99a11.8 11.8 0 0 1-2.517 2.453 7 7 0 0 1-1.048.625c-.28.132-.581.24-.829.24s-.548-.108-.829-.24a7 7 0 0 1-1.048-.625 11.8 11.8 0 0 1-2.517-2.453C1.928 10.487.545 7.169 1.141 2.692A1.54 1.54 0 0 1 2.185 1.43 63 63 0 0 1 5.072.56"/>
</svg>

After

Width:  |  Height:  |  Size: 755 B

View File

@ -56,7 +56,7 @@ window.gui = async function(options){
// DEV: Load the initgui.js file if we are in development mode
if(!window.gui_env || window.gui_env === "dev"){
await window.loadScript('/sdk/puter.dev.js');
await window.loadScript('/initgui.js', {isModule: true});
await window.loadScript(`${options.asset_dir}/initgui.js`, {isModule: true});
}
// PROD: load the minified bundles if we are in production mode

138
src/util/Component.js Normal file
View File

@ -0,0 +1,138 @@
import ValueHolder from "./ValueHolder.js";
export class Component extends HTMLElement {
// Render modes
static NO_SHADOW = Symbol('no-shadow');
static TODO = [
'value bindings for create_template',
]
constructor (property_values) {
super();
if ( this.constructor.RENDER_MODE === Component.NO_SHADOW ) {
this.dom_ = this;
} else {
this.dom_ = this.attachShadow({ mode: 'open' });
}
this.values_ = {};
if ( this.constructor.template ) {
const template = document.querySelector(this.constructor.template);
this.dom_.appendChild(template.content.cloneNode(true));
}
for ( const key in this.constructor.PROPERTIES ) {
let initial_value;
if ( property_values && key in property_values ) {
initial_value = property_values[key];
} else if ( this.constructor.PROPERTIES[key].value !== undefined ) {
initial_value = this.constructor.PROPERTIES[key].value;
}
this.values_[key] = ValueHolder.adapt(initial_value);
const listener_key = `property.${key}`;
if ( property_values[listener_key] ) {
this.values_[key].sub((value, more) => {
more = { ...more, component: this };
property_values[listener_key](value, more);
});
}
}
// Convenience for setting a property while composing components
if ( property_values && property_values.hasOwnProperty('_ref') ) {
property_values._ref(this);
}
// Setup focus handling
if ( property_values && property_values[`event.focus`] ) {
const on_focus_ = this.on_focus;
this.on_focus = (...a) => {
property_values[`event.focus`]();
on_focus_ && on_focus_(...a);
}
}
this.addEventListener('focus', () => {
if ( this.on_focus ) {
this.on_focus();
}
});
}
focus () {
super.focus();
// Apparently the 'focus' event only fires when the element is focused
// by other means than calling .focus() on it, so this isn't redundant.
// We use a 0ms timeout to ensure that the focus event has been
// processed before we call on_focus, which may rely on the focus
// event having been processed (and typically does).
setTimeout(() => {
if ( this.on_focus ) {
this.on_focus();
}
}, 0);
}
get (key) {
return this.values_[key].get();
}
set (key, value) {
this.values_[key].set(value);
}
connectedCallback () {
this.on_ready && this.on_ready(this.get_api_());
}
attach (destination) {
const el = this.create_element_();
this.dom_.appendChild(el);
if ( destination instanceof HTMLElement ) {
destination.appendChild(this);
return;
}
if ( destination.$ === 'placeholder' ) {
destination.replaceWith(this);
return;
}
// TODO: generalize displaying errors about a value;
// always show: typeof value, value.toString()
throw new Error(`Unknown destination type: ${destination}`);
}
place (slot_name, child_node) {
child_node.setAttribute('slot', slot_name);
this.appendChild(child_node);
}
create_element_ () {
const template = document.createElement('template');
if ( this.constructor.CSS ) {
const style = document.createElement('style');
style.textContent = this.constructor.CSS;
this.dom_.appendChild(style);
}
if ( this.create_template ) {
this.create_template({ template });
}
const el = template.content.cloneNode(true);
return el;
}
get_api_ () {
return {
listen: (name, callback) => {
this.values_[name].sub(callback);
callback(this.values_[name].get(), {});
}
};
}
}

38
src/util/Placeholder.js Normal file
View File

@ -0,0 +1,38 @@
/**
* @typedef {Object} PlaceholderReturn
* @property {String} html: An html string that represents the placeholder
* @property {String} id: The unique ID of the placeholder
* @property {Function} replaceWith: A function that takes a DOM element
* as an argument and replaces the placeholder with it
*/
/**
* Placeholder creates a simple element with a unique ID
* as an HTML string.
*
* This can be useful where string concatenation is used
* to build element trees.
*
* The `replaceWith` method can be used to replace the
* placeholder with a real element.
*
* @returns {PlaceholderReturn}
*/
const Placeholder = () => {
const id = Placeholder.get_next_id_();
return {
$: 'placeholder',
html: `<div id="${id}"></div>`,
id,
replaceWith: (el) => {
const place = document.getElementById(id);
place.replaceWith(el);
}
};
};
const anti_collision = `94d2cb6b85a1`; // Arbitrary random string
Placeholder.next_id_ = 0;
Placeholder.get_next_id_ = () => `${anti_collision}_${Placeholder.next_id_++}`;
export default Placeholder;

43
src/util/TeePromise.js Normal file
View File

@ -0,0 +1,43 @@
export default class TeePromise {
static STATUS_PENDING = {};
static STATUS_RUNNING = {};
static STATUS_DONE = {};
constructor () {
this.status_ = this.constructor.STATUS_PENDING;
this.donePromise = new Promise((resolve, reject) => {
this.doneResolve = resolve;
this.doneReject = reject;
});
}
get status () {
return this.status_;
}
set status (status) {
this.status_ = status;
if ( status === this.constructor.STATUS_DONE ) {
this.doneResolve();
}
}
resolve (value) {
this.status_ = this.constructor.STATUS_DONE;
this.doneResolve(value);
}
awaitDone () {
return this.donePromise;
}
then (fn, rfn) {
return this.donePromise.then(fn, rfn);
}
reject (err) {
this.status_ = this.constructor.STATUS_DONE;
this.doneReject(err);
}
/**
* @deprecated use then() instead
*/
onComplete(fn) {
return this.then(fn);
}
}

58
src/util/ValueHolder.js Normal file
View File

@ -0,0 +1,58 @@
/**
* Holds an observable value.
*/
export default class ValueHolder {
constructor (initial_value) {
this.value_ = null;
this.listeners_ = [];
Object.defineProperty(this, 'value', {
set: this.set_.bind(this),
get: this.get_.bind(this),
});
if (initial_value !== undefined) {
this.set(initial_value);
}
}
static adapt (value) {
if (value instanceof ValueHolder) {
return value;
} else {
return new ValueHolder(value);
}
}
set (value) {
this.value = value;
}
get () {
return this.value;
}
sub (listener) {
this.listeners_.push(listener);
}
set_ (value) {
const old_value = this.value_;
this.value_ = value;
const more = {
holder: this,
old_value,
};
this.listeners_.forEach(listener => listener(value, more));
}
get_ () {
return this.value_;
}
map (fn) {
const holder = new ValueHolder();
this.sub((value, more) => holder.set(fn(value, more)));
return holder;
}
}