diff --git a/.gitlab/issue_templates/Feature Request.md b/.gitlab/issue_templates/Feature Request.md new file mode 100644 index 0000000000000000000000000000000000000000..7e4c7cad02e277e3357f8cf7f1a6bacea2531b67 --- /dev/null +++ b/.gitlab/issue_templates/Feature Request.md @@ -0,0 +1,27 @@ +[Template for request for comments (RFC) on feature proposals] + +# RFC: Feature proposal: [Title] + +## Context + +[Give the necessary context for your proposal. For example, what problem will this feature solve for users? What are the use cases, benefits, and goals?] + +## Proposal + +[A precise statement of the proposed feature.] + +## Design + +[Include sketch or wireframes of the UI changes necessary for this feature] + +## Implementation (if applicable) + +[A description of the steps to implement the feature.] + +## Alternative approaches (if applicable) + +[Include any alternatives to meet this use case.] + +## Open issues (if applicable) + +[Links to and a discussion of related issues, if applicable.] diff --git a/babel.config.js b/babel.config.js index 4dba60f1ad5d4cc35155b02504d07ddb7276a7d2..11b59c343cb1d1bbe0f492ed84d8e862298f0dc9 100644 --- a/babel.config.js +++ b/babel.config.js @@ -2,6 +2,6 @@ module.exports = { presets: ['@babel/preset-env', '@babel/preset-react'], plugins: [ 'babel-plugin-styled-components', - '@babel/plugin-proposal-class-properties', + ['@babel/plugin-proposal-class-properties', { loose: true }], ], } diff --git a/config/default.js b/config/default.js index 2d981ecd29abd4fdb407caf71a9293e07334adff..f6f93f7ed11e9e7d0d9bc2bc71381b355c94bcf5 100644 --- a/config/default.js +++ b/config/default.js @@ -140,7 +140,8 @@ module.exports = { theme: process.env.PUBSWEET_THEME, baseUrl: deferConfig(cfg => { const { protocol, host, port } = cfg['pubsweet-client'] - return `${protocol}://${host}${port ? `:${port}` : ''}` + const hostname = host === '0.0.0.0' ? 'localhost' : host + return `${protocol}://${hostname}${port ? `:${port}` : ''}` }), }, 'pubsweet-component-xpub-dashboard': { diff --git a/config/permissions.js b/config/permissions.js index c377a7c20e4efc4a88a046ef803febac7bd39d50..f51de9a0dee1dd7c1486a0d4f66a1cd555228351 100644 --- a/config/permissions.js +++ b/config/permissions.js @@ -1,7 +1,7 @@ // eslint-disable-next-line no-unused-vars const { rule, shield, and, or, not, allow, deny } = require('graphql-shield') -const _userIsEditor = async (user, manuscriptId) => { +const userIsEditorQuery = async (user, manuscriptId) => { if (!user) { return false } @@ -25,9 +25,9 @@ const _userIsEditor = async (user, manuscriptId) => { const userIsEditor = rule({ cache: 'contextual', -})(async (parent, args, ctx, info) => _userIsEditor(ctx.user)) +})(async (parent, args, ctx, info) => userIsEditorQuery(ctx.user)) -const _userIsMemberOfTeamWithRole = async (user, manuscriptId, role) => { +const userIsMemberOfTeamWithRoleQuery = async (user, manuscriptId, role) => { if (!user) { return false } @@ -36,6 +36,7 @@ const _userIsMemberOfTeamWithRole = async (user, manuscriptId, role) => { .$relatedQuery('teams') .where({ role }) .andWhere({ manuscriptId }) + const rows = await query.resultSize() return !!rows } @@ -44,27 +45,26 @@ const userIsAdmin = rule({ cache: 'contextual' })( async (parent, args, ctx, info) => ctx.user && ctx.user.admin, ) -const parent_manuscript_is_published = rule({ cache: 'contextual' })( +const parentManuscriptIsPublished = rule({ cache: 'contextual' })( async (parent, args, ctx, info) => { const manuscript = await ctx.models.Manuscript.query().findById( parent.manuscriptId, ) + return !!manuscript.published }, ) -const review_is_by_user = rule({ cache: 'contextual' })( +const reviewIsByUser = rule({ cache: 'contextual' })( async (parent, args, ctx, info) => { const rows = ctx.user && - ctx.user - .$relatedQuery('teams') - .where({ role: 'reviewer' }) - .resultSize() + ctx.user.$relatedQuery('teams').where({ role: 'reviewer' }).resultSize() return !!rows }, ) + const isAuthenticated = rule({ cache: 'contextual' })( async (parent, args, ctx, info) => !!ctx.user, ) @@ -78,35 +78,44 @@ const userIsAllowedToChat = rule({ cache: 'strict' })( if (ctx.user && ctx.user.admin) { return true } + const channel = await ctx.models.Channel.query().findById(args.channelId) + const manuscript = await ctx.models.Manuscript.query().findById( channel.manuscriptId, ) - const isAuthor = await _userIsMemberOfTeamWithRole( + const isAuthor = await userIsMemberOfTeamWithRoleQuery( ctx.user, manuscript.id, 'author', ) - const isReviewer = await _userIsMemberOfTeamWithRole( + + const isReviewer = await userIsMemberOfTeamWithRoleQuery( ctx.user, manuscript.id, 'reviewer', ) - const isEditor = await _userIsEditor(ctx.user, manuscript.id) + + const isEditor = await userIsEditorQuery(ctx.user, manuscript.id) if (channel.type === 'all') { return isAuthor || isReviewer || isEditor - } else if (channel.type === 'editorial') { + } + + if (channel.type === 'editorial') { return isReviewer || isEditor } + + return false }, ) -const user_is_review_author_and_review_is_not_completed = rule({ +const userIsReviewAuthorAndReviewIsNotCompleted = rule({ cache: 'strict', })(async (parent, args, ctx, info) => { let manuscriptId + if (args.id) { ;({ manuscriptId } = await ctx.models.Review.query().findById(args.id)) } else { @@ -114,12 +123,14 @@ const user_is_review_author_and_review_is_not_completed = rule({ } const manuscript = await ctx.models.Manuscript.query().findById(manuscriptId) + const team = await ctx.models.Team.query() .where({ manuscriptId: manuscript.id, role: 'reviewer', }) .first() + if (!team) return false const members = await team @@ -133,22 +144,24 @@ const user_is_review_author_and_review_is_not_completed = rule({ return false }) -const user_is_editor_of_the_manuscript_of_the_review = rule({ +const userIsEditorOfTheManuscriptOfTheReview = rule({ cache: 'strict', })(async (parent, args, ctx, info) => { let manuscriptId + if (args.id) { ;({ manuscriptId } = await ctx.models.Review.query().findById(args.id)) } else { ;({ manuscriptId } = args.input) } - return _userIsEditor(ctx.user, manuscriptId) + return userIsEditorQuery(ctx.user, manuscriptId) }) -const user_is_invited_reviewer = rule({ cache: 'strict' })( +const userIsInvitedReviewer = rule({ cache: 'strict' })( async (parent, args, ctx, info) => { const team = await ctx.models.Team.query().findById(args.teamId) + const member = await team .$relatedQuery('members') .where({ userId: ctx.user.id, status: 'invited' }) @@ -158,7 +171,7 @@ const user_is_invited_reviewer = rule({ cache: 'strict' })( }, ) -const user_is_author = rule({ cache: 'strict' })( +const userIsAuthor = rule({ cache: 'strict' })( async (parent, args, ctx, info) => { const team = await ctx.models.Team.query() .where({ @@ -166,6 +179,7 @@ const user_is_author = rule({ cache: 'strict' })( role: 'author', }) .first() + const author = team .$relatedQuery('members') .where({ userId: ctx.user.id }) @@ -175,10 +189,11 @@ const user_is_author = rule({ cache: 'strict' })( }, ) -const user_is_author_of_files_associated_manuscript = rule({ +const userIsAuthorOfFilesAssociatedManuscript = rule({ cache: 'no_cache', })(async (parent, args, ctx, info) => { let manuscriptId + if (args.meta && args.meta.manuscriptId) { // Meta is supplied for createFile // eslint-disable-next-line prefer-destructuring @@ -202,6 +217,7 @@ const user_is_author_of_files_associated_manuscript = rule({ if (!team) { return false } + const members = await team .$relatedQuery('members') .where('userId', ctx.user.id) @@ -212,8 +228,13 @@ const user_is_author_of_files_associated_manuscript = rule({ return false }) -const user_is_author_of_the_manuscript_of_the_file = rule({ cache: 'strict' })( + +const userIsAuthorOfTheManuscriptOfTheFile = rule({ cache: 'strict' })( async (parent, args, ctx, info) => { + if (!ctx.user) { + return false + } + const manuscript = await ctx.models.File.relatedQuery('manuscript') .for(parent.id) .first() @@ -228,6 +249,7 @@ const user_is_author_of_the_manuscript_of_the_file = rule({ cache: 'strict' })( if (!team) { return false } + const members = await team .$relatedQuery('members') .where('userId', ctx.user.id) @@ -241,11 +263,13 @@ const user_is_author_of_the_manuscript_of_the_file = rule({ cache: 'strict' })( ) // ¯\_(ツ)_/¯ -const user_is_the_reviewer_of_the_manuscript_of_the_file_and_review_not_complete = rule( - { - cache: 'strict', - }, -)(async (parent, args, ctx, info) => { +const userIsTheReviewerOfTheManuscriptOfTheFileAndReviewNotComplete = rule({ + cache: 'strict', +})(async (parent, args, ctx, info) => { + if (!ctx.user) { + return false + } + const manuscript = await ctx.models.File.relatedQuery('manuscript') .for(parent.id) .first() @@ -260,6 +284,7 @@ const user_is_the_reviewer_of_the_manuscript_of_the_file_and_review_not_complete if (!team) { return false } + const members = await team .$relatedQuery('members') .where('userId', ctx.user.id) @@ -286,21 +311,21 @@ const permissions = { Mutation: { upload: isAuthenticated, createManuscript: isAuthenticated, - updateManuscript: user_is_author, - submitManuscript: user_is_author, + updateManuscript: userIsAuthor, + submitManuscript: userIsAuthor, createMessage: userIsAllowedToChat, updateReview: or( - user_is_review_author_and_review_is_not_completed, - user_is_editor_of_the_manuscript_of_the_review, + userIsReviewAuthorAndReviewIsNotCompleted, + userIsEditorOfTheManuscriptOfTheReview, ), - reviewerResponse: user_is_invited_reviewer, + reviewerResponse: userIsInvitedReviewer, completeReview: or( - user_is_review_author_and_review_is_not_completed, - user_is_editor_of_the_manuscript_of_the_review, + userIsReviewAuthorAndReviewIsNotCompleted, + userIsEditorOfTheManuscriptOfTheReview, ), createNewVersion: allow, - createFile: user_is_author_of_files_associated_manuscript, - deleteFile: user_is_author_of_files_associated_manuscript, + createFile: userIsAuthorOfFilesAssociatedManuscript, + deleteFile: userIsAuthorOfFilesAssociatedManuscript, }, Subscription: { messageCreated: userIsAllowedToChat, @@ -314,16 +339,16 @@ const permissions = { Manuscript: allow, ManuscriptVersion: allow, File: or( - parent_manuscript_is_published, + parentManuscriptIsPublished, or( - user_is_author_of_the_manuscript_of_the_file, - user_is_the_reviewer_of_the_manuscript_of_the_file_and_review_not_complete, + userIsAuthorOfTheManuscriptOfTheFile, + userIsTheReviewerOfTheManuscriptOfTheFileAndReviewNotComplete, userIsEditor, userIsAdmin, ), ), UploadResult: allow, - Review: or(parent_manuscript_is_published, review_is_by_user), + Review: or(parentManuscriptIsPublished, reviewIsByUser), ReviewComment: allow, Channel: allow, Message: allow, @@ -337,10 +362,11 @@ const permissions = { const fallbackRule = or(userIsAdmin, userIsEditor) // We only ever need to go two levels down, so no need for recursion -const addOverrideRule = permissions => { +const addOverrideRule = perms => { const adaptedPermissions = {} - Object.keys(permissions).forEach(key1 => { - const value = permissions[key1] + Object.keys(perms).forEach(key1 => { + const value = perms[key1] + if (value.constructor.name !== 'Object') { adaptedPermissions[key1] = or(fallbackRule, value) } else { diff --git a/docker-compose.yml b/docker-compose.yml index bdbbd9db199bacd236d0f5bff34de2051b70bea5..661d365e11a8ce522d6e832cc457cf033693db94 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,10 +14,11 @@ services: './webpack/webpack.development.config.js', ] ports: - - ${CLIENT_PORT:-4000}:8080 + - ${CLIENT_PORT:-4000}:${CLIENT_PORT:-4000} environment: - NODE_ENV=development - CLIENT_HOST=0.0.0.0 + - CLIENT_PORT=${CLIENT_PORT:-4000} - SERVER_PROTOCOL=http - SERVER_HOST=server - SERVER_PORT=3000 diff --git a/stories/sth/FakeButton.js b/stories/sth/FakeButton.js new file mode 100644 index 0000000000000000000000000000000000000000..b2dc5e392c1604b79951bff92fa1b8d9c6f5af08 --- /dev/null +++ b/stories/sth/FakeButton.js @@ -0,0 +1,24 @@ +import React from 'react' +import PropTypes from 'prop-types' +import styled from 'styled-components' + +const Btn = styled.button` + background-color: ${props => props.primary && 'blue'}; + font-family: ${props => props.theme.fontInterface}; +` + +/** This appears in storybook */ +const Button = ({ label, primary }) => <Btn primary={primary}>{label}</Btn> + +Button.propTypes = { + /** This also appears in storybook */ + label: PropTypes.string.isRequired, + /** As props table documentation */ + primary: PropTypes.bool, +} + +Button.defaultProps = { + primary: false, +} + +export default Button diff --git a/stories/sth/FakeButton.stories.js b/stories/sth/FakeButton.stories.js new file mode 100644 index 0000000000000000000000000000000000000000..83726b307c7a134175f11396b01422e381749757 --- /dev/null +++ b/stories/sth/FakeButton.stories.js @@ -0,0 +1,22 @@ +import React from 'react' + +import Button from './FakeButton' + +export const Base = args => <Button {...args} /> + +Base.args = { + label: 'Click me', + primary: false, +} + +export const Primary = () => <Button label="Primary" primary /> +export const NonPrimary = () => <Button label="Not primary" /> + +export default { + title: 'Fake Section/Button', + component: Button, + argTypes: { + label: { control: { type: 'text' } }, + primary: { control: { type: 'boolean' } }, + }, +}