mirror of
https://github.com/HeyPuter/puter.git
synced 2025-02-03 07:48:46 +08:00
commit
0e16a3f3b4
26
package-lock.json
generated
26
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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 }) => {
|
||||
|
@ -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': {
|
||||
|
106
packages/backend/src/routers/auth/configure-2fa.js
Normal file
106
packages/backend/src/routers/auth/configure-2fa.js
Normal 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);
|
||||
});
|
@ -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;
|
||||
|
@ -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 } : {})
|
||||
};
|
||||
|
||||
|
@ -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'))
|
||||
|
@ -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();
|
||||
}
|
||||
|
70
packages/backend/src/services/auth/OTPService.js
Normal file
70
packages/backend/src/services/auth/OTPService.js
Normal 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 };
|
@ -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;
|
||||
|
||||
|
@ -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}`);
|
||||
|
@ -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;
|
@ -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>
|
||||
|
73
src/UI/Components/Button.js
Normal file
73
src/UI/Components/Button.js
Normal 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);
|
||||
}
|
224
src/UI/Components/CodeEntryView.js
Normal file
224
src/UI/Components/CodeEntryView.js
Normal 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);
|
||||
}
|
67
src/UI/Components/ConfirmationsView.js
Normal file
67
src/UI/Components/ConfirmationsView.js
Normal 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);
|
||||
}
|
47
src/UI/Components/Flexer.js
Normal file
47
src/UI/Components/Flexer.js
Normal 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);
|
||||
}
|
24
src/UI/Components/JustHTML.js
Normal file
24
src/UI/Components/JustHTML.js
Normal 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);
|
||||
}
|
87
src/UI/Components/QRCode.js
Normal file
87
src/UI/Components/QRCode.js
Normal 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);
|
||||
}
|
87
src/UI/Components/RecoveryCodeEntryView.js
Normal file
87
src/UI/Components/RecoveryCodeEntryView.js
Normal 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);
|
100
src/UI/Components/RecoveryCodesView.js
Normal file
100
src/UI/Components/RecoveryCodesView.js
Normal 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);
|
||||
}
|
67
src/UI/Components/StepHeading.js
Normal file
67
src/UI/Components/StepHeading.js
Normal 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);
|
||||
}
|
73
src/UI/Components/StepView.js
Normal file
73
src/UI/Components/StepView.js
Normal 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);
|
||||
}
|
51
src/UI/Components/StringView.js
Normal file
51
src/UI/Components/StringView.js
Normal 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);
|
||||
}
|
28
src/UI/Components/TestView.js
Normal file
28
src/UI/Components/TestView.js
Normal 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);
|
||||
}
|
@ -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>`;
|
||||
|
103
src/UI/Settings/UITabSecurity.js
Normal file
103
src/UI/Settings/UITabSecurity.js
Normal 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');
|
||||
});
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
26
src/UI/UIComponentWindow.js
Normal file
26
src/UI/UIComponentWindow.js
Normal 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;
|
||||
}
|
@ -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
224
src/UI/UIWindow2FASetup.js
Normal 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;
|
@ -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,
|
||||
|
@ -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"> × </div>`;
|
||||
h += `<div style="-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; color: #3e5362;">`;
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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"> × </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"> × </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
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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
3
src/icons/shield.svg
Normal 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 |
@ -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
138
src/util/Component.js
Normal 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
38
src/util/Placeholder.js
Normal 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
43
src/util/TeePromise.js
Normal 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
58
src/util/ValueHolder.js
Normal 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;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user