passbolt-styleguide
Version:
Passbolt styleguide contains common styling assets used by the different sites, plugin, etc.
854 lines (800 loc) • 26.5 kB
JavaScript
/**
* Passbolt ~ Open source password manager for teams
* Copyright (c) 2021 Passbolt SA (https://www.passbolt.com)
*
* Licensed under GNU Affero General Public License version 3 of the or any later version.
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) 2021 Passbolt SA (https://www.passbolt.com)
* @license https://opensource.org/licenses/AGPL-3.0 AGPL License
* @link https://www.passbolt.com Passbolt(tm)
* @since 3.3.0
*/
import React from "react";
import PropTypes from "prop-types";
import AppContext, { withAppContext } from "../../../../shared/context/AppContext/AppContext";
import { withDialog } from "../../../contexts/DialogContext";
import QRCode from "qrcode";
import { sha512 } from "../../../lib/Crypto/sha512";
import { Trans, withTranslation } from "react-i18next";
import { withUserSettings } from "../../../contexts/UserSettingsContext";
import ShowErrorDetails from "../../Common/Error/ShowErrorDetails/ShowErrorDetails";
import AnimatedFeedback from "../../../../shared/components/Icons/AnimatedFeedback";
import MobileTransferIcon from "../../Common/Icons/MobileTransferIcon";
import FileTextSVG from "../../../../img/svg/file_text.svg";
import { createSafePortal } from "../../../../shared/utils/portals";
// Ref. http://blog.qr4.nl/page/QR-Code-Data-Capacity.aspx
const QRCODE_VERSION = 27;
const QRCODE_ERROR_CORRECTION = "L";
const QRCODE_MAXSLICE = 1465;
const QRCODE_MARGIN = 4;
const QRCCODE_PROTOCOL_VERSION = 1;
const QRCODE_WIDTH = 325;
const FETCH_INTERVAL = 333; //in ms
const MAX_UINT8 = 255;
const TransferToMobileSteps = {
HTTPS_REQUIRED: "https required",
START: "start",
IN_PROGRESS: "in progress",
COMPLETE: "complete",
CANCEL: "cancel",
ERROR: "error",
};
/**
* This component displays the user profile information
*/
class TransferToMobile extends React.Component {
/**
* Default constructor
* @param props Component props
*/
constructor(props) {
super(props);
this.state = this.defaultState;
this.timeout = undefined;
this.request = 0;
this.bindHandlers();
}
/**
* Returns the component default state
*/
get defaultState() {
const step = this.isRunningUnderHttps ? TransferToMobileSteps.START : TransferToMobileSteps.HTTPS_REQUIRED;
return {
step: step,
page: 0,
processing: false,
qrCodes: undefined, // QR code cache
debug: true,
error: undefined,
showErrorDetails: false,
transferDto: undefined,
};
}
/**
* Returns the current user
*/
get user() {
return this.context.loggedInUser;
}
/**
* Get the translate function
* @returns {function(...[*]=)}
*/
get translate() {
return this.props.t;
}
/**
* Get current domain
* @returns {string}
*/
get domain() {
return this.context.userSettings.getTrustedDomain();
}
/**
* Binds the component handlers
*/
bindHandlers() {
this.handleClickStart = this.handleClickStart.bind(this);
this.handleClickCancel = this.handleClickCancel.bind(this);
this.handleClickDone = this.handleClickDone.bind(this);
}
/**
* Whenever the user wants to start the transfer
*/
async handleClickStart() {
// Prevent click while processing
if (this.state.processing) {
return;
}
try {
this.setState({ processing: true });
await this.createTransfer();
} catch (error) {
// Could be that the user canceled or couldn't remember the passphrase
if (error.name === "UserAbortsOperationError") {
return this.handleTransferCancel();
} else {
return this.handleTransferError(error);
}
} finally {
this.setState({ processing: false });
}
}
/**
* Build first QR code
* @param {object} transferDto
* @param {int} totalPages
* @param {string} hash
* @returns {Promise<string>} image data
*/
async buildFirstQrCode(transferDto, totalPages, hash) {
// sanity checks
if (!transferDto) {
throw new Error(this.translate("Server response is empty."));
}
if (transferDto.total_pages !== totalPages || transferDto.hash !== hash) {
const error = new Error(this.translate("Server response does not match initial request."));
error.data = { transferDto: transferDto, totalPages, hash };
throw error;
}
if (!transferDto.authentication_token || !transferDto.authentication_token.token) {
throw new Error(this.translate("Authentication token is missing from server response."));
}
const str = this.getTransferMetadataDataAsString(transferDto);
const slices = this.stringToSlices(str, 0);
if (!slices || slices.length === 0) {
throw new Error(this.translate("Sorry, it is not possible to proceed. The first QR code is empty."));
}
if (slices.length > 1) {
throw new Error(this.translate("Sorry, it is not possible to proceed. The first QR code is too big."));
}
return await this.getQrCode(slices[0]);
}
/*
*
* Data prep
*
*/
/**
* Get first QR code data
*
* @param {object} transferDto
* @returns {string}
*/
getTransferMetadataDataAsString(transferDto) {
return JSON.stringify({
transfer_id: transferDto.id,
user_id: this.user.id,
domain: this.domain,
total_pages: transferDto.total_pages,
hash: transferDto.hash,
authentication_token: transferDto.authentication_token.token,
});
}
/**
* Getter for bulk of the data to transfer
*
* @returns {Promise<string>} base64 encoded JSON string with
* {user: uuid, fingerprint: string, armored_key: openpgp secret armored key block}
*/
async getTransferDataAsString() {
const fingerprint = await this.getFingerprint();
const privateKey = await this.getPrivateKey();
return JSON.stringify({ user_id: this.user.id, fingerprint, armored_key: privateKey });
}
/**
* Get the transfer data hash
* Will be send to the server so that the client can double check the data integrity
* at the end of the transfer process
*
* @param {string} message
* @throws {Error} if message is empty or not a string
* @returns {Promise<string>}
*/
async getTransferHash(message) {
return await sha512(message);
}
/*
*
* QR Code generation
*
*/
/**
* Build QR codes
* Get the raw data, slices it according to desired QR code size, build QR codes as images
* @returns {Promise<[]>}
*/
async buildQrCodes(rawData) {
const slices = this.stringToSlices(rawData, 1);
const qrCodes = [];
for (let i = 0; i < slices.length; i++) {
const qrCode = await this.getQrCode(slices[i]);
qrCodes.push(qrCode);
}
return qrCodes;
}
/**
* String to slice
*
* @param {string} data
* @param {int} startPage
* @returns {array} array of strings sliced to fit on the defined QR CODE size
*/
stringToSlices(data, startPage) {
const slices = [];
/*
* 2 reserved byte for page number, max page number 255
* 1 reserved byte for protocol version
*/
const sliceSize = QRCODE_MAXSLICE - (2 + 1);
const sliceNeeded = Math.ceil(data.length / sliceSize);
if (sliceNeeded > MAX_UINT8) {
throw new Error("Cannot transfer the data, the private key is too big.");
}
if (typeof startPage === "undefined") {
startPage = 0;
}
for (let i = 0; i < sliceNeeded; i++) {
const pageCounter = startPage + i;
/*
* Header - Version and page
* UInt8 => Hex string => Unicode value of the individual characters
* 255 => "ff" => 70 70
*
* Unfortunately we we cannot send these numbers as bytes.
* This sub optimal encoding is due to compatibility issues with iOS QR Code scanning library
*/
const version = QRCCODE_PROTOCOL_VERSION.toString(16);
const page = pageCounter.toString(16).padStart(2, "0");
const uint8Header = new Uint8ClampedArray([version.charCodeAt(0), page.charCodeAt(0), page.charCodeAt(1)]);
/*
* Data
* Similar encoding, but since data is just ASCII chars, one less step
* "F" => 102
*/
const start = i === 0 ? 0 : i * sliceSize;
let end = i === 0 ? sliceSize : sliceSize * (i + 1);
end = end > data.length ? data.length : end;
const slicedData = data.slice(start, end);
const uint8Data = this.str2bytes(slicedData);
// together
slices[i] = Uint8ClampedArray.from([...uint8Header, ...uint8Data]);
}
return slices;
}
/**
* Convert a 8bit encoded string into Uint8ClampedArray
* @param {string} str
* @returns {Uint8ClampedArray}
*/
str2bytes(str) {
const buffer = new Uint8ClampedArray(str.length);
for (let i = 0, strLen = str.length; i < strLen; i++) {
buffer[i] = str.charCodeAt(i);
}
return buffer;
}
/**
* Populates the component with data
* @param {Uint8ClampedArray} content
* @returns {Promise<string>} image data
*/
async getQrCode(content) {
try {
const data = new TextDecoder().decode(content);
return await QRCode.toDataURL(
[
{
data: data,
mode: "byte",
},
],
{
version: QRCODE_VERSION,
errorCorrectionLevel: QRCODE_ERROR_CORRECTION,
type: "image/jpeg",
quality: 1,
margin: QRCODE_MARGIN,
},
);
} catch (error) {
this.handleError(error);
}
}
/*
*
* API CALLS
*
*/
/**
* Fetch the user key id
*/
async getPrivateKey() {
return await this.context.port.request("passbolt.keyring.get-private-key");
}
/**
* Find a user gpg key
*
* @throws {Error} if fingerprint is not available
* @returns {Promise<String>} fingerprint
*/
async getFingerprint() {
const key = await this.context.port.request("passbolt.keyring.get-public-key-info-by-user", this.user.id);
if (!key || !key.fingerprint) {
throw new Error("The user fingerprint is not set.");
}
return key.fingerprint;
}
/*
*
* Transfer state machine
*
*/
/**
* Initiate the exchange by creating a transfer entity server side
* This will allow the clients to coordinate the transfer
*
* @returns {Promise<void>}
*/
async createTransfer() {
const rawString = await this.getTransferDataAsString();
const hash = await this.getTransferHash(rawString);
const qrCodes = await this.buildQrCodes(rawString);
const totalPages = qrCodes.length + 1; // +1 for the first QR code with hash and auth token
try {
const data = { total_pages: totalPages, hash: hash };
const transferDto = await this.context.port.request("passbolt.mobile.transfer.create", data);
const firstQrCode = await this.buildFirstQrCode(transferDto, totalPages, hash);
qrCodes.unshift(firstQrCode);
this.setState({ qrCodes, step: "in progress", page: 0, transferDto }, () => {
this.setInterval();
});
} catch (error) {
this.handleError(error);
}
}
/**
* Get some update
*
* @returns {Promise<void>}
*/
async getUpdatedTransfer() {
let transferDto;
try {
this.request = 1;
transferDto = await this.context.port.request("passbolt.mobile.transfer.get", this.state.transferDto.id);
this.request = 0;
} catch (error) {
// if there is an error, consider the transfer cancelled
await this.handleTransferError(error);
return;
}
if (transferDto) {
switch (transferDto.status) {
case TransferToMobileSteps.START:
// update QR code only if the page changed
break;
case TransferToMobileSteps.IN_PROGRESS:
if (transferDto.current_page !== this.state.page) {
await this.handleTransferUpdated(transferDto);
}
break;
case TransferToMobileSteps.COMPLETE:
await this.handleTransferComplete();
break;
case TransferToMobileSteps.ERROR:
await this.handleTransferError();
break;
case TransferToMobileSteps.CANCEL:
await this.handleTransferCancelled();
break;
default:
await this.handleTransferError(new Error("Unsupported status"));
break;
}
}
}
/**
* Handle when the transfer was updated
* Turn pages, etc.
*
* @param transferDto
* @returns {Promise<void>}
*/
async handleTransferUpdated(transferDto) {
this.setState({ transferDto, step: TransferToMobileSteps.IN_PROGRESS, page: transferDto.current_page });
}
/**
* Tell the server that the transfer is cancelled browser extension side
* This allows the other client to also give up.
*
* @returns {Promise<void>}
*/
async handleTransferCancel() {
this.clearInterval();
try {
// cancel server side if we had the time to create a transfer entity there
if (this.state.transferDto && this.state.transferDto !== TransferToMobileSteps.CANCEL) {
const transferDto = { id: this.state.transferDto.id, status: TransferToMobileSteps.CANCEL };
await this.context.port.request("passbolt.mobile.transfer.update", transferDto);
}
} catch (error) {
// not much to recover from
console.error(error);
}
this.setState(this.defaultState);
}
/**
* Tell the server that the transfer is cancelled mobile side
*
* @returns {Promise<void>}
*/
async handleTransferCancelled() {
this.clearInterval();
const cancelState = this.defaultState;
cancelState.step = TransferToMobileSteps.CANCEL;
this.setState(cancelState);
}
/**
* When the transfer is over
* @returns {Promise<void>}
*/
async handleTransferComplete() {
this.clearInterval();
const completeState = this.defaultState;
completeState.step = TransferToMobileSteps.COMPLETE;
this.setState(completeState);
}
/**
* When the transfer is reporting an issue
* @returns {Promise<void>}
*/
async handleTransferError(error) {
this.clearInterval();
if (!error) {
const msg = this.translate("The transfer was cancelled because the other client returned an error.");
error = new Error(msg);
}
this.handleError(error);
}
/*
*
* Update polling
*
*/
/**
* componentWillUnmount
* This method is called when a component is being removed from the DOM
*/
componentWillUnmount() {
this.clearInterval();
}
/**
* Add an interval to fetch transfer update
*/
setInterval() {
this.timeout = window.setInterval(() => {
// throttle requests so that there is only one pending at a given time
if (this.request === 0) {
this.getUpdatedTransfer();
}
}, FETCH_INTERVAL);
}
/**
* Remove the interval fetching the last transfer update
*/
clearInterval() {
if (this.timeout) {
window.clearInterval(this.timeout);
this.timeout = null;
}
this.request = 0;
}
/*
*
* UI related events
*
*/
/**
* What happens when the user clicks cancel
*
* @returns {Promise<void>}
*/
async handleClickCancel() {
this.handleTransferCancel();
}
/**
* When the transfer is over and one wants to restart
* @returns {Promise<void>}
*/
async handleClickDone() {
this.clearInterval();
this.setState(this.defaultState);
}
/*
*
* JSX Helpers
*
*/
/**
* Handle error to display the error info and retry
* @param {Error} error
*/
handleError(error) {
console.error(error);
this.setState({ step: TransferToMobileSteps.ERROR, error });
}
/**
* Return the current QR code src (inline image)
* @returns {string|*}
*/
getCurrentQrCodeSrc() {
if (typeof this.state.qrCodes[this.state.page] === "undefined") {
// TODO display something...
return "";
}
return this.state.qrCodes[this.state.page];
}
/**
* Surround the current reactDomElement with <strong> tags if the given step is the current one.
* @param {ReactDOM} reactDomElement
* @param {string} targetStep
* @returns {ReactDOM}
*/
highlightIfCurrentStep(reactDomElement, targetStep) {
return this.state.step === targetStep ? <strong>{reactDomElement}</strong> : reactDomElement;
}
/**
* Returns true if the current URL is using the protocol HTTPS
* @returns {boolean}
*/
get isRunningUnderHttps() {
const trustedDomain = this.props.context.userSettings.getTrustedDomain();
const url = new URL(trustedDomain);
return url.protocol === "https:";
}
/**
* Render
* @returns {JSX.Element}
*/
render() {
const processingClassName = this.state.processing ? "processing" : "";
return (
<>
<div className="main-column profile-mobile-transfer">
{this.state.step === TransferToMobileSteps.START && (
<div className="profile main-content mobile-transfer-step-start">
<h3>
<Trans>Welcome to the mobile app setup</Trans>
</h3>
<h4 className="no-border">
<Trans>Download the mobile app</Trans>
</h4>
<p>
<Trans>Passbolt is available on AppStore & PlayStore</Trans>
</p>
<div className="stores">
<a
className="app-store"
href="https://apps.apple.com/lv/app/passbolt-password-manager/id1569629432"
target="_blank"
rel="noopener noreferrer"
></a>
<a
className="play-store"
href="https://play.google.com/store/apps/details?id=com.passbolt.mobile.android"
target="_blank"
rel="noopener noreferrer"
></a>
</div>
<h4>
<Trans>Transfer your account key</Trans>
</h4>
<div className="transfer-account">
<MobileTransferIcon />
<div className="transfer-account-description">
<p>
<Trans>
Click start once the mobile application is installed and opened on your phone and you are ready to
scan QR codes.
</Trans>
</p>
</div>
</div>
</div>
)}
{this.state.step === TransferToMobileSteps.IN_PROGRESS && (
<div className="profile main-content mobile-transfer-step-in-progress">
<h3>
<Trans>Transfer in progress...</Trans>
</h3>
<img
id="qr-canvas"
style={{ width: `${QRCODE_WIDTH}px`, height: `${QRCODE_WIDTH}px` }}
src={this.getCurrentQrCodeSrc()}
/>
</div>
)}
{this.state.step === TransferToMobileSteps.COMPLETE && (
<div className="profile main-content mobile-transfer-step-complete">
<h3>
<Trans>Transfer complete!</Trans>
</h3>
<div className="feedback-card">
<AnimatedFeedback name="success" />
<div className="additional-information">
<p>
<Trans>You are now ready to continue the setup on your phone.</Trans>
<Trans>You can restart this process if you want to configure another phone.</Trans>
</p>
</div>
</div>
</div>
)}
{this.state.step === TransferToMobileSteps.CANCEL && (
<div className="profile main-content mobile-transfer-step-error">
<h3>
<Trans>The operation was cancelled.</Trans>
</h3>
<div className="feedback-card">
<AnimatedFeedback name="error" />
<div className="additional-information">
<p>
<Trans>
If there was an issue during the transfer, either the operation was cancelled on the mobile side,
or the authentication token expired.
</Trans>
<Trans>Please try again later or contact your administrator.</Trans>
</p>
</div>
</div>
</div>
)}
{this.state.step === TransferToMobileSteps.ERROR && (
<div className="profile main-content mobile-transfer-step-error">
<h3>
<Trans>Oops, something went wrong</Trans>
</h3>
<div className="feedback-card">
<AnimatedFeedback name="error" />
<div className="additional-information">
<p>
<Trans>
There was an issue during the transfer. Please try again later or contact your administrator.
</Trans>
</p>
</div>
</div>
<ShowErrorDetails error={this.state.error} />
</div>
)}
{this.state.step === TransferToMobileSteps.HTTPS_REQUIRED && (
<div className="profile main-content mobile-transfer-step-https-required">
<h3>
<Trans>Mobile Apps</Trans>
</h3>
<h4 className="no-border">
<Trans>Sorry the Mobile app setup feature is only available in a secure context (HTTPS).</Trans>
</h4>
<p>
<Trans>Please contact your administrator to fix this issue.</Trans>
</p>
</div>
)}
</div>
<div className="actions-wrapper">
{this.state.step === TransferToMobileSteps.START && (
<button
type="button"
className={`button primary form ${processingClassName}`}
role="button"
onClick={this.handleClickStart}
>
<Trans>Start</Trans>
</button>
)}
{this.state.step === TransferToMobileSteps.IN_PROGRESS && (
<button className={`button cancel ${processingClassName}`} type="button" onClick={this.handleClickCancel}>
<Trans>Cancel</Trans>
</button>
)}
{this.state.step === TransferToMobileSteps.COMPLETE && (
<button
className={`button primary form ${processingClassName}`}
type="button"
onClick={this.handleClickDone}
>
<Trans>Configure another phone</Trans>
</button>
)}
{(this.state.step === TransferToMobileSteps.CANCEL || this.state.step === TransferToMobileSteps.ERROR) && (
<button
className={`button primary form ${processingClassName}`}
type="button"
onClick={this.handleClickStart}
>
<Trans>Restart</Trans>
</button>
)}
</div>
{createSafePortal(
<div className="sidebar-help-section">
{[TransferToMobileSteps.START, TransferToMobileSteps.IN_PROGRESS, TransferToMobileSteps.COMPLETE].includes(
this.state.step,
) && (
<>
<h3>
<Trans>Get started in 5 easy steps</Trans>
</h3>
<p>
<Trans>1. Install the application from the store.</Trans>
</p>
<p>
<Trans>2. Open the application on your phone.</Trans>
</p>
<p>
{this.highlightIfCurrentStep(
<Trans>3. Click start in your browser.</Trans>,
TransferToMobileSteps.START,
)}
</p>
<p>
{this.highlightIfCurrentStep(
<Trans>4. Scan the QR codes with your phone.</Trans>,
TransferToMobileSteps.IN_PROGRESS,
)}
</p>
<p>
{this.highlightIfCurrentStep(<Trans>5. And you are done!</Trans>, TransferToMobileSteps.COMPLETE)}
</p>
<a className="button" href="https://passbolt.com/docs" target="_blank" rel="noopener noreferrer">
<FileTextSVG />
<span>
<Trans>Read the documentation</Trans>
</span>
</a>
</>
)}
{[TransferToMobileSteps.CANCEL, TransferToMobileSteps.ERROR, TransferToMobileSteps.HTTPS_REQUIRED].includes(
this.state.step,
) && (
<>
<h3>
<Trans>Need some help?</Trans>
</h3>
<p>
<Trans>Contact your administrator with details about what went wrong.</Trans>
</p>
<p>
<Trans>
Alternatively you can also get in touch with support on community forum or via the paid support
channels.
</Trans>
</p>
<a className="button" href="https://passbolt.com/docs" target="_blank" rel="noopener noreferrer">
<FileTextSVG />
<span>
<Trans>Help site</Trans>
</span>
</a>
</>
)}
</div>,
document.querySelector(".help-panel .sidebar-help"),
)}
</>
);
}
}
TransferToMobile.contextType = AppContext;
TransferToMobile.propTypes = {
context: PropTypes.object, // the app context
dialogContext: PropTypes.object, // The dialog context
userSettingsContext: PropTypes.object, // The user settings context
t: PropTypes.func, // The translation function
i18n: PropTypes.any, // The i18n context translation
};
export default withAppContext(withDialog(withUserSettings(withTranslation("common")(TransferToMobile))));