diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2928b5d2a0..396d706ec2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16300,9 +16300,9 @@ } }, "seafile-js": { - "version": "0.2.109", - "resolved": "https://registry.npmjs.org/seafile-js/-/seafile-js-0.2.109.tgz", - "integrity": "sha512-qcFKdC8hA1G3Mbe4PpJ/ircBD9Miaduur2qIPkjyYIzJ8MvownGxku+r13UsNh6fLp3oCs2GqzuY4UDO5y1gcw==", + "version": "0.2.111", + "resolved": "https://registry.npmjs.org/seafile-js/-/seafile-js-0.2.111.tgz", + "integrity": "sha512-PsaU3VUledLrs8guKRm0nJ99s0g3bgQmpvIb00b1ESjdsgRIcyvQsgCrG3fTJQYn+fMdGJaSP1tiDbuOZLoqrA==", "requires": { "axios": "^0.18.0", "form-data": "^2.3.2", diff --git a/frontend/package.json b/frontend/package.json index 2f8a4d6ca6..0758afb411 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,7 +38,7 @@ "react-responsive": "^6.1.2", "react-select": "^2.4.1", "reactstrap": "^6.4.0", - "seafile-js": "^0.2.109", + "seafile-js": "^0.2.111", "socket.io-client": "^2.2.0", "sw-precache-webpack-plugin": "0.11.4", "unified": "^7.0.0", diff --git a/frontend/src/components/cur-dir-path/dir-path.js b/frontend/src/components/cur-dir-path/dir-path.js index fbb7648c5f..a0030e73a7 100644 --- a/frontend/src/components/cur-dir-path/dir-path.js +++ b/frontend/src/components/cur-dir-path/dir-path.js @@ -24,7 +24,16 @@ class DirPath extends React.Component { this.props.onPathClick(path); } - onTabNavClick = (tabName, id) => { + onTabNavClick = (e, tabName, id) => { + if (window.uploader && + window.uploader.isUploadProgressDialogShow && + window.uploader.totalProgress !== 100) { + if (!window.confirm(gettext('A file is being uploaded. Are you sure you want to leave this page?'))) { + e.preventDefault(); + return false; + } + window.uploader.isUploadProgressDialogShow = false; + } this.props.onTabNavClick(tabName, id); } @@ -72,20 +81,20 @@ class DirPath extends React.Component { {this.props.pathPrefix && this.props.pathPrefix.map((item, index) => { return ( - this.onTabNavClick(item.name, item.id)}>{gettext(item.showName)} + this.onTabNavClick(e, item.name, item.id)}>{gettext(item.showName)} / ); })} {this.props.pathPrefix && this.props.pathPrefix.length === 0 && ( - this.onTabNavClick('my-libs')}>{gettext('Libraries')} + this.onTabNavClick(e, 'my-libs')}>{gettext('Libraries')} / )} {!this.props.pathPrefix && ( - this.onTabNavClick('my-libs')}>{gettext('Libraries')} + this.onTabNavClick(e, 'my-libs')}>{gettext('Libraries')} / )} diff --git a/frontend/src/components/dialog/internal-link.js b/frontend/src/components/dialog/internal-link.js index b6e1f675a9..075ae4b1f2 100644 --- a/frontend/src/components/dialog/internal-link.js +++ b/frontend/src/components/dialog/internal-link.js @@ -10,6 +10,7 @@ import { Utils } from '../../utils/utils'; const propTypes = { path: PropTypes.string.isRequired, repoID: PropTypes.string.isRequired, + direntType: PropTypes.string }; class InternalLink extends React.Component { @@ -21,9 +22,8 @@ class InternalLink extends React.Component { } componentDidMount() { - let repoID = this.props.repoID; - let path = this.props.path; - seafileAPI.getInternalLink(repoID, path).then(res => { + let { repoID, path, direntType } = this.props; + seafileAPI.getInternalLink(repoID, path, direntType).then(res => { this.setState({ smartLink: res.data.smart_link }); diff --git a/frontend/src/components/dialog/invitation-revoke-dialog.js b/frontend/src/components/dialog/invitation-revoke-dialog.js new file mode 100644 index 0000000000..c703c154b1 --- /dev/null +++ b/frontend/src/components/dialog/invitation-revoke-dialog.js @@ -0,0 +1,66 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { gettext } from '../../utils/constants'; +import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap'; +import { seafileAPI } from '../../utils/seafile-api'; +import { Utils } from '../../utils/utils'; +import Loading from '../loading'; +import toaster from '../toast'; + +const propTypes = { + accepter: PropTypes.string.isRequired, + token: PropTypes.string.isRequired, + revokeInvitation: PropTypes.func.isRequired, + toggleDialog: PropTypes.func.isRequired +}; + +class InvitationRevokeDialog extends React.Component { + + constructor(props) { + super(props); + this.state = { + isSubmitting: false + }; + } + + onRevokeInvitation = () => { + this.setState({ + isSubmitting: true, + }); + + seafileAPI.revokeInvitation(this.props.token).then((res) => { + this.props.revokeInvitation(); + this.props.toggleDialog(); + const msg = gettext('Successfully revoked access of user {placeholder}.').replace('{placeholder}', this.props.accepter); + toaster.success(msg); + }).catch((error) => { + const errorMsg = Utils.getErrorMsg(error); + toaster.danger(errorMsg); + this.props.toggleDialog(); + }); + }; + + render() { + const { accepter, toggleDialog } = this.props; + const { isSubmitting } = this.state; + const email = '' + Utils.HTMLescape(this.props.accepter) + ''; + const content = gettext('Are you sure to revoke access of user {placeholder} ?').replace('{placeholder}', email); + + return ( + + {gettext('Revoke Access')} + +

+
+ + + + +
+ ); + } +} + +InvitationRevokeDialog.propTypes = propTypes; + +export default InvitationRevokeDialog; diff --git a/frontend/src/components/dialog/invite-people-dialog.js b/frontend/src/components/dialog/invite-people-dialog.js index d8e54fa4e9..0c6deb4bcc 100644 --- a/frontend/src/components/dialog/invite-people-dialog.js +++ b/frontend/src/components/dialog/invite-people-dialog.js @@ -1,9 +1,16 @@ import React from 'react'; import PropTypes from 'prop-types'; -import {gettext} from '../../utils/constants'; -import {seafileAPI} from '../../utils/seafile-api'; -import {Modal, ModalHeader, ModalBody, ModalFooter, Input, Button} from 'reactstrap'; +import { Utils } from '../../utils/utils'; +import { gettext } from '../../utils/constants'; +import { seafileAPI } from '../../utils/seafile-api'; +import { Modal, ModalHeader, ModalBody, ModalFooter, Input, Button } from 'reactstrap'; import toaster from '../toast'; +import Loading from '../loading'; + +const InvitePeopleDialogPropTypes = { + onInvitePeople: PropTypes.func.isRequired, + toggleDialog: PropTypes.func.isRequired, +}; class InvitePeopleDialog extends React.Component { @@ -12,11 +19,12 @@ class InvitePeopleDialog extends React.Component { this.state = { emails: '', errorMsg: '', + isSubmitting: false }; } - handleEmailsChange = (event) => { - let emails = event.target.value; + handleInputChange = (e) => { + let emails = e.target.value; this.setState({ emails: emails }); @@ -36,91 +44,88 @@ class InvitePeopleDialog extends React.Component { handleSubmitInvite = () => { let emails = this.state.emails.trim(); + if (!emails) { + this.setState({ + errorMsg: gettext('It is required.') + }); + return false; + } + let emailsArray = []; - emails = emails.split(','); - for (let i = 0; i < emails.length; i++) { + emails = emails.split(','); + for (let i = 0, len = emails.length; i < len; i++) { let email = emails[i].trim(); if (email) { emailsArray.push(email); } } - if (emailsArray.length) { - seafileAPI.invitePeople(emailsArray).then((res) => { - this.setState({ - emails: '', - }); - this.props.toggleInvitePeopleDialog(); - // success messages - let successMsg = ''; - if (res.data.success.length === 1) { - successMsg = gettext('Successfully invited %(email).') - .replace('%(email)', res.data.success[0].accepter); - } else if(res.data.success.length > 1) { - successMsg = gettext('Successfully invited %(email) and %(num) other people.') - .replace('%(email)', res.data.success[0].accepter) - .replace('%(num)', res.data.success.length - 1); - } - if (successMsg) { - toaster.success(successMsg, {duration: 2}); - this.props.onInvitePeople(res.data.success); - } - // failed messages - if (res.data.failed.length) { - for (let i = 0; i< res.data.failed.length; i++){ - let failedMsg = res.data.failed[i].email + ': ' + res.data.failed[i].error_msg; - toaster.danger(failedMsg, {duration: 3});} - } - }).catch((error) => { - this.props.toggleInvitePeopleDialog(); - if (error.response){ - toaster.danger(error.response.data.detail || gettext('Error'), {duration: 3}); - } else { - toaster.danger(gettext('Please check the network.'), {duration: 3}); - } + + if (!emailsArray.length) { + this.setState({ + errorMsg: gettext('Email is invalid.') }); - } else { - if (this.state.emails){ - this.setState({ - errorMsg: gettext('Email is invalid.') - }); - } else { - this.setState({ - errorMsg: gettext('It is required.') - }); - } + return false; } + + this.setState({ + isSubmitting: true + }); + seafileAPI.invitePeople(emailsArray).then((res) => { + this.props.toggleDialog(); + const success = res.data.success; + if (success.length) { + let successMsg = ''; + if (success.length == 1) { + successMsg = gettext('Successfully invited %(email).') + .replace('%(email)', success[0].accepter); + } else { + successMsg = gettext('Successfully invited %(email) and %(num) other people.') + .replace('%(email)', success[0].accepter) + .replace('%(num)', success.length - 1); + } + toaster.success(successMsg); + this.props.onInvitePeople(success); + } + const failed = res.data.failed; + if (failed.length) { + for (let i = 0, len = failed.length; i < len; i++) { + let failedMsg = failed[i].email + ': ' + failed[i].error_msg; + toaster.danger(failedMsg); + } + } + }).catch((error) => { + const errorMsg = Utils.getErrorMsg(error); + toaster.danger(errorMsg); + this.props.toggleDialog(); + }); } render() { + const { isSubmitting } = this.state; return ( - - {gettext('Invite People')} + + {gettext('Invite People')} - {this.state.errorMsg} +

{this.state.errorMsg}

- - + +
); } } -const InvitePeopleDialogPropTypes = { - toggleInvitePeopleDialog: PropTypes.func.isRequired, - isInvitePeopleDialogOpen: PropTypes.bool.isRequired, - onInvitePeople: PropTypes.func.isRequired, -}; - InvitePeopleDialog.propTypes = InvitePeopleDialogPropTypes; -export default InvitePeopleDialog; \ No newline at end of file +export default InvitePeopleDialog; diff --git a/frontend/src/components/dialog/list-taggedfiles-dialog.js b/frontend/src/components/dialog/list-taggedfiles-dialog.js index 4866900866..4f2fac1438 100644 --- a/frontend/src/components/dialog/list-taggedfiles-dialog.js +++ b/frontend/src/components/dialog/list-taggedfiles-dialog.js @@ -38,7 +38,7 @@ class ListTaggedFilesDialog extends React.Component { seafileAPI.deleteFileTag(repoID, fileTagID).then(res => { this.getTaggedFiles(); this.props.updateUsedRepoTags(); - if (this.props.onFileTagChanged) this.onFileTagChanged(taggedFile); + if ((this.props.onFileTagChanged) && !taggedFile.file_deleted) this.onFileTagChanged(taggedFile); }).catch(error => { let errMessage = Utils.getErrorMsg(error); toaster.danger(errMessage); diff --git a/frontend/src/components/dialog/org-add-member-dialog.js b/frontend/src/components/dialog/org-add-member-dialog.js index 4ffcdbc9cc..133a730b64 100644 --- a/frontend/src/components/dialog/org-add-member-dialog.js +++ b/frontend/src/components/dialog/org-add-member-dialog.js @@ -34,7 +34,7 @@ class AddMemberDialog extends React.Component { const email = this.state.selectedOption.email; this.refs.orgSelect.clearSelect(); this.setState({ errMessage: [] }); - seafileAPI.orgAdminAddDepartGroupUser(orgID, this.props.groupID, email).then((res) => { + seafileAPI.orgAdminAddGroupMember(orgID, this.props.groupID, email).then((res) => { this.setState({ selectedOption: null }); if (res.data.failed.length > 0) { this.setState({ errMessage: res.data.failed[0].error_msg }); diff --git a/frontend/src/components/dialog/org-add-repo-dialog.js b/frontend/src/components/dialog/org-add-repo-dialog.js index 3f393dec01..ee9d9838f0 100644 --- a/frontend/src/components/dialog/org-add-repo-dialog.js +++ b/frontend/src/components/dialog/org-add-repo-dialog.js @@ -30,7 +30,7 @@ class AddRepoDialog extends React.Component { handleSubmit = () => { let isValid = this.validateName(); if (isValid) { - seafileAPI.orgAdminAddDepartGroupRepo(orgID, this.props.groupID, this.state.repoName.trim()).then((res) => { + seafileAPI.orgAdminAddDepartmentRepo(orgID, this.props.groupID, this.state.repoName.trim()).then((res) => { this.props.toggle(); this.props.onRepoChanged(); }).catch(error => { diff --git a/frontend/src/components/dialog/org-delete-member-dialog.js b/frontend/src/components/dialog/org-delete-member-dialog.js index 9e3cabc715..78c853bea6 100644 --- a/frontend/src/components/dialog/org-delete-member-dialog.js +++ b/frontend/src/components/dialog/org-delete-member-dialog.js @@ -21,7 +21,7 @@ class DeleteMemberDialog extends React.Component { deleteMember = () => { const userEmail = this.props.member.email; - seafileAPI.orgAdminDeleteDepartGroupUser(orgID, this.props.groupID, userEmail).then((res) => { + seafileAPI.orgAdminDeleteGroupMember(orgID, this.props.groupID, userEmail).then((res) => { if (res.data.success) { this.props.onMemberChanged(); this.props.toggle(); diff --git a/frontend/src/components/dialog/org-delete-repo-dialog.js b/frontend/src/components/dialog/org-delete-repo-dialog.js index 2d8fd93b78..5c958a3f85 100644 --- a/frontend/src/components/dialog/org-delete-repo-dialog.js +++ b/frontend/src/components/dialog/org-delete-repo-dialog.js @@ -13,7 +13,7 @@ class DeleteRepoDialog extends React.Component { } deleteRepo = () => { - seafileAPI.orgAdminDeleteDepartGroupRepo(orgID, this.props.groupID, this.props.repo.repo_id).then((res) => { + seafileAPI.orgAdminDeleteDepartmentRepo(orgID, this.props.groupID, this.props.repo.repo_id).then((res) => { if (res.data.success) { this.props.onRepoChanged(); this.props.toggle(); diff --git a/frontend/src/components/dialog/set-org-user-contact-email.js b/frontend/src/components/dialog/set-org-user-contact-email.js new file mode 100644 index 0000000000..c2ae23fbdd --- /dev/null +++ b/frontend/src/components/dialog/set-org-user-contact-email.js @@ -0,0 +1,75 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap'; +import { gettext } from '../../utils/constants'; +import { seafileAPI } from '../../utils/seafile-api'; +import { Utils } from '../../utils/utils'; + +const propTypes = { + orgID: PropTypes.string.isRequired, + email: PropTypes.string.isRequired, + contactEmail: PropTypes.string.isRequired, + updateContactEmail: PropTypes.func.isRequired, + toggleDialog: PropTypes.func.isRequired +}; + +class SetOrgUserContactEmail extends React.Component { + + constructor(props) { + super(props); + this.state = { + inputValue: this.props.contactEmail, + submitBtnDisabled: false + }; + } + + handleInputChange = (e) => { + this.setState({ + inputValue: e.target.value + }); + } + + formSubmit = () => { + const { orgID, email } = this.props; + const contactEmail = this.state.inputValue.trim(); + + this.setState({ + submitBtnDisabled: true + }); + + seafileAPI.setOrgUserContactEmail(orgID, email, contactEmail).then((res) => { + const newContactEmail = contactEmail ? res.data.contact_email : ''; + this.props.updateContactEmail(newContactEmail); + this.props.toggleDialog(); + }).catch((error) => { + let errorMsg = Utils.getErrorMsg(error); + this.setState({ + formErrorMsg: errorMsg, + submitBtnDisabled: false + }); + }); + } + + render() { + const { inputValue, formErrorMsg, submitBtnDisabled } = this.state; + return ( + + {gettext('Set user contact email')} + + + + {formErrorMsg &&

{formErrorMsg}

} +
+
+ + + + +
+ ); + } +} + +SetOrgUserContactEmail.propTypes = propTypes; + +export default SetOrgUserContactEmail; diff --git a/frontend/src/components/dialog/set-org-user-name.js b/frontend/src/components/dialog/set-org-user-name.js new file mode 100644 index 0000000000..cb58081414 --- /dev/null +++ b/frontend/src/components/dialog/set-org-user-name.js @@ -0,0 +1,77 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap'; +import { gettext } from '../../utils/constants'; +import { seafileAPI } from '../../utils/seafile-api'; +import { Utils } from '../../utils/utils'; + +const propTypes = { + orgID: PropTypes.string.isRequired, + email: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + updateName: PropTypes.func.isRequired, + toggleDialog: PropTypes.func.isRequired +}; + +class SetOrgUserName extends React.Component { + + constructor(props) { + super(props); + this.state = { + inputValue: this.props.name, + submitBtnDisabled: false + }; + } + + handleInputChange = (e) => { + this.setState({ + inputValue: e.target.value + }); + } + + formSubmit = () => { + const { orgID, email } = this.props; + const name = this.state.inputValue.trim(); + + this.setState({ + submitBtnDisabled: true + }); + + // when name is '', api returns the previous name + // but newName needs to be '' + seafileAPI.setOrgUserName(orgID, email, name).then((res) => { + const newName = name ? res.data.name : ''; + this.props.updateName(newName); + this.props.toggleDialog(); + }).catch((error) => { + let errorMsg = Utils.getErrorMsg(error); + this.setState({ + formErrorMsg: errorMsg, + submitBtnDisabled: false + }); + }); + } + + render() { + const { inputValue, formErrorMsg, submitBtnDisabled } = this.state; + return ( + + {gettext('Set user name')} + + + + {formErrorMsg &&

{formErrorMsg}

} +
+
+ + + + +
+ ); + } +} + +SetOrgUserName.propTypes = propTypes; + +export default SetOrgUserName; diff --git a/frontend/src/components/dialog/set-org-user-quota.js b/frontend/src/components/dialog/set-org-user-quota.js new file mode 100644 index 0000000000..6d34cc6948 --- /dev/null +++ b/frontend/src/components/dialog/set-org-user-quota.js @@ -0,0 +1,89 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Modal, ModalHeader, ModalBody, ModalFooter, InputGroup, InputGroupAddon, InputGroupText } from 'reactstrap'; +import { gettext } from '../../utils/constants'; +import { seafileAPI } from '../../utils/seafile-api'; +import { Utils } from '../../utils/utils'; + +const propTypes = { + orgID: PropTypes.string.isRequired, + email: PropTypes.string.isRequired, + quotaTotal: PropTypes.string.isRequired, + updateQuota: PropTypes.func.isRequired, + toggleDialog: PropTypes.func.isRequired +}; + +class SetOrgUserQuota extends React.Component { + + constructor(props) { + super(props); + const initialQuota = this.props.quotaTotal < 0 ? '' : + this.props.quotaTotal / (1000 * 1000); + this.state = { + inputValue: initialQuota, + submitBtnDisabled: false + }; + } + + handleInputChange = (e) => { + this.setState({ + inputValue: e.target.value + }); + } + + formSubmit = () => { + const { orgID, email } = this.props; + const quota = this.state.inputValue.trim(); + + if (!quota) { + this.setState({ + formErrorMsg: gettext('It is required.') + }); + return false; + } + + this.setState({ + submitBtnDisabled: true + }); + + seafileAPI.setOrgUserQuota(orgID, email, quota).then((res) => { + this.props.updateQuota(res.data.quota_total); + this.props.toggleDialog(); + }).catch((error) => { + let errorMsg = Utils.getErrorMsg(error); + this.setState({ + formErrorMsg: errorMsg, + submitBtnDisabled: false + }); + }); + } + + render() { + const { inputValue, formErrorMsg, submitBtnDisabled } = this.state; + return ( + + {gettext('Set user quota')} + + + + + + MB + + +

{gettext('Tip: 0 means default limit')}

+ {formErrorMsg &&

{formErrorMsg}

} +
+
+ + + + +
+ ); + } +} + +SetOrgUserQuota.propTypes = propTypes; + +export default SetOrgUserQuota; diff --git a/frontend/src/components/dialog/share-dialog.js b/frontend/src/components/dialog/share-dialog.js index 35c6195949..7dc8a423a4 100644 --- a/frontend/src/components/dialog/share-dialog.js +++ b/frontend/src/components/dialog/share-dialog.js @@ -76,7 +76,7 @@ class ShareDialog extends React.Component { } let activeTab = this.state.activeTab; - const {repoEncrypted, userPerm, enableDirPrivateShare} = this.props; + const {repoEncrypted, userPerm, enableDirPrivateShare, itemType} = this.props; const enableShareLink = !repoEncrypted && canGenerateShareLink; const enableUploadLink = !repoEncrypted && canGenerateUploadLink && userPerm == 'rw'; @@ -98,6 +98,11 @@ class ShareDialog extends React.Component { } + + + {gettext('Internal Link')} + + {enableDirPrivateShare && @@ -134,6 +139,13 @@ class ShareDialog extends React.Component { /> } + + + {enableDirPrivateShare && diff --git a/frontend/src/components/file-chooser/tree-list-item.js b/frontend/src/components/file-chooser/tree-list-item.js index b3e201c53d..97714b4b5d 100644 --- a/frontend/src/components/file-chooser/tree-list-item.js +++ b/frontend/src/components/file-chooser/tree-list-item.js @@ -88,9 +88,13 @@ class TreeViewItem extends React.Component { let isCurrentPath = this.props.selectedPath === this.state.filePath; const fileName = node.object.name; - if (this.props.fileSuffixes && fileName && fileName.indexOf('.') !== -1) { - const suffix = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase(); - if (!this.props.fileSuffixes.includes(suffix)) return null; + if (this.props.fileSuffixes && fileName && node.object.type === 'file') { + if ( fileName.indexOf('.') !== -1) { + const suffix = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase(); + if (!this.props.fileSuffixes.includes(suffix)) return null; + } else { + if (node.object.type === 'file') return null; + } } return( diff --git a/frontend/src/components/file-uploader/file-uploader.js b/frontend/src/components/file-uploader/file-uploader.js index 3d1d83b743..372547bb39 100644 --- a/frontend/src/components/file-uploader/file-uploader.js +++ b/frontend/src/components/file-uploader/file-uploader.js @@ -53,6 +53,7 @@ class FileUploader extends React.Component { this.timestamp = null; this.loaded = 0; this.bitrateInterval = 500; // Interval in milliseconds to calculate the bitrate + window.onbeforeunload = this.onbeforeunload; } componentDidMount() { @@ -86,11 +87,20 @@ class FileUploader extends React.Component { } componentWillUnmount = () => { + window.onbeforeunload = null; if (this.props.dragAndDrop === true) { this.resumable.disableDropOnDocument(); } } + onbeforeunload = () => { + if (window.uploader && + window.uploader.isUploadProgressDialogShow && + window.uploader.totalProgress !== 100) { + return ''; + } + } + bindCallbackHandler = () => { let {maxFilesErrorCallback, minFileSizeErrorCallback, maxFileSizeErrorCallback, fileTypeErrorCallback } = this.props; @@ -207,6 +217,7 @@ class FileUploader extends React.Component { isUploadProgressDialogShow: true, uploadFileList: uploadFileList, }); + Utils.registerGlobalVariable('uploader', 'isUploadProgressDialogShow', true); } buildCustomFileObj = (resumableFile) => { @@ -261,6 +272,7 @@ class FileUploader extends React.Component { totalProgress: progress, uploadBitrate: uploadBitrate }); + Utils.registerGlobalVariable('uploader', 'totalProgress', progress); } onFileUploadSuccess = (resumableFile, message) => { @@ -466,6 +478,7 @@ class FileUploader extends React.Component { onCloseUploadDialog = () => { this.resumable.files = []; this.setState({isUploadProgressDialogShow: false, uploadFileList: []}); + Utils.registerGlobalVariable('uploader', 'isUploadProgressDialogShow', false); } onUploadCancel = (uploadingItem) => { @@ -529,6 +542,7 @@ class FileUploader extends React.Component { }, () => { this.resumable.upload(); }); + Utils.registerGlobalVariable('uploader', 'isUploadProgressDialogShow', true); } cancelFileUpload = () => { diff --git a/frontend/src/components/index-viewer.js b/frontend/src/components/index-viewer.js index 22f66226c3..7549326bd3 100644 --- a/frontend/src/components/index-viewer.js +++ b/frontend/src/components/index-viewer.js @@ -249,16 +249,12 @@ class FolderItem extends React.Component { }); } - expandChildNodes = () => { - this.setState({ expanded: true }); - } - renderLink = (node) => { const className = node.path === this.props.currentPath ? 'wiki-nav-content wiki-nav-content-highlight' : 'wiki-nav-content'; if (node.href && node.name) { - return ; + return ; } else if (node.name) { - return
{node.name}
; + return
{node.name}
; } else { return null; } diff --git a/frontend/src/components/main-side-nav.js b/frontend/src/components/main-side-nav.js index 8b0aa4a9c7..e8210b22fe 100644 --- a/frontend/src/components/main-side-nav.js +++ b/frontend/src/components/main-side-nav.js @@ -62,7 +62,16 @@ class MainSideNav extends React.Component { }); } - tabItemClick = (param, id) => { + tabItemClick = (e, param, id) => { + if (window.uploader && + window.uploader.isUploadProgressDialogShow && + window.uploader.totalProgress !== 100) { + if (!window.confirm(gettext('A file is being uploaded. Are you sure you want to leave this page?'))) { + e.preventDefault(); + return false; + } + window.uploader.isUploadProgressDialogShow = false; + } this.props.tabItemClick(param, id); } @@ -78,7 +87,7 @@ class MainSideNav extends React.Component { return (
  • - this.tabItemClick('groups')}> + this.tabItemClick(e, 'groups')}> {gettext('All Groups')} @@ -86,7 +95,7 @@ class MainSideNav extends React.Component { {this.state.groupItems.map(item => { return (
  • - this.tabItemClick(item.name, item.id)}> + this.tabItemClick(e, item.name, item.id)}> {item.name} @@ -111,7 +120,7 @@ class MainSideNav extends React.Component { if (canGenerateShareLink) { linksNavItem = (
  • - this.tabItemClick('share-admin-share-links')}> + this.tabItemClick(e, 'share-admin-share-links')}> {gettext('Links')} @@ -120,7 +129,7 @@ class MainSideNav extends React.Component { } else if (canGenerateUploadLink) { linksNavItem = (
  • - this.tabItemClick('share-admin-upload-links')}> + this.tabItemClick(e, 'share-admin-upload-links')}> {gettext('Links')} @@ -131,14 +140,14 @@ class MainSideNav extends React.Component {
      {canAddRepo && (
    • - this.tabItemClick('share-admin-libs')}> + this.tabItemClick(e, 'share-admin-libs')}> {gettext('Libraries')}
    • )}
    • - this.tabItemClick('share-admin-folders')}> + this.tabItemClick(e, 'share-admin-folders')}> {gettext('Folders')} @@ -157,20 +166,20 @@ class MainSideNav extends React.Component {
        {canAddRepo && (
      • - this.tabItemClick('my-libs')}> + this.tabItemClick(e, 'my-libs')}> {gettext('My Libraries')}
      • )}
      • - this.tabItemClick('shared-libs')}> + this.tabItemClick(e, 'shared-libs')}> {gettext('Shared with me')}
      • { canViewOrg && -
      • this.tabItemClick('org')}> +
      • this.tabItemClick(e, 'org')}> {gettext('Shared with all')} @@ -200,14 +209,14 @@ class MainSideNav extends React.Component {

        {gettext('Tools')}

        • - this.tabItemClick('starred')}> + this.tabItemClick(e, 'starred')}> {gettext('Favorites')}
        • {showActivity &&
        • - this.tabItemClick('dashboard')}> + this.tabItemClick(e, 'dashboard')}> {gettext('Activities')} @@ -215,14 +224,14 @@ class MainSideNav extends React.Component { } {enableWiki &&
        • - this.tabItemClick('published')}> + this.tabItemClick(e, 'published')}> {gettext('Published Libraries')}
        • } {isDocs && -
        • this.tabItemClick('drafts')}> +
        • this.tabItemClick(e, 'drafts')}> @@ -233,14 +242,14 @@ class MainSideNav extends React.Component {
        • }
        • - this.tabItemClick('linked-devices')}> + this.tabItemClick(e, 'linked-devices')}> {gettext('Linked Devices')}
        • {canInvitePeople &&
        • - this.tabItemClick('invitations')}> + this.tabItemClick(e, 'invitations')}> {gettext('Invite People')} diff --git a/frontend/src/components/org-admin-group-nav.js b/frontend/src/components/org-admin-group-nav.js new file mode 100644 index 0000000000..ae0d728b1a --- /dev/null +++ b/frontend/src/components/org-admin-group-nav.js @@ -0,0 +1,36 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from '@reach/router'; +import { siteRoot, gettext } from '../utils/constants'; + +const propTypes = { + groupID: PropTypes.string.isRequired, + currentItem: PropTypes.string.isRequired +}; + +class OrgAdminGroupNav extends React.Component { + + render() { + const { groupID, currentItem } = this.props; + const urlBase = `${siteRoot}org/groupadmin/${groupID}/`; + return ( +
          +
            +
          • + {gettext('Group Info')} +
          • +
          • + {gettext('Libraries')} +
          • +
          • + {gettext('Members')} +
          • +
          +
          + ); + } +} + +OrgAdminGroupNav.propTypes = propTypes; + +export default OrgAdminGroupNav; diff --git a/frontend/src/components/org-admin-user-nav.js b/frontend/src/components/org-admin-user-nav.js new file mode 100644 index 0000000000..3e15d5ab1d --- /dev/null +++ b/frontend/src/components/org-admin-user-nav.js @@ -0,0 +1,36 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from '@reach/router'; +import { siteRoot, gettext } from '../utils/constants'; + +const propTypes = { + email: PropTypes.string.isRequired, + currentItem: PropTypes.string.isRequired +}; + +class OrgAdminUserNav extends React.Component { + + render() { + const { email, currentItem } = this.props; + const urlBase = `${siteRoot}org/useradmin/info/${encodeURIComponent(email)}/`; + return ( +
          +
            +
          • + {gettext('Profile')} +
          • +
          • + {gettext('Owned Libraries')} +
          • +
          • + {gettext('Shared Libraries')} +
          • +
          +
          + ); + } +} + +OrgAdminUserNav.propTypes = propTypes; + +export default OrgAdminUserNav; diff --git a/frontend/src/css/invitations.css b/frontend/src/css/invitations.css index c79a33abe8..34d67ad870 100644 --- a/frontend/src/css/invitations.css +++ b/frontend/src/css/invitations.css @@ -21,3 +21,9 @@ cursor: pointer; vertical-align: middle; } + +.submit-btn .loading-icon { + margin: 1px auto; + width: 21px; + height: 21px; +} diff --git a/frontend/src/css/org-admin-user.css b/frontend/src/css/org-admin-user.css new file mode 100644 index 0000000000..9ebdeb8ced --- /dev/null +++ b/frontend/src/css/org-admin-user.css @@ -0,0 +1,3 @@ +.cur-view-path.org-admin-user-nav { + padding: 0 16px 1px; +} diff --git a/frontend/src/pages/invitations/invitations-view.js b/frontend/src/pages/invitations/invitations-view.js index bc453082e0..fa225472ac 100644 --- a/frontend/src/pages/invitations/invitations-view.js +++ b/frontend/src/pages/invitations/invitations-view.js @@ -1,103 +1,149 @@ -import React, {Fragment} from 'react'; +import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; -import {gettext, siteRoot} from '../../utils/constants'; +import moment from 'moment'; +import { gettext, siteRoot, loginUrl, canInvitePeople } from '../../utils/constants'; +import { Utils } from '../../utils/utils'; +import { seafileAPI } from '../../utils/seafile-api'; import InvitationsToolbar from '../../components/toolbar/invitations-toolbar'; import InvitePeopleDialog from '../../components/dialog/invite-people-dialog'; -import {seafileAPI} from '../../utils/seafile-api'; -import {Table} from 'reactstrap'; +import InvitationRevokeDialog from '../../components/dialog/invitation-revoke-dialog'; import Loading from '../../components/loading'; -import moment from 'moment'; import toaster from '../../components/toast'; import EmptyTip from '../../components/empty-tip'; import '../../css/invitations.css'; -class InvitationsListItem extends React.Component { +if (!canInvitePeople) { + location.href = siteRoot; +} + +class Item extends React.Component { constructor(props) { super(props); this.state = { - isOperationShow: false, + isOpIconShown: false, + isRevokeDialogOpen: false }; } - onMouseEnter = (event) => { - event.preventDefault(); + onMouseEnter = () => { this.setState({ - isOperationShow: true, - }); - } - - onMouseOver = () => { - this.setState({ - isOperationShow: true, + isOpIconShown: true }); } onMouseLeave = () => { this.setState({ - isOperationShow: false, + isOpIconShown: false + }); + } + + deleteItem = () => { + // make the icon avoid being clicked repeatedly + this.setState({ + isOpIconShown: false + }); + const token = this.props.invitation.token; + seafileAPI.deleteInvitation(token).then((res) => { + this.setState({deleted: true}); + toaster.success(gettext('Successfully deleted 1 item.')); + }).catch((error) => { + const errorMsg = Utils.getErrorMsg(error); + toaster.danger(errorMsg); + this.setState({ + isOpIconShown: true + }); + }); + } + + revokeItem = () => { + this.setState({deleted: true}); + } + + toggleRevokeDialog = () => { + this.setState({ + isRevokeDialogOpen: !this.state.isRevokeDialogOpen }); } render() { + const { isOpIconShown, deleted, isRevokeDialogOpen } = this.state; + + if (deleted) { + return null; + } + const invitationItem = this.props.invitation; - const acceptIcon = ; - const deleteOperation = ; + const operation = invitationItem.accept_time ? + + : + + ; + return ( - - {invitationItem.accepter} - {moment(invitationItem.invite_time).format('YYYY-MM-DD')} - {moment(invitationItem.expire_time).format('YYYY-MM-DD')} - {invitationItem.accept_time && acceptIcon} - {!invitationItem.accept_time && deleteOperation} - + + + {invitationItem.accepter} + {moment(invitationItem.invite_time).format('YYYY-MM-DD')} + {moment(invitationItem.expire_time).format('YYYY-MM-DD')} + {invitationItem.accept_time && } + {isOpIconShown && operation} + + {isRevokeDialogOpen && + + } + ); } } -const InvitationsListItemPropTypes = { +const ItemPropTypes = { invitation: PropTypes.object.isRequired, - onItemDelete: PropTypes.func.isRequired, }; -InvitationsListItem.propTypes = InvitationsListItemPropTypes; +Item.propTypes = ItemPropTypes; -class InvitationsListView extends React.Component { +class Content extends Component { constructor(props) { super(props); } - onItemDelete = (token, index) => { - seafileAPI.deleteInvitation(token).then((res) => { - this.props.onDeleteInvitation(index); - toaster.success(gettext('Successfully deleted 1 item.'), {duration: 1}); - }).catch((error) => { - if (error.response){ - toaster.danger(error.response.data.detail || gettext('Error'), {duration: 3}); - } else { - toaster.danger(gettext('Please check the network.'), {duration: 3}); - } - }); - } - render() { - const invitationsListItems = this.props.invitationsList.map((invitation, index) => { - return ( - ); - }); + const { + loading, errorMsg, invitationsList + } = this.props.data; + + if (loading) { + return ; + } + + if (errorMsg) { + return

          {errorMsg}

          ; + } + + if (!invitationsList.length) { + return ( + +

          {gettext('You have not invited any people.')}

          +
          + ); + } return ( - +
          @@ -108,100 +154,74 @@ class InvitationsListView extends React.Component { - {invitationsListItems} + {invitationsList.map((invitation, index) => { + return ( + + ); + })} -
          {gettext('Email')}
          + ); } } -const InvitationsListViewPropTypes = { - invitationsList: PropTypes.array.isRequired, - onDeleteInvitation: PropTypes.func.isRequired, -}; - -InvitationsListView.propTypes = InvitationsListViewPropTypes; - class InvitationsView extends React.Component { constructor(props) { super(props); this.state = { - isInvitePeopleDialogOpen: false, + loading: true, + errorMsg: '', invitationsList: [], - isLoading: true, - permissionDeniedMsg: '', - showEmptyTip: false, + isInvitePeopleDialogOpen: false }; } - listInvitations = () => { + componentDidMount() { seafileAPI.listInvitations().then((res) => { this.setState({ invitationsList: res.data, - showEmptyTip: true, - isLoading: false, + loading: false }); }).catch((error) => { - this.setState({ - isLoading: false, - }); - if (error.response){ - if (error.response.status === 403){ - let permissionDeniedMsg = gettext('Permission error'); + if (error.response) { + if (error.response.status == 403) { this.setState({ - permissionDeniedMsg: permissionDeniedMsg, - }); - } else{ - toaster.danger(error.response.data.detail || gettext('Error'), {duration: 3}); + loading: false, + errorMsg: gettext('Permission denied') + }); + location.href = `${loginUrl}?next=${encodeURIComponent(location.href)}`; + } else { + this.setState({ + loading: false, + errorMsg: gettext('Error') + }); } } else { - toaster.danger(gettext('Please check the network.'), {duration: 3}); + this.setState({ + loading: false, + errorMsg: gettext('Please check the network.') + }); } - }); + }); } onInvitePeople = (invitationsArray) => { - invitationsArray.push.apply(invitationsArray,this.state.invitationsList); + invitationsArray.push.apply(invitationsArray, this.state.invitationsList); this.setState({ invitationsList: invitationsArray, }); } - onDeleteInvitation = (index) => { - this.state.invitationsList.splice(index, 1); - this.setState({ - invitationsList: this.state.invitationsList, - }); - } - - componentDidMount() { - this.listInvitations(); - } - toggleInvitePeopleDialog = () => { this.setState({ isInvitePeopleDialogOpen: !this.state.isInvitePeopleDialogOpen }); } - emptyTip = () => { - return ( - -

          {gettext('You have not invited any people.')}

          -
          - ); - } - - handlePermissionDenied = () => { - window.location = siteRoot; - return( -
          - {this.state.permissionDeniedMsg} -
          - ); - } - render() { return ( @@ -216,22 +236,14 @@ class InvitationsView extends React.Component {

          {gettext('Invite People')}

          - {this.state.isLoading && } - {(!this.state.isLoading && this.state.permissionDeniedMsg !== '') && this.handlePermissionDenied() } - {(!this.state.isLoading && this.state.showEmptyTip && this.state.invitationsList.length === 0) && this.emptyTip()} - {(!this.state.isLoading && this.state.invitationsList.length !== 0) && - < InvitationsListView - invitationsList={this.state.invitationsList} - onDeleteInvitation={this.onDeleteInvitation} - />} +
          {this.state.isInvitePeopleDialogOpen && }
          diff --git a/frontend/src/pages/org-admin/index.js b/frontend/src/pages/org-admin/index.js index ae71320bc5..a62a2ebad4 100644 --- a/frontend/src/pages/org-admin/index.js +++ b/frontend/src/pages/org-admin/index.js @@ -5,7 +5,13 @@ import { Router } from '@reach/router'; import { siteRoot } from '../../utils/constants'; import SidePanel from './side-panel'; import OrgUsers from './org-users'; +import OrgUserProfile from './org-user-profile'; +import OrgUserRepos from './org-user-repos'; +import OrgUserSharedRepos from './org-user-shared-repos'; import OrgGroups from './org-groups'; +import OrgGroupInfo from './org-group-info'; +import OrgGroupRepos from './org-group-repos'; +import OrgGroupMembers from './org-group-members'; import OrgLibraries from './org-libraries'; import OrgInfo from './org-info'; import OrgLinks from './org-links'; @@ -35,10 +41,14 @@ class Org extends React.Component { componentDidMount() { let href = window.location.href.split('/'); let currentTab = href[href.length - 2]; - if (currentTab == 'useradmin') { + + if (location.href.indexOf(`${siteRoot}org/useradmin`) != -1) { currentTab = 'users'; } - if (currentTab > 0) { + if (location.href.indexOf(`${siteRoot}org/groupadmin`) != -1) { + currentTab = 'groupadmin'; + } + if (location.href.indexOf(`${siteRoot}org/departmentadmin`) != -1) { currentTab = 'departmentadmin'; } this.setState({currentTab: currentTab}); @@ -61,7 +71,13 @@ class Org extends React.Component { - + + + + + + + @@ -69,9 +85,9 @@ class Org extends React.Component { - - - + + + diff --git a/frontend/src/pages/org-admin/org-department-item.js b/frontend/src/pages/org-admin/org-department-item.js index 09766b82b8..9f81356140 100644 --- a/frontend/src/pages/org-admin/org-department-item.js +++ b/frontend/src/pages/org-admin/org-department-item.js @@ -59,7 +59,7 @@ class OrgDepartmentItem extends React.Component { } listOrgGroupRepo = (groupID) => { - seafileAPI.orgAdminListDepartGroupRepos(orgID, groupID).then(res => { + seafileAPI.orgAdminListGroupRepos(orgID, groupID).then(res => { this.setState({ repos: res.data.libraries }); }).catch(error => { let errMessage = Utils.getErrorMsg(error); @@ -392,7 +392,7 @@ class MemberItem extends React.Component { onChangeUserRole = (role) => { let isAdmin = role === 'Admin' ? true : false; - seafileAPI.orgAdminSetDepartGroupUserRole(orgID, this.props.groupID, this.props.member.email, isAdmin).then((res) => { + seafileAPI.orgAdminSetGroupMemberRole(orgID, this.props.groupID, this.props.member.email, isAdmin).then((res) => { this.props.onMemberChanged(); }).catch(error => { let errMessage = Utils.getErrorMsg(error); diff --git a/frontend/src/pages/org-admin/org-group-info.js b/frontend/src/pages/org-admin/org-group-info.js new file mode 100644 index 0000000000..57209c43f1 --- /dev/null +++ b/frontend/src/pages/org-admin/org-group-info.js @@ -0,0 +1,105 @@ +import React, { Component, Fragment } from 'react'; +import { Link } from '@reach/router'; +import { seafileAPI } from '../../utils/seafile-api'; +import { gettext, loginUrl, siteRoot } from '../../utils/constants'; +import Loading from '../../components/loading'; +import OrgAdminGroupNav from '../../components/org-admin-group-nav'; +import MainPanelTopbar from './main-panel-topbar'; + +import '../../css/org-admin-user.css'; + +const { orgID } = window.org.pageOptions; + +class OrgGroupInfo extends Component { + + constructor(props) { + super(props); + this.state = { + loading: true, + errorMsg: '' + }; + } + + componentDidMount() { + seafileAPI.orgAdminGetGroup(orgID, this.props.groupID).then((res) => { + this.setState(Object.assign({ + loading: false + }, res.data)); + }).catch((error) => { + if (error.response) { + if (error.response.status == 403) { + this.setState({ + loading: false, + errorMsg: gettext('Permission denied') + }); + location.href = `${loginUrl}?next=${encodeURIComponent(location.href)}`; + } else { + this.setState({ + loading: false, + errorMsg: gettext('Error') + }); + } + } else { + this.setState({ + loading: false, + errorMsg: gettext('Please check the network.') + }); + } + }); + } + + render() { + return ( + + +
          +
          + +
          + +
          +
          +
          +
          + ); + } +} + +class Content extends Component { + + constructor(props) { + super(props); + this.state = { + }; + } + + render() { + const { + loading, errorMsg, + group_name, creator_email, creator_name + } = this.props.data; + + if (loading) { + return ; + } + if (errorMsg) { + return

          {errorMsg}

          ; + } + + return ( +
          +
          {gettext('Name')}
          +
          {group_name}
          + +
          {gettext('Creator')}
          +
          + {creator_name} +
          +
          + ); + } +} + +export default OrgGroupInfo; diff --git a/frontend/src/pages/org-admin/org-group-members.js b/frontend/src/pages/org-admin/org-group-members.js new file mode 100644 index 0000000000..342feb390b --- /dev/null +++ b/frontend/src/pages/org-admin/org-group-members.js @@ -0,0 +1,146 @@ +import React, { Component, Fragment } from 'react'; +import { Link } from '@reach/router'; +import { seafileAPI } from '../../utils/seafile-api'; +import { gettext, loginUrl, siteRoot } from '../../utils/constants'; +import Loading from '../../components/loading'; +import OrgAdminGroupNav from '../../components/org-admin-group-nav'; +import MainPanelTopbar from './main-panel-topbar'; + +import '../../css/org-admin-user.css'; + +const { orgID } = window.org.pageOptions; + +class OrgGroupMembers extends Component { + + constructor(props) { + super(props); + this.state = { + loading: true, + errorMsg: '' + }; + } + + componentDidMount() { + seafileAPI.orgAdminListGroupMembers(orgID, this.props.groupID).then((res) => { + this.setState(Object.assign({ + loading: false + }, res.data)); + }).catch((error) => { + if (error.response) { + if (error.response.status == 403) { + this.setState({ + loading: false, + errorMsg: gettext('Permission denied') + }); + location.href = `${loginUrl}?next=${encodeURIComponent(location.href)}`; + } else { + this.setState({ + loading: false, + errorMsg: gettext('Error') + }); + } + } else { + this.setState({ + loading: false, + errorMsg: gettext('Please check the network.') + }); + } + }); + } + + render() { + return ( + + +
          +
          + +
          + +
          +
          +
          +
          + ); + } +} + +class Content extends Component { + + constructor(props) { + super(props); + } + + render() { + const { + loading, errorMsg, members + } = this.props.data; + + if (loading) { + return ; + } + if (errorMsg) { + return

          {errorMsg}

          ; + } + + return ( + + + + + + + + + + + {members.map((item, index) => { + return ; + })} + +
          {gettext('Name')}{gettext('Role')}
          +
          + ); + } +} + +class Item extends Component { + + constructor(props) { + super(props); + } + + getRoleText() { + switch (this.props.data.role) { + case 'Owner': + return gettext('Owner'); + case 'Admin': + return gettext('Admin'); + case 'Member': + return gettext('Member'); + } + } + + render() { + const item = this.props.data; + return ( + + + + + + + {item.name} + + + {this.getRoleText()} + + + + ); + } +} + +export default OrgGroupMembers; diff --git a/frontend/src/pages/org-admin/org-group-repos.js b/frontend/src/pages/org-admin/org-group-repos.js new file mode 100644 index 0000000000..90f8d25132 --- /dev/null +++ b/frontend/src/pages/org-admin/org-group-repos.js @@ -0,0 +1,195 @@ +import React, { Component, Fragment } from 'react'; +import { Link } from '@reach/router'; +import { seafileAPI } from '../../utils/seafile-api'; +import { gettext, loginUrl, siteRoot } from '../../utils/constants'; +import { Utils } from '../../utils/utils'; +import Loading from '../../components/loading'; +import toaster from '../../components/toast'; +import OrgAdminGroupNav from '../../components/org-admin-group-nav'; +import DeleteRepoDialog from '../../components/dialog/delete-repo-dialog'; +import MainPanelTopbar from './main-panel-topbar'; + +import '../../css/org-admin-user.css'; + +const { orgID } = window.org.pageOptions; + +class OrgGroupRepos extends Component { + + constructor(props) { + super(props); + this.state = { + loading: true, + errorMsg: '' + }; + } + + componentDidMount() { + seafileAPI.orgAdminListGroupRepos(orgID, this.props.groupID).then((res) => { + this.setState(Object.assign({ + loading: false + }, res.data)); + }).catch((error) => { + if (error.response) { + if (error.response.status == 403) { + this.setState({ + loading: false, + errorMsg: gettext('Permission denied') + }); + location.href = `${loginUrl}?next=${encodeURIComponent(location.href)}`; + } else { + this.setState({ + loading: false, + errorMsg: gettext('Error') + }); + } + } else { + this.setState({ + loading: false, + errorMsg: gettext('Please check the network.') + }); + } + }); + } + + render() { + return ( + + +
          +
          + +
          + +
          +
          +
          +
          + ); + } +} + +class Content extends Component { + + constructor(props) { + super(props); + } + + render() { + const { + loading, errorMsg, libraries + } = this.props.data; + + if (loading) { + return ; + } + if (errorMsg) { + return

          {errorMsg}

          ; + } + + return ( + + + + + + + + + + + + + {libraries.map((item, index) => { + return ; + })} + +
          {/*icon*/}{gettext('Name')}{gettext('Size')}{gettext('Shared By')}
          +
          + ); + } +} + +class Item extends Component { + + constructor(props) { + super(props); + this.state = { + isOpIconShown: false, + deleted: false, + isDeleteRepoDialogOpen: false + }; + } + + handleMouseOver = () => { + this.setState({ + isOpIconShown: true + }); + } + + handleMouseOut = () => { + this.setState({ + isOpIconShown: false + }); + } + + handleDeleteIconClick = (e) => { + e.preventDefault(); + this.toggleDeleteRepoDialog(); + } + + toggleDeleteRepoDialog = () => { + this.setState({ + isDeleteRepoDialogOpen: !this.state.isDeleteRepoDialogOpen + }); + } + + deleteRepo = () => { + const repo = this.props.data; + seafileAPI.deleteOrgRepo(orgID, repo.repo_id).then((res) => { + this.setState({ + deleted: true + }); + const msg = gettext('Successfully deleted {name}.').replace('{name}', repo.name); + toaster.success(msg); + }).catch((error) => { + const errorMsg = Utils.getErrorMsg(error); + toaster.danger(errorMsg); + }); + } + + render() { + const { deleted, isOpIconShown, isDeleteRepoDialogOpen } = this.state; + const repo = this.props.data; + + if (deleted) { + return null; + } + + return ( + + + + {Utils.getLibIconTitle(repo)} + + {repo.name} + {Utils.bytesToSize(repo.size)} + {repo.shared_by_name} + + + + + {isDeleteRepoDialogOpen && + + } + + ); + } +} + +export default OrgGroupRepos; diff --git a/frontend/src/pages/org-admin/org-user-item.js b/frontend/src/pages/org-admin/org-user-item.js index 039c3dda56..305fa12030 100644 --- a/frontend/src/pages/org-admin/org-user-item.js +++ b/frontend/src/pages/org-admin/org-user-item.js @@ -56,12 +56,15 @@ class UserItem extends React.Component { toggleResetPW = () => { const email = this.props.user.email; + toaster.success(gettext('Resetting user\'s password, please wait for a moment.')); seafileAPI.resetOrgUserPassword(orgID, email).then(res => { let msg; msg = gettext('Successfully reset password to %(passwd)s for user %(user)s.'); msg = msg.replace('%(passwd)s', res.data.new_password); msg = msg.replace('%(user)s', email); - toaster.success(msg); + toaster.success(msg, { + duration: 15 + }); }).catch(error => { let errMessage = Utils.getErrorMsg(error); toaster.danger(errMessage); diff --git a/frontend/src/pages/org-admin/org-user-profile.js b/frontend/src/pages/org-admin/org-user-profile.js new file mode 100644 index 0000000000..99c2e43a50 --- /dev/null +++ b/frontend/src/pages/org-admin/org-user-profile.js @@ -0,0 +1,202 @@ +import React, { Component, Fragment } from 'react'; +import { seafileAPI } from '../../utils/seafile-api'; +import { gettext, loginUrl } from '../../utils/constants'; +import { Utils } from '../../utils/utils'; +import Loading from '../../components/loading'; +import OrgAdminUserNav from '../../components/org-admin-user-nav'; +import SetOrgUserName from '../../components/dialog/set-org-user-name'; +import SetOrgUserContactEmail from '../../components/dialog/set-org-user-contact-email'; +import SetOrgUserQuota from '../../components/dialog/set-org-user-quota'; +import MainPanelTopbar from './main-panel-topbar'; + +import '../../css/org-admin-user.css'; + +const { orgID, orgName } = window.org.pageOptions; + +class OrgUserProfile extends Component { + + constructor(props) { + super(props); + this.state = { + loading: true, + errorMsg: '' + }; + } + + componentDidMount() { + seafileAPI.getOrgUserInfo(orgID, this.props.email).then((res) => { + this.setState(Object.assign({ + loading: false + }, res.data)); + }).catch((error) => { + if (error.response) { + if (error.response.status == 403) { + this.setState({ + loading: false, + errorMsg: gettext('Permission denied') + }); + location.href = `${loginUrl}?next=${encodeURIComponent(location.href)}`; + } else { + this.setState({ + loading: false, + errorMsg: gettext('Error') + }); + } + } else { + this.setState({ + loading: false, + errorMsg: gettext('Please check the network.') + }); + } + }); + } + + updateName = (name) => { + this.setState({ + name: name + }); + } + + updateContactEmail = (contactEmail) => { + this.setState({ + contact_email: contactEmail + }); + } + + updateQuota = (quota) => { + this.setState({ + quota_total: quota + }); + } + + render() { + return ( + + +
          +
          + +
          + +
          +
          +
          +
          + ); + } +} + +class Content extends Component { + + constructor(props) { + super(props); + this.state = { + isSetNameDialogOpen: false, + isSetContactEmailDialogOpen: false, + isSetQuotaDialogOpen: false + }; + } + + toggleSetNameDialog = () => { + this.setState({ + isSetNameDialogOpen: !this.state.isSetNameDialogOpen + }); + } + + toggleSetContactEmailDialog = () => { + this.setState({ + isSetContactEmailDialogOpen: !this.state.isSetContactEmailDialogOpen + }); + } + + toggleSetQuotaDialog = () => { + this.setState({ + isSetQuotaDialogOpen: !this.state.isSetQuotaDialogOpen + }); + } + + render() { + const { + loading, errorMsg, + avatar_url, email, contact_email, + name, quota_total, quota_usage + } = this.props.data; + const { isSetNameDialogOpen, isSetContactEmailDialogOpen, isSetQuotaDialogOpen } = this.state; + + if (loading) { + return ; + } + if (errorMsg) { + return

          {errorMsg}

          ; + } + + return ( + +
          +
          {gettext('Avatar')}
          +
          + +
          + +
          ID
          +
          {email}
          + +
          {gettext('Name')}
          +
          + {name || '--'} + +
          + +
          {gettext('Contact Email')}
          +
          + {contact_email || '--'} + +
          + +
          {gettext('Organization')}
          +
          {orgName}
          + +
          {gettext('Space Used / Quota')}
          +
          + {`${Utils.bytesToSize(quota_usage)}${quota_total > 0 ? ' / ' + Utils.bytesToSize(quota_total) : ''}`} + +
          +
          + {isSetNameDialogOpen && + + } + {isSetContactEmailDialogOpen && + + } + {isSetQuotaDialogOpen && + + } +
          + ); + } +} + +export default OrgUserProfile; diff --git a/frontend/src/pages/org-admin/org-user-repos.js b/frontend/src/pages/org-admin/org-user-repos.js new file mode 100644 index 0000000000..fb8f4ba87b --- /dev/null +++ b/frontend/src/pages/org-admin/org-user-repos.js @@ -0,0 +1,195 @@ +import React, { Component, Fragment } from 'react'; +import moment from 'moment'; +import { seafileAPI } from '../../utils/seafile-api'; +import { gettext, loginUrl } from '../../utils/constants'; +import { Utils } from '../../utils/utils'; +import Loading from '../../components/loading'; +import toaster from '../../components/toast'; +import OrgAdminUserNav from '../../components/org-admin-user-nav'; +import DeleteRepoDialog from '../../components/dialog/delete-repo-dialog'; +import MainPanelTopbar from './main-panel-topbar'; + +import '../../css/org-admin-user.css'; + +const { orgID } = window.org.pageOptions; + +class OrgUserOwnedRepos extends Component { + + constructor(props) { + super(props); + this.state = { + loading: true, + errorMsg: '' + }; + } + + componentDidMount() { + seafileAPI.getOrgUserOwnedRepos(orgID, this.props.email).then((res) => { + this.setState(Object.assign({ + loading: false + }, res.data)); + }).catch((error) => { + if (error.response) { + if (error.response.status == 403) { + this.setState({ + loading: false, + errorMsg: gettext('Permission denied') + }); + location.href = `${loginUrl}?next=${encodeURIComponent(location.href)}`; + } else { + this.setState({ + loading: false, + errorMsg: gettext('Error') + }); + } + } else { + this.setState({ + loading: false, + errorMsg: gettext('Please check the network.') + }); + } + }); + } + + render() { + return ( + + +
          +
          + +
          + +
          +
          +
          +
          + ); + } +} + +class Content extends Component { + + constructor(props) { + super(props); + } + + render() { + const { + loading, errorMsg, repo_list + } = this.props.data; + + if (loading) { + return ; + } + if (errorMsg) { + return

          {errorMsg}

          ; + } + + return ( + + + + + + + + + + + + + {repo_list.map((item, index) => { + return ; + })} + +
          {/*icon*/}{gettext('Name')}{gettext('Size')}{gettext('Last Update')}
          +
          + ); + } +} + +class Item extends Component { + + constructor(props) { + super(props); + this.state = { + isOpIconShown: false, + deleted: false, + isDeleteRepoDialogOpen: false + }; + } + + handleMouseOver = () => { + this.setState({ + isOpIconShown: true + }); + } + + handleMouseOut = () => { + this.setState({ + isOpIconShown: false + }); + } + + handleDeleteIconClick = (e) => { + e.preventDefault(); + this.toggleDeleteRepoDialog(); + } + + toggleDeleteRepoDialog = () => { + this.setState({ + isDeleteRepoDialogOpen: !this.state.isDeleteRepoDialogOpen + }); + } + + deleteRepo = () => { + const repo = this.props.data; + seafileAPI.deleteOrgRepo(orgID, repo.repo_id).then((res) => { + this.setState({ + deleted: true + }); + const msg = gettext('Successfully deleted {name}.').replace('{name}', repo.repo_name); + toaster.success(msg); + }).catch((error) => { + const errorMsg = Utils.getErrorMsg(error); + toaster.danger(errorMsg); + }); + } + + render() { + const { deleted, isOpIconShown, isDeleteRepoDialogOpen } = this.state; + const repo = this.props.data; + + if (deleted) { + return null; + } + + return ( + + + + {Utils.getLibIconTitle(repo)} + + {repo.repo_name} + {Utils.bytesToSize(repo.size)} + {moment(repo.last_modified).format('YYYY-MM-DD')} + + + + + {isDeleteRepoDialogOpen && + + } + + ); + } +} + +export default OrgUserOwnedRepos; diff --git a/frontend/src/pages/org-admin/org-user-shared-repos.js b/frontend/src/pages/org-admin/org-user-shared-repos.js new file mode 100644 index 0000000000..2c698d705b --- /dev/null +++ b/frontend/src/pages/org-admin/org-user-shared-repos.js @@ -0,0 +1,132 @@ +import React, { Component, Fragment } from 'react'; +import moment from 'moment'; +import { seafileAPI } from '../../utils/seafile-api'; +import { gettext, loginUrl } from '../../utils/constants'; +import { Utils } from '../../utils/utils'; +import Loading from '../../components/loading'; +import OrgAdminUserNav from '../../components/org-admin-user-nav'; +import MainPanelTopbar from './main-panel-topbar'; + +import '../../css/org-admin-user.css'; + +const { orgID } = window.org.pageOptions; + +class OrgUserSharedRepos extends Component { + + constructor(props) { + super(props); + this.state = { + loading: true, + errorMsg: '' + }; + } + + componentDidMount() { + seafileAPI.getOrgUserBesharedRepos(orgID, this.props.email).then((res) => { + this.setState(Object.assign({ + loading: false + }, res.data)); + }).catch((error) => { + if (error.response) { + if (error.response.status == 403) { + this.setState({ + loading: false, + errorMsg: gettext('Permission denied') + }); + location.href = `${loginUrl}?next=${encodeURIComponent(location.href)}`; + } else { + this.setState({ + loading: false, + errorMsg: gettext('Error') + }); + } + } else { + this.setState({ + loading: false, + errorMsg: gettext('Please check the network.') + }); + } + }); + } + + render() { + return ( + + +
          +
          + +
          + +
          +
          +
          +
          + ); + } +} + +class Content extends Component { + + constructor(props) { + super(props); + } + + render() { + const { + loading, errorMsg, repo_list + } = this.props.data; + + if (loading) { + return ; + } + if (errorMsg) { + return

          {errorMsg}

          ; + } + + return ( + + + + + + + + + + + + {repo_list.map((item, index) => { + return ; + })} + +
          {/*icon*/}{gettext('Name')}{gettext('Owner')}{gettext('Size')}{gettext('Last Update')}
          + ); + } +} + +class Item extends Component { + + constructor(props) { + super(props); + } + + render() { + const repo = this.props.data; + return ( + + + {Utils.getLibIconTitle(repo)} + + {repo.repo_name} + {repo.owner_name} + {Utils.bytesToSize(repo.size)} + {moment(repo.last_modified).format('YYYY-MM-DD')} + + ); + } +} + +export default OrgUserSharedRepos; diff --git a/frontend/src/utils/utils.js b/frontend/src/utils/utils.js index a44aa3389a..c60602255d 100644 --- a/frontend/src/utils/utils.js +++ b/frontend/src/utils/utils.js @@ -983,6 +983,13 @@ export const Utils = { } return false; + }, + + registerGlobalVariable(namespace, key, value) { + if (!window[namespace]) { + window[namespace] = {}; + } + window[namespace][key] = value; } }; diff --git a/media/css/sf_font3/iconfont.css b/media/css/sf_font3/iconfont.css index f858ebce99..fe3e68aaa4 100644 --- a/media/css/sf_font3/iconfont.css +++ b/media/css/sf_font3/iconfont.css @@ -1,10 +1,10 @@ @font-face {font-family: "sf3-font"; - src: url('iconfont.eot?t=1562919437059'); /* IE9 */ - src: url('iconfont.eot?t=1562919437059#iefix') format('embedded-opentype'), /* IE6-IE8 */ - url('data:application/x-font-woff2;charset=utf-8;base64,d09GMgABAAAAAAXIAAsAAAAADBgAAAV6AAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHEIGVgCDOAqLFIhzATYCJAMYCw4ABCAFhG0HXRseChGVpDuR/UzI5P6wvAd1auyv0yQR3c8g2tLZEzh2sRf8VS5JyxMxh5jo0Tq0jkfErQbAfftlu6bW3SBMhNnuztJ89b+87Pb8yYY2HqdRCiFBOBoggIDsH8de/nVe5/nuvn+wQs8IzbKmD7gbWERblPBmBUxTnIlt9giyeQWz4DN5lcuBAKBIgQxSo1YDNyQIkNsRAKRf756dIVX0EHqwBJJKxRwqgPwDERL3O3cYwN/J55NHcBIJ4CDykFdq0qNmN1S5S+625JVvCtrxCpTLWQH4RwE8ABmAAJB+rL0H+EwqJ3LKZQsoB0BSa3HAXe6u+27Lb9+IQO42F75KXAKFFv/yOPAQIAJEBSnXQtmAEiJwlxgZEgUHGaBoDi14KNzQQoCiJcogAgBUF9wkGSC3Ac4MqZRpGw1E8JAgGJGRkGgytdJBIkCiVh5qTNXh93Rk/gWAMp9S30JCvItaV9nRMWo7DO2rOffdBgZMbMEKW3TDumX22MKcuQcLQi0D1sjyvMACi2XRodx5y6ZuzPIFWkQUU5DNOZAfXGQ2L1xpj7W8fyDg3l5j9mJbMGha4HeEyaRVhWFzyNfj9Okmm+XTZzzRLfluBOeY/GHGclvO2cPOtZy70LH6dCk6NycUSKRu/U4hYrE4HOGYeWPxnWeLlpXfdq72upJ7zlQMLrFFZwDz1phM084WX1rp5NkWe3sSBgYKStTDsh6GNSeShcnHXfq1J1PEaac8szdbheiWbMO8bQ4xtjVnKAVrCUa6EYqXjS4ihPaoS7nyFykL2EjiXUj1vvlA20s8d3/fQrvoDsymc7favZMsLcPzmD/fE528eHVBCyE6f4yyhen827xbc7SzF7fWzcu8mxHdsqVd5l1Ktb7FKy+Z9m2uAQYzll5bGybJrT14pF8v9lfAtP8iUb757KQRNY90614lyM2mTveZpk9IvRkZ0YxIpqveSU+PQz1a0LXTQ8/lr9fiZ9szIxm/O6vNkYfX8vy0Ia3N8tOeHj3M/bpBvv45od70ot9+J7TpxXrqH8tznoGZ9emM4jV2/fmdtp2OlVjOd+H5w578D2dSG6CBpXSj+LaNJTfdtb/z82uhZn4G/51YVm7W3Val25RN89jEgybDb32d51bNutfb2evZD0PW7XVordUPZVh1jfmBngylUh31b7gwJ1A3HNacdKtA2cnb/6mh2a6p8Y+7Rd1K04uoQoumK8KDgc+uyzvXvXZ9w3Xu8a7ruwqiBqKftWFo/ubrm3W7Ns/SE8P+Uv9H0gXcov9N4qXeER+rWp+ypl2G+EqzQXFqref3Lff2tmJW57Z7uwwvOjsOd5ed8t2QB/YEa0Vrgit+UMV/Xpb9a+yg+JI5ly7lsHEJpbYNuum6Zd1meWW3vcQ4yDV5hQfw7bF7vCXRbKc6qyC5lX8p0y5tBrFdxUcdDRXeSGz1WrrQqKvO8X3bCjDjBRZCcBDYnMD2wOrSgzSoxjkhlAgNfxRFZQHNNhyAogA7kwbi/V1KaRoth/1RmgoOnGrJAJ4kCyFgZBAxLlCRVAaq0P3RTAJ1RKYBUOBLAGH0DRy9bcAz+kEImF8Qmf2DirEjUC26OiWT05BdK5whkv+kIcjKOseyjds8LX1A9zo7LYsS4gt0G0JUF1U9dY8Veok9treuMWNilYXuxNUwz0JJZUSwojdLl7Jk7g5FkGWXqU6BYyAi3mdEPwjEincymaFYK298/APovJo52lPzYH+BEfIBqye1QgXEvXwdVfNROMh+02kYRRlhv1gsyB1ZYVZxQRK/0QgCU+hnRJOL0unFY+XFSyqXf1kKNCDvpIojPBGISFREDXZzfkY+SydnvM37adJhQ/6O0Ds7uhhPLLqcNlHb7QAAAA==') format('woff2'), - url('iconfont.woff?t=1562919437059') format('woff'), - url('iconfont.ttf?t=1562919437059') format('truetype'), /* chrome, firefox, opera, Safari, Android, iOS 4.2+ */ - url('iconfont.svg?t=1562919437059#sf3-font') format('svg'); /* iOS 4.1- */ + src: url('iconfont.eot?t=1564546243284'); /* IE9 */ + src: url('iconfont.eot?t=1564546243284#iefix') format('embedded-opentype'), /* IE6-IE8 */ + url('data:application/x-font-woff2;charset=utf-8;base64,d09GMgABAAAAAAakAAsAAAAADUAAAAZVAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHEIGVgCDTgqNDIpEATYCJAMcCxAABCAFhG0HcRsdC1GUTlKX7EeCbdtghVejLpYjGdqh2RIKfanCHUG19m/PHv69D+wAyLMlRYlNRREID2RsjIrREYaNeFdLtwmtC8WN/+8uitZUquTb3uXyZX82mzYepzECjZAgHJLhvzN7k9kUb6JFvKLqoEJ2CmfbHjRpndw5rrKigzvX+Dr//bHSzfG1xXwbVmCFXvH/38i6aMuwpgM+OoxoixQsOz8wOsDTkM5mYsGHbwAuJ5BNc3TOH96GsK/E7YFOT6xJYT/lVdawQuuoSw4txTdkbXqfZoGv7vvjP+zHPklV4LteP74v4OoX8itFiR5hp6Ewri+M3lEU2ARK4l1p9IWtYNt0KJftsu0cyFpJ+iV6Dh+JH+37v8d6HYPYckTlrN9guyUhKdp/eJWaqHHUD+ZdreZXR9k53IUd8ABuBfAQbiXwBG4VcArsrNKbnoOYA2kP5BOYh9U+leLuC5TDXNrWjsncesI4t7I2IpWHL7+Fit+FwqY47sEUVQAQlRDKqxFEVrMtrQlV2Y6pwLp+l323k0omVtXE1XV1NPD01Z7l075qsZKjbfRWVrHZNTNeFQ23ut3lSpGWYKqwsikfVQ2LVd3M0xe8v1IpGEwvreWqVMwqBV+DXG/x07DUctxo3NjrbzQJdX0+AqAqYyo0GOYlLhvDLOLyan6rMRKWe6qVdlAg30mNsNl8vkbP6g4cNoc1xA1YsjoixkwJqjqurgiAijYm87Y5sD5x2SwaP4hgAAMQQETcE4bT25YcrG4shtPalx3Jt1eEpb0cK12fB71igE/W93sWQoCJUUYkCARHW65BEIhnbaTkDxFV2AeIrBrS5JUA7FjHcx+XV/PIAmUpLO/nya6zxZoKTOEj1N2obfUVWekqPyb6MKpiQNbvSSmt3UatCOleoOsbijfI6iFFXnuWuk+MRoHJJLRYxGbzziCZGsJMGOiqlltTgwSqzY4llgKzWVSnI6xiNO7QyFhqO4FIlZUEUyjQKRVbLISuaGWRwnL7/3I6oGNGNEpmKvad/htP639r+y+A299rdeiWUvtzGXOSgmQVqRSGCkybXiK5Ra46V61beO6yEJ/BRbDdoH7j/98fNqU8N63rtdDUMv+zmcKLXc7bG41CHGcdkwD/O/+M5RrCrl5D4Kafc1dfiCMJT7rlwaLA9JGvgik7qVhQI7ofRWeFPn+anPJBPjtqvc2ODWsEcGRy35szqwwfVzSYHOO/uYCbLLnZU4F9Mc2kXz0aamkpeXw49NDr89KOcT6FkzbjyqFuQE8KXYnE7NVXwU9lyhyNRmr9VsqYG4Pfpq8dXJv+bWeLnERDGCRgmIFgKXDy9R1+55w/7nTdIb0YuTPiq6MjtJKuQp/eO73Ukd4SGkKfjPzB2KrKFvuBSV7XO4K/UjivsE37pfIo7JT1aorwWt/j8a0YJ3Tg8Qj97Z3QPWcFMTeDpU95tpwEjm24zamEb9/FfP3JKZuISNe+DZ9Lso0cOHUv/D5ngP2ex30H7Ev2SpBLlxCJYF+6KCF5Ll6M7+viJfcDf+c/KIMfkgwG0ociVGtTDV9S/va0y6SlMjasTRKSTkuRLWAXSreQkBS3PSXlUg4yJK9+z8nfkP/7K5xRkJOXk4NbS17+lpOfnW90FVrbbm1KGvgc8DVRAgUA/P8CfYyynWsQFaOTwe1H36FudQNqRPd+fTeTx8OLg23vZ/H/bfeRXvLTRHy10j1DCWgIvoMY2fhlzZUIH9FxcF9TlsWV4XorA5Hf+G7tBxKyjK9pctTv+sgEGfk/IBMxJI0pKLQWqSVzEyqdQ6i1jiDb8Hh0ZwzbEmUf1n0wCMM+IRn0AwrDvqkl8wcqk/6gNhxbkN2E7Sk7y2EP0xhTwUJlG2VzarRsJYeFOnZ95z6aRZVy0uw4Y+S5LagwMDjbeMQGeR17zJcmEtFKM9XqgV0Nq4pUyzTDXLypSNvzg/CyPb2cajh0hqGUGMmCktkoWjnS0DuJQ69pMTf39X3IWKikuKbfSXQMCchi+UrIE4D6iBpSv/uiRcslIyLtsKZUmZE6kIflwqgSVaK05RvNoJzwTJcot3r81niairyjJPXtbINB8Ds2kaKIMqqoo4lW90jSrEK3IkMXeDofkFu2c3RXmE9T2dpXFNs4cb1dSCwXedrkWLm2WVpJ5fJ5AAAAAAA=') format('woff2'), + url('iconfont.woff?t=1564546243284') format('woff'), + url('iconfont.ttf?t=1564546243284') format('truetype'), /* chrome, firefox, opera, Safari, Android, iOS 4.2+ */ + url('iconfont.svg?t=1564546243284#sf3-font') format('svg'); /* iOS 4.1- */ } .sf3-font { @@ -35,3 +35,7 @@ content: "\e657"; } +.sf3-font-cancel-invitation:before { + content: "\e661"; +} + diff --git a/media/css/sf_font3/iconfont.eot b/media/css/sf_font3/iconfont.eot index 3748273478..7563a84236 100644 Binary files a/media/css/sf_font3/iconfont.eot and b/media/css/sf_font3/iconfont.eot differ diff --git a/media/css/sf_font3/iconfont.js b/media/css/sf_font3/iconfont.js index 76f5196568..018147f11f 100644 --- a/media/css/sf_font3/iconfont.js +++ b/media/css/sf_font3/iconfont.js @@ -1 +1 @@ -!function(h){var c,e='',t=(c=document.getElementsByTagName("script"))[c.length-1].getAttribute("data-injectcss");if(t&&!h.__iconfont__svg__cssinject__){h.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(c){console&&console.log(c)}}!function(c){if(document.addEventListener)if(~["complete","loaded","interactive"].indexOf(document.readyState))setTimeout(c,0);else{var t=function(){document.removeEventListener("DOMContentLoaded",t,!1),c()};document.addEventListener("DOMContentLoaded",t,!1)}else document.attachEvent&&(n=c,o=h.document,l=!1,(i=function(){try{o.documentElement.doScroll("left")}catch(c){return void setTimeout(i,50)}e()})(),o.onreadystatechange=function(){"complete"==o.readyState&&(o.onreadystatechange=null,e())});function e(){l||(l=!0,n())}var n,o,l,i}(function(){var c,t;(c=document.createElement("div")).innerHTML=e,e=null,(t=c.getElementsByTagName("svg")[0])&&(t.setAttribute("aria-hidden","true"),t.style.position="absolute",t.style.width=0,t.style.height=0,t.style.overflow="hidden",function(c,t){t.firstChild?function(c,t){t.parentNode.insertBefore(c,t)}(c,t.firstChild):t.appendChild(c)}(t,document.body))})}(window); \ No newline at end of file +!function(h){var c,e='',t=(c=document.getElementsByTagName("script"))[c.length-1].getAttribute("data-injectcss");if(t&&!h.__iconfont__svg__cssinject__){h.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(c){console&&console.log(c)}}!function(c){if(document.addEventListener)if(~["complete","loaded","interactive"].indexOf(document.readyState))setTimeout(c,0);else{var t=function(){document.removeEventListener("DOMContentLoaded",t,!1),c()};document.addEventListener("DOMContentLoaded",t,!1)}else document.attachEvent&&(n=c,o=h.document,l=!1,(i=function(){try{o.documentElement.doScroll("left")}catch(c){return void setTimeout(i,50)}e()})(),o.onreadystatechange=function(){"complete"==o.readyState&&(o.onreadystatechange=null,e())});function e(){l||(l=!0,n())}var n,o,l,i}(function(){var c,t;(c=document.createElement("div")).innerHTML=e,e=null,(t=c.getElementsByTagName("svg")[0])&&(t.setAttribute("aria-hidden","true"),t.style.position="absolute",t.style.width=0,t.style.height=0,t.style.overflow="hidden",function(c,t){t.firstChild?function(c,t){t.parentNode.insertBefore(c,t)}(c,t.firstChild):t.appendChild(c)}(t,document.body))})}(window); \ No newline at end of file diff --git a/media/css/sf_font3/iconfont.svg b/media/css/sf_font3/iconfont.svg index 05920c8701..112d096f78 100644 --- a/media/css/sf_font3/iconfont.svg +++ b/media/css/sf_font3/iconfont.svg @@ -35,6 +35,9 @@ Created by iconfont + + + diff --git a/media/css/sf_font3/iconfont.ttf b/media/css/sf_font3/iconfont.ttf index 05c8fb3953..916f7ac43d 100644 Binary files a/media/css/sf_font3/iconfont.ttf and b/media/css/sf_font3/iconfont.ttf differ diff --git a/media/css/sf_font3/iconfont.woff b/media/css/sf_font3/iconfont.woff index e8aa715e1d..14c28f5dd2 100644 Binary files a/media/css/sf_font3/iconfont.woff and b/media/css/sf_font3/iconfont.woff differ diff --git a/media/css/sf_font3/iconfont.woff2 b/media/css/sf_font3/iconfont.woff2 index f15e9bf543..a563978679 100644 Binary files a/media/css/sf_font3/iconfont.woff2 and b/media/css/sf_font3/iconfont.woff2 differ diff --git a/seahub/api2/endpoints/invitation.py b/seahub/api2/endpoints/invitation.py index 21b945294a..676cc28d93 100644 --- a/seahub/api2/endpoints/invitation.py +++ b/seahub/api2/endpoints/invitation.py @@ -1,17 +1,25 @@ # Copyright (c) 2012-2016 Seafile Ltd. +import logging + from django.shortcuts import get_object_or_404 from rest_framework import status from rest_framework.authentication import SessionAuthentication from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView +from django.utils.translation import ugettext as _ from seahub.api2.authentication import TokenAuthentication from seahub.api2.permissions import CanInviteGuest from seahub.api2.throttling import UserRateThrottle from seahub.api2.utils import api_error from seahub.invitations.models import Invitation +from seahub.base.accounts import User +from post_office.models import STATUS +from seahub.utils.mail import send_html_email_with_dj_template, MAIL_PRIORITY +from seahub.utils import get_site_name +logger = logging.getLogger(__name__) json_content_type = 'application/json; charset=utf-8' def invitation_owner_check(func): @@ -50,3 +58,63 @@ class InvitationView(APIView): return Response({ }, status=204) + + +class InvitationRevokeView(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated, CanInviteGuest) + throttle_classes = (UserRateThrottle, ) + + def post(self, request, token, format=None): + """Revoke invitation when the accepter successfully creates an account. + And set the account to inactive. + """ + # recourse check + invitation = Invitation.objects.get_by_token(token) + if not invitation: + error_msg = "Invitation not found." + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # permission check + if request.user.username != invitation.inviter: + error_msg = "Permission denied." + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + if invitation.accept_time is None: + error_msg = "The email address didn't accept the invitation." + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + email = invitation.accepter + inviter = invitation.inviter + + try: + user = User.objects.get(email) + except User.DoesNotExist: + error_msg = 'User %s not found.' % email + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # set the account to inactive. + user.freeze_user() + + # delete the invitation. + invitation.delete() + + # send email + site_name = get_site_name() + subject = _('%(user)s revoke your access to %(site_name)s.') % { + 'user': inviter, 'site_name': site_name} + context = { + 'inviter': inviter, + 'site_name': site_name, + } + + m = send_html_email_with_dj_template( + email, dj_template='invitations/invitation_revoke_email.html', + subject=subject, + context=context, + priority=MAIL_PRIORITY.now + ) + if m.status != STATUS.sent: + logger.warning('send revoke access email to %s failed') + + return Response({'success': True}) diff --git a/seahub/api2/endpoints/invitations.py b/seahub/api2/endpoints/invitations.py index cc885907ca..a3422bd62b 100644 --- a/seahub/api2/endpoints/invitations.py +++ b/seahub/api2/endpoints/invitations.py @@ -59,14 +59,13 @@ class InvitationsView(APIView): _('%s is already invited.') % accepter) try: - User.objects.get(accepter) - user_exists = True + user = User.objects.get(accepter) + # user is active return exist + if user.is_active is True: + return api_error(status.HTTP_400_BAD_REQUEST, + _('User %s already exists.') % accepter) except User.DoesNotExist: - user_exists = False - - if user_exists: - return api_error(status.HTTP_400_BAD_REQUEST, - _('User %s already exists.') % accepter) + pass i = Invitation.objects.add(inviter=request.user.username, accepter=accepter) @@ -127,22 +126,26 @@ class InvitationsBatchView(APIView): continue try: - User.objects.get(accepter) - result['failed'].append({ - 'email': accepter, - 'error_msg': _('User %s already exists.') % accepter - }) - continue - except User.DoesNotExist: - i = Invitation.objects.add(inviter=request.user.username, - accepter=accepter) - m = i.send_to(email=accepter) - if m.status == STATUS.sent: - result['success'].append(i.to_dict()) - else: + user = User.objects.get(accepter) + # user is active return exist + if user.is_active is True: result['failed'].append({ 'email': accepter, - 'error_msg': _('Internal Server Error'), - }) + 'error_msg': _('User %s already exists.') % accepter + }) + continue + except User.DoesNotExist: + pass + + i = Invitation.objects.add(inviter=request.user.username, + accepter=accepter) + m = i.send_to(email=accepter) + if m.status == STATUS.sent: + result['success'].append(i.to_dict()) + else: + result['failed'].append({ + 'email': accepter, + 'error_msg': _('Internal Server Error'), + }) return Response(result) diff --git a/seahub/api2/views.py b/seahub/api2/views.py index e136bc3da5..7d5ab86e79 100644 --- a/seahub/api2/views.py +++ b/seahub/api2/views.py @@ -999,7 +999,7 @@ class Repos(APIView): storage_id = request.data.get("storage_id", None) if storage_id and storage_id not in [s['storage_id'] for s in storages]: error_msg = 'storage_id invalid.' - return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + return None, api_error(status.HTTP_400_BAD_REQUEST, error_msg) repo_id = seafile_api.create_repo(repo_name, repo_desc, username, passwd, @@ -1043,7 +1043,7 @@ class Repos(APIView): salt = request.data.get('salt', '') if not salt: error_msg = 'salt invalid.' - return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + return None, api_error(status.HTTP_400_BAD_REQUEST, error_msg) if len(magic) != 64 or len(random_key) != 96 or enc_version < 0: return None, api_error(status.HTTP_400_BAD_REQUEST, diff --git a/seahub/invitations/models.py b/seahub/invitations/models.py index 8bb34d94ba..6e6d0b364d 100644 --- a/seahub/invitations/models.py +++ b/seahub/invitations/models.py @@ -31,6 +31,13 @@ class InvitationManager(models.Manager): def delete_all_expire_invitation(self): super(InvitationManager, self).filter(expire_time__lte=timezone.now()).delete() + def get_by_token(self, token): + qs = self.filter(token=token) + if qs.count() > 0: + return qs[0] + return None + + class Invitation(models.Model): INVITE_TYPE_CHOICES = ( (GUEST, _('Guest')), diff --git a/seahub/invitations/templates/invitations/invitation_revoke_email.html b/seahub/invitations/templates/invitations/invitation_revoke_email.html new file mode 100644 index 0000000000..6a9c2fb04e --- /dev/null +++ b/seahub/invitations/templates/invitations/invitation_revoke_email.html @@ -0,0 +1,18 @@ +{% extends 'email_base.html' %} + +{% load i18n %} + +{% block email_con %} + +{% autoescape off %} + +

          {% trans "Hi," %}

          + +

          +{% blocktrans %}{{ inviter }} revoke your access to {{ site_name }}.{% endblocktrans %} +

          + + +{% endautoescape %} + +{% endblock %} \ No newline at end of file diff --git a/seahub/invitations/views.py b/seahub/invitations/views.py index 273d13e941..8283ed526b 100644 --- a/seahub/invitations/views.py +++ b/seahub/invitations/views.py @@ -5,7 +5,7 @@ from django.shortcuts import get_object_or_404, render from django.utils.translation import ugettext as _ -from seahub.auth import login as auth_login +from seahub.auth import login as auth_login, authenticate from seahub.auth import get_backends from seahub.base.accounts import User from seahub.constants import GUEST_USER @@ -21,36 +21,55 @@ def token_view(request, token): if i.is_expired(): raise Http404 + if request.method == 'GET': + try: + user = User.objects.get(email=i.accepter) + if user.is_active is True: + # user is active return exist + messages.error(request, _('A user with this email already exists.')) + except User.DoesNotExist: + pass + + return render(request, 'invitations/token_view.html', {'iv': i, }) + if request.method == 'POST': passwd = request.POST.get('password', '') if not passwd: return HttpResponseRedirect(request.META.get('HTTP_REFERER')) try: - User.objects.get(email=i.accepter) - messages.error(request, _('A user with this email already exists.')) + user = User.objects.get(email=i.accepter) + if user.is_active is True: + # user is active return exist + messages.error(request, _('A user with this email already exists.')) + return render(request, 'invitations/token_view.html', {'iv': i, }) + else: + # user is inactive then set active and new password + user.set_password(passwd) + user.is_active = True + user.save() + user = authenticate(username=user.username, password=passwd) + except User.DoesNotExist: - # Create user, set that user as guest, and log user in. - u = User.objects.create_user(email=i.accepter, password=passwd, - is_active=True) - User.objects.update_role(u.username, GUEST_USER) - - i.accept() # Update invitaion accept time. - + # Create user, set that user as guest. + user = User.objects.create_user( + email=i.accepter, password=passwd, is_active=True) + User.objects.update_role(user.username, GUEST_USER) for backend in get_backends(): - u.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__) - auth_login(request, u) + user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__) - # send signal to notify inviter - accept_guest_invitation_successful.send( - sender=None, invitation_obj=i) + # Update invitation accept time. + i.accept() - # send email to notify admin - if NOTIFY_ADMIN_AFTER_REGISTRATION: - notify_admins_on_register_complete(u.email) + # login + auth_login(request, user) - return HttpResponseRedirect(SITE_ROOT) + # send signal to notify inviter + accept_guest_invitation_successful.send( + sender=None, invitation_obj=i) - return render(request, 'invitations/token_view.html', { - 'iv': i, - }) + # send email to notify admin + if NOTIFY_ADMIN_AFTER_REGISTRATION: + notify_admins_on_register_complete(user.email) + + return HttpResponseRedirect(SITE_ROOT) diff --git a/seahub/share/utils.py b/seahub/share/utils.py index 68ad86f110..a88eecbafd 100644 --- a/seahub/share/utils.py +++ b/seahub/share/utils.py @@ -12,30 +12,40 @@ logger = logging.getLogger(__name__) def is_repo_admin(username, repo_id): - repo_owner = seafile_api.get_repo_owner(repo_id) - + # repo is shared to user with admin permission try: - if '@seafile_group' in repo_owner: - # is group owned repo - group_id = int(repo_owner.split('@')[0]) + user_share_permission = ExtraSharePermission.objects. \ + get_user_permission(repo_id, username) + if user_share_permission == PERMISSION_ADMIN: + return True + + # get all groups that repo is shared to with admin permission + group_ids = ExtraGroupsSharePermission.objects.get_admin_groups_by_repo(repo_id) + for group_id in group_ids: if is_group_admin(group_id, username): return True - else: - user_share_permission = ExtraSharePermission.objects.\ - get_user_permission(repo_id, username) - if user_share_permission == PERMISSION_ADMIN: - return True - - # get all groups that repo is shared to with admin permission - group_ids = ExtraGroupsSharePermission.objects.get_admin_groups_by_repo(repo_id) - for group_id in group_ids: - if is_group_admin(group_id, username): - return True - return False except Exception as e: logger.error(e) return False + repo_owner = seafile_api.get_repo_owner(repo_id) or seafile_api.get_org_repo_owner(repo_id) + if not repo_owner: + logger.error('repo %s owner is None' % repo_id) + return False + + # repo owner + if username == repo_owner: + return True + + # user is department admin + if '@seafile_group' in repo_owner: + # is group owned repo + group_id = int(repo_owner.split('@')[0]) + if is_group_admin(group_id, username): + return True + + return False + def share_dir_to_user(repo, path, owner, share_from, share_to, permission, org_id=None): # Share repo or subdir to user with permission(r, rw, admin). extra_share_permission = '' diff --git a/seahub/urls.py b/seahub/urls.py index 7cc7a9f8f0..311d52c9cb 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -69,7 +69,7 @@ from seahub.api2.endpoints.copy_move_task import CopyMoveTaskView from seahub.api2.endpoints.query_copy_move_progress import QueryCopyMoveProgressView from seahub.api2.endpoints.move_folder_merge import MoveFolderMergeView from seahub.api2.endpoints.invitations import InvitationsView, InvitationsBatchView -from seahub.api2.endpoints.invitation import InvitationView +from seahub.api2.endpoints.invitation import InvitationView, InvitationRevokeView from seahub.api2.endpoints.notifications import NotificationsView, NotificationView from seahub.api2.endpoints.user_enabled_modules import UserEnabledModulesView from seahub.api2.endpoints.repo_file_uploaded_bytes import RepoFileUploadedBytesView @@ -407,6 +407,7 @@ urlpatterns = [ url(r'^api/v2.1/invitations/$', InvitationsView.as_view()), url(r'^api/v2.1/invitations/batch/$', InvitationsBatchView.as_view()), url(r'^api/v2.1/invitations/(?P[a-f0-9]{32})/$', InvitationView.as_view()), + url(r'^api/v2.1/invitations/(?P[a-f0-9]{32})/revoke/$', InvitationRevokeView.as_view()), ## user::avatar url(r'^api/v2.1/user-avatar/$', UserAvatarView.as_view(), name='api-v2.1-user-avatar'), diff --git a/tests/api/endpoints/test_invitation.py b/tests/api/endpoints/test_invitation.py index f748e3f6be..a448c63b97 100644 --- a/tests/api/endpoints/test_invitation.py +++ b/tests/api/endpoints/test_invitation.py @@ -5,6 +5,9 @@ from seahub.base.accounts import UserPermissions from seahub.invitations.models import Invitation from seahub.test_utils import BaseTestCase from seahub.api2.permissions import CanInviteGuest +from tests.common.utils import randstring +from seahub.base.accounts import User +from django.core.urlresolvers import reverse class InvitationsTest(BaseTestCase): @@ -69,3 +72,98 @@ class InvitationsTest(BaseTestCase): self.assertEqual(204, resp.status_code) assert len(Invitation.objects.all()) == 0 + + +class InvitationRevokeTest(BaseTestCase): + def setUp(self): + self.login_as(self.user) + self.username = self.user.username + self.tmp_username = 'user_%s@test.com' % randstring(4) + + # add invitation + self.i = Invitation.objects.add(inviter=self.username, accepter=self.tmp_username) + self.endpoint = '/api/v2.1/invitations/' + self.i.token + '/revoke/' + assert len(Invitation.objects.all()) == 1 + + # accept invitation + self.i.accept() + self.tmp_user = self.create_user(self.tmp_username, is_staff=False) + assert self.tmp_user.is_active is True + + def tearDown(self): + self.remove_user(self.tmp_username) + + @patch.object(CanInviteGuest, 'has_permission') + @patch.object(UserPermissions, 'can_invite_guest') + def test_can_post(self, mock_can_invite_guest, mock_has_permission): + + mock_can_invite_guest.return_val = True + mock_has_permission.return_val = True + + resp = self.client.post(self.endpoint) + self.assertEqual(200, resp.status_code) + tmp_user = User.objects.get(self.tmp_username) + + assert len(Invitation.objects.all()) == 0 + assert tmp_user.is_active is False + + @patch.object(CanInviteGuest, 'has_permission') + @patch.object(UserPermissions, 'can_invite_guest') + def test_can_invite_again_after_revoke(self, mock_can_invite_guest, mock_has_permission): + mock_can_invite_guest.return_val = True + mock_has_permission.return_val = True + + # revoke + resp = self.client.post(self.endpoint) + self.assertEqual(200, resp.status_code) + tmp_user = User.objects.get(self.tmp_username) + + assert len(Invitation.objects.all()) == 0 + assert tmp_user.is_active is False + + # invite again + invite_endpoint = '/api/v2.1/invitations/' + resp = self.client.post(invite_endpoint, { + 'type': 'guest', + 'accepter': self.tmp_username, + }) + self.assertEqual(201, resp.status_code) + assert len(Invitation.objects.all()) == 1 + + @patch.object(CanInviteGuest, 'has_permission') + @patch.object(UserPermissions, 'can_invite_guest') + def test_can_invite_batch_again_and_accept_again_after_revoke(self, mock_can_invite_guest, mock_has_permission): + + mock_can_invite_guest.return_val = True + mock_has_permission.return_val = True + + # revoke + resp = self.client.post(self.endpoint) + self.assertEqual(200, resp.status_code) + tmp_user = User.objects.get(self.tmp_username) + + assert len(Invitation.objects.all()) == 0 + assert tmp_user.is_active is False + + # invite again + invite_batch_endpoint = '/api/v2.1/invitations/batch/' + resp = self.client.post(invite_batch_endpoint, { + 'type': 'guest', + 'accepter': [self.tmp_username, ], + }) + self.assertEqual(200, resp.status_code) + assert len(Invitation.objects.all()) == 1 + + # accept again + self.logout() + + iv = Invitation.objects.all()[0] + token_endpoint = reverse('invitations:token_view', args=[iv.token]) + assert iv.accept_time is None + resp = self.client.post(token_endpoint, { + 'password': 'passwd' + }) + self.assertEqual(302, resp.status_code) + assert Invitation.objects.get(pk=iv.pk).accept_time is not None + tmp_user_accept = User.objects.get(self.tmp_username) + assert tmp_user_accept.is_active is True