diff --git a/frontend/config/webpack.entry.js b/frontend/config/webpack.entry.js index 1f1d8645d19..b535d44bc71 100644 --- a/frontend/config/webpack.entry.js +++ b/frontend/config/webpack.entry.js @@ -39,6 +39,8 @@ const entryFiles = { sysAdmin: '/pages/sys-admin', search: '/pages/search', uploadLink: '/pages/upload-link', + subscription: '/subscription.js', + institutionAdmin: '/pages/institution-admin/index.js' }; const getEntries = (isEnvDevelopment) => { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d55c799bca3..4fdaa340902 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,7 +14,7 @@ "@gatsbyjs/reach-router": "1.3.9", "@seafile/react-image-lightbox": "2.0.2", "@seafile/resumablejs": "1.1.16", - "@seafile/sdoc-editor": "0.5.57", + "@seafile/sdoc-editor": "0.5.60", "@seafile/seafile-calendar": "0.0.12", "@seafile/seafile-editor": "1.0.82", "@uiw/codemirror-extensions-langs": "^4.19.4", @@ -4617,9 +4617,9 @@ "integrity": "sha512-8rBbmAEuuwOAGHYGCtEzpx+bxAcGS+V30otMmhRe7bPAdh4E57RWgCa8x7pkzHGFlY1t5d+ILz1gojvPVMYQig==" }, "node_modules/@seafile/sdoc-editor": { - "version": "0.5.57", - "resolved": "https://registry.npmjs.org/@seafile/sdoc-editor/-/sdoc-editor-0.5.57.tgz", - "integrity": "sha512-6b+3dlbI88gKdJPKQLz/GEb4QDFlxYNFAxql0QvS6f62tbkD3JKqlL1VdObJLAcRy0AtRMskr/sCMRkCLCvFVg==", + "version": "0.5.60", + "resolved": "https://registry.npmjs.org/@seafile/sdoc-editor/-/sdoc-editor-0.5.60.tgz", + "integrity": "sha512-StuX2ScBP4AkJeDZ3v679AqESMF4N3mCFyY3b0m6W3D39b1FFZX2E0OchMUEcG/AVqM2zqy4nRv9AomE26MqDw==", "dependencies": { "@seafile/print-js": "1.6.5", "@seafile/react-image-lightbox": "2.0.4", @@ -31292,9 +31292,9 @@ "integrity": "sha512-8rBbmAEuuwOAGHYGCtEzpx+bxAcGS+V30otMmhRe7bPAdh4E57RWgCa8x7pkzHGFlY1t5d+ILz1gojvPVMYQig==" }, "@seafile/sdoc-editor": { - "version": "0.5.57", - "resolved": "https://registry.npmjs.org/@seafile/sdoc-editor/-/sdoc-editor-0.5.57.tgz", - "integrity": "sha512-6b+3dlbI88gKdJPKQLz/GEb4QDFlxYNFAxql0QvS6f62tbkD3JKqlL1VdObJLAcRy0AtRMskr/sCMRkCLCvFVg==", + "version": "0.5.60", + "resolved": "https://registry.npmjs.org/@seafile/sdoc-editor/-/sdoc-editor-0.5.60.tgz", + "integrity": "sha512-StuX2ScBP4AkJeDZ3v679AqESMF4N3mCFyY3b0m6W3D39b1FFZX2E0OchMUEcG/AVqM2zqy4nRv9AomE26MqDw==", "requires": { "@seafile/print-js": "1.6.5", "@seafile/react-image-lightbox": "2.0.4", diff --git a/frontend/package.json b/frontend/package.json index 76cc6189169..23ab28a874a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,7 @@ "@gatsbyjs/reach-router": "1.3.9", "@seafile/react-image-lightbox": "2.0.2", "@seafile/resumablejs": "1.1.16", - "@seafile/sdoc-editor": "0.5.57", + "@seafile/sdoc-editor": "0.5.60", "@seafile/seafile-calendar": "0.0.12", "@seafile/seafile-editor": "1.0.82", "@uiw/codemirror-extensions-langs": "^4.19.4", diff --git a/frontend/src/assets/icons/currency.svg b/frontend/src/assets/icons/currency.svg new file mode 100644 index 00000000000..23bc368565e --- /dev/null +++ b/frontend/src/assets/icons/currency.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/common/account.js b/frontend/src/components/common/account.js index 1f991c1b567..379e3d52250 100644 --- a/frontend/src/components/common/account.js +++ b/frontend/src/components/common/account.js @@ -6,6 +6,10 @@ import { seafileAPI } from '../../utils/seafile-api'; import { siteRoot, gettext, appAvatarURL, enableSSOToThirdpartWebsite } from '../../utils/constants'; import toaster from '../toast'; +const { + isOrgContext, +} = window.app.pageOptions; + const propTypes = { isAdminPanel: PropTypes.bool, }; @@ -22,6 +26,7 @@ class Account extends Component { isStaff: false, isOrgStaff: false, usageRate: '', + enableSubscription: false, }; this.isFirstMounted = true; } @@ -81,6 +86,7 @@ class Account extends Component { isInstAdmin: resp.data.is_inst_admin, isOrgStaff: resp.data.is_org_staff === 1 ? true : false, showInfo: !this.state.showInfo, + enableSubscription: resp.data.enable_subscription, }); }).catch(error => { let errMessage = Utils.getErrorMsg(error); @@ -163,6 +169,7 @@ class Account extends Component { {gettext('Settings')} + {(this.state.enableSubscription && !isOrgContext) && {'付费管理'}} {this.renderMenu()} {enableSSOToThirdpartWebsite && {gettext('Customer Portal')}} {gettext('Log out')} diff --git a/frontend/src/components/subscription.js b/frontend/src/components/subscription.js new file mode 100644 index 00000000000..e1c00945bb0 --- /dev/null +++ b/frontend/src/components/subscription.js @@ -0,0 +1,523 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import toaster from './toast'; +import { Modal, ModalHeader, ModalBody, ModalFooter, InputGroup, InputGroupAddon, InputGroupText, Input, Button } from 'reactstrap'; +import { gettext, serviceURL } from '../utils/constants'; +import { Utils } from '../utils/utils'; +import { subscriptionAPI } from '../utils/subscription-api'; +import Loading from './loading'; + +import '../css/layout.css'; +import '../css/subscription.css'; + +const { + isOrgContext, +} = window.app.pageOptions; + + +const PlansPropTypes = { + plans: PropTypes.array.isRequired, + onPay: PropTypes.func.isRequired, + paymentType: PropTypes.string.isRequired, + handleContentScroll: PropTypes.func.isRequired, +}; + +class Plans extends Component { + constructor(props) { + super(props); + this.state = { + currentPlan: props.plans[0], + assetQuotaUnitCount: 1, + count: 1, + }; + } + + togglePlan = (plan) => { + this.setState({currentPlan: plan}, () => { + }); + }; + + onPay = () => { + let { paymentType } = this.props; + let { currentPlan, assetQuotaUnitCount, count } = this.state; + let totalAmount, assetQuota, newUserCount; + + // parse + if (paymentType === 'paid') { + newUserCount = currentPlan.count; + totalAmount = currentPlan.total_amount; + } else if (paymentType === 'extend_time') { + newUserCount = currentPlan.count; + assetQuota = currentPlan.asset_quota; + totalAmount = currentPlan.total_amount; + } else if (paymentType === 'add_user') { + newUserCount = count; + totalAmount = count * currentPlan.price_per_user; + } else if (paymentType === 'buy_quota') { + assetQuota = (assetQuotaUnitCount) * currentPlan.asset_quota_unit; + totalAmount = assetQuotaUnitCount * currentPlan.price_per_asset_quota_unit; + } else { + toaster.danger(gettext('Internal Server Error.')); + return; + } + + this.props.onPay(currentPlan.plan_id, newUserCount, assetQuota, totalAmount); + }; + + onCountInputChange = (e) => { + let { currentPlan } = this.state; + if (!currentPlan.can_custom_count) { + return; + } + let count = e.target.value.replace(/^(0+)|[^\d]+/g, ''); + if (count < 1) { + count = 1; + } else if (count > 9999) { + count = 9999; + } + this.setState({count: count}); + }; + + onAssetQuotaUnitCountInputChange = (e) => { + let { currentPlan } = this.state; + if (!currentPlan.can_custom_asset_quota) { + return; + } + let count = e.target.value.replace(/^(0+)|[^\d]+/g, ''); + if (count < 1) { + count = 1; + } else if (count > 9999) { + count = 9999; + } + this.setState({assetQuotaUnitCount: count}); + }; + + renderPaidOrExtendTime = () => { + let { plans, paymentType } = this.props; + let { currentPlan } = this.state; + let boughtQuota = 0; + if (paymentType === 'extend_time') { + boughtQuota = currentPlan.asset_quota - 100; + } + let totalAmount = currentPlan.total_amount; + let originalTotalAmount = totalAmount; + return ( +
+ {'选择方案'} +
+ {plans.map((item, index) => { + let selectedCss = item.plan_id === currentPlan.plan_id ? 'plan-selected' : ''; + let countDescription = '¥' + item.price_per_user; + if (isOrgContext) { + countDescription += '/每用户'; + } + return ( +
+ {item.name} + {countDescription} +
+ ); + })} +
+ + {paymentType === 'extend_time' && boughtQuota > 0 && + + {'增加空间'} +
+
+ {currentPlan.asset_quota_unit + 'GB x ' + (boughtQuota / currentPlan.asset_quota_unit)} + {/* 续费时候需要减去附赠的100GB */} + {'¥' + (boughtQuota / currentPlan.asset_quota_unit) * currentPlan.price_per_asset_quota_unit} +
+
+
+ } + + {'方案汇总'} +
+
+
+ {'所选方案'} + {currentPlan.name} +
+ {isOrgContext && +
+ {'成员人数'} + {currentPlan.count + '人'} +
+ } +
+ {'可用空间'} + {'100GB(附赠)' + (boughtQuota > 0 ? '+' + boughtQuota + 'GB(扩充)' : '')} +
+
+ {'到期时间'} + {currentPlan.new_term_end} +
+
+ {'实际支付金额'} + + {originalTotalAmount !== totalAmount && + {'¥' + originalTotalAmount} + } + {'¥' + totalAmount + ' '} + +
+
+
+ +
+ ); + }; + + renderAddUser = () => { + let { currentPlan, count } = this.state; + let operationIntro = '新增用户'; + let originalTotalAmount = count * currentPlan.price_per_user; + let totalAmount = originalTotalAmount; + return ( +
+
+

{currentPlan.name}

+ + {'¥ '}{currentPlan.price}{' ' + currentPlan.description} + + + + {operationIntro} + + + + + {'总价 ¥ ' + totalAmount} + {originalTotalAmount !== totalAmount && + {' ¥' + originalTotalAmount} + } + + {'有效期至 ' + currentPlan.new_term_end} + {'注:当有效期剩余天数少于计划中的时候,增加用户的价格按天来计算'} + +
+ ); + }; + + renderBuyQuota = () => { + let { currentPlan, assetQuotaUnitCount } = this.state; + let operationIntro = '新增空间'; + let originalTotalAmount = assetQuotaUnitCount * currentPlan.price_per_asset_quota_unit; + let totalAmount = originalTotalAmount; + return ( +
+
+

{currentPlan.name}

+ + {'¥ '}{currentPlan.asset_quota_price}{' ' + currentPlan.asset_quota_description} + + + + {operationIntro} + + + + {' x ' + currentPlan.asset_quota_unit + 'GB'} + + + + {'总价 ¥ ' + totalAmount} + {originalTotalAmount !== totalAmount && + {' ¥' + originalTotalAmount} + } + + {'有效期至 ' + currentPlan.new_term_end} + {'注:当有效期剩余天数少于计划中的时候,增加空间的价格按天来计算'} + +
+ ); + }; + + render() { + let { paymentType } = this.props; + if (paymentType === 'paid' || paymentType === 'extend_time') { + return this.renderPaidOrExtendTime(); + } else if (paymentType === 'add_user') { + return this.renderAddUser(); + } else if (paymentType === 'buy_quota') { + return this.renderBuyQuota(); + } else { + toaster.danger(gettext('Internal Server Error.')); + return; + } + } +} + +Plans.propTypes = PlansPropTypes; + +const PlansDialogPropTypes = { + isOrgContext: PropTypes.bool.isRequired, + paymentType: PropTypes.string.isRequired, + paymentTypeTrans: PropTypes.string.isRequired, + toggleDialog: PropTypes.func.isRequired, +}; + +class PlansDialog extends Component { + + constructor(props) { + super(props); + this.state = { + isLoading: true, + isWaiting: false, + planList: [], + paymentSourceList: [], + }; + } + + getPlans = () => { + subscriptionAPI.getSubscriptionPlans(this.props.paymentType).then((res) => { + this.setState({ + planList: res.data.plan_list, + paymentSourceList: res.data.payment_source_list, + isLoading: false, + }); + }).catch(error => { + let errorMsg = Utils.getErrorMsg(error); + this.setState({ + isLoading: false, + errorMsg: errorMsg, + }); + }); + }; + + onPay = (planID, count, asset_quota, totalAmount) => { + this.setState({ isWaiting: true }); + let payUrl = serviceURL + '/subscription/pay/?payment_source=' + this.state.paymentSourceList[0] + + '&payment_type=' + this.props.paymentType + '&plan_id=' + planID + + '&total_amount=' + totalAmount; + if (count) { + payUrl += '&count=' + count; + } + if (asset_quota) { + payUrl += '&asset_quota=' + asset_quota; + } + window.open(payUrl); + }; + + onReload = () => { + window.location.reload(); + }; + + componentDidMount() { + this.getPlans(); + } + + render() { + const { isLoading, isWaiting, planList } = this.state; + const { toggleDialog, paymentTypeTrans, paymentType } = this.props; + const modalStyle = (paymentType === 'paid' || paymentType === 'extend_time') ? + {width: '560px', maxWidth: '560px'} : {width: '560px'}; + + if (isLoading) { + return ( + + {paymentTypeTrans} + + + + + ); + } + if (isWaiting) { + return ( + + {paymentTypeTrans} + +
{'是否完成付款?'}
+
+ + + +
+ ); + } + return ( + + {paymentTypeTrans} + +
+ +
+
+
+ ); + } +} + +PlansDialog.propTypes = PlansDialogPropTypes; + +const propTypes = { + isOrgContext: PropTypes.bool.isRequired, + handleContentScroll: PropTypes.func, +}; + +class Subscription extends Component { + + constructor(props) { + super(props); + this.paymentTypeTransMap = { + paid: '立即购买', + extend_time: '立即续费', + add_user: '增加用户', + buy_quota: '增加空间', + }; + this.state = { + isLoading: true, + errorMsg: '', + isDialogOpen: false, + planName: this.props.isOrgContext ? '团队版' : '个人版', + userLimit: 20, + assetQuota: 1, + termEnd: '长期', + subscription: null, + paymentTypeList: [], + currentPaymentType: '', + errorMsgCode: '' + }; + } + + getSubscription = () => { + subscriptionAPI.getSubscription().then((res) => { + const subscription = res.data.subscription; + const paymentTypeList = res.data.payment_type_list; + if (!subscription) { + this.setState({ + isLoading: false, + paymentTypeList: paymentTypeList, + }); + } else { + let isActive = subscription.is_active; + let plan = subscription.plan; + this.setState({ + isLoading: false, + subscription, + planName: plan.name, + userLimit: subscription.user_limit, + assetQuota: isActive ? subscription.asset_quota : plan.asset_quota, + termEnd: isActive ? subscription.term_end : '已过期', + paymentTypeList: paymentTypeList, + }); + } + }).catch(error => { + let errorMsg = Utils.getErrorMsg(error); + this.setState({ + isLoading: false, + errorMsg: errorMsg, + }); + }); + }; + + toggleDialog = () => { + this.setState({ isDialogOpen: !this.state.isDialogOpen }); + }; + + togglePaymentType = (paymentType) => { + this.setState({ currentPaymentType: paymentType }); + this.toggleDialog(); + }; + + componentDidMount() { + this.getSubscription(); + } + + render() { + const { isLoading, errorMsg, planName, userLimit, assetQuota, termEnd, + isDialogOpen, paymentTypeList, currentPaymentType } = this.state; + if (isLoading) { + return ; + } + if (errorMsg) { + return

{errorMsg}

; + } + return ( + +
+
+

{'当前版本'}

+

{planName}

+
+ {this.props.isOrgContext && +
+

{'用户数限制'}

+

{userLimit}

+
+ } +
+

{'空间'}

+

{assetQuota ? assetQuota + 'GB' : '1GB'}

+
+
+

{'订阅有效期'}

+

{termEnd}

+
+
+

{'云服务付费方案'}

+

+ {'查看详情'} +

+
+ {paymentTypeList.map((item, index) => { + let name = this.paymentTypeTransMap[item]; + return ( + + ); + })} + {!this.state.subscription && +
+

{'销售咨询'}

+ +

{'微信扫码联系销售'}

+
+ } +
+ {isDialogOpen && + + } +
+ ); + } +} + +Subscription.propTypes = propTypes; + +export default Subscription; diff --git a/frontend/src/css/subscription.css b/frontend/src/css/subscription.css new file mode 100644 index 00000000000..7b02a0d57a2 --- /dev/null +++ b/frontend/src/css/subscription.css @@ -0,0 +1,230 @@ +.content { + padding: 0rem 1rem 8rem; + overflow: auto; + } + + .content .dropdown .btn-outline-primary { + color: #f19645; + background-color: transparent; + background-image: none; + border-color: #f19645; + } + + .content .dropdown .btn-outline-primary:hover { + color: #fff; + background-color: #f19645; + border-color: #f19645; + } + + .content .dropdown .btn-outline-primary:focus { + box-shadow: 0 0 0 2px rgba(241, 150, 69, 0.5); + } + + .content .dropdown .btn-outline-primary:active { + color: #fff; + background-color: #f19645; + border-color: #f19645; + } + + .content .dropdown .btn-outline-primary:not(:disabled):not(.disabled):active:focus, + .content .dropdown .btn-outline-primary:not(:disabled):not(.disabled).active:focus, + .content .dropdown .show > .btn-outline-primary.dropdown-toggle:focus { + box-shadow: 0 0 0 2px rgba(241, 150, 69, 0.5); + } + + .subscription-info { + margin: 1em 0 3em; + } + + .subscription-info-heading { + font-size: 1rem; + font-weight: normal; + padding-bottom: 0.2em; + border-bottom: 1px solid #ddd; + margin-bottom: 0.7em; + } + + .price-version-container-header { + max-width: 90%; + border-radius: 10px; + padding: 0 1rem 2rem; + box-shadow: 0 3px 8px 3px rgba(116, 129, 141, 0.15); + overflow: hidden; + margin: 0.5rem 1rem; + } + + .price-version-container-header .price-version-container-top { + height: 10px; + margin: 0 -1rem; + } + + .price-version-container-header .price-version-plan-name { + font-size: 1.5rem; + padding: 1rem 0; + border-bottom: 1px solid #ED7109; + } + + .price-version-container-header .price-table-btn-details { + color: #fff; + font-size: 1rem; + } + + .price-version-container-header .font-500 { + font-weight: 500; + } + + .price-version-container-header .price-version-plan-price { + font-size: 2rem; + } + + .price-version-container-header .price-version-plan-whole-price { + font-size: 24px; + } + + .price-version-container-header .price-version-plan-valid-day, + .price-version-container-header .price-version-plan-user-number { + font-size: 18px; + } + + .subscription-container { + width: 480px; + margin: 0.5rem auto; + } + + .price-version-container-header .user-quota-plan-name { + margin-bottom: 24px; + text-align: center; + border-bottom: 1px solid rgba(253, 150, 68, 0.4); + } + + .subscription-container .items-dl { + margin-top: 10px; + } + + .plan-description-item { + border: 1px solid #bdbdbd; + border-radius: 5px; + margin: 0 0 10px; + display: flex; + justify-content: space-between; + background-color: #f9f9f9; + line-height: 3; + padding: 0 20px; + cursor: pointer; + } + + .plan-description-item.plan-selected { + background-color: #fff5eb; + border-color: #ED7109; + border-width: 2px; + } + + .plan-description-item .plan-name { + font-size: 20px; + font-weight: 500; + } + + .plan-description-item .plan-description { + font-size: 20px; + font-weight: 500; + color: #ED7109; + } + + .order-item { + margin: 0px; + display: flex; + justify-content: space-between; + border-left: 1px solid #bdbdbd; + border-right: 1px solid #bdbdbd; + background-color: #f9f9f9; + padding: 5px 20px; + line-height: 2; + } + + .order-item.order-item-top { + border-top: 1px solid #bdbdbd; + border-radius: 5px 5px 0 0; + } + + .order-item.order-item-middle { + border-bottom: 1px solid #bdbdbd; + } + + .order-item.order-item-bottom { + border-bottom: 1px solid #bdbdbd; + border-radius: 0 0 5px 5px; + } + + .order-item .order-into { + color: #666666; + } + + .order-item .order-value { + font-weight: 500; + color: #000; + } + + .order-item .order-value .input-group-append, + .order-item .order-value .input-group-prepend { + height: 38px; + } + + .order-item .order-price { + font-size: 26px; + font-weight: 500; + color: #ED7109; + } + + .order-item .order-price span:first-child { + font-size: 16px; + } + + .subscription-submit { + background-color: #ED7109; + border-radius: 5px; + width: 100%; + margin-bottom: 10px; + color: white; + height: 44px; + font-size: 16px; + } + + .subscription-notice { + font-size: 14px; + color: #9a9a9a; + } + + .subscription-container .subscription-subtitle { + font-size: 18px; + font-weight: 500; + } + + .subscription-container .subscription-list.order-item-top.order-item-bottom { + border-radius: 5px; + } + + .subscription-container .subscription-list span { + height: 36px; + line-height: 36px; + } + + .subscription-container .text-orange { + color: #ED7109 !important; + } + + .user-numbers.input-group .input-group-prepend { + width: 50%; + } + + .space-quota .input-group .input-group-append, + .space-quota .input-group .input-group-prepend { + width: 33.33%; + } + + .subscription-add-space .input-group .input-group-append .input-group-text, + .price-version-container-header .input-group .input-group-prepend .input-group-text { + width: 100%; + text-align: center; + font-weight: 500; + display: block; + } diff --git a/frontend/src/pages/institution-admin/api/index.js b/frontend/src/pages/institution-admin/api/index.js new file mode 100644 index 00000000000..76693a42006 --- /dev/null +++ b/frontend/src/pages/institution-admin/api/index.js @@ -0,0 +1,122 @@ +import axios from 'axios'; +import cookie from 'react-cookies'; +import { siteRoot } from '../../../utils/constants'; + +class InstAdminAPI { + + init({ server, username, password, token }) { + this.server = server; + this.username = username; + this.password = password; + this.token = token; //none + if (this.token && this.server) { + this.req = axios.create({ + baseURL: this.server, + headers: { 'Authorization': 'Token ' + this.token }, + }); + } + return this; + } + + initForSeahubUsage({ siteRoot, xcsrfHeaders }) { + if (siteRoot && siteRoot.charAt(siteRoot.length-1) === '/') { + var server = siteRoot.substring(0, siteRoot.length-1); + this.server = server; + } else { + this.server = siteRoot; + } + + this.req = axios.create({ + headers: { + 'X-CSRFToken': xcsrfHeaders, + } + }); + return this; + } + + _sendPostRequest(url, form) { + if (form.getHeaders) { + return this.req.post(url, form, { + headers:form.getHeaders() + }); + } else { + return this.req.post(url, form); + } + } + + getToken() { + const url = this.server + '/api2/auth-token/'; + axios.post(url, { + username: this.username, + password: this.password + }).then((response) => { + this.token = response.data; + return this.token; + }); + } + + listInstitutionUsers(page, perPage) { + const url = this.server + '/api/v2.1/institutions/admin/users/'; + let params = { + }; + if (page != undefined) { + params.page = page; + } + if (perPage != undefined) { + params.per_page = perPage; + } + return this.req.get(url); + } + + searchInstitutionUsers(q) { + const url = this.server + '/api/v2.1/institutions/admin/search-user/'; + const params = { + q: q + }; + return this.req.get(url, {params: params}); + } + + deleteInstitutionUser(email) { + const url = this.server + '/api/v2.1/institutions/admin/users/' + encodeURIComponent(email) + '/'; + return this.req.delete(url); + } + + getInstitutionUserInfo(email) { + const url = this.server + '/api/v2.1/institutions/admin/users/' + encodeURIComponent(email) + '/'; + return this.req.get(url); + } + + setInstitutionUserQuote(email, quota) { + const url = this.server + '/api/v2.1/institutions/admin/users/' + encodeURIComponent(email) + '/'; + const data = { + quota_total: quota + }; + return this.req.put(url, data); + } + + listInstitutionUserRepos(email) { + const url = this.server + '/api/v2.1/institutions/admin/users/' + encodeURIComponent(email) + '/libraries/'; + return this.req.get(url); + } + + listInstitutionUserGroups(email) { + const url = this.server + '/api/v2.1/institutions/admin/users/' + encodeURIComponent(email) + '/groups/'; + return this.req.get(url); + } + + updateInstitutionUserStatus(email, is_active) { + const url = this.server + '/api/v2.1/institutions/admin/users/' + encodeURIComponent(email) + '/'; + const data = { + is_active: is_active ? 'true' : 'false', + }; + return this.req.put(url, data); + } + +} + + +const instAdminAPI = new InstAdminAPI(); +const xcsrfHeaders = cookie.load('sfcsrftoken'); +instAdminAPI.initForSeahubUsage({ siteRoot, xcsrfHeaders }); + +export default instAdminAPI; diff --git a/frontend/src/pages/institution-admin/index.js b/frontend/src/pages/institution-admin/index.js new file mode 100644 index 00000000000..863de069f1a --- /dev/null +++ b/frontend/src/pages/institution-admin/index.js @@ -0,0 +1,32 @@ +import React, { useState } from 'react'; +import ReactDom from 'react-dom'; +import MediaQuery from 'react-responsive'; +import { Modal } from 'reactstrap'; +import SidePanel from './side-panel'; +import MainPanel from './main-panel'; + +import '../../css/layout.css'; +import '../../css/toolbar.css'; + +export default function Institutions() { + + const [isSidePanelClosed, setIsSidePanelClosed] = useState(false); + + const toggleSidePanel = () => { + setIsSidePanelClosed(!isSidePanelClosed); + }; + + return ( + <> +
+ + +
+ + + + + ); +} + +ReactDom.render(, document.getElementById('wrapper')); diff --git a/frontend/src/pages/institution-admin/main-panel.js b/frontend/src/pages/institution-admin/main-panel.js new file mode 100644 index 00000000000..5cb73d51d82 --- /dev/null +++ b/frontend/src/pages/institution-admin/main-panel.js @@ -0,0 +1,47 @@ +import React from 'react'; +import MainPanelTopbar from '../sys-admin/main-panel-topbar'; +import UserList from './user-list'; +import { Router, navigate } from '@gatsbyjs/reach-router'; +import UserContent from './user-content'; +import UsersNav from './users-nav'; +import UserInfo from './user-content/user-info'; +import UserRepos from './user-content/user-repos'; +import UserGroups from './user-content/user-groups'; +import { gettext, siteRoot } from '../../utils/constants'; +import Search from '../sys-admin/search'; +import UserListSearch from './user-list-search'; + +export default function MainPanel(props) { + + + const searchItems = (keyword) => { + navigate(`${siteRoot}inst/useradmin/search/?query=${encodeURIComponent(keyword)}`); + }; + + const getSearch = () => { + // offer 'Search' for 'DB' & 'LDAPImported' users + return ; + }; + + return ( +
+ +
+
+ + + + + + + + + + + + +
+
+
+ ); +} diff --git a/frontend/src/pages/institution-admin/side-panel.js b/frontend/src/pages/institution-admin/side-panel.js new file mode 100644 index 00000000000..11e6892ab00 --- /dev/null +++ b/frontend/src/pages/institution-admin/side-panel.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from '@gatsbyjs/reach-router'; +import Logo from '../../components/logo'; +import { gettext, siteRoot, institutionName } from '../../utils/constants'; + +const propTypes = { + isSidePanelClosed: PropTypes.bool.isRequired, + onCloseSidePanel: PropTypes.func.isRequired, +}; + +class SidePanel extends React.Component { + + render() { + return ( +
+
+ +
+
+
+
+

{institutionName}

+
    +
  • + + + {gettext('Users')} + +
  • +
+
+
+
+
+ ); + } +} + +SidePanel.propTypes = propTypes; + +export default SidePanel; diff --git a/frontend/src/pages/institution-admin/user-content/index.js b/frontend/src/pages/institution-admin/user-content/index.js new file mode 100644 index 00000000000..52f5b1b7c13 --- /dev/null +++ b/frontend/src/pages/institution-admin/user-content/index.js @@ -0,0 +1,37 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from '@gatsbyjs/reach-router'; +import { gettext, siteRoot } from '../../../utils/constants'; + +const NAV_ITEMS = [ + {name: 'info', urlPart: '', text: gettext('Info')}, + {name: 'owned-repos', urlPart: 'owned-libraries', text: gettext('Owned Libraries')}, + {name: 'groups', urlPart: 'groups', text: gettext('Groups')} +]; + +const UserContent = ({ children, ...rest }) => { + const nav = rest['*']; + const username = rest.email; + return ( + <> +
    + {NAV_ITEMS.map((item, index) => { + return ( +
  • + {item.text} +
  • + ); + })} +
+
+ {children} +
+ + ); +}; + +UserContent.propTypes = { + children: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), +}; + +export default UserContent; \ No newline at end of file diff --git a/frontend/src/pages/institution-admin/user-content/user-group-item.js b/frontend/src/pages/institution-admin/user-content/user-group-item.js new file mode 100644 index 00000000000..590ff884bb8 --- /dev/null +++ b/frontend/src/pages/institution-admin/user-content/user-group-item.js @@ -0,0 +1,49 @@ +import React, { useCallback, useState } from 'react'; +import PropTypes from 'prop-types'; +import { gettext, siteRoot } from '../../../utils/constants'; +import moment from 'moment'; +import { Link } from '@gatsbyjs/reach-router'; + +const UserGroupItem = ({ group }) => { + + const [highlight, setHighlight] = useState(false); + + const handleMouseEnter = useCallback(() => { + setHighlight(true); + }, []); + + const handleMouseLeave = useCallback(() => { + setHighlight(false); + }, []); + + const getRoleText = useCallback((group) => { + let roleText; + if (group.is_admin) { + roleText = gettext('Admin'); + return roleText; + } + + if (group.is_owner) { + roleText = gettext('Owner'); + return roleText; + } + + roleText = gettext('Member'); + return roleText; + }, []); + + return ( + + {group.name} + {getRoleText(group)} + {moment(group.created_at).format('YYYY-MM-DD HH:mm')} + + + ); +}; + +UserGroupItem.propTypes = { + group: PropTypes.object, +}; + +export default UserGroupItem; diff --git a/frontend/src/pages/institution-admin/user-content/user-groups.js b/frontend/src/pages/institution-admin/user-content/user-groups.js new file mode 100644 index 00000000000..9d3ba47edc0 --- /dev/null +++ b/frontend/src/pages/institution-admin/user-content/user-groups.js @@ -0,0 +1,52 @@ +import { useParams } from '@gatsbyjs/reach-router'; +import React, { useEffect, useState } from 'react'; +import { gettext } from '../../../utils/constants'; +import Loading from '../../../components/loading'; +import EmptyTip from '../../../components/empty-tip'; +import UserGroupItem from './user-group-item'; +import instAdminAPI from '../api'; + +export default function UsersGroups() { + + const [isLoading, setIsLoading] = useState(true); + const [groups, setGroups] = useState(null); + const params = useParams(); + + useEffect(() => { + instAdminAPI.listInstitutionUserGroups(decodeURIComponent(params.email)).then(res => { + const { groups_list } = res.data; + setGroups(groups_list); + setIsLoading(false); + }); + }, [params.email]); + + if (isLoading) { + return ; + } + + if (groups.length === 0) { + return ( + +

{gettext('This user has not created or joined any groups')}

+
+ ); + } + + return ( + + + + + + + + + + + {groups.map((group) => { + return ; + })} + +
{gettext('Name')}{gettext('Role')}{gettext('Create At')}{gettext('Operations')}
+ ); +} diff --git a/frontend/src/pages/institution-admin/user-content/user-info.js b/frontend/src/pages/institution-admin/user-content/user-info.js new file mode 100644 index 00000000000..d9f972045ae --- /dev/null +++ b/frontend/src/pages/institution-admin/user-content/user-info.js @@ -0,0 +1,72 @@ +import { useParams } from '@gatsbyjs/reach-router'; +import React, { useCallback, useEffect, useState } from 'react'; +import { Utils } from '../../../utils/utils'; +import { gettext } from '../../../utils/constants'; +import Loading from '../../../components/loading'; +import SetQuotaDialog from '../../../components/dialog/sysadmin-dialog/set-quota'; +import instAdminAPI from '../api'; + +export default function UserInfo() { + + const [isLoading, setIsLoading] = useState(true); + const [user, setUser] = useState(null); + const [isShowEditDialog, setIsShowEditDialog] = useState(false); + const params = useParams(); + + useEffect(() => { + instAdminAPI.getInstitutionUserInfo(decodeURIComponent(params.email)).then(res => { + const user = res.data; + setUser(user); + setIsLoading(false); + }); + }, [params.email]); + + const toggleSetQuotaDialog = useCallback(() => { + setIsShowEditDialog(!isShowEditDialog); + }, [isShowEditDialog]); + + const updateQuota = useCallback((quote) => { + instAdminAPI.setInstitutionUserQuote(user.email, quote).then(res => { + // convert value to mb + const newUser = {...user, quota_total: quote * 1000 * 1000}; + setUser(newUser); + }); + }, [user]); + + if (isLoading) { + return ; + } + + + return ( + <> +
+
{gettext('Avatar')}
+
+ {user.name} +
+ +
{gettext('Email')}
+
{user.email}
+ +
{gettext('Name')}
+
+ {user.name || '--'} +
+ +
{gettext('Space Used / Quota')}
+
+ {`${Utils.bytesToSize(user.quota_usage)} / ${user.quota_total > 0 ? Utils.bytesToSize(user.quota_total) : '--'}`} + + +
+
+ {isShowEditDialog && ( + + )} + + ); +} diff --git a/frontend/src/pages/institution-admin/user-content/user-repo-item.js b/frontend/src/pages/institution-admin/user-content/user-repo-item.js new file mode 100644 index 00000000000..f2a92f21244 --- /dev/null +++ b/frontend/src/pages/institution-admin/user-content/user-repo-item.js @@ -0,0 +1,50 @@ +import React, { useCallback, useState } from 'react'; +import { Link } from '@gatsbyjs/reach-router'; +import PropTypes from 'prop-types'; +import moment from 'moment'; +import { Utils } from '../../../utils/utils'; +import { enableSysAdminViewRepo, gettext, isPro, siteRoot } from '../../../utils/constants'; + +const UserRepoItem = ({ repo }) => { + + const [highlight, setHighlight] = useState(false); + + const handleMouseEnter = useCallback(() => { + setHighlight(true); + }, []); + + const handleMouseLeave = useCallback(() => { + setHighlight(false); + }, []); + + const renderRepoName = (repo) => { + if (repo.name) { + if (isPro && enableSysAdminViewRepo && !repo.encrypted) { + return {repo.name}; + } else { + return repo.name; + } + } else { + return gettext('Broken ({repo_id_placeholder})').replace('{repo_id_placeholder}', repo.id); + } + }; + + const iconUrl = Utils.getLibIconUrl(repo); + const iconTitle = Utils.getLibIconTitle(repo); + + return ( + + {iconTitle} + {renderRepoName(repo)} + {Utils.bytesToSize(repo.size)} + {moment(repo.last_modified).fromNow()} + + + ); +}; + +UserRepoItem.propTypes = { + repo: PropTypes.object, +}; + +export default UserRepoItem; diff --git a/frontend/src/pages/institution-admin/user-content/user-repos.js b/frontend/src/pages/institution-admin/user-content/user-repos.js new file mode 100644 index 00000000000..06d28032b35 --- /dev/null +++ b/frontend/src/pages/institution-admin/user-content/user-repos.js @@ -0,0 +1,53 @@ +import { useParams } from '@gatsbyjs/reach-router'; +import React, { useEffect, useState } from 'react'; +import { gettext } from '../../../utils/constants'; +import Loading from '../../../components/loading'; +import EmptyTip from '../../../components/empty-tip'; +import instAdminAPI from '../api'; +import UserRepoItem from './user-repo-item'; + +export default function UserRepos() { + + const [isLoading, setIsLoading] = useState(true); + const [repos, setRepos] = useState(null); + const params = useParams(); + + useEffect(() => { + instAdminAPI.listInstitutionUserRepos(decodeURIComponent(params.email)).then(res => { + const { repo_list } = res.data; + setRepos(repo_list); + setIsLoading(false); + }); + }, [params.email]); + + if (isLoading) { + return ; + } + + if (repos.length === 0) { + return ( + +

{gettext('No libraries')}

+
+ ); + } + + return ( + + + + + + + + + + + + {repos.map((repo) => { + return ; + })} + +
{gettext('Name')}{gettext('Size')}{gettext('Last Update')}{/* Operations */}
+ ); +} diff --git a/frontend/src/pages/institution-admin/user-list-search/index.js b/frontend/src/pages/institution-admin/user-list-search/index.js new file mode 100644 index 00000000000..8a85a1a4140 --- /dev/null +++ b/frontend/src/pages/institution-admin/user-list-search/index.js @@ -0,0 +1,100 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useLocation } from '@gatsbyjs/reach-router'; +import Loading from '../../../components/loading'; +import { gettext } from '../../../utils/constants'; +import CommonOperationConfirmationDialog from '../../../components/dialog/common-operation-confirmation-dialog'; +import UserItem from '../user-list/user-item'; +import instAdminAPI from '../api'; + +const UserListSearch = () => { + const [isLoading, setIsLoading] = useState(true); + const [userList, setUserList] = useState([]); + const [deleteUser, setDeleteUser] = useState(null); + const [deleteMessage, setDeleteMessage] = useState(''); + const [isShowDeleteUserDialog, setIsShowDeleteDialog] = useState(false); + + const location = useLocation(); + + useEffect(() => { + const params = new URLSearchParams(location.search); + const q = params.get('query'); + instAdminAPI.searchInstitutionUsers(q).then(res => { + const { user_list } = res.data; + setUserList(user_list); + setIsLoading(false); + }); + }, [location.search]); + + const deleteInstUserToggle = useCallback((user) => { + if (user) { + const deleteMessage = gettext('Are you sure you want to delete {placeholder} ?').replace('{placeholder}', user.name); + setDeleteUser(user); + setDeleteMessage(deleteMessage); + } + setIsShowDeleteDialog(!isShowDeleteUserDialog); + }, [isShowDeleteUserDialog]); + + const deleteInstUser = useCallback(() => { + instAdminAPI.deleteInstitutionUser(deleteUser.email).then(res => { + const newUserList = userList.filter(item => item.email !== deleteUser.email); + setUserList(newUserList); + }); + }, [deleteUser?.email, userList]); + + const updateInstUserStatus = useCallback((user) => { + const is_active = user.is_active ? false : true; + instAdminAPI.updateInstitutionUserStatus(user.email, is_active).then(res => { + const newUserList = userList.map(item => { + if (item.email === user.email) { + item.is_active = is_active; + } + return item; + }); + setUserList(newUserList); + }); + }, [userList]); + + + if (isLoading) { + return ; + } + + return ( +
+ + + + + + + + + + + + {userList.map((user) => { + return ( + + ); + })} + +
{gettext('Email')} / {gettext('Name')} / {gettext('Contact Email')}{gettext('Status')}{gettext('Space Used')}{gettext('Create At')} / {gettext('Last Login')}{gettext('Operations')}
+ {isShowDeleteUserDialog && ( + + )} +
+ ); +}; + +export default UserListSearch; diff --git a/frontend/src/pages/institution-admin/user-list/index.js b/frontend/src/pages/institution-admin/user-list/index.js new file mode 100644 index 00000000000..5258c79e4c5 --- /dev/null +++ b/frontend/src/pages/institution-admin/user-list/index.js @@ -0,0 +1,156 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import Loading from '../../../components/loading'; +import { gettext } from '../../../utils/constants'; +import Paginator from '../../../components/paginator'; +import CommonOperationConfirmationDialog from '../../../components/dialog/common-operation-confirmation-dialog'; +import UserItem from './user-item'; +import instAdminAPI from '../api'; + +const UserList = ({ onUserLinkClick }) => { + const [isLoading, setIsLoading] = useState(true); + const [userList, setUserList] = useState([]); + const [currentPage, setCurrentPage] = useState(1); + const [curPerPage, setCurPrePage] = useState(25); + const [hasNextPage, setHasNextPage] = useState(true); + const [deleteUser, setDeleteUser] = useState(null); + const [deleteMessage, setDeleteMessage] = useState(''); + const [isShowDeleteUserDialog, setIsShowDeleteDialog] = useState(false); + + useEffect(() => { + instAdminAPI.listInstitutionUsers(currentPage, curPerPage).then(res => { + const { user_list, total_count } = res.data; + setUserList(user_list); + if (user_list.length >= total_count) { + setHasNextPage(false); + } + setIsLoading(false); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const getPreviousPage = useCallback(() => { + if (curPerPage === 1) return; + const newPage = currentPage - 1; + setCurrentPage(newPage); + instAdminAPI.listInstitutionUsers(newPage, curPerPage).then(res => { + const { user_list, total_count } = res.data; + setUserList(user_list); + if (newPage * curPerPage >= total_count) { + setHasNextPage(false); + } + }); + }, [curPerPage, currentPage]); + + const getNextPage = useCallback(() => { + if (!hasNextPage) return; + const newPage = currentPage + 1; + setCurrentPage(newPage); + instAdminAPI.listInstitutionUsers(newPage, curPerPage).then(res => { + const { user_list, total_count } = res.data; + setUserList(user_list); + if (newPage * curPerPage >= total_count) { + setHasNextPage(false); + } + }); + }, [curPerPage, currentPage, hasNextPage]); + + const resetPerPage = useCallback((perPage) => { + setCurPrePage(perPage); + instAdminAPI.listInstitutionUsers(1, perPage).then(res => { + const { user_list, total_count } = res.data; + setUserList(user_list); + if (1 * curPerPage >= total_count) { + setHasNextPage(false); + } + }); + }, [curPerPage]); + + const deleteInstUserToggle = useCallback((user) => { + if (user) { + const deleteMessage = gettext('Are you sure you want to delete {placeholder} ?').replace('{placeholder}', user.name); + setDeleteUser(user); + setDeleteMessage(deleteMessage); + } + setIsShowDeleteDialog(!isShowDeleteUserDialog); + }, [isShowDeleteUserDialog]); + + const deleteInstUser = useCallback(() => { + instAdminAPI.deleteInstitutionUser(deleteUser.email).then(res => { + const newUserList = userList.filter(item => item.email !== deleteUser.email); + setUserList(newUserList); + }); + }, [deleteUser?.email, userList]); + + const updateInstUserStatus = useCallback((user) => { + const is_active = user.is_active ? false : true; + instAdminAPI.updateInstitutionUserStatus(user.email, is_active).then(res => { + const newUserList = userList.map(item => { + if (item.email === user.email) { + item.is_active = is_active; + } + return item; + }); + setUserList(newUserList); + }); + }, [userList]); + + + if (isLoading) { + return ; + } + + return ( +
+ + + + + + + + + + + + {userList.map((user) => { + return ( + + ); + })} + +
{gettext('Email')} / {gettext('Name')} / {gettext('Contact Email')}{gettext('Status')}{gettext('Space Used')}{gettext('Create At')} / {gettext('Last Login')}{gettext('Operations')}
+ {hasNextPage && ( + + )} + {isShowDeleteUserDialog && ( + + )} +
+ ); +}; + +UserList.propTypes = { + onUserLinkClick: PropTypes.func, +}; + +export default UserList; diff --git a/frontend/src/pages/institution-admin/user-list/user-item.js b/frontend/src/pages/institution-admin/user-list/user-item.js new file mode 100644 index 00000000000..2150878ed64 --- /dev/null +++ b/frontend/src/pages/institution-admin/user-list/user-item.js @@ -0,0 +1,98 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { Link } from '@gatsbyjs/reach-router'; +import Selector from '../../../components/single-selector'; +import { gettext, username } from '../../../utils/constants'; +import { Utils } from '../../../utils/utils'; +import moment from 'moment'; + +const OPERATIONS =[ + { + value: 'active', + text: gettext('active'), + is_active: true, + isSelected: false, + }, + { + value: 'inactive', + text: gettext('inactive'), + is_active: false, + isSelected: false, + }, +]; + +const UserItem = ({ user, deleteInstUser, updateInstUserStatus }) => { + const [highlight, setHighlight] = useState(false); + const [isOpIconShow, setIsOpIconSHow] = useState(false); + + const operations = OPERATIONS.map(item => { + if (user.is_active === item.is_active) { + item.isSelected = true; + } else { + item.isSelected = false; + } + return item; + }); + const currentSelection = operations.find(item => item.isSelected); + + const handleMouseEnter = () => { + setHighlight(true); + setIsOpIconSHow(true); + }; + + const handleMouseLeave = () => { + setHighlight(false); + setIsOpIconSHow(false); + }; + + const updateStatus = () => { + updateInstUserStatus(user); + }; + + const deleteCurrentUser = () => { + deleteInstUser(user); + }; + + return ( + + + {user.email} +
+ {user.name} +
+ {user.contact_email} + + + + + + {`${Utils.bytesToSize(user.quota_usage)} / ${user.quota_total > 0 ? Utils.bytesToSize(user.quota_total) : '--'}`} + + + {`${user.create_time ? moment(user.create_time).format('YYYY-MM-DD HH:mm') : '--'} /`} +
+ {`${user.last_login ? moment(user.last_login).fromNow() : '--'}`} +
+ {`${user.last_access_time ? moment(user.last_access_time).fromNow() : '--'}`} + + + {isOpIconShow && !user.is_institution_admin && !user.is_system_admin && user.email !== username && ( + {gettext('Delete')} + )} + + + ); +}; + +UserItem.propTypes = { + user: PropTypes.object.isRequired, + deleteInstUser: PropTypes.func.isRequired, + updateInstUserStatus: PropTypes.func.isRequired, +}; + +export default UserItem; diff --git a/frontend/src/pages/institution-admin/users-nav/index.js b/frontend/src/pages/institution-admin/users-nav/index.js new file mode 100644 index 00000000000..a08a94301b4 --- /dev/null +++ b/frontend/src/pages/institution-admin/users-nav/index.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { Link } from '@gatsbyjs/reach-router'; +import { siteRoot, gettext } from '../../../utils/constants'; +import { getNavMessage } from '../utils'; + +const UsersNav = () => { + const { username } = getNavMessage(window.location.href); + + return ( +
+
+

{gettext('Users')}{username ? ' / ' + username : ''}

+
+
+ ); +}; + +export default UsersNav; diff --git a/frontend/src/pages/institution-admin/utils/index.js b/frontend/src/pages/institution-admin/utils/index.js new file mode 100644 index 00000000000..a4fdb5445eb --- /dev/null +++ b/frontend/src/pages/institution-admin/utils/index.js @@ -0,0 +1,8 @@ +export const getNavMessage = (url) => { + const paths = url.split('/'); + const adminIdx = paths.findIndex(item => item ==='useradmin'); + const email = paths[adminIdx + 1]; + const nav = paths[adminIdx + 2]; + const username = decodeURIComponent(email); + return { username, nav }; +}; \ No newline at end of file diff --git a/frontend/src/pages/org-admin/index.js b/frontend/src/pages/org-admin/index.js index 58a4ba91790..9850085afb6 100644 --- a/frontend/src/pages/org-admin/index.js +++ b/frontend/src/pages/org-admin/index.js @@ -40,6 +40,7 @@ import OrgLogsFileAudit from './org-logs-file-audit'; import OrgLogsFileUpdate from './org-logs-file-update'; import OrgLogsPermAudit from './org-logs-perm-audit'; import OrgSAMLConfig from './org-saml-config'; +import OrgSubscription from './org-subscription'; import '../../css/layout.css'; import '../../css/toolbar.css'; @@ -100,6 +101,7 @@ class Org extends React.Component { + diff --git a/frontend/src/pages/org-admin/org-subscription.js b/frontend/src/pages/org-admin/org-subscription.js new file mode 100644 index 00000000000..6006f585520 --- /dev/null +++ b/frontend/src/pages/org-admin/org-subscription.js @@ -0,0 +1,32 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import MainPanelTopbar from './main-panel-topbar'; +import Subscription from '../../components/subscription'; + +const propTypes = { + onCloseSidePanel: PropTypes.func, +}; + +class OrgSubscription extends Component { + render() { + return ( + + +
+
+
+

{'付费管理'}

+
+
+ +
+
+
+
+ ); + } +} + +OrgSubscription.propTypes = propTypes; + +export default OrgSubscription; diff --git a/frontend/src/pages/org-admin/side-panel.js b/frontend/src/pages/org-admin/side-panel.js index 0946548e6b5..b19273caffe 100644 --- a/frontend/src/pages/org-admin/side-panel.js +++ b/frontend/src/pages/org-admin/side-panel.js @@ -2,7 +2,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Link } from '@gatsbyjs/reach-router'; import Logo from '../../components/logo'; -import { gettext, siteRoot, enableMultiADFS } from '../../utils/constants'; +import Icon from '../../components/icon'; +import { gettext, siteRoot, enableSubscription, enableMultiADFS } from '../../utils/constants'; const propTypes = { isSidePanelClosed: PropTypes.bool.isRequired, @@ -80,6 +81,14 @@ class SidePanel extends React.Component { {gettext('Departments')} + {enableSubscription && +
  • + this.tabItemClick('subscription')} > + + {'付费管理'} + +
  • + }
  • this.tabItemClick('publinkadmin')} > diff --git a/frontend/src/pages/sdoc/sdoc-file-history/helper.js b/frontend/src/pages/sdoc/sdoc-file-history/helper.js new file mode 100644 index 00000000000..4b85fe68575 --- /dev/null +++ b/frontend/src/pages/sdoc/sdoc-file-history/helper.js @@ -0,0 +1,30 @@ +export const getLastVersion = (path, isShowChanges, historyGroups) => { + const [monthIndex, dayIndex, dailyIndex] = path; + const monthHistoryGroup = historyGroups[monthIndex]; + const dayHistoryGroup = monthHistoryGroup.children[dayIndex]; + let lastVersion = ''; + if (isShowChanges) { + if (dayHistoryGroup.showDaily) { + lastVersion = dayHistoryGroup.children[dailyIndex + 1]; + } + if (!lastVersion) { + lastVersion = monthHistoryGroup.children[dayIndex + 1]?.children[0]; + } + if (!lastVersion) { + lastVersion = historyGroups[monthIndex + 1]?.children[0]?.children[0]; + } + if (monthIndex === 0 && !lastVersion) { + lastVersion = 'init'; + } + } + return lastVersion; +}; + +export const getCurrentAndLastVersion = (path, historyGroups, isShowChanges) => { + const [monthIndex, dayIndex, dailyIndex] = path; + const monthHistoryGroup = historyGroups[monthIndex]; + const dayHistoryGroup = monthHistoryGroup.children[dayIndex]; + const currentVersion = dayHistoryGroup.children[dailyIndex]; + const lastVersion = getLastVersion(path, isShowChanges, historyGroups); + return [currentVersion, lastVersion]; +}; diff --git a/frontend/src/pages/sdoc/sdoc-file-history/history-version.js b/frontend/src/pages/sdoc/sdoc-file-history/history-version.js index abec95db3b2..42d32223a30 100644 --- a/frontend/src/pages/sdoc/sdoc-file-history/history-version.js +++ b/frontend/src/pages/sdoc/sdoc-file-history/history-version.js @@ -1,11 +1,12 @@ import moment from 'moment'; import React from 'react'; import PropTypes from 'prop-types'; -import { Dropdown, DropdownToggle, DropdownMenu, DropdownItem} from 'reactstrap'; +import { Dropdown, DropdownToggle, DropdownMenu, DropdownItem, Modal, ModalBody } from 'reactstrap'; import classnames from 'classnames'; import { gettext, filePath } from '../../../utils/constants'; import URLDecorator from '../../../utils/url-decorator'; import Rename from '../../../components/rename'; +import { isMobile } from '../../../utils/utils'; import '../../../css/history-record-item.css'; @@ -20,6 +21,7 @@ class HistoryVersion extends React.Component { isMenuShow: false, isRenameShow: false, }; + this.isMobile = isMobile; } onMouseEnter = () => { @@ -61,7 +63,8 @@ class HistoryVersion extends React.Component { }; toggleRename = () => { - this.setState({isRenameShow: !this.state.isRenameShow}); + this.isMobile && this.setState({ isMenuShow: false }); + this.setState({ isRenameShow: !this.state.isRenameShow }); }; onRenameConfirm = (newName) => { @@ -118,22 +121,57 @@ class HistoryVersion extends React.Component {
    - - - - {/* {(this.props.index !== 0) && {gettext('Restore')}} */} - {gettext('Download')} - {(path[0] !== 0 && path[1] !== 0 && path[2] !== 0) && {gettext('Copy')}} - {gettext('Rename')} - - + {this.isMobile + ? ( + <> + + + + + {(path[0] !== 0 && path[1] !== 0 && path[2] !== 0) && ( +
    + + {gettext('Copy')} +
    + )} +
    + + {gettext('Rename')} +
    + + + + ) + : ( + + + {/* {(this.props.index !== 0) && {gettext('Restore')}} */} + {gettext('Download')} + {(path[0] !== 0 && path[1] !== 0 && path[2] !== 0) && {gettext('Copy')}} + {gettext('Rename')} + + + )}
  • ); diff --git a/frontend/src/pages/sdoc/sdoc-file-history/index.css b/frontend/src/pages/sdoc/sdoc-file-history/index.css index da2b5fe75d0..f7b7829bc09 100644 --- a/frontend/src/pages/sdoc/sdoc-file-history/index.css +++ b/frontend/src/pages/sdoc/sdoc-file-history/index.css @@ -1,6 +1,11 @@ +.sdoc-file-history .sdoc-content-wrapper { + flex: 1; + /* Header is 50px */ + height: calc(100vh - 50px); +} + .sdoc-file-history .sdoc-file-history-container { flex: 1; - overflow-x: hidden; } .sdoc-file-history .sdoc-file-history-header { @@ -21,6 +26,7 @@ .sdoc-file-history .sdoc-file-history-header .sdoc-file-history-header-right { height: 100%; min-width: 100px; + align-items: center; } .sdoc-file-history .sdoc-file-history-header .sdoc-file-changes-container { @@ -35,12 +41,22 @@ width: 1px; } +.sdoc-file-history .sdoc-file-history-header .sdoc-file-changes-switch { + display: flex; + align-items: center; +} + .sdoc-file-history .sdoc-file-history-header .sdoc-file-changes-last, -.sdoc-file-history .sdoc-file-history-header .sdoc-file-changes-next { - padding: 0 8px; +.sdoc-file-history .sdoc-file-history-header .sdoc-file-changes-next, +.sdoc-file-history .sdoc-file-history-header .sdoc-file-changes-switch { + padding: 0 8px; height: 100%; } +.sdoc-file-history .sdoc-file-history-header .sdoc-file-changes-switch { + padding: 0; +} + .sdoc-file-history .sdoc-file-history-header .sdoc-file-changes-last .fas, .sdoc-file-history .sdoc-file-history-header .sdoc-file-changes-next .fas { color: #000; @@ -59,6 +75,17 @@ opacity: .75; } +.sdoc-file-history .sdoc-file-history-header .sdoc-file-changes-switch .sf3-font-history:hover { + cursor: pointer; + color: #000; +} + +.sdoc-file-history .sdoc-file-history-header .sdoc-file-changes-switch .sf3-font-history { + font-size: 18px; + color: #666; +} + + .sdoc-file-history .sdoc-file-history-content { height: 100%; width: 100%; @@ -78,11 +105,24 @@ padding: 20px 40px; } +.sdoc-file-history.mobile .sdoc-file-history-container .sdoc-file-history-content .sdoc-editor-content { + padding: 0; + min-width: auto; +} + .sdoc-file-history .sdoc-file-history-content .sdoc-scroll-container { position: relative !important; width: 100%; } +.sdoc-file-history .sdoc-file-history-content .sdoc-editor-container { + flex-direction: row; +} + +.sdoc-editor-container.mobile .sdoc-editor-content.readonly .sdoc-article-container { + width: 100%; +} + .sdoc-file-history .sdoc-file-history-content .sdoc-article-container { width: 100%; padding-top: 0; @@ -98,11 +138,15 @@ .sdoc-file-history .sdoc-file-history-panel { width: 260px; border-left: 1px solid #e5e5e5; + height: calc(100vh - 50px); } .sdoc-file-history .sdoc-file-history-panel .sdoc-file-history-select-range { + display: flex; + justify-content: space-between; + align-items: center; padding: 10px 18px; - height: 50px;; + height: 50px; border-bottom: 1px solid #e5e5e5; background-color: #fff; } @@ -115,9 +159,14 @@ line-height: 29px; } +.sdoc-file-history .sdoc-file-history-panel .sdoc-file-history-select-range .sdoc-side-panel-close { + opacity: 0.7; + cursor: pointer; +} + .sdoc-file-history .sdoc-file-history-panel .sdoc-file-history-diff-switch { padding: 0 18px; - height: 50px;; + height: 50px; border-top: 1px solid #e5e5e5; } @@ -138,7 +187,7 @@ /* history versions */ .sdoc-file-history-versions { - flex: 1; + flex: 1; flex-direction: column; min-height: 0; overflow: auto; @@ -179,19 +228,35 @@ white-space: nowrap; } -.sdoc-file-history-versions .history-list-item.item-active .history-info .name{ +.sdoc-file-history-versions .history-list-item.item-active .history-info .name { color: #ff8000; } +/* mobile options modal */ +.sdoc-mobile-history-options-modal { + margin: 0; + transition: none; + transform: translateY(calc(100vh - 100%)) !important; +} + +.sdoc-mobile-history-options-modal .sdoc-operation-mobile-modal-body .option-item { + padding: 10px 5px; +} + +.sdoc-mobile-history-options-modal .sdoc-operation-mobile-modal-body .option-item a, +.sdoc-mobile-history-options-modal .sdoc-operation-mobile-modal-body .option-item a:hover { + color: #212529; +} + .sdoc-file-history-versions .history-list-item .history-operation:hover { cursor: pointer; } -.sdoc-file-history-versions .history-list-item .history-operation a.fas { +.sdoc-file-history-versions .history-list-item .history-operation a.fas { color: #888; } -.sdoc-file-history-versions .history-list-item .history-operation:hover a.fas { +.sdoc-file-history-versions .history-list-item .history-operation:hover a.fas { color: #333; } @@ -199,7 +264,7 @@ background-color: #FFECD9 !important; } -.sdoc-file-history-versions .history-list-item.item-active .history-info .time { +.sdoc-file-history-versions .history-list-item.item-active .history-info .time { color: #ff8000; } @@ -230,7 +295,7 @@ line-height: 50px; font-size: 1rem; padding: 0 10px; - background-color: rgb(250,250,249); + background-color: rgb(250, 250, 249); font-weight: bolder; } diff --git a/frontend/src/pages/sdoc/sdoc-file-history/index.js b/frontend/src/pages/sdoc/sdoc-file-history/index.js index 06afcf105a0..a1763eef191 100644 --- a/frontend/src/pages/sdoc/sdoc-file-history/index.js +++ b/frontend/src/pages/sdoc/sdoc-file-history/index.js @@ -3,20 +3,22 @@ import ReactDom from 'react-dom'; import { UncontrolledTooltip } from 'reactstrap'; import classnames from 'classnames'; import { DiffViewer } from '@seafile/sdoc-editor'; +import moment from 'moment'; import { seafileAPI } from '../../../utils/seafile-api'; -import { gettext, historyRepoID } from '../../../utils/constants'; +import { PER_PAGE, gettext, historyRepoID } from '../../../utils/constants'; import Loading from '../../../components/loading'; import GoBack from '../../../components/common/go-back'; import SidePanel from './side-panel'; -import { Utils } from '../../../utils/utils'; +import { Utils, isMobile } from '../../../utils/utils'; import toaster from '../../../components/toast'; +import { getCurrentAndLastVersion } from './helper'; import '../../../css/layout.css'; import './index.css'; const { serviceURL, avatarURL, siteRoot } = window.app.config; const { username, name } = window.app.pageOptions; -const { repoID, fileName, filePath, docUuid, assetsUrl } = window.fileHistory.pageOptions; +const { repoID, fileName, filePath, docUuid, assetsUrl } = window.fileHistory.pageOptions; window.seafile = { repoID, @@ -45,9 +47,16 @@ class SdocFileHistory extends React.Component { lastVersionContent: '', changes: [], currentDiffIndex: 0, + isMobile: isMobile, + sidePanelInitData: {}, + showSidePanel: true, }; } + componentDidMount() { + this.firstLoadSdocHistory(); + } + getInitContent = (firstChildren) => { if (firstChildren) { return { @@ -227,7 +236,11 @@ class SdocFileHistory extends React.Component { this.jumpToElement(currentDiffIndex + 1); }; - renderChangesTip = () => { + changeSidePanelStatus = () => { + this.setState({ showSidePanel: !this.state.showSidePanel }); + }; + + renderChangesTip = ({ onChangeSidePanelDisplay }) => { const { isShowChanges, changes, currentDiffIndex, isLoading } = this.state; if (isLoading) return null; if (!isShowChanges) return null; @@ -238,6 +251,9 @@ class SdocFileHistory extends React.Component {
    {gettext('No changes')}
    +
    + +
    ); } @@ -271,43 +287,108 @@ class SdocFileHistory extends React.Component { {gettext('Next modification')} +
    + +
    ); }; - render() { - const { currentVersion, isShowChanges, currentVersionContent, lastVersionContent, isLoading } = this.state; + formatHistories(histories) { + const oldHistoryGroups = []; // when init data, it will be [] + if (!Array.isArray(histories) || histories.length === 0) return oldHistoryGroups; + const newHistoryGroups = oldHistoryGroups.slice(0); + histories.forEach(history => { + const { date } = history; + const momentDate = moment(date); + const month = momentDate.format('YYYY-MM'); + const monthItem = newHistoryGroups.find(item => item.month === month); + if (monthItem) { + monthItem.children.push({ day: momentDate.format('YYYY-MM-DD'), showDaily: false, children: [history] }); + } else { + newHistoryGroups.push({ + month, + children: [ + { day: momentDate.format('YYYY-MM-DD'), showDaily: false, children: [history] } + ] + }); + } + }); + return newHistoryGroups; + } + firstLoadSdocHistory() { + const currentPage = 1; + seafileAPI.listSdocHistory(docUuid, currentPage, PER_PAGE).then(res => { + const result = res.data; + const resultCount = result.histories.length; + const historyGroups = this.formatHistories(result.histories); + let hasMore = resultCount >= PER_PAGE; + const sidePanelInitData = { + historyGroups, + hasMore, + isLoading: false, + }; + this.setState({ sidePanelInitData }); + + if (historyGroups.length > 0) { + const path = [0, 0, 0]; + const { isShowChanges } = this.state; + this.onSelectHistoryVersion(...getCurrentAndLastVersion(path, historyGroups, isShowChanges)); + } + }).catch((error) => { + const errorMessage = 'there has an error in server'; + const isLoading = false; + this.setState({ isLoading }); + this.setState({ sidePanelInitData: { isLoading, errorMessage } }); + throw Error(errorMessage); + }); + } + + render() { + const { currentVersion, isShowChanges, currentVersionContent, lastVersionContent, isLoading, isMobile, sidePanelInitData, showSidePanel } = this.state; return ( -
    +
    -
    -
    +
    +
    {fileName}
    - {this.renderChangesTip()} + {this.renderChangesTip({ onChangeSidePanelDisplay: this.changeSidePanelStatus })}
    -
    this.historyContentRef = ref}> +
    this.historyContentRef = ref}> {isLoading ? (
    ) : ( - + <> + + { + showSidePanel && ( + + ) + } + )}
    -
    ); } diff --git a/frontend/src/pages/sdoc/sdoc-file-history/side-panel.js b/frontend/src/pages/sdoc/sdoc-file-history/side-panel.js index 73a929e238a..31a70d1085e 100644 --- a/frontend/src/pages/sdoc/sdoc-file-history/side-panel.js +++ b/frontend/src/pages/sdoc/sdoc-file-history/side-panel.js @@ -10,6 +10,7 @@ import editUtilities from '../../../utils/editor-utilities'; import toaster from '../../../components/toast'; import HistoryVersion from './history-version'; import Switch from '../../../components/common/switch'; +import { getCurrentAndLastVersion, getLastVersion } from './helper'; moment.locale(window.app.config.lang); @@ -19,43 +20,17 @@ class SidePanel extends Component { constructor(props) { super(props); + const { sidePanelInitData } = props; this.state = { - isLoading: true, - historyGroups: [], - errorMessage: '', - hasMore: false, + isLoading: sidePanelInitData.isLoading ?? true, + historyGroups: sidePanelInitData.historyGroups || [], + errorMessage: sidePanelInitData.errorMessage || '', + hasMore: sidePanelInitData.hasMore || false, isReloadingData: false, }; this.currentPage = 1; } - // listSdocDailyHistoryDetail - - componentDidMount() { - this.firstLoadSdocHistory(); - } - - firstLoadSdocHistory() { - this.currentPage = 1; - seafileAPI.listSdocHistory(docUuid, this.currentPage, PER_PAGE).then(res => { - const result = res.data; - const resultCount = result.histories.length; - const historyGroups = this.formatHistories(result.histories); - this.setState({ - historyGroups: this.formatHistories(result.histories), - hasMore: resultCount >= PER_PAGE, - isLoading: false, - }, () => { - if (historyGroups.length > 0) { - this.onSelectHistoryVersion([0, 0, 0]); - } - }); - }).catch((error) => { - this.setState({isLoading: false}); - throw Error('there has an error in server'); - }); - } - formatHistories(histories) { const oldHistoryGroups = this.state.historyGroups; if (!Array.isArray(histories) || histories.length === 0) return oldHistoryGroups; @@ -66,12 +41,12 @@ class SidePanel extends Component { const month = momentDate.format('YYYY-MM'); const monthItem = newHistoryGroups.find(item => item.month === month); if (monthItem) { - monthItem.children.push({ day: momentDate.format('YYYY-MM-DD'), showDaily: false, children: [ history ] }); + monthItem.children.push({ day: momentDate.format('YYYY-MM-DD'), showDaily: false, children: [history] }); } else { newHistoryGroups.push({ month, children: [ - { day: momentDate.format('YYYY-MM-DD'), showDaily: false, children: [ history ] } + { day: momentDate.format('YYYY-MM-DD'), showDaily: false, children: [history] } ] }); } @@ -94,7 +69,7 @@ class SidePanel extends Component { this.setState({ isReloadingData: true }, () => { seafileAPI.listSdocHistory(docUuid, this.currentPage, PER_PAGE).then(res => { this.updateResultState(res.data); - this.setState({isReloadingData: false}); + this.setState({ isReloadingData: false }); }); }); } @@ -130,7 +105,7 @@ class SidePanel extends Component { const { commit_id, path } = currentItem; editUtilities.revertFile(path, commit_id).then(res => { if (res.data.success) { - this.setState({isLoading: true}, () => { + this.setState({ isLoading: true }, () => { this.firstLoadSdocHistory(); }); } @@ -142,38 +117,10 @@ class SidePanel extends Component { }); }; - getLastVersion = (path, isShowChanges) => { - const { historyGroups } = this.state; - const [monthIndex, dayIndex, dailyIndex] = path; - const monthHistoryGroup = historyGroups[monthIndex]; - const dayHistoryGroup = monthHistoryGroup.children[dayIndex]; - let lastVersion = ''; - if (isShowChanges) { - if (dayHistoryGroup.showDaily) { - lastVersion = dayHistoryGroup.children[dailyIndex + 1]; - } - if (!lastVersion) { - lastVersion = monthHistoryGroup.children[dayIndex + 1]?.children[0]; - } - if (!lastVersion) { - lastVersion = historyGroups[monthIndex + 1]?.children[0]?.children[0]; - } - if (monthIndex === 0 && !lastVersion) { - lastVersion = 'init'; - } - } - return lastVersion; - }; - onSelectHistoryVersion = (path) => { const { historyGroups } = this.state; const { isShowChanges } = this.props; - const [monthIndex, dayIndex, dailyIndex] = path; - const monthHistoryGroup = historyGroups[monthIndex]; - const dayHistoryGroup = monthHistoryGroup.children[dayIndex]; - const currentVersion = dayHistoryGroup.children[dailyIndex]; - const lastVersion = this.getLastVersion(path, isShowChanges); - this.props.onSelectHistoryVersion(currentVersion, lastVersion); + this.props.onSelectHistoryVersion(...getCurrentAndLastVersion(path, historyGroups, isShowChanges)); }; copyHistoryFile = (historyVersion) => { @@ -297,7 +244,7 @@ class SidePanel extends Component { const dayIndex = historyGroups[monthIndex].children.findIndex(item => item.day === day); const dailyIndex = historyGroups[monthIndex].children[dayIndex].children.findIndex(item => item.date === date); const path = [monthIndex, dayIndex, dailyIndex]; - lastVersion = this.getLastVersion(path, nextShowChanges); + lastVersion = getLastVersion(path, nextShowChanges, this.state.historyGroups); } this.props.onShowChanges(nextShowChanges, lastVersion); }; @@ -306,14 +253,17 @@ class SidePanel extends Component { const { historyGroups } = this.state; return ( -
    +
    {gettext('History Versions')}
    +
    + +
    {this.renderHistoryVersions()} @@ -337,6 +287,13 @@ SidePanel.propTypes = { currentVersion: PropTypes.object, onSelectHistoryVersion: PropTypes.func, onShowChanges: PropTypes.func, + sidePanelInitData: PropTypes.shape({ + isLoading: PropTypes.bool, + historyGroups: PropTypes.array, + hasMore: PropTypes.bool, + errorMessage: PropTypes.string, + }), + onClose: PropTypes.func, }; export default SidePanel; diff --git a/frontend/src/pages/wiki/index-md-viewer/index.js b/frontend/src/pages/wiki/index-md-viewer/index.js index e7dfdfa934e..9ba56a28812 100644 --- a/frontend/src/pages/wiki/index-md-viewer/index.js +++ b/frontend/src/pages/wiki/index-md-viewer/index.js @@ -40,7 +40,8 @@ class IndexMdViewer extends React.Component { if (node.path) { this.setState({ currentPath: node.path }); } - if (node.href) this.props.onLinkClick(node.href); + const url = new URL(node.href); + if (node.href) this.props.onLinkClick(url.href); }; changeInlineNode = (item) => { diff --git a/frontend/src/subscription.js b/frontend/src/subscription.js new file mode 100644 index 00000000000..8c5177163d9 --- /dev/null +++ b/frontend/src/subscription.js @@ -0,0 +1,72 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { siteRoot, mediaUrl, logoPath, logoWidth, logoHeight, siteTitle } from './utils/constants'; +import SideNav from './components/user-settings/side-nav'; +import Account from './components/common/account'; +import Notification from './components/common/notification'; +import Subscription from './components/subscription'; + +import './css/toolbar.css'; +import './css/search.css'; +import './css/user-settings.css'; + + +class UserSubscription extends React.Component { + + constructor(props) { + super(props); + this.sideNavItems = [ + { show: true, href: '#current-plan', text: '当前版本' }, + { show: true, href: '#asset-quota', text: '空间' }, + { show: true, href: '#current-subscription-period', text: '订阅有效期' }, + { show: true, href: '#product-price', text: '云服务付费方案' }, + ]; + this.state = { + curItemID: this.sideNavItems[0].href.substr(1), + }; + } + + handleContentScroll = (e) => { + // Mobile does not display the sideNav, so when scrolling don't update curItemID + const scrollTop = e.target.scrollTop; + const scrolled = this.sideNavItems.filter((item, index) => { + return item.show && document.getElementById(item.href.substr(1)).offsetTop - 45 < scrollTop; + }); + if (scrolled.length) { + this.setState({ + curItemID: scrolled[scrolled.length - 1].href.substr(1) + }); + } + }; + + render() { + let logoUrl = logoPath.startsWith('http') ? logoPath : mediaUrl + logoPath; + return ( +
    +
    + + logo + +
    + + +
    +
    +
    +
    + +
    +
    +

    {'付费管理'}

    + +
    +
    +
    + ); + } +} + +ReactDOM.render( + , + document.getElementById('wrapper') +); diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js index 98d720cc61d..73d6a9f77f2 100644 --- a/frontend/src/utils/constants.js +++ b/frontend/src/utils/constants.js @@ -142,6 +142,7 @@ export const orgEnableAdminCustomLogo = window.org ? window.org.pageOptions.orgE export const orgEnableAdminCustomName = window.org ? window.org.pageOptions.orgEnableAdminCustomName === 'True' : false; export const orgEnableAdminInviteUser = window.org ? window.org.pageOptions.orgEnableAdminInviteUser === 'True' : false; export const enableMultiADFS = window.org ? window.org.pageOptions.enableMultiADFS === 'True' : false; +export const enableSubscription = window.org ? window.org.pageOptions.enableSubscription : false; // sys admin export const constanceEnabled = window.sysadmin ? window.sysadmin.pageOptions.constance_enabled : ''; @@ -166,3 +167,6 @@ export const enableDingtalk = window.sysadmin ? window.sysadmin.pageOptions.enab export const enableSysAdminViewRepo = window.sysadmin ? window.sysadmin.pageOptions.enableSysAdminViewRepo : ''; export const haveLDAP = window.sysadmin ? window.sysadmin.pageOptions.haveLDAP : ''; export const enableShareLinkReportAbuse = window.sysadmin ? window.sysadmin.pageOptions.enable_share_link_report_abuse : ''; + +// institution admin +export const institutionName = window.app ? window.app.pageOptions.institutionName : ''; diff --git a/frontend/src/utils/subscription-api.js b/frontend/src/utils/subscription-api.js new file mode 100644 index 00000000000..9a45cd7a651 --- /dev/null +++ b/frontend/src/utils/subscription-api.js @@ -0,0 +1,61 @@ +import axios from 'axios'; +import cookie from 'react-cookies'; +import { siteRoot } from './constants'; + +class SubscriptionAPI { + + init({ server, username, password, token }) { + this.server = server; + this.username = username; + this.password = password; + this.token = token; //none + if (this.token && this.server) { + this.req = axios.create({ + baseURL: this.server, + headers: { 'Authorization': 'Token ' + this.token }, + }); + } + return this; + } + + initForSeahubUsage({ siteRoot, xcsrfHeaders }) { + if (siteRoot && siteRoot.charAt(siteRoot.length - 1) === '/') { + var server = siteRoot.substring(0, siteRoot.length - 1); + this.server = server; + } else { + this.server = siteRoot; + } + + this.req = axios.create({ + headers: { + 'X-CSRFToken': xcsrfHeaders, + } + }); + return this; + } + + getSubscription() { + const url = this.server + '/api/v2.1/subscription/'; + return this.req.get(url); + } + + getSubscriptionPlans(paymentType) { + const url = this.server + '/api/v2.1/subscription/plans/'; + let params = { + payment_type: paymentType, + }; + return this.req.get(url, { params: params }); + } + + getSubscriptionLogs() { + const url = this.server + '/api/v2.1/subscription/logs/'; + return this.req.get(url); + } + +} + +let subscriptionAPI = new SubscriptionAPI(); +let xcsrfHeaders = cookie.load('sfcsrftoken'); +subscriptionAPI.initForSeahubUsage({ siteRoot, xcsrfHeaders }); + +export { subscriptionAPI }; diff --git a/frontend/src/utils/utils.js b/frontend/src/utils/utils.js index 87052e01b9e..ef63828dc2c 100644 --- a/frontend/src/utils/utils.js +++ b/frontend/src/utils/utils.js @@ -1632,3 +1632,5 @@ export const Utils = { } }; + +export const isMobile = (typeof (window) !== 'undefined') && (window.innerWidth < 768 || navigator.userAgent.toLowerCase().match(/(ipod|ipad|iphone|android|coolpad|mmp|smartphone|midp|wap|xoom|symbian|j2me|blackberry|wince)/i) != null); diff --git a/media/css/seahub_react.css b/media/css/seahub_react.css index 65190fb411d..5b82fc56a09 100644 --- a/media/css/seahub_react.css +++ b/media/css/seahub_react.css @@ -686,6 +686,7 @@ a, a:hover { color: #ec8000; } } .side-nav-con .fas, +.side-nav-con .seafile-multicolor-icon, .side-nav-con [class^="sf2-icon-"], .side-nav-con [class^="sf3-font-"], .side-nav-con [class^="fas"] { @@ -1324,3 +1325,10 @@ a.table-sort-op:hover { text-overflow: ellipsis; white-space: nowrap; } + +.word-break-all { + /* overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; */ + word-break: break-all; +} diff --git a/media/img/qr-sale.png b/media/img/qr-sale.png new file mode 100644 index 00000000000..e986c08b1c6 Binary files /dev/null and b/media/img/qr-sale.png differ diff --git a/seahub/api2/endpoints/admin/organizations.py b/seahub/api2/endpoints/admin/organizations.py index 9b2d6429ea7..3a9d88fdccf 100644 --- a/seahub/api2/endpoints/admin/organizations.py +++ b/seahub/api2/endpoints/admin/organizations.py @@ -77,6 +77,9 @@ def get_org_detailed_info(org): users = ccnet_api.get_org_emailusers(org.url_prefix, -1, -1) org_info['users_count'] = len(users) + active_users_count = len([m for m in users if m.is_active]) + org_info['active_users_count'] = active_users_count + repos = seafile_api.get_org_repo_list(org_id, -1, -1) org_info['repos_count'] = len(repos) @@ -460,3 +463,30 @@ def get(self, request): result.append(org_info) return Response({'organization_list': result}) + + +class AdminOrganizationsBaseInfo(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAdminUser, IsProVersion) + throttle_classes = (UserRateThrottle,) + + def get(self, request): + ''' + Get base info of organizations in bulk by ids + ''' + if not MULTI_TENANCY: + error_msg = 'Feature is not enabled.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + org_ids = request.GET.getlist('org_ids',[]) + orgs = [] + for org_id in org_ids: + try: + org = ccnet_api.get_org_by_id(int(org_id)) + if not org: + continue + except: + continue + base_info = {'org_id': org.org_id, 'org_name': org.org_name} + orgs.append(base_info) + return Response({'organization_list': orgs}) diff --git a/seahub/api2/endpoints/admin/users.py b/seahub/api2/endpoints/admin/users.py index f85ceacf6d2..d4b8f861888 100644 --- a/seahub/api2/endpoints/admin/users.py +++ b/seahub/api2/endpoints/admin/users.py @@ -23,7 +23,7 @@ from seahub.api2.authentication import TokenAuthentication from seahub.api2.throttling import UserRateThrottle -from seahub.api2.utils import api_error, to_python_boolean +from seahub.api2.utils import api_error, to_python_boolean, get_user_common_info from seahub.api2.models import TokenV2 from seahub.utils.ccnet_db import get_ccnet_db_name import seahub.settings as settings @@ -2092,3 +2092,28 @@ def put(self, request): logger.error(e) return Response({'success': True}) + + +class AdminUserList(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAdminUser, ) + throttle_classes = (UserRateThrottle, ) + + def post(self, request): + """return user_list by user_id_list + """ + # argument check + user_id_list = request.data.get('user_id_list') + if not isinstance(user_id_list, list): + error_msg = 'user_id_list invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + # main + user_list = list() + for user_id in user_id_list: + if not isinstance(user_id, str): + continue + user_info = get_user_common_info(user_id) + user_list.append(user_info) + + return Response({'user_list': user_list}) diff --git a/seahub/api2/endpoints/subscription.py b/seahub/api2/endpoints/subscription.py new file mode 100644 index 00000000000..252d2c9e185 --- /dev/null +++ b/seahub/api2/endpoints/subscription.py @@ -0,0 +1,132 @@ +import logging +import requests + +from rest_framework.views import APIView +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework import status +from rest_framework.response import Response +from django.utils.translation import gettext as _ + +from seahub.api2.authentication import TokenAuthentication +from seahub.api2.throttling import UserRateThrottle +from seahub.api2.utils import api_error +from seahub.subscription.utils import subscription_check, get_customer_id, \ + get_subscription_api_headers, subscription_permission_check, \ + handler_subscription_api_response +from seahub.subscription.settings import SUBSCRIPTION_SERVER_URL + +logger = logging.getLogger(__name__) + + +class SubscriptionView(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + def get(self, request): + """Get subscription + """ + # check + if not subscription_check(): + error_msg = _('Feature is not enabled.') + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if not subscription_permission_check(request): + error_msg = _('Permission denied.') + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + # main + try: + customer_id = get_customer_id(request) + headers = get_subscription_api_headers() + + data = { + 'customer_id': customer_id, + } + url = SUBSCRIPTION_SERVER_URL.rstrip('/') + '/api/seafile/subscription/' + response = requests.get(url, params=data, headers=headers) + response = handler_subscription_api_response(response) + except Exception as e: + logger.error(e) + error_msg = _('Internal Server Error') + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + return Response(response.json(), status=response.status_code) + + +class SubscriptionPlansView(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + def get(self, request): + """Get plans + """ + # check + if not subscription_check(): + error_msg = _('Feature is not enabled.') + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if not subscription_permission_check(request): + error_msg = _('Permission denied.') + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + payment_type = request.GET.get('payment_type') + + # main + try: + customer_id = get_customer_id(request) + headers = get_subscription_api_headers() + + data = { + 'customer_id': customer_id, + 'payment_type': payment_type, + } + url = SUBSCRIPTION_SERVER_URL.rstrip( + '/') + '/api/seafile/subscription/plans/' + response = requests.get(url, params=data, headers=headers) + response = handler_subscription_api_response(response) + except Exception as e: + logger.error(e) + error_msg = _('Internal Server Error') + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + return Response(response.json(), status=response.status_code) + + +class SubscriptionLogsView(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + def get(self, request): + """Get subscription logs by paid + """ + # check + if not subscription_check(): + error_msg = _('Feature is not enabled.') + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if not subscription_permission_check(request): + error_msg = _('Permission denied.') + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + # main + try: + customer_id = get_customer_id(request) + headers = get_subscription_api_headers() + + data = { + 'customer_id': customer_id, + } + url = SUBSCRIPTION_SERVER_URL.rstrip( + '/') + '/api/seafile/subscription/logs/' + response = requests.get(url, params=data, headers=headers) + response = handler_subscription_api_response(response) + except Exception as e: + logger.error(e) + error_msg = _('Internal Server Error') + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + return Response(response.json(), status=response.status_code) diff --git a/seahub/api2/views.py b/seahub/api2/views.py index b8b2aa79596..3fe552d7aa7 100644 --- a/seahub/api2/views.py +++ b/seahub/api2/views.py @@ -105,7 +105,7 @@ STORAGE_CLASS_MAPPING_POLICY, \ ENABLE_RESET_ENCRYPTED_REPO_PASSWORD, SHARE_LINK_EXPIRE_DAYS_MAX, \ SHARE_LINK_EXPIRE_DAYS_MIN, SHARE_LINK_EXPIRE_DAYS_DEFAULT - +from seahub.subscription.utils import subscription_check try: from seahub.settings import CLOUD_MODE @@ -331,6 +331,7 @@ def _get_account_info(self, request): info['contact_email'] = p.contact_email if p else "" info['institution'] = p.institution if p and p.institution else "" info['is_staff'] = request.user.is_staff + info['enable_subscription'] = subscription_check() if getattr(settings, 'MULTI_INSTITUTION', False): from seahub.institutions.models import InstitutionAdmin diff --git a/seahub/institutions/api_urls.py b/seahub/institutions/api_urls.py new file mode 100644 index 00000000000..d6eba34aa97 --- /dev/null +++ b/seahub/institutions/api_urls.py @@ -0,0 +1,16 @@ +# Copyright (c) 2012-2016 Seafile Ltd. +from django.urls import path + +from .api_views import InstAdminUsers, InstAdminUser, \ + InstAdminSearchUser, InstAdminUserLibraries, InstAdminUserGroups + +urlpatterns = [ + path('admin/users/', InstAdminUsers.as_view(), name='api-v2.1-inst-admin-users'), + path('admin/search-user/', + InstAdminSearchUser.as_view(), name='api-v2.1-inst-admin-search-user'), + path('admin/users//', InstAdminUser.as_view(), name='api-v2.1-inst-admin-user'), + path('admin/users//libraries/', + InstAdminUserLibraries.as_view(), name='api-v2.1-inst-admin-user-libraries'), + path('admin/users//groups/', + InstAdminUserGroups.as_view(), name='api-v2.1-inst-admin-user-groups'), +] diff --git a/seahub/institutions/api_views.py b/seahub/institutions/api_views.py new file mode 100644 index 00000000000..ee985da3230 --- /dev/null +++ b/seahub/institutions/api_views.py @@ -0,0 +1,434 @@ +# Copyright (c) 2012-2016 Seafile Ltd. +import logging + +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import BasePermission +from rest_framework.authentication import SessionAuthentication + +from django.conf import settings +from django.utils.translation import gettext as _ + +from seaserv import ccnet_api, seafile_api + +from seahub.api2.utils import api_error +from seahub.api2.permissions import IsProVersion +from seahub.api2.throttling import UserRateThrottle +from seahub.api2.authentication import TokenAuthentication + +from seahub.base.accounts import User +from seahub.base.models import UserLastLogin +from seahub.base.templatetags.seahub_tags import email2nickname, email2contact_email +from seahub.profile.models import Profile +from seahub.institutions.models import InstitutionAdmin +from seahub.institutions.utils import get_institution_available_quota +from seahub.utils import inactive_user +from seahub.utils.timeutils import datetime_to_isoformat_timestr +from seahub.utils.timeutils import timestamp_to_isoformat_timestr +from seahub.utils.file_size import get_file_size_unit +from seahub.utils.licenseparse import user_number_over_limit +from seahub.avatar.templatetags.avatar_tags import api_avatar_url + + +logger = logging.getLogger(__name__) + + +class IsInstAdmin(BasePermission): + """ + Check whether is inst admin + """ + + def has_permission(self, request, *args, **kwargs): + + # permission check + if not getattr(settings, 'MULTI_INSTITUTION', False): + return False + + username = request.user.username + + try: + inst_admin = InstitutionAdmin.objects.get(user=username) + except InstitutionAdmin.DoesNotExist: + inst_admin = False + + if not inst_admin: + return False + + inst = inst_admin.institution + profile = Profile.objects.get_profile_by_user(username) + if profile and profile.institution != inst.name: + return False + + return True + + +class InstAdminUsers(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + throttle_classes = (UserRateThrottle,) + permission_classes = (IsProVersion, IsInstAdmin) + + def get(self, request): + + """List users in institution. + """ + + username = request.user.username + inst_admin = InstitutionAdmin.objects.get(user=username) + inst = inst_admin.institution + + # get user list + try: + current_page = int(request.GET.get('page', '1')) + per_page = int(request.GET.get('per_page', '100')) + except ValueError: + current_page = 1 + per_page = 100 + + offset = per_page * (current_page - 1) + inst_users = Profile.objects.filter(institution=inst.name)[offset:offset + per_page] + + admin_users = InstitutionAdmin.objects.filter(institution=inst) + admin_emails = [user.user for user in admin_users] + + last_logins = UserLastLogin.objects.filter(username__in=[x.user for x in inst_users]) + + result = [] + for user in inst_users: + + email = user.user + + user_info = {} + user_info['email'] = email + user_info['name'] = email2nickname(email) + user_info['contact_email'] = email2contact_email(email) + user_info['is_institution_admin'] = email in admin_emails + user_info['avatar_url'], _, _ = api_avatar_url(email, 72) + + try: + user_obj = User.objects.get(email=email) + user_info['is_active'] = user_obj.is_active + user_info['is_system_admin'] = user_obj.is_staff + user_info['create_time'] = timestamp_to_isoformat_timestr(user_obj.ctime) + except User.DoesNotExist: + user_info['is_active'] = '' + user_info['is_system_admin'] = '' + user_info['create_time'] = '' + + user_info['last_login'] = '' + for last_login in last_logins: + if last_login.username == email: + last_login_time = last_login.last_login + user_info['last_login'] = datetime_to_isoformat_timestr(last_login_time) + + try: + user_info['quota_total'] = seafile_api.get_user_quota(email) + user_info['quota_usage'] = seafile_api.get_user_self_usage(email) + except Exception as e: + logger.error(e) + user_info['quota_total'] = -1 + user_info['quota_usage'] = -1 + + result.append(user_info) + + return Response({ + 'user_list': result, + 'total_count': inst_users.count() + }) + + +class InstAdminUser(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + throttle_classes = (UserRateThrottle,) + permission_classes = (IsProVersion, IsInstAdmin) + + def put(self, request, email): + + """ Update user info in institution. + """ + + username = request.user.username + inst_admin = InstitutionAdmin.objects.get(user=username) + inst = inst_admin.institution + + try: + user_obj = User.objects.get(email=email) + except User.DoesNotExist: + error_msg = f'User {email} not found.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + profile = Profile.objects.get_profile_by_user(email) + if not profile or \ + profile.institution != inst.name: + error_msg = f'User {email} not found in {inst.name}.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # set user quota + quota_total_mb = request.data.get("quota_total", None) + if quota_total_mb is not None: + try: + quota_total_mb = int(quota_total_mb) + except ValueError: + error_msg = _("Must be an integer that is greater than or equal to 0.") + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if quota_total_mb < 0: + error_msg = _("Space quota is too low (minimum value is 0).") + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + quota = quota_total_mb * get_file_size_unit('MB') + available_quota = get_institution_available_quota(inst) + if available_quota is not None: + # None means has unlimit quota + if available_quota == 0 or available_quota < quota: + error_msg = _(f"Failed to set quota: maximum quota is {available_quota} MB") + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + seafile_api.set_user_quota(email, quota) + + is_active = request.data.get("is_active", None) + if is_active is not None: + + is_active = is_active.lower() + if is_active not in ('true', 'false'): + error_msg = "is_active invalid." + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if is_active == 'true': + + if user_number_over_limit(new_users=1): + error_msg = _("The number of users exceeds the limit.") + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + else: + # del tokens and personal repo api tokens + try: + inactive_user(email) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + user_obj.is_active = is_active == 'true' + user_obj.save() + + return Response({'success': True}) + + def get(self, request, email): + + """ Get user info in institution. + """ + + username = request.user.username + inst_admin = InstitutionAdmin.objects.get(user=username) + inst = inst_admin.institution + + # get user info + try: + user_obj = User.objects.get(email=email) + except User.DoesNotExist: + error_msg = f'User {email} not found.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + profile = Profile.objects.get_profile_by_user(email) + if not profile or \ + profile.institution != inst.name: + error_msg = f'User {email} not found in {inst.name}.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + user_info = {} + user_info['email'] = email + user_info['name'] = email2nickname(email) + user_info['contact_email'] = email2contact_email(email) + user_info['is_active'] = user_obj.is_active + user_info['avatar_url'], _, _ = api_avatar_url(email, 72) + try: + user_info['quota_total'] = seafile_api.get_user_quota(email) + user_info['quota_usage'] = seafile_api.get_user_self_usage(email) + except Exception as e: + logger.error(e) + user_info['quota_total'] = -1 + user_info['quota_usage'] = -1 + + return Response(user_info) + + def delete(self, request, email): + + """ Delete user in institution. + """ + + # delete user + try: + user = User.objects.get(email=email) + except User.DoesNotExist: + error_msg = f'User {email} not found.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + if user.is_staff: + error_msg = f'User {email} is system administrator.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + user.delete() + return Response({'success': True}) + + +class InstAdminSearchUser(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + throttle_classes = (UserRateThrottle,) + permission_classes = (IsProVersion, IsInstAdmin) + + def get(self, request): + + """Search user in institution. + """ + + username = request.user.username + inst_admin = InstitutionAdmin.objects.get(user=username) + inst = inst_admin.institution + + # search user + q = request.GET.get('q', '').lower() + if not q: + error_msg = 'q invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + inst_users = Profile.objects.filter(institution=inst.name) + admin_users = InstitutionAdmin.objects.filter(institution=inst) + admin_emails = [user.user for user in admin_users] + last_logins = UserLastLogin.objects.filter(username__in=[x.user for x in inst_users]) + + result = [] + for user in inst_users: + + email = user.user + + if q not in email and \ + q not in email2nickname(email) and \ + q not in email2contact_email(email): + continue + + user_info = {} + user_info['email'] = email + user_info['name'] = email2nickname(email) + user_info['contact_email'] = email2contact_email(email) + user_info['is_institution_admin'] = email in admin_emails + user_info['avatar_url'], _, _ = api_avatar_url(email, 72) + + try: + user_obj = User.objects.get(email=email) + user_info['is_active'] = user_obj.is_active + user_info['is_system_admin'] = user_obj.is_staff + user_info['create_time'] = timestamp_to_isoformat_timestr(user_obj.ctime) + except User.DoesNotExist: + user_info['is_active'] = '' + user_info['is_system_admin'] = '' + user_info['create_time'] = '' + + user_info['last_login'] = '' + for last_login in last_logins: + if last_login.username == email: + last_login_time = last_login.last_login + user_info['last_login'] = datetime_to_isoformat_timestr(last_login_time) + + try: + user_info['quota_total'] = seafile_api.get_user_quota(email) + user_info['quota_usage'] = seafile_api.get_user_self_usage(email) + except Exception as e: + logger.error(e) + user_info['quota_total'] = -1 + user_info['quota_usage'] = -1 + + result.append(user_info) + + return Response({'user_list': result}) + + +class InstAdminUserLibraries(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + throttle_classes = (UserRateThrottle,) + permission_classes = (IsProVersion, IsInstAdmin) + + def get(self, request, email): + + """Get user repos. + """ + + try: + User.objects.get(email=email) + except User.DoesNotExist: + error_msg = f'User {email} not found.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + username = request.user.username + inst_admin = InstitutionAdmin.objects.get(user=username) + inst = inst_admin.institution + profile = Profile.objects.get_profile_by_user(email) + if not profile or \ + profile.institution != inst.name: + error_msg = f'User {email} not found in {inst.name}.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + repo_info_list = [] + owned_repos = seafile_api.get_owned_repo_list(email) + + for repo in owned_repos: + + if repo.is_virtual: + continue + + repo_info = {} + repo_info['id'] = repo.repo_id + repo_info['name'] = repo.repo_name + repo_info['size'] = repo.size + repo_info['last_modified'] = timestamp_to_isoformat_timestr(repo.last_modified) + + repo_info_list.append(repo_info) + + return Response({"repo_list": repo_info_list}) + + +class InstAdminUserGroups(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + throttle_classes = (UserRateThrottle,) + permission_classes = (IsProVersion, IsInstAdmin) + + def get(self, request, email): + + """Get user repos. + """ + + try: + User.objects.get(email=email) + except User.DoesNotExist: + error_msg = f'User {email} not found.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + username = request.user.username + inst_admin = InstitutionAdmin.objects.get(user=username) + inst = inst_admin.institution + + profile = Profile.objects.get_profile_by_user(email) + if not profile or \ + profile.institution != inst.name: + error_msg = f'User {email} not found in {inst.name}.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + group_info_list = [] + groups = ccnet_api.get_groups(email) + + for group in groups: + + group_info = {} + group_info['id'] = group.id + group_info['name'] = group.group_name + group_info['is_owner'] = group.creator_name == email + group_info['is_admin'] = 1 == ccnet_api.check_group_staff(group.id, email) + group_info['created_at'] = timestamp_to_isoformat_timestr(group.timestamp) + + group_info_list.append(group_info) + + return Response({"groups_list": group_info_list}) diff --git a/seahub/institutions/templates/institutions/admin.html b/seahub/institutions/templates/institutions/admin.html new file mode 100644 index 00000000000..111afb30c43 --- /dev/null +++ b/seahub/institutions/templates/institutions/admin.html @@ -0,0 +1,27 @@ +{% extends 'base_for_react.html' %} +{% load render_bundle from webpack_loader %} +{% load seahub_tags %} + +{% block extra_style %} +{% render_bundle 'institutionAdmin' 'css' %} +{% endblock %} + +{% block extra_script %} + +{% render_bundle 'institutionAdmin' 'js' %} +{% endblock %} diff --git a/seahub/institutions/templates/institutions/base.html b/seahub/institutions/templates/institutions/base.html deleted file mode 100644 index 619f437a881..00000000000 --- a/seahub/institutions/templates/institutions/base.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} - -{% block main_class %}d-flex ovhd{% endblock %} - -{% block admin_link %} -{% trans "Exit admin panel" %} -{% endblock %} - -{% block main_content %} -
    -
    - - {% block left_panel %} -

    {{ request.user.institution.name }}

    - - -
    - -
    - {% endblock %} -
    - -
    - {% block right_panel %}{% endblock %} -
    -
    -{% endblock %} diff --git a/seahub/institutions/templates/institutions/info.html b/seahub/institutions/templates/institutions/info.html deleted file mode 100644 index c9a55b32d02..00000000000 --- a/seahub/institutions/templates/institutions/info.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "institutions/base.html" %} -{% load seahub_tags i18n %} - -{% block cur_info %}tab-cur{% endblock %} - -{% block right_panel %} -

    {% trans "Info" %}

    - -
    -
    {% trans "Name" %}
    -
    {{ inst.name }}
    - -
    {% trans "Libraries" %}
    -
    {{repos_count}}
    - -
    {% trans "Activated Users" %} / {% trans "Total Users" %}
    -
    - {% if active_users_count %}{{ active_users_count }}{% else %}--{% endif %} - / - {% if users_count %}{{ users_count }}{% else %}--{% endif %} -
    - -
    {% trans "Groups" %}
    -
    {{groups_count}}
    -
    -{% endblock %} - diff --git a/seahub/institutions/templates/institutions/user_info.html b/seahub/institutions/templates/institutions/user_info.html deleted file mode 100644 index b73c892a11b..00000000000 --- a/seahub/institutions/templates/institutions/user_info.html +++ /dev/null @@ -1,174 +0,0 @@ -{% extends "institutions/base.html" %} -{% load i18n avatar_tags seahub_tags %} -{% load static %} - -{% block right_panel %} -

    - Users - / - {{ email }} -

    - -
    - - -
    -
    -
    {% trans "Avatar" %}
    -
    {% avatar email 48 %}
    - -
    {% trans "Email" %}
    -
    {{ email }}
    - - {% if profile %} -
    {% trans "Name" context "true name" %}
    -
    {{ profile.nickname }}
    - {% endif %} - - {% if d_profile %} -
    {% trans "Department" %}
    -
    {{ d_profile.department }}
    - -
    {% trans "Telephone" %}
    -
    {{ d_profile.telephone }}
    - {% endif %} - -
    {% trans "Space Used" %}
    -
    {{ space_usage|seahub_filesizeformat }} {% if space_quota > 0 %} / {{ space_quota|seahub_filesizeformat }} {% endif %} {% trans "Set Quota" %}
    -
    - -
    {% csrf_token %} -

    {% trans "Set Quota" %}

    - - MB -

    {% trans "Available quota:" %} {{ available_quota|seahub_filesizeformat}}

    -

    - -
    -
    - -
    - {% if owned_repos %} - - - - - - - - - - {% for repo in owned_repos %} - - {% if repo.encrypted %} - - {% else %} - - {% endif %} - - {% if not repo.name %} - - {% else %} - {% if repo.encrypted %} - - {% elif enable_sys_admin_view_repo %} - - {% else %} - - {% endif %} - {% endif %} - - - - - - {% endfor %} -
    {% trans "Name" %}{% trans "Size"%}{% trans "Last Update"%}{% trans "Operations" %}
    {% trans {% trans Broken ({{repo.id}}){{ repo.name }}{{ repo.name }}{{ repo.name }}{{ repo.size|filesizeformat }}{{ repo.last_modify|translate_seahub_time }} -
    - {% else %} -
    -

    {% trans "This user has not created any libraries" %}

    -
    - {% endif %} -
    - -
    - {% if personal_groups %} - - - - - - - - {% for group in personal_groups %} - - - - - - - {% endfor %} -
    {% trans "Name" %}{% trans "Role" %}{% trans "Create At" %}{% trans "Operations" %}
    {{ group.group_name }}{{ group.role }}{{ group.timestamp|tsstr_sec }}
    - {% else %} -
    -

    {% trans "This user has not created or joined any groups" %}

    -
    - {% endif %} -
    -
    - -{% endblock %} - - -{% block extra_script %} - - -{% endblock %} diff --git a/seahub/institutions/templates/institutions/useradmin.html b/seahub/institutions/templates/institutions/useradmin.html deleted file mode 100644 index 4b3289eac4d..00000000000 --- a/seahub/institutions/templates/institutions/useradmin.html +++ /dev/null @@ -1,148 +0,0 @@ -{% extends "institutions/base.html" %} -{% load seahub_tags i18n %} -{% block cur_users %}tab-cur{% endblock %} - - -{% block right_panel %} - - -{% if users %} - - - - - - - - - - {% for user in users %} - - - - - - - - {% endfor %} -
    {% trans "Email" %} / {% trans "Name" %} / {% trans "Contact Email" %}{% trans "Status" %}{% trans "Space Used" %}{% trans "Create At / Last Login" %}{% trans "Operations" %}
    - {{ user.email }} - {% if user.name %}
    {{ user.name }}{% endif %} - {% if user.contact_email %}
    {{ user.contact_email }}{% endif %} -
    -
    - {% if user.is_active %} - {% trans "Active" %} - {% else %} - {% trans "Inactive" %} - {% endif %} - {% if not user.is_self and not user.is_staff and not user.is_institution_admin %} - - {% endif %} -
    - -
    -

    {{ user.space_usage|seahub_filesizeformat }} {% if user.space_quota > 0 %} / {{ user.space_quota|seahub_filesizeformat }} {% endif %}

    -
    - {% if user.source == "DB" %} - {{ user.ctime|tsstr_sec }} /
    - {% else %} - -- / - {% endif %} - {% if user.last_login %}{{user.last_login|translate_seahub_time}} {% else %} -- {% endif %} -
    - {% if not user.is_self and not user.is_staff and not user.is_institution_admin %} - {% trans "Delete" %} - {% endif %} -
    - -{% include "snippets/admin_paginator.html" %} -{% else %} -

    {% trans "Empty" %}

    -{% endif %} - -
    -

    {% trans "Activating..., please wait" %}

    -
    - -{% endblock %} - -{% block extra_script %} - -{% endblock %} diff --git a/seahub/institutions/templates/institutions/useradmin_search.html b/seahub/institutions/templates/institutions/useradmin_search.html deleted file mode 100644 index 55f2761c2fa..00000000000 --- a/seahub/institutions/templates/institutions/useradmin_search.html +++ /dev/null @@ -1,77 +0,0 @@ -{% extends "institutions/base.html" %} -{% load seahub_tags i18n %} -{% block cur_users %}tab-cur{% endblock %} - - -{% block right_panel %} -

    {% trans "Search User"%}

    - -
    -
    -
    - -
    -

    {% trans "Result"%}

    - -{% if users %} - - - - - - - - - - {% for user in users %} - - - - - - - - {% endfor %} -
    {% trans "Email" %} / {% trans "Name" %} / {% trans "Contact Email" %}{% trans "Status" %}{% trans "Space Used" %}{% trans "Create At / Last Login" %}{% trans "Operations" %}
    - {{ user.email }} - {% if user.name %}
    {{ user.name }}{% endif %} - {% if user.contact_email %}
    {{ user.contact_email }}{% endif %} -
    -
    - {% if user.is_active %} - {% trans "Active" %} - {% else %} - {% trans "Inactive" %} - {% endif %} -
    -
    -

    {{ user.space_usage|seahub_filesizeformat }} {% if user.space_quota > 0 %} / {{ user.space_quota|seahub_filesizeformat }} {% endif %}

    -
    - {% if user.source == "DB" %} - {{ user.ctime|tsstr_sec }} /
    - {% else %} - -- / - {% endif %} - {% if user.last_login %}{{user.last_login|translate_seahub_time}} {% else %} -- {% endif %} -
    - {% if not user.is_self and not user.is_staff and not user.is_institution_admin %} - {% trans "Delete" %} - {% endif %} -
    - -{% else %} -

    {% trans "Empty" %}

    -{% endif %} - -{% endblock %} - -{% block extra_script %} - -{% endblock %} diff --git a/seahub/institutions/urls.py b/seahub/institutions/urls.py index 7c295dd0bc7..dc9e29b487d 100644 --- a/seahub/institutions/urls.py +++ b/seahub/institutions/urls.py @@ -1,15 +1,18 @@ # Copyright (c) 2012-2016 Seafile Ltd. from django.urls import path -from .views import (info, useradmin, user_info, user_remove, useradmin_search, +from .views import (info, useradmin_react_fake_view, useradmin, user_info, user_remove, useradmin_search, user_toggle_status, user_set_quota) urlpatterns = [ path('info/', info, name="info"), - path('useradmin/', useradmin, name="useradmin"), + path('useradmin/', useradmin_react_fake_view, name="useradmin"), + path('useradmin/search/', useradmin_react_fake_view, name="useradmin_search"), + path('useradmin//', useradmin_react_fake_view, name='useraedmin_email'), + path('useradmin//owned-libraries/', useradmin_react_fake_view, name='useradmin_libraries'), + path('useradmin//groups/', useradmin_react_fake_view, name='useradmin_groups'), path('useradmin/info//', user_info, name='user_info'), path('useradmin/remove//', user_remove, name='user_remove'), - path('useradmin/search/', useradmin_search, name="useradmin_search"), path('useradmin/set_quota//', user_set_quota, name='user_set_quota'), path('useradmin/toggle_status//', user_toggle_status, name='user_toggle_status'), ] diff --git a/seahub/institutions/views.py b/seahub/institutions/views.py index 7389215b0eb..9bb6dd9dc1a 100644 --- a/seahub/institutions/views.py +++ b/seahub/institutions/views.py @@ -54,6 +54,16 @@ def info(request): 'inst': inst, }) +@inst_admin_required +def useradmin_react_fake_view(request, **kwargs): + """List users in the institution. + """ + # Make sure page request is an int. If not, deliver first page. + inst = request.user.institution + + return render(request, 'institutions/admin.html', { + 'institution': inst.name, + }) @inst_admin_required def useradmin(request): diff --git a/seahub/organizations/templates/organizations/org_admin_react.html b/seahub/organizations/templates/organizations/org_admin_react.html index 53fad10895c..03117e518a0 100644 --- a/seahub/organizations/templates/organizations/org_admin_react.html +++ b/seahub/organizations/templates/organizations/org_admin_react.html @@ -18,6 +18,8 @@ orgEnableAdminCustomName: '{{ org_enable_admin_custom_name }}', orgEnableAdminInviteUser: '{{ org_enable_admin_invite_user }}', enableMultiADFS: '{{ enable_multi_adfs }}', + isOrgContext: true, + enableSubscription: {% if enable_subscription %} true {% else %} false {% endif %}, } } diff --git a/seahub/organizations/urls.py b/seahub/organizations/urls.py index b48bbbb1cac..405c42e48a5 100644 --- a/seahub/organizations/urls.py +++ b/seahub/organizations/urls.py @@ -45,4 +45,6 @@ path('associate//', org_associate, name='org_associate'), path('samlconfig/', react_fake_view, name='saml_config'), + + re_path(r'^subscription/$', react_fake_view, name='org_subscription'), ] diff --git a/seahub/organizations/views.py b/seahub/organizations/views.py index d7dccc96724..a2fb49d4ed2 100644 --- a/seahub/organizations/views.py +++ b/seahub/organizations/views.py @@ -35,6 +35,7 @@ ORG_ENABLE_ADMIN_CUSTOM_LOGO, ORG_ENABLE_ADMIN_CUSTOM_NAME, \ ORG_ENABLE_ADMIN_INVITE_USER from seahub.organizations.utils import get_or_create_invitation_link +from seahub.subscription.utils import subscription_check # Get an instance of a logger logger = logging.getLogger(__name__) @@ -260,6 +261,7 @@ def react_fake_view(request, **kwargs): 'group_id': group_id, 'invitation_link': invitation_link, 'enable_multi_adfs': ENABLE_MULTI_ADFS, + 'enable_subscription': subscription_check(), }) @login_required diff --git a/seahub/settings.py b/seahub/settings.py index 0732519da56..237df5b17b6 100644 --- a/seahub/settings.py +++ b/seahub/settings.py @@ -279,6 +279,7 @@ 'seahub.krb5_auth', 'seahub.django_cas_ng', 'seahub.seadoc', + 'seahub.subscription', ] @@ -526,13 +527,6 @@ 'MAX_ENTRIES': 1000000 } }, - - # Compatible with existing `COMPRESS_CACHE_BACKEND` setting after - # upgrading to django-compressor v2.2. - # ref: https://manual.seafile.com/deploy_pro/deploy_in_a_cluster.html - 'django.core.cache.backends.locmem.LocMemCache': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - }, } # rest_framwork diff --git a/seahub/subscription/__init__.py b/seahub/subscription/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seahub/subscription/settings.py b/seahub/subscription/settings.py new file mode 100644 index 00000000000..eacc941975f --- /dev/null +++ b/seahub/subscription/settings.py @@ -0,0 +1,10 @@ +from django.conf import settings + + +ENABLE_SUBSCRIPTION = getattr(settings, 'ENABLE_SUBSCRIPTION', False) +SUBSCRIPTION_SERVER_AUTH_KEY = getattr( + settings, 'SUBSCRIPTION_SERVER_AUTH_KEY', '') +SUBSCRIPTION_SERVER_URL = getattr( + settings, 'SUBSCRIPTION_SERVER_URL', '') + +SUBSCRIPTION_ORG_PREFIX = getattr(settings, 'SUBSCRIPTION_ORG_PREFIX', 'org_') diff --git a/seahub/subscription/urls.py b/seahub/subscription/urls.py new file mode 100644 index 00000000000..e6bf185ecd7 --- /dev/null +++ b/seahub/subscription/urls.py @@ -0,0 +1,7 @@ +from django.urls import re_path +from .views import subscription_view, subscription_pay_view + +urlpatterns = [ + re_path(r'^$', subscription_view, name="subscription"), + re_path(r'pay/$', subscription_pay_view, name="subscription-pay"), +] diff --git a/seahub/subscription/utils.py b/seahub/subscription/utils.py new file mode 100644 index 00000000000..f0aa0ecdc6d --- /dev/null +++ b/seahub/subscription/utils.py @@ -0,0 +1,90 @@ +import logging +import requests +from django.core.cache import cache + +from seahub.utils import normalize_cache_key +from seahub.utils import is_pro_version, is_org_context +from .settings import ENABLE_SUBSCRIPTION, SUBSCRIPTION_SERVER_AUTH_KEY, \ + SUBSCRIPTION_SERVER_URL, SUBSCRIPTION_ORG_PREFIX + +logger = logging.getLogger(__name__) + +SUBSCRIPTION_TOKEN_CACHE_KEY = 'SUBSCRIPTION_TOKEN' + + +def subscription_check(): + + if not is_pro_version() or not ENABLE_SUBSCRIPTION: + return False + + if not SUBSCRIPTION_SERVER_AUTH_KEY \ + or not SUBSCRIPTION_SERVER_URL: + logger.error('subscription relevant settings invalid.') + logger.error( + 'please check SUBSCRIPTION_SERVER_AUTH_KEY') + logger.error('SUBSCRIPTION_SERVER_URL: %s' % SUBSCRIPTION_SERVER_URL) + return False + + return True + + +def get_subscription_jwt_token(): + cache_key = normalize_cache_key(SUBSCRIPTION_TOKEN_CACHE_KEY) + jwt_token = cache.get(cache_key, None) + + if not jwt_token: + data = { + 'auth_key': SUBSCRIPTION_SERVER_AUTH_KEY, + } + url = SUBSCRIPTION_SERVER_URL.rstrip('/') + '/api/jwt-auth/' + response = requests.post(url, json=data) + if response.status_code >= 400: + raise ConnectionError(response.status_code, response.text) + + response_dic = response.json() + jwt_token = response_dic.get('token') + cache.set(cache_key, jwt_token, 3000) + + return jwt_token + + +def clear_subscription_jwt_token(): + cache_key = normalize_cache_key(SUBSCRIPTION_TOKEN_CACHE_KEY) + cache.set(cache_key, None) + + +def get_subscription_api_headers(): + jwt_token = get_subscription_jwt_token() + headers = { + 'Authorization': 'JWT ' + jwt_token, + 'Content-Type': 'application/json', + } + + return headers + + +def handler_subscription_api_response(response): + if response.status_code == 403: + clear_subscription_jwt_token() + response.status_code = 500 + + return response + + +def subscription_permission_check(request): + if is_org_context(request): + is_org_staff = request.user.org.is_staff + if not is_org_staff: + return False + + return True + + +def get_customer_id(request): + if is_org_context(request): + org_id = request.user.org.org_id + customer_id = SUBSCRIPTION_ORG_PREFIX + str(org_id) + else: + customer_id = request.user.username + + return customer_id diff --git a/seahub/subscription/views.py b/seahub/subscription/views.py new file mode 100644 index 00000000000..08b6bb8763c --- /dev/null +++ b/seahub/subscription/views.py @@ -0,0 +1,91 @@ +# Copyright (c) 2012-2020 Seafile Ltd. +# encoding: utf-8 +import requests +import logging +from django.shortcuts import render +from django.utils.translation import gettext as _ +from django.http import HttpResponseRedirect + +from seahub.utils import render_error, is_org_context +from seahub.auth.decorators import login_required +from .utils import subscription_check, subscription_permission_check, \ + get_subscription_api_headers, get_customer_id, handler_subscription_api_response +from .settings import SUBSCRIPTION_SERVER_URL + +logger = logging.getLogger(__name__) + + +@login_required +def subscription_view(request): + """ + subscription + """ + if not subscription_check(): + return render_error(request, _('Feature is not enabled.')) + + if is_org_context(request): + return render_error(request, _('Permission denied.')) + + return_dict = {} + template = 'subscription/subscription_react.html' + return render(request, template, return_dict) + + +@login_required +def subscription_pay_view(request): + """ + subscription + """ + if not subscription_check(): + return render_error(request, _('Feature is not enabled.')) + + if not subscription_permission_check(request): + error_msg = _('Permission denied.') + return render_error(request, error_msg) + + plan_id = request.GET.get('plan_id') + payment_source = request.GET.get('payment_source') + payment_type = request.GET.get('payment_type') + count = request.GET.get('count') + asset_quota = request.GET.get('asset_quota') + total_amount = request.GET.get('total_amount') + + # main + try: + customer_id = get_customer_id(request) + headers = get_subscription_api_headers() + + data = { + 'customer_id': customer_id, + 'plan_id': plan_id, + 'payment_source': payment_source, + 'payment_type': payment_type, + 'total_amount': total_amount, + } + if count: + data['count'] = count + if asset_quota: + data['asset_quota'] = asset_quota + + url = SUBSCRIPTION_SERVER_URL.rstrip('/') + '/api/seafile/subscription/pay/' + response = requests.post(url, json=data, headers=headers) + response = handler_subscription_api_response(response) + response_dic = response.json() + if response.status_code >= 400: + error_msg = response_dic.get('error_msg') + if 'non_field_errors' in response_dic and response_dic['non_field_errors']: + error_msg = response_dic['non_field_errors'][0] + return render_error(request, error_msg) + + use_redirect_url = response_dic.get('use_redirect_url') + redirect_url = response_dic.get('redirect_url') + + if use_redirect_url and redirect_url: + return HttpResponseRedirect(redirect_url) + + if not use_redirect_url: + return render(request, 'subscription/pay_result.html', {'info': '支付成功'}) + except Exception as e: + logger.error(e) + error_msg = _('Internal Server Error') + return render_error(request, error_msg) diff --git a/seahub/templates/base.html b/seahub/templates/base.html index a9ab2ce395c..fa8313f5181 100644 --- a/seahub/templates/base.html +++ b/seahub/templates/base.html @@ -84,12 +84,6 @@ {% endfor %} {% endif %} - -
    -
    - - -
    {% block extra-hidden-dom %} {% endblock %} @@ -107,7 +101,6 @@ } {# 'id="jquery"' is for pdf file view with pdf2html #} - diff --git a/seahub/templates/repo_history_view.html b/seahub/templates/repo_history_view.html deleted file mode 100644 index e4632d4d0b6..00000000000 --- a/seahub/templates/repo_history_view.html +++ /dev/null @@ -1,128 +0,0 @@ -{% extends "base_wide_page.html" %} - -{% load seahub_tags avatar_tags i18n %} - - -{% block extra_style %} - -{% endblock %} - -{% block wide_page_content %} -

    {% blocktrans with repo_name=repo.props.name %}{{repo_name}} Snapshot{% endblocktrans %} ({{ current_commit.props.ctime|tsstr_sec }})

    - -{% if referer %} - - - -{% endif %} - - {% if path == '/' %} -
    - {{ current_commit.props.desc|translate_commit_desc }} - - - {% if current_commit.props.creator_name %} - - {{ current_commit.props.creator_name|email2nickname }} - {% else %} - {% trans "Unknown"%} - {% endif %} - - {{ current_commit.props.ctime|translate_seahub_time }} - -
    - {% endif %} - -
    -

    - {% trans "Current path: "%} - {% for name, link in zipped %} - {% if not forloop.last %} - {{ name }} / - {% else %} - {{ name }} - {% endif %} - {% endfor %} -

    - - {% if path == '/' and is_repo_owner %} - - {% endif %} -
    - - - - - - - - - - {% for dirent in dir_list %} - - - - - - - {% endfor %} - - {% for dirent in file_list %} - - - - - - - {% endfor %} -
    {% trans "Name"%}{% trans "Size"%}{% trans "Operations"%}
    {% trans {{ dirent.obj_name }}{% trans "Restore" %}
    {% trans {{ dirent.props.obj_name }}{{ dirent.file_size|filesizeformat }} - {% trans "Restore" %} - {% trans "Download" %} -
    - {% endblock %} - -{% block extra_script %} - -{% endblock %} diff --git a/seahub/templates/subscription/pay_result.html b/seahub/templates/subscription/pay_result.html new file mode 100644 index 00000000000..6a6a725035e --- /dev/null +++ b/seahub/templates/subscription/pay_result.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block main_panel %} +
    +

    {{ info }}

    +
    +{% endblock %} + diff --git a/seahub/templates/subscription/subscription_react.html b/seahub/templates/subscription/subscription_react.html new file mode 100644 index 00000000000..4be94901c56 --- /dev/null +++ b/seahub/templates/subscription/subscription_react.html @@ -0,0 +1,19 @@ +{% extends 'base_for_react.html' %} +{% load seahub_tags avatar_tags i18n %} +{% load render_bundle from webpack_loader %} + +{% block sub_title %}付费管理 - {% endblock %} + +{% block extra_style %} +{% render_bundle 'subscription' 'css' %} +{% endblock %} + +{% block extra_script %} + +{% render_bundle 'subscription' 'js' %} +{% endblock %} diff --git a/seahub/urls.py b/seahub/urls.py index 21d1f157956..ba563ec93d6 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -141,7 +141,7 @@ from seahub.api2.endpoints.admin.device_errors import AdminDeviceErrors from seahub.api2.endpoints.admin.users import AdminUsers, AdminUser, AdminUserResetPassword, AdminAdminUsers, \ AdminUserGroups, AdminUserShareLinks, AdminUserUploadLinks, AdminUserBeSharedRepos, \ - AdminLDAPUsers, AdminSearchUser, AdminUpdateUserCcnetEmail + AdminLDAPUsers, AdminSearchUser, AdminUpdateUserCcnetEmail, AdminUserList from seahub.api2.endpoints.admin.device_trusted_ip import AdminDeviceTrustedIP from seahub.api2.endpoints.admin.libraries import AdminLibraries, AdminLibrary, \ AdminSearchLibrary @@ -163,7 +163,7 @@ AdminImportUsers from seahub.api2.endpoints.admin.operation_logs import AdminOperationLogs from seahub.api2.endpoints.admin.organizations import AdminOrganizations, \ - AdminOrganization, AdminSearchOrganization + AdminOrganization, AdminSearchOrganization, AdminOrganizationsBaseInfo from seahub.api2.endpoints.admin.institutions import AdminInstitutions, AdminInstitution from seahub.api2.endpoints.admin.institution_users import AdminInstitutionUsers, AdminInstitutionUser from seahub.api2.endpoints.admin.org_users import AdminOrgUsers, AdminOrgUser @@ -205,6 +205,7 @@ LibraryIndexState, QuestionAnsweringSearchInLibrary, FileDownloadToken from seahub.wiki2.views import wiki_view from seahub.api2.endpoints.wiki2 import Wikis2View, Wiki2View, Wiki2ConfigView, Wiki2PagesDirView, Wiki2PageContentView +from seahub.api2.endpoints.subscription import SubscriptionView, SubscriptionPlansView, SubscriptionLogsView urlpatterns = [ path('accounts/', include('seahub.base.registration_urls')), @@ -591,6 +592,7 @@ re_path(r'^api/v2.1/admin/ldap-users/$', AdminLDAPUsers.as_view(), name='api-v2.1-admin-ldap-users'), re_path(r'^api/v2.1/admin/search-user/$', AdminSearchUser.as_view(), name='api-v2.1-admin-search-user'), re_path(r'^api/v2.1/admin/update-user-ccnet-email/$', AdminUpdateUserCcnetEmail.as_view(), name='api-v2.1-admin-update-user-ccnet-email'), + re_path(r'^api/v2.1/admin/user-list/$', AdminUserList.as_view(), name='api-v2.1-admin-user-list'), # [^...] Matches any single character not in brackets # + Matches between one and unlimited times, as many times as possible @@ -682,6 +684,7 @@ ## admin::organizations re_path(r'^api/v2.1/admin/organizations/$', AdminOrganizations.as_view(), name='api-v2.1-admin-organizations'), + re_path(r'^api/v2.1/admin/organizations-basic-info/$', AdminOrganizationsBaseInfo.as_view(), name='api-v2.1-admin-organizations-basic-info'), re_path(r'^api/v2.1/admin/search-organization/$', AdminSearchOrganization.as_view(), name='api-v2.1-admin-Search-organization'), re_path(r'^api/v2.1/admin/organizations/(?P\d+)/$', AdminOrganization.as_view(), name='api-v2.1-admin-organization'), re_path(r'^api/v2.1/admin/organizations/(?P\d+)/users/$', AdminOrgUsers.as_view(), name='api-v2.1-admin-org-users'), @@ -901,6 +904,11 @@ path('org/', include('seahub.organizations.urls')), ] +if getattr(settings, 'MULTI_INSTITUTION', False): + urlpatterns += [ + re_path(r'^api/v2.1/institutions/', include('seahub.institutions.api_urls')), + ] + if getattr(settings, 'ENABLE_SHIB_LOGIN', False): urlpatterns += [ re_path(r'^shib-complete/', TemplateView.as_view(template_name='shibboleth/complete.html'), name="shib_complete"), @@ -1002,3 +1010,11 @@ re_path(r'^client-sso/(?P[^/]+)/$', client_sso, name="client_sso"), re_path(r'^client-sso/(?P[^/]+)/complete/$', client_sso_complete, name="client_sso_complete"), ] + +if getattr(settings, 'ENABLE_SUBSCRIPTION', False): + urlpatterns += [ + re_path(r'^subscription/', include('seahub.subscription.urls')), + re_path(r'^api/v2.1/subscription/$', SubscriptionView.as_view(), name='api-v2.1-subscription'), + re_path(r'^api/v2.1/subscription/plans/$', SubscriptionPlansView.as_view(), name='api-v2.1-subscription-plans'), + re_path(r'^api/v2.1/subscription/logs/$', SubscriptionLogsView.as_view(), name='api-v2.1-subscription-logs'), + ] diff --git a/tests/seahub/institutions/test_views.py b/tests/seahub/institutions/test_views.py deleted file mode 100644 index 834ca4ab9da..00000000000 --- a/tests/seahub/institutions/test_views.py +++ /dev/null @@ -1,128 +0,0 @@ -from django.core import mail -from django.conf import settings -from django.urls import reverse -from django.test import override_settings - -from seahub.base.accounts import User -from seahub.institutions.models import Institution, InstitutionAdmin -from seahub.institutions.utils import is_institution_admin -from seahub.profile.models import Profile -from seahub.test_utils import BaseTestCase - -settings.MIDDLEWARE.append( - 'seahub.institutions.middleware.InstitutionMiddleware', -) - - -class InstTestBase(BaseTestCase): - def setUp(self): - self.inst = Institution.objects.create(name='inst_test') - - assert len(Profile.objects.all()) == 0 - p = Profile.objects.add_or_update(self.user.username, '') - p.institution = self.inst.name - p.save() - - p = Profile.objects.add_or_update(self.admin.username, '') - p.institution = self.inst.name - p.save() - assert len(Profile.objects.all()) == 2 - - InstitutionAdmin.objects.create(institution=self.inst, - user=self.user.username) - -class InfoTest(InstTestBase): - @override_settings( - MIDDLEWARE=settings.MIDDLEWARE, - MULTI_INSTITUTION=True - ) - def test_can_render(self): - self.login_as(self.user) - - resp = self.client.get(reverse('institutions:info')) - self.assertEqual(200, resp.status_code) - assert resp.context['inst'] == self.inst - - -class UseradminTest(InstTestBase): - @override_settings( - MIDDLEWARE=settings.MIDDLEWARE, - MULTI_INSTITUTION=True - ) - def test_can_list(self): - self.login_as(self.user) - resp = self.client.get(reverse('institutions:useradmin')) - self.assertEqual(200, resp.status_code) - assert resp.context['inst'] == self.inst - assert len(resp.context['users']) == 2 - - -class UseradminSearchTest(InstTestBase): - @override_settings( - MIDDLEWARE=settings.MIDDLEWARE, - MULTI_INSTITUTION=True - ) - def test_can_search(self): - self.login_as(self.user) - resp = self.client.get(reverse('institutions:useradmin_search') + '?q=@') - self.assertEqual(200, resp.status_code) - assert resp.context['inst'] == self.inst - assert len(resp.context['users']) == 2 - assert resp.context['q'] == '@' - - -class UserToggleStatusTest(InstTestBase): - @override_settings( - MIDDLEWARE=settings.MIDDLEWARE, - MULTI_INSTITUTION=True - ) - def test_can_activate(self): - self.login_as(self.user) - self.assertEqual(len(mail.outbox), 0) - - old_passwd = self.admin.enc_password - resp = self.client.post( - reverse('institutions:user_toggle_status', args=[self.admin.username]), - {'s': 1}, - HTTP_X_REQUESTED_WITH='XMLHttpRequest' - ) - self.assertEqual(200, resp.status_code) - self.assertContains(resp, '"success": true') - - u = User.objects.get(email=self.admin.username) - assert u.is_active is True - assert u.enc_password == old_passwd - self.assertEqual(len(mail.outbox), 1) - - @override_settings( - MIDDLEWARE=settings.MIDDLEWARE, - MULTI_INSTITUTION=True - ) - def test_can_deactivate(self): - self.login_as(self.user) - - old_passwd = self.admin.enc_password - resp = self.client.post( - reverse('institutions:user_toggle_status', args=[self.admin.username]), - {'s': 0}, - HTTP_X_REQUESTED_WITH='XMLHttpRequest' - ) - self.assertEqual(200, resp.status_code) - self.assertContains(resp, '"success": true') - - u = User.objects.get(email=self.admin.username) - assert u.is_active is False - assert u.enc_password == old_passwd - - -class UserIsAdminTest(InstTestBase): - @override_settings( - MIDDLEWARE=settings.MIDDLEWARE, - MULTI_INSTITUTION=True - ) - - def test_is_institution_admin(self): - assert is_institution_admin(self.user.username) == True - assert is_institution_admin(self.admin.username) == False - assert is_institution_admin(self.user.username, self.inst) == True - assert is_institution_admin(self.admin.username, self.inst) == False diff --git a/tests/seahub/views/repo/test_repo_history_view.py b/tests/seahub/views/repo/test_repo_history_view.py deleted file mode 100644 index 1a613ea5e46..00000000000 --- a/tests/seahub/views/repo/test_repo_history_view.py +++ /dev/null @@ -1,14 +0,0 @@ -from django.urls import reverse - -from seahub.test_utils import BaseTestCase - -class RepoHistoryViewTest(BaseTestCase): - def setUp(self): - self.login_as(self.user) - self.url = reverse('repo_history_view', args=[self.repo.id]) + '?commit_id=' + self.repo.head_cmmt_id - - def test_can_render(self): - resp = self.client.get(self.url) - self.assertEqual(200, resp.status_code) - self.assertTemplateUsed(resp, 'repo_history_view.html') - assert resp.context['user_perm'] == 'rw'