diff --git a/app/components/Auth.js b/app/components/Auth.js index fa8ce301f..f2be746c1 100644 --- a/app/components/Auth.js +++ b/app/components/Auth.js @@ -2,8 +2,8 @@ import React from 'react'; import { Provider } from 'mobx-react'; import stores from 'stores'; -import ApiKeysStore from 'stores/settings/ApiKeysStore'; -import MembersStore from 'stores/settings/MembersStore'; +import ApiKeySettingsStore from 'stores/ApiKeySettingsStore'; +import MemberSettingsStore from 'stores/MemberSettingsStore'; import DocumentsStore from 'stores/DocumentsStore'; import CollectionsStore from 'stores/CollectionsStore'; import CacheStore from 'stores/CacheStore'; @@ -23,8 +23,8 @@ const Auth = ({ children }: Props) => { const { user, team } = stores.auth; const cache = new CacheStore(user.id); authenticatedStores = { - apiKeys: new ApiKeysStore(), - members: new MembersStore(), + apiKeys: new ApiKeySettingsStore(), + memberSettings: new MemberSettingsStore(), documents: new DocumentsStore({ ui: stores.ui, cache, diff --git a/app/scenes/Settings/Members.js b/app/scenes/Settings/Members.js index c0c1b1dd5..58e20084b 100644 --- a/app/scenes/Settings/Members.js +++ b/app/scenes/Settings/Members.js @@ -10,7 +10,7 @@ import { color } from 'shared/styles/constants'; import AuthStore from 'stores/AuthStore'; import ErrorsStore from 'stores/ErrorsStore'; -import MembersStore from 'stores/settings/MembersStore'; +import MemberSettingsStore from 'stores/MemberSettingsStore'; import CenteredContent from 'components/CenteredContent'; import LoadingPlaceholder from 'components/LoadingPlaceholder'; import PageTitle from 'components/PageTitle'; @@ -21,7 +21,7 @@ class Members extends Component { props: { auth: AuthStore, errors: ErrorsStore, - members: MembersStore, + memberSettings: MemberSettingsStore, }; @observable members; @@ -31,7 +31,7 @@ class Members extends Component { @observable isInviting: boolean = false; componentDidMount() { - this.props.members.fetchMembers(); + this.props.memberSettings.fetchUsers(); } render() { @@ -43,13 +43,13 @@ class Members extends Component {

Members

- {!this.props.members.isFetching ? ( + {!this.props.memberSettings.isLoaded ? ( - {this.props.members.members && ( + {this.props.memberSettings.users && ( - {this.props.members.members.map(member => ( + {this.props.memberSettings.users.map(member => ( - + {member.name} {member.email && `(${member.email})`} @@ -58,7 +58,7 @@ class Members extends Component { )} {member.isSuspended && Suspended} - + {user.id !== member.id && } @@ -93,6 +93,10 @@ const Member = styled(Flex)` } `; +const MemberDetails = styled(Flex)` + opacity: ${({ suspended }) => (suspended ? 0.5 : 1)}; +`; + const UserName = styled.span` padding-left: 8px; `; @@ -108,4 +112,4 @@ const Badge = styled.span` font-weight: normal; `; -export default inject('auth', 'errors', 'members')(Members); +export default inject('auth', 'errors', 'memberSettings')(Members); diff --git a/app/scenes/Settings/Tokens.js b/app/scenes/Settings/Tokens.js index 3d8bdffc5..e2980019e 100644 --- a/app/scenes/Settings/Tokens.js +++ b/app/scenes/Settings/Tokens.js @@ -5,7 +5,7 @@ import { observer, inject } from 'mobx-react'; import { Link } from 'react-router-dom'; import styled from 'styled-components'; import ApiToken from './components/ApiToken'; -import ApiKeysStore from 'stores/settings/ApiKeysStore'; +import ApiKeySettingsStore from 'stores/ApiKeySettingsStore'; import { color } from 'shared/styles/constants'; import Button from 'components/Button'; @@ -19,7 +19,7 @@ import Subheading from 'components/Subheading'; class Tokens extends Component { @observable name: string = ''; props: { - apiKeys: ApiKeysStore, + apiKeys: ApiKeySettingsStore, }; componentDidMount() { diff --git a/app/scenes/Settings/components/MemberMenu.js b/app/scenes/Settings/components/MemberMenu.js index edd038edc..d5664e6e8 100644 --- a/app/scenes/Settings/components/MemberMenu.js +++ b/app/scenes/Settings/components/MemberMenu.js @@ -1,16 +1,15 @@ // @flow import React, { Component } from 'react'; import { inject, observer } from 'mobx-react'; -import styled from 'styled-components'; -import MembersStore from 'stores/settings/MembersStore'; +import MemberSettingsStore from 'stores/MemberSettingsStore'; import MoreIcon from 'components/Icon/MoreIcon'; import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; import type { User } from 'types'; type Props = { user: User, - members: MembersStore, + memberSettings: MemberSettingsStore, }; @observer @@ -19,18 +18,45 @@ class MemberMenu extends Component { handlePromote = (ev: SyntheticEvent) => { ev.preventDefault(); + const { user, memberSettings } = this.props; + if ( + !window.confirm( + `Are you want to make ${ + user.name + } an admin? Admins can modify team and billing information.` + ) + ) { + return; + } + memberSettings.promote(user); }; handleDemote = (ev: SyntheticEvent) => { ev.preventDefault(); + const { user, memberSettings } = this.props; + if (!window.confirm(`Are you want to make ${user.name} a member?`)) { + return; + } + memberSettings.demote(user); }; handleSuspend = (ev: SyntheticEvent) => { ev.preventDefault(); + const { user, memberSettings } = this.props; + if ( + !window.confirm( + "Are you want to suspend this account? Suspended users won't be able to access Outline." + ) + ) { + return; + } + memberSettings.suspend(user); }; handleActivate = (ev: SyntheticEvent) => { ev.preventDefault(); + const { user, memberSettings } = this.props; + memberSettings.activate(user); }; render() { @@ -51,7 +77,7 @@ class MemberMenu extends Component { ))} {user.isSuspended ? ( - Activate account… + Activate account ) : ( @@ -64,4 +90,4 @@ class MemberMenu extends Component { } } -export default inject('members')(MemberMenu); +export default inject('memberSettings')(MemberMenu); diff --git a/app/stores/settings/ApiKeysStore.js b/app/stores/ApiKeySettingsStore.js similarity index 94% rename from app/stores/settings/ApiKeysStore.js rename to app/stores/ApiKeySettingsStore.js index cda6dae40..28d15b645 100644 --- a/app/stores/settings/ApiKeysStore.js +++ b/app/stores/ApiKeySettingsStore.js @@ -4,7 +4,7 @@ import invariant from 'invariant'; import { client } from 'utils/ApiClient'; import type { ApiKey } from 'types'; -class SettingsApiKeysStore { +class SettingsApiKeySettingsStore { @observable apiKeys: ApiKey[] = []; @observable isFetching: boolean = false; @observable isSaving: boolean = false; @@ -57,4 +57,4 @@ class SettingsApiKeysStore { }; } -export default SettingsApiKeysStore; +export default SettingsApiKeySettingsStore; diff --git a/app/stores/MemberSettingsStore.js b/app/stores/MemberSettingsStore.js new file mode 100644 index 000000000..9a22fe9c2 --- /dev/null +++ b/app/stores/MemberSettingsStore.js @@ -0,0 +1,67 @@ +// @flow +import { observable, action, runInAction } from 'mobx'; +import invariant from 'invariant'; +import { client } from 'utils/ApiClient'; +import type { User } from 'types'; + +class MemberSettingsStore { + @observable users: User[] = []; + @observable isLoaded: boolean = false; + @observable isSaving: boolean = false; + + @action + fetchUsers = async () => { + try { + const res = await client.post('/team.users'); + invariant(res && res.data, 'Data should be available'); + const { data } = res; + + runInAction('fetchUsers', () => { + this.users = data.reverse(); + }); + } catch (e) { + console.error('Something went wrong'); + } + this.isLoaded = false; + }; + + @action + promote = async (user: User) => { + return this.actionOnUser('promote', user); + }; + + @action + demote = async (user: User) => { + return this.actionOnUser('demote', user); + }; + + @action + suspend = async (user: User) => { + return this.actionOnUser('suspend', user); + }; + + @action + activate = async (user: User) => { + return this.actionOnUser('activate', user); + }; + + actionOnUser = async (action: string, user: User) => { + try { + const res = await client.post(`/user.${action}`, { + id: user.id, + }); + invariant(res && res.data, 'Data should be available'); + const { data } = res; + + runInAction(`MemberSettingsStore#${action}`, () => { + this.users = this.users.map( + user => (user.id === data.id ? data : user) + ); + }); + } catch (e) { + console.error('Something went wrong'); + } + }; +} + +export default MemberSettingsStore; diff --git a/app/stores/settings/MembersStore.js b/app/stores/settings/MembersStore.js deleted file mode 100644 index b2380e5ea..000000000 --- a/app/stores/settings/MembersStore.js +++ /dev/null @@ -1,31 +0,0 @@ -// @flow -import { observable, action, runInAction } from 'mobx'; -import invariant from 'invariant'; -import { client } from 'utils/ApiClient'; -import type { User } from 'types'; - -class SettingsUsersStore { - @observable members: User[] = []; - @observable isFetching: boolean = false; - @observable isSaving: boolean = false; - - @action - fetchMembers = async () => { - this.isFetching = true; - - try { - const res = await client.post('/team.users'); - invariant(res && res.data, 'Data should be available'); - const { data } = res; - - runInAction('fetchMembers', () => { - this.members = data.reverse(); - }); - } catch (e) { - console.error('Something went wrong'); - } - this.isFetching = false; - }; -} - -export default SettingsUsersStore; diff --git a/app/types/index.js b/app/types/index.js index a03ef21eb..5c5a4dad0 100644 --- a/app/types/index.js +++ b/app/types/index.js @@ -5,6 +5,8 @@ export type User = { name: string, email: string, username: string, + isAdmin?: boolean, + isSuspended?: boolean, }; export type Team = { diff --git a/server/api/__snapshots__/team.test.js.snap b/server/api/__snapshots__/team.test.js.snap index 917e0d182..eac4cccf3 100644 --- a/server/api/__snapshots__/team.test.js.snap +++ b/server/api/__snapshots__/team.test.js.snap @@ -1,62 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`#team.addAdmin should promote a new admin 1`] = ` -Object { - "data": Object { - "avatarUrl": "http://example.com/avatar.png", - "email": "user1@example.com", - "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", - "isAdmin": true, - "name": "User 1", - "username": "user1", - }, - "ok": true, - "status": 200, -} -`; - -exports[`#team.addAdmin should require admin 1`] = ` -Object { - "error": "admin_required", - "message": "An admin role is required to access this resource", - "ok": false, - "status": 403, -} -`; - -exports[`#team.removeAdmin should demote an admin 1`] = ` -Object { - "data": Object { - "avatarUrl": "http://example.com/avatar.png", - "email": "user1@example.com", - "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", - "isAdmin": false, - "name": "User 1", - "username": "user1", - }, - "ok": true, - "status": 200, -} -`; - -exports[`#team.removeAdmin should require admin 1`] = ` -Object { - "error": "admin_required", - "message": "An admin role is required to access this resource", - "ok": false, - "status": 403, -} -`; - -exports[`#team.removeAdmin shouldn't demote admins if only one available 1`] = ` -Object { - "error": "validation_error", - "message": "At least one admin is required", - "ok": false, - "status": 400, -} -`; - exports[`#team.users should require admin for detailed info 1`] = ` Object { "data": Array [ @@ -91,6 +34,7 @@ Object { "email": "admin@example.com", "id": "fa952cff-fa64-4d42-a6ea-6955c9689046", "isAdmin": true, + "isSuspended": false, "name": "Admin User", "username": "admin", }, @@ -99,6 +43,7 @@ Object { "email": "user1@example.com", "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", "isAdmin": false, + "isSuspended": false, "name": "User 1", "username": "user1", }, diff --git a/server/api/user.js b/server/api/user.js index 97c09a44e..b8a1dbf82 100644 --- a/server/api/user.js +++ b/server/api/user.js @@ -125,7 +125,7 @@ router.post('user.suspend', auth(), async ctx => { const admin = ctx.state.user; const userId = ctx.body.id; const teamId = ctx.state.user.teamId; - ctx.assertPresent(userId, 'user is required'); + ctx.assertPresent(userId, 'id is required'); const user = await User.findById(userId); authorize(ctx.state.user, 'suspend', user); @@ -152,7 +152,7 @@ router.post('user.activate', auth(), async ctx => { const admin = ctx.state.user; const userId = ctx.body.id; const teamId = ctx.state.user.teamId; - ctx.assertPresent(userId, 'user is required'); + ctx.assertPresent(userId, 'id is required'); const user = await User.findById(userId); authorize(ctx.state.user, 'activate', user); diff --git a/server/api/user.test.js b/server/api/user.test.js index 941b3e0e0..a7b18e2ab 100644 --- a/server/api/user.test.js +++ b/server/api/user.test.js @@ -72,7 +72,7 @@ describe('#user.promote', async () => { const { admin, user } = await seed(); const res = await server.post('/api/user.promote', { - body: { token: admin.getJwtToken(), user: user.id }, + body: { token: admin.getJwtToken(), id: user.id }, }); const body = await res.json(); @@ -83,7 +83,7 @@ describe('#user.promote', async () => { it('should require admin', async () => { const { user } = await seed(); const res = await server.post('/api/user.promote', { - body: { token: user.getJwtToken(), user: user.id }, + body: { token: user.getJwtToken(), id: user.id }, }); const body = await res.json(); @@ -100,7 +100,7 @@ describe('#user.demote', async () => { const res = await server.post('/api/user.demote', { body: { token: admin.getJwtToken(), - user: user.id, + id: user.id, }, }); const body = await res.json(); @@ -115,7 +115,7 @@ describe('#user.demote', async () => { const res = await server.post('/api/user.demote', { body: { token: admin.getJwtToken(), - user: admin.id, + id: admin.id, }, }); const body = await res.json(); @@ -127,7 +127,7 @@ describe('#user.demote', async () => { it('should require admin', async () => { const { user } = await seed(); const res = await server.post('/api/user.promote', { - body: { token: user.getJwtToken(), user: user.id }, + body: { token: user.getJwtToken(), id: user.id }, }); const body = await res.json(); @@ -143,7 +143,7 @@ describe('#user.suspend', async () => { const res = await server.post('/api/user.suspend', { body: { token: admin.getJwtToken(), - user: user.id, + id: user.id, }, }); const body = await res.json(); @@ -158,7 +158,7 @@ describe('#user.suspend', async () => { const res = await server.post('/api/user.suspend', { body: { token: admin.getJwtToken(), - user: admin.id, + id: admin.id, }, }); const body = await res.json(); @@ -170,7 +170,7 @@ describe('#user.suspend', async () => { it('should require admin', async () => { const { user } = await seed(); const res = await server.post('/api/user.suspend', { - body: { token: user.getJwtToken(), user: user.id }, + body: { token: user.getJwtToken(), id: user.id }, }); const body = await res.json(); @@ -191,7 +191,7 @@ describe('#user.activate', async () => { const res = await server.post('/api/user.activate', { body: { token: admin.getJwtToken(), - user: user.id, + id: user.id, }, }); const body = await res.json(); @@ -203,7 +203,7 @@ describe('#user.activate', async () => { it('should require admin', async () => { const { user } = await seed(); const res = await server.post('/api/user.activate', { - body: { token: user.getJwtToken(), user: user.id }, + body: { token: user.getJwtToken(), id: user.id }, }); const body = await res.json(); diff --git a/server/presenters/user.js b/server/presenters/user.js index 6dbdeaeac..6aa12b0f7 100644 --- a/server/presenters/user.js +++ b/server/presenters/user.js @@ -29,9 +29,9 @@ export default ( user.avatarUrl || (user.slackData ? user.slackData.image_192 : null); if (options.includeDetails) { + userData.email = user.email; userData.isAdmin = user.isAdmin; userData.isSuspended = user.isSuspended; - userData.email = user.email; } return userData;