UNPKG

passbolt-styleguide

Version:

Passbolt styleguide contains common styling assets used by the different sites, plugin, etc.

748 lines (677 loc) 26.8 kB
/** * Passbolt ~ Open source password manager for teams * Copyright (c) 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) 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.2.0 */ import React from "react"; import { Link, withRouter } from "react-router-dom"; import PropTypes from "prop-types"; import { Trans, withTranslation } from "react-i18next"; import { SecretGenerator } from "../../../shared/lib/SecretGenerator/SecretGenerator"; import { withPrepareResourceContext } from "../../contexts/PrepareResourceContext"; import SpinnerSVG from "../../../img/svg/spinner.svg"; import Password from "../../../shared/components/Password/Password"; import PasswordComplexity from "../../../shared/components/PasswordComplexity/PasswordComplexity"; import PownedService from "../../../shared/services/api/secrets/pownedService"; import { withAppContext } from "../../../shared/context/AppContext/AppContext"; import { withPasswordPolicies } from "../../../shared/context/PasswordPoliciesContext/PasswordPoliciesContext"; import { withPasswordExpiry } from "../../../react-extension/contexts/PasswordExpirySettingsContext"; import { ConfirmCreatePageRuleVariations } from "../ConfirmCreatePage/ConfirmCreatePage"; import { ENTROPY_THRESHOLDS } from "../../../shared/lib/SecretGenerator/SecretGeneratorComplexity"; import EntityValidationError from "../../../shared/models/entity/abstract/entityValidationError"; import ResourcePasswordDescriptionViewModel from "../../../shared/models/resource/ResourcePasswordDescriptionViewModel"; import ResourceViewModel from "../../../shared/models/resource/ResourceViewModel"; import { DateTime } from "luxon"; import { withResourceTypesLocalStorage } from "../../../shared/context/ResourceTypesLocalStorageContext/ResourceTypesLocalStorageContext"; import ResourceTypesCollection from "../../../shared/models/entity/resourceType/resourceTypesCollection"; import { withMetadataTypesSettingsLocalStorage } from "../../../shared/context/MetadataTypesSettingsLocalStorageContext/MetadataTypesSettingsLocalStorageContext"; import MetadataTypesSettingsEntity from "../../../shared/models/entity/metadata/metadataTypesSettingsEntity"; import { RESOURCE_TYPE_PASSWORD_AND_DESCRIPTION_SLUG, RESOURCE_TYPE_V5_DEFAULT_SLUG, } from "../../../shared/models/entity/resourceType/resourceTypeSchemasDefinition"; import ResourceViewModelFactory from "../../../shared/models/resource/ResourceViewModelFactory"; import CaretLeftSVG from "../../../img/svg/caret_left.svg"; import CloseSVG from "../../../img/svg/close.svg"; import DiceSVG from "../../../img/svg/dice.svg"; import SettingsSVG from "../../../img/svg/settings.svg"; class ResourceCreatePage extends React.Component { /** * @constructor * @param {object} props */ constructor(props) { super(props); this.initEventHandlers(); this.state = this.defaultState; this.createInputRef(); this.isPwndProcessingPromise = null; } /** * Get the default state * @returns {object} */ get defaultState() { return { resourceViewModel: new ResourcePasswordDescriptionViewModel(), errors: new EntityValidationError(), //the validation errors set unexpectedErrorMessage: "", hasAlreadyBeenValidated: false, // True if the form has already been submitted once isPasswordDictionaryCheckRequested: true, // Is the password check against a dictionary request. isPasswordDictionaryCheckServiceAvailable: true, // Is the password dictionary check service available. passwordInDictionary: this.props.location.state?.passwordInDictionary || false, passwordEntropy: null, generatorSettings: null, processing: false, }; } /** * initialize event handlers * @returns {void} */ initEventHandlers() { this.handleGoBackClick = this.handleGoBackClick.bind(this); this.handleFormSubmit = this.handleFormSubmit.bind(this); this.handleInputChange = this.handleInputChange.bind(this); this.handleGeneratePasswordButtonClick = this.handleGeneratePasswordButtonClick.bind(this); this.handleOpenGenerator = this.handleOpenGenerator.bind(this); this.handleCancelButtonClick = this.handleCancelButtonClick.bind(this); this.save = this.save.bind(this); } /** * when the component is mounted * @returns {Promise<void>} */ async componentDidMount() { this.props.passwordExpiryContext.findSettings(); const policies = await this.props.passwordPoliciesContext.loadPolicies(); this.initPwnedPasswordService(policies); await this.initResourceViewModel(); } async initResourceViewModel() { const resourceViewModelDto = await this.getPreparedResource(); let resourceType; if (this.props.metadataTypeSettings.isDefaultResourceTypeV5) { resourceType = this.props.resourceTypes.getFirstBySlug(RESOURCE_TYPE_V5_DEFAULT_SLUG); } else if (this.props.metadataTypeSettings.isDefaultResourceTypeV4) { resourceType = this.props.resourceTypes.getFirstBySlug(RESOURCE_TYPE_PASSWORD_AND_DESCRIPTION_SLUG); } resourceViewModelDto.resource_type_id = resourceType.id; const expired = this.getResourceExpirationDate(); resourceViewModelDto.expired = expired; const resourceViewModel = ResourceViewModelFactory.createFromResourceTypeAndResourceViewModelDto( resourceType, resourceViewModelDto, ); const passwordEntropy = resourceViewModel.password ? SecretGenerator.entropy(resourceViewModel.password) : null; await this.focusFirstEmptyField(resourceViewModel); this.setState({ resourceViewModel, passwordEntropy }); } /** * Get the expiration date on the given resource according to the password expiry settings. * The value is set to `undefined` if the feature is not activated, * otherwise it is set to `null` if the expiration date must be unset * or else a `DateTime` at when the expiration should occur. * @returns {DateTime|null|undefined} */ getResourceExpirationDate() { if (!this.props.passwordExpiryContext.isFeatureEnabled()) { return undefined; } const passwordExpirySettings = this.props.passwordExpiryContext.getSettings(); if (!passwordExpirySettings?.automatic_update) { return undefined; } if (passwordExpirySettings.default_expiry_period == null) { return null; } return DateTime.utc().plus({ days: passwordExpirySettings.default_expiry_period }).toISO(); } /** * Initialize the pwned password service */ initPwnedPasswordService(policies) { const isPasswordDictionaryCheckRequested = policies?.external_dictionary_check; if (isPasswordDictionaryCheckRequested) { this.pownedService = new PownedService(this.props.context.port); } this.setState({ isPasswordDictionaryCheckRequested }); } /** * Create DOM nodes or React elements references. */ createInputRef() { this.nameInputRef = React.createRef(); this.uriInputRef = React.createRef(); this.usernameInputRef = React.createRef(); this.passwordInputRef = React.createRef(); } /* * ============================================================= * Resource password generator * ============================================================= */ /** * Consumes a prepared resource if any and initialise the form with it. * If no resources were preparen the form is initialised with the tab info. * @returns {Promise<object>} */ async getPreparedResource() { const preparedResource = this.props.prepareResourceContext.consumePreparedResource(); const lastGeneratedPassword = this.props.prepareResourceContext.lastGeneratedPassword; if (preparedResource) { preparedResource.password = lastGeneratedPassword ?? preparedResource.password; return preparedResource; } return await this.getPasswordMetaFromTabInfo(); } /** * Retrieve the password meta information of the current tab * @returns {Promise<{name: string, uri: string, username: string, password: string}>} */ async getPasswordMetaFromTabInfo() { let name = ""; let uri = ""; let username = ""; let password = ""; const ignoreNames = ["newtab"]; const ignoreUris = ["chrome://newtab/", "about:newtab"]; try { const tabInfo = await this.props.context.port.request( "passbolt.quickaccess.prepare-resource", this.props.context.getOpenerTabId(), ); if (!ignoreNames.includes(tabInfo["name"])) { name = tabInfo["name"].substring(0, 255); } if (!ignoreUris.includes(tabInfo["uris"]) && tabInfo["uris"].length > 0) { uri = tabInfo["uris"][0]; } username = tabInfo.username?.length > 0 ? tabInfo.username : this.props.context.userSettings.username; password = tabInfo.secret_clear?.length > 0 ? tabInfo.secret_clear : this.generateSecret(); } catch (error) { console.error(error); } return { name, uri, username, password }; } /** * Focuses on the first empty field in the form after the animation has finished. * @param {ResourceViewModel} resourceViewModel * @returns {Promise<void>} */ focusFirstEmptyField(resourceViewModel) { return new Promise((resolve) => { /* * Wait 210ms, the time for the animation to be completed. * If we don't wait the animation to be completed, then the focus will screw the animation. Some browsers need * elements to be visible to give them focus, therefore the browser makes it visible while the animation is * running, making the element blinking. */ setTimeout(() => { if (!resourceViewModel.name) { this.nameInputRef.current.focus(); } else if (!resourceViewModel.uri) { this.uriInputRef.current.focus(); } else if (!resourceViewModel.username) { this.usernameInputRef.current.focus(); } else if (!resourceViewModel.password) { this.passwordInputRef.current.focus(); } resolve(); }, 210); }); } /** * Handles click on the `go back` button * @param {React.Event} ev */ handleGoBackClick(ev) { ev.preventDefault(); this.props.prepareResourceContext.resetSecretGeneratorSettings(); this.props.history.goBack(); } /** * Handles the click on the "x" button */ handleCancelButtonClick() { this.props.prepareResourceContext.resetSecretGeneratorSettings(); } /** * Validate the form data and returns true if it's valid * @returns {EntityValidationError} */ validate() { const errors = this.state.resourceViewModel.validate(ResourceViewModel.CREATE_MODE); this.setState({ errors }); return errors; } /* * ============================================================= * Form submit * ============================================================= */ /** * Handles the form submission * @param {React.Event} event * @returns {Promise<void>} */ async handleFormSubmit(event) { event.preventDefault(); if (this.state.processing) { return; } this.setState({ processing: true, hasAlreadyBeenValidated: true, }); const validationErrors = this.validate(); if (validationErrors.hasErrors()) { this.setState({ processing: false }); this.focusFirstFieldError(validationErrors); return; } if (!this.isMinimumRequiredEntropyReached()) { this.handleComfirmPasswordCreation(ConfirmCreatePageRuleVariations.MINIMUM_ENTROPY); return; } if (await this.isPasswordInDictionary()) { this.handleComfirmPasswordCreation(ConfirmCreatePageRuleVariations.IN_DICTIONARY); return; } await this.save(); } /** * Focus the first field of the form which is in error state. * @param {EntityValidationError} validationErrors */ focusFirstFieldError(validationErrors) { if (validationErrors.hasError("name")) { this.nameInputRef.current.focus(); } else if (validationErrors.hasError("uri")) { this.uriInputRef.current.focus(); } else if (validationErrors.hasError("password")) { this.passwordInputRef.current.focus(); } } /** * Handle the request to confirm password creation when it is weak * @param {string} createPageRuleVariation */ handleComfirmPasswordCreation(createPageRuleVariation) { this.persistResourceInPreparedStorage(); const pageProps = { resourceName: this.state.resourceViewModel.name, rule: createPageRuleVariation, }; this.props.history.push("/webAccessibleResources/quickaccess/resources/confirm-create", pageProps); } /** * Save the resource * @returns {Promise<void>} */ async save() { const resourceDto = this.state.resourceViewModel.toResourceDto(); const secretDto = this.state.resourceViewModel.toSecretDto(); let resource; try { resource = await this.props.context.port.request("passbolt.resources.create", resourceDto, secretDto); } catch (error) { this.handleSubmitError(error); return; } /* * Remove the create step from the history. * The user needs to be redirected to the home page and not the create page while clicking on go back * password details page. */ const goToComponentState = { goBackEntriesCount: -2, }; this.props.prepareResourceContext.resetSecretGeneratorSettings(); this.props.history.push(`/webAccessibleResources/quickaccess/resources/view/${resource.id}`, goToComponentState); } /** * Handles error during form submission * @param {Error} error */ handleSubmitError(error) { if (error.name === "UserAbortsOperationError") { this.setState({ processing: false }); return; } const isBadRequestError = error.name === "PassboltApiFetchError" && error.data.code === 400 && (error.data.body?.name || error.data.body?.username || error.data.body?.uri); if (!isBadRequestError) { this.setState({ unexpectedErrorMessage: error.message, processing: false, }); return; } const apiErrors = this.formatApiErrors(error.data.body); this.setState({ errors: apiErrors }); } /** * Check if the password is part of a dictionary. * @return {Promise<boolean>} */ async isPasswordInDictionary() { if (!this.state.isPasswordDictionaryCheckRequested || !this.state.isPasswordDictionaryCheckServiceAvailable) { return false; } const { isPwnedServiceAvailable, inDictionary } = await this.pownedService.evaluateSecret( this.state.resourceViewModel.password, ); if (!isPwnedServiceAvailable) { this.setState({ isPasswordDictionaryCheckServiceAvailable: false }); return false; } return inDictionary; } /** * Returns true if the strict minimum entropy is reached (entropy is higher than very weak entropy) * @param {number} passphraseEntropy * @returns {boolean} */ isMinimumRequiredEntropyReached() { return this.state.passwordEntropy && this.state.passwordEntropy >= ENTROPY_THRESHOLDS.WEAK; } /** * Format the given BadRequest error invalid field information. * @param {object} errorBody * @returns {EntityValidationError} */ formatApiErrors(errorBody) { const errors = new EntityValidationError(); const fieldsInError = Object.keys(errorBody); for (let i = 0; i < fieldsInError.length; i++) { const prop = fieldsInError[i]; const errorMessages = errorBody[prop].join(", "); errors.addError(prop, "api-validation", errorMessages); } return errors; } /** * Handles form input changed * @param {React.Event} event */ handleInputChange(event) { const { name, value } = event.target; const newState = { resourceViewModel: this.state.resourceViewModel.cloneWithMutation(name, value), }; if (name === "password") { newState.passwordInDictionary = false; newState.passwordEntropy = value?.length ? SecretGenerator.entropy(value) : null; } if (this.state.hasAlreadyBeenValidated) { newState.errors = newState.resourceViewModel.validate(ResourceViewModel.CREATE_MODE); } this.setState(newState); } /** * Handles click on "regenerate" a password */ handleGeneratePasswordButtonClick() { if (this.state.processing) { return; } const password = this.generateSecret(); const resourceViewModel = this.state.resourceViewModel.cloneWithMutation("password", password); const passwordEntropy = SecretGenerator.entropy(password); this.setState({ resourceViewModel, passwordEntropy, passwordInDictionary: false }); } /** * Generates a new secret based on the current configuration * @returns {string} */ generateSecret() { const configuration = this.props.prepareResourceContext.settings; return SecretGenerator.generate(configuration); } /** * Whenever the user wants to go to the password generator */ handleOpenGenerator() { if (this.state.processing) { return; } this.persistResourceInPreparedStorage(); this.props.history.push("/webAccessibleResources/quickaccess/resources/generate-password"); } /** * Persist the current resource state in the prepared storage. * It is used when the user is opening the generator or has to confirm the resource creation. */ persistResourceInPreparedStorage() { const resource = { name: this.state.resourceViewModel.name, username: this.state.resourceViewModel.username, uri: this.state.resourceViewModel.uri, password: this.state.resourceViewModel.password, }; this.props.prepareResourceContext.onPrepareResource(resource); } /** * Returns true if the logged in user can use the password generator capability. * @returns {boolean} */ get canUsePasswordGenerator() { return this.props.context.siteSettings.canIUse("passwordGenerator"); } /** * Get the translate function * @returns {function(...[*]=)} */ get translate() { return this.props.t; } /** * Render the component * @returns {JSX} */ render() { const passwordEntropy = this.state.passwordInDictionary ? 0 : this.state.passwordEntropy; return ( <div className="resource-create"> <div className="back-link"> <a href="#" className="primary-action" onClick={this.handleGoBackClick} title={this.translate("Cancel the operation")} > <CaretLeftSVG /> <span className="primary-action-title"> <Trans>Create password</Trans> </span> </a> <Link to="/webAccessibleResources/quickaccess/home" onClick={this.handleCancelButtonClick} className="secondary-action button-transparent button" title={this.translate("Cancel")} > <CloseSVG /> <span className="visually-hidden"> <Trans>Cancel</Trans> </span> </Link> </div> <form onSubmit={this.handleFormSubmit}> <div className="resource-create-form"> <div className="form-container"> <div className={`input text required ${this.state.errors.hasError("name") ? "error" : ""}`}> <label htmlFor="name"> <Trans>Name</Trans> </label> <input name="name" value={this.state.resourceViewModel.name || ""} onChange={this.handleInputChange} disabled={this.state.processing} ref={this.nameInputRef} className="required fluid" maxLength="255" type="text" id="name" autoComplete="off" /> {this.state.errors.hasError("name", "required") && ( <div className="error-message"> <Trans>A name is required.</Trans> </div> )} {this.state.errors.hasError("name", "api-validation") && ( <div className="error-message">{this.state.errors.getError("name", "api-validation")}</div> )} </div> <div className={`input text ${this.state.errors.hasError("uri") ? "error" : ""}`}> <label htmlFor="uri"> <Trans>URL</Trans> </label> <input name="uri" value={this.state.resourceViewModel.uri || ""} onChange={this.handleInputChange} disabled={this.state.processing} ref={this.uriInputRef} className="fluid" maxLength="1024" type="text" id="uri" autoComplete="off" /> {this.state.errors.hasError("uri", "maxLength") && ( <div className="error-message"> <Trans>The URI cannot exceed 1024 characters.</Trans> </div> )} {this.state.errors.hasError("uri", "api-validation") && ( <div className="error-message">{this.state.errors.getError("uri", "api-validation")}</div> )} </div> <div className={`input text ${this.state.errors.hasError("username") ? "error" : ""}`}> <label htmlFor="username"> <Trans>Username</Trans> </label> <input name="username" value={this.state.resourceViewModel.username || ""} onChange={this.handleInputChange} disabled={this.state.processing} ref={this.usernameInputRef} className="fluid" maxLength="255" type="text" id="username" autoComplete="off" /> {this.state.errors.hasError("username", "api-validation") && ( <div className="error-message">{this.state.errors.getError("username", "api-validation")}</div> )} </div> <div className={`input-password-wrapper input required ${this.state.errors.hasError("password") ? "error" : ""}`} > <label htmlFor="password"> <Trans>Password</Trans> </label> <div className="password-button-inline"> <Password name="password" value={this.state.resourceViewModel.password || ""} preview={true} onChange={this.handleInputChange} disabled={this.state.processing} autoComplete="new-password" placeholder={this.translate("Password")} id="password" inputRef={this.passwordInputRef} /> <button type="button" onClick={this.handleGeneratePasswordButtonClick} className={`password-generate button-icon button ${this.state.processing ? "disabled" : ""}`} > <DiceSVG /> <span className="visually-hidden"> <Trans>Generate</Trans> </span> </button> {this.canUsePasswordGenerator && ( <button type="button" onClick={this.handleOpenGenerator} className="password-generator button-icon button" > <SettingsSVG /> <span className="visually-hidden"> <Trans>Open generator</Trans> </span> </button> )} </div> <PasswordComplexity entropy={passwordEntropy} error={this.state.errors.hasError("password")} /> {this.state.errors.hasError("password", "required") && ( <div className="error-message"> <Trans>A password is required.</Trans> </div> )} {this.state.errors.hasError("password", "api-validation") && ( <div className="error-message">{this.state.errors.getError("password", "api-validation")}</div> )} </div> </div> </div> <div className="submit-wrapper input"> <button type="submit" className={`button primary big full-width ${this.state.processing ? "processing" : ""}`} role="button" disabled={this.state.processing} > <Trans>Save</Trans> {this.state.processing && <SpinnerSVG />} </button> {this.state.unexpectedErrorMessage && ( <div className="error-message">{this.state.unexpectedErrorMessage}</div> )} </div> </form> </div> ); } } ResourceCreatePage.propTypes = { context: PropTypes.any, // The application context resourceTypes: PropTypes.instanceOf(ResourceTypesCollection), // The resource types collection metadataTypeSettings: PropTypes.instanceOf(MetadataTypesSettingsEntity), // The metadata type settings prepareResourceContext: PropTypes.any, // The password generator context history: PropTypes.object, location: PropTypes.any, t: PropTypes.func, // The translation function passwordPoliciesContext: PropTypes.object, // The password policy context passwordExpiryContext: PropTypes.object, // The password expiry context }; export default withAppContext( withRouter( withResourceTypesLocalStorage( withMetadataTypesSettingsLocalStorage( withPrepareResourceContext( withPasswordExpiry(withPasswordPolicies(withTranslation("common")(ResourceCreatePage))), ), ), ), ), );