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 (
+
+