Add users management to site settings (#126)

This commit is contained in:
Riccardo Graziosi
2022-06-24 14:39:35 +02:00
committed by GitHub
parent bc15140512
commit 37fb99a868
71 changed files with 1093 additions and 1409 deletions

View File

@@ -0,0 +1,60 @@
import { Action } from 'redux';
import { ThunkAction } from 'redux-thunk';
import IUserJSON from '../../interfaces/json/IUser';
import { State } from '../../reducers/rootReducer';
export const USERS_REQUEST_START = 'USERS_REQUEST_START';
interface UsersRequestStartAction {
type: typeof USERS_REQUEST_START;
}
export const USERS_REQUEST_SUCCESS = 'USERS_REQUEST_SUCCESS';
interface UsersRequestSuccessAction {
type: typeof USERS_REQUEST_SUCCESS;
users: Array<IUserJSON>;
}
export const USERS_REQUEST_FAILURE = 'USERS_REQUEST_FAILURE';
interface UsersRequestFailureAction {
type: typeof USERS_REQUEST_FAILURE;
error: string;
}
export type UsersRequestActionTypes =
UsersRequestStartAction |
UsersRequestSuccessAction |
UsersRequestFailureAction;
const usersRequestStart = (): UsersRequestActionTypes => ({
type: USERS_REQUEST_START,
});
const usersRequestSuccess = (
users: Array<IUserJSON>
): UsersRequestActionTypes => ({
type: USERS_REQUEST_SUCCESS,
users,
});
const usersRequestFailure = (error: string): UsersRequestActionTypes => ({
type: USERS_REQUEST_FAILURE,
error,
});
export const requestUsers = (): ThunkAction<void, State, null, Action<string>> => (
async (dispatch) => {
dispatch(usersRequestStart());
try {
const response = await fetch('/users');
const json = await response.json();
dispatch(usersRequestSuccess(json));
} catch (e) {
dispatch(usersRequestFailure(e));
}
}
)

View File

@@ -0,0 +1,87 @@
import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import IUserJSON from "../../interfaces/json/IUser";
import { State } from "../../reducers/rootReducer";
export const USER_UPDATE_START = 'USER_UPDATE_START';
interface UserUpdateStartAction {
type: typeof USER_UPDATE_START;
}
export const USER_UPDATE_SUCCESS = 'USER_UPDATE_SUCCESS';
interface UserUpdateSuccessAction {
type: typeof USER_UPDATE_SUCCESS;
user: IUserJSON;
}
export const USER_UPDATE_FAILURE = 'USER_UPDATE_FAILURE';
interface UserUpdateFailureAction {
type: typeof USER_UPDATE_FAILURE;
error: string;
}
export type UserUpdateActionTypes =
UserUpdateStartAction |
UserUpdateSuccessAction |
UserUpdateFailureAction;
const userUpdateStart = (): UserUpdateStartAction => ({
type: USER_UPDATE_START,
});
const userUpdateSuccess = (
userJSON: IUserJSON,
): UserUpdateSuccessAction => ({
type: USER_UPDATE_SUCCESS,
user: userJSON,
});
const userUpdateFailure = (error: string): UserUpdateFailureAction => ({
type: USER_UPDATE_FAILURE,
error,
});
interface UpdateUserParams {
id: number;
role?: string;
status?: string;
authenticityToken: string;
}
export const updateUser = ({
id,
role = null,
status = null,
authenticityToken,
}: UpdateUserParams): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(userUpdateStart());
const user = Object.assign({},
role !== null ? { role } : null,
status !== null ? { status } : null,
);
try {
const res = await fetch(`/users/${id}`, {
method: 'PATCH',
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({ user }),
});
const json = await res.json();
if (res.status === HttpStatus.OK) {
dispatch(userUpdateSuccess(json));
} else {
dispatch(userUpdateFailure(json.error));
}
return Promise.resolve(res);
} catch (e) {
dispatch(userUpdateFailure(e));
return Promise.resolve(null);
}
};

View File

@@ -92,7 +92,7 @@ class BoardsSiteSettingsP extends React.Component<Props> {
return (
<>
<Box>
<h2>{I18n.t('site_settings.boards.title')}</h2>
<h2>{ I18n.t('site_settings.boards.title') }</h2>
{
boards.items.length > 0 ?

View File

@@ -0,0 +1,157 @@
import * as React from "react";
import Gravatar from 'react-gravatar';
import I18n from 'i18n-js';
import IUser, { UserRoles, USER_ROLE_ADMIN, USER_ROLE_USER, USER_STATUS_ACTIVE, USER_STATUS_BLOCKED, USER_STATUS_DELETED } from "../../../interfaces/IUser";
import Separator from "../../common/Separator";
import UserForm from "./UserForm";
import { MutedText } from "../../common/CustomTexts";
interface Props {
user: IUser;
updateUserRole(
id: number,
role: UserRoles,
closeEditMode: Function,
): void;
updateUserStatus(
id: number,
status: typeof USER_STATUS_ACTIVE | typeof USER_STATUS_BLOCKED,
): void;
currentUserRole: UserRoles;
currentUserEmail: string;
}
interface State {
editMode: boolean;
}
class UserEditable extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { editMode: false };
this.toggleEditMode = this.toggleEditMode.bind(this);
this._handleUpdateUserRole = this._handleUpdateUserRole.bind(this);
this._handleUpdateUserStatus = this._handleUpdateUserStatus.bind(this);
}
toggleEditMode() {
this.setState({ editMode: !this.state.editMode });
}
_handleUpdateUserRole(newRole: UserRoles) {
this.props.updateUserRole(
this.props.user.id,
newRole,
this.toggleEditMode,
);
}
_handleUpdateUserStatus() {
const { user } = this.props;
const currentStatus = user.status;
let newStatus: typeof USER_STATUS_ACTIVE | typeof USER_STATUS_BLOCKED;
if (currentStatus === 'deleted') return;
if (currentStatus === 'active') newStatus = 'blocked';
else newStatus = 'active';
const confirmationMessage =
newStatus === 'blocked' ?
I18n.t('site_settings.users.block_confirmation', { name: user.fullName })
:
I18n.t('site_settings.users.unblock_confirmation', { name: user.fullName });
const confirmationResponse = confirm(confirmationMessage);
if (confirmationResponse) {
this.props.updateUserStatus(user.id, newStatus);
}
}
render() {
const { user, currentUserRole, currentUserEmail } = this.props;
const { editMode } = this.state;
const editEnabled =
user.status === USER_STATUS_ACTIVE &&
currentUserRole === USER_ROLE_ADMIN &&
currentUserEmail !== user.email;
const blockEnabled =
user.status !== USER_STATUS_DELETED &&
(currentUserRole === USER_ROLE_ADMIN || user.role === USER_ROLE_USER) &&
currentUserEmail !== user.email;
return (
<li className="userEditable">
{
editMode === false ?
<>
<div className="userInfo">
<Gravatar email={user.email} size={42} className="gravatar userGravatar" />
<div className="userFullNameRoleStatus">
<span className="userFullName">{ user.fullName }</span>
<div className="userRoleStatus">
<span>
<MutedText>{ I18n.t(`site_settings.users.role_${user.role}`) }</MutedText>
</span>
{
user.status !== USER_STATUS_ACTIVE ?
<>
<Separator />
<span className={`userStatus userStatus${user.status}`}>
{ I18n.t(`site_settings.users.status_${user.status}`) }
</span>
</>
:
null
}
</div>
</div>
</div>
<div className="userEditableActions">
<a
onClick={() => editEnabled && this.toggleEditMode()}
className={editEnabled ? '' : 'actionDisabled'}
>
{ I18n.t('common.buttons.edit') }
</a>
<Separator />
<a
onClick={() => blockEnabled && this._handleUpdateUserStatus()}
className={blockEnabled ? '' : 'actionDisabled'}
>
{
user.status !== USER_STATUS_BLOCKED ?
I18n.t('site_settings.users.block')
:
I18n.t('site_settings.users.unblock')
}
</a>
</div>
</>
:
<>
<UserForm user={user} updateUserRole={this._handleUpdateUserRole} />
<a onClick={this.toggleEditMode} className="userEditCancelButton">
{ I18n.t('common.buttons.cancel') }
</a>
</>
}
</li>
);
}
}
export default UserEditable;

View File

@@ -0,0 +1,79 @@
import * as React from 'react';
import Gravatar from 'react-gravatar';
import I18n from 'i18n-js';
import Button from '../../common/Button';
import IUser, { UserRoles, USER_ROLE_ADMIN, USER_ROLE_MODERATOR, USER_ROLE_USER } from '../../../interfaces/IUser';
interface Props {
user: IUser;
updateUserRole(newRole: UserRoles): void;
}
interface State {
role: UserRoles;
}
class UserForm extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { role: this.props.user.role };
this._handleUpdateUserRole = this._handleUpdateUserRole.bind(this);
}
_handleUpdateUserRole(selectedRole: UserRoles) {
const { user, updateUserRole } = this.props;
let confirmation = true;
if (selectedRole === 'admin') {
confirmation = confirm(I18n.t('site_settings.users.role_to_admin_confirmation', { name: user.fullName }));
}
if (confirmation) updateUserRole(selectedRole);
}
render() {
const { user } = this.props;
const selectedRole = this.state.role;
return (
<div className="userForm">
<Gravatar email={user.email} size={42} className="gravatar userGravatar" />
<div className="userFullNameRoleForm">
<span className="userFullName">{ user.fullName }</span>
<select
value={selectedRole || 'Loading...'}
onChange={
(e: React.FormEvent) => {
this.setState({role: (e.target as HTMLSelectElement).value as UserRoles});
}}
id="selectPickerUserRole"
className="selectPicker"
>
<optgroup label="Roles">
<option value={USER_ROLE_USER}>
{ I18n.t(`site_settings.users.role_${USER_ROLE_USER}`) }
</option>
<option value={USER_ROLE_MODERATOR}>
{ I18n.t(`site_settings.users.role_${USER_ROLE_MODERATOR}`) }
</option>
<option value={USER_ROLE_ADMIN}>
{ I18n.t(`site_settings.users.role_${USER_ROLE_ADMIN}`) }
</option>
</optgroup>
</select>
</div>
<Button onClick={() => this._handleUpdateUserRole(selectedRole)} className="updateUserButton">
{ I18n.t('common.buttons.update') }
</Button>
</div>
);
}
}
export default UserForm;

View File

@@ -0,0 +1,103 @@
import * as React from 'react';
import I18n from 'i18n-js';
import UserEditable from './UserEditable';
import Box from '../../common/Box';
import SiteSettingsInfoBox from '../../common/SiteSettingsInfoBox';
import { UsersState } from '../../../reducers/usersReducer';
import { UserRoles, USER_STATUS_ACTIVE, USER_STATUS_BLOCKED } from '../../../interfaces/IUser';
import HttpStatus from '../../../constants/http_status';
interface Props {
users: UsersState;
settingsAreUpdating: boolean;
settingsError: string;
requestUsers(): void;
updateUserRole(
id: number,
role: UserRoles,
authenticityToken: string,
): Promise<any>;
updateUserStatus(
id: number,
status: typeof USER_STATUS_ACTIVE | typeof USER_STATUS_BLOCKED,
authenticityToken: string,
): void;
currentUserEmail: string;
currentUserRole: UserRoles;
authenticityToken: string;
}
class UsersSiteSettingsP extends React.Component<Props> {
constructor(props: Props) {
super(props);
this._handleUpdateUserRole = this._handleUpdateUserRole.bind(this);
this._handleUpdateUserStatus = this._handleUpdateUserStatus.bind(this);
}
componentDidMount() {
this.props.requestUsers();
}
_handleUpdateUserRole(id: number, role: UserRoles, closeEditMode: Function) {
this.props.updateUserRole(
id,
role,
this.props.authenticityToken,
).then(res => {
if (res?.status !== HttpStatus.OK) return;
closeEditMode();
});
}
_handleUpdateUserStatus(id: number, status: typeof USER_STATUS_ACTIVE | typeof USER_STATUS_BLOCKED) {
this.props.updateUserStatus(
id,
status,
this.props.authenticityToken,
);
}
render() {
const {
users,
settingsAreUpdating,
settingsError,
currentUserRole,
currentUserEmail,
} = this.props;
return (
<>
<Box>
<h2>{ I18n.t('site_settings.users.title') }</h2>
<ul className="usersList">
{
users.items.map((user, i) => (
<UserEditable
user={user}
updateUserRole={this._handleUpdateUserRole}
updateUserStatus={this._handleUpdateUserStatus}
currentUserEmail={currentUserEmail}
currentUserRole={currentUserRole}
key={i}
/>
))
}
</ul>
</Box>
<SiteSettingsInfoBox areUpdating={settingsAreUpdating || users.areLoading} error={settingsError} />
</>
);
}
}
export default UsersSiteSettingsP;

View File

@@ -0,0 +1,39 @@
import * as React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import UsersSiteSettings from '../../../containers/UsersSiteSettings';
import createStoreHelper from '../../../helpers/createStore';
import { UserRoles } from '../../../interfaces/IUser';
import { State } from '../../../reducers/rootReducer';
interface Props {
currentUserEmail: string;
currentUserRole: UserRoles;
authenticityToken: string;
}
class UsersSiteSettingsRoot extends React.Component<Props> {
store: Store<State, any>;
constructor(props: Props) {
super(props);
this.store = createStoreHelper();
}
render() {
return (
<Provider store={this.store}>
<UsersSiteSettings
currentUserEmail={this.props.currentUserEmail}
currentUserRole={this.props.currentUserRole}
authenticityToken={this.props.authenticityToken}
/>
</Provider>
);
}
}
export default UsersSiteSettingsRoot;

View File

@@ -0,0 +1,49 @@
import { connect } from "react-redux";
import UsersSiteSettingsP from "../components/SiteSettings/Users/UsersSiteSettingsP";
import { requestUsers } from "../actions/User/requestUsers";
import { updateUser } from "../actions/User/updateUser";
import { UserRoles, USER_STATUS_ACTIVE, USER_STATUS_BLOCKED } from "../interfaces/IUser";
import { State } from "../reducers/rootReducer";
const mapStateToProps = (state: State) => ({
users: state.users,
settingsAreUpdating: state.siteSettings.users.areUpdating,
settingsError: state.siteSettings.users.error,
});
const mapDispatchToProps = (dispatch: any) => ({
requestUsers() {
dispatch(requestUsers());
},
updateUserRole(
id: number,
role: UserRoles,
authenticityToken: string,
): Promise<any> {
return dispatch(updateUser({
id,
role,
authenticityToken,
}));
},
updateUserStatus(
id: number,
status: typeof USER_STATUS_ACTIVE | typeof USER_STATUS_BLOCKED,
authenticityToken: string,
) {
dispatch(updateUser({
id,
status,
authenticityToken,
}));
},
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(UsersSiteSettingsP);

View File

@@ -0,0 +1,29 @@
// Roles
export const USER_ROLE_USER = 'user';
export const USER_ROLE_MODERATOR = 'moderator';
export const USER_ROLE_ADMIN = 'admin';
export type UserRoles =
typeof USER_ROLE_USER |
typeof USER_ROLE_MODERATOR |
typeof USER_ROLE_ADMIN;
// Statuses
export const USER_STATUS_ACTIVE = 'active';
export const USER_STATUS_BLOCKED = 'blocked';
export const USER_STATUS_DELETED = 'deleted';
export type UserStatuses =
typeof USER_STATUS_ACTIVE |
typeof USER_STATUS_BLOCKED |
typeof USER_STATUS_DELETED;
interface IUser {
id: number;
email: string;
fullName: string;
role: UserRoles;
status: UserStatuses;
}
export default IUser;

View File

@@ -0,0 +1,9 @@
interface IUserJSON {
id: number;
email: string;
full_name: string;
role: string;
status: string;
}
export default IUserJSON;

View File

@@ -0,0 +1,58 @@
import {
UsersRequestActionTypes,
USERS_REQUEST_START,
USERS_REQUEST_SUCCESS,
USERS_REQUEST_FAILURE,
} from '../../actions/User/requestUsers';
import {
UserUpdateActionTypes,
USER_UPDATE_START,
USER_UPDATE_SUCCESS,
USER_UPDATE_FAILURE,
} from '../../actions/User/updateUser';
export interface SiteSettingsUsersState {
areUpdating: boolean;
error: string;
}
const initialState: SiteSettingsUsersState = {
areUpdating: false,
error: '',
};
const siteSettingsUsersReducer = (
state = initialState,
action: UsersRequestActionTypes | UserUpdateActionTypes,
) => {
switch (action.type) {
case USERS_REQUEST_START:
case USER_UPDATE_START:
return {
...state,
areUpdating: true,
};
case USERS_REQUEST_SUCCESS:
case USER_UPDATE_SUCCESS:
return {
...state,
areUpdating: false,
error: '',
};
case USERS_REQUEST_FAILURE:
case USER_UPDATE_FAILURE:
return {
...state,
areUpdating: false,
error: action.error,
};
default:
return state;
}
}
export default siteSettingsUsersReducer;

View File

@@ -38,7 +38,7 @@ const initialState: BoardsState = {
items: [],
areLoading: false,
error: '',
}
};
const boardsReducer = (
state = initialState,

View File

@@ -3,6 +3,7 @@ import { combineReducers } from 'redux';
import postsReducer from './postsReducer';
import boardsReducer from './boardsReducer';
import postStatusesReducer from './postStatusesReducer';
import usersReducer from './usersReducer';
import currentPostReducer from './currentPostReducer';
import siteSettingsReducer from './siteSettingsReducer';
@@ -10,6 +11,7 @@ const rootReducer = combineReducers({
posts: postsReducer,
boards: boardsReducer,
postStatuses: postStatusesReducer,
users: usersReducer,
currentPost: currentPostReducer,
siteSettings: siteSettingsReducer,
});

View File

@@ -61,20 +61,37 @@ import {
POSTSTATUS_UPDATE_FAILURE,
} from '../actions/PostStatus/updatePostStatus';
import {
UsersRequestActionTypes,
USERS_REQUEST_START,
USERS_REQUEST_SUCCESS,
USERS_REQUEST_FAILURE,
} from '../actions/User/requestUsers';
import {
UserUpdateActionTypes,
USER_UPDATE_START,
USER_UPDATE_SUCCESS,
USER_UPDATE_FAILURE,
} from '../actions/User/updateUser';
import siteSettingsBoardsReducer, { SiteSettingsBoardsState } from './SiteSettings/boardsReducer';
import siteSettingsPostStatusesReducer, { SiteSettingsPostStatusesState } from './SiteSettings/postStatusesReducer';
import siteSettingsRoadmapReducer, { SiteSettingsRoadmapState } from './SiteSettings/roadmapReducer';
import siteSettingsUsersReducer, { SiteSettingsUsersState } from './SiteSettings/usersReducer';
interface SiteSettingsState {
boards: SiteSettingsBoardsState;
postStatuses: SiteSettingsPostStatusesState;
roadmap: SiteSettingsRoadmapState;
users: SiteSettingsUsersState;
}
const initialState: SiteSettingsState = {
boards: siteSettingsBoardsReducer(undefined, {} as any),
postStatuses: siteSettingsPostStatusesReducer(undefined, {} as any),
roadmap: siteSettingsRoadmapReducer(undefined, {} as any),
users: siteSettingsUsersReducer(undefined, {} as any),
};
const siteSettingsReducer = (
@@ -88,7 +105,9 @@ const siteSettingsReducer = (
PostStatusOrderUpdateActionTypes |
PostStatusDeleteActionTypes |
PostStatusSubmitActionTypes |
PostStatusUpdateActionTypes
PostStatusUpdateActionTypes |
UsersRequestActionTypes |
UserUpdateActionTypes
): SiteSettingsState => {
switch (action.type) {
case BOARDS_REQUEST_START:
@@ -134,6 +153,17 @@ const siteSettingsReducer = (
roadmap: siteSettingsRoadmapReducer(state.roadmap, action),
};
case USERS_REQUEST_START:
case USERS_REQUEST_SUCCESS:
case USERS_REQUEST_FAILURE:
case USER_UPDATE_START:
case USER_UPDATE_SUCCESS:
case USER_UPDATE_FAILURE:
return {
...state,
users: siteSettingsUsersReducer(state.users, action),
};
default:
return state;
}

View File

@@ -0,0 +1,75 @@
import {
UsersRequestActionTypes,
USERS_REQUEST_START,
USERS_REQUEST_SUCCESS,
USERS_REQUEST_FAILURE,
} from '../actions/User/requestUsers';
import {
UserUpdateActionTypes,
USER_UPDATE_SUCCESS,
} from '../actions/User/updateUser';
import IUser from "../interfaces/IUser";
export interface UsersState {
items: Array<IUser>;
areLoading: boolean;
error: string;
}
const initialState: UsersState = {
items: [],
areLoading: false,
error: '',
};
const usersReducer = (
state = initialState,
action: UsersRequestActionTypes | UserUpdateActionTypes,
) => {
switch (action.type) {
case USERS_REQUEST_START:
return {
...state,
areLoading: true,
};
case USERS_REQUEST_SUCCESS:
return {
...state,
areLoading: false,
error: '',
items: action.users.map(userJson => ({
id: userJson.id,
email: userJson.email,
fullName: userJson.full_name,
role: userJson.role,
status: userJson.status,
})),
};
case USERS_REQUEST_FAILURE:
return {
...state,
areLoading: false,
error: action.error,
};
case USER_UPDATE_SUCCESS:
return {
...state,
items: state.items.map(user => {
return (user.id === action.user.id) ?
{...user, role: action.user.role, status: action.user.status}
:
user;
}),
};
default:
return state;
}
}
export default usersReducer;

View File

@@ -172,4 +172,9 @@
background-color: $primary-color !important;
border-color: $primary-color !important;
}
}
.selectPicker {
@extend
.custom-select;
}

View File

@@ -119,12 +119,6 @@
@extend
.d-flex,
.justify-content-between;
.selectPicker {
@extend
.custom-select,
.mx-2;
}
}
.postDescription {

View File

@@ -0,0 +1,68 @@
ul.usersList {
@extend
.pl-1;
list-style: none;
li.userEditable {
@extend
.d-flex,
.justify-content-between,
.my-2,
.p-3;
.userGravatar {
@extend .mr-3, .align-self-center;
}
.userFullName {
font-size: 18px;
}
.userInfo {
@extend .d-flex;
.userFullNameRoleStatus {
@extend
.d-flex,
.flex-column;
}
}
.userEditableActions {
@extend .align-self-center;
}
.userForm {
@extend .d-flex;
.userFullNameRoleForm {
@extend .d-flex, .flex-column;
}
}
.updateUserButton { @extend .align-self-center; }
.userEditCancelButton { @extend .align-self-center; }
a {
cursor: pointer;
&:hover { text-decoration: underline; }
}
a.actionDisabled {
@extend .mutedText;
text-decoration: none;
cursor: not-allowed;
}
.updateUserButton {
margin-left: 16px;
}
.userStatusblocked { color: orange; }
.userStatusdeleted { color: red; }
}
}

View File

@@ -20,6 +20,7 @@
@import 'components/SiteSettings/Boards';
@import 'components/SiteSettings/PostStatuses';
@import 'components/SiteSettings/Roadmap';
@import 'components/SiteSettings/Users';
/* Icons */
@import 'icons/drag_icon';