From f87e49992612353aaa8359ac0955002b35b14807 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 18 Aug 2018 23:16:08 -0700 Subject: [PATCH 1/5] Share restrictions --- app/components/Checkbox.js | 45 +++++++++++ app/components/HelpText/HelpText.js | 1 + app/components/Sidebar/Settings.js | 6 ++ app/menus/DocumentMenu.js | 11 ++- app/routes.js | 2 + app/scenes/Document/components/Header.js | 11 ++- app/scenes/Settings/Details.js | 2 +- app/scenes/Settings/Security.js | 80 +++++++++++++++++++ app/scenes/Settings/Shares.js | 17 +++- app/stores/AuthStore.js | 6 +- app/types/index.js | 1 + server/api/shares.js | 4 +- server/api/team.js | 3 +- .../20180819054252-disable-sharing.js | 12 +++ server/models/Team.js | 16 ++-- server/policies/team.js | 5 ++ server/presenters/team.js | 1 + 17 files changed, 204 insertions(+), 19 deletions(-) create mode 100644 app/components/Checkbox.js create mode 100644 app/scenes/Settings/Security.js create mode 100644 server/migrations/20180819054252-disable-sharing.js diff --git a/app/components/Checkbox.js b/app/components/Checkbox.js new file mode 100644 index 000000000000..6f17e645ed3a --- /dev/null +++ b/app/components/Checkbox.js @@ -0,0 +1,45 @@ +// @flow +import * as React from 'react'; +import styled from 'styled-components'; +import HelpText from 'components/HelpText'; + +export type Props = { + checked?: boolean, + label?: string, + className?: string, + note?: string, +}; + +const LabelText = styled.span` + font-weight: 500; + margin-left: 10px; +`; + +const Wrapper = styled.div` + padding-bottom: 8px; +`; + +const Label = styled.label` + display: flex; + align-items: center; +`; + +export default function Checkbox({ + label, + note, + className, + short, + ...rest +}: Props) { + return ( + + + + {note && {note}} + + + ); +} diff --git a/app/components/HelpText/HelpText.js b/app/components/HelpText/HelpText.js index f096c9f20075..9a0df7f6ff59 100644 --- a/app/components/HelpText/HelpText.js +++ b/app/components/HelpText/HelpText.js @@ -4,6 +4,7 @@ import styled from 'styled-components'; const HelpText = styled.p` margin-top: 0; color: ${props => props.theme.slateDark}; + font-size: ${props => (props.small ? '13px' : 'auto')}; `; export default HelpText; diff --git a/app/components/Sidebar/Settings.js b/app/components/Sidebar/Settings.js index 8a38ba73f036..a5b9d6fe7b15 100644 --- a/app/components/Sidebar/Settings.js +++ b/app/components/Sidebar/Settings.js @@ -5,6 +5,7 @@ import { DocumentIcon, ProfileIcon, SettingsIcon, + PadlockIcon, CodeIcon, UserIcon, LinkIcon, @@ -61,6 +62,11 @@ class SettingsSidebar extends React.Component { Details )} + {user.isAdmin && ( + }> + Security + + )} }> People diff --git a/app/menus/DocumentMenu.js b/app/menus/DocumentMenu.js index 026891b47a40..b471110c2874 100644 --- a/app/menus/DocumentMenu.js +++ b/app/menus/DocumentMenu.js @@ -6,11 +6,13 @@ import { MoreIcon } from 'outline-icons'; import Document from 'models/Document'; import UiStore from 'stores/UiStore'; +import AuthStore from 'stores/AuthStore'; import { documentMoveUrl } from 'utils/routeHelpers'; import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; type Props = { ui: UiStore, + auth: AuthStore, label?: React.Node, history: Object, document: Document, @@ -69,7 +71,8 @@ class DocumentMenu extends React.Component { }; render() { - const { document, label, className, showPrint } = this.props; + const { document, label, className, showPrint, auth } = this.props; + const canShareDocuments = auth.team && auth.team.sharing; return ( } className={className}> @@ -91,12 +94,12 @@ class DocumentMenu extends React.Component { Star )} - Share link… - + }
{ } } -export default withRouter(inject('ui')(DocumentMenu)); +export default withRouter(inject('ui', 'auth')(DocumentMenu)); diff --git a/app/routes.js b/app/routes.js index b2be2b83936d..5450806dec7f 100644 --- a/app/routes.js +++ b/app/routes.js @@ -11,6 +11,7 @@ import Document from 'scenes/Document'; import Search from 'scenes/Search'; import Settings from 'scenes/Settings'; import Details from 'scenes/Settings/Details'; +import Security from 'scenes/Settings/Security'; import People from 'scenes/Settings/People'; import Slack from 'scenes/Settings/Slack'; import Shares from 'scenes/Settings/Shares'; @@ -43,6 +44,7 @@ export default function Routes() { + diff --git a/app/scenes/Document/components/Header.js b/app/scenes/Document/components/Header.js index 4ae8202defd6..2b9c4668a409 100644 --- a/app/scenes/Document/components/Header.js +++ b/app/scenes/Document/components/Header.js @@ -2,11 +2,12 @@ import * as React from 'react'; import { throttle } from 'lodash'; import { observable } from 'mobx'; -import { observer } from 'mobx-react'; +import { observer, inject } from 'mobx-react'; import styled from 'styled-components'; import breakpoint from 'styled-components-breakpoint'; import { NewDocumentIcon } from 'outline-icons'; import Document from 'models/Document'; +import AuthStore from 'stores/AuthStore'; import { documentEditUrl } from 'utils/routeHelpers'; import Flex from 'shared/components/Flex'; @@ -32,6 +33,7 @@ type Props = { autosave?: boolean, }) => *, history: Object, + auth: AuthStore, }; @observer @@ -90,7 +92,9 @@ class Header extends React.Component { isPublishing, isSaving, savingIsDisabled, + auth, } = this.props; + const canShareDocuments = auth.team && auth.team.sharing; return ( { )} {!isDraft && - !isEditing && ( + !isEditing && + canShareDocuments && ( Share @@ -251,4 +256,4 @@ const Link = styled.a` cursor: ${props => (props.disabled ? 'default' : 'pointer')}; `; -export default Header; +export default inject('auth')(Header); diff --git a/app/scenes/Settings/Details.js b/app/scenes/Settings/Details.js index 4854e4800cb1..686d6d74588f 100644 --- a/app/scenes/Settings/Details.js +++ b/app/scenes/Settings/Details.js @@ -44,7 +44,7 @@ class Details extends React.Component { name: this.name, avatarUrl: this.avatarUrl, }); - this.props.ui.showToast('Details saved', 'success'); + this.props.ui.showToast('Settings saved', 'success'); }; handleNameChange = (ev: SyntheticInputEvent<*>) => { diff --git a/app/scenes/Settings/Security.js b/app/scenes/Settings/Security.js new file mode 100644 index 000000000000..9ccb8793e82e --- /dev/null +++ b/app/scenes/Settings/Security.js @@ -0,0 +1,80 @@ +// @flow +import * as React from 'react'; +import { observable } from 'mobx'; +import { observer, inject } from 'mobx-react'; + +import AuthStore from 'stores/AuthStore'; +import UiStore from 'stores/UiStore'; +import Checkbox from 'components/Checkbox'; +import Button from 'components/Button'; +import CenteredContent from 'components/CenteredContent'; +import PageTitle from 'components/PageTitle'; +import HelpText from 'components/HelpText'; + +type Props = { + auth: AuthStore, + ui: UiStore, +}; + +@observer +class Security extends React.Component { + form: ?HTMLFormElement; + + @observable sharing: boolean; + + componentDidMount() { + const { auth } = this.props; + if (auth.team) { + this.sharing = auth.team.sharing; + } + } + + handleSubmit = async (ev: SyntheticEvent<*>) => { + ev.preventDefault(); + + await this.props.auth.updateTeam({ + sharing: this.sharing, + }); + this.props.ui.showToast('Settings saved', 'success'); + }; + + handleChange = (ev: SyntheticInputEvent<*>) => { + if (ev.target.name === 'sharing') { + this.sharing = ev.target.checked; + } + }; + + get isValid() { + return this.form && this.form.checkValidity(); + } + + render() { + const { isSaving } = this.props.auth; + + return ( + + +

Security

+ + Settings that impact the access, security and privacy of your + knowledgebase. + + +
(this.form = ref)}> + + + +
+ ); + } +} + +export default inject('auth', 'ui')(Security); diff --git a/app/scenes/Settings/Shares.js b/app/scenes/Settings/Shares.js index cefaaac20ed8..86ea10a4fefe 100644 --- a/app/scenes/Settings/Shares.js +++ b/app/scenes/Settings/Shares.js @@ -1,7 +1,9 @@ // @flow import * as React from 'react'; import { observer, inject } from 'mobx-react'; +import { Link } from 'react-router-dom'; import SharesStore from 'stores/SharesStore'; +import AuthStore from 'stores/AuthStore'; import ShareListItem from './components/ShareListItem'; import List from 'components/List'; @@ -11,6 +13,7 @@ import HelpText from 'components/HelpText'; type Props = { shares: SharesStore, + auth: AuthStore, }; @observer @@ -20,7 +23,9 @@ class Shares extends React.Component { } render() { - const { shares } = this.props; + const { shares, auth } = this.props; + const { user } = auth; + const canShareDocuments = auth.team && auth.team.sharing; return ( @@ -31,7 +36,13 @@ class Shares extends React.Component { can access a read-only version of the document until the link has been revoked. - + {user && + user.isAdmin && ( + + {!canShareDocuments && Sharing is currently disabled.} You can turn {canShareDocuments ? 'off' : 'on'} public document + sharing in security settings. + + )} {shares.orderedData.map(share => ( @@ -42,4 +53,4 @@ class Shares extends React.Component { } } -export default inject('shares')(Shares); +export default inject('shares', 'auth')(Shares); diff --git a/app/stores/AuthStore.js b/app/stores/AuthStore.js index ba9cd30d70a1..02672538a348 100644 --- a/app/stores/AuthStore.js +++ b/app/stores/AuthStore.js @@ -78,7 +78,11 @@ class AuthStore { }; @action - updateTeam = async (params: { name: string, avatarUrl: ?string }) => { + updateTeam = async (params: { + name?: string, + avatarUrl?: ?string, + sharing?: boolean, + }) => { this.isSaving = true; try { diff --git a/app/types/index.js b/app/types/index.js index f1d0ce770a4d..a461d79363f2 100644 --- a/app/types/index.js +++ b/app/types/index.js @@ -31,6 +31,7 @@ export type Team = { avatarUrl: string, slackConnected: boolean, googleConnected: boolean, + sharing: boolean, }; export type NavigationNode = { diff --git a/server/api/shares.js b/server/api/shares.js index 35b9f34563c4..be3c8c267dd2 100644 --- a/server/api/shares.js +++ b/server/api/shares.js @@ -4,7 +4,7 @@ import Sequelize from 'sequelize'; import auth from '../middlewares/authentication'; import pagination from './middlewares/pagination'; import { presentShare } from '../presenters'; -import { Document, User, Share } from '../models'; +import { Document, User, Share, Team } from '../models'; import policy from '../policies'; const Op = Sequelize.Op; @@ -57,7 +57,9 @@ router.post('shares.create', auth(), async ctx => { const user = ctx.state.user; const document = await Document.findById(documentId); + const team = await Team.findById(user.teamId); authorize(user, 'share', document); + authorize(user, 'share', team); const [share] = await Share.findOrCreate({ where: { diff --git a/server/api/team.js b/server/api/team.js index d04c729ca0ee..3bc4a1b8af78 100644 --- a/server/api/team.js +++ b/server/api/team.js @@ -12,7 +12,7 @@ const { authorize } = policy; const router = new Router(); router.post('team.update', auth(), async ctx => { - const { name, avatarUrl } = ctx.body; + const { name, avatarUrl, sharing } = ctx.body; const endpoint = publicS3Endpoint(); const user = ctx.state.user; @@ -20,6 +20,7 @@ router.post('team.update', auth(), async ctx => { authorize(user, 'update', team); if (name) team.name = name; + if (sharing !== undefined) team.sharing = sharing; if (avatarUrl && avatarUrl.startsWith(`${endpoint}/uploads/${user.id}`)) { team.avatarUrl = avatarUrl; } diff --git a/server/migrations/20180819054252-disable-sharing.js b/server/migrations/20180819054252-disable-sharing.js new file mode 100644 index 000000000000..38246eee7925 --- /dev/null +++ b/server/migrations/20180819054252-disable-sharing.js @@ -0,0 +1,12 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn('teams', 'sharing', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + }); + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.removeColumn('teams', 'sharing'); + } +} \ No newline at end of file diff --git a/server/models/Team.js b/server/models/Team.js index 808e85b5b202..933303854cdb 100644 --- a/server/models/Team.js +++ b/server/models/Team.js @@ -17,6 +17,7 @@ const Team = sequelize.define( slackId: { type: DataTypes.STRING, allowNull: true }, googleId: { type: DataTypes.STRING, allowNull: true }, avatarUrl: { type: DataTypes.STRING, allowNull: true }, + sharing: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true }, slackData: DataTypes.JSONB, }, { @@ -39,11 +40,16 @@ const uploadAvatar = async model => { const endpoint = publicS3Endpoint(); if (model.avatarUrl && !model.avatarUrl.startsWith(endpoint)) { - const newUrl = await uploadToS3FromUrl( - model.avatarUrl, - `avatars/${model.id}/${uuid.v4()}` - ); - if (newUrl) model.avatarUrl = newUrl; + try { + const newUrl = await uploadToS3FromUrl( + model.avatarUrl, + `avatars/${model.id}/${uuid.v4()}` + ); + if (newUrl) model.avatarUrl = newUrl; + } catch (err) { + // we can try again next time + console.error(err); + } } }; diff --git a/server/policies/team.js b/server/policies/team.js index acd077770ab8..414a9cc39fb4 100644 --- a/server/policies/team.js +++ b/server/policies/team.js @@ -7,6 +7,11 @@ const { allow } = policy; allow(User, 'read', Team, (user, team) => team && user.teamId === team.id); +allow(User, 'share', Team, (user, team) => { + if (!team || user.teamId !== team.id) return false; + return team.sharing; +}); + allow(User, ['update', 'export'], Team, (user, team) => { if (!team || user.teamId !== team.id) return false; if (user.isAdmin) return true; diff --git a/server/presenters/team.js b/server/presenters/team.js index 863de4a3f0fe..a600ac2cba18 100644 --- a/server/presenters/team.js +++ b/server/presenters/team.js @@ -11,6 +11,7 @@ function present(ctx: Object, team: Team) { team.avatarUrl || (team.slackData ? team.slackData.image_88 : null), slackConnected: !!team.slackId, googleConnected: !!team.googleId, + sharing: team.sharing, }; } From c3ea96d6c96c7574bd1d18da31b41ea2252b3dc0 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 19 Aug 2018 01:18:28 -0700 Subject: [PATCH 2/5] Tweak language, add spec --- app/components/Toasts/components/Toast.js | 2 +- app/scenes/Settings/Security.js | 4 ++-- server/api/shares.js | 5 +++-- server/api/shares.test.js | 10 ++++++++++ shared/styles/theme.js | 2 +- 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/app/components/Toasts/components/Toast.js b/app/components/Toasts/components/Toast.js index e2b603551aa2..9bc3ab7d5852 100644 --- a/app/components/Toasts/components/Toast.js +++ b/app/components/Toasts/components/Toast.js @@ -49,7 +49,7 @@ const Container = styled.li` align-items: center; animation: ${fadeAndScaleIn} 100ms ease; margin: 8px 0; - padding: 8px; + padding: 10px 12px; color: ${props => props.theme.white}; background: ${props => props.theme[props.type]}; font-size: 15px; diff --git a/app/scenes/Settings/Security.js b/app/scenes/Settings/Security.js index 9ccb8793e82e..35f31dcc765e 100644 --- a/app/scenes/Settings/Security.js +++ b/app/scenes/Settings/Security.js @@ -62,11 +62,11 @@ class Security extends React.Component {
(this.form = ref)}>