mirror of
https://github.com/valitydev/redash.git
synced 2024-11-06 17:15:17 +00:00
Refactor User Profile page and add extension points to it (#4996)
* Move components specific to UserProfile page to corresponding folder * Split UserProfile page into components * Rename components, refine code a bit * Add some extension points * Fix margin
This commit is contained in:
parent
a563900f0a
commit
6f842ef94a
@ -1,295 +0,0 @@
|
||||
import React, { Fragment } from "react";
|
||||
import { includes, get } from "lodash";
|
||||
import Alert from "antd/lib/alert";
|
||||
import Button from "antd/lib/button";
|
||||
import Form from "antd/lib/form";
|
||||
import Modal from "antd/lib/modal";
|
||||
import Tag from "antd/lib/tag";
|
||||
import User from "@/services/user";
|
||||
import Group from "@/services/group";
|
||||
import { currentUser } from "@/services/auth";
|
||||
import { absoluteUrl } from "@/services/utils";
|
||||
import { UserProfile } from "../proptypes";
|
||||
import DynamicForm from "../dynamic-form/DynamicForm";
|
||||
import ChangePasswordDialog from "./ChangePasswordDialog";
|
||||
import InputWithCopy from "../InputWithCopy";
|
||||
|
||||
export default class UserEdit extends React.Component {
|
||||
static propTypes = {
|
||||
user: UserProfile.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
user: this.props.user,
|
||||
groups: [],
|
||||
loadingGroups: true,
|
||||
regeneratingApiKey: false,
|
||||
sendingPasswordEmail: false,
|
||||
resendingInvitation: false,
|
||||
togglingUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
Group.query().then(groups => {
|
||||
this.setState({
|
||||
groups: groups.map(({ id, name }) => ({ value: id, name })),
|
||||
loadingGroups: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
changePassword = () => {
|
||||
ChangePasswordDialog.showModal({ user: this.props.user });
|
||||
};
|
||||
|
||||
sendPasswordReset = () => {
|
||||
this.setState({ sendingPasswordEmail: true });
|
||||
|
||||
User.sendPasswordReset(this.state.user)
|
||||
.then(passwordLink => {
|
||||
this.setState({ passwordLink });
|
||||
})
|
||||
.finally(() => {
|
||||
this.setState({ sendingPasswordEmail: false });
|
||||
});
|
||||
};
|
||||
|
||||
resendInvitation = () => {
|
||||
this.setState({ resendingInvitation: true });
|
||||
|
||||
User.resendInvitation(this.state.user)
|
||||
.then(passwordLink => {
|
||||
this.setState({ passwordLink });
|
||||
})
|
||||
.finally(() => {
|
||||
this.setState({ resendingInvitation: false });
|
||||
});
|
||||
};
|
||||
|
||||
regenerateApiKey = () => {
|
||||
const doRegenerate = () => {
|
||||
this.setState({ regeneratingApiKey: true });
|
||||
User.regenerateApiKey(this.state.user)
|
||||
.then(apiKey => {
|
||||
if (apiKey) {
|
||||
const { user } = this.state;
|
||||
this.setState({ user: { ...user, apiKey } });
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.setState({ regeneratingApiKey: false });
|
||||
});
|
||||
};
|
||||
|
||||
Modal.confirm({
|
||||
title: "Regenerate API Key",
|
||||
content: "Are you sure you want to regenerate?",
|
||||
okText: "Regenerate",
|
||||
onOk: doRegenerate,
|
||||
maskClosable: true,
|
||||
autoFocusButton: null,
|
||||
});
|
||||
};
|
||||
|
||||
toggleUser = () => {
|
||||
const { user } = this.state;
|
||||
const toggleUser = user.isDisabled ? User.enableUser : User.disableUser;
|
||||
|
||||
this.setState({ togglingUser: true });
|
||||
toggleUser(user)
|
||||
.then(data => {
|
||||
if (data) {
|
||||
this.setState({ user: User.convertUserInfo(data) });
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.setState({ togglingUser: false });
|
||||
});
|
||||
};
|
||||
|
||||
saveUser = (values, successCallback, errorCallback) => {
|
||||
const data = {
|
||||
id: this.props.user.id,
|
||||
...values,
|
||||
};
|
||||
|
||||
User.save(data)
|
||||
.then(user => {
|
||||
successCallback("Saved.");
|
||||
this.setState({ user: User.convertUserInfo(user) });
|
||||
})
|
||||
.catch(error => {
|
||||
errorCallback(get(error, "response.data.message", "Failed saving."));
|
||||
});
|
||||
};
|
||||
|
||||
renderUserInfoForm() {
|
||||
const { user, groups, loadingGroups } = this.state;
|
||||
|
||||
const formFields = [
|
||||
{
|
||||
name: "name",
|
||||
title: "Name",
|
||||
type: "text",
|
||||
initialValue: user.name,
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
title: "Email",
|
||||
type: "email",
|
||||
initialValue: user.email,
|
||||
},
|
||||
!user.isDisabled && currentUser.id !== user.id
|
||||
? {
|
||||
name: "group_ids",
|
||||
title: "Groups",
|
||||
type: "select",
|
||||
mode: "multiple",
|
||||
options: groups,
|
||||
initialValue: groups.filter(group => includes(user.groupIds, group.value)).map(group => group.value),
|
||||
loading: loadingGroups,
|
||||
placeholder: loadingGroups ? "Loading..." : "",
|
||||
}
|
||||
: {
|
||||
name: "group_ids",
|
||||
title: "Groups",
|
||||
type: "content",
|
||||
content: this.renderUserGroups(),
|
||||
},
|
||||
].map(field => ({ readOnly: user.isDisabled, required: true, ...field }));
|
||||
|
||||
return <DynamicForm fields={formFields} onSubmit={this.saveUser} hideSubmitButton={user.isDisabled} />;
|
||||
}
|
||||
|
||||
renderUserGroups() {
|
||||
const { user, groups, loadingGroups } = this.state;
|
||||
|
||||
return loadingGroups ? (
|
||||
"Loading..."
|
||||
) : (
|
||||
<div data-test="Groups">
|
||||
{groups
|
||||
.filter(group => includes(user.groupIds, group.value))
|
||||
.map(group => (
|
||||
<Tag className="m-b-5 m-r-5" key={group.value}>
|
||||
<a href={`groups/${group.value}`}>{group.name}</a>
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderApiKey() {
|
||||
const { user, regeneratingApiKey } = this.state;
|
||||
|
||||
return (
|
||||
<Form layout="vertical">
|
||||
<hr />
|
||||
<Form.Item label="API Key" className="m-b-10">
|
||||
<InputWithCopy id="apiKey" className="hide-in-percy" value={user.apiKey} data-test="ApiKey" readOnly />
|
||||
</Form.Item>
|
||||
<Button
|
||||
className="w-100"
|
||||
onClick={this.regenerateApiKey}
|
||||
loading={regeneratingApiKey}
|
||||
data-test="RegenerateApiKey">
|
||||
Regenerate
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
renderPasswordLinkAlert() {
|
||||
const { user, passwordLink } = this.state;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
message="Email not sent!"
|
||||
description={
|
||||
<Fragment>
|
||||
<p>
|
||||
The mail server is not configured, please send the following link to <b>{user.name}</b>:
|
||||
</p>
|
||||
<InputWithCopy value={absoluteUrl(passwordLink)} readOnly />
|
||||
</Fragment>
|
||||
}
|
||||
type="warning"
|
||||
className="m-t-20"
|
||||
afterClose={() => {
|
||||
this.setState({ passwordLink: null });
|
||||
}}
|
||||
closable
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderResendInvitation() {
|
||||
return (
|
||||
<Button className="w-100 m-t-10" onClick={this.resendInvitation} loading={this.state.resendingInvitation}>
|
||||
Resend Invitation
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
renderSendPasswordReset() {
|
||||
const { sendingPasswordEmail } = this.state;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Button className="w-100 m-t-10" onClick={this.sendPasswordReset} loading={sendingPasswordEmail}>
|
||||
Send Password Reset Email
|
||||
</Button>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
rendertoggleUser() {
|
||||
const { user, togglingUser } = this.state;
|
||||
|
||||
return user.isDisabled ? (
|
||||
<Button className="w-100 m-t-10" type="primary" onClick={this.toggleUser} loading={togglingUser}>
|
||||
Enable User
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="w-100 m-t-10" type="danger" onClick={this.toggleUser} loading={togglingUser}>
|
||||
Disable User
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { user, passwordLink } = this.state;
|
||||
|
||||
return (
|
||||
<div className="col-md-4 col-md-offset-4">
|
||||
<img alt="Profile" src={user.profileImageUrl} className="profile__image" width="40" />
|
||||
<h3 className="profile__h3">{user.name}</h3>
|
||||
<hr />
|
||||
{this.renderUserInfoForm()}
|
||||
{!user.isDisabled && (
|
||||
<Fragment>
|
||||
{this.renderApiKey()}
|
||||
<hr />
|
||||
<h5>Password</h5>
|
||||
{user.id === currentUser.id && (
|
||||
<Button className="w-100 m-t-10" onClick={this.changePassword} data-test="ChangePassword">
|
||||
Change Password
|
||||
</Button>
|
||||
)}
|
||||
{currentUser.isAdmin && user.id !== currentUser.id && (
|
||||
<Fragment>
|
||||
{user.isInvitationPending ? this.renderResendInvitation() : this.renderSendPasswordReset()}
|
||||
{passwordLink && this.renderPasswordLinkAlert()}
|
||||
</Fragment>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
<hr />
|
||||
{currentUser.isAdmin && user.id !== currentUser.id && this.rendertoggleUser()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,63 +0,0 @@
|
||||
import React from "react";
|
||||
import { includes } from "lodash";
|
||||
import Tag from "antd/lib/tag";
|
||||
import Group from "@/services/group";
|
||||
import { UserProfile } from "../proptypes";
|
||||
|
||||
export default class UserShow extends React.Component {
|
||||
static propTypes = {
|
||||
user: UserProfile.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { groups: [], loadingGroups: true };
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
Group.query().then(groups => {
|
||||
this.setState({ groups, loadingGroups: false });
|
||||
});
|
||||
}
|
||||
|
||||
renderUserGroups() {
|
||||
const { groupIds } = this.props.user;
|
||||
const { groups } = this.state;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{groups
|
||||
.filter(group => includes(groupIds, group.id))
|
||||
.map(group => (
|
||||
<Tag className="m-t-5 m-r-5" key={group.id}>
|
||||
<a href={`groups/${group.id}`}>{group.name}</a>
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { name, email, profileImageUrl } = this.props.user;
|
||||
const { loadingGroups } = this.state;
|
||||
|
||||
return (
|
||||
<div className="col-md-4 col-md-offset-4 profile__container">
|
||||
<img alt="profile" src={profileImageUrl} className="profile__image" width="40" />
|
||||
|
||||
<h3 className="profile__h3">{name}</h3>
|
||||
|
||||
<hr />
|
||||
|
||||
<dl className="profile__dl">
|
||||
<dt>Name:</dt>
|
||||
<dd>{name}</dd>
|
||||
<dt>Email:</dt>
|
||||
<dd>{email}</dd>
|
||||
<dt>Groups:</dt>
|
||||
<dd>{loadingGroups ? "Loading..." : this.renderUserGroups()}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,52 +1,72 @@
|
||||
import React from "react";
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
|
||||
import EmailSettingsWarning from "@/components/EmailSettingsWarning";
|
||||
import UserEdit from "@/components/users/UserEdit";
|
||||
import UserShow from "@/components/users/UserShow";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import LoadingState from "@/components/items-list/components/LoadingState";
|
||||
import wrapSettingsTab from "@/components/SettingsWrapper";
|
||||
|
||||
import User from "@/services/user";
|
||||
import { currentUser } from "@/services/auth";
|
||||
|
||||
import EditableUserProfile from "./components/EditableUserProfile";
|
||||
import ReadOnlyUserProfile from "./components/ReadOnlyUserProfile";
|
||||
|
||||
import "./settings.less";
|
||||
|
||||
class UserProfile extends React.Component {
|
||||
static propTypes = {
|
||||
userId: PropTypes.string,
|
||||
onError: PropTypes.func,
|
||||
};
|
||||
function UserProfile({ userId, onError }) {
|
||||
const [user, setUser] = useState(null);
|
||||
|
||||
static defaultProps = {
|
||||
userId: null, // defaults to `currentUser.id`
|
||||
onError: () => {},
|
||||
};
|
||||
const onErrorRef = useRef(onError);
|
||||
onErrorRef.current = onError;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { user: null };
|
||||
}
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
User.get({ id: userId || currentUser.id })
|
||||
.then(user => {
|
||||
if (!isCancelled) {
|
||||
setUser(User.convertUserInfo(user));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (!isCancelled) {
|
||||
onErrorRef.current(error);
|
||||
}
|
||||
});
|
||||
|
||||
componentDidMount() {
|
||||
const userId = this.props.userId || currentUser.id;
|
||||
User.get({ id: userId })
|
||||
.then(user => this.setState({ user: User.convertUserInfo(user) }))
|
||||
.catch(error => this.props.onError(error));
|
||||
}
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [userId]);
|
||||
|
||||
render() {
|
||||
const { user } = this.state;
|
||||
const canEdit = user && (currentUser.isAdmin || currentUser.id === user.id);
|
||||
const UserComponent = canEdit ? UserEdit : UserShow;
|
||||
return (
|
||||
<React.Fragment>
|
||||
<EmailSettingsWarning featureName="invite emails" className="m-b-20" adminOnly />
|
||||
<div className="row">{user ? <UserComponent user={user} /> : <LoadingState className="" />}</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
const canEdit = user && (currentUser.isAdmin || currentUser.id === user.id);
|
||||
return (
|
||||
<React.Fragment>
|
||||
<EmailSettingsWarning featureName="invite emails" className="m-b-20" adminOnly />
|
||||
<div className="row">
|
||||
{!user && <LoadingState className="" />}
|
||||
{user && (
|
||||
<DynamicComponent name="UserProfile" user={user}>
|
||||
{!canEdit && <ReadOnlyUserProfile user={user} />}
|
||||
{canEdit && <EditableUserProfile user={user} />}
|
||||
</DynamicComponent>
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
UserProfile.propTypes = {
|
||||
userId: PropTypes.string,
|
||||
onError: PropTypes.func,
|
||||
};
|
||||
|
||||
UserProfile.defaultProps = {
|
||||
userId: null, // defaults to `currentUser.id`
|
||||
onError: () => {},
|
||||
};
|
||||
|
||||
const UserProfilePage = wrapSettingsTab(
|
||||
{
|
||||
title: "Account",
|
||||
|
@ -20,7 +20,6 @@ import * as Sidebar from "@/components/items-list/components/Sidebar";
|
||||
import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTable";
|
||||
|
||||
import Layout from "@/components/layouts/ContentWithSidebar";
|
||||
import CreateUserDialog from "@/components/users/CreateUserDialog";
|
||||
import wrapSettingsTab from "@/components/SettingsWrapper";
|
||||
|
||||
import { currentUser } from "@/services/auth";
|
||||
@ -30,6 +29,8 @@ import navigateTo from "@/components/ApplicationArea/navigateTo";
|
||||
import notification from "@/services/notification";
|
||||
import { absoluteUrl } from "@/services/utils";
|
||||
|
||||
import CreateUserDialog from "./components/CreateUserDialog";
|
||||
|
||||
function UsersListActions({ user, enableUser, disableUser, deleteUser }) {
|
||||
if (user.id === currentUser.id) {
|
||||
return null;
|
||||
|
64
client/app/pages/users/components/ApiKeyForm.jsx
Normal file
64
client/app/pages/users/components/ApiKeyForm.jsx
Normal file
@ -0,0 +1,64 @@
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Button from "antd/lib/button";
|
||||
import Form from "antd/lib/form";
|
||||
import Modal from "antd/lib/modal";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import InputWithCopy from "@/components/InputWithCopy";
|
||||
import { UserProfile } from "@/components/proptypes";
|
||||
import User from "@/services/user";
|
||||
|
||||
export default function ApiKeyForm(props) {
|
||||
const { user, onChange } = props;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
const regenerateApiKey = useCallback(() => {
|
||||
const doRegenerate = () => {
|
||||
setLoading(true);
|
||||
User.regenerateApiKey(user)
|
||||
.then(apiKey => {
|
||||
if (apiKey) {
|
||||
onChangeRef.current({ ...user, apiKey });
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
Modal.confirm({
|
||||
title: "Regenerate API Key",
|
||||
content: "Are you sure you want to regenerate?",
|
||||
okText: "Regenerate",
|
||||
onOk: doRegenerate,
|
||||
maskClosable: true,
|
||||
autoFocusButton: null,
|
||||
});
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<DynamicComponent name="UserProfile.ApiKeyForm" {...props}>
|
||||
<Form layout="vertical">
|
||||
<hr />
|
||||
<Form.Item label="API Key" className="m-b-10">
|
||||
<InputWithCopy id="apiKey" className="hide-in-percy" value={user.apiKey} data-test="ApiKey" readOnly />
|
||||
</Form.Item>
|
||||
<Button className="w-100" onClick={regenerateApiKey} loading={loading} data-test="RegenerateApiKey">
|
||||
Regenerate
|
||||
</Button>
|
||||
</Form>
|
||||
</DynamicComponent>
|
||||
);
|
||||
}
|
||||
|
||||
ApiKeyForm.propTypes = {
|
||||
user: UserProfile.isRequired,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
ApiKeyForm.defaultProps = {
|
||||
onChange: () => {},
|
||||
};
|
37
client/app/pages/users/components/EditableUserProfile.jsx
Normal file
37
client/app/pages/users/components/EditableUserProfile.jsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { UserProfile } from "@/components/proptypes";
|
||||
|
||||
import UserInfoForm from "./UserInfoForm";
|
||||
import ApiKeyForm from "./ApiKeyForm";
|
||||
import PasswordForm from "./PasswordForm";
|
||||
import ToggleUserForm from "./ToggleUserForm";
|
||||
|
||||
export default function EditableUserProfile(props) {
|
||||
const [user, setUser] = useState(props.user);
|
||||
|
||||
useEffect(() => {
|
||||
setUser(props.user);
|
||||
}, [props.user]);
|
||||
|
||||
return (
|
||||
<div className="col-md-4 col-md-offset-4">
|
||||
<img alt="Profile" src={user.profileImageUrl} className="profile__image" width="40" />
|
||||
<h3 className="profile__h3">{user.name}</h3>
|
||||
<hr />
|
||||
<UserInfoForm user={user} onChange={setUser} />
|
||||
{!user.isDisabled && (
|
||||
<React.Fragment>
|
||||
<ApiKeyForm user={user} onChange={setUser} />
|
||||
<hr />
|
||||
<PasswordForm user={user} />
|
||||
</React.Fragment>
|
||||
)}
|
||||
<hr />
|
||||
<ToggleUserForm user={user} onChange={setUser} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EditableUserProfile.propTypes = {
|
||||
user: UserProfile.isRequired,
|
||||
};
|
@ -3,10 +3,10 @@ import React from "react";
|
||||
import Form from "antd/lib/form";
|
||||
import Modal from "antd/lib/modal";
|
||||
import Input from "antd/lib/input";
|
||||
import { UserProfile } from "@/components/proptypes";
|
||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
import User from "@/services/user";
|
||||
import notification from "@/services/notification";
|
||||
import { UserProfile } from "../proptypes";
|
||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
|
||||
class ChangePasswordDialog extends React.Component {
|
||||
static propTypes = {
|
@ -0,0 +1,45 @@
|
||||
import { isString } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Alert from "antd/lib/alert";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import InputWithCopy from "@/components/InputWithCopy";
|
||||
import { UserProfile } from "@/components/proptypes";
|
||||
import { absoluteUrl } from "@/services/utils";
|
||||
|
||||
export default function PasswordLinkAlert(props) {
|
||||
const { user, passwordLink, ...restProps } = props;
|
||||
|
||||
if (!isString(passwordLink)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DynamicComponent name="UserProfile.PasswordLinkAlert" {...props}>
|
||||
<Alert
|
||||
message="Email not sent!"
|
||||
description={
|
||||
<React.Fragment>
|
||||
<p>
|
||||
The mail server is not configured, please send the following link to <b>{user.name}</b>:
|
||||
</p>
|
||||
<InputWithCopy value={absoluteUrl(passwordLink)} readOnly />
|
||||
</React.Fragment>
|
||||
}
|
||||
type="warning"
|
||||
className="m-t-20"
|
||||
closable
|
||||
{...restProps}
|
||||
/>
|
||||
</DynamicComponent>
|
||||
);
|
||||
}
|
||||
|
||||
PasswordLinkAlert.propTypes = {
|
||||
user: UserProfile.isRequired,
|
||||
passwordLink: PropTypes.string,
|
||||
};
|
||||
|
||||
PasswordLinkAlert.defaultProps = {
|
||||
passwordLink: null,
|
||||
};
|
@ -0,0 +1,37 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import Button from "antd/lib/button";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import { UserProfile } from "@/components/proptypes";
|
||||
import User from "@/services/user";
|
||||
import PasswordLinkAlert from "./PasswordLinkAlert";
|
||||
|
||||
export default function PasswordResetForm(props) {
|
||||
const { user } = props;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [passwordLink, setPasswordLink] = useState(null);
|
||||
|
||||
const sendPasswordReset = useCallback(() => {
|
||||
setLoading(true);
|
||||
User.sendPasswordReset(user)
|
||||
.then(passwordLink => {
|
||||
setPasswordLink(passwordLink);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<DynamicComponent name="UserProfile.PasswordResetForm" {...props}>
|
||||
<Button className="w-100 m-t-10" onClick={sendPasswordReset} loading={loading}>
|
||||
Send Password Reset Email
|
||||
</Button>
|
||||
<PasswordLinkAlert user={user} passwordLink={passwordLink} afterClose={() => setPasswordLink(null)} />
|
||||
</DynamicComponent>
|
||||
);
|
||||
}
|
||||
|
||||
PasswordResetForm.propTypes = {
|
||||
user: UserProfile.isRequired,
|
||||
};
|
@ -0,0 +1,38 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import Button from "antd/lib/button";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import { UserProfile } from "@/components/proptypes";
|
||||
import User from "@/services/user";
|
||||
import PasswordLinkAlert from "./PasswordLinkAlert";
|
||||
|
||||
export default function ResendInvitationForm(props) {
|
||||
const { user } = props;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [passwordLink, setPasswordLink] = useState(null);
|
||||
|
||||
const resendInvitation = useCallback(() => {
|
||||
setLoading(true);
|
||||
|
||||
User.resendInvitation(user)
|
||||
.then(passwordLink => {
|
||||
setPasswordLink(passwordLink);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<DynamicComponent name="UserProfile.ResendInvitationForm" {...props}>
|
||||
<Button className="w-100 m-t-10" onClick={resendInvitation} loading={loading}>
|
||||
Resend Invitation
|
||||
</Button>
|
||||
<PasswordLinkAlert user={user} passwordLink={passwordLink} afterClose={() => setPasswordLink(null)} />
|
||||
</DynamicComponent>
|
||||
);
|
||||
}
|
||||
|
||||
ResendInvitationForm.propTypes = {
|
||||
user: UserProfile.isRequired,
|
||||
};
|
37
client/app/pages/users/components/PasswordForm/index.jsx
Normal file
37
client/app/pages/users/components/PasswordForm/index.jsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React, { useCallback } from "react";
|
||||
import Button from "antd/lib/button";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import { UserProfile } from "@/components/proptypes";
|
||||
import { currentUser } from "@/services/auth";
|
||||
|
||||
import ChangePasswordDialog from "./ChangePasswordDialog";
|
||||
import PasswordResetForm from "./PasswordResetForm";
|
||||
import ResendInvitationForm from "./ResendInvitationForm";
|
||||
|
||||
export default function PasswordForm(props) {
|
||||
const { user } = props;
|
||||
|
||||
const changePassword = useCallback(() => {
|
||||
ChangePasswordDialog.showModal({ user });
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<DynamicComponent name="UserProfile.PasswordForm" {...props}>
|
||||
<h5>Password</h5>
|
||||
{user.id === currentUser.id && (
|
||||
<Button className="w-100 m-t-10" onClick={changePassword} data-test="ChangePassword">
|
||||
Change Password
|
||||
</Button>
|
||||
)}
|
||||
{user.id !== currentUser.id && currentUser.isAdmin && (
|
||||
<React.Fragment>
|
||||
{user.isInvitationPending ? <ResendInvitationForm user={user} /> : <PasswordResetForm user={user} />}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</DynamicComponent>
|
||||
);
|
||||
}
|
||||
|
||||
PasswordForm.propTypes = {
|
||||
user: UserProfile.isRequired,
|
||||
};
|
29
client/app/pages/users/components/ReadOnlyUserProfile.jsx
Normal file
29
client/app/pages/users/components/ReadOnlyUserProfile.jsx
Normal file
@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import { UserProfile } from "@/components/proptypes";
|
||||
|
||||
import UserGroups from "./UserGroups";
|
||||
import useUserGroups from "../hooks/useUserGroups";
|
||||
|
||||
export default function ReadOnlyUserProfile({ user }) {
|
||||
const { groups, isLoading: isLoadingGroups } = useUserGroups(user);
|
||||
|
||||
return (
|
||||
<div className="col-md-4 col-md-offset-4 profile__container">
|
||||
<img alt="profile" src={user.profileImageUrl} className="profile__image" width="40" />
|
||||
<h3 className="profile__h3">{user.name}</h3>
|
||||
<hr />
|
||||
<dl className="profile__dl">
|
||||
<dt>Name:</dt>
|
||||
<dd>{user.name}</dd>
|
||||
<dt>Email:</dt>
|
||||
<dd>{user.email}</dd>
|
||||
<dt className="m-b-5">Groups:</dt>
|
||||
<dd>{isLoadingGroups ? "Loading..." : <UserGroups groups={groups} />}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReadOnlyUserProfile.propTypes = {
|
||||
user: UserProfile.isRequired,
|
||||
};
|
@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import renderer from "react-test-renderer";
|
||||
import Group from "@/services/group";
|
||||
import UserShow from "./UserShow";
|
||||
import ReadOnlyUserProfile from "./ReadOnlyUserProfile";
|
||||
|
||||
beforeEach(() => {
|
||||
Group.query = jest.fn().mockResolvedValue([]);
|
||||
@ -16,7 +16,7 @@ test("renders correctly", () => {
|
||||
profileImageUrl: "http://www.images.com/llama.jpg",
|
||||
};
|
||||
|
||||
const component = renderer.create(<UserShow user={user} />);
|
||||
const component = renderer.create(<ReadOnlyUserProfile user={user} />);
|
||||
const tree = component.toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
53
client/app/pages/users/components/ToggleUserForm.jsx
Normal file
53
client/app/pages/users/components/ToggleUserForm.jsx
Normal file
@ -0,0 +1,53 @@
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Button from "antd/lib/button";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import { UserProfile } from "@/components/proptypes";
|
||||
import { currentUser } from "@/services/auth";
|
||||
import User from "@/services/user";
|
||||
|
||||
export default function ToggleUserForm(props) {
|
||||
const { user, onChange } = props;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
const toggleUser = useCallback(() => {
|
||||
const action = user.isDisabled ? User.enableUser : User.disableUser;
|
||||
setLoading(true);
|
||||
action(user)
|
||||
.then(data => {
|
||||
if (data) {
|
||||
onChangeRef.current(User.convertUserInfo(data));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [user]);
|
||||
|
||||
if (!currentUser.isAdmin || user.id === currentUser.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buttonProps = {
|
||||
type: user.isDisabled ? "primary" : "danger",
|
||||
children: user.isDisabled ? "Enable User" : "Disable User",
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicComponent name="UserProfile.ToggleUserForm">
|
||||
<Button className="w-100 m-t-10" onClick={toggleUser} loading={loading} {...buttonProps} />
|
||||
</DynamicComponent>
|
||||
);
|
||||
}
|
||||
|
||||
ToggleUserForm.propTypes = {
|
||||
user: UserProfile.isRequired,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
ToggleUserForm.defaultProps = {
|
||||
onChange: () => {},
|
||||
};
|
29
client/app/pages/users/components/UserGroups.jsx
Normal file
29
client/app/pages/users/components/UserGroups.jsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { map } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Tag from "antd/lib/tag";
|
||||
|
||||
export default function UserGroups({ groups, ...props }) {
|
||||
return (
|
||||
<div {...props}>
|
||||
{map(groups, group => (
|
||||
<Tag className="m-b-5 m-r-5" key={group.id}>
|
||||
<a href={`groups/${group.id}`}>{group.name}</a>
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
UserGroups.propTypes = {
|
||||
groups: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string,
|
||||
})
|
||||
),
|
||||
};
|
||||
|
||||
UserGroups.defaultProps = {
|
||||
groups: [],
|
||||
};
|
94
client/app/pages/users/components/UserInfoForm.jsx
Normal file
94
client/app/pages/users/components/UserInfoForm.jsx
Normal file
@ -0,0 +1,94 @@
|
||||
import { get, map } from "lodash";
|
||||
import React, { useRef, useMemo, useCallback } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { UserProfile } from "@/components/proptypes";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import DynamicForm from "@/components/dynamic-form/DynamicForm";
|
||||
|
||||
import User from "@/services/user";
|
||||
import { currentUser } from "@/services/auth";
|
||||
|
||||
import UserGroups from "./UserGroups";
|
||||
import useUserGroups from "../hooks/useUserGroups";
|
||||
|
||||
export default function UserInfoForm(props) {
|
||||
const { user, onChange } = props;
|
||||
|
||||
const { groups, allGroups, isLoading: isLoadingGroups } = useUserGroups(user);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
const saveUser = useCallback(
|
||||
(values, successCallback, errorCallback) => {
|
||||
const data = {
|
||||
...values,
|
||||
id: user.id,
|
||||
};
|
||||
|
||||
User.save(data)
|
||||
.then(user => {
|
||||
successCallback("Saved.");
|
||||
onChangeRef.current(User.convertUserInfo(user));
|
||||
})
|
||||
.catch(error => {
|
||||
errorCallback(get(error, "response.data.message", "Failed saving."));
|
||||
});
|
||||
},
|
||||
[user]
|
||||
);
|
||||
|
||||
const formFields = useMemo(
|
||||
() =>
|
||||
map(
|
||||
[
|
||||
{
|
||||
name: "name",
|
||||
title: "Name",
|
||||
type: "text",
|
||||
initialValue: user.name,
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
title: "Email",
|
||||
type: "email",
|
||||
initialValue: user.email,
|
||||
},
|
||||
!user.isDisabled && currentUser.id !== user.id
|
||||
? {
|
||||
name: "group_ids",
|
||||
title: "Groups",
|
||||
type: "select",
|
||||
mode: "multiple",
|
||||
options: map(allGroups, group => ({ name: group.name, value: group.id })),
|
||||
initialValue: map(groups, group => group.id),
|
||||
loading: isLoadingGroups,
|
||||
placeholder: isLoadingGroups ? "Loading..." : "",
|
||||
}
|
||||
: {
|
||||
name: "group_ids",
|
||||
title: "Groups",
|
||||
type: "content",
|
||||
content: isLoadingGroups ? "Loading..." : <UserGroups data-test="Groups" groups={groups} />,
|
||||
},
|
||||
],
|
||||
field => ({ readOnly: user.isDisabled, required: true, ...field })
|
||||
),
|
||||
[user, groups, allGroups, isLoadingGroups]
|
||||
);
|
||||
|
||||
return (
|
||||
<DynamicComponent name="UserProfile.UserInfoForm" {...props}>
|
||||
<DynamicForm fields={formFields} onSubmit={saveUser} hideSubmitButton={user.isDisabled} />
|
||||
</DynamicComponent>
|
||||
);
|
||||
}
|
||||
|
||||
UserInfoForm.propTypes = {
|
||||
user: UserProfile.isRequired,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
UserInfoForm.defaultProps = {
|
||||
onChange: () => {},
|
||||
};
|
@ -31,7 +31,9 @@ exports[`renders correctly 1`] = `
|
||||
<dd>
|
||||
john@doe.com
|
||||
</dd>
|
||||
<dt>
|
||||
<dt
|
||||
className="m-b-5"
|
||||
>
|
||||
Groups:
|
||||
</dt>
|
||||
<dd>
|
22
client/app/pages/users/hooks/useUserGroups.js
Normal file
22
client/app/pages/users/hooks/useUserGroups.js
Normal file
@ -0,0 +1,22 @@
|
||||
import { filter, includes, isArray } from "lodash";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Group from "@/services/group";
|
||||
|
||||
export default function useUserGroups(user) {
|
||||
const [allGroups, setAllGroups] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const groups = useMemo(() => filter(allGroups, group => includes(user.groupIds, group.id)), [allGroups, user]);
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
Group.query().then(groups => {
|
||||
if (!isCancelled) {
|
||||
setAllGroups(isArray(groups) ? groups : []);
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return useMemo(() => ({ groups, allGroups, isLoading }), [groups, allGroups, isLoading]);
|
||||
}
|
Loading…
Reference in New Issue
Block a user