diff --git a/src/app/ResolveRoute.js b/src/app/ResolveRoute.js index f5b0223bd..08849402f 100644 --- a/src/app/ResolveRoute.js +++ b/src/app/ResolveRoute.js @@ -3,7 +3,7 @@ import GDPRUserList from './utils/GDPRUserList'; export const routeRegex = { UserProfile1: /^\/(@[\w\.\d-]+)\/?$/, - UserProfile2: /^\/(@[\w\.\d-]+)\/(transfers|curation-rewards|author-rewards|permissions|communities|password|settings|delegations)\/?$/, + UserProfile2: /^\/(@[\w\.\d-]+)\/(transfers|curation-rewards|author-rewards|permissions|communities|password|settings|delegations|proposals|witnesses|steem_tools)\/?$/, }; export default function resolveRoute(path) { diff --git a/src/app/ResolveRoute.test.js b/src/app/ResolveRoute.test.js index 53f69e70d..141a068e3 100644 --- a/src/app/ResolveRoute.test.js +++ b/src/app/ResolveRoute.test.js @@ -7,7 +7,7 @@ describe('routeRegex', () => { ['UserProfile1', /^\/(@[\w\.\d-]+)\/?$/], [ 'UserProfile2', - /^\/(@[\w\.\d-]+)\/(transfers|curation-rewards|author-rewards|permissions|communities|password|settings|delegations)\/?$/, + /^\/(@[\w\.\d-]+)\/(transfers|curation-rewards|author-rewards|permissions|communities|password|settings|delegations|proposals|witnesses|steem_tools)\/?$/, ], ]; diff --git a/src/app/assets/stylesheets/_themes.scss b/src/app/assets/stylesheets/_themes.scss index 75164e68f..8af017884 100755 --- a/src/app/assets/stylesheets/_themes.scss +++ b/src/app/assets/stylesheets/_themes.scss @@ -9,7 +9,10 @@ $themes: ( backgroundColorOpaque: $color-background-off-white, backgroundTransparent: transparent, backgroundColorWarning: $color-background-warning, + backgroundColorDanger: $alert-color, + backgroundColorSecondary: $color-white, moduleBackgroundColor: $color-white, + sectionBackgroundColor: $color-white, menuBackgroundColor: $color-background-dark, moduleMediumBackgroundColor: $color-white, navBackgroundColor: $color-white, @@ -20,6 +23,7 @@ $themes: ( borderDark: 1px solid $color-text-gray, borderAccent: 1px solid $color-blue, borderWarning: 1px solid $color-text-warning, + borderDanger: 1px solid $alert-color, borderTransparent: transparent, roundedCorners: 5px, roundedCornersTop: 5px 5px 0 0, @@ -30,6 +34,7 @@ $themes: ( textColorAccent: $color-text-blue, textColorAccentHover: $color-blue-original-dark, textColorError: $color-text-red, + textColorOnDanger: $color-white, contentBorderAccent: $color-transparent, buttonBackground: $color-blue-original-dark, buttonBackgroundHover: $color-blue-original-light, @@ -37,6 +42,9 @@ $themes: ( buttonTextShadow: 0 1px 0 rgba(0,0,0,0.20), buttonTextHover: $color-text-white, buttonBoxShadow: $color-transparent, + overlayBackground: rgba(0, 0, 0, 0.35), + codeBlockBackground: #1e1e1e, + codeBlockText: #d4d4d4, ), light: ( colorAccent: $color-teal, @@ -48,7 +56,10 @@ $themes: ( backgroundColorOpaque: $color-background-off-white, backgroundTransparent: transparent, backgroundColorWarning: $color-background-warning, + backgroundColorDanger: $alert-color, + backgroundColorSecondary: $color-white, moduleBackgroundColor: $color-white, + sectionBackgroundColor: $color-white, menuBackgroundColor: $color-background-dark, moduleMediumBackgroundColor: $color-transparent, navBackgroundColor: $color-white, @@ -59,6 +70,7 @@ $themes: ( borderDark: 1px solid $color-text-gray, borderAccent: 1px solid $color-teal, borderWarning: 1px solid $color-text-warning, + borderDanger: 1px solid $alert-color, borderTransparent: transparent, roundedCorners: 5px, roundedCornersTop: 5px 5px 0 0, @@ -69,6 +81,7 @@ $themes: ( textColorAccent: $color-text-teal, textColorAccentHover: $color-teal, textColorError: $color-text-red, + textColorOnDanger: $color-white, contentBorderAccent: $color-teal, buttonBackground: $color-blue-black, buttonBackgroundHover: $color-teal, @@ -77,6 +90,9 @@ $themes: ( buttonTextHover: $color-white, buttonBoxShadow: $color-teal, buttonBoxShadowHover: $color-blue-black, + overlayBackground: rgba(0, 0, 0, 0.35), + codeBlockBackground: #1e1e1e, + codeBlockText: #d4d4d4, ), dark: ( colorAccent: $color-teal, @@ -87,7 +103,10 @@ $themes: ( backgroundColorEmphasis: $color-background-super-dark, backgroundColorOpaque: $color-blue-dark, backgroundColorWarning: $color-background-warning-dark, + backgroundColorDanger: $alert-color, + backgroundColorSecondary: $color-blue-dark, moduleBackgroundColor: $color-background-dark, + sectionBackgroundColor: $color-blue-dark, backgroundTransparent: transparent, menuBackgroundColor: $color-blue-dark, moduleMediumBackgroundColor: $color-background-dark, @@ -99,6 +118,7 @@ $themes: ( borderDark: 1px solid $color-text-gray-light, borderAccent: 1px solid $color-teal, borderWarning: 1px solid $color-background-warning-dark, + borderDanger: 1px solid $alert-color, borderTransparent: transparent, roundedCorners: 5px, roundedCornersTop: 5px 5px 0 0, @@ -109,6 +129,7 @@ $themes: ( textColorAccent: $color-teal, textColorAccentHover: $color-teal-light, textColorError: $color-text-red, + textColorOnDanger: $color-white, contentBorderAccent: $color-teal, buttonBackground: $color-white, buttonBackgroundHover: $color-teal, @@ -118,6 +139,9 @@ $themes: ( buttonBoxShadow: $color-teal, buttonBoxShadowHover: $color-white, inputPriceWarning: rgba(255, 153, 0, 0.83), + overlayBackground: rgba(0, 0, 0, 0.35), + codeBlockBackground: #10151b, + codeBlockText: #d4d4d4, ), ); diff --git a/src/app/client_config.js b/src/app/client_config.js index 79361d549..190d7b289 100644 --- a/src/app/client_config.js +++ b/src/app/client_config.js @@ -51,3 +51,7 @@ export const SITE_DESCRIPTION = // various export const SUPPORT_EMAIL = 'support@' + APP_DOMAIN; + +// External links +export const STEEMDB_BLOCK_URL = "https://steemdb.io/block"; +export const STEEMDB_TRANSACTION_URL = "https://steemdb.io/tx"; \ No newline at end of file diff --git a/src/app/components/all.scss b/src/app/components/all.scss index 1b3a9a319..f646f7b97 100644 --- a/src/app/components/all.scss +++ b/src/app/components/all.scss @@ -29,6 +29,10 @@ @import './elements/OutgoingDelegations'; @import './elements/VotersModal'; @import './elements/ProposalCreatorModal'; +@import './elements/RouteSettings'; +@import './elements/WithdrawRoutesModal'; +@import './elements/SteemToolsContent/SteemToolsContent'; +@import './elements/SteemToolsContent/SteemToolsMenu'; // modules @import './modules/Header/styles'; diff --git a/src/app/components/cards/TransferHistoryRow/index.jsx b/src/app/components/cards/TransferHistoryRow/index.jsx index c2056831a..5b97f50fd 100644 --- a/src/app/components/cards/TransferHistoryRow/index.jsx +++ b/src/app/components/cards/TransferHistoryRow/index.jsx @@ -6,12 +6,15 @@ import Memo from 'app/components/elements/Memo'; import { numberWithCommas, vestsToSp } from 'app/utils/StateFunctions'; import tt from 'counterpart'; import GDPRUserList from 'app/utils/GDPRUserList'; +import { STEEMDB_BLOCK_URL, STEEMDB_TRANSACTION_URL } from 'app/client_config'; class TransferHistoryRow extends React.Component { render() { const { op, context, + block, + trx, curation_reward, author_reward, benefactor_reward, @@ -287,8 +290,14 @@ class TransferHistoryRow extends React.Component { } return ( - + + {block && (
+ Block: {block} +
)} + {(trx && trx !== '0000000000000000000000000000000000000000') && (
+ TxID: {trx} +
)} + tt('steem_tools.authority_management.confirm_action', { + fallback: 'Action', + }), + account: () => + tt('steem_tools.authority_management.account_to_modify', { + fallback: 'Account', + }), + authority_type: () => + tt('steem_tools.authority_management.authority_type', { + fallback: 'Authority Type', + }), + target_account: () => + tt('steem_tools.authority_management.add_account_authority', { + fallback: 'Account Authority', + }), + weight: () => + tt('steem_tools.authority_management.weight', { + fallback: 'Weight', + }), + weight_threshold: () => + tt('steem_tools.authority_management.weight_threshold', { + fallback: 'Weight Threshold', + }), +}; + +const ConfirmAuthorityManagement = ({ operation }) => { + const fields = Object.keys(operation).filter((key) => { + const value = operation[key]; + return value !== undefined && value !== null && value !== ''; + }); + + return ( +
+ {fields.map((key, index) => ( +
+ + {LABELS[key] ? LABELS[key]() : key} + + +
+ ))} +
+ ); +}; + +ConfirmAuthorityManagement.propTypes = { + operation: PropTypes.shape({ + action: PropTypes.string, + account: PropTypes.string, + authority_type: PropTypes.string, + target_account: PropTypes.string, + weight: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + weight_threshold: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + }).isRequired, +}; + +export default ConfirmAuthorityManagement; diff --git a/src/app/components/elements/ConfirmCreateWitness/index.jsx b/src/app/components/elements/ConfirmCreateWitness/index.jsx new file mode 100644 index 000000000..c92f9b59e --- /dev/null +++ b/src/app/components/elements/ConfirmCreateWitness/index.jsx @@ -0,0 +1,87 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import tt from 'counterpart'; + +const ConfirmCreateWitness = ({ operation }) => { + const { + owner, + url, + new_signing_key, + account_creation_fee, + maximum_block_size, + sbd_interest_rate, + mode, + } = operation; + + const modeLabel = + mode === 'update' + ? tt('steem_tools.create_witness.confirm_mode_update') + : tt('steem_tools.create_witness.confirm_mode_create'); + + return ( +
+
+ + {tt('steem_tools.create_witness.witness_account')} + + +
+ +
+ + {tt('steem_tools.create_witness.confirm_mode')} + + +
+ +
+ + {tt('steem_tools.create_witness.block_signing_key')} + + +
+ +
+ + {tt('steem_tools.create_witness.witness_url')} + + +
+ +
+ + {tt('steem_tools.create_witness.account_creation_fee')} + + +
+ +
+ + {tt('steem_tools.create_witness.maximum_block_size')} + + +
+ +
+ + {tt('steem_tools.create_witness.sbd_interest_rate')} + + +
+
+ ); +}; + +ConfirmCreateWitness.propTypes = { + operation: PropTypes.shape({ + owner: PropTypes.string.isRequired, + url: PropTypes.string.isRequired, + new_signing_key: PropTypes.string.isRequired, + account_creation_fee: PropTypes.string.isRequired, + maximum_block_size: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + sbd_interest_rate: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + mode: PropTypes.oneOf(['create', 'update']).isRequired, + }).isRequired, +}; + +export default ConfirmCreateWitness; diff --git a/src/app/components/elements/ConfirmDisableWitness/index.jsx b/src/app/components/elements/ConfirmDisableWitness/index.jsx new file mode 100644 index 000000000..74ef74e79 --- /dev/null +++ b/src/app/components/elements/ConfirmDisableWitness/index.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import tt from 'counterpart'; + +const ConfirmDisableWitness = ({ operation }) => { + const { owner, current_signing_key, new_signing_key } = operation; + + return ( +
+
+ + {tt('steem_tools.disable_witness.witness_account')} + + +
+ +
+ + {tt('steem_tools.disable_witness.current_signing_key')} + + +
+ +
+ + {tt('steem_tools.disable_witness.null_signing_key')} + + +
+
+ ); +}; + +ConfirmDisableWitness.propTypes = { + operation: PropTypes.shape({ + owner: PropTypes.string.isRequired, + current_signing_key: PropTypes.string, + new_signing_key: PropTypes.string.isRequired, + }).isRequired, +}; + +export default ConfirmDisableWitness; \ No newline at end of file diff --git a/src/app/components/elements/ConfirmWithdrawVestingRoute/index.jsx b/src/app/components/elements/ConfirmWithdrawVestingRoute/index.jsx new file mode 100644 index 000000000..e68ae5ae4 --- /dev/null +++ b/src/app/components/elements/ConfirmWithdrawVestingRoute/index.jsx @@ -0,0 +1,66 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import tt from 'counterpart'; + +const ConfirmWithdrawVestingRoute = ({ operation }) => { + const { from, to, percentage, asset } = operation; + + const getAssetLabel = (isAutoVest) => { + return isAutoVest ? tt('advanced_routes.steem_power') : tt('advanced_routes.steem'); + }; + + return ( +
+
+ + {tt('advanced_routes.from')} + + +
+
+ + {tt('advanced_routes.to')} + + +
+
+ + {tt('advanced_routes.percentage')} + + + +
+
+ ); +}; + +ConfirmWithdrawVestingRoute.propTypes = { + operation: PropTypes.shape({ + from: PropTypes.string.isRequired, + to: PropTypes.string.isRequired, + percentage: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + asset: PropTypes.bool.isRequired, + }).isRequired, +}; + +export default ConfirmWithdrawVestingRoute; diff --git a/src/app/components/elements/ConversionsModal.jsx b/src/app/components/elements/ConversionsModal.jsx index b6e06b8e0..d93f9cff4 100644 --- a/src/app/components/elements/ConversionsModal.jsx +++ b/src/app/components/elements/ConversionsModal.jsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactModal from 'react-modal'; import CloseButton from 'app/components/elements/CloseButton'; +import tt from 'counterpart'; ReactModal.defaultStyles.overlay.backgroundColor = 'rgba(0, 0, 0, 0.6)'; @@ -9,22 +10,22 @@ const ConversionsModal = ({ isOpen, onClose, combinedConversions }) => {
-

Conversions

+

{tt('converttosteem_jsx.current_conversions')}

{combinedConversions.length === 0 ? ( -

No conversion data available.

+

{tt('converttosteem_jsx.no_conversion_data')}

) : ( - - - - + + + + diff --git a/src/app/components/elements/ConvertToSteem.jsx b/src/app/components/elements/ConvertToSteem.jsx index cf232d936..54bb851a2 100644 --- a/src/app/components/elements/ConvertToSteem.jsx +++ b/src/app/components/elements/ConvertToSteem.jsx @@ -22,6 +22,7 @@ class ConvertToSteem extends React.Component { this.shouldComponentUpdate = shouldComponentUpdate(this, 'ConvertToSteem'); this.state = { toggle_check: false, + errorMessage: undefined, }; this.initForm(props); } @@ -73,10 +74,10 @@ class ConvertToSteem extends React.Component { this.setState({ loading: false }); if (onClose) onClose(); }; - const error = () => { - this.setState({ loading: false }); + const error = (msg_error) => { + this.setState({ loading: false, errorMessage: String(msg_error) }); }; - this.setState({ loading: true }); + this.setState({ errorMessage: undefined, loading: true }); convert(currentUser, amount.value, success, error); }; @@ -103,7 +104,7 @@ class ConvertToSteem extends React.Component { render() { const { onClose, currentUser, sbd_balance } = this.props; - const { loading, amount, marketRate } = this.state; + const { loading, amount, marketRate, errorMessage } = this.state; const { submitting, valid, handleSubmit } = this.state.convertToSteem; const { prices } = this.props; @@ -276,6 +277,13 @@ class ConvertToSteem extends React.Component { ) : null} + {errorMessage && ( +
+
+ {errorMessage} +
+
+ )}
diff --git a/src/app/components/elements/OutgoingDelegations.jsx b/src/app/components/elements/OutgoingDelegations.jsx index 961077570..21c2daecc 100644 --- a/src/app/components/elements/OutgoingDelegations.jsx +++ b/src/app/components/elements/OutgoingDelegations.jsx @@ -319,14 +319,12 @@ class OutgoingDelegations extends React.Component { type="text" id="delegatee" name="delegatee" + placeholder={tt( + 'outgoingdelegations_jsx.filters.search_delegatee' + )} value={delegatee} onChange={this.handleFindAccounts} /> -
diff --git a/src/app/components/elements/OutgoingDelegations.scss b/src/app/components/elements/OutgoingDelegations.scss index 1596d53c2..fe5b6e1b7 100644 --- a/src/app/components/elements/OutgoingDelegations.scss +++ b/src/app/components/elements/OutgoingDelegations.scss @@ -2,11 +2,20 @@ width: 100%; font-family: "Source Sans Pro", "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 1rem; - border: 1px solid #7e7d7d; outline: none; padding: 0.25rem 0.5rem 0.5rem 0; border-radius: 5px; padding: 1.5rem .5rem; + border-color: transparent; + background: transparent; + box-shadow: 0 0 5px rgba(109, 207, 246, 0.5); + @include themify($themes) { + color: themed('textColorPrimary'); + } + input::placeholder { + font-weight: lighter; + color: #7e7d7d; + } } .input-box{ @@ -16,30 +25,5 @@ .input-box input:focus{ border: 1px solid #06D6A9; -} - -.input-box label { - transform: translateX(10px) translateY(-3rem); - left: 0; - padding: 1rem; - padding-top: 0.5rem; - pointer-events: none; - font-size: 1rem; - color: #7e7d7d; - transition: 0.5s; - margin-right: 15px; - max-width: fit-content; -} - -.input-box input:focus ~ label, -.input-box .focus ~ label{ - transform: translateX(10px) translateY(-3.95rem); - font-size: .75rem; - padding: .45rem; - padding-top: .1rem; - padding-bottom: .1rem; - background-color: #06D6A9; - font-weight: bold; - color: #FFFFFF; - border-radius: 5px; + background: transparent; } diff --git a/src/app/components/elements/PdfDownload.jsx b/src/app/components/elements/PdfDownload.jsx new file mode 100644 index 000000000..c2ec7479f --- /dev/null +++ b/src/app/components/elements/PdfDownload.jsx @@ -0,0 +1,625 @@ +import React from 'react'; +import QRious from 'qrious'; +import jsPDF from 'jspdf'; +import RobotoRegular from 'app/assets/fonts/Roboto-Regular.ttf'; +import RobotoBold from 'app/assets/fonts/Roboto-Bold.ttf'; +import RobotoMonoRegular from 'app/assets/fonts/RobotoMono-Regular.ttf'; +import pdfLogoSvg from 'app/assets/images/pdf-logo.svg'; + +function image2canvas(image, bgcolor) { + var canvas = document.createElement('canvas'); + canvas.width = image.width * 32; + canvas.height = image.height * 32; + + var ctx = canvas.getContext('2d'); + ctx.fillStyle = bgcolor; + ctx.fillRect(0.0, 0.0, canvas.width, canvas.height); + ctx.drawImage(image, 0, 0, canvas.width, canvas.height); + + return canvas; +} + +class PdfDownload extends React.Component { + constructor(props) { + super(props); + this.renderPdf = this.renderPdf.bind(this); + } + + componentDidUpdate(prevProps) { + if (this.props.dlPdf && !prevProps.dlPdf) { + try { + var keys = this.props.keys; + var name = this.props.name; + var filename = 'Keys for @' + name + '.pdf'; + this.renderPdf(keys, name, filename).save(filename); + } catch (error) { + console.error(error); + } + if (this.props.resetDlPdf) { + this.props.resetDlPdf(); + } + } + } + + drawFilledRect(ctx, x, y, w, h, options) { + ctx.setDrawColor(0); + ctx.setFillColor(options.color); + ctx.rect(x, y, w, h, 'F'); + } + + drawImageFromCanvas(ctx, selector, x, y, w, h, bgcolor) { + var el = document.querySelector(selector); + if (!el) return; + var canvas = image2canvas(el, bgcolor); + ctx.addImage(canvas, 'JPEG', x, y, w, h); + } + + drawQr(ctx, data, x, y, size, bgcolor) { + var canvas = document.createElement('canvas'); + new QRious({ + element: canvas, + size: 250, + value: data, + background: bgcolor, + }); + ctx.addImage(canvas, 'PNG', x, y, size, size); + } + + renderText(ctx, text, options) { + var textLines = ctx + .setFont(options.font) + .setFontSize(options.fontSize * options.scale) + .setTextColor(options.color) + .splitTextToSize(text, options.maxWidth); + ctx.text(textLines, options.x, options.y + options.fontSize); + return textLines.length * options.fontSize * options.lineHeight; + } + + renderPdf(keys, name, filename) { + var widthInches = this.props.widthInches || 8.5; + var lineHeight = 1.2; + var margin = 0.3; + var maxLineWidth = widthInches - margin * 2.0; + var scale = 72; + var qrSize = 1.1; + + var ctx = new jsPDF({ + orientation: 'portrait', + unit: 'in', + lineHeight: lineHeight, + format: 'letter', + }).setProperties({ title: filename }); + + ctx.addFont(RobotoRegular, 'Roboto-Regular', 'normal'); + ctx.addFont(RobotoBold, 'Roboto-Bold', 'normal'); + ctx.addFont(RobotoMonoRegular, 'RobotoMono-Regular', 'normal'); + + var offset = 0.0; + var sectionStart = 0; + var sectionHeight = 0; + + // HEADER + sectionHeight = 1.29; + this.drawFilledRect(ctx, 0.0, 0.0, widthInches, sectionHeight, { + color: '#1f0fd1', + }); + + this.drawImageFromCanvas( + ctx, + '.pdf-logo', + widthInches - margin - 1.9, + 0.36, + 0.98 * 1.8, + 0.3 * 1.8, + '#1F0FD1' + ); + + offset += 0.265; + offset += this.renderText(ctx, 'Keys for @' + name, { + scale: scale, + x: margin, + y: offset, + lineHeight: 1.0, + maxWidth: maxLineWidth, + color: 'white', + fontSize: 0.36, + font: 'Roboto-Bold', + }); + + offset += 0.15; + offset += this.renderText( + ctx, + 'Generated at ' + + new Date() + .toISOString() + .replace(/\.\d{3}/, '') + + ' by steemit.com', + { + scale: scale, + x: margin, + y: offset, + lineHeight: 1.0, + maxWidth: maxLineWidth, + color: 'white', + fontSize: 0.14, + font: 'Roboto-Bold', + } + ); + + offset = sectionStart + sectionHeight; + + // BODY - PRIVATE KEYS INTRO + offset += 0.1; + offset += this.renderText( + ctx, + 'Instead of password based authentication, blockchain accounts ' + + 'have a set of public and private key pairs that are used for ' + + 'authentication as well as the encryption and decryption of ' + + 'data. Do not share this file with anyone.', + { + scale: scale, + x: margin, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'Roboto-Regular', + } + ); + + // Steemit Account + offset += 0.4; + offset += this.renderText(ctx, 'Your Steemit Private Keys', { + scale: scale, + x: margin, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.18, + font: 'Roboto-Bold', + }); + offset += 0.1; + + // POSTING KEY + sectionStart = offset; + sectionHeight = qrSize + 0.15 * 2; + this.drawFilledRect(ctx, 0.0, offset, widthInches, sectionHeight, { + color: 'f4f4f4', + }); + + offset += 0.15; + this.drawQr( + ctx, + 'steem://import/wif/' + keys.postingPrivate + '/account/' + name, + margin, + offset, + qrSize, + '#f4f4f4' + ); + + offset += 0.1; + offset += this.renderText(ctx, 'Private Posting Key', { + scale: scale, + x: margin + qrSize + 0.1, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'Roboto-Bold', + }); + + offset += this.renderText( + ctx, + 'Used to log in to apps such as Steemit.com and perform social ' + + 'actions such as posting, commenting, and voting.', + { + scale: scale, + x: margin + qrSize + 0.1, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth - (qrSize + 0.1), + color: 'black', + fontSize: 0.14, + font: 'Roboto-Regular', + } + ); + + offset += 0.075; + offset += this.renderText(ctx, keys.postingPrivate, { + scale: scale, + x: margin + qrSize + 0.1, + y: sectionStart + sectionHeight - 0.6, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'RobotoMono-Regular', + }); + offset += 0.2; + offset = sectionStart + sectionHeight; + + // MEMO KEY + sectionStart = offset; + sectionHeight = qrSize + 0.15 * 2; + + offset += 0.15; + this.drawQr( + ctx, + 'steem://import/wif/' + keys.memoPrivate + '/account/' + name, + margin, + offset, + qrSize, + '#ffffff' + ); + + offset += 0.1; + offset += this.renderText(ctx, 'Private Memo Key', { + scale: scale, + x: margin + qrSize + 0.1, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'Roboto-Bold', + }); + + offset += this.renderText(ctx, 'Used to decrypt private transfer memos.', { + scale: scale, + x: margin + qrSize + 0.1, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth - (qrSize + 0.1), + color: 'black', + fontSize: 0.14, + font: 'Roboto-Regular', + }); + + offset += 0.075; + offset += this.renderText(ctx, keys.memoPrivate, { + scale: scale, + x: margin + qrSize + 0.1, + y: sectionStart + sectionHeight - 0.6, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'RobotoMono-Regular', + }); + + offset += 0.1; + offset = sectionStart + sectionHeight; + + // ACTIVE KEY + sectionStart = offset; + sectionHeight = qrSize + 0.15 * 2; + this.drawFilledRect(ctx, 0.0, offset, widthInches, sectionHeight, { + color: '#f4f4f4', + }); + + offset += 0.15; + this.drawQr( + ctx, + 'steem://import/wif/' + keys.activePrivate + '/account/' + name, + margin, + offset, + qrSize, + '#f4f4f4' + ); + + offset += 0.1; + offset += this.renderText(ctx, 'Private Active Key', { + scale: scale, + x: margin + qrSize + 0.1, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'Roboto-Bold', + }); + + offset += this.renderText( + ctx, + 'Used for monetary and wallet related actions, such as ' + + 'transferring tokens or powering STEEM up and down.', + { + scale: scale, + x: margin + qrSize + 0.1, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth - (qrSize + 0.1), + color: 'black', + fontSize: 0.14, + font: 'Roboto-Regular', + } + ); + + offset += 0.075; + offset += this.renderText(ctx, keys.activePrivate, { + scale: scale, + x: margin + qrSize + 0.1, + y: sectionStart + sectionHeight - 0.6, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'RobotoMono-Regular', + }); + offset += 0.6; + + // OWNER KEY + sectionStart = offset; + sectionHeight = qrSize + 0.15 * 2; + + offset += 0.15; + this.drawQr( + ctx, + 'steem://import/wif/' + keys.ownerPrivate + '/account/' + name, + margin, + offset, + qrSize, + '#ffffff' + ); + + offset += 0.1; + offset += this.renderText(ctx, 'Private Owner Key', { + scale: scale, + x: margin + qrSize + 0.1, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth - qrSize - 0.1, + color: 'black', + fontSize: 0.14, + font: 'Roboto-Bold', + }); + + offset += this.renderText( + ctx, + 'This key is used to reset all your other keys. It is ' + + 'recommended to keep it offline at all times. If your ' + + 'account is compromised, use this key to recover it ' + + 'within 30 days at https://steemitwallet.com.', + { + scale: scale, + x: margin + qrSize + 0.1, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth - (qrSize + 0.1), + color: 'black', + fontSize: 0.14, + font: 'Roboto-Regular', + } + ); + + offset += 0.075; + offset += this.renderText(ctx, keys.ownerPrivate, { + scale: scale, + x: margin + qrSize + 0.1, + y: sectionStart + sectionHeight - 0.6, + lineHeight: lineHeight, + maxWidth: maxLineWidth - qrSize - 0.1, + color: 'black', + fontSize: 0.14, + font: 'RobotoMono-Regular', + }); + + offset = sectionStart + sectionHeight; + + // MASTER PASSWORD + sectionHeight = 1; + sectionStart = offset; + this.drawFilledRect(ctx, 0.0, offset, widthInches, sectionHeight, { + color: '#f4f4f4', + }); + + offset += 0.2; + offset += this.renderText(ctx, 'Master Password', { + scale: scale, + x: margin, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'Roboto-Bold', + }); + + offset += this.renderText( + ctx, + 'The seed password used to generate this document. ' + + 'Do not share this key.', + { + scale: scale, + x: margin, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'Roboto-Regular', + } + ); + + offset += 0.075; + offset += this.renderText(ctx, keys.master || this.props.password || '', { + scale: scale, + x: margin, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'RobotoMono-Regular', + }); + + offset = sectionStart + sectionHeight; + + // PUBLIC KEYS INTRO + sectionStart = offset; + sectionHeight = 1.0; + + offset += 0.1; + offset += this.renderText(ctx, 'Your Steemit Public Keys', { + scale: scale, + x: margin, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.18, + font: 'Roboto-Bold', + }); + + offset += 0.1; + offset += this.renderText( + ctx, + 'Public keys are associated with usernames and are used to ' + + 'encrypt and verify messages. Your public keys are not required ' + + 'for login. You can view these anytime at: https://steemscan.com/account/' + + name, + { + scale: scale, + x: margin, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.15, + font: 'Roboto-Regular', + } + ); + + offset = sectionStart + sectionHeight; + + // PUBLIC KEYS + this.renderText(ctx, 'Posting Public', { + scale: scale, + x: margin, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'Roboto-Bold', + }); + + offset += this.renderText(ctx, keys.postingPublic, { + scale: scale, + x: 1.25, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'RobotoMono-Regular', + }); + + this.renderText(ctx, 'Memo Public', { + scale: scale, + x: margin, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'Roboto-Bold', + }); + + offset += this.renderText(ctx, keys.memoPublic, { + scale: scale, + x: 1.25, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'RobotoMono-Regular', + }); + + this.renderText(ctx, 'Active Public', { + scale: scale, + x: margin, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'Roboto-Bold', + }); + + offset += this.renderText(ctx, keys.activePublic, { + scale: scale, + x: 1.25, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'RobotoMono-Regular', + }); + + this.renderText(ctx, 'Owner Public', { + scale: scale, + x: margin, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'Roboto-Bold', + }); + + offset += this.renderText(ctx, keys.ownerPublic, { + scale: scale, + x: 1.25, + y: offset, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: 'black', + fontSize: 0.14, + font: 'RobotoMono-Regular', + }); + + this.renderText(ctx, 'v0.1', { + scale: scale, + x: maxLineWidth - 0.2, + y: offset - 0.2, + lineHeight: lineHeight, + maxWidth: maxLineWidth, + color: '#bbbbbb', + fontSize: 0.14, + font: 'Roboto-Regular', + }); + + return ctx; + } + + render() { + // The SVG from svg-inline-loader is raw markup; we wrap it in a data URI + // so it can be rendered as an for canvas conversion. + var svgDataUri = + 'data:image/svg+xml;base64,' + + (typeof btoa !== 'undefined' + ? btoa(pdfLogoSvg) + : ''); + + return ( +
+ +
+ ); + } +} + +export default PdfDownload; diff --git a/src/app/components/elements/RouteSettings.jsx b/src/app/components/elements/RouteSettings.jsx index a14535840..8bad6ebe5 100644 --- a/src/app/components/elements/RouteSettings.jsx +++ b/src/app/components/elements/RouteSettings.jsx @@ -1,25 +1,45 @@ import React from 'react'; +import reactForm from 'app/utils/ReactForm'; import { connect } from 'react-redux'; import tt from 'counterpart'; +import shouldComponentUpdate from 'app/utils/shouldComponentUpdate'; import * as transactionActions from 'app/redux/TransactionReducer'; import * as userActions from 'app/redux/UserReducer'; import * as globalActions from 'app/redux/GlobalReducer'; import Icon from 'app/components/elements/Icon'; +import { FormattedHTMLMessage } from 'app/Translator'; +import { validate_account_name } from 'app/utils/ChainValidation'; +import ConfirmWithdrawVestingRoute from 'app/components/elements/ConfirmWithdrawVestingRoute'; +import LoadingIndicator from 'app/components/elements/LoadingIndicator'; +import { api } from '@steemit/steem-js'; class RouteSettings extends React.Component { constructor(props) { super(props); + this.shouldComponentUpdate = shouldComponentUpdate(this, 'RouteSettings'); this.state = { - broadcasting: false, errorMessage: undefined, - proxyAccount: '', - percentage: 100, - autoVest: false, + remainingPercentage: 100, + maxWithdrawRoutes: 10, }; + this.initForm(props); } - componentDidMount() { + async componentWillMount() { this.updateRemainingPercentage(this.props.withdraw_routes); + await this.fetchConfig() + } + + fetchConfig() { + api.callAsync('database_api.get_config', {}) + .then(res => { + this.setState({ + maxWithdrawRoutes: res.STEEM_MAX_WITHDRAW_ROUTES + }); + }) + .catch(err => { + console.error('Error fetching config:', err); + }); } componentDidUpdate(prevProps) { @@ -32,180 +52,337 @@ class RouteSettings extends React.Component { if (!routes) return; const totalRoutedPercentage = routes.reduce((total, route) => total + route.percent, 0); const remainingPercentage = 100 - (totalRoutedPercentage / 100); - this.setState({ percentage: remainingPercentage }); + this.setState({ remainingPercentage }); } removeWithdrawRoute = (toAccount) => { - this.setState({ broadcasting: true, errorMessage: undefined }); + this.setState({ loading: true, errorMessage: undefined }); const { account, setWithdrawVestingRoute } = this.props; - - // To remove a route, you set the percent to 0 setWithdrawVestingRoute({ account, proxy: toAccount, percent: 0, autoVest: false, - successCallback: () => this.setState({ broadcasting: false }), - errorCallback: (error) => this.setState({ broadcasting: false, errorMessage: String(error) }), + successCallback: () => this.setState({ loading: false }), + errorCallback: (error) => this.setState({ loading: false, errorMessage: String(error) }), }); }; - setWithdrawRoute = (e) => { - e.preventDefault(); - this.setState({ broadcasting: true, errorMessage: undefined }); - + setWithdrawRoute = () => { + this.setState({ loading: true, errorMessage: undefined }); const { account, setWithdrawVestingRoute, hideModal } = this.props; - const { proxyAccount, percentage, autoVest } = this.state; + const { to, percentage, asset } = this.state; setWithdrawVestingRoute({ account, - proxy: proxyAccount, - percent: Math.round(percentage * 100), // API expects percentage * 100 - autoVest, + proxy: to.value, + percent: Math.round(percentage.value * 100), // API expects percentage * 100 + autoVest: asset.value === 'SP', successCallback: () => { - this.setState({ broadcasting: false }); - hideModal(); // Close modal on success + this.setState({ loading: false }); + to.props.onChange('') + percentage.props.onChange(0) + // hideModal(); }, errorCallback: (error) => { - this.setState({ broadcasting: false, errorMessage: String(error) }); + this.setState({ loading: false, errorMessage: String(error) }); }, }); }; - handleInputChange = (event) => { - const { name, value } = event.target; - this.setState({ [name]: value }); + validatePercentage = (percentage, remainingPercentage, to) => { + const { withdraw_routes } = this.props; + const withdrawRoutes = withdraw_routes && withdraw_routes.length > 0 + ? withdraw_routes + : []; + if (remainingPercentage <= 0) { + return null + } + if (!percentage || isNaN(percentage)) { + return 'Percentage is required and must be a number.'; + } + const percentageFloat = parseFloat(percentage); + if (percentageFloat <= 0 || percentageFloat > 100) { + return `Percentage must be greater than 0 and less than or equal to ${100}.`; + } + const existingRoute = withdrawRoutes.find(route => route.to_account === to); + if (existingRoute) { + const updatedRemaining = remainingPercentage + (existingRoute.percent / 100); + if (percentageFloat > updatedRemaining) { + return `Only ${updatedRemaining}% is left.`; + } + } else { + if (percentageFloat > remainingPercentage) { + return `Only ${remainingPercentage}% is left.`; + } + } + return null; + } + + onChangeTo = async value => { + const cleanValue = value.replace(/\s+/g, ''); + this.state.to.props.onChange(cleanValue); }; - - handleCheckboxChange = (event) => { - const { name, checked } = event.target; - this.setState({ [name]: checked }); + + initForm(props) { + const fields = ['percentage', 'to','asset']; + const validate = values => { + const { remainingPercentage } = this.state; + return { + percentage: this.validatePercentage(values.percentage, remainingPercentage, values.to ), + to: validate_account_name(values.to), + }; + }; + reactForm({ + name: 'routeSettings', + instance: this, + fields, + initialValues: { percentage: 0, to: '', asset: 'STEEM' }, + validation: validate, + }); } render() { - if (!this.props.account) return null; // Render nothing if account data is not available yet + if (!this.props.account) return null; + const { remainingPercentage, maxWithdrawRoutes, errorMessage, to, percentage, asset, loading } = this.state; + const { withdraw_routes, hideModal, account } = this.props; + const { valid, handleSubmit, submitting } = this.state.routeSettings; - const { broadcasting, errorMessage, proxyAccount, percentage, autoVest } = this.state; - const { withdraw_routes, hideModal } = this.props; + const sortedRoutes = withdraw_routes && withdraw_routes.length > 0 + ? [...withdraw_routes].sort((a, b) => b.percent - a.percent) + : []; - let totalRoutedPercentage = 0; - const currentRoutesList = withdraw_routes && withdraw_routes.map(route => { - totalRoutedPercentage += route.percent; + const currentRoutesList = sortedRoutes.map(route => { return ( -
-
-
@{route.to_account}
-
{route.percent / 100}% of power down
-
-
- -
-
+
+ + + + + ); }); - - const remainingPercentage = 100 - (totalRoutedPercentage / 100); + const remainingRoutes = maxWithdrawRoutes - sortedRoutes.length const currentRoutes = ( -
-

Current Withdraw Routes

-

Your active power down routing configurations.

-
- {withdraw_routes && withdraw_routes.length > 0 ? currentRoutesList :

No withdraw routes are set.

} -
- {withdraw_routes && withdraw_routes.length > 0 && ( -
-
- Total routed: {totalRoutedPercentage / 100}% -
-
- Remaining to you: {remainingPercentage}% -
+
+
{tt('advanced_routes.current_routes', { accounts_number: (remainingRoutes) })}
+ + {sortedRoutes && sortedRoutes.length > 0 ? ( +
+
IDRequest IDAmountDate{tt('converttosteem_jsx.id')}{tt('converttosteem_jsx.request_id')}{tt('g.amount')}{tt('g.date')}
+ + {route.to_account} + + {route.percent / 100}%{route.auto_vest ? tt('advanced_routes.steem_power') : tt('advanced_routes.steem')} +
this.removeWithdrawRoute(route.to_account)} + title={tt('g.remove')} + disabled={loading} + > + + × + +
+
+ + + + + + + + + + { + + + + + } + {currentRoutesList} + +
{tt('advanced_routes.account')}{tt('advanced_routes.percentage')}{tt('advanced_routes.receive')}{tt('advanced_routes.remove')}
+ + {account} + + {remainingPercentage}%{tt('advanced_routes.steem')} +
+ ) : ( +

{tt('advanced_routes.no_routes')}

)} ); + return ( -
+
-

{tt('userwallet_jsx.advanced_routes')}

+

+ {tt('userwallet_jsx.set_advanced_routes')} +

- - {currentRoutes} -
- -

Add New Route

-
-
- - - +
{ + this.setWithdrawRoute(); + })}> +
+
+
+ +
+
+
-
- -
-
-
-

Withdraw Route Information:

-
    -
  • This only sets up routing rules for future power downs.
  • -
  • You must still initiate a power down separately.
  • -
  • Auto-vest converts payments directly to STEEM Power.
  • -
+
+
+ {tt('advanced_routes.from')} +
+
+
+ @ + +
- {errorMessage &&

{errorMessage}

}
-
+
+
+ {tt('advanced_routes.to')} +
+
+
+ @ + { + await this.onChangeTo(e.target.value); + }} + /> +
+
+ {(to && to.touched && to.error) ? ( +
+ {to && + to.touched && + to.error && + to.error}  +
+ ) : null} +
+
+
+ {tt('advanced_routes.percentage')} +
+
+
+ + {asset && asset.value && ( + + + + )} +
+
+ {(percentage && percentage.touched && percentage.error) ? ( +
+ {percentage && + percentage.touched && + percentage.error && + percentage.error}  +
+ ) : null} -
-
- - + {(remainingRoutes <= 0 || remainingPercentage <= 0) && (
+ {remainingRoutes <= 0 && tt('advanced_routes.not_remaining_routes')} + {(remainingRoutes <= 0 && remainingPercentage <= 0) &&
} + {remainingPercentage <= 0 && tt('advanced_routes.not_remaining_percentage')} +
)}
-
+ {currentRoutes} + {errorMessage && ( +
+
+ {errorMessage} +
+
+ )} +
+
+ {loading && ( + + +
+
+ )} + {!loading && ( + + + + )} +
+
+
); } @@ -218,11 +395,11 @@ export default connect( const accountName = values.get('account'); const routes = state.user.get('withdraw_routes'); - + const withdraw_routes = routes && routes.toJS ? routes.toJS() : []; - return { - ...ownProps, + return { + ...ownProps, account: accountName, withdraw_routes, }; @@ -244,6 +421,15 @@ export default connect( dispatch(userActions.getWithdrawRoutes({ account })); if (successCallback) return successCallback(...args); }; + const confirm = () => ( + + ); const isRemove = percent === 0; dispatch( transactionActions.broadcastOperation({ @@ -254,12 +440,7 @@ export default connect( percent: percent, auto_vest: autoVest, }, - confirm: isRemove - ? tt('g.are_you_sure') - : tt('userwallet_jsx.confirm_route_setup', { - percent: percent / 100, - account: proxy, - }), + confirm, successCallback: successCallbackWrapper, errorCallback, }) diff --git a/src/app/components/elements/RouteSettings.scss b/src/app/components/elements/RouteSettings.scss new file mode 100644 index 000000000..cd5ff254a --- /dev/null +++ b/src/app/components/elements/RouteSettings.scss @@ -0,0 +1,25 @@ +.flex-container-1 { + -ms-flex: 0 0 16.66667%; + flex: 0 0 16.66667%; + max-width: 16.66667%; +} + +.flex-container-2 { + -ms-flex: 0 0 83.33333%; + flex: 0 0 83.33333%; + max-width: 83.33333%; +} + +@media (max-width: 425px) { + .flex-container-1 { + -ms-flex: 0 0 33.33333%; + flex: 0 0 33.33333%; + max-width: 33.33333%; + } + + .flex-container-2 { + -ms-flex: 0 0 66.66667%; + flex: 0 0 66.66667%; + max-width: 66.66667%; + } +} diff --git a/src/app/components/elements/SteemToolsContent/SteemToolsContent.jsx b/src/app/components/elements/SteemToolsContent/SteemToolsContent.jsx new file mode 100644 index 000000000..aaead1999 --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/SteemToolsContent.jsx @@ -0,0 +1,187 @@ +import React from "react"; +import { SteemToolsMenu, DEFAULT_MENU_DATA } from "./SteemToolsMenu"; +import ClaimDiscounted from "./sections/ClaimDiscounted"; +import KeyGeneration from "./sections/KeyGeneration"; +import CreateAccount from "./sections/CreateAccount"; +import ChangeRecovery from "./sections/ChangeRecovery"; +import AuthorityManagement from "./sections/AuthorityManagement"; +import UpdateProxy from "./sections/UpdateProxy"; +import DeclineVoting from "./sections/DeclineVoting"; +import CreateWitness from "./sections/CreateWitness"; +import GenerateBrainKeys from "./sections/GenerateBrainKeys"; +import PublishPriceFeed from "./sections/PublishPriceFeed"; +import DisableWitness from "./sections/DisableWitness"; + + +class SteemToolsContent extends React.Component { + constructor(props) { + super(props); + + this.DESKTOP_MIN = 900; + + this.state = { + collapsed: false, + activeItemId: props.defaultKey || "claim-discounted", + activeCategoryId: null, + query: "", + variant: "collapsible", + isDesktop: false, + }; + + this.handleToggle = this.handleToggle.bind(this); + this.handleSelectItem = this.handleSelectItem.bind(this); + this.scrollContentTop = this.scrollContentTop.bind(this); + this.renderContent = this.renderContent.bind(this); + this.getCategorySectionIds = this.getCategorySectionIds.bind(this); + this.onResize = this.onResize.bind(this); + } + + componentDidMount() { + this.onResize(); + window.addEventListener("resize", this.onResize); + this.scrollContentTop(); + } + + componentWillUnmount() { + window.removeEventListener("resize", this.onResize); + } + + onResize() { + var isDesktopNow = window.innerWidth >= this.DESKTOP_MIN; + + this.setState(function (s) { + if (s.isDesktop === isDesktopNow) return null; + + return { + isDesktop: isDesktopNow, + collapsed: isDesktopNow ? false : true, + }; + }); + } + + scrollContentTop() { + if (this._contentRef) { + this._contentRef.scrollTop = 0; + } + } + + handleToggle() { + this.setState(function (s) { + return { collapsed: !s.collapsed }; + }); + } + + handleSelectItem(item) { + var shouldAutoClose = (this.state.variant === "collapsible") && !this.state.isDesktop; + + this.setState({ + activeItemId: item.id, + activeCategoryId: null, + collapsed: shouldAutoClose ? true : this.state.collapsed, + }); + + this.scrollContentTop(); + if (this.props.onSelect) this.props.onSelect(item); + } + + getCategorySectionIds(menuData, categoryId) { + var sec = (menuData || []).filter(function (s) { + return s.id === categoryId; + })[0]; + if (!sec) return []; + return (sec.items || []).map(function (it) { + return it.id; + }); + } + + renderContent(sectionId) { + switch (sectionId) { + case "claim-discounted": return ; + case "key-generation": return ; + case "create-account": return ; + + case "change-recovery": return ; + case "authority-management": return ; + + case "update-proxy": return ; + case "decline-voting": return ; + + case "create-witness": return ; + case "generate-brain-keys": return ; + case "publish-price-feed": return ; + case "disable-witness": return ; + + default: + return (
); + } + } + + render() { + var palette = this.props.menuData || DEFAULT_MENU_DATA; + var collapsed = this.state.collapsed; + var variant = this.state.variant; + + var wrapperClass = [ + "advtools-wrapper", + collapsed && variant === "collapsible" ? "collapsed" : "", + ].filter(Boolean).join(" "); + + var sectionIds = []; + if (this.state.activeCategoryId) { + sectionIds = this.getCategorySectionIds(palette, this.state.activeCategoryId); + } else if (this.state.activeItemId) { + sectionIds = [this.state.activeItemId]; + } + + return ( +
+
+
+ + + + + {!this.state.isDesktop && !collapsed && ( +
+ )} + +
+ {sectionIds.map(function (id) { + return ( +
+ {this.renderContent(id)} +
+ ); + }.bind(this))} +
+
+ +
+
+ ); + } +} + +export default SteemToolsContent; diff --git a/src/app/components/elements/SteemToolsContent/SteemToolsContent.scss b/src/app/components/elements/SteemToolsContent/SteemToolsContent.scss new file mode 100644 index 000000000..a5b51fe31 --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/SteemToolsContent.scss @@ -0,0 +1,341 @@ +.advtools-wrapper { + --advtools-offset: 0px; + display: flex; + height: calc(100vh - var(--advtools-offset)); + min-height: 480px; + border-radius: 20px; + overflow: hidden; + position: relative; + + @include themify($themes) { + background: themed('backgroundColor'); + border: themed('border'); + } +} + +.advtools-content { + flex: 1 1 auto; + min-width: 0; + overflow-y: auto; + padding: 32px 36px; + + @include themify($themes) { + background: themed('backgroundColor'); + } +} + +.advtools-overlay { + content: ""; + position: absolute; + inset: 0; + display: none; + z-index: 10; + backdrop-filter: blur(1px); + + @include themify($themes) { + background: themed('overlayBackground'); + } +} + +.advtools-wrapper:not(.collapsed) .advtools-overlay { + display: none; +} + +.advtools-input-group-error { + .input-group-label { + @include themify($themes) { + color: themed('backgroundColorDanger'); + border-color: themed('backgroundColorDanger'); + } + + background-color: rgba(220, 53, 69, 0.15); + } + + .input-group-field { + @include themify($themes) { + border-color: themed('backgroundColorDanger'); + } + } +} + +.code-block { + padding: 14px 16px; + border-radius: 10px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 13px; + overflow-x: auto; + position: relative; + + @include themify($themes) { + background-color: themed('codeBlockBackground'); + color: themed('codeBlockText'); + } +} + +.grid-2 { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 18px; +} + +.advtools-grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.advtools-grid-dynamic-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 1rem; +} + +.advtools-grid-dynamic-1 { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; + margin-bottom: 1rem; +} + +.KeyGenerationTableWrapper { + overflow-x: auto; +} + +.KeyGenerationTable { + width: 100%; + text-align: left; + font-family: monospace; + + th, + td { + padding: 10px; + } + + thead { + @include themify($themes) { + background-color: themed('tableRowEvenBackgroundColor'); + color: themed('textColorPrimary'); + } + } + + .key-type-cell { + font-weight: bold; + width: 220px; + } + + .key-value-cell { + word-break: break-all; + } + + tbody { + tr.role-group-even { + @include themify($themes) { + background-color: themed('sectionBackgroundColor'); + } + } + + tr.role-group-odd { + @include themify($themes) { + background-color: themed('tableRowEvenBackgroundColor'); + } + } + } +} + +.advtools-panel { + border-radius: 12px; + border: transparent; + transition: 0.2s all ease-in-out; + padding: 22px; + margin-top: 12px; + + @include themify($themes) { + background: themed('sectionBackgroundColor'); + box-shadow: 2px 2px 2px 0 rgba(0, 0, 0, 0.1), 7px 7px 0 0 themed('buttonBoxShadow'); + } +} + +.advtools-btn-primary { + border: none; + transition: 0.2s all ease-in-out; + + @include themify($themes) { + background-color: themed('buttonBackground'); + box-shadow: 0px 0px 0px 0 rgba(0, 0, 0, 0), 5px 5px 0 0 themed('buttonBoxShadow'); + color: themed('buttonText'); + } + + &:hover, + &:focus { + @include themify($themes) { + background-color: themed('buttonBackgroundHover'); + box-shadow: 2px 2px 2px 0 rgba(0, 0, 0, 0.1), 7px 7px 0 0 themed('buttonBoxShadowHover'); + color: themed('buttonTextHover'); + } + } + + &.disabled, + &[disabled] { + opacity: 0.25; + cursor: not-allowed; + + @include themify($themes) { + background-color: themed('buttonBackground'); + box-shadow: 0px 0px 0px 0 rgba(0, 0, 0, 0); + color: themed('buttonText'); + } + } +} + +.advtools-panel .advtools-master-check, +.advtools-panel .advtools-acknowledge-check { + display: flex; + align-items: center; + padding: 10px; + border-radius: 4px; + + label.switch { + margin-top: auto; + margin-bottom: auto; + } +} + +.advtools-master-check { + @include themify($themes) { + background: themed('highlightBackgroundColor'); + border-left: 4px solid themed('colorAccent'); + } +} + +.advtools-acknowledge-check { + @include themify($themes) { + background: themed('backgroundColorWarning'); + border-left: 4px solid themed('backgroundColorDanger'); + } +} + +.advtools-message-error { + display: flex; + align-items: center; + padding: 10px; + border-radius: 4px; + margin: 1rem 0; + + @include themify($themes) { + background: themed('backgroundColorWarning'); + border-left: 4px solid themed('backgroundColorDanger'); + color: themed('textColorPrimary'); + } +} + +.advtools-message-success { + display: flex; + align-items: center; + padding: 10px; + border-radius: 4px; + margin: 1rem 0; + + @include themify($themes) { + background: themed('highlightBackgroundColor'); + border-left: 4px solid themed('colorAccent'); + color: themed('textColorPrimary'); + } +} + +.advtools-error-hint { + font-size: 12px; + margin-bottom: 1rem; + + @include themify($themes) { + color: themed('textColorError'); + } +} + +#generate-button { + margin-right: 0 !important; +} + +@media (max-width: 900px) { + .advtools-content { + padding-top: 56px; + } +} + +@media (max-width: 1024px) { + .grid-2 { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .advtools-content { + padding: 20px; + } + + .advtools-wrapper:not(.collapsed) .advtools-overlay { + display: block; + } +} + +@media (max-width: 550px) { + .advtools-panel { + #generate-button { + width: 100%; + } + + .row-column-mobile { + display: flex; + flex-direction: column; + } + + .flex-mobile-full { + width: 100%; + flex: none; + max-width: 100%; + padding-top: 0; + padding-bottom: 8px; + } + + .input-group-mobile { + width: 100%; + } + + .grid-column-mobile { + grid-template-columns: 1fr; + } + } +} + +.flex-container-1-extended { + -ms-flex: 0 0 33.33333%; + flex: 0 0 33.33333%; + max-width: 33.33333%; +} + +.flex-container-2-extended { + -ms-flex: 0 0 66.66667%; + flex: 0 0 66.66667%; + max-width: 66.66667%; +} + +.change-recovery-account-hint { + margin-top: 0; + font-size: 12px; + opacity: 0.8; + margin-bottom: 1.25rem; +} + +@media (max-width: 425px) { + .flex-container-1-extended { + -ms-flex: 0 0 41.66667%; + flex: 0 0 41.66667%; + max-width: 41.66667%; + } + + .flex-container-2-extended { + -ms-flex: 0 0 58.33333%; + flex: 0 0 58.33333%; + max-width: 58.33333%; + } +} diff --git a/src/app/components/elements/SteemToolsContent/SteemToolsMenu.jsx b/src/app/components/elements/SteemToolsContent/SteemToolsMenu.jsx new file mode 100644 index 000000000..3a17d0b1f --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/SteemToolsMenu.jsx @@ -0,0 +1,121 @@ +import React from "react"; +import tt from 'counterpart'; + +const DEFAULT_MENU_DATA = [ + { + id: 'account-creation', + title: tt('steem_tools.menu.account_creation'), + items: [ + { id: 'claim-discounted', label: tt('steem_tools.menu.claim_account_creation_token') }, + { id: 'key-generation', label: tt('steem_tools.menu.key_generator') }, + { id: 'create-account', label: tt('steem_tools.menu.create_account') }, + ], + }, + { + id: 'account-security', + title: tt('steem_tools.menu.account_security'), + items: [ + { id: 'change-recovery', label: tt('steem_tools.menu.change_recovery_account') }, + { id: 'authority-management', label: tt('steem_tools.menu.authority_management') }, + ], + }, + { + id: 'governance', + title: tt('steem_tools.menu.governance'), + items: [ + { id: 'update-proxy', label: tt('steem_tools.menu.update_proxy') }, + { id: 'decline-voting', label: tt('steem_tools.menu.decline_voting_rights') }, + ], + }, + { + id: 'witness-operations', + title: tt('steem_tools.menu.witness_operations'), + items: [ + { id: 'create-witness', label: tt('steem_tools.menu.create_update_witness') }, + { id: 'generate-brain-keys', label: tt('steem_tools.menu.generate_brain_keys') }, + { id: 'publish-price-feed', label: tt('steem_tools.menu.publish_witness_price_feed') }, + { id: 'disable-witness', label: tt('steem_tools.menu.disable_witness') }, + ], + }, +]; + +class SteemToolsMenu extends React.Component { + constructor(props) { + super(props); + this.handleSelectItem = this.handleSelectItem.bind(this); + } + + handleSelectItem(item) { + if (this.props.onSelectItem) this.props.onSelectItem(item); + } + + filterItems(menu, query) { + if (!query) return menu; + var q = String(query || "").toLowerCase(); + return (menu || []) + .map(function (sec) { + return { + id: sec.id, + title: sec.title, + items: (sec.items || []).filter(function (it) { + return it.label.toLowerCase().indexOf(q) !== -1; + }), + }; + }) + .filter(function (sec) { + return sec.items.length > 0; + }); + } + + render() { + var menuData = this.props.menuData || DEFAULT_MENU_DATA; + var filtered = this.filterItems(menuData, this.props.query); + var collapsed = !!this.props.collapsed; + var variant = this.props.variant || "collapsible"; + var activeItemId = this.props.activeItemId; + + return ( +
+
+ {filtered.map(function (section) { + return ( +
+
+ {section.title} +
+ + {section.items.map(function (item) { + return ( +
+
{item.label}
+
+ ); + }.bind(this))} +
+ ); + }.bind(this))} +
+
+ ); + } +} + +export { SteemToolsMenu, DEFAULT_MENU_DATA }; diff --git a/src/app/components/elements/SteemToolsContent/SteemToolsMenu.scss b/src/app/components/elements/SteemToolsContent/SteemToolsMenu.scss new file mode 100644 index 000000000..ab030b39e --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/SteemToolsMenu.scss @@ -0,0 +1,222 @@ +.advtools-wrapper { + display: flex; + min-height: 480px; + border-radius: 20px; + overflow: hidden; + position: relative; + --advtools-offset: 0px; + height: max-content; + + @include themify($themes) { + border: themed('border'); + background: themed('backgroundColor'); + } +} + +.advtools-sidebar { + position: relative; + display: flex; + flex-direction: column; + width: 280px; + flex: 0 0 auto; + flex-basis: 280px; + overflow: hidden; + transition: width .25s ease, flex-basis .25s ease, opacity .2s ease; + + @include themify($themes) { + background: themed('moduleBackgroundColor'); + color: themed('textColorPrimary'); + } +} + +.advtools-scroll { + flex: 1; + overflow: auto; + padding: 8px 10px 14px; + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.28) rgba(0, 0, 0, 0.04); +} + +.advtools-scroll::-webkit-scrollbar { + width: 10px; +} + +.advtools-scroll::-webkit-scrollbar-track { + margin-block: 8px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.04); +} + +.advtools-scroll::-webkit-scrollbar-thumb { + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; + background: rgba(0, 0, 0, 0.28); +} + +.advtools-menu-section { + margin: 8px 0 14px; +} + +.advtools-section-title { + font-weight: 800; + padding: 0 10px; + text-transform: uppercase; + letter-spacing: .6px; + opacity: .95; + font-size: 12px; + @include themify($themes) { + color: themed('colorAccent'); + } +} + +.advtools-item { + display: flex; + align-items: center; + gap: 10px; + padding: 2.5px 10px; + margin: 6px 0; + border-radius: 10px; + cursor: pointer; + transition: background .15s ease, transform .05s ease, box-shadow .15s ease, color .15s ease; + font-size: 14px; + + @include themify($themes) { + color: themed('textColorPrimary'); + } +} + +.advtools-item:hover { + background: rgba(31, 191, 143, 0.10); + box-shadow: inset 0 0 0 1px rgba(31, 191, 143, 0.15); +} + +.advtools-item:active { + transform: scale(.98); +} + +.advtools-item.active { + background: rgba(31, 191, 143, 0.18); + box-shadow: inset 0 0 0 1px rgba(31, 191, 143, 0.22); +} + +.advtools-item .ico { + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + opacity: .95; + + @include themify($themes) { + color: themed('textColorPrimary'); + } +} + +.advtools-item.active .ico { + @include themify($themes) { + color: themed('colorAccent'); + } +} + +.advtools-item .label { + font-size: 14px; +} + +.advtools-toggle { + position: absolute; + top: 20px; + left: 280px; + width: 18px; + height: 55px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 30; + border: 1px solid #d7dbe0; + border-left: none; + border-radius: 0 10px 10px 0; + box-shadow: 2px 0 6px rgba(0, 0, 0, 0.03); + transition: background .15s ease, border-color .15s ease, left .25s ease; + appearance: none; + + @include themify($themes) { + background: themed('backgroundColorEmphasis'); + } +} + +.advtools-toggle:hover { + @include themify($themes) { + background: themed('moduleBackgroundColor'); + } +} + +.advtools-toggle-chevron { + width: 7.5px; + height: 7.5px; + border-right: 1.75px solid #aaa; + border-bottom: 1.75px solid #aaa; + transform: rotate(135deg); + margin-right: 2px; + transition: transform .2s ease, border-color .1s ease, margin-right .2s ease; +} + +.advtools-toggle-chevron.flipped { + transform: rotate(-45deg); + margin-right: 5px; +} + +.advtools-toggle:hover .advtools-toggle-chevron { + @include themify($themes) { + border-color: themed('colorAccent'); + } +} + +.advtools-wrapper.collapsed .advtools-sidebar { + width: 0; + flex-basis: 0; + opacity: 0; + overflow: hidden; +} + +.advtools-wrapper.collapsed .advtools-toggle { + left: 0; +} + +.advtools-overlay { + content: ""; + position: absolute; + inset: 0; + display: none; + z-index: 10; + backdrop-filter: blur(1px); + + @include themify($themes) { + background: themed('overlayBackground'); + } +} + +@media (max-width: 768px) { + .advtools-sidebar { + position: absolute; + z-index: 20; + height: 100%; + width: 260px; + flex: 0 0 260px; + transform: translateX(0); + transition: transform .2s ease; + box-shadow: 0 6px 30px rgba(0, 0, 0, 0.2); + } + + .advtools-toggle { + left: 260px; + } + + .advtools-wrapper.collapsed .advtools-sidebar { + transform: translateX(-110%); + width: 260px; + flex: 0 0 260px; + } +} diff --git a/src/app/components/elements/SteemToolsContent/sections/AuthorityManagement.jsx b/src/app/components/elements/SteemToolsContent/sections/AuthorityManagement.jsx new file mode 100644 index 000000000..29bd59bad --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/sections/AuthorityManagement.jsx @@ -0,0 +1,1144 @@ +import React from 'react'; +import { Link } from 'react-router'; +import { connect } from 'react-redux'; +import tt from 'counterpart'; +import { api } from '@steemit/steem-js'; +import { FormattedHTMLMessage } from 'app/Translator'; +import LoadingIndicator from 'app/components/elements/LoadingIndicator'; +import ConfirmAuthorityManagement from 'app/components/elements/ConfirmAuthorityManagement'; +import AuthorityManagementSmallTable from './AuthorityManagementSmallTable'; +import { validate_account_name } from 'app/utils/ChainValidation'; +import * as transactionActions from 'app/redux/TransactionReducer'; +import * as appActions from 'app/redux/AppReducer'; +import * as globalActions from 'app/redux/GlobalReducer'; +import * as userActions from 'app/redux/UserReducer'; + +const AUTHORITY_TYPES = ['owner', 'active', 'posting']; +const MAX_AUTHORITY_WEIGHT = 65535; + +function getFieldValue(source, key, fallback = undefined) { + if (!source) return fallback; + if (typeof source.get === 'function') { + const value = source.get(key); + return value === undefined ? fallback : value; + } + if (Object.prototype.hasOwnProperty.call(source, key)) { + const value = source[key]; + return value === undefined ? fallback : value; + } + return fallback; +} + +function toJSValue(value) { + if (!value) return value; + if (typeof value.toJS === 'function') return value.toJS(); + return value; +} + +function normalizeAuthority(authority) { + const raw = toJSValue(authority) || {}; + return { + weight_threshold: Number(raw.weight_threshold || 1), + account_auths: Array.isArray(raw.account_auths) + ? raw.account_auths.map((item) => [item[0], Number(item[1])]) + : [], + key_auths: Array.isArray(raw.key_auths) + ? raw.key_auths.map((item) => [item[0], Number(item[1])]) + : [], + }; +} + +function sanitizeAccountInput(value) { + return String(value || '').replace(/\s/g, '').toLowerCase(); +} + +function sanitizeIntegerInput(value) { + return String(value == null ? '' : value).replace(/[^\d]/g, ''); +} + +function preventInvalidNumberKeys(e) { + if (['e', 'E', '+', '-', '.', ',', ' '].includes(e.key)) { + e.preventDefault(); + } +} + +class AuthorityManagement extends React.Component { + constructor(props) { + super(props); + + this.state = { + authorityType: 'posting', + targetAuthorityAccount: '', + weight: '1', + weightThreshold: '1', + loading: false, + error: null, + success: null, + nameError: null, + nameAvailable: null, + isCheckingName: false, + optimisticAuthorities: {}, + acknowledged: false, + }; + + this.checkAccountNameTimer = null; + + this.onChange = this.onChange.bind(this); + this.onAuthorityTypeChange = this.onAuthorityTypeChange.bind(this); + this.onAddAuthority = this.onAddAuthority.bind(this); + this.onRemoveAllAuthorities = this.onRemoveAllAuthorities.bind(this); + this.onRemoveSingleAuthority = this.onRemoveSingleAuthority.bind(this); + this.onFailure = this.onFailure.bind(this); + this.onSuccess = this.onSuccess.bind(this); + this.checkAccountName = this.checkAccountName.bind(this); + this.logAuthoritiesSnapshot = this.logAuthoritiesSnapshot.bind(this); + this.onAcknowledgeToggle = this.onAcknowledgeToggle.bind(this); + } + + componentDidMount() { + this.syncThresholdFromProps(this.state.authorityType, this.props); + this.logAuthoritiesSnapshot(this.props); + } + + componentDidUpdate(prevProps, prevState) { + if ( + prevState.authorityType !== this.state.authorityType || + prevProps.account !== this.props.account + ) { + this.syncThresholdFromProps(this.state.authorityType, this.props); + } + + if (prevProps.account !== this.props.account) { + this.logAuthoritiesSnapshot(this.props); + } + } + + componentWillUnmount() { + if (this.checkAccountNameTimer) { + clearTimeout(this.checkAccountNameTimer); + } + } + + logAuthoritiesSnapshot(props = this.props) { + const snapshot = AUTHORITY_TYPES.reduce((acc, type) => { + acc[type] = this.getSelectedAuthority(type, props); + return acc; + }, {}); + } + + getSelectedAuthority(authorityType = this.state.authorityType, props = this.props) { + const { account } = props; + const { optimisticAuthorities } = this.state; + + if (optimisticAuthorities && optimisticAuthorities[authorityType]) { + return normalizeAuthority(optimisticAuthorities[authorityType]); + } + + if (!account) { + return normalizeAuthority(null); + } + return normalizeAuthority(getFieldValue(account, authorityType, null)); + } + + getAuthorityRows(props = this.props) { + return AUTHORITY_TYPES.flatMap((type) => { + const authority = this.getSelectedAuthority(type, props); + return authority.account_auths.map(([name, accountWeight]) => ({ + type, + name, + weight: accountWeight, + })); + }); + } + + syncThresholdFromProps(authorityType, props = this.props) { + const authority = this.getSelectedAuthority(authorityType, props); + const nextThreshold = String(authority.weight_threshold || 1); + if (this.state.weightThreshold !== nextThreshold) { + this.setState({ weightThreshold: nextThreshold }); + } + } + + onChange(e) { + const { name, value } = e.target; + + if (name === 'targetAuthorityAccount') { + const sanitizedValue = sanitizeAccountInput(value); + const nameValidationError = sanitizedValue + ? validate_account_name(sanitizedValue) + : null; + + this.setState({ + targetAuthorityAccount: sanitizedValue, + nameError: nameValidationError, + nameAvailable: null, + isCheckingName: false, + error: null, + success: null, + }); + + if (this.checkAccountNameTimer) { + clearTimeout(this.checkAccountNameTimer); + } + + if (sanitizedValue && !nameValidationError) { + this.checkAccountName(sanitizedValue); + } + + return; + } + + if (name === 'weight' || name === 'weightThreshold') { + const sanitizedValue = sanitizeIntegerInput(value); + this.setState({ + [name]: sanitizedValue, + error: null, + success: null, + }); + return; + } + + this.setState({ + [name]: value, + error: null, + success: null, + }); + } + + onAuthorityTypeChange(e) { + const authorityType = e.target.value; + const authority = this.getSelectedAuthority(authorityType); + this.setState({ + authorityType, + weightThreshold: String(authority.weight_threshold || 1), + error: null, + success: null, + }); + } + + checkAccountName(username) { + this.setState({ nameAvailable: null }); + + this.checkAccountNameTimer = setTimeout(() => { + this.setState({ isCheckingName: true }); + + const normalizedUsername = username.trim().toLowerCase(); + + api.callAsync('condenser_api.lookup_accounts', [normalizedUsername, 1]) + .then((accounts) => { + const exists = + Array.isArray(accounts) && + accounts.length > 0 && + String(accounts[0]).toLowerCase() === normalizedUsername; + + if (this.state.targetAuthorityAccount === normalizedUsername) { + this.setState({ + nameAvailable: exists, + isCheckingName: false, + nameError: exists + ? null + : tt( + 'steem_tools.authority_management.error_account_not_found', + { + fallback: + 'Account not found. Authority account must exist.', + } + ), + }); + } + }) + .catch((error) => { + console.error( + 'API Error checking authority account name:', + error + ); + if (this.state.targetAuthorityAccount === normalizedUsername) { + this.setState({ + isCheckingName: false, + nameAvailable: null, + }); + } + }); + }, 500); + } + + onAcknowledgeToggle(e) { + this.setState({ acknowledged: e.target.checked }); + } + + onFailure(error) { + let errorMessage = error; + if ( + !errorMessage || + errorMessage === 0 || + errorMessage === false || + String(errorMessage).toLowerCase().includes('undefined') + ) { + errorMessage = tt( + 'steem_tools.authority_management.unexpected_error' + ); + } + + this.setState({ + loading: false, + error: errorMessage, + success: null, + }); + } + + onSuccess(authorityType, updatedAuthority) { + const { currentUser, refreshAccount } = this.props; + refreshAccount(currentUser); + this.setState((prevState) => ({ + loading: false, + error: null, + success: tt('steem_tools.authority_management.success_message'), + targetAuthorityAccount: '', + weight: '1', + nameError: null, + nameAvailable: null, + isCheckingName: false, + optimisticAuthorities: { + ...(prevState.optimisticAuthorities || {}), + [authorityType]: updatedAuthority, + }, + })); + } + + buildUpdatedAuthority(removeMode = false, removeAllMode = false) { + const { account } = this.props; + const { + authorityType, + targetAuthorityAccount, + weightThreshold, + weight, + } = this.state; + + if (!account) { + throw new Error( + tt('steem_tools.authority_management.error_no_account_data') + ); + } + + const currentAuthority = this.getSelectedAuthority(authorityType); + const parsedThreshold = parseInt(weightThreshold, 10); + + if (!Number.isInteger(parsedThreshold) || parsedThreshold < 1) { + throw new Error( + tt('steem_tools.authority_management.error_invalid_threshold') + ); + } + + if (removeAllMode) { + return { + weight_threshold: parsedThreshold, + account_auths: [], + key_auths: currentAuthority.key_auths, + }; + } + + const normalizedTarget = sanitizeAccountInput(targetAuthorityAccount); + const nameValidationError = validate_account_name(normalizedTarget); + const parsedWeight = parseInt(weight, 10); + + if (!normalizedTarget) { + throw new Error( + tt( + 'steem_tools.authority_management.error_no_target_authority_account' + ) + ); + } + + if (nameValidationError) { + throw new Error(nameValidationError); + } + + if ( + !removeMode && + (!Number.isInteger(parsedWeight) || parsedWeight < 1) + ) { + throw new Error( + tt('steem_tools.authority_management.error_invalid_weight') + ); + } + + if (!removeMode && parsedWeight > MAX_AUTHORITY_WEIGHT) { + throw new Error( + tt('steem_tools.authority_management.error_weight_too_large', { + max: MAX_AUTHORITY_WEIGHT, + fallback: `Weight cannot be greater than ${MAX_AUTHORITY_WEIGHT}.`, + }) + ); + } + + const nextAccountAuths = [...currentAuthority.account_auths]; + const existingIndex = nextAccountAuths.findIndex( + ([name]) => name === normalizedTarget + ); + + if (removeMode) { + if (existingIndex === -1) { + throw new Error( + tt( + 'steem_tools.authority_management.error_authority_not_found' + ) + ); + } + nextAccountAuths.splice(existingIndex, 1); + } else if (existingIndex >= 0) { + nextAccountAuths[existingIndex] = [normalizedTarget, parsedWeight]; + } else { + nextAccountAuths.push([normalizedTarget, parsedWeight]); + } + + nextAccountAuths.sort((a, b) => a[0].localeCompare(b[0])); + + return { + weight_threshold: parsedThreshold, + account_auths: nextAccountAuths, + key_auths: currentAuthority.key_auths, + }; + } + + getConfirmPayload(removeMode, removeAllMode, updatedAuthority, currentAuthority) { + const { accountName } = this.props; + const { authorityType, targetAuthorityAccount, weight } = this.state; + + const authorityTypeLabel = tt( + `steem_tools.authority_management.authority_${authorityType}` + ); + + if (removeAllMode) { + const accountNames = currentAuthority.account_auths.map( + ([name]) => `@${name}` + ); + const count = accountNames.length; + + if (count <= 5) { + return tt( + 'steem_tools.authority_management.confirm_remove_multiple_message', + { + count, + authorityType: authorityTypeLabel, + account: `@${accountName}`, + accounts: accountNames.join(', '), + fallback: `You are about to remove ${count} ${authorityTypeLabel} account authorit${ + count === 1 ? 'y' : 'ies' + } from @${accountName}: ${accountNames.join( + ', ' + )}. Continue?`, + } + ); + } + + return tt( + 'steem_tools.authority_management.confirm_remove_multiple_count_message', + { + count, + authorityType: authorityTypeLabel, + account: `@${accountName}`, + fallback: `You are about to remove ${count} ${authorityTypeLabel} account authorities from @${accountName}. Continue?`, + } + ); + } + + const normalizedTarget = sanitizeAccountInput(targetAuthorityAccount); + const existingRow = currentAuthority.account_auths.find( + ([name]) => name === normalizedTarget + ); + const operation = { + action: removeMode + ? tt( + 'steem_tools.authority_management.confirm_action_remove_single', + { + fallback: 'Remove authority', + } + ) + : tt( + 'steem_tools.authority_management.confirm_action_add_single', + { + fallback: 'Add authority', + } + ), + account: `@${accountName}`, + authority_type: authorityTypeLabel, + target_account: `@${normalizedTarget}`, + weight_threshold: String(updatedAuthority.weight_threshold), + weight: removeMode + ? String(existingRow ? existingRow[1] : '') + : String(parseInt(weight, 10)), + }; + + return () => ; + } + + submitAuthorityChange(removeMode = false, removeAllMode = false) { + const { currentUser, accountName, account, updateAuthority } = this.props; + const { authorityType, nameError, nameAvailable, isCheckingName, acknowledged } = + this.state; + + if (!acknowledged) { + return; + } + + if (!currentUser) { + this.setState({ + error: tt('steem_tools.authority_management.error_no_account'), + success: null, + }); + return; + } + + if (!accountName) { + this.setState({ + error: tt( + 'steem_tools.authority_management.error_no_target_account' + ), + success: null, + }); + return; + } + + if (currentUser !== accountName) { + this.setState({ + error: tt('steem_tools.authority_management.error_not_allowed'), + success: null, + }); + return; + } + + if (!removeMode && !removeAllMode) { + if (isCheckingName) { + this.setState({ + error: tt( + 'steem_tools.authority_management.error_checking_account', + { + fallback: 'Still checking account name. Please wait.', + } + ), + success: null, + }); + return; + } + + if (nameError || !nameAvailable) { + this.setState({ + error: + nameError || + tt( + 'steem_tools.authority_management.error_invalid_account', + { + fallback: 'Invalid authority account', + } + ), + success: null, + }); + return; + } + } + + try { + const currentAuthority = this.getSelectedAuthority(authorityType); + const authority = this.buildUpdatedAuthority( + removeMode, + removeAllMode + ); + const memoKey = getFieldValue(account, 'memo_key', ''); + const jsonMetadata = getFieldValue(account, 'json_metadata', ''); + const confirm = this.getConfirmPayload( + removeMode, + removeAllMode, + authority, + currentAuthority + ); + + this.setState({ + loading: true, + error: null, + success: null, + }); + + updateAuthority( + accountName, + authorityType, + authority, + memoKey, + jsonMetadata, + confirm, + () => this.onSuccess(authorityType, authority), + this.onFailure + ); + } catch (e) { + this.setState({ + error: + e && e.message + ? e.message + : tt( + 'steem_tools.authority_management.unexpected_error' + ), + success: null, + }); + } + } + + onAddAuthority() { + this.submitAuthorityChange(false, false); + } + + onRemoveAllAuthorities() { + this.submitAuthorityChange(false, true); + } + + onRemoveSingleAuthority(type, name) { + const { loading } = this.state; + const { currentUser, accountName } = this.props; + + if (loading || currentUser !== accountName) return; + + this.setState( + { + authorityType: type, + targetAuthorityAccount: name, + nameError: null, + nameAvailable: true, + isCheckingName: false, + error: null, + success: null, + }, + () => { + this.submitAuthorityChange(true, false); + } + ); + } + + renderCurrentAuthoritiesDesktop(rows, canEdit) { + if (!rows.length) { + return ( +
+ {tt('steem_tools.authority_management.no_account_authorities')} +
+ ); + } + + return ( +
+ + + + + + + + + + {rows.map(({ type, name, weight }) => ( + + + + + + + ))} + +
{tt('steem_tools.authority_management.authority_type')} + {tt( + 'steem_tools.authority_management.authorized_account_header' + )} + {tt('steem_tools.authority_management.weight_header')} +
+ {tt( + `steem_tools.authority_management.authority_${type}` + )} + + @{name} + {weight} + +
+
+ ); + } + + render() { + const { currentUser, accountName } = this.props; + const { + authorityType, + targetAuthorityAccount, + weight, + weightThreshold, + loading, + error, + success, + nameError, + nameAvailable, + isCheckingName, + acknowledged, + } = this.state; + + const isOwner = !!currentUser && !!accountName && currentUser === accountName; + const canEdit = !loading && isOwner; + const currentSelectedAuthority = this.getSelectedAuthority(authorityType); + const rows = this.getAuthorityRows(); + const hasAuthorities = currentSelectedAuthority.account_auths.length > 0; + + return ( +
+
+
+

+ {tt('steem_tools.authority_management.title')} +

+
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+ {tt( + 'steem_tools.authority_management.account_to_modify' + )} +
+
+
+
+ @ + +
+
+
+ +
+
+
+ {tt( + 'steem_tools.authority_management.authority_type' + )} +
+
+
+ +
+
+ +
+
+
+ {tt( + 'steem_tools.authority_management.weight_threshold' + )} +
+
+
+ +
+ {tt( + 'steem_tools.authority_management.weight_threshold_hint' + )} +
+
+
+ +
+
+
+ {tt( + 'steem_tools.authority_management.add_account_authority' + )} +
+
+
+
+ @ + + {isCheckingName && ( +
+ +
+ )} +
+ + {nameError && ( +
+ {nameError} +
+ )} + + {nameAvailable === true && !nameError && ( +
+ {tt( + 'steem_tools.authority_management.success_account_found', + { + fallback: 'Account found', + } + )} +
+ )} + + {!nameError && + !nameAvailable && + !isCheckingName && ( +
+ {tt('steem_tools.authority_management.account_authority_hint',)} +
+ )} +
+
+ +
+
+
+ {tt('steem_tools.authority_management.weight')} +
+
+
+ +
+ {tt( + 'steem_tools.authority_management.weight_hint', + { + max: MAX_AUTHORITY_WEIGHT, + } + )} +
+
+
+ +
+
+

+ {tt( + 'steem_tools.authority_management.current_account_authorities' + )} +

+
+
+ +
+
+
+ +
+
+ {this.renderCurrentAuthoritiesDesktop( + rows, + canEdit && acknowledged + )} +
+
+
+ + {!loading && !isOwner && currentUser && accountName ? ( +
+
+
+ {tt( + 'steem_tools.authority_management.error_not_allowed' + )} +
+
+
+ ) : null} + + {!loading && error ? ( +
+
+
+ {error} +
+
+
+ ) : null} + + {!loading && success ? ( +
+
+
+ {success} +
+
+
+ ) : null} + +
+
+ + {tt('steem_tools.authority_management.acknowledge_warning')} + + +
+
+ +
+
+ {loading ? ( + + + + ) : ( + + + + + + )} +
+
+
+
+
+ ); + } +} + +export default connect( + (state, ownProps) => { + const user = state.user.get('current'); + const currentUser = user && user.get('username'); + + const accountName = ownProps.accountname || currentUser || ''; + + const account = accountName + ? state.global.getIn(['accounts', accountName]) + : null; + + return { + currentUser, + accountName, + account, + }; + }, + (dispatch) => ({ + updateAuthority: ( + account, + authorityType, + authority, + memoKey, + jsonMetadata, + confirm, + successCallback, + errorCallback + ) => { + const successCb = () => { + dispatch(globalActions.getState({ url: `@${account}/permissions` })); + if (successCallback) successCallback(); + }; + + const operation = { + account, + memo_key: memoKey || '', + json_metadata: jsonMetadata || '', + [authorityType]: authority, + }; + + dispatch( + transactionActions.broadcastOperation({ + type: 'account_update', + operation, + confirm, + successCallback: successCb, + errorCallback, + }) + ); + }, + refreshAccount: (username) => + dispatch( + userActions.refreshAccount({ + username, + }) + ), + removeNotification: (key) => + dispatch(appActions.removeNotification({ key })), + }) +)(AuthorityManagement); diff --git a/src/app/components/elements/SteemToolsContent/sections/AuthorityManagementSmallTable.jsx b/src/app/components/elements/SteemToolsContent/sections/AuthorityManagementSmallTable.jsx new file mode 100644 index 000000000..d7cac215e --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/sections/AuthorityManagementSmallTable.jsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { Link } from 'react-router'; +import tt from 'counterpart'; + +const AuthorityManagementSmallTable = ({ + rows, + loading, + canEdit, + onRemoveSingleAuthority, +}) => { + if (!rows.length) { + return ( +
+ {tt('steem_tools.authority_management.no_account_authorities')} +
+ ); + } + + return ( + + + + + + + + {rows.map(({ type, name, weight }) => ( + + + + ))} + +
+ + + {`${tt( + 'steem_tools.authority_management.current_account_authorities' + )} (${rows.length})`} + + +
+ + + {`${tt('steem_tools.authority_management.authority_type')}: `} + + {tt( + `steem_tools.authority_management.authority_${type}` + )} + + + + + {`${tt( + 'steem_tools.authority_management.authorized_account_header' + )}: `} + + @{name} + + + + + {`${tt( + 'steem_tools.authority_management.weight_header' + )}: `} + + {weight} + + + +
+ ); +}; + +export default AuthorityManagementSmallTable; diff --git a/src/app/components/elements/SteemToolsContent/sections/ChangeRecovery.jsx b/src/app/components/elements/SteemToolsContent/sections/ChangeRecovery.jsx new file mode 100644 index 000000000..d918fa744 --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/sections/ChangeRecovery.jsx @@ -0,0 +1,514 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import tt from 'counterpart'; +import { api } from '@steemit/steem-js'; +import { FormattedHTMLMessage } from 'app/Translator'; +import LoadingIndicator from 'app/components/elements/LoadingIndicator'; +import { validate_account_name } from 'app/utils/ChainValidation'; +import * as transactionActions from 'app/redux/TransactionReducer'; +import * as appActions from 'app/redux/AppReducer'; +import * as globalActions from 'app/redux/GlobalReducer'; +import * as userActions from 'app/redux/UserReducer'; +import ConfirmChangeRecoveryAccount from 'app/components/elements/ConfirmChangeRecoveryAccount'; + +class ChangeRecovery extends React.Component { + constructor(props) { + super(props); + this.state = { + newRecoveryAccount: '', + loading: false, + error: null, + success: null, + nameError: null, + nameAvailable: null, + isCheckingName: false, + acknowledged: false, + }; + this.checkAccountNameTimer = null; + + this.onChange = this.onChange.bind(this); + this.onSubmit = this.onSubmit.bind(this); + this.onFailure = this.onFailure.bind(this); + this.onSuccess = this.onSuccess.bind(this); + this.checkAccountName = this.checkAccountName.bind(this); + this.onAcknowledgeToggle = this.onAcknowledgeToggle.bind(this); + } + + componentWillUnmount() { + if (this.checkAccountNameTimer) { + clearTimeout(this.checkAccountNameTimer); + } + } + + onChange(e) { + const value = e.target.value.replace(/\s/g, '').toLowerCase(); + const nameValidationError = value ? validate_account_name(value) : null; + + this.setState({ + newRecoveryAccount: value, + nameError: nameValidationError, + nameAvailable: null, + isCheckingName: false, + error: null, + success: null, + }); + + if (this.checkAccountNameTimer) { + clearTimeout(this.checkAccountNameTimer); + } + + if (value && !nameValidationError) { + this.checkAccountName(value); + } + } + + checkAccountName(username) { + this.setState({ nameAvailable: null }); + this.checkAccountNameTimer = setTimeout(() => { + this.setState({ isCheckingName: true }); + const normalizedUsername = username.trim().toLowerCase(); + api.callAsync('condenser_api.lookup_accounts', [normalizedUsername, 1]) + .then(accounts => { + const exists = Array.isArray(accounts) && accounts.length > 0 && String(accounts[0]).toLowerCase() === normalizedUsername; + if (this.state.newRecoveryAccount === normalizedUsername) { + let existsError = !exists ? tt('steem_tools.change_recovery_account.error_account_not_found') : null; + if (exists && this.props.accountName === normalizedUsername) { + existsError = tt('steem_tools.change_recovery_account.error_same_account', { fallback: 'Cannot change to the same account' }); + } + this.setState({ + nameAvailable: exists && !existsError, + isCheckingName: false, + nameError: existsError + }); + } + }) + .catch(e => { + console.error('API Error checking account name:', e); + if (this.state.newRecoveryAccount === normalizedUsername) { + this.setState({ isCheckingName: false }); + } + }); + }, 500); + } + + onAcknowledgeToggle(e) { + this.setState({ acknowledged: e.target.checked }); + } + + onFailure(error) { + let errorMessage = error; + if ( + !errorMessage || + errorMessage === 0 || + errorMessage === false || + String(errorMessage).toLowerCase().includes('undefined') + ) { + errorMessage = tt('steem_tools.change_recovery_account.unexpected_error'); + } + + this.setState({ + loading: false, + error: errorMessage, + success: null, + }); + } + + onSuccess() { + const { currentUser, refreshAccount } = this.props; + refreshAccount(currentUser); + this.setState({ + loading: false, + error: null, + success: tt('steem_tools.change_recovery_account.success_message'), + newRecoveryAccount: '', + }); + } + + onSubmit() { + const { currentUser, account, accountName, changeRecoveryAccount } = this.props; + const { newRecoveryAccount, acknowledged } = this.state; + + if (!acknowledged) { + return; + } + + if (!currentUser) { + this.setState({ + error: tt('steem_tools.change_recovery_account.error_no_account'), + success: null, + }); + return; + } + + let recoveryAccount = ''; + let pendingRecoveryAccount = ''; + + if (account) { + const recoveryInfo = account.get('account_recovery'); + if (recoveryInfo) { + pendingRecoveryAccount = recoveryInfo.get('recovery_account') || ''; + } + recoveryAccount = account.get('recovery_account') || ''; + } + + if (!accountName) { + this.setState({ + error: tt('steem_tools.change_recovery_account.error_no_target_account'), + success: null, + }); + return; + } + + if (currentUser !== accountName) { + this.setState({ + error: tt('steem_tools.change_recovery_account.error_not_allowed'), + success: null, + }); + return; + } + + if (!newRecoveryAccount) { + this.setState({ + error: tt('g.required'), + success: null, + }); + return; + } + + if (this.state.nameError || !this.state.nameAvailable) { + this.setState({ + error: tt('steem_tools.change_recovery_account.error_invalid_account'), + success: null, + }); + return; + } + + this.setState({ + loading: true, + error: null, + success: null, + }); + + changeRecoveryAccount( + accountName, + recoveryAccount, + pendingRecoveryAccount, + newRecoveryAccount, + this.onSuccess, + this.onFailure + ); + } + + render() { + const { currentUser, account, accountName } = this.props; + const { newRecoveryAccount, loading, error, success, nameError, nameAvailable, isCheckingName, acknowledged } = this.state; + + let recoveryAccount = ''; + let pendingRecoveryAccount = ''; + let daysLeft = 0; + + if (account) { + const recoveryInfo = account.get('account_recovery'); + if (recoveryInfo) { + pendingRecoveryAccount = (recoveryInfo.get('recovery_account') || '').trim(); + + const effectiveOn = recoveryInfo.get('effective_on'); + if (effectiveOn) { + const effectiveDate = new Date(effectiveOn); + const now = new Date(); + daysLeft = Math.ceil((effectiveDate - now) / (1000 * 60 * 60 * 24)); + if (daysLeft < 0) daysLeft = 0; + } + } + recoveryAccount = account.get('recovery_account') || ''; + } + + const isOwner = + !!currentUser && + !!accountName && + currentUser === accountName; + + const canChangeRecovery = !loading && isOwner; + + return ( +
+
+
+

{tt('steem_tools.change_recovery_account.title')}

+
+ +
+
+
+ +
+
+
+
+ +
+
+
+
{tt('steem_tools.change_recovery_account.current_account')}
+
+
+
+ @ + +
+
+
+ +
+
+
{tt('steem_tools.change_recovery_account.current_recovery_account')}
+
+
+
+ @ + +
+
+
+ + {pendingRecoveryAccount ? ( +
+
+
{tt('steem_tools.change_recovery_account.pending_recovery_account')}
+
+
+
+ @ + +
+ {daysLeft > 0 ? ( +
+ {tt('steem_tools.change_recovery_account.days_left_message', { days: daysLeft })} +
+ ) : null} +
+
+ ) : null} + +
+
+
{tt('steem_tools.change_recovery_account.new_account')}
+
+
+
+ @ + + {isCheckingName && ( +
+ +
+ )} +
+ {nameError && ( +
+ {nameError} +
+ )} + {nameAvailable === true && !nameError && ( +
+ {tt('steem_tools.change_recovery_account.success_account_found', { fallback: 'Account found' })} +
+ )} + {!nameError && !nameAvailable && !isCheckingName && ( +
+ {tt('steem_tools.change_recovery_account.new_account_hint')} +
+ )} +
+
+ + {!loading && !isOwner && currentUser && accountName ? ( +
+
+
+ {tt('steem_tools.change_recovery_account.error_not_allowed')} +
+
+
+ ) : null} + + {!loading && error ? ( +
+
+
{error}
+
+
+ ) : null} + + {!loading && success ? ( +
+
+
{success}
+
+
+ ) : null} + +
+
+ + {tt('steem_tools.change_recovery_account.acknowledge_warning')} + + +
+
+ +
+
+ {loading ? ( + + + + ) : ( + + )} +
+
+
+
+
+ ); + } +} + +export default connect( + (state, ownProps) => { + const user = state.user.get('current'); + const currentUser = user && user.get('username'); + + const accountName = + ownProps.accountname || + currentUser || + ''; + + const account = accountName + ? state.global.getIn(['accounts', accountName]) + : null; + + return { + currentUser, + accountName, + account, + }; + }, + dispatch => ({ + changeRecoveryAccount: ( + account, + current_recovery_account, + pending_recovery_account, + new_account, + successCallback, + errorCallback + ) => { + const successCb = () => { + dispatch(globalActions.getState({ url: `@${account}/transfers` })); + if (successCallback) successCallback(); + }; + + const operation = { + account_to_recover: account, + new_recovery_account: new_account, + extensions: [], + }; + + const confirm = () => ( + pending_recovery_account + ? ( + + ) : ( + + ) + ); + + dispatch( + transactionActions.broadcastOperation({ + type: 'change_recovery_account', + operation, + successCallback: successCb, + errorCallback, + confirm, + }) + ); + }, + refreshAccount: username => + dispatch( + userActions.refreshAccount({ + username, + }) + ), + removeNotification: key => dispatch(appActions.removeNotification({ key })), + }) +)(ChangeRecovery); diff --git a/src/app/components/elements/SteemToolsContent/sections/ClaimDiscounted.jsx b/src/app/components/elements/SteemToolsContent/sections/ClaimDiscounted.jsx new file mode 100644 index 000000000..66e02591f --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/sections/ClaimDiscounted.jsx @@ -0,0 +1,811 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import tt from 'counterpart'; +import { api } from '@steemit/steem-js'; +import { FormattedHTMLMessage } from 'app/Translator'; +import { numberWithCommas } from 'app/utils/StateFunctions'; + +import shouldComponentUpdate from 'app/utils/shouldComponentUpdate'; +import LoadingIndicator from 'app/components/elements/LoadingIndicator'; +import Icon from 'app/components/elements/Icon'; +import * as transactionActions from 'app/redux/TransactionReducer'; +import * as userActions from 'app/redux/UserReducer'; +import * as appActions from 'app/redux/AppReducer'; +import ConfirmClaimAct from './ConfirmClaimAct'; + +let STEEM_RC_REGEN_TIME = 60 * 60 * 24 * 5; // 5 days +let STEEM_BLOCK_INTERVAL = 3; +let STEEM_NUM_RESOURCE_TYPES = 5; + +let RESOURCE_HISTORY_BYTES = 0; +let RESOURCE_NEW_ACCOUNTS = 1; +let RESOURCE_MARKET_BYTES = 2; +let RESOURCE_STATE_BYTES = 3; +let RESOURCE_EXECUTION_TIME = 4; + +let RESOURCE_NAMES = [ + 'resource_history_bytes', + 'resource_new_accounts', + 'resource_market_bytes', + 'resource_state_bytes', + 'resource_execution_time', +]; + +function computeRcCostOfResource(curveParams, currentPool, resourceCount, rcRegen) { + if (resourceCount <= BigInt(0)) return BigInt(0); + + let coeffA = BigInt(curveParams.coeff_a); + let coeffB = BigInt(curveParams.coeff_b); + let shift = BigInt(curveParams.shift); + + let num = rcRegen * coeffA; + num = num >> shift; + num = num + BigInt(1); + num = num * resourceCount; + + let denom = coeffB; + if (currentPool > BigInt(0)) denom = denom + currentPool; + + let result = num / denom; + return result + BigInt(1); +} + +function regenerateMana(currentMana, lastUpdateTime, maxMana, nowSec) { + let maxManaBig = BigInt(maxMana); + let manaBig = BigInt(currentMana); + + let elapsed = nowSec - Number(lastUpdateTime); + if (elapsed <= 0) return manaBig; + + let regenAmount = + (maxManaBig * BigInt(elapsed)) / BigInt(STEEM_RC_REGEN_TIME); + manaBig = manaBig + regenAmount; + + if (manaBig > maxManaBig) manaBig = maxManaBig; + return manaBig; +} + +function getClaimAccountResourceUsage(sizeInfo) { + let TX_SIZE = BigInt(120); + + let txObjBase = + sizeInfo && sizeInfo.transaction_object_base_size + ? BigInt(sizeInfo.transaction_object_base_size) + : BigInt(35) * BigInt(174); + + let txObjByte = + sizeInfo && sizeInfo.transaction_object_byte_size + ? BigInt(sizeInfo.transaction_object_byte_size) + : BigInt(174); + + let resourceCount = new Array(STEEM_NUM_RESOURCE_TYPES); + for (let i = 0; i < resourceCount.length; i++) resourceCount[i] = BigInt(0); + + resourceCount[RESOURCE_HISTORY_BYTES] = TX_SIZE; + resourceCount[RESOURCE_NEW_ACCOUNTS] = BigInt(1); + resourceCount[RESOURCE_MARKET_BYTES] = BigInt(0); + resourceCount[RESOURCE_STATE_BYTES] = txObjBase + txObjByte * TX_SIZE; + resourceCount[RESOURCE_EXECUTION_TIME] = BigInt(10000); + + return resourceCount; +} + +function computeClaimAccountCost(resourceParams, resourcePool, rcRegen, sizeInfo) { + let resourceUsage = getClaimAccountResourceUsage(sizeInfo); + let totalCost = BigInt(0); + + for (let i = 0; i < STEEM_NUM_RESOURCE_TYPES; i++) { + let name = RESOURCE_NAMES[i]; + let params = resourceParams ? resourceParams[name] : null; + let poolObj = resourcePool ? resourcePool[name] : null; + + if (!params || !poolObj) continue; + + let resourceDynamicsParams = params.resource_dynamics_params; + let resourceUnit = + resourceDynamicsParams && resourceDynamicsParams.resource_unit + ? BigInt(resourceDynamicsParams.resource_unit) + : BigInt(1); + + let pool = + poolObj && poolObj.pool !== undefined ? BigInt(poolObj.pool) : BigInt(0); + let curveParams = params.price_curve_params; + + let scaledUsage = resourceUsage[i] * resourceUnit; + + let c = + rcRegen > BigInt(0) + ? computeRcCostOfResource(curveParams, pool, scaledUsage, rcRegen) + : BigInt(0); + + totalCost = totalCost + c; + } + + return totalCost; +} + +function isInvalidErrorValue(value) { + if ( + value === false || + value === 0 || + value === null || + value === undefined + ) { + return true; + } + + let text = ''; + + if (typeof value === 'string') { + text = value; + } else if (value instanceof Error) { + text = value.message || String(value); + } else { + try { + text = String(value); + } catch (e) { + return true; + } + } + + let normalized = text.trim().toLowerCase(); + + if (!normalized) return true; + if (normalized === '0') return true; + if (normalized === 'false') return true; + if (normalized === 'null') return true; + if (normalized === 'undefined') return true; + if (normalized.includes('undefined')) return true; + + return false; +} + +function normalizeErrorMessage(value, fallback = tt('g.error')) { + if (isInvalidErrorValue(value)) { + return fallback; + } + + if (typeof value === 'string') { + return value.trim(); + } + + if (value instanceof Error) { + let msg = (value.message || String(value) || '').trim(); + return isInvalidErrorValue(msg) ? fallback : msg; + } + + try { + let msg = String(value).trim(); + return isInvalidErrorValue(msg) ? fallback : msg; + } catch (e) { + return fallback; + } +} + +class ClaimDiscounted extends React.Component { + constructor(props) { + super(props); + this.shouldComponentUpdate = shouldComponentUpdate(this, 'ClaimDiscounted'); + + this.state = { + loading: false, + error: null, + + username: props.username || '', + claimedAct: 0, + steemBalance: 0, + steemPerAct: props.steemPerActRaw || '3.000 STEEM', + + rcCurrent: BigInt(0), + rcMax: BigInt(0), + rcPerAct: BigInt(0), + + claimableByRc: 0, + claimableBySteem: 0, + + payment: 'RC', + claimAmount: 1, + }; + + this.MAX_CLAIM_AMOUNT = 600; + } + + componentDidMount() { + this.refresh(); + } + + componentDidUpdate(prevProps) { + if (prevProps.username !== this.props.username) { + let nextUsername = this.props.username || ''; + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ username: nextUsername }, () => this.refresh()); + } + if ( + prevProps.pending_claimed_accounts !== this.props.pending_claimed_accounts || + prevProps.balance !== this.props.balance || + prevProps.totalVestingShares !== this.props.totalVestingShares || + prevProps.steemPerActRaw !== this.props.steemPerActRaw || + prevProps.steemPerActNum !== this.props.steemPerActNum + ) { + this.refresh(); + } + } + + async refresh() { + let username = this.props.accountname || this.state.username; + if (!username) return; + + this.setState({ loading: true, error: null }); + try { + let calls = await Promise.all([ + api.callAsync('rc_api.find_rc_accounts', { accounts: [username] }), + api.callAsync('rc_api.get_resource_params', {}), + api.callAsync('rc_api.get_resource_pool', {}), + ]); + + let rcAccRes = calls[0]; + let resParamsRes = calls[1]; + let resPoolRes = calls[2]; + + let claimedAct = Number(this.props.pending_claimed_accounts || 0); + let steemBalance = parseFloat(String(this.props.balance || '0.000')) || 0; + let steemPerActRaw = this.props.steemPerActRaw || '3.000 STEEM'; + + let totalVestingSharesRaw = String(this.props.totalVestingShares || '0.00'); + let totalVestingShares = BigInt(totalVestingSharesRaw.replace('.', '')); + + let rcRegenDivisor = BigInt(STEEM_RC_REGEN_TIME / STEEM_BLOCK_INTERVAL); + let rcRegen = + rcRegenDivisor > BigInt(0) ? totalVestingShares / rcRegenDivisor : BigInt(0); + + let rcAcc = + rcAccRes && rcAccRes.rc_accounts && rcAccRes.rc_accounts.length + ? rcAccRes.rc_accounts[0] + : null; + + let nowSec = Math.floor(Date.now() / 1000); + + let rcCurrent = rcAcc + ? regenerateMana( + rcAcc.rc_manabar.current_mana, + rcAcc.rc_manabar.last_update_time, + rcAcc.max_rc, + nowSec + ) + : BigInt(0); + + let rcMax = rcAcc ? BigInt(rcAcc.max_rc) : BigInt(0); + + let resourceParams = + resParamsRes && resParamsRes.resource_params ? resParamsRes.resource_params : {}; + let resourcePool = + resPoolRes && resPoolRes.resource_pool ? resPoolRes.resource_pool : {}; + + let sizeInfo = + resParamsRes && resParamsRes.size_info && resParamsRes.size_info.resource_state_bytes + ? resParamsRes.size_info.resource_state_bytes + : null; + + let rcPerAct = computeClaimAccountCost(resourceParams, resourcePool, rcRegen, sizeInfo); + + let claimableByRc = rcPerAct > BigInt(0) ? Number(rcCurrent / rcPerAct) : 0; + + let steemPerActNum = Number(this.props.steemPerActNum || 3); + let claimableBySteem = steemPerActNum > 0 ? Math.floor(steemBalance / steemPerActNum) : 0; + + let payment = this.state.payment; + let maxClaimable = payment === 'RC' ? claimableByRc : claimableBySteem; + let maxAllowed = Math.min(maxClaimable, this.MAX_CLAIM_AMOUNT); + + let nextClaimAmount = this.state.claimAmount || 1; + if (nextClaimAmount > Math.max(maxAllowed, 1)) nextClaimAmount = Math.max(maxAllowed, 1); + if (nextClaimAmount < 1) nextClaimAmount = 1; + + this.setState({ + claimedAct: claimedAct, + steemBalance: steemBalance, + steemPerAct: steemPerActRaw, + + rcCurrent: rcCurrent, + rcMax: rcMax, + rcPerAct: rcPerAct, + + claimableByRc: claimableByRc, + claimableBySteem: claimableBySteem, + + claimAmount: nextClaimAmount, + loading: false, + }); + } catch (e) { + console.error('ClaimDiscounted.refresh', e); + this.setState({ + loading: false, + error: normalizeErrorMessage( + e, + tt('steem_tools.claim_act.unexpected_error') + ), + }); + } + } + + onChangePayment = (e) => { + let payment = e.target.value; + let maxClaimable = payment === 'RC' ? this.state.claimableByRc : this.state.claimableBySteem; + let maxAllowed = Math.min(maxClaimable, this.MAX_CLAIM_AMOUNT); + + let nextClaimAmount = this.state.claimAmount || 1; + if (nextClaimAmount > Math.max(maxAllowed, 1)) nextClaimAmount = Math.max(maxAllowed, 1); + if (nextClaimAmount < 1) nextClaimAmount = 1; + + this.setState({ payment: payment, claimAmount: nextClaimAmount }); + }; + + onChangeClaimAmount = (e) => { + let v = Number(e.target.value); + let maxClaimable = + this.state.payment === 'RC' ? this.state.claimableByRc : this.state.claimableBySteem; + let maxAllowed = Math.min(maxClaimable, this.MAX_CLAIM_AMOUNT); + + let safe = Number.isFinite(v) ? v : 1; + let next = safe; + if (next < 1) next = 1; + if (next > Math.max(maxAllowed, 1)) next = Math.max(maxAllowed, 1); + + this.setState({ claimAmount: next }); + }; + + onClaim = () => { + let username = this.state.username; + if (!this.props.isLoggedIn || !username) { + this.setState({ error: tt('steem_tools.claim_act.login_required') }); + return; + } + + let payment = this.state.payment; + let claimAmount = this.state.claimAmount; + + let claimable = payment === 'RC' ? this.state.claimableByRc : this.state.claimableBySteem; + let maxAllowed = Math.min(claimable, this.MAX_CLAIM_AMOUNT); + + if (claimAmount < 1 || claimAmount > maxAllowed) { + this.setState({ error: tt('steem_tools.claim_act.invalid_amount') }); + return; + } + + let fee = payment === 'RC' ? '0.000 STEEM' : this.props.steemPerActRaw; + + this.setState({ loading: true, error: null }); + + this.props.claimAct({ + creator: username, + fee, + payment, + claimAmount, + claimable, + rcPerAct: this.state.rcPerAct, + steemPerAct: this.state.steemPerAct, + optimisticUpdate: () => { + let nextClaimed = Number(this.state.claimedAct || 0) + Number(claimAmount || 0); + + let nextRcCurrent = this.state.rcCurrent; + let nextSteemBalance = this.state.steemBalance; + + if (payment === 'RC') { + let spent = BigInt(0); + try { + spent = BigInt(this.state.rcPerAct || BigInt(0)) * BigInt(Number(claimAmount || 0)); + } catch (e) { + spent = BigInt(0); + } + nextRcCurrent = nextRcCurrent > spent ? nextRcCurrent - spent : BigInt(0); + } else { + let steemPerActNum = Number(this.props.steemPerActNum || 3); + let spentSteem = steemPerActNum * Number(claimAmount || 0); + nextSteemBalance = Math.max(0, Number(nextSteemBalance || 0) - spentSteem); + } + + let nextClaimableByRc = + this.state.rcPerAct > BigInt(0) ? Number(nextRcCurrent / this.state.rcPerAct) : 0; + + let steemPerActNum2 = Number(this.props.steemPerActNum || 3); + let nextClaimableBySteem = + steemPerActNum2 > 0 ? Math.floor(nextSteemBalance / steemPerActNum2) : 0; + + let nextMaxClaimable = + payment === 'RC' ? nextClaimableByRc : nextClaimableBySteem; + let nextMaxAllowed = Math.min(nextMaxClaimable, this.MAX_CLAIM_AMOUNT); + + let nextClaimAmount = this.state.claimAmount || 1; + if (nextClaimAmount > Math.max(nextMaxAllowed, 1)) + nextClaimAmount = Math.max(nextMaxAllowed, 1); + if (nextClaimAmount < 1) nextClaimAmount = 1; + + this.setState({ + claimedAct: nextClaimed, + rcCurrent: nextRcCurrent, + steemBalance: nextSteemBalance, + claimableByRc: nextClaimableByRc, + claimableBySteem: nextClaimableBySteem, + claimAmount: nextClaimAmount, + }); + }, + successCallback: () => { + this.setState({ loading: false }); + }, + errorCallback: (err) => { + this.setState({ + loading: false, + error: normalizeErrorMessage( + err, + tt('steem_tools.claim_act.unexpected_error') + ), + }); + }, + }); + }; + + render() { + let loading = this.state.loading; + let rawError = this.state.error; + let error = isInvalidErrorValue(rawError) + ? null + : normalizeErrorMessage( + rawError, + tt('steem_tools.claim_act.unexpected_error') + ); + + let username = this.state.username || this.props.accountname; + let claimedAct = this.state.claimedAct; + + let payment = this.state.payment; + let claimAmount = this.state.claimAmount; + + let claimableByRc = this.state.claimableByRc; + let claimableBySteem = this.state.claimableBySteem; + + let steemPerAct = this.state.steemPerAct; + let rcPerAct = this.state.rcPerAct; + + let claimable = payment === 'RC' ? claimableByRc : claimableBySteem; + let maxAllowed = Math.min(claimable, this.MAX_CLAIM_AMOUNT); + + let tooltipRc = tt('steem_tools.claim_act.claimable_rc_tooltip', {rc_per_act: numberWithCommas(String(rcPerAct))}) + let tooltipSteem = tt('steem_tools.claim_act.claimable_steem_tooltip', {steem_per_act: String(steemPerAct)}) + + let canClaim = + Boolean(this.props.isLoggedIn) && + !loading && + Number.isFinite(Number(claimAmount)) && + Number(claimAmount) >= 1 && + Number(claimAmount) <= Number(maxAllowed); + + return ( +
+
+
+

{tt('steem_tools.claim_act.title')}

+
+ +
+
+
+ +
+
+
+ +
+ {loading ? ( +
+
+ +
+
+ ) : ( +
+
+
+
+
{tt('steem_tools.claim_act.account')}
+
+
+ +
+
+ @ + +
+
+
+ +
+
+ {tt('steem_tools.claim_act.claimed')} +
+ +
+
+ + + {tt('steem_tools.claim_act.act_suffix')} + +
+
+
+ +
+
+
+
{tt('steem_tools.claim_act.claimable')}
+
+
+ +
+
+
+
+
{tt('steem_tools.claim_act.based_on_rc')}
+
+ + {tooltipRc} +
+
+ +
+ +
+
+ +
+
+
{tt('steem_tools.claim_act.based_on_steem')}
+
+ + {tooltipSteem} +
+
+ +
+ +
+
+
+
+
+ +
+
+
+
{tt('steem_tools.claim_act.payment')}
+
+
+ +
+
+ +
+
+
+ +
+
+
{tt('steem_tools.claim_act.amount')}
+
+ +
+
+ + + {tt('steem_tools.claim_act.act_suffix')} + +
+ +
+ {payment === 'RC' + ? `${numberWithCommas(String(rcPerAct))} RC per ACT` + : `${String(steemPerAct)} per ACT`} +
+
+
+ + {error ? ( +
+
+
{error}
+
+
+ ) : null} + +
+
+ +
+
+
+ )} +
+
+
+ ); + } +} + +export default connect( + function mapState(state, ownProps) { + let current = + state && state.user && state.user.get ? state.user.get('current') : null; + + let username = + current && current.get && current.get('username') ? current.get('username') : ''; + + let account = ownProps.accountname + ? state.global.getIn(['accounts', ownProps.accountname]) + : null; + + let steem_balance = + account && account.get && account.get('balance') ? account.get('balance') : '0.000 STEEM'; + + let pending_claimed_accounts = + account && account.get && account.get('pending_claimed_accounts') + ? account.get('pending_claimed_accounts') + : 0; + + let balance = String(steem_balance).split(' ')[0] || '0.000'; + + let totalVestingShares = + state.global.getIn(['props', 'total_vesting_shares']) + ? String(state.global.getIn(['props', 'total_vesting_shares'])).split(' ')[0] + : '0.00'; + + let DEFAULT_ACCOUNT_CREATION_FEE = '3.000 STEEM'; + + let witness_schedule = state.global.get ? state.global.get('witness_schedule') : null; + let median_props = + witness_schedule && witness_schedule.get ? witness_schedule.get('median_props') : null; + let account_creation_fee = + median_props && median_props.get ? median_props.get('account_creation_fee') : null; + + let steemPerActRaw = + account_creation_fee && String(account_creation_fee).includes(' ') + ? String(account_creation_fee) + : DEFAULT_ACCOUNT_CREATION_FEE; + + let steemPerActNum = steemPerActRaw + ? parseFloat(String(steemPerActRaw).split(' ')[0]) || 3 + : 3; + + let accounts = state.global.get ? state.global.get('accounts') : null; + let isLoggedIn = !!username; + + return { + ...ownProps, + username: username || '', + isLoggedIn, + balance, + accounts, + pending_claimed_accounts, + totalVestingShares, + steemPerActNum, + steemPerActRaw, + }; + }, + function mapDispatch(dispatch) { + return { + claimAct: ({ + creator, + fee, + payment, + claimAmount, + claimable, + rcPerAct, + steemPerAct, + optimisticUpdate, + successCallback, + errorCallback, + }) => { + let confirm = () => ( + + ); + + let refreshAndNotify = () => { + dispatch(userActions.refreshAccount({ owner: creator })); + dispatch( + appActions.addNotification({ + key: 'claim_act_' + Date.now(), + message: tt('steem_tools.claim_act.claim_notification', { + count: claimAmount, + }), + dismissAfter: 5000, + }) + ); + }; + + let operations = []; + for (let i = 0; i < claimAmount; i++) { + operations.push([ + 'claim_account', + { + creator: creator, + fee: fee, + extensions: [], + }, + ]); + } + + dispatch( + transactionActions.broadcastOperations({ + operations, + confirm, + confirmTitle: tt('steem_tools.claim_act.confirm_title'), + successCallback: () => { + refreshAndNotify(); + if (optimisticUpdate) optimisticUpdate(); + if (successCallback) successCallback(); + }, + errorCallback: (err) => { + if (errorCallback) errorCallback(err); + }, + }) + ); + }, + }; + } +)(ClaimDiscounted); diff --git a/src/app/components/elements/SteemToolsContent/sections/ConfirmClaimAct.jsx b/src/app/components/elements/SteemToolsContent/sections/ConfirmClaimAct.jsx new file mode 100644 index 000000000..18abdc5d1 --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/sections/ConfirmClaimAct.jsx @@ -0,0 +1,87 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import tt from 'counterpart'; + +const ConfirmClaimAct = ({ operation }) => { + const { account, payment, fee, claimAmount, claimable, rcPerAct, steemPerAct } = operation; + + const paymentLabel = payment === 'RC' ? tt('steem_tools.claim_act.pay_with_rc') : tt('steem_tools.claim_act.pay_with_steem'); + + const perActLabel = payment === 'RC' + ? String(rcPerAct) + : String(steemPerAct); + + return ( +
+
+ + {tt('steem_tools.claim_act.account')} + + +
+ +
+ + {tt('steem_tools.claim_act.payment')} + + +
+ +
+ + {tt('steem_tools.claim_act.modal_per_act')} + + + + {paymentLabel} + +
+ +
+ + {tt('steem_tools.claim_act.modal_amount')} + + + + {tt('steem_tools.claim_act.act_suffix')} + +
+
+ ); +}; + + + +ConfirmClaimAct.propTypes = { + operation: PropTypes.shape({ + account: PropTypes.string.isRequired, + payment: PropTypes.oneOf(['RC', 'STEEM']).isRequired, + fee: PropTypes.string.isRequired, + claimAmount: PropTypes.number.isRequired, + claimable: PropTypes.number.isRequired, + rcPerAct: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + steemPerAct: PropTypes.string.isRequired, + }).isRequired, +}; + +export default ConfirmClaimAct; diff --git a/src/app/components/elements/SteemToolsContent/sections/ConfirmCreateAccount.jsx b/src/app/components/elements/SteemToolsContent/sections/ConfirmCreateAccount.jsx new file mode 100644 index 000000000..41c5a7900 --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/sections/ConfirmCreateAccount.jsx @@ -0,0 +1,55 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import tt from 'counterpart'; + +var ConfirmCreateAccount = function(props) { + var operation = props.operation; + var creator = operation.creator; + var newAccount = operation.newAccount; + var paymentMode = operation.paymentMode; + var fee = operation.fee; + + var paymentLabel = paymentMode === 'TOKEN' + ? tt('steem_tools.create_account.confirm_payment_act') + : tt('steem_tools.create_account.confirm_payment_steem', { fee: fee }); + + return ( +
+
+ + {tt('steem_tools.create_account.confirm_creator')} + + +
+ +
+ + {tt('steem_tools.create_account.confirm_new_account')} + + +
+ +
+ + {tt('steem_tools.create_account.confirm_payment')} + + +
+ +
+ {tt('steem_tools.create_account.confirm_note')} +
+
+ ); +}; + +ConfirmCreateAccount.propTypes = { + operation: PropTypes.shape({ + creator: PropTypes.string.isRequired, + newAccount: PropTypes.string.isRequired, + paymentMode: PropTypes.oneOf(['TOKEN', 'STEEM']).isRequired, + fee: PropTypes.string, + }).isRequired, +}; + +export default ConfirmCreateAccount; diff --git a/src/app/components/elements/SteemToolsContent/sections/CreateAccount.jsx b/src/app/components/elements/SteemToolsContent/sections/CreateAccount.jsx new file mode 100644 index 000000000..cce805b38 --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/sections/CreateAccount.jsx @@ -0,0 +1,794 @@ + +import React from 'react'; +import { connect } from 'react-redux'; +import tt from 'counterpart'; +import { PrivateKey } from '@steemit/steem-js/lib/auth/ecc'; +import { api } from '@steemit/steem-js'; +import PdfDownload from 'app/components/elements/PdfDownload'; +import ConfirmCreateAccount from 'app/components/elements/SteemToolsContent/sections/ConfirmCreateAccount'; +import { FormattedHTMLMessage } from 'app/Translator'; + +import LoadingIndicator from 'app/components/elements/LoadingIndicator'; +import * as transactionActions from 'app/redux/TransactionReducer'; +import * as userActions from 'app/redux/UserReducer'; +import * as appActions from 'app/redux/AppReducer'; +import shouldComponentUpdate from 'app/utils/shouldComponentUpdate'; +import { validate_account_name } from 'app/utils/ChainValidation'; + +function isInvalidErrorValue(value) { + if ( + value === false || + value === 0 || + value === null || + value === undefined + ) { + return true; + } + + let text = ''; + + if (typeof value === 'string') { + text = value; + } else if (value instanceof Error) { + text = value.message || String(value); + } else { + try { + text = String(value); + } catch (e) { + return true; + } + } + + let normalized = text.trim().toLowerCase(); + + if (!normalized) return true; + if (normalized === '0') return true; + if (normalized === 'false') return true; + if (normalized === 'null') return true; + if (normalized === 'undefined') return true; + if (normalized.includes('undefined')) return true; + + return false; +} + +function normalizeErrorMessage(value, fallback = tt('g.error')) { + if (isInvalidErrorValue(value)) { + return fallback; + } + + if (typeof value === 'string') { + return value.trim(); + } + + if (value instanceof Error) { + let msg = (value.message || String(value) || '').trim(); + return isInvalidErrorValue(msg) ? fallback : msg; + } + + try { + let msg = String(value).trim(); + return isInvalidErrorValue(msg) ? fallback : msg; + } catch (e) { + return fallback; + } +} + +class CreateAccount extends React.Component { + constructor(props) { + super(props); + this.shouldComponentUpdate = shouldComponentUpdate(this, 'CreateAccount'); + + this.state = { + loading: false, + error: null, + + creator: props.accountname || props.username || '', + createe: '', + + active_priv: '', + active_pub: '', + posting_priv: '', + posting_pub: '', + owner_priv: '', + owner_pub: '', + memo_priv: '', + memo_pub: '', + + paymentMode: 'TOKEN', + hasExported: false, + acknowledged: false, + successMessage: null, + dlPdf: false, + + useMasterPassword: true, + masterPassword: '', + nameError: null, + nameAvailable: null, + isCheckingName: false, + }; + this.checkAccountNameTimer = null; + } + + componentDidMount() { + this.generateMasterPassword(); + } + + componentDidUpdate(prevProps) { + if ( + prevProps.username !== this.props.username || + prevProps.accountname !== this.props.accountname + ) { + let nextUsername = this.props.accountname || this.props.username || ''; + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ creator: nextUsername }); + } + } + + componentWillUnmount() { + if (this.checkAccountNameTimer) { + clearTimeout(this.checkAccountNameTimer); + } + if (this.deriveKeysTimer) { + clearTimeout(this.deriveKeysTimer); + } + } + + onChange = (e) => { + let name = e.target.name; + let value = e.target.value; + + if (name.includes('_pub') || name.includes('_priv')) { + value = value.replace(/\s/g, ''); + } + + this.setState({ [name]: value, successMessage: null, error: null }); + + if (name.includes('_pub') || name.includes('_priv') || name === 'createe') { + this.setState({ hasExported: false, acknowledged: false }); + } + }; + + onPaymentModeChange = (e) => { + this.setState({ paymentMode: e.target.value }); + }; + + onAcknowledgeToggle = (e) => { + this.setState({ acknowledged: e.target.checked }); + }; + + onToggleMasterPassword = (e) => { + var checked = e.target.checked; + if (checked) { + this.setState({ + useMasterPassword: true, + hasExported: false, + acknowledged: false, + }); + } else { + this.setState({ + useMasterPassword: false, + masterPassword: '', + active_priv: '', active_pub: '', + posting_priv: '', posting_pub: '', + owner_priv: '', owner_pub: '', + memo_priv: '', memo_pub: '', + hasExported: false, + acknowledged: false, + }); + } + }; + + onMasterPasswordChange = (e) => { + var password = e.target.value.replace(/\s/g, ''); + this.setState({ masterPassword: password, hasExported: false, acknowledged: false }, () => { + if (this.deriveKeysTimer) { + clearTimeout(this.deriveKeysTimer); + } + this.deriveKeysTimer = setTimeout(() => { + this.deriveKeysFromMaster(password); + }, 300); + }); + }; + + generateMasterPassword = () => { + var randomSeed = Date.now() + '-' + Math.random().toString(36).substring(2, 15) + '-' + Math.random().toString(36).substring(2, 15); + var privateKey = PrivateKey.fromSeed(randomSeed).toWif(); + var generatedWif = 'P' + privateKey; + this.setState({ masterPassword: generatedWif, hasExported: false, acknowledged: false }, () => { + if (this.deriveKeysTimer) { + clearTimeout(this.deriveKeysTimer); + } + this.deriveKeysTimer = setTimeout(() => { + this.deriveKeysFromMaster(generatedWif); + }, 300); + }); + }; + + deriveKeysFromMaster = (password) => { + var createe = this.state.createe; + var nameValidationError = createe ? validate_account_name(createe) : true; + + if (!createe || !password || nameValidationError) { + this.setState({ + active_priv: '', active_pub: '', + posting_priv: '', posting_pub: '', + owner_priv: '', owner_pub: '', + memo_priv: '', memo_pub: '', + }); + return; + } + + try { + var roles = ['active', 'posting', 'owner', 'memo']; + var newState = {}; + roles.forEach(function(role) { + var pk = PrivateKey.fromSeed(createe + role + password); + newState[role + '_priv'] = pk.toWif(); + newState[role + '_pub'] = pk.toPublicKey().toString(); + }); + this.setState(newState); + } catch (e) { + this.setState({ error: String(e.message) }); + } + }; + + onCreateeChange = (e) => { + var value = e.target.value.replace(/\s/g, '').toLowerCase(); + var nameValidationError = value ? validate_account_name(value) : null; + this.setState({ + createe: value, + nameError: nameValidationError, + nameAvailable: null, + isCheckingName: false, + successMessage: null, + error: null, + hasExported: false, + acknowledged: false + }, () => { + if (this.state.useMasterPassword && this.state.masterPassword) { + if (this.deriveKeysTimer) { + clearTimeout(this.deriveKeysTimer); + } + this.deriveKeysTimer = setTimeout(() => { + this.deriveKeysFromMaster(this.state.masterPassword); + }, 300); + } + }); + + if (this.checkAccountNameTimer) { + clearTimeout(this.checkAccountNameTimer); + } + + if (value && !nameValidationError) { + this.checkAccountName(value); + } + }; + + checkAccountName = (username) => { + this.setState({ nameAvailable: null }); + this.checkAccountNameTimer = setTimeout(() => { + this.setState({ isCheckingName: true }); + const normalizedUsername = username.trim().toLowerCase(); + api.callAsync('condenser_api.lookup_accounts', [normalizedUsername, 1]) + .then(accounts => { + const exists = Array.isArray(accounts) && accounts.length > 0 && String(accounts[0]).toLowerCase() === normalizedUsername; + if (this.state.createe === normalizedUsername) { + this.setState({ + nameAvailable: !exists, + isCheckingName: false, + nameError: exists ? tt('steem_tools.create_account.error_account_exists') : null + }); + } + }) + .catch(e => { + console.error('API Error checking account name:', e); + if (this.state.createe === normalizedUsername) { + this.setState({ isCheckingName: false }); + } + }); + }, 500); + }; + + onExportKeys = () => { + var st = this.state; + + if (!st.createe) { + this.setState({ error: tt('steem_tools.create_account.error_no_account_export') }); + return; + } + + var content = 'Account: ' + st.createe + '\n'; + if (st.useMasterPassword && st.masterPassword) { + content += 'Master Password: ' + st.masterPassword + '\n'; + } + content += '\n'; + + var roles = [ + { id: 'owner', pub: st.owner_pub, priv: st.owner_priv }, + { id: 'active', pub: st.active_pub, priv: st.active_priv }, + { id: 'posting', pub: st.posting_pub, priv: st.posting_priv }, + { id: 'memo', pub: st.memo_pub, priv: st.memo_priv } + ]; + + roles.forEach(function(role) { + content += role.id.toUpperCase() + ' KEYS\n'; + if (role.priv) content += 'Private: ' + role.priv + '\n'; + content += 'Public: ' + role.pub + '\n\n'; + }); + + var element = document.createElement("a"); + var file = new Blob([content], { type: 'text/plain' }); + element.href = URL.createObjectURL(file); + element.download = st.createe + '_steem_keys.txt'; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); + + this.setState({ hasExported: true, error: null }); + }; + + onExportPdf = () => { + var st = this.state; + if (!st.createe) { + this.setState({ error: tt('steem_tools.create_account.error_no_account_export') }); + return; + } + this.setState({ dlPdf: true, hasExported: true, error: null }); + }; + + resetDlPdf = () => { + this.setState({ dlPdf: false }); + }; + + onCreateAccount = () => { + var st = this.state; + + if (st.nameError) { + this.setState({ error: st.nameError }); + return; + } + + if (!st.creator || !st.createe) { + this.setState({ error: tt('steem_tools.create_account.error_no_creator') }); + return; + } + + if (!st.active_pub || !st.posting_pub || !st.owner_pub || !st.memo_pub) { + this.setState({ error: tt('steem_tools.create_account.error_no_keys') }); + return; + } + + if (!st.hasExported || !st.acknowledged) { + this.setState({ error: tt('steem_tools.create_account.error_no_export') }); + return; + } + + this.setState({ loading: true, error: null, successMessage: null }); + + var ownerParams = { weight_threshold: 1, account_auths: [], key_auths: [[st.owner_pub, 1]] }; + var activeParams = { weight_threshold: 1, account_auths: [], key_auths: [[st.active_pub, 1]] }; + var postingParams = { weight_threshold: 1, account_auths: [], key_auths: [[st.posting_pub, 1]] }; + + var operationType = st.paymentMode === 'TOKEN' ? 'create_claimed_account' : 'account_create'; + var operationDetails = { + creator: st.creator, + new_account_name: st.createe, + owner: ownerParams, + active: activeParams, + posting: postingParams, + memo_key: st.memo_pub, + json_metadata: "", + extensions: [], + }; + + if (st.paymentMode === 'STEEM') { + operationDetails.fee = this.props.steemPerActRaw; + } + + var self = this; + var accountName = st.createe; + + var refreshAndNotify = function() { + self.props.notifyAction(accountName); + self.setState({ + loading: false, + successMessage: tt('steem_tools.create_account.success_created', { account: accountName }), + createe: '', + masterPassword: '', + active_priv: '', active_pub: '', + posting_priv: '', posting_pub: '', + owner_priv: '', owner_pub: '', + memo_priv: '', memo_pub: '', + hasExported: false, acknowledged: false + }); + }; + + var confirmData = { + creator: st.creator, + newAccount: st.createe, + paymentMode: st.paymentMode, + fee: st.paymentMode === 'STEEM' ? this.props.steemPerActRaw : '', + }; + + this.props.broadcastCreate(operationType, operationDetails, confirmData, refreshAndNotify, function(err) { + self.setState({ loading: false, error: String(err) }); + }); + }; + + render() { + var st = this.state; + var loading = st.loading; + var rawError = st.error; + var error = isInvalidErrorValue(rawError) + ? null + : normalizeErrorMessage( + rawError, + tt('g.error') + ); + var successMessage = st.successMessage; + var creator = st.creator; + var createe = st.createe; + var paymentMode = st.paymentMode; + var hasExported = st.hasExported; + var acknowledged = st.acknowledged; + var useMasterPassword = st.useMasterPassword; + var masterPassword = st.masterPassword; + var nameAvailable = st.nameAvailable; + var isCheckingName = st.isCheckingName; + + var pendingTokens = Number(this.props.pending_claimed_accounts || 0); + var steemBalance = this.props.balance || '0.000 STEEM'; + var steemFeeRaw = this.props.steemPerActRaw; + + + + var canCreate = hasExported && acknowledged && !isCheckingName && nameAvailable === true; + var hasKeys = st.active_pub && st.posting_pub && st.owner_pub && st.memo_pub; + + var rolesData = [ + { id: 'owner', label: tt('steem_tools.create_account.owner_key'), publicVal: st.owner_pub, privateVal: st.owner_priv, pubName: 'owner_pub', privName: 'owner_priv' }, + { id: 'active', label: tt('steem_tools.create_account.active_key'), publicVal: st.active_pub, privateVal: st.active_priv, pubName: 'active_pub', privName: 'active_priv' }, + { id: 'posting', label: tt('steem_tools.create_account.posting_key'), publicVal: st.posting_pub, privateVal: st.posting_priv, pubName: 'posting_pub', privName: 'posting_priv' }, + { id: 'memo', label: tt('steem_tools.create_account.memo_key'), publicVal: st.memo_pub, privateVal: st.memo_priv, pubName: 'memo_pub', privName: 'memo_priv' } + ]; + + return ( +
+
+
+

{tt('steem_tools.create_account.panel_title')}

+
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+
{tt('steem_tools.create_account.creator_label')}
+
+
+
+
+ @ + +
+
+
+ +
+
+
+
{tt('steem_tools.create_account.new_account_label')}
+
+
+
+
+ @ + + {isCheckingName && ( +
+ +
+ )} +
+ {st.nameError && ( +
+ {st.nameError} +
+ )} + {nameAvailable === true && !st.nameError && ( +
+ {tt('steem_tools.create_account.success_account_available')} +
+ )} +
+
+ +
+
+
+
{tt('steem_tools.create_account.payment_mode_label')}
+
+
+
+
+ +
+
+
+
+
+
+
+ + {tt('steem_tools.create_account.keys_description')} + +
+
+
+ +
+
+ + {tt('steem_tools.create_account.use_master_password')} + + +
+
+ + {useMasterPassword && ( +
+
+
+
{tt('steem_tools.create_account.master_password_label')}
+
+
+
+
+ +
+ +
+
+
+
+ )} + + {rolesData.map(function(role) { + return ( +
+
+
{role.label}
+
+ {useMasterPassword && ( +
+ +
+ )} +
+ +
+
+
+
+ ); + }.bind(this))} + + {error ? ( +
+
+
{error}
+
+
+ ) : null} + + {successMessage ? ( +
+
+
{successMessage}
+
+
+ ) : null} + +
+
+ + {tt('steem_tools.create_account.acknowledge_warning')} + + +
+
+ +
+
+ + + +    + +
+
+
+
+ + +
+ ); + } +} + +export default connect( + function mapState(state, ownProps) { + var current = + state && state.user && state.user.get ? state.user.get('current') : null; + var username = + current && current.get && current.get('username') ? current.get('username') : ''; + + var account = ownProps.accountname + ? state.global.getIn(['accounts', ownProps.accountname]) + : username + ? state.global.getIn(['accounts', username]) + : null; + + var steem_balance = account && account.get && account.get('balance') ? account.get('balance') : '0.000 STEEM'; + var pending_claimed_accounts = account && account.get && account.get('pending_claimed_accounts') ? account.get('pending_claimed_accounts') : 0; + + var DEFAULT_ACCOUNT_CREATION_FEE = '3.000 STEEM'; + var witness_schedule = state.global.get ? state.global.get('witness_schedule') : null; + var median_props = witness_schedule && witness_schedule.get ? witness_schedule.get('median_props') : null; + var account_creation_fee = median_props && median_props.get ? median_props.get('account_creation_fee') : null; + + var steemPerActRaw = account_creation_fee && String(account_creation_fee).includes(' ') + ? String(account_creation_fee) + : DEFAULT_ACCOUNT_CREATION_FEE; + + return { + ...ownProps, + username: username || '', + balance: steem_balance, + pending_claimed_accounts: pending_claimed_accounts, + steemPerActRaw: steemPerActRaw, + }; + }, + function mapDispatch(dispatch) { + return { + broadcastCreate: function(type, operation, confirmData, successCallback, errorCallback) { + var confirm = function() { + return ( + + ); + }; + dispatch( + transactionActions.broadcastOperation({ + type: type, + operation: operation, + confirm: confirm, + successCallback: successCallback, + errorCallback: errorCallback, + }) + ); + }, + notifyAction: function(accountName) { + dispatch( + appActions.addNotification({ + key: 'create_acc_' + Date.now(), + message: tt('steem_tools.create_account.notify_created', { account: accountName }), + dismissAfter: 5000, + }) + ); + } + }; + } +)(CreateAccount); diff --git a/src/app/components/elements/SteemToolsContent/sections/CreateWitness.jsx b/src/app/components/elements/SteemToolsContent/sections/CreateWitness.jsx new file mode 100644 index 000000000..6150026c5 --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/sections/CreateWitness.jsx @@ -0,0 +1,452 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import tt from 'counterpart'; +import { FormattedHTMLMessage } from 'app/Translator'; +import LoadingIndicator from 'app/components/elements/LoadingIndicator'; +import { api } from '@steemit/steem-js'; +import * as transactionActions from 'app/redux/TransactionReducer'; +import * as appActions from 'app/redux/AppReducer'; +import * as globalActions from 'app/redux/GlobalReducer'; +import * as userActions from 'app/redux/UserReducer'; +import ConfirmCreateWitness from 'app/components/elements/ConfirmCreateWitness'; + +const NULL_WITNESS_SIGNING_KEY = 'STM1111111111111111111111111111111114T1Anm'; + +class CreateWitness extends React.Component { + constructor(props) { + super(props); + this.state = { + blockSigningKey: '', + witnessUrl: '', + accountCreationFee: '0.000 STEEM', + maximumBlockSize: '65536', + sbdInterestRate: '0', + loading: false, + loadingWitnessData: false, + error: null, + success: null, + isExistingWitness: false, + }; + + this.onChange = this.onChange.bind(this); + this.onSubmit = this.onSubmit.bind(this); + this.onFailure = this.onFailure.bind(this); + this.onSuccess = this.onSuccess.bind(this); + this.loadWitnessData = this.loadWitnessData.bind(this); + this.isNullWitnessSigningKey = this.isNullWitnessSigningKey.bind(this); + this.isValidSigningKey = this.isValidSigningKey.bind(this); + } + + componentDidMount() { + this.loadWitnessData(this.props); + } + + componentDidUpdate(prevProps) { + if (prevProps.accountName !== this.props.accountName) { + this.loadWitnessData(this.props); + } + } + + onChange(e) { + const { name, value } = e.target; + let nextValue = value; + if (name === 'sbdInterestRate') { + if (value === '') { + nextValue = ''; + } else { + const numericValue = parseFloat(value); + if (!Number.isNaN(numericValue)) { + nextValue = String(Math.min(100, Math.max(0, numericValue))); + } + } + } + this.setState({ + [name]: nextValue, + error: null, + success: null, + }); + } + + isNullWitnessSigningKey(value) { + return String(value || '').trim() === NULL_WITNESS_SIGNING_KEY; + } + + isValidSigningKey(value) { + const normalized = String(value || '').trim(); + if (!normalized) return false; + return ( + normalized === NULL_WITNESS_SIGNING_KEY || + /^STM[1-9A-HJ-NP-Za-km-z]{50}$/.test(normalized) + ); + } + + async loadWitnessData(props = this.props) { + const { accountName } = props; + if (!accountName) return; + + this.setState({ loadingWitnessData: true, error: null, success: null }); + + try { + const witness = await api.getWitnessByAccountAsync(accountName); + + if (witness) { + const propsData = witness.props || {}; + const blockchainSbdInterestRate = Number(propsData.sbd_interest_rate || 0); + const displaySbdInterestRate = String(blockchainSbdInterestRate / 100); + + this.setState({ + blockSigningKey: witness.signing_key || '', + witnessUrl: witness.url || '', + accountCreationFee: propsData.account_creation_fee || '0.000 STEEM', + maximumBlockSize: String(propsData.maximum_block_size || 65536), + sbdInterestRate: displaySbdInterestRate, + isExistingWitness: true, + loadingWitnessData: false, + }); + return; + } + + this.setState({ + blockSigningKey: '', + witnessUrl: '', + accountCreationFee: '0.000 STEEM', + maximumBlockSize: '65536', + sbdInterestRate: '0', + isExistingWitness: false, + loadingWitnessData: false, + }); + } catch (error) { + this.setState({ + loadingWitnessData: false, + error: tt('steem_tools.create_witness.error_loading_witness'), + }); + } + } + + onFailure(error) { + let errorMessage = error; + if (!errorMessage || String(errorMessage).toLowerCase().includes('undefined')) { + errorMessage = tt('steem_tools.create_witness.unexpected_error'); + } + this.setState({ loading: false, error: errorMessage, success: null }); + } + + onSuccess() { + const { currentUser, refreshAccount } = this.props; + refreshAccount(currentUser); + this.setState({ + loading: false, + error: null, + success: tt('steem_tools.create_witness.success_message'), + }); + this.loadWitnessData(); + } + + onSubmit() { + const { currentUser, accountName, broadcastWitnessSetProperties } = this.props; + const { + blockSigningKey, + witnessUrl, + accountCreationFee, + maximumBlockSize, + sbdInterestRate, + } = this.state; + + if (!currentUser || currentUser !== accountName) { + this.setState({ error: tt('steem_tools.create_witness.error_not_allowed') }); + return; + } + + if (!blockSigningKey.trim() || !witnessUrl.trim()) { + this.setState({ error: tt('steem_tools.create_witness.error_no_signing_key') }); + return; + } + + if (!this.isValidSigningKey(blockSigningKey)) { + this.setState({ error: tt('steem_tools.create_witness.error_invalid_signing_key') }); + return; + } + + if (!/^https?:\/\/.+/i.test(witnessUrl.trim())) { + this.setState({ error: tt('steem_tools.create_witness.error_invalid_url') }); + return; + } + + const feeValue = parseFloat(accountCreationFee.replace(' STEEM', '')); + const parsedMaxBlockSize = parseInt(maximumBlockSize, 10); + const parsedSbdRatePercent = parseFloat(sbdInterestRate); + + if (isNaN(parsedSbdRatePercent) || parsedSbdRatePercent < 0 || parsedSbdRatePercent > 100) { + this.setState({ error: tt('steem_tools.create_witness.error_invalid_sbd_interest_rate') }); + return; + } + + const blockchainSbdInterestRate = Math.round(parsedSbdRatePercent * 100); + + const operation = { + owner: accountName, + props: { + account_creation_fee: feeValue.toFixed(3) + ' STEEM', + maximum_block_size: parsedMaxBlockSize, + sbd_interest_rate: blockchainSbdInterestRate, + url: witnessUrl.trim(), + new_signing_key: blockSigningKey.trim(), + }, + extensions: [], + }; + + this.setState({ loading: true, error: null, success: null }); + broadcastWitnessSetProperties( + operation, + this.state.isExistingWitness, + this.onSuccess, + this.onFailure + ); + } + + render() { + const { currentUser, accountName } = this.props; + const { + blockSigningKey, + witnessUrl, + accountCreationFee, + maximumBlockSize, + sbdInterestRate, + loading, + loadingWitnessData, + error, + success, + isExistingWitness, + } = this.state; + + const isOwner = !!currentUser && !!accountName && currentUser === accountName; + const isNullSigningKey = this.isNullWitnessSigningKey(blockSigningKey); + const isKeyValid = blockSigningKey === '' || this.isValidSigningKey(blockSigningKey); + const isUrlValid = witnessUrl === '' || /^https?:\/\/.+/i.test(witnessUrl.trim()); + const parsedSbdRate = parseFloat(sbdInterestRate); + const isSbdRateValid = sbdInterestRate === '' || (!Number.isNaN(parsedSbdRate) && parsedSbdRate >= 0 && parsedSbdRate <= 100); + + const canEdit = !loading && !loadingWitnessData && isOwner; + const canSubmit = + canEdit && + blockSigningKey.trim() !== '' && + witnessUrl.trim() !== '' && + isKeyValid && + isUrlValid && + isSbdRateValid; + + return ( +
+
+

{tt('steem_tools.create_witness.title')}

+
+ +
+
+ +
+
+ +
+
+
+ {tt('steem_tools.create_witness.witness_account')} +
+
+
+ @ + +
+
+ {isExistingWitness + ? tt('steem_tools.create_witness.mode_update') + : tt('steem_tools.create_witness.mode_create')} +
+
+
+ +
+
+ {tt('steem_tools.create_witness.block_signing_key')} +
+
+ + {!isKeyValid ? ( +
+ {tt('steem_tools.create_witness.error_invalid_signing_key')} +
+ ) : isNullSigningKey ? ( +
+ {tt('steem_tools.create_witness.disable_signing_key_hint')} +
+ ) : ( +
+ {tt('steem_tools.create_witness.block_signing_key_hint')} +
+ )} +
+
+ +
+
+ {tt('steem_tools.create_witness.witness_url')} +
+
+ + {!isUrlValid && ( +
+ {tt('steem_tools.create_witness.error_invalid_url')} +
+ )} +
+
+ +
+
+ {tt('steem_tools.create_witness.account_creation_fee')} +
+
+
+ + this.onChange({ + target: { + name: 'accountCreationFee', + value: `${e.target.value} STEEM`, + }, + }) + } + disabled={!canEdit} + /> + STEEM +
+
+
+ +
+
+ {tt('steem_tools.create_witness.sbd_interest_rate')} +
+
+
+ + % +
+ {!isSbdRateValid && ( +
+ {tt('steem_tools.create_witness.error_invalid_sbd_interest_rate')} +
+ )} +
+
+ + {(error || success) && ( +
+
+ {error &&
{error}
} + {success &&
{success}
} +
+
+ )} + +
+
+ {loading || loadingWitnessData ? ( + + ) : ( + + )} +
+
+
+
+ ); + } +} + +export default connect( + (state, ownProps) => { + const user = state.user.get('current'); + const currentUser = user && user.get('username'); + const accountName = ownProps.accountname || currentUser || ''; + const account = accountName ? state.global.getIn(['accounts', accountName]) : null; + return { currentUser, accountName, account }; + }, + (dispatch) => ({ + broadcastWitnessSetProperties: (operation, isExistingWitness, successCallback, errorCallback) => { + const successCb = () => { + dispatch(globalActions.getState({ url: `@${operation.owner}/witnesses` })); + if (successCallback) successCallback(); + }; + + const confirm = () => ( + + ); + + dispatch( + transactionActions.broadcastOperation({ + type: 'witness_set_properties', + operation, + confirm, + successCallback: successCb, + errorCallback, + }) + ); + }, + refreshAccount: (username) => dispatch(userActions.refreshAccount({ username })), + }) +)(CreateWitness); diff --git a/src/app/components/elements/SteemToolsContent/sections/DeclineVoting.jsx b/src/app/components/elements/SteemToolsContent/sections/DeclineVoting.jsx new file mode 100644 index 000000000..a00d29520 --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/sections/DeclineVoting.jsx @@ -0,0 +1,685 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import tt from 'counterpart'; +import { api } from '@steemit/steem-js'; +import { FormattedHTMLMessage } from 'app/Translator'; +import LoadingIndicator from 'app/components/elements/LoadingIndicator'; +import * as transactionActions from 'app/redux/TransactionReducer'; +import * as appActions from 'app/redux/AppReducer'; +import * as globalActions from 'app/redux/GlobalReducer'; +import * as userActions from 'app/redux/UserReducer'; + +class DeclineVoting extends React.Component { + constructor(props) { + super(props); + this.state = { + confirmPermanentDecline: false, + loading: false, + checkingRequest: false, + hasPendingDeclineRequest: false, + declineRequest: null, + requestLookupError: null, + error: null, + success: null, + }; + + this.requestLookupSeq = 0; + this.unmounted = false; + + this.onCheckboxChange = this.onCheckboxChange.bind(this); + this.onSubmit = this.onSubmit.bind(this); + this.onSubmitCancel = this.onSubmitCancel.bind(this); + this.onFailure = this.onFailure.bind(this); + this.onSuccess = this.onSuccess.bind(this); + this.loadDeclineRequestStatus = + this.loadDeclineRequestStatus.bind(this); + this.extractRequests = this.extractRequests.bind(this); + this.formatEffectiveDate = this.formatEffectiveDate.bind(this); + this.getPendingDeclineMeta = this.getPendingDeclineMeta.bind(this); + } + + componentDidMount() { + this.loadDeclineRequestStatus(this.props); + } + + componentDidUpdate(prevProps) { + if ( + prevProps.accountName !== this.props.accountName || + prevProps.currentUser !== this.props.currentUser + ) { + this.setState( + { + confirmPermanentDecline: false, + error: null, + success: null, + }, + () => this.loadDeclineRequestStatus(this.props) + ); + } + } + + componentWillUnmount() { + this.unmounted = true; + } + + onCheckboxChange(e) { + this.setState({ + confirmPermanentDecline: e.target.checked, + error: null, + success: null, + }); + } + + extractRequests(result) { + if (Array.isArray(result)) return result; + if (result && Array.isArray(result.requests)) return result.requests; + return []; + } + + formatEffectiveDate(value) { + const raw = String(value || '').trim(); + const match = raw.match( + /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?$/ + ); + + if (!match) return raw; + + const [, year, month, day] = match; + return `${day}-${month}-${year}`; + } + + getPendingDeclineMeta() { + const { declineRequest } = this.state; + const effectiveRaw = + declineRequest && declineRequest.effective_date + ? declineRequest.effective_date + : null; + + if (!effectiveRaw) { + return { + daysLeft: null, + dayLabel: null, + effectiveDateText: '', + }; + } + + const effectiveDate = new Date(effectiveRaw); + const now = new Date(); + + const daysLeft = Math.max( + 0, + Math.ceil((effectiveDate - now) / (1000 * 60 * 60 * 24)) + ); + + return { + daysLeft, + dayLabel: daysLeft === 1 ? 'day' : 'days', + effectiveDateText: this.formatEffectiveDate(effectiveRaw), + }; + } + + loadDeclineRequestStatus(props = this.props) { + const { currentUser, accountName } = props; + const lookupSeq = ++this.requestLookupSeq; + + if (!currentUser || !accountName || currentUser !== accountName) { + this.setState({ + checkingRequest: false, + hasPendingDeclineRequest: false, + declineRequest: null, + requestLookupError: null, + confirmPermanentDecline: false, + }); + return; + } + + this.setState({ + checkingRequest: true, + requestLookupError: null, + }); + + api.callAsync('database_api.find_decline_voting_rights_requests', { + accounts: [accountName], + }) + .then((result) => { + if (this.unmounted || lookupSeq !== this.requestLookupSeq) { + return; + } + + const requests = this.extractRequests(result); + const normalizedAccount = String(accountName || '').toLowerCase(); + + const declineRequest = + requests.find( + (item) => + String(item.account || '').toLowerCase() === + normalizedAccount + ) || null; + + this.setState({ + checkingRequest: false, + hasPendingDeclineRequest: !!declineRequest, + declineRequest, + requestLookupError: null, + confirmPermanentDecline: false, + }); + }) + .catch((error) => { + if (this.unmounted || lookupSeq !== this.requestLookupSeq) { + return; + } + + this.setState({ + checkingRequest: false, + hasPendingDeclineRequest: false, + declineRequest: null, + requestLookupError: + (error && error.message) || + tt( + 'steem_tools.decline_voting.error_check_pending_request' + ), + }); + }); + } + + onFailure(error) { + let errorMessage = error; + if ( + !errorMessage || + errorMessage === 0 || + errorMessage === false || + String(errorMessage).toLowerCase().includes('undefined') + ) { + errorMessage = tt('steem_tools.decline_voting.unexpected_error'); + } + + this.setState({ + loading: false, + error: errorMessage, + success: null, + }); + } + + onSuccess(mode) { + const { currentUser, refreshAccount } = this.props; + + refreshAccount(currentUser); + + this.setState( + (prevState) => ({ + loading: false, + error: null, + success: + mode === 'cancel' + ? tt( + 'steem_tools.decline_voting.cancel_success_message' + ) + : tt('steem_tools.decline_voting.success_message'), + confirmPermanentDecline: false, + ...(mode === 'decline' + ? { + hasPendingDeclineRequest: true, + declineRequest: prevState.declineRequest || { + effective_date: '', + account: currentUser, + }, + } + : {}), + ...(mode === 'cancel' + ? { + hasPendingDeclineRequest: false, + declineRequest: null, + } + : {}), + }), + () => { + this.loadDeclineRequestStatus(this.props); + } + ); + } + + onSubmit() { + const { currentUser, accountName, declineVotingRights } = this.props; + const { + confirmPermanentDecline, + checkingRequest, + hasPendingDeclineRequest, + } = this.state; + + if (!currentUser) { + this.setState({ + error: tt('steem_tools.decline_voting.error_no_account'), + success: null, + }); + return; + } + + if (!accountName) { + this.setState({ + error: tt('steem_tools.decline_voting.error_no_target_account'), + success: null, + }); + return; + } + + if (currentUser !== accountName) { + this.setState({ + error: tt('steem_tools.decline_voting.error_not_allowed'), + success: null, + }); + return; + } + + if (checkingRequest) { + this.setState({ + error: tt( + 'steem_tools.decline_voting.checking_pending_request' + ), + success: null, + }); + return; + } + + if (hasPendingDeclineRequest) { + this.setState({ + error: tt( + 'steem_tools.decline_voting.pending_request_already_exists' + ), + success: null, + }); + return; + } + + if (!confirmPermanentDecline) { + this.setState({ + error: tt( + 'steem_tools.decline_voting.error_confirmation_required' + ), + success: null, + }); + return; + } + + this.setState({ + loading: true, + error: null, + success: null, + }); + + declineVotingRights( + accountName, + true, + () => this.onSuccess('decline'), + this.onFailure + ); + } + + onSubmitCancel() { + const { currentUser, accountName, declineVotingRights } = this.props; + const { checkingRequest, hasPendingDeclineRequest } = this.state; + + if (!currentUser) { + this.setState({ + error: tt('steem_tools.decline_voting.error_no_account'), + success: null, + }); + return; + } + + if (!accountName) { + this.setState({ + error: tt('steem_tools.decline_voting.error_no_target_account'), + success: null, + }); + return; + } + + if (currentUser !== accountName) { + this.setState({ + error: tt('steem_tools.decline_voting.error_not_allowed'), + success: null, + }); + return; + } + + if (checkingRequest) { + this.setState({ + error: tt( + 'steem_tools.decline_voting.checking_pending_request' + ), + success: null, + }); + return; + } + + if (!hasPendingDeclineRequest) { + this.setState({ + error: tt( + 'steem_tools.decline_voting.error_no_pending_request' + ), + success: null, + }); + return; + } + + this.setState({ + loading: true, + error: null, + success: null, + }); + + declineVotingRights( + accountName, + false, + () => this.onSuccess('cancel'), + this.onFailure + ); + } + + render() { + const { currentUser, accountName } = this.props; + const { + confirmPermanentDecline, + loading, + checkingRequest, + hasPendingDeclineRequest, + requestLookupError, + error, + success, + } = this.state; + + const isOwner = + !!currentUser && + !!accountName && + currentUser === accountName; + + const canDecline = + !loading && + !checkingRequest && + isOwner && + confirmPermanentDecline && + !hasPendingDeclineRequest; + + const canCancelPending = + !loading && + !checkingRequest && + isOwner && + hasPendingDeclineRequest; + + const pendingMeta = this.getPendingDeclineMeta(); + + const pendingWarningMessage = + pendingMeta.daysLeft != null + ? tt('steem_tools.decline_voting.pending_warning_message', { + days: pendingMeta.daysLeft, + day_label: pendingMeta.dayLabel, + account: accountName, + effective_date: pendingMeta.effectiveDateText, + }) + : tt( + 'steem_tools.decline_voting.pending_warning_message_no_date', + { + account: accountName, + } + ); + + return ( +
+
+
+

+ {tt('steem_tools.decline_voting.title')} +

+
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+ {tt('steem_tools.decline_voting.current_account')} +
+
+
+
+ @ + +
+
+
+ + {checkingRequest && isOwner ? ( +
+
+
+ + + {tt( + 'steem_tools.decline_voting.checking_pending_request' + )} + +
+
+
+ ) : null} + + {!checkingRequest && requestLookupError && isOwner ? ( +
+
+
+ {requestLookupError} +
+
+
+ ) : null} + + {!checkingRequest && hasPendingDeclineRequest ? ( +
+
+
+ {pendingWarningMessage} +
+
+
+ ) : ( +
+
+
+ +
+
+
+ )} + + {!hasPendingDeclineRequest ? ( +
+
+ + {tt( + 'steem_tools.decline_voting.confirmation_label' + )} + + +
+
+ ) : null} + + {!loading && !isOwner && currentUser && accountName ? ( +
+
+
+ {tt( + 'steem_tools.decline_voting.error_not_allowed' + )} +
+
+
+ ) : null} + + {!loading && error ? ( +
+
+
+ {error} +
+
+
+ ) : null} + + {!loading && success ? ( +
+
+
+ {success} +
+
+
+ ) : null} + +
+
+ {loading ? ( + + + + ) : hasPendingDeclineRequest ? ( + + ) : ( + + )} +
+
+
+
+
+ ); + } +} + +export default connect( + (state, ownProps) => { + const user = state.user.get('current'); + const currentUser = user && user.get('username'); + + const accountName = + ownProps.accountname || + currentUser || + ''; + + const account = accountName + ? state.global.getIn(['accounts', accountName]) + : null; + + return { + currentUser, + accountName, + account, + }; + }, + dispatch => ({ + declineVotingRights: ( + account, + decline, + successCallback, + errorCallback + ) => { + const successCb = () => { + dispatch(globalActions.getState({ url: `@${account}/permissions` })); + if (successCallback) successCallback(); + }; + + const operation = { + account, + decline, + }; + + const conf = decline + ? tt( + 'steem_tools.decline_voting.confirm_broadcast_message', + { account } + ) + : tt( + 'steem_tools.decline_voting.confirm_cancel_broadcast_message', + { account } + ); + + dispatch( + transactionActions.broadcastOperation({ + type: 'decline_voting_rights', + operation, + confirm: conf + '?', + confirmTitle: decline ? tt('steem_tools.decline_voting.confirm_decline_voting_rights') : tt('steem_tools.decline_voting.cancel_decline_voting_rights_request'), + successCallback: successCb, + errorCallback, + }) + ); + }, + refreshAccount: username => + dispatch( + userActions.refreshAccount({ + username, + }) + ), + removeNotification: key => + dispatch(appActions.removeNotification({ key })), + }) +)(DeclineVoting); diff --git a/src/app/components/elements/SteemToolsContent/sections/DisableWitness.jsx b/src/app/components/elements/SteemToolsContent/sections/DisableWitness.jsx new file mode 100644 index 000000000..ffa2fb537 --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/sections/DisableWitness.jsx @@ -0,0 +1,403 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import tt from 'counterpart'; +import { FormattedHTMLMessage } from 'app/Translator'; +import LoadingIndicator from 'app/components/elements/LoadingIndicator'; +import { api } from '@steemit/steem-js'; +import * as transactionActions from 'app/redux/TransactionReducer'; +import * as appActions from 'app/redux/AppReducer'; +import * as globalActions from 'app/redux/GlobalReducer'; +import * as userActions from 'app/redux/UserReducer'; +import ConfirmDisableWitness from 'app/components/elements/ConfirmDisableWitness'; + +class DisableWitness extends React.Component { + constructor(props) { + super(props); + this.state = { + loading: false, + loadingWitnessData: false, + error: null, + success: null, + isWitness: false, + currentSigningKey: '', + witnessUrl: '', + accountCreationFee: '0.000 STEEM', + maximumBlockSize: '65536', + sbdInterestRate: '0', + }; + + this.onSubmit = this.onSubmit.bind(this); + this.onFailure = this.onFailure.bind(this); + this.onSuccess = this.onSuccess.bind(this); + this.loadWitnessData = this.loadWitnessData.bind(this); + } + + componentDidMount() { + this.loadWitnessData(this.props); + } + + componentDidUpdate(prevProps) { + if (prevProps.accountName !== this.props.accountName) { + this.loadWitnessData(this.props); + } + } + + async loadWitnessData(props = this.props) { + const { accountName } = props; + + if (!accountName) return; + + this.setState({ + loadingWitnessData: true, + error: null, + success: null, + }); + + try { + const witness = await api.getWitnessByAccountAsync(accountName); + + if (witness) { + const propsData = witness.props || {}; + this.setState({ + isWitness: true, + currentSigningKey: witness.signing_key || '', + witnessUrl: witness.url || '', + accountCreationFee: propsData.account_creation_fee || '0.000 STEEM', + maximumBlockSize: String(propsData.maximum_block_size || 65536), + sbdInterestRate: String(propsData.sbd_interest_rate || 0), + loadingWitnessData: false, + }); + return; + } + + this.setState({ + isWitness: false, + currentSigningKey: '', + witnessUrl: '', + accountCreationFee: '0.000 STEEM', + maximumBlockSize: '65536', + sbdInterestRate: '0', + loadingWitnessData: false, + }); + } catch (error) { + this.setState({ + loadingWitnessData: false, + error: tt('steem_tools.disable_witness.error_loading_witness'), + success: null, + }); + } + } + + onFailure(error) { + let errorMessage = error; + if ( + !errorMessage || + errorMessage === 0 || + errorMessage === false || + String(errorMessage).toLowerCase().includes('undefined') + ) { + errorMessage = tt('steem_tools.disable_witness.unexpected_error'); + } + + this.setState({ + loading: false, + error: errorMessage, + success: null, + }); + } + + onSuccess() { + const { currentUser, refreshAccount } = this.props; + refreshAccount(currentUser); + this.setState({ + loading: false, + error: null, + success: tt('steem_tools.disable_witness.success_message'), + }); + this.loadWitnessData(); + } + + onSubmit() { + const { currentUser, accountName, disableWitness } = this.props; + const { + isWitness, + witnessUrl, + accountCreationFee, + maximumBlockSize, + sbdInterestRate, + currentSigningKey, + } = this.state; + + if (!currentUser) { + this.setState({ + error: tt('steem_tools.disable_witness.error_no_account'), + success: null, + }); + return; + } + + if (!accountName) { + this.setState({ + error: tt('steem_tools.disable_witness.error_no_target_account'), + success: null, + }); + return; + } + + if (currentUser !== accountName) { + this.setState({ + error: tt('steem_tools.disable_witness.error_not_allowed'), + success: null, + }); + return; + } + + if (!isWitness) { + this.setState({ + error: tt('steem_tools.disable_witness.error_not_witness'), + success: null, + }); + return; + } + + this.setState({ + loading: true, + error: null, + success: null, + }); + + disableWitness( + { + owner: accountName, + url: witnessUrl || '', + block_signing_key: 'STM1111111111111111111111111111111114T1Anm', + props: { + account_creation_fee: accountCreationFee, + maximum_block_size: parseInt(maximumBlockSize, 10) || 65536, + sbd_interest_rate: parseInt(sbdInterestRate, 10) || 0, + }, + fee: '0.000 STEEM', + current_signing_key: currentSigningKey || '', + }, + this.onSuccess, + this.onFailure + ); + } + + render() { + const { + currentUser, + accountName, + } = this.props; + + const { + loading, + loadingWitnessData, + error, + success, + isWitness, + currentSigningKey, + } = this.state; + + const isOwner = + !!currentUser && + !!accountName && + currentUser === accountName; + + const canDisable = !loading && !loadingWitnessData && isOwner && isWitness; + + return ( +
+
+
+

{tt('steem_tools.disable_witness.title')}

+
+ +
+
+
+ +
+
+
+
+ +
+
+
+
{tt('steem_tools.disable_witness.witness_account')}
+
+
+
+ @ + +
+ {!loadingWitnessData ? ( +
+ {isWitness + ? tt('steem_tools.disable_witness.witness_status_yes') + : tt('steem_tools.disable_witness.witness_status_no')} +
+ ) : null} +
+
+ + {isWitness ? ( +
+
+
{tt('steem_tools.disable_witness.current_signing_key')}
+
+
+ +
+
+ ) : null} + + {!loading && !loadingWitnessData && !isOwner && currentUser && accountName ? ( +
+
+
+ {tt('steem_tools.disable_witness.error_not_allowed')} +
+
+
+ ) : null} + + {!loading && !loadingWitnessData && isOwner && !isWitness && accountName ? ( +
+
+
+ {tt('steem_tools.disable_witness.error_not_witness')} +
+
+
+ ) : null} + + {!loading && !loadingWitnessData && error ? ( +
+
+
{error}
+
+
+ ) : null} + + {!loading && !loadingWitnessData && success ? ( +
+
+
{success}
+
+
+ ) : null} + +
+
+ {loading || loadingWitnessData ? ( + + + + ) : ( + + )} +
+
+
+
+
+ ); + } +} + +export default connect( + (state, ownProps) => { + const user = state.user.get('current'); + const currentUser = user && user.get('username'); + + const accountName = + ownProps.accountname || + currentUser || + ''; + + const account = accountName + ? state.global.getIn(['accounts', accountName]) + : null; + + return { + currentUser, + accountName, + account, + }; + }, + dispatch => ({ + disableWitness: ( + operation, + successCallback, + errorCallback + ) => { + const successCb = () => { + dispatch(globalActions.getState({ url: `@${operation.owner}/witnesses` })); + if (successCallback) successCallback(); + }; + + const confirm = () => ( + + ); + + dispatch( + transactionActions.broadcastOperation({ + type: 'witness_update', + operation: { + owner: operation.owner, + url: operation.url, + block_signing_key: operation.block_signing_key, + props: operation.props, + fee: operation.fee, + }, + confirm, + successCallback: successCb, + errorCallback, + }) + ); + }, + refreshAccount: username => + dispatch( + userActions.refreshAccount({ + username, + }) + ), + removeNotification: key => + dispatch(appActions.removeNotification({ key })), + }) +)(DisableWitness); diff --git a/src/app/components/elements/SteemToolsContent/sections/GenerateBrainKeys.jsx b/src/app/components/elements/SteemToolsContent/sections/GenerateBrainKeys.jsx new file mode 100644 index 000000000..4505ec6ca --- /dev/null +++ b/src/app/components/elements/SteemToolsContent/sections/GenerateBrainKeys.jsx @@ -0,0 +1,403 @@ +import React from 'react'; +import tt from 'counterpart'; +import createHash from 'create-hash'; +import { PrivateKey } from '@steemit/steem-js/lib/auth/ecc'; +import { FormattedHTMLMessage } from 'app/Translator'; +import { STEEM_WORDS } from './steem_words'; + +class GenerateBrainKeys extends React.Component { + constructor(props) { + super(props); + this.state = { + keysVisible: false, + brainKey: '', + editableBrainKey: '', + privateKey: '', + publicKey: '', + error: null, + success: null, + }; + + this.generateKeys = this.generateKeys.bind(this); + this.handleEditableBrainKeyChange = this.handleEditableBrainKeyChange.bind(this); + this.exportKeys = this.exportKeys.bind(this); + this.buildBrainSequence = this.buildBrainSequence.bind(this); + this.normalizeBrainKey = this.normalizeBrainKey.bind(this); + this.sha256 = this.sha256.bind(this); + this.sha512 = this.sha512.bind(this); + this.bufferToBigInt = this.bufferToBigInt.bind(this); + this.getSecureRandomBytes = this.getSecureRandomBytes.bind(this); + this.derivePrivateKey = this.derivePrivateKey.bind(this); + this.generateKeyPairFromBrainKey = this.generateKeyPairFromBrainKey.bind(this); + this.updateKeysFromBrainKey = this.updateKeysFromBrainKey.bind(this); + } + + componentDidMount() { + this.generateKeys(); + } + + normalizeBrainKey(value) { + return String(value || '').trim().replace(/\s+/g, ' ').toLowerCase() + } + + sha256(data) { + return createHash('sha256').update(data).digest(); + } + + sha512(data) { + return createHash('sha512').update(data).digest(); + } + + bufferToBigInt(bufferLike) { + const buffer = bufferLike instanceof Uint8Array ? bufferLike : new Uint8Array(bufferLike); + let hex = ''; + + for (let i = 0; i < buffer.length; i += 1) { + hex += buffer[i].toString(16).padStart(2, '0'); + } + + return hex ? BigInt(`0x${hex}`) : BigInt(0); + } + + getSecureRandomBytes(length) { + if ( + typeof window !== 'undefined' && + window.crypto && + typeof window.crypto.getRandomValues === 'function' + ) { + const bytes = new Uint8Array(length); + window.crypto.getRandomValues(bytes); + return bytes; + } + + throw new Error('Secure random generator is not available in this environment'); + } + + buildBrainSequence(wordCount = 16) { + if (!Array.isArray(STEEM_WORDS) || !STEEM_WORDS.length) { + throw new Error('STEEM_WORDS is empty or invalid'); + } + + const entropy1 = this.getSecureRandomBytes(32); + const entropy2 = this.getSecureRandomBytes(32); + + let entropy = + (this.bufferToBigInt(entropy1) << BigInt(32 * 8)) + + this.bufferToBigInt(entropy2); + + const wordListSize = BigInt(STEEM_WORDS.length); + const words = []; + + for (let i = 0; i < wordCount; i += 1) { + const choice = Number(entropy % wordListSize); + entropy = entropy / wordListSize; + words.push(STEEM_WORDS[choice]); + } + + return this.normalizeBrainKey(words.join(' ')).toUpperCase(); + } + + derivePrivateKey(brainKey, sequence = 0) { + const normalizedBrainKey = this.normalizeBrainKey(brainKey); + + if (!normalizedBrainKey) { + throw new Error('Brain key is empty'); + } + + const seed = `${normalizedBrainKey} ${sequence}`; + const privateKeyBuffer = this.sha256(seed); + return PrivateKey.fromBuffer(privateKeyBuffer); + } + + generateKeyPairFromBrainKey(brainKey) { + const normalizedBrainKey = this.normalizeBrainKey(brainKey); + const privateKey = this.derivePrivateKey(normalizedBrainKey, 0); + const wifPrivateKey = privateKey.toWif(); + const publicKey = privateKey.toPublicKey().toString(); + + return { + brainKey: normalizedBrainKey.toUpperCase(), + privateKey: wifPrivateKey, + publicKey, + }; + } + + updateKeysFromBrainKey(rawBrainKey) { + const normalizedBrainKey = this.normalizeBrainKey(rawBrainKey); + + if (!normalizedBrainKey) { + this.setState({ + editableBrainKey: rawBrainKey, + keysVisible: false, + brainKey: '', + privateKey: '', + publicKey: '', + error: null, + success: null, + }); + return; + } + + try { + const generatedKeys = this.generateKeyPairFromBrainKey(rawBrainKey); + + this.setState({ + editableBrainKey: rawBrainKey, + keysVisible: true, + brainKey: generatedKeys.brainKey, + privateKey: generatedKeys.privateKey, + publicKey: generatedKeys.publicKey, + error: null, + success: tt('steem_tools.generate_brain_keys.success_message'), + }); + } catch (e) { + this.setState({ + editableBrainKey: rawBrainKey, + keysVisible: false, + brainKey: '', + privateKey: '', + publicKey: '', + error: tt('steem_tools.generate_brain_keys.error_generating', { + message: e && e.message ? e.message : 'Unknown error', + }), + success: null, + }); + } + } + + handleEditableBrainKeyChange(e) { + const value = e.target.value; + this.updateKeysFromBrainKey(value); + } + + generateKeys() { + try { + const generatedBrainKey = this.buildBrainSequence(16); + const generatedKeys = this.generateKeyPairFromBrainKey(generatedBrainKey); + + this.setState({ + keysVisible: true, + brainKey: generatedKeys.brainKey, + editableBrainKey: generatedKeys.brainKey, + privateKey: generatedKeys.privateKey, + publicKey: generatedKeys.publicKey, + error: null, + success: tt('steem_tools.generate_brain_keys.success_message'), + }); + } catch (e) { + this.setState({ + error: tt('steem_tools.generate_brain_keys.error_generating', { + message: e && e.message ? e.message : 'Unknown error', + }), + success: null, + }); + } + } + + exportKeys() { + const { keysVisible, brainKey, privateKey, publicKey } = this.state; + + if (!keysVisible) return; + + try { + const payload = { + brain_priv_key: brainKey.toUpperCase(), + wif_priv_key: privateKey, + pub_key: publicKey, + }; + + const file = new Blob([JSON.stringify(payload, null, 2)], { + type: 'application/json', + }); + + const element = document.createElement('a'); + element.href = URL.createObjectURL(file); + element.download = `witness_brain_keys_${Date.now()}.json`; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); + URL.revokeObjectURL(element.href); + + this.setState({ + error: null, + success: tt('steem_tools.generate_brain_keys.export_success_message'), + }); + } catch (e) { + this.setState({ + error: tt('steem_tools.generate_brain_keys.error_exporting', { + message: e && e.message ? e.message : 'Unknown error', + }), + success: null, + }); + } + } + + render() { + const { + keysVisible, + brainKey, + editableBrainKey, + privateKey, + publicKey, + error, + success, + } = this.state; + + const keyRows = [ + { + key: 'brain', + type: tt('steem_tools.generate_brain_keys.brain_sequence_label'), + value: brainKey, + }, + { + key: 'private', + type: tt('steem_tools.generate_brain_keys.private_key_label'), + value: privateKey, + }, + { + key: 'public', + type: tt('steem_tools.generate_brain_keys.public_key_label'), + value: publicKey, + }, + ]; + + return ( +
+
+
+

+ {tt('steem_tools.generate_brain_keys.panel_title')} +

+
+ +
+
+
+ +
+
+
+ +
+
+
+
+ {tt('steem_tools.generate_brain_keys.brain_sequence_label')} +
+ +