passbolt-styleguide
Version:
Passbolt styleguide contains common styling assets used by the different sites, plugin, etc.
753 lines (679 loc) • 26.2 kB
JavaScript
/**
* Passbolt ~ Open source password manager for teams
* Copyright (c) 2020 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) 2020 Passbolt SA (https://www.passbolt.com)
* @license https://opensource.org/licenses/AGPL-3.0 AGPL License
* @link https://www.passbolt.com Passbolt(tm)
* @since 2.13.0
*/
import * as React from "react";
import PropTypes from "prop-types";
import {withRouter} from "react-router-dom";
import {withAppContext} from "./AppContext";
import {withLoading} from "./LoadingContext";
import {withActionFeedback} from "./ActionFeedbackContext";
import EditUserGroup from "../components/UserGroup/EditUserGroup/EditUserGroup";
import {withDialog} from "./DialogContext";
import {DateTime} from "luxon";
import {withTranslation} from "react-i18next";
/**
* Context related to users ( filter, current selections, etc.)
*/
export const UserWorkspaceContext = React.createContext({
filter: {
type: null, // Filter type
payload: null // Filter payload
},
sorter: {
propertyName: 'user', // The name of the property to sort on
asc: false // True if the sort must be descendant
},
filteredUsers: [], // The current list of filtered users
selectedUsers: [], // The current list of selected users
details: {
user: null, // The user to focus details on
group: null, // The user group to focus details on
locked: true // The details display is locked
},
scrollTo: {
user: null // The user to scroll to
},
groupToEdit: null, // The group to edit
onUserScrolled: () => {}, // Whenever one scrolled to a user
onDetailsLocked: () => {}, // Lock or unlock detail (hide or display the group or user details)
onSorterChanged: () => {}, // Whenever the sorter changed
onUserSelected: {
single: () => {}// Whenever a single user has been selected
},
onGroupToEdit: () => {} // Whenever a group will be edited
});
/**
* The related context provider
*/
class UserWorkspaceContextProvider extends React.Component {
/**
* Default constructor
* @param props The component props
*/
constructor(props) {
super(props);
this.state = this.defaultState;
this.initializeProperties();
}
/**
* Returns the default component state
*/
get defaultState() {
return {
filter: {type: UserWorkspaceFilterTypes.NONE}, // The current user search filter
sorter: {
propertyName: 'modified', // The name of the property to sort on
asc: false // True if the sort must be descendant
},
filteredUsers: [], // The current list of filtered users
selectedUsers: [], // The current list of selected users
details: {
user: null, // The user to focus details on
group: null, // The group to focus details on
locked: true // The details display is locked
},
scrollTo: {
user: null // The user to scroll to
},
groupToEdit: null, // The group to edit
getTranslatedRoleName: this.getTranslatedRoleName.bind(this), // Tools to retrieve a user translated role name
onUserScrolled: this.handleUserScrolled.bind(this), // Whenever one scrolled to a user
onDetailsLocked: this.handleDetailsLocked.bind(this), // Lock or unlock detail (hide or display the group or user details)
onSorterChanged: this.handleSorterChange.bind(this), // Whenever the sorter changed
onUserSelected: {
single: this.handleUserSelected.bind(this)// Whenever a single user has been selected
},
onGroupToEdit: this.handleGroupToEdit.bind(this), // Whenever a group will be edited
isAttentionRequired: this.isAttentionRequired.bind(this), // Whenever a user needs attention
};
}
/**
* Initialize class properties out of the state ( for performance purpose )
*/
initializeProperties() {
this.users = null; // A cache of the last known list of users from the App context
this.groups = []; // A cache of the last known list of groups from the App context
this.routeLocationKey = null; // The current route location key being resolved, it will be used to avoid double execution of the route change handler.
}
/**
* Whenever the component is mounted
*/
componentDidMount() {
this.populate();
this.handleUsersWaitedFor();
}
/**
* Whenever the component has updated in terms of props or state
* @param prevProps
*/
async componentDidUpdate(prevProps, prevState) {
await this.handleFilterChange(prevState.filter);
this.handleUsersLoaded();
await this.handleUsersChange();
await this.handleGroupsChange();
await this.handleRouteChange(prevProps.location);
}
/**
* Handles the user search filter change
*/
async handleFilterChange(previousFilter) {
const hasFilterChanged = previousFilter !== this.state.filter;
if (hasFilterChanged) {
// Avoid a side-effect whenever one inputs a specific user url (it unselect the user otherwise )
const isNotNonePreviousFilter = previousFilter.type !== UserWorkspaceFilterTypes.NONE;
if (isNotNonePreviousFilter) {
this.populate();
await this.unselectAll();
}
}
}
/**
* Handle the users changes
*/
async handleUsersChange() {
const hasUsersChanged = this.props.context.users && this.props.context.users !== this.users;
if (hasUsersChanged) {
this.users = this.props.context.users;
await this.search(this.state.filter);
await this.updateDetails();
await this.unselectUnknownUsers();
}
}
/**
* Handle the groups change
*/
async handleGroupsChange() {
const hasGroupsChanged = this.props.context.groups && this.props.context.groups !== this.groups;
if (hasGroupsChanged) {
this.groups = this.props.context.groups;
await this.refreshSearchFilter();
await this.updateDetails();
}
}
/**
* Handle the route location change
* @param previousLocation Previous router location
*/
async handleRouteChange(previousLocation) {
const hasLocationChanged = this.props.location.key !== previousLocation.key; // Did the route change as per react-dom-router
/*
* Multiple componentDidUpdate can be triggered with the same route change.
* Avoid this by storing and checking the last route key change resolved by the component.
* @deprecated to removed with react-router-dom v6.1.0 @see https://github.com/supasate/connected-react-router/issues/129#issuecomment-446212160
*/
const hasLocationChangedRouter5 = this.props.location.key !== this.routeLocationKey;
this.routeLocationKey = this.props.location.key;
const isAppFirstLoad = this.state.filter.type === UserWorkspaceFilterTypes.NONE;
if ((hasLocationChanged && hasLocationChangedRouter5) || isAppFirstLoad) {
await this.handleGroupRouteChange();
await this.handleUserRouteChange();
}
}
/**
* Handle the group view route change
* E.g. /groups/view/:selectedGroupId
*/
async handleGroupRouteChange() {
const hasUsersAndGroups = this.users !== null && this.groups !== null;
if (hasUsersAndGroups) {
const groupId = this.props.match.params.selectedGroupId;
if (groupId && this.props.context.groups) {
const group = this.props.context.groups.find(group => group.id === groupId);
if (group) { // Known group
await this.search({type: UserWorkspaceFilterTypes.GROUP, payload: {group}});
await this.detailGroup(group);
// Case of edit path
const isEditRoute = this.props.location.pathname.includes('edit');
if (isEditRoute) {
await this.updateGroupToEdit(group);
this.props.dialogContext.open(EditUserGroup);
}
} else { // Unknown group
this.handleUnknownGroup();
}
}
}
}
/**
* Handle the user view route change
*/
async handleUserRouteChange() {
const isUserLocation = this.props.location.pathname.includes('users')
|| this.props.location.pathname.includes('account-recovery-requests/review');
if (isUserLocation) {
const userId = this.props.match.params.selectedUserId;
if (userId) { // Case of user view
await this.handleSingleUserRouteChange(userId);
} else { // Case of all and applied filters
await this.handleAllUserRouteChange();
}
}
}
/**
* Handle the user view route change with a user id
* E.g. /users/view/:userId
*/
async handleSingleUserRouteChange(userId) {
const hasUsers = this.users !== null;
if (hasUsers) {
const user = this.users.find(user => user.id === userId);
const hasNoneFilter = this.state.filter.type === UserWorkspaceFilterTypes.NONE;
if (hasNoneFilter) { // Case of user view by url bar inputting
await this.search({type: UserWorkspaceFilterTypes.ALL});
}
// If the user does not exist, it should display an error
if (user) {
await this.selectFromRoute(user);
await this.scrollTo(user);
await this.detailUser(user);
} else {
this.handleUnknownUser();
}
}
}
/**
* Handle the user view route change without a user id in the path
* E.g. /password
*/
async handleAllUserRouteChange() {
const hasUsers = this.users !== null;
if (hasUsers) {
const filter = this.props.location.state?.filter || {type: UserWorkspaceFilterTypes.ALL};
await this.search(filter);
await this.detailNothing();
}
}
/**
* Handle the lock detail display
*/
async handleDetailsLocked() {
await this.lockDetails();
}
/**
* Handle an unknown user ( passe by route parameter user identifier )
*/
handleUnknownUser() {
this.props.actionFeedbackContext.displayError("The user does not exist");
this.props.history.push({pathname: "/app/users"});
}
/**
* Handle an unknown user ( passe by route parameter user identifier )
*/
handleUnknownGroup() {
this.props.actionFeedbackContext.displayError("The group does not exist");
this.props.history.push({pathname: "/app/users"});
}
/**
* Handle the scrolling of a user
*/
async handleUserScrolled() {
await this.scrollNothing();
}
/**
* Handle the change of sorter ( on property or direction )
* @param propertyName The name of the property to sort on
*/
async handleSorterChange(propertyName) {
await this.updateSorter(propertyName);
await this.sort();
}
/**
* Handle the single user selection
* @param user The selected user
*/
async handleUserSelected(user) {
await this.select(user);
this.redirectAfterSelection();
}
/**
* Handle the will to edit a group
* @param group
*/
async handleGroupToEdit(group) {
await this.updateGroupToEdit(group);
}
/**
* Handle the wait for the initial user to be loaded
*/
handleUsersWaitedFor() {
this.props.loadingContext.add();
}
/**
* Handle the intial loading of the users
*/
handleUsersLoaded() {
const hasUsersBeenInitialized = this.users === null && this.props.context.users;
if (hasUsersBeenInitialized) {
this.props.loadingContext.remove();
this.handleUsersLoaded = () => {};
}
}
/**
* Returns true if the given user requires attention from an admin.
* @param {User} user
* @returns {Boolean}
*/
isAttentionRequired(user) {
return Boolean(user.pending_account_recovery_request);
}
/**
* Populate the context with initial data such as users and groups
*/
populate() {
this.props.context.port.request("passbolt.users.update-local-storage");
this.props.context.port.request("passbolt.groups.update-local-storage");
}
/** USER SEARCH **/
/**
* Search for the users which matches the given filter and sort them
* @param filter
*/
async search(filter) {
const isRecentlyModifiedFilter = filter.type === UserWorkspaceFilterTypes.RECENTLY_MODIFIED;
const searchOperations = {
[UserWorkspaceFilterTypes.GROUP]: this.searchByGroup.bind(this),
[UserWorkspaceFilterTypes.TEXT]: this.searchByText.bind(this),
[UserWorkspaceFilterTypes.RECENTLY_MODIFIED]: this.searchByRecentlyModified.bind(this),
[UserWorkspaceFilterTypes.ALL]: this.searchAll.bind(this),
[UserWorkspaceFilterTypes.NONE]: () => { /* No search */ }
};
await searchOperations[filter.type](filter);
if (!isRecentlyModifiedFilter) {
await this.sort();
} else {
await this.resetSorter();
}
}
/**
* All filter ( no filter at all )
* @param filter The All filter
*/
async searchAll(filter) {
await this.setState({filter, filteredUsers: this.users});
}
/**
* Filter the users which belongs to the given group
* @param filter The group filter
*/
async searchByGroup(filter) {
const group = filter.payload.group;
const usersGroupIds = group.groups_users.map(groupUser => groupUser.user_id);
const filteredUsers = this.users.filter(user => usersGroupIds.some(userId => userId === user.id));
await this.setState({filter, filteredUsers});
}
/**
* Filter the users which textual properties matched some user text words
* @param filter A textual filter
*/
async searchByText(filter) {
const text = filter.payload;
const words = (text && text.split(/\s+/)) || [''];
// Test match of some escaped test words against the name / username
const escapeWord = word => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const wordToRegex = word => new RegExp(escapeWord(word), 'i');
const matchWord = (word, value) => wordToRegex(word).test(value);
const matchUsernameProperty = (word, user) => matchWord(word, user.username);
const matchNameProperty = (word, user) => matchWord(word, user.profile.first_name) || matchWord(word, user.profile.last_name);
const matchUser = (word, user) => matchUsernameProperty(word, user) || matchNameProperty(word, user);
const matchText = user => words.every(word => matchUser(word, user));
const filteredUsers = this.users.filter(matchText);
await this.setState({filter, filteredUsers});
}
/**
* Keep the most recently modified users ( current state: just sort everything with the most recent modified users )
* @param filter A recently modified filter
*/
async searchByRecentlyModified(filter) {
const recentlyModifiedSorter = (user1, user2) => DateTime.fromISO(user2.modified) < DateTime.fromISO(user1.modified) ? -1 : 1;
const filteredUsers = this.users.sort(recentlyModifiedSorter);
await this.setState({filter, filteredUsers});
}
/**
* Refresh the filter in case of its payload is outdated due to the updated list of users
*/
async refreshSearchFilter() {
const hasGroupFilter = this.state.filter.type === UserWorkspaceFilterTypes.GROUP;
if (hasGroupFilter) {
const isGroupStillExist = this.groups.some(group => group.id === this.state.filter.payload.group.id);
if (isGroupStillExist) { // Case of group exists but may have somme applied changes on it
const updatedGroup = this.groups.find(group => group.id === this.state.filter.payload.group.id);
const filter = Object.assign(this.state.filter, {payload: {group: updatedGroup}});
await this.search(filter);
} else { // Case of filter group deleted
const filter = {type: UserWorkspaceFilterTypes.ALL};
this.props.history.push({pathname: '/app/users', state: {filter}});
}
}
}
/** USER SELECTION */
/**
* Select the given user as the single selected users if not already selected as single. Otherwise unselect it
* @param user The user to select
*/
async select(user) {
const mustUnselect = this.state.selectedUsers.length === 1 && this.state.selectedUsers[0].id === user.id;
await this.setState({selectedUsers: mustUnselect ? [] : [user]});
}
/**
* Selects the given user when one comes from the navigation route
* @param user An user
*/
async selectFromRoute(user) {
const selectedUsers = [user];
await this.setState({selectedUsers});
}
/**
* Unselect all the users
*/
async unselectAll() {
const hasSelectedUsers = this.state.selectedUsers.length !== 0;
if (hasSelectedUsers) {
await this.setState({selectedUsers: []});
}
}
/**
* Remove from the selected users those which are not known users in regard of the current users list
*/
async unselectUnknownUsers() {
const matchId = selectedUser => user => user.id === selectedUser.id;
const matchSelectedUser = selectedUser => this.users.some(matchId(selectedUser));
const selectedUsers = this.state.selectedUsers.filter(matchSelectedUser);
await this.setState({selectedUsers});
}
/**
* Navigate to the appropriate url after some users selection operation
*/
redirectAfterSelection() {
const hasUsersAndGroups = this.users !== null && this.groups !== null;
if (hasUsersAndGroups) {
const hasUserSelected = this.state.selectedUsers.length === 1;
if (hasUserSelected) { // Case of selected user
this.props.history.push(`/app/users/view/${this.state.selectedUsers[0].id}`);
} else {
const {filter} = this.state;
const isGroupFilter = filter.type === UserWorkspaceFilterTypes.GROUP;
if (isGroupFilter) {
const mustRedirect = this.props.location.pathname !== `/app/groups/view/${this.state.filter.payload.group.id}`;
if (mustRedirect) {
this.props.history.push({pathname: `/app/groups/view/${this.state.filter.payload.group.id}`});
}
} else {
const mustRedirect = this.props.location.pathname !== '/app/users';
if (mustRedirect) {
this.props.history.push({pathname: `/app/users`, state: {filter}});
}
}
}
}
}
/** USER SORTER **/
/**
* Update the users sorter given a property name
* @param propertyName
*/
async updateSorter(propertyName) {
const hasSortPropertyChanged = this.state.sorter.propertyName !== propertyName;
const asc = hasSortPropertyChanged || !this.state.sorter.asc;
const sorter = {propertyName, asc};
await this.setState({sorter});
}
/**
* Reset the user sorter
*/
async resetSorter() {
const sorter = {propertyName: 'modified', asc: false};
this.setState({sorter});
}
/**
* Sort the users given the current sorter
*/
async sort() {
const reverseSorter = sorter => (s1, s2) => -sorter(s1, s2);
const baseSorter = sorter => this.state.sorter.asc ? sorter : reverseSorter(sorter);
const keySorter = (key, sorter) => baseSorter((s1, s2) => sorter(s1[key], s2[key]));
const plainObjectSorter = sorter => baseSorter(sorter);
const dateSorter = (d1, d2) => !d1 ? -1 : (!d2 ? 1 : DateTime.fromISO(d1) < DateTime.fromISO(d2) ? -1 : 1);
const stringSorter = (s1, s2) => (s1 || "").localeCompare(s2 || "");
const mfaSorter = (u1, u2) => (u2.is_mfa_enabled === u1.is_mfa_enabled) ? 0 : u2.is_mfa_enabled ? -1 : 1;
const accountRecoveryUserSettingStatusSorter = (u1, u2) => (u2?.account_recovery_user_setting?.status === u1?.account_recovery_user_setting?.status) ? 0 : u2?.account_recovery_user_setting?.status ? -1 : 1;
const getUserFullName = user => `${user.profile.first_name} ${user.profile.last_name}`;
const nameSorter = (u1, u2) => getUserFullName(u1).localeCompare(getUserFullName(u2));
const roleNameSorter = (roleIdU1, roleIdU2) => this.getTranslatedRoleName(roleIdU1).localeCompare(this.getTranslatedRoleName(roleIdU2));
const dateOrStringSorter = ['modified', 'last_logged_in'].includes(this.state.sorter.propertyName) ? dateSorter : stringSorter;
const attentionRequireSorter = (u1, u2) => (this.isAttentionRequired(u2) === this.isAttentionRequired(u1)) ? 0 : this.isAttentionRequired(u2) ? 1 : -1;
const isNameProperty = this.state.sorter.propertyName === 'name';
const isMfaProperty = this.state.sorter.propertyName === 'is_mfa_enabled';
const isRoleNameProperty = this.state.sorter.propertyName === 'role.name';
const isAttentionRequiredProperty = this.state.sorter.propertyName === 'attentionRequired';
const isAccountRecoveryUserSettingStatusProperty = this.state.sorter.propertyName === 'account_recovery_user_setting.status';
let propertySorter;
if (isNameProperty) {
propertySorter = plainObjectSorter(nameSorter);
} else if (isMfaProperty) {
propertySorter = plainObjectSorter(mfaSorter);
} else if (isAttentionRequiredProperty) {
propertySorter = plainObjectSorter(attentionRequireSorter);
} else if (isRoleNameProperty) {
propertySorter = keySorter("role_id", roleNameSorter);
} else if (isAccountRecoveryUserSettingStatusProperty) {
propertySorter = plainObjectSorter(accountRecoveryUserSettingStatusSorter);
} else {
propertySorter = keySorter(this.state.sorter.propertyName, dateOrStringSorter);
}
await this.setState({filteredUsers: this.state.filteredUsers.sort(propertySorter)});
}
/** USER DETAILS **/
/**
* Set the details focus on the given group
* @param group The group to focus on
*/
async detailGroup(group) {
const locked = this.state.details.locked;
await this.setState({details: {group, user: null, locked}});
}
/**
* Set the details focus on the given user
* @param user The user to focus on
*/
async detailUser(user) {
const locked = this.state.details.locked;
await this.setState({details: {group: null, user, locked}});
}
/**
* Remove the details on something
*/
async detailNothing() {
const hasDetails = this.state.details.user || this.state.details.group;
if (hasDetails) {
const locked = this.state.details.locked;
await this.setState({details: {group: null, user: null, locked}});
}
}
/**
* Lock the group or user details display ( hide or show )
* @returns {Promise<void>}
*/
async lockDetails() {
const details = this.state.details;
const locked = this.state.details.locked;
await this.setState({details: Object.assign({}, details, {locked: !locked})});
}
/**
* Update the current details with the current list of users or groups
*/
async updateDetails() {
const hasDetails = this.state.details.user || this.state.details.group;
if (hasDetails) {
const hasUserDetails = this.state.details.user;
const locked = this.state.details.locked;
if (hasUserDetails) { // Case of user details
const updatedUserDetails = this.users.find(user => user.id === this.state.details.user.id);
await this.setState({details: {user: updatedUserDetails, group: null, locked}});
} else { // Case of group details
const updatedGroupDetails = this.groups.find(group => group.id === this.state.details.group.id);
await this.setState({details: {group: updatedGroupDetails, user: null, locked}});
}
}
}
/** USER SCROLLING **/
/**
* Set the user to scroll to
* @param user A user
*/
async scrollTo(user) {
await this.setState({scrollTo: {user}});
}
/**
* Unset the user to scroll to
*/
async scrollNothing() {
await this.setState({scrollTo: {}});
}
/** GROUP EDIT **/
/**
* Updates the group to edit
* @param groupToEdit The group to edit
*/
async updateGroupToEdit(groupToEdit) {
await this.setState({groupToEdit});
}
/** Common roles getters **/
/**
* Get the translated role name by role id
* @param {string} id The role id
* @return {string}
*/
getTranslatedRoleName(id) {
if (this.props.context.roles) {
/*
* The i18n parser can't find the translation for passwordStrength.label
* To fix that we can use it in comment
* this.translate("admin")
* this.translate("user")
*/
return this.props.t(this.props.context.roles.find(role => role.id === id).name);
}
return "";
}
/**
* Render the component
* @returns {JSX}
*/
render() {
return (
<UserWorkspaceContext.Provider value={this.state}>
{this.props.children}
</UserWorkspaceContext.Provider>
);
}
}
UserWorkspaceContextProvider.displayName = 'UserWorkspaceContextProvider';
UserWorkspaceContextProvider.propTypes = {
context: PropTypes.any, // The application context
children: PropTypes.any, // The component children
location: PropTypes.object, // The router location
match: PropTypes.object, // The router match helper
history: PropTypes.object, // The router history
actionFeedbackContext: PropTypes.object, // The action feedback context
loadingContext: PropTypes.object, // The loading context
dialogContext: PropTypes.any, // The dialog context
t: PropTypes.func, // The translation function
};
export default withAppContext(withRouter(withDialog(withActionFeedback(withLoading(withTranslation('common')(UserWorkspaceContextProvider))))));
/**
* User Workspace Context Consumer HOC
* @param WrappedComponent
*/
export function withUserWorkspace(WrappedComponent) {
return class WithUserWorkspace extends React.Component {
render() {
return (
<UserWorkspaceContext.Consumer>
{
UserWorkspaceContext => <WrappedComponent userWorkspaceContext={UserWorkspaceContext} {...this.props} />
}
</UserWorkspaceContext.Consumer>
);
}
};
}
/**
* The list of user workspace search filter types
*/
export const UserWorkspaceFilterTypes = {
NONE: 'NONE', // Initial filter at page load
ALL: 'ALL', // All users
GROUP: 'FILTER-BY-GROUP', // Users for a given group
TEXT: 'FILTER-BY-TEXT-SEARCH', // Users matching some text words
RECENTLY_MODIFIED: 'FILTER-BY-RECENTLY-MODIFIED', // Keep recently modified users
};