diff --git a/frontend/config/webpack.config.dev.js b/frontend/config/webpack.config.dev.js index f4b090b052..b15617522c 100644 --- a/frontend/config/webpack.config.dev.js +++ b/frontend/config/webpack.config.dev.js @@ -194,6 +194,11 @@ module.exports = { require.resolve('react-dev-utils/webpackHotDevClient'), paths.appSrc + "/view-file-unknown.js", ], + settings: [ + require.resolve('./polyfills'), + require.resolve('react-dev-utils/webpackHotDevClient'), + paths.appSrc + "/settings.js", + ], orgAdmin: [ require.resolve('./polyfills'), require.resolve('react-dev-utils/webpackHotDevClient'), diff --git a/frontend/config/webpack.config.prod.js b/frontend/config/webpack.config.prod.js index 6fbe09b931..a41ff61334 100644 --- a/frontend/config/webpack.config.prod.js +++ b/frontend/config/webpack.config.prod.js @@ -87,6 +87,7 @@ module.exports = { viewFileSVG: [require.resolve('./polyfills'), paths.appSrc + "/view-file-svg.js"], viewFileAudio: [require.resolve('./polyfills'), paths.appSrc + "/view-file-audio.js"], viewFileUnknown: [require.resolve('./polyfills'), paths.appSrc + "/view-file-unknown.js"], + settings: [require.resolve('./polyfills'), paths.appSrc + "/settings.js"], orgAdmin: [require.resolve('./polyfills'), paths.appSrc + "/pages/org-admin"], sysAdmin: [require.resolve('./polyfills'), paths.appSrc + "/pages/sys-admin"], viewDataGrid: [require.resolve('./polyfills'), paths.appSrc + "/view-file-ctable.js"], diff --git a/frontend/src/components/dialog/confirm-delete-account.js b/frontend/src/components/dialog/confirm-delete-account.js new file mode 100644 index 0000000000..e65dd24727 --- /dev/null +++ b/frontend/src/components/dialog/confirm-delete-account.js @@ -0,0 +1,45 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from 'reactstrap'; +import { gettext } from '../../utils/constants'; + +const propTypes = { + formActionURL: PropTypes.string.isRequired, + csrfToken: PropTypes.string.isRequired, + toggle: PropTypes.func.isRequired +}; + +class ConfirmDeleteAccount extends Component { + + constructor(props) { + super(props); + this.form = React.createRef(); + } + + action = () => { + this.form.current.submit(); + } + + render() { + const {formActionURL, csrfToken, toggle} = this.props; + return ( + + {gettext('Delete Account')} + +

{gettext('Really want to delete your account?')}

+
+ +
+
+ + + + +
+ ); + } +} + +ConfirmDeleteAccount.propTypes = propTypes; + +export default ConfirmDeleteAccount; diff --git a/frontend/src/components/dialog/confirm-disconnect-wechat.js b/frontend/src/components/dialog/confirm-disconnect-wechat.js new file mode 100644 index 0000000000..f04a3b27e3 --- /dev/null +++ b/frontend/src/components/dialog/confirm-disconnect-wechat.js @@ -0,0 +1,45 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from 'reactstrap'; +import { gettext } from '../../utils/constants'; + +const propTypes = { + formActionURL: PropTypes.string.isRequired, + csrfToken: PropTypes.string.isRequired, + toggle: PropTypes.func.isRequired +}; + +class ConfirmDisconnectWechat extends Component { + + constructor(props) { + super(props); + this.form = React.createRef(); + } + + disconnect = () => { + this.form.current.submit(); + } + + render() { + const {formActionURL, csrfToken, toggle} = this.props; + return ( + + {gettext('Disconnect')} + +

{gettext('Are you sure you want to disconnect?')}

+
+ +
+
+ + + + +
+ ); + } +} + +ConfirmDisconnectWechat.propTypes = propTypes; + +export default ConfirmDisconnectWechat; diff --git a/frontend/src/components/user-settings/delete-account.js b/frontend/src/components/user-settings/delete-account.js new file mode 100644 index 0000000000..db7e2ed808 --- /dev/null +++ b/frontend/src/components/user-settings/delete-account.js @@ -0,0 +1,54 @@ +import React from 'react'; +import { gettext, siteRoot } from '../../utils/constants'; +import ModalPortal from '../modal-portal'; +import ConfirmDeleteAccount from '../dialog/confirm-delete-account'; + +const { + csrfToken +} = window.app.pageOptions; + +class DeleteAccount extends React.Component { + + constructor(props) { + super(props); + this.state = { + isConfirmDialogOpen: false + }; + } + + confirmDelete = (e) => { + e.preventDefault(); + this.setState({ + isConfirmDialogOpen: true + }); + } + + toggleDialog = () => { + this.setState({ + isConfirmDialogOpen: !this.state.isConfirmDialogOpen + }); + } + + render() { + return ( + +
+

{gettext('Delete Account')}

+

{gettext('This operation will not be reverted. Please think twice!')}

+ +
+ {this.state.isConfirmDialogOpen && ( + + + + )} +
+ ); + } +} + +export default DeleteAccount; diff --git a/frontend/src/components/user-settings/email-notice.js b/frontend/src/components/user-settings/email-notice.js new file mode 100644 index 0000000000..8c9701aa16 --- /dev/null +++ b/frontend/src/components/user-settings/email-notice.js @@ -0,0 +1,78 @@ +import React from 'react'; +import { gettext } from '../../utils/constants'; +import { seafileAPI } from '../../utils/seafile-api'; +import toaster from '../toast'; + +const { + initialEmailNotificationInterval +} = window.app.pageOptions; + +class EmailNotice extends React.Component { + + constructor(props) { + super(props); + + // interval: in seconds + this.intervalOptions = [ + {interval: 0, text: gettext('Don\'t send emails')}, + {interval: 3600, text: gettext('Per hour')}, + {interval: 14400, text: gettext('Per 4 hours')}, + {interval: 86400, text: gettext('Per day')}, + {interval: 604800, text: gettext('Per week')} + ]; + + this.state = { + currentInterval: initialEmailNotificationInterval + }; + } + + inputChange = (e) => { + if (e.target.checked) { + this.setState({ + currentInterval: e.target.value + }); + } + } + + formSubmit = (e) => { + e.preventDefault(); + const { currentInterval } = this.state; + if (currentInterval == initialEmailNotificationInterval) { + return; + } + seafileAPI.updateEmailNotificationInterval(this.state.currentInterval).then((res) => { + toaster.success(gettext('Success')); + }).catch((error) => { + let errorMsg = ''; + if (error.response) { + errorMsg = error.response.data.error_msg || gettext('Error'); + } else { + errorMsg = gettext('Please check the network.'); + } + toaster.danger(errorMsg); + }); + } + + render() { + const { currentInterval } = this.state; + return ( +
+

{gettext('Email Notification of File Changes')}

+
+ {this.intervalOptions.map((item, index) => { + return ( + + + +
+
+ ); + })} + +
+
+ ); + } +} + +export default EmailNotice; diff --git a/frontend/src/components/user-settings/language-setting.js b/frontend/src/components/user-settings/language-setting.js new file mode 100644 index 0000000000..0fcb543bf8 --- /dev/null +++ b/frontend/src/components/user-settings/language-setting.js @@ -0,0 +1,49 @@ +import React from 'react'; +import { Dropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'reactstrap'; +import { gettext, siteRoot } from '../../utils/constants'; + +const { + currentLang, langList +} = window.app.pageOptions; + +class LanguageSetting extends React.Component { + + constructor(props) { + super(props); + this.state = { + dropdownOpen: false, + }; + } + + toggle = () => { + this.setState({ + dropdownOpen: !this.state.dropdownOpen + }); + } + + render() { + return ( +
+

{gettext('Language Setting')}

+ + + {currentLang} + + + {langList.map((item, index) => { + return ( + + + {item.langName} + + + ); + })} + + +
+ ); + } +} + +export default LanguageSetting; diff --git a/frontend/src/components/user-settings/list-in-address-book.js b/frontend/src/components/user-settings/list-in-address-book.js new file mode 100644 index 0000000000..b81ab43730 --- /dev/null +++ b/frontend/src/components/user-settings/list-in-address-book.js @@ -0,0 +1,39 @@ +import React from 'react'; +import { gettext } from '../../utils/constants'; + +class ListInAddressBook extends React.Component { + + constructor(props) { + super(props); + const { list_in_address_book } = this.props.userInfo; + this.state = { + inputChecked: list_in_address_book + }; + } + + handleInputChange = (e) => { + const checked = e.target.checked; + this.setState({ + inputChecked: checked + }); + this.props.updateUserInfo({ + list_in_address_book: checked.toString() + }); + } + + render() { + const { inputChecked } = this.state; + + return ( +
+

{gettext('Global Address Book')}

+
+ + +
+
+ ); + } +} + +export default ListInAddressBook; diff --git a/frontend/src/components/user-settings/side-nav.js b/frontend/src/components/user-settings/side-nav.js new file mode 100644 index 0000000000..ca62462922 --- /dev/null +++ b/frontend/src/components/user-settings/side-nav.js @@ -0,0 +1,50 @@ +import React from 'react'; +import { isPro, gettext } from '../../utils/constants'; + +const { + canUpdatePassword, + enableAddressBook, + enableWebdavSecret, + twoFactorAuthEnabled, + enableWechatWork, + enableDeleteAccount +} = window.app.pageOptions; + +class SideNav extends React.Component { + + constructor(props) { + super(props); + } + + render() { + return ( + + ); + } +} + +export default SideNav; diff --git a/frontend/src/components/user-settings/social-login.js b/frontend/src/components/user-settings/social-login.js new file mode 100644 index 0000000000..565fc17c12 --- /dev/null +++ b/frontend/src/components/user-settings/social-login.js @@ -0,0 +1,60 @@ +import React from 'react'; +import { gettext, siteRoot } from '../../utils/constants'; +import ModalPortal from '../modal-portal'; +import ConfirmDisconnectWechat from '../dialog/confirm-disconnect-wechat'; + +const { + csrfToken, + langCode, + socialConnected, + socialNextPage +} = window.app.pageOptions; + +class SocialLogin extends React.Component { + + constructor(props) { + super(props); + this.state = { + isConfirmDialogOpen: false + }; + } + + confirmDisconnect = (e) => { + e.preventDefault(); + this.setState({ + isConfirmDialogOpen: true + }); + } + + toggleDialog = () => { + this.setState({ + isConfirmDialogOpen: !this.state.isConfirmDialogOpen + }); + } + + render() { + return ( + +
+

{gettext('Social Login')}

+

{langCode == 'zh-cn' ? '企业微信': 'WeChat Work'}

+ {socialConnected ? + {gettext('Disconnect')} : + {gettext('Connect')} + } +
+ {this.state.isConfirmDialogOpen && ( + + + + )} +
+ ); + } +} + +export default SocialLogin; diff --git a/frontend/src/components/user-settings/two-factor-auth.js b/frontend/src/components/user-settings/two-factor-auth.js new file mode 100644 index 0000000000..12fa123afd --- /dev/null +++ b/frontend/src/components/user-settings/two-factor-auth.js @@ -0,0 +1,52 @@ +import React from 'react'; +import { gettext, siteRoot } from '../../utils/constants'; + +const { + defaultDevice, + backupTokens +} = window.app.pageOptions; + +class TwoFactorAuthentication extends React.Component { + + constructor(props) { + super(props); + } + + renderEnabled = () => { + return ( + +

{gettext('Status: enabled')}

+ + {gettext('Disable Two-Factor Authentication')} +

+ {gettext('If you don\'t have any device with you, you can access your account using backup codes.')} + {backupTokens == 1 ? gettext('You have only one backup code remaining.') : + gettext('You have {num} backup codes remaining.').replace('{num}', backupTokens)} +

+ {gettext('Show Codes')} +
+ ); + } + + renderDisabled = () => { + return ( + +

{gettext('Two-factor authentication is not enabled for your account. Enable two-factor authentication for enhanced account security.')}

+ + {gettext('Enable Two-Factor Authentication')} +
+ ); + } + + render() { + return ( +
+

{gettext('Two-Factor Authentication')}

+ {defaultDevice ? this.renderEnabled() : this.renderDisabled()} +
+ ); + } +} + +export default TwoFactorAuthentication; diff --git a/frontend/src/components/user-settings/user-avatar-form.js b/frontend/src/components/user-settings/user-avatar-form.js new file mode 100644 index 0000000000..685b7e0abf --- /dev/null +++ b/frontend/src/components/user-settings/user-avatar-form.js @@ -0,0 +1,74 @@ +import React from 'react'; +import { gettext, siteRoot } from '../../utils/constants'; +import toaster from '../toast'; + +const { avatarURL, csrfToken } = window.app.pageOptions; + +class UserAvatarForm extends React.Component { + + constructor(props) { + super(props); + this.fileInput = React.createRef(); + this.form = React.createRef(); + this.state = { + avatarSrc: avatarURL + }; + } + + fileInputChange = () => { + + // no file selected + if (!this.fileInput.current.files.length) { + return; + } + + const file = this.fileInput.current.files[0]; + const fileName = file.name; + + // no file extension + if (fileName.lastIndexOf('.') == -1) { + toaster.danger(gettext('Please choose an image file.'), { + duration: 5 + }); + return false; + } + + const fileExt = fileName.substr((fileName.lastIndexOf('.') + 1)).toLowerCase(); + const allowedExt = ['jpg','jpeg', 'png', 'gif']; + if (allowedExt.indexOf(fileExt) == -1) { + const errorMsg = gettext('File extensions can only be {placeholder}.') + .replace('{placeholder}', allowedExt.join(', ')); + toaster.danger(errorMsg, {duration: 5}); + return false; + } + + // file size should be less than 1MB + if (file.size > 1024*1024) { + const errorMsg = gettext('The file is too large. Allowed maximum size is 1MB.'); + toaster.danger(errorMsg, {duration: 5}); + return false; + } + + this.form.current.submit(); + } + + openFileInput = () => { + this.fileInput.current.click(); + } + + render() { + return ( +
+ + +
+ + + +
+
+ ); + } +} + +export default UserAvatarForm; diff --git a/frontend/src/components/user-settings/user-basic-info-form.js b/frontend/src/components/user-settings/user-basic-info-form.js new file mode 100644 index 0000000000..0cd094981a --- /dev/null +++ b/frontend/src/components/user-settings/user-basic-info-form.js @@ -0,0 +1,107 @@ +import React from 'react'; +import { gettext } from '../../utils/constants'; + +const { + nameLabel, + enableUpdateUserInfo, + enableUserSetContactEmail +} = window.app.pageOptions; + +class UserBasicInfoForm extends React.Component { + + constructor(props) { + super(props); + const { + contact_email, + login_id, + name, + telephone + } = this.props.userInfo; + this.state = { + contactEmail: contact_email, + loginID: login_id, + name: name, + telephone: telephone + }; + } + + handleNameInputChange = (e) => { + this.setState({ + name: e.target.value + }); + } + + handleContactEmailInputChange = (e) => { + this.setState({ + contactEmail: e.target.value + }); + } + + handleTelephoneInputChange = (e) => { + this.setState({ + telephone: e.target.value + }); + } + + handleSubmit = (e) => { + e.preventDefault(); + this.props.updateUserInfo({ + name: this.state.name, + contact_email: this.state.contactEmail, + telephone: this.state.telephone + }); + } + + render() { + const { + contactEmail, + loginID, + name, + telephone + } = this.state; + + return ( +
+ +
+ +
+ +
+
+ + {loginID && ( +
+ +
+ +
+

{gettext('You can use this field at login.')}

+
+ )} + + {(contactEmail || enableUserSetContactEmail) && ( +
+ +
+ +
+

{gettext('Your notifications will be sent to this email.')}

+
+ )} + + {(telephone != undefined) && ( +
+ +
+ +
+
+ )} + +
+ ); + } +} + +export default UserBasicInfoForm; diff --git a/frontend/src/components/user-settings/webdav-password.js b/frontend/src/components/user-settings/webdav-password.js new file mode 100644 index 0000000000..5415fe4b9a --- /dev/null +++ b/frontend/src/components/user-settings/webdav-password.js @@ -0,0 +1,92 @@ +import React from 'react'; +import { Button, Input, InputGroup, InputGroupAddon } from 'reactstrap'; +import { gettext } from '../../utils/constants'; +import { seafileAPI } from '../../utils/seafile-api'; +import toaster from '../toast'; + +const { webdavPasswd } = window.app.pageOptions; + +class WebdavPassword extends React.Component { + + constructor(props) { + super(props); + this.state = { + isPasswordVisible: false, + password: webdavPasswd + }; + } + + handleInputChange = (e) => { + let passwd = e.target.value.trim(); + this.setState({password: passwd}); + } + + togglePasswordVisible = () => { + this.setState({ + isPasswordVisible: !this.state.isPasswordVisible + }); + } + + generatePassword = () => { + let randomPassword = ''; + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (let i = 0; i < 8; i++) { + randomPassword += possible.charAt(Math.floor(Math.random() * possible.length)); + } + this.setState({ + password: randomPassword, + isPasswordVisible: true + }); + } + + updatePassword = (password) => { + seafileAPI.updateWebdavSecret(password).then((res) => { + toaster.success(gettext('Success')); + }).catch((error) => { + let errorMsg = ''; + if (error.response) { + if (error.response.data && error.response.data['error_msg']) { + errorMsg = error.response.data['error_msg']; + } else { + errorMsg = gettext('Error'); + } + } else { + errorMsg = gettext('Please check the network.'); + } + toaster.danger(errorMsg); + }); + } + + handleUpdate = () => { + this.updatePassword(this.state.password); + } + + handleDelete = () => { + this.setState({ + password: '' + }); + this.updatePassword(''); + } + + render() { + return ( +
+

{gettext('WebDav Password')}

+ +
+ + + + + + + +
+ + +
+ ); + } +} + +export default WebdavPassword; diff --git a/frontend/src/css/user-settings.css b/frontend/src/css/user-settings.css new file mode 100644 index 0000000000..124f92e0ad --- /dev/null +++ b/frontend/src/css/user-settings.css @@ -0,0 +1,48 @@ +body { + overflow: hidden; +} +#wrapper { + height: 100%; +} +.top-header { + background: #f4f4f7; + border-bottom: 1px solid #e8e8e8; + padding: 8px 16px 4px; + height: 53px; + flex-shrink: 0; +} + +.side-panel { + flex: 0 0 22%; + padding: 20px; + border-right: 1px solid #eee; +} +.main-panel { + flex: 1 0 78%; +} +.heading { + padding: 8px 16px; + background: #f9f9f9; + font-size: 1rem; + color: #322; + font-weight: normal; + line-height: 1.5; + margin:0; +} +.content { + padding: 0rem 1rem; + overflow: auto; +} +.setting-item { + margin: 1em 0 2em; +} +.setting-item-heading { + font-size: 1rem; + font-weight: normal; + padding-bottom: 0.2em; + border-bottom: 1px solid #ddd; + margin-bottom: 0.7em; +} +.user-avatar { + border-radius: 3px; +} diff --git a/frontend/src/settings.js b/frontend/src/settings.js new file mode 100644 index 0000000000..a64b426483 --- /dev/null +++ b/frontend/src/settings.js @@ -0,0 +1,132 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { navigate } from '@reach/router'; +import { Utils } from './utils/utils'; +import { isPro, gettext, siteRoot, mediaUrl, logoPath, logoWidth, logoHeight, siteTitle } from './utils/constants'; +import { seafileAPI } from './utils/seafile-api'; +import toaster from './components/toast'; +import CommonToolbar from './components/toolbar/common-toolbar'; +import SideNav from './components/user-settings/side-nav'; +import UserAvatarForm from './components/user-settings/user-avatar-form'; +import UserBasicInfoForm from './components/user-settings/user-basic-info-form'; +import WebdavPassword from './components/user-settings/webdav-password'; +import LanguageSetting from './components/user-settings/language-setting'; +import ListInAddressBook from './components/user-settings/list-in-address-book'; +import EmailNotice from './components/user-settings/email-notice'; +import TwoFactorAuthentication from './components/user-settings/two-factor-auth'; +import SocialLogin from './components/user-settings/social-login'; +import DeleteAccount from './components/user-settings/delete-account'; + +import './css/toolbar.css'; +import './css/search.css'; + +import './css/user-settings.css'; + +const { + canUpdatePassword, passwordOperationText, + enableAddressBook, + enableWebdavSecret, + twoFactorAuthEnabled, + enableWechatWork, + enableDeleteAccount +} = window.app.pageOptions; + +class Settings extends React.Component { + + constructor(props) { + super(props); + this.state = { + }; + } + + componentDidMount() { + seafileAPI.getUserInfo().then((res) => { + this.setState({ + userInfo: res.data + }); + }).catch((error) => { + // do nothing + }); + } + + updateUserInfo = (data) => { + seafileAPI.updateUserInfo(data).then((res) => { + this.setState({ + userInfo: res.data + }); + toaster.success(gettext('Success')); + }).catch((error) => { + let errorMsg = ''; + if (error.response) { + if (error.response.data && error.response.data['error_msg']) { + errorMsg = error.response.data['error_msg']; + } else { + errorMsg = gettext('Error'); + } + } else { + errorMsg = gettext('Please check the network.'); + } + toaster.danger(errorMsg); + }); + } + + onSearchedClick = (selectedItem) => { + if (selectedItem.is_dir === true) { + let url = siteRoot + 'library/' + selectedItem.repo_id + '/' + selectedItem.repo_name + selectedItem.path; + navigate(url, {repalce: true}); + } else { + let url = siteRoot + 'lib/' + selectedItem.repo_id + '/file' + Utils.encodePath(selectedItem.path); + let newWindow = window.open('about:blank'); + newWindow.location.href = url; + } + } + + render() { + return ( + +
+
+ + logo + + +
+
+
+ +
+
+

{gettext('Settings')}

+
+
+

{gettext('Profile Setting')}

+ + {this.state.userInfo && } +
+ {canUpdatePassword && +
+

{gettext('Password')}

+ {passwordOperationText} +
+ } + {enableWebdavSecret && } + {enableAddressBook && this.state.userInfo && + } + + {isPro && } + {twoFactorAuthEnabled && } + {enableWechatWork && } + {enableDeleteAccount && } +
+
+
+
+
+ ); + } +} + +ReactDOM.render( + , + document.getElementById('wrapper') +); diff --git a/seahub/profile/templates/profile/set_profile_react.html b/seahub/profile/templates/profile/set_profile_react.html new file mode 100644 index 0000000000..d07d710c6c --- /dev/null +++ b/seahub/profile/templates/profile/set_profile_react.html @@ -0,0 +1,57 @@ +{% extends 'base_for_react.html' %} +{% load seahub_tags avatar_tags i18n %} +{% load render_bundle from webpack_loader %} + +{% block sub_title %}{% trans "Settings" %} - {% endblock %} + +{% block extra_style %} +{% render_bundle 'settings' 'css' %} +{% endblock %} + +{% block extra_script %} + +{% render_bundle 'settings' 'js' %} +{% endblock %} diff --git a/seahub/profile/views.py b/seahub/profile/views.py index 8fd44fda12..35e69f47e4 100644 --- a/seahub/profile/views.py +++ b/seahub/profile/views.py @@ -131,7 +131,9 @@ def edit_profile(request): resp_dict['default_device'] = default_device(request.user) resp_dict['backup_tokens'] = backup_tokens - return render(request, 'profile/set_profile.html', resp_dict) + #template = 'profile/set_profile.html' + template = 'profile/set_profile_react.html' + return render(request, template, resp_dict) @login_required def user_profile(request, username):