diff --git a/src/redux/session.js b/src/redux/session.js index f7d762017..ca775f29d 100644 --- a/src/redux/session.js +++ b/src/redux/session.js @@ -149,6 +149,8 @@ module.exports.selectMuteStatus = state => get(state, ['session', 'session', 'pe module.exports.selectIsMuted = state => (module.exports.selectMuteStatus(state).muteExpiresAt || 0) * 1000 > Date.now(); module.exports.selectNewStudiosLaunched = state => get(state, ['session', 'session', 'flags', 'new_studios_launched'], false); +module.exports.selectStudioTransferLaunched = state => get(state, ['session', 'session', 'flags', + 'studio_transfer_launched'], false); module.exports.selectHasFetchedSession = state => state.session.status === module.exports.Status.FETCHED; diff --git a/src/redux/studio-permissions.js b/src/redux/studio-permissions.js index 6a00ffa08..99b75f9d9 100644 --- a/src/redux/studio-permissions.js +++ b/src/redux/studio-permissions.js @@ -3,12 +3,12 @@ const {selectUserId, selectIsAdmin, selectIsSocial, selectHasFetchedSession, selectStudioCommentsGloballyEnabled} = require('./session'); // Fine-grain selector helpers - not exported, use the higher level selectors below -const isCreator = state => selectUserId(state) === state.studio.owner; +const isHost = state => selectUserId(state) === state.studio.owner; const isCurator = state => state.studio.curator; -const isManager = state => state.studio.manager || isCreator(state); +const isManager = state => state.studio.manager || isHost(state); // Action-based permissions selectors -const selectCanEditInfo = state => !selectIsMuted(state) && (selectIsAdmin(state) || isCreator(state)); +const selectCanEditInfo = state => !selectIsMuted(state) && (selectIsAdmin(state) || isHost(state)); const selectCanAddProjects = state => !selectIsMuted(state) && (isManager(state) || @@ -35,7 +35,7 @@ const selectCanDeleteCommentWithoutConfirm = state => selectIsAdmin(state); const selectCanFollowStudio = state => selectIsLoggedIn(state); // Matching existing behavior, only admin/creator is allowed to toggle comments. -const selectCanEditCommentsAllowed = state => !selectIsMuted(state) && (selectIsAdmin(state) || isCreator(state)); +const selectCanEditCommentsAllowed = state => !selectIsMuted(state) && (selectIsAdmin(state) || isHost(state)); const selectCanEditOpenToAll = state => !selectIsMuted(state) && isManager(state); const selectShowCuratorInvite = state => !selectIsMuted(state) && !!state.studio.invited; @@ -54,6 +54,20 @@ const selectCanRemoveManager = (state, managerId) => !selectIsMuted(state) && (selectIsAdmin(state) || isManager(state)) && managerId !== state.studio.owner; const selectCanPromoteCurators = state => !selectIsMuted(state) && isManager(state); +const selectCanTransfer = (state, managerId) => { + // Nobody can transfer a class studio. + // classroomId is loaded only for educator and admin users. Only educators can create class studios, + // so educators and admins are the only users who otherwise would be able to transfer a class studio. + if (state.studio.classroomId !== null) return false; + if (state.studio.managers > 1) { // If there is more than one manager, + if (managerId === state.studio.owner) { // and the selected manager is the current owner/host, + if (isHost(state)) return true; // Owner/host can transfer + if (selectIsAdmin(state)) return true; // Admin can transfer + } + } + return false; +}; + const selectCanRemoveProject = (state, creatorUsername, actorId) => { if (selectIsMuted(state)) return false; @@ -73,7 +87,7 @@ const selectCanRemoveProject = (state, creatorUsername, actorId) => { // We should only show the mute errors to muted users who have any permissions related to the content // TODO these duplicate the behavior embedded in the non-muted parts of the selectors above, it would be good // to extract this. -const selectShowEditMuteError = state => selectIsMuted(state) && (isCreator(state) || selectIsAdmin(state)); +const selectShowEditMuteError = state => selectIsMuted(state) && (isHost(state) || selectIsAdmin(state)); const selectShowProjectMuteError = state => selectIsMuted(state) && (selectIsAdmin(state) || isManager(state) || @@ -99,6 +113,7 @@ export { selectCanRemoveCurator, selectCanRemoveManager, selectCanPromoteCurators, + selectCanTransfer, selectCanRemoveProject, selectShowCommentsList, selectShowCommentsGloballyOffError, diff --git a/src/redux/studio.js b/src/redux/studio.js index ecbd58ad1..0fa77c7ba 100644 --- a/src/redux/studio.js +++ b/src/redux/studio.js @@ -4,7 +4,7 @@ const {withAdmin} = require('../lib/admin-requests'); const api = require('../lib/api'); const log = require('../lib/log'); -const {selectUsername, selectToken, selectIsEducator} = require('./session'); +const {selectUsername, selectToken, selectIsEducator, selectIsAdmin} = require('./session'); const Status = keyMirror({ FETCHED: null, @@ -28,7 +28,7 @@ const getInitialState = () => ({ owner: null, public: null, - // BEWARE: classroomId is only loaded if the user is an educator + // BEWARE: classroomId is only loaded if the user is an educator or admin classroomId: null, rolesStatus: Status.NOT_FETCHED, @@ -164,7 +164,7 @@ const getRoles = () => ((dispatch, getState) => { }); // Since the user is now loaded, it's a good time to check if the studio is part of a classroom - if (selectIsEducator(state)) { + if (selectIsEducator(state) || selectIsAdmin(state)) { api({uri: `/studios/${studioId}/classroom`}, (err, body, res) => { // No error states for inability/problems loading classroom, just swallow them if (!err && res.statusCode === 200 && body) dispatch(setInfo({classroomId: body.id})); diff --git a/src/views/studio/l10n.json b/src/views/studio/l10n.json index 3b5d28ecb..d76dc95dc 100644 --- a/src/views/studio/l10n.json +++ b/src/views/studio/l10n.json @@ -50,6 +50,7 @@ "studio.projectErrors.duplicate": "That project is already in this studio.", "studio.creatorRole": "Studio Creator", + "studio.hostRole": "Studio Host", "studio.managersHeader": "Managers", @@ -88,10 +89,26 @@ "studio.managerThresholdInfo": "This studio has {numberOfManagers} managers. Studios can have a maximum of {managerLimit} managers.", "studio.managerThresholdRemoveManagers": "Before you can add another manager, you will need to remove managers until there are fewer than {managerLimit}.", + "studio.transfer.youAreAboutTo": "You are about to make someone else the studio host.", + "studio.transfer.cannotUndo": "You cannot undo this.", + "studio.transfer.thisMeans": "This means...", + "studio.transfer.noLongerEdit": "You will no longer be able to edit the title, thumbnail, and description", + "studio.transfer.noLongerDelete": "You will no longer be able to delete the studio", + "studio.transfer.whichManager": "Which manager do you want to make the host?", + "studio.transfer.currentHost": "Current Host", + "studio.transfer.newHost": "New Host", + "studio.transfer.confirmWithPassword": "To confirm changing the studio host, please enter your password.", + "studio.transfer.forgotPassword": "Forgot password?", + "studio.transfer.alert.somethingWentWrong": "Something went wrong transferring this studio to a new host.", + "studio.remove": "Remove", "studio.promote": "Promote", + "studio.transfer": "Change Studio Host", "studio.cancel": "Cancel", "studio.okay": "Okay", + "studio.next": "Next", + "studio.back": "Back", + "studio.confirm": "Confirm", "studio.commentsHeader": "Comments", "studio.commentsNotAllowed": "Commenting for this studio has been turned off.", @@ -138,5 +155,7 @@ "studio.alertCuratorInvited": "Curator invite sent to \"{name}\"", "studio.alertManagerPromote": "\"{name}\" is now a manager", "studio.alertManagerPromoteError": "Something went wrong promoting \"{name}\"", - "studio.alertMemberRemoveError": "Something went wrong removing \"{name}\"" + "studio.alertMemberRemoveError": "Something went wrong removing \"{name}\"", + "studio.alertTransfer": "\"{name}\" is now the host", + "studio.alertTransferRateLimit": "You can only change the host once a day. Try again tomorrow." } diff --git a/src/views/studio/lib/studio-member-actions.js b/src/views/studio/lib/studio-member-actions.js index bd1db01ff..e8a498ddf 100644 --- a/src/views/studio/lib/studio-member-actions.js +++ b/src/views/studio/lib/studio-member-actions.js @@ -2,7 +2,7 @@ import keyMirror from 'keymirror'; import api from '../../../lib/api'; import {curators, managers} from './redux-modules'; -import {selectUsername} from '../../../redux/session'; +import {selectUsername, selectToken} from '../../../redux/session'; import {selectStudioId, setRoles, setInfo} from '../../../redux/studio'; import {withAdmin} from '../../../lib/admin-requests'; @@ -187,6 +187,26 @@ const acceptInvitation = () => ((dispatch, getState) => new Promise((resolve, re }); })); +const transferHost = (password, newHostName, newHostId) => + ((dispatch, getState) => new Promise((resolve, reject) => { + const state = getState(); + const studioId = selectStudioId(state); + const token = selectToken(state); + newHostName = newHostName.trim(); + api({ + uri: `/studios/${studioId}/transfer/${newHostName}?password=${password}`, + method: 'PUT', + authentication: token, + withCredentials: true, + useCsrf: true + }, (err, body, res) => { + const error = normalizeError(err, body, res); + if (error) return reject(error); + dispatch(setInfo({owner: newHostId})); + return resolve(); + }); + })); + export { Errors, loadManagers, @@ -195,5 +215,6 @@ export { acceptInvitation, promoteCurator, removeCurator, - removeManager + removeManager, + transferHost }; diff --git a/src/views/studio/modals/transfer-host-confirmation.jsx b/src/views/studio/modals/transfer-host-confirmation.jsx new file mode 100644 index 000000000..e459b04e9 --- /dev/null +++ b/src/views/studio/modals/transfer-host-confirmation.jsx @@ -0,0 +1,136 @@ +import React, {useState} from 'react'; +import PropTypes from 'prop-types'; +import {connect} from 'react-redux'; +import {FormattedMessage} from 'react-intl'; + +import ModalInnerContent from '../../../components/modal/base/modal-inner-content.jsx'; + +import TransferHostTile from './transfer-host-tile.jsx'; +import Form from '../../../components/forms/form.jsx'; + +import {managers} from '../lib/redux-modules'; + +import './transfer-host-modal.scss'; + +const TransferHostConfirmation = ({ + handleBack, + handleTransfer, + items, + hostId, + selectedId +}) => { + const currentHostUsername = items.find(item => item.id === hostId).username; + const currentHostImage = items.find(item => item.id === hostId).profile.images['90x90']; + const newHostUsername = items.find(item => item.id === selectedId).username; + const newHostImage = items.find(item => item.id === selectedId).profile.images['90x90']; + const [passwordInputValue, setPasswordInputValue] = useState(''); + const handleSubmit = () => { + handleTransfer(passwordInputValue, newHostUsername, selectedId); + }; + const handleChangePasswordInput = e => { + setPasswordInputValue(e.target.value); + }; + return ( + +
+
+
+ +
+ +
+ +
+
+ +
+ +
+
+
+

+ +

+
+
+ +
+ + + +
+
+ + +
+
+
+ ); +}; + +TransferHostConfirmation.propTypes = { + handleBack: PropTypes.func, + handleTransfer: PropTypes.func, + items: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.id, + username: PropTypes.string, + profile: PropTypes.shape({ + images: PropTypes.shape({ + '90x90': PropTypes.string + }) + }) + })), + selectedId: PropTypes.number, + hostId: PropTypes.number +}; + +export default connect( + state => ({ + hostId: state.studio.owner, + ...managers.selector(state) + }) +)(TransferHostConfirmation); diff --git a/src/views/studio/modals/transfer-host-info.jsx b/src/views/studio/modals/transfer-host-info.jsx new file mode 100644 index 000000000..6adceaaa1 --- /dev/null +++ b/src/views/studio/modals/transfer-host-info.jsx @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {FormattedMessage} from 'react-intl'; + +import ModalInnerContent from '../../../components/modal/base/modal-inner-content.jsx'; + +import AlertComponent from '../../../components/alert/alert-component.jsx'; +import errorIcon from '../../../components/alert/icon-alert-error.svg'; + +import './transfer-host-modal.scss'; + +const TransferHostInfo = ({ + handleClose, + handleNext +}) => + (
+ + +
+

+ +

+
+
+ +
+ + + + +
+ + +
+
+
); + +TransferHostInfo.propTypes = { + handleClose: PropTypes.func, + handleNext: PropTypes.func +}; + +export default TransferHostInfo; diff --git a/src/views/studio/modals/transfer-host-modal.jsx b/src/views/studio/modals/transfer-host-modal.jsx new file mode 100644 index 000000000..fa149aca6 --- /dev/null +++ b/src/views/studio/modals/transfer-host-modal.jsx @@ -0,0 +1,61 @@ +import React, {useState} from 'react'; +import PropTypes from 'prop-types'; +import {FormattedMessage} from 'react-intl'; +import keyMirror from 'keymirror'; + +import Modal from '../../../components/modal/base/modal.jsx'; +import ModalTitle from '../../../components/modal/base/modal-title.jsx'; + +import TransferHostInfo from './transfer-host-info.jsx'; +import TransferHostSelection from './transfer-host-selection.jsx'; +import TransferHostConfirmation from './transfer-host-confirmation.jsx'; + +import './transfer-host-modal.scss'; + +const STEPS = keyMirror({ + info: null, + selection: null, + confirmation: null +}); + +const TransferHostModal = ({ + handleClose, + handleTransfer +}) => { + const [step, setStep] = useState(STEPS.info); + const [selectedId, setSelectedId] = useState(null); + return ( + } + /> + {step === STEPS.info && setStep(STEPS.selection)} // eslint-disable-line react/jsx-no-bind + />} + {step === STEPS.selection && setStep(STEPS.confirmation)} // eslint-disable-line react/jsx-no-bind + handleBack={() => setStep(STEPS.info)} // eslint-disable-line react/jsx-no-bind + handleSelected={setSelectedId} + selectedId={selectedId} + />} + {step === STEPS.confirmation && setStep(STEPS.selection)} // eslint-disable-line react/jsx-no-bind + handleTransfer={handleTransfer} + selectedId={selectedId} + />} + ); +}; + +TransferHostModal.propTypes = { + handleClose: PropTypes.func, + handleTransfer: PropTypes.func +}; + +export default TransferHostModal; diff --git a/src/views/studio/modals/transfer-host-modal.scss b/src/views/studio/modals/transfer-host-modal.scss new file mode 100644 index 000000000..80989d22f --- /dev/null +++ b/src/views/studio/modals/transfer-host-modal.scss @@ -0,0 +1,176 @@ +@import "../../../colors"; +@import "../../../frameless"; + +.transfer-host-modal { + .transfer-host-title { + background: $ui-blue; + border-top-left-radius: 12px; + border-top-right-radius: 12px; + padding-top: .75rem; + width: 100%; + height: 3rem; + cursor: pointer; + } + + .transfer-info-title { + margin-top: 3rem; + } + + h2 { + line-height: 2.5rem; + margin-bottom: 1rem; + } + + .list-header { + font-weight: bold; + } + + ul { + line-height: 1rem; + margin-top: 0px; + } + + .content { + display: flex; + align-items: flex-start; + } + + .transfer-host-image { + margin-top: 2rem; + } + + .inner { + padding: 1rem; + } + + .transfer-host-alert-wrapper { + margin-right: 2rem; + } + + .transfer-host-alert-wrapper .alert-wrapper { + position: relative; + display: block; + margin-bottom: 2rem; + } + + .transfer-host-alert .alert-msg { + font-size: 1rem; + } + + .transfer-host-button-row { + display: flex; + justify-content: flex-end; + padding-top: 1.5rem; + } + + .transfer-host-button-row-split { + justify-content: space-between; + } + + .transfer-selection-buttons { + padding: 1rem; + } + + .button { + margin: 0px; + } + + .button:disabled { + background-color: $active-dark-gray; + } + + .cancel-button { + background-color: $ui-white; + color: $ui-blue; + box-shadow: 0px 0px 0 1px $ui-blue; + margin-right: 1rem; + } + + .next-button { + min-width: 5rem; + } + + .transfer-selection-heading { + padding: 1rem; + background: $ui-blue-10percent; + } + + .transfer-selection-scroll-pane { + height: 250px; + padding-left: 1rem; + padding-right: 1rem; + background: $ui-blue-10percent; + overflow: auto; + } + + .transfer-host-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0,1fr)); + @media #{$intermediate-and-smaller} { + & { grid-template-columns: repeat(2, minmax(0,1fr)); } + } + column-gap: 12px; + row-gap: 12px; + margin-bottom: 1rem; + } + + .transfer-host-name-selected { + color: white !important; + } + + .transfer-selection-icon { + margin: auto 8px; + } + + .transfer-host-tile-selected { + background: $ui-aqua; + } + + .transfer-password-instruction { + padding: 3rem 3rem 2rem; + } + + .transfer-form { + padding: 0px 1rem 1rem; + } + + .transfer-password-input { + margin-left: 2rem; + border: 1px solid $ui-border; + border-radius: .5rem; + padding: 0.5rem 1rem; + font-size: 1.5rem; + margin-bottom: 1rem; + } + + .col-sm-9 .input { + font-size: 1.5rem; + width: 50%; + } + + .transfer-forgot-link { + margin: 0px 2rem 3rem; + } + + .transfer-outcome { + background: $ui-blue-10percent; + padding: 2rem 3rem; + display: flex; + } + + .transfer-outcome-tile { + width: 220px; + box-shadow: 0px 3px 5px $box-shadow-light-gray; + } + + .transfer-outcome-label { + margin-bottom: 0.5rem; + font-weight: bold; + font-size: 12px; + } + + .transfer-outcome-arrow { + width: 40px; + margin: auto 3rem 1rem 3rem; + } +} \ No newline at end of file diff --git a/src/views/studio/modals/transfer-host-selection.jsx b/src/views/studio/modals/transfer-host-selection.jsx new file mode 100644 index 000000000..3e20ab9b3 --- /dev/null +++ b/src/views/studio/modals/transfer-host-selection.jsx @@ -0,0 +1,115 @@ +import React, {useEffect} from 'react'; +import PropTypes from 'prop-types'; +import {connect} from 'react-redux'; +import {FormattedMessage} from 'react-intl'; +import classNames from 'classnames'; + +import ModalInnerContent from '../../../components/modal/base/modal-inner-content.jsx'; + +import TransferHostTile from './transfer-host-tile.jsx'; + +import {managers} from '../lib/redux-modules'; +import {loadManagers} from '../lib/studio-member-actions'; + +import './transfer-host-modal.scss'; + +const TransferHostSelection = ({ + handleSelected, + handleNext, + handleBack, + loading, + moreToLoad, + onLoadMore, + items, + hostId, + selectedId +}) => { + useEffect(() => { + if (items.length === 0) onLoadMore(); + }, []); + + return ( + +
+

+ +

+
+
+
+ {items.filter(item => hostId !== item.id).map(item => + ( handleSelected(item.id)} + id={item.id} + username={item.username} + image={item.profile.images['90x90']} + isCreator={false} + selected={item.id === selectedId} + />) + )} + {moreToLoad && +
+ +
+ } +
+
+
+ + +
+
+ ); +}; + +TransferHostSelection.propTypes = { + handleBack: PropTypes.func, + handleNext: PropTypes.func, + handleSelected: PropTypes.func, + items: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.id, + username: PropTypes.string, + profile: PropTypes.shape({ + images: PropTypes.shape({ + '90x90': PropTypes.string + }) + }) + })), + loading: PropTypes.bool, + moreToLoad: PropTypes.bool, + onLoadMore: PropTypes.func, + selectedId: PropTypes.number, + hostId: PropTypes.number +}; + +export default connect( + state => ({ + hostId: state.studio.owner, + ...managers.selector(state) + }), + { + onLoadMore: loadManagers + } +)(TransferHostSelection); diff --git a/src/views/studio/modals/transfer-host-tile.jsx b/src/views/studio/modals/transfer-host-tile.jsx new file mode 100644 index 000000000..84afe9179 --- /dev/null +++ b/src/views/studio/modals/transfer-host-tile.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +const TransferHostTile = ({ + className, username, image, selected, handleSelected +}) => ( +
+ +
+
+ {username} +
+
+ {selected && +
+ +
} +
+); + +TransferHostTile.propTypes = { + className: PropTypes.string, + username: PropTypes.string, + handleSelected: PropTypes.func, + image: PropTypes.string, + selected: PropTypes.bool +}; + +export default TransferHostTile; diff --git a/src/views/studio/studio-member-tile.jsx b/src/views/studio/studio-member-tile.jsx index ee75655fa..2c5457117 100644 --- a/src/views/studio/studio-member-tile.jsx +++ b/src/views/studio/studio-member-tile.jsx @@ -7,15 +7,19 @@ import {FormattedMessage} from 'react-intl'; import PromoteModal from './modals/promote-modal.jsx'; import ManagerLimitModal from './modals/manager-limit-modal.jsx'; +import TransferHostModal from './modals/transfer-host-modal.jsx'; import { - selectCanRemoveCurator, selectCanRemoveManager, selectCanPromoteCurators + selectCanRemoveCurator, selectCanRemoveManager, selectCanPromoteCurators, + selectCanTransfer } from '../../redux/studio-permissions'; +import {selectStudioTransferLaunched} from '../../redux/session.js'; import { Errors, promoteCurator, removeCurator, - removeManager + removeManager, + transferHost } from './lib/studio-member-actions'; import {selectStudioHasReachedManagerLimit} from '../../redux/studio'; @@ -26,11 +30,13 @@ import removeIcon from './icons/remove-icon.svg'; import promoteIcon from './icons/curator-icon.svg'; const StudioMemberTile = ({ - canRemove, canPromote, onRemove, onPromote, isCreator, hasReachedManagerLimit, // mapState props + canRemove, canPromote, onRemove, canTransferHost, onPromote, onTransferHost, + isCreator, hasReachedManagerLimit, // mapState props username, image // own props }) => { const [submitting, setSubmitting] = useState(false); - const [modalOpen, setModalOpen] = useState(false); + const [promoteModalOpen, setPromoteModalOpen] = useState(false); + const [transferHostModalOpen, setTransferHostModalOpen] = useState(false); const [managerLimitReached, setManagerLimitReached] = useState(false); const {errorAlert, successAlert} = useAlertContext(); const userUrl = `/users/${username}`; @@ -49,12 +55,12 @@ const StudioMemberTile = ({ >{username} {isCreator &&
} - {(canRemove || canPromote) && + {(canRemove || canPromote || canTransferHost) && {canPromote &&
  • } + {canTransferHost &&
  • + +
  • }
    } - {modalOpen && + {promoteModalOpen && ((hasReachedManagerLimit || managerLimitReached) ? setModalOpen(false)} + handleClose={() => setPromoteModalOpen(false)} /> : setModalOpen(false)} + handleClose={() => setPromoteModalOpen(false)} handlePromote={() => { onPromote(username) .then(() => { @@ -102,7 +119,7 @@ const StudioMemberTile = ({ .catch(error => { if (error === Errors.MANAGER_LIMIT) { setManagerLimitReached(true); - setModalOpen(true); + setPromoteModalOpen(true); } else { errorAlert({ id: 'studio.alertManagerPromoteError', @@ -115,6 +132,27 @@ const StudioMemberTile = ({ /> ) } + {transferHostModalOpen && + setTransferHostModalOpen(false)} + handleTransfer={(password, newHostUsername, newHostUsernameId) => { + onTransferHost(password, newHostUsername, newHostUsernameId) + .then(() => { + setTransferHostModalOpen(false); + successAlert({ + id: 'studio.alertTransfer', + values: {name: newHostUsername} + }); + }) + .catch(() => { + setTransferHostModalOpen(false); + errorAlert({ + id: 'studio.transfer.alert.somethingWentWrong' + }); + }); + }} + /> + } ); }; @@ -122,8 +160,10 @@ const StudioMemberTile = ({ StudioMemberTile.propTypes = { canRemove: PropTypes.bool, canPromote: PropTypes.bool, + canTransferHost: PropTypes.bool, onRemove: PropTypes.func, onPromote: PropTypes.func, + onTransferHost: PropTypes.func, username: PropTypes.string, image: PropTypes.string, isCreator: PropTypes.bool, @@ -134,10 +174,13 @@ const ManagerTile = connect( (state, ownProps) => ({ canRemove: selectCanRemoveManager(state, ownProps.id), canPromote: false, + canTransferHost: selectCanTransfer(state, ownProps.id) && + selectStudioTransferLaunched(state), isCreator: state.studio.owner === ownProps.id }), { - onRemove: removeManager + onRemove: removeManager, + onTransferHost: transferHost } )(StudioMemberTile); diff --git a/src/views/studio/studio.scss b/src/views/studio/studio.scss index dba475b44..30b18490c 100644 --- a/src/views/studio/studio.scss +++ b/src/views/studio/studio.scss @@ -399,6 +399,11 @@ $radius: 8px; background: transparent; border: none; } + + .studio-member-tile-menu-wide { + white-space: nowrap; + padding-right: 2rem !important; + } } .studio-members + .studio-members { diff --git a/static/svgs/studio/icon-alert-error.svg b/static/svgs/studio/icon-alert-error.svg new file mode 100644 index 000000000..6dc116bdf --- /dev/null +++ b/static/svgs/studio/icon-alert-error.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/svgs/studio/r-arrow.svg b/static/svgs/studio/r-arrow.svg new file mode 100644 index 000000000..142288cb3 --- /dev/null +++ b/static/svgs/studio/r-arrow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/svgs/studio/transfer-host.svg b/static/svgs/studio/transfer-host.svg new file mode 100644 index 000000000..5b164d232 --- /dev/null +++ b/static/svgs/studio/transfer-host.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + +