diff --git a/app/assets/stylesheets/application.sass.scss b/app/assets/stylesheets/application.sass.scss index 5bf6b003..a808384a 100644 --- a/app/assets/stylesheets/application.sass.scss +++ b/app/assets/stylesheets/application.sass.scss @@ -22,11 +22,15 @@ @import 'components/Roadmap'; @import 'components/Billing'; - /* Site Settings Components */ - @import 'components/SiteSettings'; - @import 'components/SiteSettings/Boards'; - @import 'components/SiteSettings/PostStatuses'; - @import 'components/SiteSettings/Roadmap'; - @import 'components/SiteSettings/Users'; - @import 'components/SiteSettings/Authentication'; - @import 'components/SiteSettings/Appearance/'; \ No newline at end of file +/* Site Settings Components */ +@import 'components/SiteSettings'; +@import 'components/SiteSettings/General'; +@import 'components/SiteSettings/Boards'; +@import 'components/SiteSettings/PostStatuses'; +@import 'components/SiteSettings/Roadmap'; +@import 'components/SiteSettings/Authentication'; +@import 'components/SiteSettings/Appearance/'; + +/* Moderation Components */ +@import 'components/Moderation/Feedback'; +@import 'components/Moderation/Users'; \ No newline at end of file diff --git a/app/assets/stylesheets/common/_index.scss b/app/assets/stylesheets/common/_index.scss index cf27a9fd..f5adfc7c 100644 --- a/app/assets/stylesheets/common/_index.scss +++ b/app/assets/stylesheets/common/_index.scss @@ -159,16 +159,20 @@ body { @extend .badge, .badge-pill, - .p-2; + .p-2, + .ml-1, + .mr-1; font-size: 13px; + text-transform: uppercase; } .badgeLight { @extend .badge-light; - background-color: var(--astuto-grey-light); } +.badgeWarning { @extend .badge-warning; } +.badgeDanger { @extend .badge-danger; } .container { max-width: 960px; @@ -323,4 +327,26 @@ body { .alert-primary, .text-center, .m-0; +} + +.highlighted { + @extend .p-2; + + background-color: rgba(255, 255, 0, 0.2); +} + +.notificationDot { + display: inline-block; + color: white; + background-color: var(--primary-color); + min-width: 22px; + font-size: 14px; + border-radius: 100%; + text-align: center; +} + +.notificationDot.notificationDotTop { + position: relative; + bottom: 8px; + scale: 80%; } \ No newline at end of file diff --git a/app/assets/stylesheets/components/Board.scss b/app/assets/stylesheets/components/Board.scss index aa6b3f71..3b14eea8 100644 --- a/app/assets/stylesheets/components/Board.scss +++ b/app/assets/stylesheets/components/Board.scss @@ -24,13 +24,37 @@ } .newPostForm { - @extend .my-2; + @extend + .mt-4, + .mb-2; #postDescription { height: 100px; min-height: 100px; resize: vertical; } + + .form-group-dnf { + margin: 0; + padding: 0; + height: 1px; + + /* honeypot field 1 */ + #email { + visibility: hidden; + height: 1px; + } + } + + /* honeypot field 2 */ + #name { + position: absolute; + left: -9999px; + } + } + + .anonymousFeedbackLink { + @extend .my-2; } } diff --git a/app/assets/stylesheets/components/Moderation/Feedback/index.scss b/app/assets/stylesheets/components/Moderation/Feedback/index.scss new file mode 100644 index 00000000..9ac958e2 --- /dev/null +++ b/app/assets/stylesheets/components/Moderation/Feedback/index.scss @@ -0,0 +1,88 @@ +.feedbackModerationContainer { + .badges { + @extend + .d-flex, + .flex-row, + .flex-wrap; + + row-gap: 8px; + } + + .changeFeedbackModerationSettingsLink { + @extend + .align-self-auto, + .mt-2; + } + + .filterModerationFeedbackNav { + @extend + .nav, + .nav-pills, + .align-self-center, + .px-2, + .mt-4; + + .nav-item { + cursor: pointer; + } + + .nav-link { + color: var(--astuto-black); + + &.active { + color: white; + background-color: var(--astuto-black); + } + } + } + + .emptyList { + @extend .mt-4, .mb-2; + + text-align: center; + } + + .feedbackModerationList { + .feedbackListItem { + @extend + .d-flex, + .flex-row, + .justify-content-between, + .my-4, + .mx-2, + .p-2; + + .feedbackListItemIconAndContent { + @extend .d-flex; + + .feedbackListItemIcon { + @extend .align-self-center; + } + + .feedbackListItemContent { + @extend .ml-4; + + .feedbackListItemTitle { + @extend + .font-weight-bold, + .mb-1; + + &:hover { + cursor: pointer; + text-decoration: underline; + } + } + + .feedbackListItemDescription { + color: var(--astuto-grey); + } + } + } + + .feedbackListItemActions { + @extend + .d-flex; + } + } + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/components/SiteSettings/Users/index.scss b/app/assets/stylesheets/components/Moderation/Users/index.scss similarity index 100% rename from app/assets/stylesheets/components/SiteSettings/Users/index.scss rename to app/assets/stylesheets/components/Moderation/Users/index.scss diff --git a/app/assets/stylesheets/components/Post.scss b/app/assets/stylesheets/components/Post.scss index fc412570..52430503 100644 --- a/app/assets/stylesheets/components/Post.scss +++ b/app/assets/stylesheets/components/Post.scss @@ -110,7 +110,9 @@ .mb-3; .postInfo { - @extend .d-flex; + @extend + .d-flex, + .mb-2; span { @extend .mr-2; diff --git a/app/assets/stylesheets/components/SiteSettings/General/index.scss b/app/assets/stylesheets/components/SiteSettings/General/index.scss new file mode 100644 index 00000000..44af6972 --- /dev/null +++ b/app/assets/stylesheets/components/SiteSettings/General/index.scss @@ -0,0 +1,7 @@ +.generalSiteSettingsContainer { + .settingsGroup { + @extend .mt-4; + + scroll-margin-top: 96px; + } +} \ No newline at end of file diff --git a/app/controllers/moderation_controller.rb b/app/controllers/moderation_controller.rb new file mode 100644 index 00000000..12031706 --- /dev/null +++ b/app/controllers/moderation_controller.rb @@ -0,0 +1,18 @@ +class ModerationController < ApplicationController + include ApplicationHelper + + before_action :authenticate_moderator + before_action :set_page_title + + def feedback + end + + def users + end + + private + + def set_page_title + @page_title = t('header.menu.moderation') + end +end \ No newline at end of file diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 89c0f686..56d457e4 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -1,5 +1,7 @@ class PostsController < ApplicationController - before_action :authenticate_user!, only: [:create, :update, :destroy] + before_action :authenticate_user!, only: [:update, :destroy] + before_action :authenticate_moderator, only: [:moderation] + before_action :authenticate_moderator_if_post_not_approved, only: [:show] before_action :check_tenant_subscription, only: [:create, :update, :destroy] def index @@ -23,22 +25,40 @@ class PostsController < ApplicationController .group('posts.id') .where(board_id: params[:board_id] || Board.first.id) .where(created_at: start_date.beginning_of_day..end_date.end_of_day) + .where(approval_status: "approved") .search_by_name_or_description(params[:search]) .order_by(params[:sort_by]) .page(params[:page]) - # apply post status filter if present - posts = posts.where(post_status_id: params[:post_status_ids].map { |id| id == "0" ? nil : id }) if params[:post_status_ids].present? + # apply post status filter if present + posts = posts.where(post_status_id: params[:post_status_ids].map { |id| id == "0" ? nil : id }) if params[:post_status_ids].present? render json: posts end def create - @post = Post.new - @post.assign_attributes(post_create_params) + if anti_spam_checks || invalid_anonymous_submission + render json: { + error: t('errors.unknown') + }, status: :unprocessable_entity + return + end + + # handle anonymous feedback + approval_status = "pending" + is_anonymous = params[:post][:is_anonymous] + + if Current.tenant.tenant_setting.feedback_approval_policy == "never_require_approval" || + (Current.tenant.tenant_setting.feedback_approval_policy == "anonymous_require_approval" && !is_anonymous) || + (user_signed_in? && current_user.moderator?) + approval_status = "approved" + end + + @post = Post.new(approval_status: approval_status) + @post.assign_attributes(post_create_params(is_anonymous: is_anonymous)) if @post.save - Follow.create(post_id: @post.id, user_id: current_user.id) + Follow.create(post_id: @post.id, user_id: current_user.id) unless is_anonymous render json: @post, status: :created else @@ -56,6 +76,7 @@ class PostsController < ApplicationController :title, :slug, :description, + :approval_status, :board_id, :user_id, :post_status_id, @@ -64,7 +85,7 @@ class PostsController < ApplicationController 'users.email as user_email', 'users.full_name as user_full_name' ) - .joins(:user) + .eager_load(:user) # left outer join .find(params[:id]) @post_statuses = PostStatus.select(:id, :name, :color).order(order: :asc) @@ -121,13 +142,49 @@ class PostsController < ApplicationController end end + # Returns a list of posts for moderation in JSON + def moderation + posts = Post + .select( + :id, + :title, + :description, + :slug, + :approval_status, + :user_id, + :board_id, + :created_at, + 'users.email as user_email', + 'users.full_name as user_full_name' + ) + .eager_load(:user) + .where(approval_status: ["pending", "rejected"]) + .order_by(created_at: :desc) + .limit(100) + + render json: posts + end + private + + def authenticate_moderator_if_post_not_approved + post = Post.friendly.find(params[:id]) + authenticate_moderator unless post.approval_status == "approved" + end + + def anti_spam_checks + params[:post][:dnf1] != "" || params[:post][:dnf2] != "" || Time.now.to_i - params[:post][:form_rendered_at] < 3 + end + + def invalid_anonymous_submission + (params[:post][:is_anonymous] == false && !user_signed_in?) || (params[:post][:is_anonymous] == true && Current.tenant.tenant_setting.allow_anonymous_feedback == false) + end - def post_create_params + def post_create_params(is_anonymous: false) params .require(:post) .permit(policy(@post).permitted_attributes_for_create) - .merge(user_id: current_user.id) + .merge(user_id: is_anonymous ? nil : current_user.id) end def post_update_params diff --git a/app/controllers/site_settings_controller.rb b/app/controllers/site_settings_controller.rb index 04f6910e..68f384b8 100644 --- a/app/controllers/site_settings_controller.rb +++ b/app/controllers/site_settings_controller.rb @@ -1,12 +1,7 @@ class SiteSettingsController < ApplicationController include ApplicationHelper - before_action :authenticate_admin, - only: [:general, :authentication, :boards, :post_statuses, :roadmap, :appearance] - - before_action :authenticate_moderator, - only: [:users] - + before_action :authenticate_admin before_action :set_page_title def general @@ -27,9 +22,6 @@ class SiteSettingsController < ApplicationController def appearance end - def users - end - private def set_page_title diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 325992c2..019d0a1f 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -31,7 +31,7 @@ module ApplicationHelper def authenticate_moderator return if check_user_signed_in == false - unless current_user.moderator? + unless current_user.moderator? flash[:alert] = t('errors.not_enough_privileges') redirect_to root_path return diff --git a/app/javascript/actions/Post/requestPosts.ts b/app/javascript/actions/Post/requestPosts.ts index a2f93051..461868a6 100644 --- a/app/javascript/actions/Post/requestPosts.ts +++ b/app/javascript/actions/Post/requestPosts.ts @@ -78,4 +78,20 @@ export const requestPosts = ( } catch (e) { dispatch(postsRequestFailure(e)); } +} + +// Used to get posts that require moderation (i.e. pending or rejected posts) +export const requestPostsForModeration = ( + +): ThunkAction> => async (dispatch) => { + dispatch(postsRequestStart()); + + try { + const response = await fetch('/posts/moderation'); + const json = await response.json(); + + dispatch(postsRequestSuccess(json, 1)); + } catch (e) { + dispatch(postsRequestFailure(e)); + } } \ No newline at end of file diff --git a/app/javascript/actions/Post/updatePost.ts b/app/javascript/actions/Post/updatePost.ts index ace6c64a..3c2ba540 100644 --- a/app/javascript/actions/Post/updatePost.ts +++ b/app/javascript/actions/Post/updatePost.ts @@ -4,6 +4,7 @@ import HttpStatus from "../../constants/http_status"; import buildRequestHeaders from "../../helpers/buildRequestHeaders"; import IPostJSON from "../../interfaces/json/IPost"; import { State } from "../../reducers/rootReducer"; +import { PostApprovalStatus } from "../../interfaces/IPost"; export const POST_UPDATE_START = 'POST_UPDATE_START'; interface PostUpdateStartAction { @@ -31,7 +32,7 @@ const postUpdateStart = (): PostUpdateStartAction => ({ type: POST_UPDATE_START, }); -const postUpdateSuccess = ( +export const postUpdateSuccess = ( postJSON: IPostJSON, ): PostUpdateSuccessAction => ({ type: POST_UPDATE_SUCCESS, @@ -78,6 +79,39 @@ export const updatePost = ( } catch (e) { dispatch(postUpdateFailure(e)); + return Promise.resolve(null); + } +}; + +export const updatePostApprovalStatus = ( + id: number, + approvalStatus: PostApprovalStatus, + authenticityToken: string, +): ThunkAction> => async (dispatch) => { + dispatch(postUpdateStart()); + + try { + const res = await fetch(`/posts/${id}`, { + method: 'PATCH', + headers: buildRequestHeaders(authenticityToken), + body: JSON.stringify({ + post: { + approval_status: approvalStatus, + } + }), + }); + const json = await res.json(); + + if (res.status === HttpStatus.OK) { + dispatch(postUpdateSuccess(json)); + } else { + dispatch(postUpdateFailure(json.error)); + } + + return Promise.resolve(res); + } catch (e) { + dispatch(postUpdateFailure(e)); + return Promise.resolve(null); } }; \ No newline at end of file diff --git a/app/javascript/components/Board/BoardP.tsx b/app/javascript/components/Board/BoardP.tsx index be3160ae..89f90a88 100644 --- a/app/javascript/components/Board/BoardP.tsx +++ b/app/javascript/components/Board/BoardP.tsx @@ -20,7 +20,9 @@ interface Props { board: IBoard; isLoggedIn: boolean; isPowerUser: boolean; + currentUserFullName: string; tenantSetting: ITenantSetting; + componentRenderedAt: number; authenticityToken: string; posts: PostsState; postStatuses: PostStatusesState; @@ -91,7 +93,9 @@ class BoardP extends React.Component { board, isLoggedIn, isPowerUser, + currentUserFullName, tenantSetting, + componentRenderedAt, authenticityToken, posts, postStatuses, @@ -110,8 +114,12 @@ class BoardP extends React.Component { +
{ @@ -42,12 +55,19 @@ class NewPost extends React.Component { title: '', description: '', + isSubmissionAnonymous: false, + + dnf1: '', + dnf2: '', }; this.toggleForm = this.toggleForm.bind(this); this.onTitleChange = this.onTitleChange.bind(this); this.onDescriptionChange = this.onDescriptionChange.bind(this); this.submitForm = this.submitForm.bind(this); + + this.onDnf1Change = this.onDnf1Change.bind(this) + this.onDnf2Change = this.onDnf2Change.bind(this) } toggleForm() { @@ -72,6 +92,18 @@ class NewPost extends React.Component { }); } + onDnf1Change(dnf1: string) { + this.setState({ + dnf1, + }); + } + + onDnf2Change(dnf2: string) { + this.setState({ + dnf2, + }); + } + async submitForm(e: React.FormEvent) { e.preventDefault(); @@ -82,8 +114,8 @@ class NewPost extends React.Component { }); const boardId = this.props.board.id; - const { authenticityToken } = this.props; - const { title, description } = this.state; + const { authenticityToken, componentRenderedAt } = this.props; + const { title, description, isSubmissionAnonymous, dnf1, dnf2 } = this.state; if (title === '') { this.setState({ @@ -102,6 +134,12 @@ class NewPost extends React.Component { title, description, board_id: boardId, + + is_anonymous: isSubmissionAnonymous, + + dnf1, + dnf2, + form_rendered_at: componentRenderedAt, }, }), }); @@ -109,16 +147,22 @@ class NewPost extends React.Component { this.setState({isLoading: false}); if (res.status === HttpStatus.Created) { - this.setState({ - success: I18n.t('board.new_post.submit_success'), + if (json.approval_status === POST_APPROVAL_STATUS_APPROVED) { + this.setState({ + success: I18n.t('board.new_post.submit_success'), + }); - title: '', - description: '', - }); - - setTimeout(() => ( - window.location.href = `/posts/${json.slug || json.id}` - ), 1000); + setTimeout(() => ( + window.location.href = `/posts/${json.slug || json.id}` + ), 1000); + } else { + this.setState({ + success: I18n.t('board.new_post.submit_pending'), + title: '', + description: '', + showForm: false, + }); + } } else { this.setState({error: json.error}); } @@ -131,7 +175,13 @@ class NewPost extends React.Component { } render() { - const { board, isLoggedIn } = this.props; + const { + board, + isLoggedIn, + currentUserFullName, + isAnonymousFeedbackAllowed + } = this.props; + const { showForm, error, @@ -139,7 +189,11 @@ class NewPost extends React.Component { isLoading, title, - description + description, + isSubmissionAnonymous, + + dnf1, + dnf2, } = this.state; return ( @@ -154,23 +208,47 @@ class NewPost extends React.Component { {board.description} + + { - isLoggedIn ? - - : - - {I18n.t('board.new_post.login_button')} - + (isAnonymousFeedbackAllowed && !showForm) && + } { @@ -180,7 +258,16 @@ class NewPost extends React.Component { description={description} handleTitleChange={this.onTitleChange} handleDescriptionChange={this.onDescriptionChange} + handleSubmit={this.submitForm} + + dnf1={dnf1} + dnf2={dnf2} + handleDnf1Change={this.onDnf1Change} + handleDnf2Change={this.onDnf2Change} + + currentUserFullName={currentUserFullName} + isSubmissionAnonymous={isSubmissionAnonymous} /> : null diff --git a/app/javascript/components/Board/NewPostForm.tsx b/app/javascript/components/Board/NewPostForm.tsx index 05e2a6b4..7e63a65c 100644 --- a/app/javascript/components/Board/NewPostForm.tsx +++ b/app/javascript/components/Board/NewPostForm.tsx @@ -2,13 +2,23 @@ import * as React from 'react'; import I18n from 'i18n-js'; import Button from '../common/Button'; +import { SmallMutedText } from '../common/CustomTexts'; interface Props { title: string; description: string; handleTitleChange(title: string): void; handleDescriptionChange(description: string): void; + handleSubmit(e: object): void; + + dnf1: string; + dnf2: string; + handleDnf1Change(dnf1: string): void; + handleDnf2Change(dnf2: string): void; + + currentUserFullName: string; + isSubmissionAnonymous: boolean; } const NewPostForm = ({ @@ -16,17 +26,26 @@ const NewPostForm = ({ description, handleTitleChange, handleDescriptionChange, + handleSubmit, + + dnf1, + dnf2, + handleDnf1Change, + handleDnf2Change, + + currentUserFullName, + isSubmissionAnonymous, }: Props) => (
- handleTitleChange(e.target.value)} maxLength={128} + placeholder={I18n.t('board.new_post.title')} id="postTitle" className="form-control" @@ -34,20 +53,60 @@ const NewPostForm = ({ autoFocus />
+ + { /* Honeypot field 1 */ } +
+ handleDnf1Change(e.target.value)} + maxLength={128} + placeholder="email" + autoComplete="off" + + id="email" + className="form-control" + /> +
+ + { /* Honeypot field 2 */ } + handleDnf2Change(e.target.value)} + maxLength={128} + placeholder="name" + autoComplete="off" + tabIndex={-1} + + id="name" + className="form-control" + /> +
-
+ + + + { + isSubmissionAnonymous ? + I18n.t('board.new_post.anonymous_submission_help') + : + I18n.t('board.new_post.non_anonymous_submission_help', { name: currentUserFullName }) + } +
); diff --git a/app/javascript/components/Board/index.tsx b/app/javascript/components/Board/index.tsx index 820c78cb..132a890c 100644 --- a/app/javascript/components/Board/index.tsx +++ b/app/javascript/components/Board/index.tsx @@ -14,7 +14,9 @@ interface Props { board: IBoard; isLoggedIn: boolean; isPowerUser: boolean; + currentUserFullName: string; tenantSetting: ITenantSetting; + componentRenderedAt: number; authenticityToken: string; } @@ -32,7 +34,9 @@ class BoardRoot extends React.Component { board, isLoggedIn, isPowerUser, + currentUserFullName, tenantSetting, + componentRenderedAt, authenticityToken, } = this.props; @@ -42,7 +46,9 @@ class BoardRoot extends React.Component { board={board} isLoggedIn={isLoggedIn} isPowerUser={isPowerUser} + currentUserFullName={currentUserFullName} tenantSetting={tenantSetting} + componentRenderedAt={componentRenderedAt} authenticityToken={authenticityToken} /> diff --git a/app/javascript/components/Moderation/Feedback/FeedbackListItem.tsx b/app/javascript/components/Moderation/Feedback/FeedbackListItem.tsx new file mode 100644 index 00000000..6a9ac623 --- /dev/null +++ b/app/javascript/components/Moderation/Feedback/FeedbackListItem.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import I18n from 'i18n-js'; +import Gravatar from 'react-gravatar'; + +import IPost, { PostApprovalStatus } from '../../../interfaces/IPost'; +import { AnonymousIcon, ApproveIcon, RejectIcon } from '../../common/Icons'; +import ReactMarkdown from 'react-markdown'; +import ActionLink from '../../common/ActionLink'; + +interface Props { + post: IPost; + + onUpdatePostApprovalStatus( + id: number, + approvalStatus: PostApprovalStatus, + ): Promise; + + hideRejectButton: boolean; +} + +const FeedbackListItem = ({ post, onUpdatePostApprovalStatus, hideRejectButton }: Props) => { + return ( +
+
+
+ { + post.userId ? + + : + + } +
+ +
+

window.location.href = `/posts/${post.slug || post.id}`}> + {post.title} +

+ + + {post.description.length > 200 ? `${post.description.slice(0, 200)}...` : post.description} + +
+
+ +
+ { + onUpdatePostApprovalStatus(post.id, 'approved') + }} + icon={} + > + {I18n.t('common.buttons.approve')} + + + {!hideRejectButton && + { + onUpdatePostApprovalStatus(post.id, 'rejected') + }} + icon={} + > + {I18n.t('common.buttons.reject')} + + } +
+
+ ); +}; + +export default FeedbackListItem; \ No newline at end of file diff --git a/app/javascript/components/Moderation/Feedback/FeedbackModerationList.tsx b/app/javascript/components/Moderation/Feedback/FeedbackModerationList.tsx new file mode 100644 index 00000000..a825ff92 --- /dev/null +++ b/app/javascript/components/Moderation/Feedback/FeedbackModerationList.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import I18n from 'i18n-js'; + +import IPost, { PostApprovalStatus } from '../../../interfaces/IPost'; +import FeedbackListItem from './FeedbackListItem'; +import { MutedText } from '../../common/CustomTexts'; + +interface Props { + posts: Array; + + onUpdatePostApprovalStatus( + id: number, + approvalStatus: PostApprovalStatus, + ): Promise; + + hideRejectButton?: boolean; +} + +const FeedbackModerationList = ({ posts, onUpdatePostApprovalStatus, hideRejectButton = false }: Props) => { + return ( +
+ { + (posts && posts.length > 0) ? + posts.map((post, i) => ( + + )) + : +
+ {I18n.t('board.posts_list.empty')} +
+ } +
+ ); +}; + +export default FeedbackModerationList; \ No newline at end of file diff --git a/app/javascript/components/Moderation/Feedback/FeedbackModerationP.tsx b/app/javascript/components/Moderation/Feedback/FeedbackModerationP.tsx new file mode 100644 index 00000000..24a5c70b --- /dev/null +++ b/app/javascript/components/Moderation/Feedback/FeedbackModerationP.tsx @@ -0,0 +1,157 @@ +import * as React from 'react'; +import I18n from 'i18n-js'; + +import Box from '../../common/Box'; +import SiteSettingsInfoBox from '../../common/SiteSettingsInfoBox'; +import { UserRoles } from '../../../interfaces/IUser'; +import { TenantSettingFeedbackApprovalPolicy } from '../../../interfaces/ITenantSetting'; +import Badge, { BADGE_TYPE_LIGHT } from '../../common/Badge'; +import ActionLink from '../../common/ActionLink'; +import { SettingsIcon } from '../../common/Icons'; +import IPost, { PostApprovalStatus } from '../../../interfaces/IPost'; +import FeedbackModerationList from './FeedbackModerationList'; + +interface Props { + currentUserRole: UserRoles; + changeFeedbackModerationSettingsUrl: string; + tenantSettingAllowAnonymousFeedback: boolean; + tenantSettingFeedbackApprovalPolicy: TenantSettingFeedbackApprovalPolicy; + authenticityToken: string; + + posts: Array; + areLoading: boolean; + areUpdating: boolean; + error: string; + requestPostsForModeration(): void; + updatePostApprovalStatus( + id: number, + approvalStatus: PostApprovalStatus, + authenticityToken: string + ): Promise; +} + +interface State { + filter: 'pending' | 'rejected'; +} + +class FeedbackModerationP extends React.Component { + constructor(props: Props) { + super(props); + + this.state = { + filter: 'pending', + }; + } + + componentDidMount() { + this.props.requestPostsForModeration(); + } + + getFeedbackPolicyString(tenantSettingFeedbackApprovalPolicy: TenantSettingFeedbackApprovalPolicy): string { + switch (tenantSettingFeedbackApprovalPolicy) { + case 'anonymous_require_approval': + return I18n.t('site_settings.general.feedback_approval_policy_anonymous_require_approval'); + case 'never_require_approval': + return I18n.t('site_settings.general.feedback_approval_policy_never_require_approval'); + case 'always_require_approval': + return I18n.t('site_settings.general.feedback_approval_policy_always_require_approval'); + } + } + + render() { + const { + currentUserRole, + changeFeedbackModerationSettingsUrl, + tenantSettingAllowAnonymousFeedback, + tenantSettingFeedbackApprovalPolicy, + + posts, + areLoading, + areUpdating, + error, + + updatePostApprovalStatus, + + authenticityToken, + } = this.props; + + const { filter } = this.state; + + const pendingPosts = posts.filter(post => post.approvalStatus === 'pending'); + const rejectedPosts = posts.filter(post => post.approvalStatus === 'rejected'); + + return ( + <> + +

{ I18n.t('moderation.menu.feedback') }

+ + { + (currentUserRole === 'admin' || currentUserRole === 'owner') && + <> +
+ + { + tenantSettingAllowAnonymousFeedback ? + I18n.t('moderation.feedback.anonymous_feedback_allowed') + : + I18n.t('moderation.feedback.anonymous_feedback_not_allowed') + } + + + + { this.getFeedbackPolicyString(tenantSettingFeedbackApprovalPolicy) } + +
+ + window.location.href = changeFeedbackModerationSettingsUrl} icon={} + customClass="changeFeedbackModerationSettingsLink" + > + { I18n.t('moderation.feedback.change_feedback_moderation_settings') } + + + } + + + + { + filter === 'pending' ? + + updatePostApprovalStatus(id, approvalStatus, authenticityToken) + } + /> + : + + updatePostApprovalStatus(id, approvalStatus, authenticityToken) + } + hideRejectButton + /> + } +
+ + + + ); + } +} + +export default FeedbackModerationP; \ No newline at end of file diff --git a/app/javascript/components/Moderation/Feedback/index.tsx b/app/javascript/components/Moderation/Feedback/index.tsx new file mode 100644 index 00000000..729d4c55 --- /dev/null +++ b/app/javascript/components/Moderation/Feedback/index.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { Provider } from 'react-redux'; +import { Store } from 'redux'; + +import FeedbackModeration from '../../../containers/FeedbackModeration'; + +import createStoreHelper from '../../../helpers/createStore'; +import { State } from '../../../reducers/rootReducer'; +import { UserRoles } from '../../../interfaces/IUser'; +import { TenantSettingFeedbackApprovalPolicy } from '../../../interfaces/ITenantSetting'; + +interface Props { + currentUserRole: UserRoles; + changeFeedbackModerationSettingsUrl: string; + tenantSettingAllowAnonymousFeedback: boolean; + tenantSettingFeedbackApprovalPolicy: TenantSettingFeedbackApprovalPolicy; + authenticityToken: string; +} + +class FeedbackModerationRoot extends React.Component { + store: Store; + + constructor(props: Props) { + super(props); + + this.store = createStoreHelper(); + } + + render() { + return ( + + + + ); + } +} + +export default FeedbackModerationRoot; \ No newline at end of file diff --git a/app/javascript/components/SiteSettings/Users/UserEditable.tsx b/app/javascript/components/Moderation/Users/UserEditable.tsx similarity index 91% rename from app/javascript/components/SiteSettings/Users/UserEditable.tsx rename to app/javascript/components/Moderation/Users/UserEditable.tsx index 3edc8d7f..3fe08706 100644 --- a/app/javascript/components/SiteSettings/Users/UserEditable.tsx +++ b/app/javascript/components/Moderation/Users/UserEditable.tsx @@ -64,9 +64,9 @@ class UserEditable extends React.Component { const confirmationMessage = newStatus === 'blocked' ? - I18n.t('site_settings.users.block_confirmation', { name: user.fullName }) + I18n.t('moderation.users.block_confirmation', { name: user.fullName }) : - I18n.t('site_settings.users.unblock_confirmation', { name: user.fullName }); + I18n.t('moderation.users.unblock_confirmation', { name: user.fullName }); const confirmationResponse = confirm(confirmationMessage); @@ -101,7 +101,7 @@ class UserEditable extends React.Component {
- { I18n.t(`site_settings.users.role_${user.role}`) } + { I18n.t(`moderation.users.role_${user.role}`) } { @@ -109,7 +109,7 @@ class UserEditable extends React.Component { <> - { I18n.t(`site_settings.users.status_${user.status}`) } + { I18n.t(`moderation.users.status_${user.status}`) } : @@ -137,9 +137,9 @@ class UserEditable extends React.Component { > { user.status !== USER_STATUS_BLOCKED ? - I18n.t('site_settings.users.block') + I18n.t('moderation.users.block') : - I18n.t('site_settings.users.unblock') + I18n.t('moderation.users.unblock') }
diff --git a/app/javascript/components/SiteSettings/Users/UserForm.tsx b/app/javascript/components/Moderation/Users/UserForm.tsx similarity index 82% rename from app/javascript/components/SiteSettings/Users/UserForm.tsx rename to app/javascript/components/Moderation/Users/UserForm.tsx index ae0b6a4d..89f74db9 100644 --- a/app/javascript/components/SiteSettings/Users/UserForm.tsx +++ b/app/javascript/components/Moderation/Users/UserForm.tsx @@ -30,9 +30,9 @@ class UserForm extends React.Component { if (selectedRole !== currentRole) { if (selectedRole === 'moderator') - confirmation = confirm(I18n.t('site_settings.users.role_to_moderator_confirmation', { name: user.fullName })); + confirmation = confirm(I18n.t('moderation.users.role_to_moderator_confirmation', { name: user.fullName })); else if (selectedRole === 'admin') - confirmation = confirm(I18n.t('site_settings.users.role_to_admin_confirmation', { name: user.fullName })); + confirmation = confirm(I18n.t('moderation.users.role_to_admin_confirmation', { name: user.fullName })); } if (confirmation) updateUserRole(selectedRole); @@ -60,13 +60,13 @@ class UserForm extends React.Component { > diff --git a/app/javascript/components/SiteSettings/Users/UsersSiteSettingsP.tsx b/app/javascript/components/Moderation/Users/UsersModerationP.tsx similarity index 88% rename from app/javascript/components/SiteSettings/Users/UsersSiteSettingsP.tsx rename to app/javascript/components/Moderation/Users/UsersModerationP.tsx index b9f8996f..70256bb8 100644 --- a/app/javascript/components/SiteSettings/Users/UsersSiteSettingsP.tsx +++ b/app/javascript/components/Moderation/Users/UsersModerationP.tsx @@ -32,7 +32,7 @@ interface Props { authenticityToken: string; } -class UsersSiteSettingsP extends React.Component { +class UsersModerationP extends React.Component { constructor(props: Props) { super(props); @@ -81,14 +81,14 @@ class UsersSiteSettingsP extends React.Component { return ( <> -

{ I18n.t('site_settings.users.title') }

+

{ I18n.t('moderation.users.title') }

{numberOfUsers} {I18n.t('activerecord.models.user', {count: users.items.length})}  ( - {numberOfActiveUsers} {I18n.t('site_settings.users.status_active')},  - {numberOfBlockedUsers} {I18n.t('site_settings.users.status_blocked')},  - {numberOfDeletedUsers} {I18n.t('site_settings.users.status_deleted')}) + {numberOfActiveUsers} {I18n.t('moderation.users.status_active')},  + {numberOfBlockedUsers} {I18n.t('moderation.users.status_blocked')},  + {numberOfDeletedUsers} {I18n.t('moderation.users.status_deleted')})

    @@ -117,4 +117,4 @@ class UsersSiteSettingsP extends React.Component { } } -export default UsersSiteSettingsP; \ No newline at end of file +export default UsersModerationP; \ No newline at end of file diff --git a/app/javascript/components/SiteSettings/Users/index.tsx b/app/javascript/components/Moderation/Users/index.tsx similarity index 80% rename from app/javascript/components/SiteSettings/Users/index.tsx rename to app/javascript/components/Moderation/Users/index.tsx index 1ccfa8b8..3aeacf40 100644 --- a/app/javascript/components/SiteSettings/Users/index.tsx +++ b/app/javascript/components/Moderation/Users/index.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Provider } from 'react-redux'; import { Store } from 'redux'; -import UsersSiteSettings from '../../../containers/UsersSiteSettings'; +import UsersModeration from '../../../containers/UsersModeration'; import createStoreHelper from '../../../helpers/createStore'; import { UserRoles } from '../../../interfaces/IUser'; @@ -14,7 +14,7 @@ interface Props { authenticityToken: string; } -class UsersSiteSettingsRoot extends React.Component { +class UsersModerationRoot extends React.Component { store: Store; constructor(props: Props) { @@ -26,7 +26,7 @@ class UsersSiteSettingsRoot extends React.Component { render() { return ( - { } } -export default UsersSiteSettingsRoot; \ No newline at end of file +export default UsersModerationRoot; \ No newline at end of file diff --git a/app/javascript/components/Post/PostFooter.tsx b/app/javascript/components/Post/PostFooter.tsx index 1dc17e11..2f746184 100644 --- a/app/javascript/components/Post/PostFooter.tsx +++ b/app/javascript/components/Post/PostFooter.tsx @@ -29,14 +29,23 @@ const PostFooter = ({ }: Props) => (
    - {I18n.t('post.published_by').toLowerCase()}   -   - {authorFullName} + { + authorEmail ? + <> + {I18n.t('post.published_by').toLowerCase()}   +   + {authorFullName} + + : + {I18n.t('post.published_anonymously').toLowerCase()} + } + - {friendlyDate(createdAt)} + + {friendlyDate(createdAt)}
    { - isPowerUser || authorEmail === currentUserEmail ? + isPowerUser || (authorEmail && authorEmail === currentUserEmail) ?
    } customClass='editAction'> {I18n.t('common.buttons.edit')} diff --git a/app/javascript/components/Post/PostP.tsx b/app/javascript/components/Post/PostP.tsx index e887066a..50565b49 100644 --- a/app/javascript/components/Post/PostP.tsx +++ b/app/javascript/components/Post/PostP.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import ReactMarkdown from 'react-markdown'; import I18n from 'i18n-js'; -import IPost from '../../interfaces/IPost'; +import IPost, { POST_APPROVAL_STATUS_APPROVED, POST_APPROVAL_STATUS_PENDING } from '../../interfaces/IPost'; import IPostStatus from '../../interfaces/IPostStatus'; import IBoard from '../../interfaces/IBoard'; import ITenantSetting from '../../interfaces/ITenantSetting'; @@ -28,6 +28,7 @@ import { fromRailsStringToJavascriptDate } from '../../helpers/datetime'; import HttpStatus from '../../constants/http_status'; import ActionLink from '../common/ActionLink'; import { EditIcon } from '../common/Icons'; +import Badge, { BADGE_TYPE_DANGER, BADGE_TYPE_WARNING } from '../common/Badge'; interface Props { postId: number; @@ -249,6 +250,15 @@ class PostP extends React.Component { }
    + + { + (isPowerUser && post.approvalStatus !== POST_APPROVAL_STATUS_APPROVED) && +
    + + { I18n.t(`activerecord.attributes.post.approval_status_${post.approvalStatus.toLowerCase()}`) } + +
    + } { if (res?.status !== HttpStatus.OK) return; + + const urlWithoutHash = window.location.href.split('#')[0]; + window.history.pushState({}, document.title, urlWithoutHash); window.location.reload(); }); }; const customDomain = watch('customDomain'); + React.useEffect(() => { + if (window.location.hash) { + const anchor = window.location.hash.substring(1); + const anchorElement = document.getElementById(anchor); + + if (anchorElement) { + anchorElement.classList.add('highlighted'); + + setTimeout( () => { + anchorElement.scrollIntoView({ + behavior: 'smooth' + }) + }, 50); + } + } + }, []); + return ( <> - +

    { I18n.t('site_settings.general.title') }

    @@ -226,64 +254,98 @@ const GeneralSiteSettingsP = ({
    } -
    -

    { I18n.t('site_settings.general.subtitle_header') }

    - -
    -
    - - +
    +

    { I18n.t('site_settings.general.subtitle_moderation') }

    + +
    +
    + + + + { I18n.t('site_settings.general.allow_anonymous_feedback_help') } + +
    -
    - -
    -
    - - -
    -
    -

    { I18n.t('site_settings.general.subtitle_visibility') }

    - -
    -
    - - - - { I18n.t('site_settings.general.show_vote_count_help') } - +
    + + + + { I18n.t('site_settings.general.feedback_approval_policy_help') } +
    -
    - -
    -
    - - - - { I18n.t('site_settings.general.show_vote_button_in_board_help') } - + -
    +
    +
    +

    { I18n.t('site_settings.general.subtitle_visibility') }

    -
    -
    - - +
    +
    + + + + { I18n.t('site_settings.general.show_vote_count_help') } + +
    +
    + +
    +
    + + + + { I18n.t('site_settings.general.show_vote_button_in_board_help') } + +
    +
    + +
    +
    + + +
    diff --git a/app/javascript/components/Tour/Tour.tsx b/app/javascript/components/Tour/Tour.tsx index 2ea6e111..fe959f89 100644 --- a/app/javascript/components/Tour/Tour.tsx +++ b/app/javascript/components/Tour/Tour.tsx @@ -43,7 +43,13 @@ const Tour = ({ userFullName }: Props) => { { target: '.siteSettingsDropdown', title: 'Site settings', - content: 'Click "Site settings" to customize your feedback space. You can add custom boards and statuses, manage users, personalize appearance, and more.', + content: 'Click "Site settings" to customize your feedback space. You can add custom boards and statuses, configure various settings, personalize appearance, and more.', + disableBeacon: true, + }, + { + target: '.moderationDropdown', + title: 'Moderation', + content: 'Click "Moderation" to approve or reject submitted feedback and to manage users.', disableBeacon: true, }, { @@ -107,8 +113,8 @@ const Tour = ({ userFullName }: Props) => { // Open profile navbar if ( state.type === 'step:after' && - (((state.action === 'next' || state.action === 'close') && (state.step.target === '.sidebarFilters' || state.step.target === '.siteSettingsDropdown')) || - (state.action === 'prev' && state.step.target === '.tourDropdown')) + (((state.action === 'next' || state.action === 'close') && (state.step.target === '.sidebarFilters' || state.step.target === '.siteSettingsDropdown' || state.step.target === '.moderationDropdown')) || + (state.action === 'prev' && (state.step.target === '.moderationDropdown' || state.step.target === '.tourDropdown'))) ) { if (vw < BOOTSTRAP_BREAKPOINT_SM) openBoardsNav(); diff --git a/app/javascript/components/common/Badge.tsx b/app/javascript/components/common/Badge.tsx new file mode 100644 index 00000000..37e7bf0c --- /dev/null +++ b/app/javascript/components/common/Badge.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; + +export const BADGE_TYPE_LIGHT = 'badgeLight'; +export const BADGE_TYPE_WARNING = 'badgeWarning'; +export const BADGE_TYPE_DANGER = 'badgeDanger'; + +export type BadgeTypes = + typeof BADGE_TYPE_LIGHT | + typeof BADGE_TYPE_WARNING | + typeof BADGE_TYPE_DANGER; + +interface Props { + type: BadgeTypes; + children: string; +} + +const Badge = ({ type, children }: Props) => ( + + {children} + +); + +export default Badge; \ No newline at end of file diff --git a/app/javascript/components/common/Icons.tsx b/app/javascript/components/common/Icons.tsx index 8bb4dbef..1a33c54d 100644 --- a/app/javascript/components/common/Icons.tsx +++ b/app/javascript/components/common/Icons.tsx @@ -2,14 +2,21 @@ import * as React from 'react'; import I18n from 'i18n-js'; import { BsReply } from 'react-icons/bs'; -import { FiEdit, FiDelete } from 'react-icons/fi'; +import { FiEdit, FiDelete, FiSettings } from 'react-icons/fi'; import { ImCancelCircle } from 'react-icons/im'; import { TbLock, TbLockOpen } from 'react-icons/tb'; -import { MdContentCopy, MdDone, MdOutlineArrowBack } from 'react-icons/md'; import { GrTest, GrClearOption } from 'react-icons/gr'; -import { MdOutlineLibraryBooks } from "react-icons/md"; -import { MdVerified } from "react-icons/md"; import { BiLike, BiSolidLike } from "react-icons/bi"; +import { + MdContentCopy, + MdDone, + MdOutlineArrowBack, + MdOutlineLibraryBooks, + MdVerified, + MdCheck, + MdClear, +} from 'react-icons/md'; +import { FaUserNinja } from "react-icons/fa"; export const EditIcon = () => ; @@ -43,4 +50,12 @@ export const ClearIcon = () => ; export const LikeIcon = ({size = 32}) => ; -export const SolidLikeIcon = ({size = 32}) => ; \ No newline at end of file +export const SolidLikeIcon = ({size = 32}) => ; + +export const SettingsIcon = () => ; + +export const AnonymousIcon = ({size = 32, title=I18n.t('defaults.user_full_name')}) => ; + +export const ApproveIcon = () => ; + +export const RejectIcon = () => ; \ No newline at end of file diff --git a/app/javascript/containers/BoardsSiteSettings.tsx b/app/javascript/containers/BoardsSiteSettings.tsx index 91117b14..de2ed5cf 100644 --- a/app/javascript/containers/BoardsSiteSettings.tsx +++ b/app/javascript/containers/BoardsSiteSettings.tsx @@ -62,7 +62,6 @@ const mapDispatchToProps = (dispatch: any) => ({ deleteBoard(id: number, authenticityToken: string) { dispatch(deleteBoard(id, authenticityToken)).then(res => { - console.log(res); if (res && res.status === HttpStatus.Accepted) { window.location.reload(); } diff --git a/app/javascript/containers/FeedbackModeration.tsx b/app/javascript/containers/FeedbackModeration.tsx new file mode 100644 index 00000000..29cdd470 --- /dev/null +++ b/app/javascript/containers/FeedbackModeration.tsx @@ -0,0 +1,34 @@ +import { connect } from "react-redux"; + +import FeedbackModerationP from "../components/Moderation/Feedback/FeedbackModerationP"; + +import { State } from "../reducers/rootReducer"; +import { requestPostsForModeration } from "../actions/Post/requestPosts"; +import { updatePostApprovalStatus } from "../actions/Post/updatePost"; +import { PostApprovalStatus } from "../interfaces/IPost"; + +const mapStateToProps = (state: State) => ({ + posts: state.moderation.feedback.posts, + areLoading: state.moderation.feedback.areLoading, + areUpdating: state.moderation.feedback.areUpdating, + error: state.moderation.feedback.error, +}); + +const mapDispatchToProps = (dispatch: any) => ({ + requestPostsForModeration() { + dispatch(requestPostsForModeration()); + }, + + updatePostApprovalStatus( + id: number, + approvalStatus: PostApprovalStatus, + authenticityToken: string + ) { + return dispatch(updatePostApprovalStatus(id, approvalStatus, authenticityToken)); + } +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(FeedbackModerationP); \ No newline at end of file diff --git a/app/javascript/containers/GeneralSiteSettings.tsx b/app/javascript/containers/GeneralSiteSettings.tsx index 408aa84f..d62db91a 100644 --- a/app/javascript/containers/GeneralSiteSettings.tsx +++ b/app/javascript/containers/GeneralSiteSettings.tsx @@ -3,7 +3,7 @@ import { connect } from "react-redux"; import GeneralSiteSettingsP from "../components/SiteSettings/General/GeneralSiteSettingsP"; import { updateTenant } from "../actions/Tenant/updateTenant"; import { State } from "../reducers/rootReducer"; -import { TenantSettingBrandDisplay, TenantSettingCollapseBoardsInHeader } from "../interfaces/ITenantSetting"; +import { TenantSettingBrandDisplay, TenantSettingCollapseBoardsInHeader, TenantSettingFeedbackApprovalPolicy } from "../interfaces/ITenantSetting"; const mapStateToProps = (state: State) => ({ areUpdating: state.siteSettings.general.areUpdating, @@ -18,6 +18,8 @@ const mapDispatchToProps = (dispatch: any) => ({ locale: string, rootBoardId: number, customDomain: string, + allowAnonymousFeedback: boolean, + feedbackApprovalPolicy: TenantSettingFeedbackApprovalPolicy, showRoadmapInHeader: boolean, collapseBoardsInHeader: TenantSettingCollapseBoardsInHeader, showVoteCount: boolean, @@ -30,12 +32,14 @@ const mapDispatchToProps = (dispatch: any) => ({ siteLogo, tenantSetting: { brand_display: brandDisplaySetting, + root_board_id: rootBoardId, + allow_anonymous_feedback: allowAnonymousFeedback, + feedback_approval_policy: feedbackApprovalPolicy, + show_roadmap_in_header: showRoadmapInHeader, + collapse_boards_in_header: collapseBoardsInHeader, show_vote_count: showVoteCount, show_vote_button_in_board: showVoteButtonInBoard, show_powered_by: showPoweredBy, - root_board_id: rootBoardId, - show_roadmap_in_header: showRoadmapInHeader, - collapse_boards_in_header: collapseBoardsInHeader, }, locale, customDomain, diff --git a/app/javascript/containers/UsersSiteSettings.tsx b/app/javascript/containers/UsersModeration.tsx similarity index 81% rename from app/javascript/containers/UsersSiteSettings.tsx rename to app/javascript/containers/UsersModeration.tsx index bde63960..c0cbc385 100644 --- a/app/javascript/containers/UsersSiteSettings.tsx +++ b/app/javascript/containers/UsersModeration.tsx @@ -1,6 +1,6 @@ import { connect } from "react-redux"; -import UsersSiteSettingsP from "../components/SiteSettings/Users/UsersSiteSettingsP"; +import UsersModerationP from "../components/Moderation/Users/UsersModerationP"; import { requestUsers } from "../actions/User/requestUsers"; import { updateUser } from "../actions/User/updateUser"; @@ -9,8 +9,8 @@ import { State } from "../reducers/rootReducer"; const mapStateToProps = (state: State) => ({ users: state.users, - settingsAreUpdating: state.siteSettings.users.areUpdating, - settingsError: state.siteSettings.users.error, + settingsAreUpdating: state.moderation.users.areUpdating, + settingsError: state.moderation.users.error, }); const mapDispatchToProps = (dispatch: any) => ({ @@ -46,4 +46,4 @@ const mapDispatchToProps = (dispatch: any) => ({ export default connect( mapStateToProps, mapDispatchToProps -)(UsersSiteSettingsP); \ No newline at end of file +)(UsersModerationP); \ No newline at end of file diff --git a/app/javascript/interfaces/IPost.ts b/app/javascript/interfaces/IPost.ts index c8b31b31..e59765d1 100644 --- a/app/javascript/interfaces/IPost.ts +++ b/app/javascript/interfaces/IPost.ts @@ -1,8 +1,19 @@ +// Approval status +export const POST_APPROVAL_STATUS_APPROVED = 'approved'; +export const POST_APPROVAL_STATUS_PENDING = 'pending'; +export const POST_APPROVAL_STATUS_REJECTED = 'rejected'; + +export type PostApprovalStatus = + typeof POST_APPROVAL_STATUS_APPROVED | + typeof POST_APPROVAL_STATUS_PENDING | + typeof POST_APPROVAL_STATUS_REJECTED; + interface IPost { id: number; title: string; slug?: string; description?: string; + approvalStatus: PostApprovalStatus; boardId: number; postStatusId?: number; likeCount: number; diff --git a/app/javascript/interfaces/ITenantSetting.ts b/app/javascript/interfaces/ITenantSetting.ts index 637f72ae..b869841b 100644 --- a/app/javascript/interfaces/ITenantSetting.ts +++ b/app/javascript/interfaces/ITenantSetting.ts @@ -10,6 +10,16 @@ export type TenantSettingBrandDisplay = typeof TENANT_SETTING_BRAND_DISPLAY_LOGO_ONLY | typeof TENANT_SETTING_BRAND_DISPLAY_NONE; +// Feedback approval policy +export const TENANT_SETTING_FEEDBACK_APPROVAL_POLICY_ANONYMOUS_REQUIRE_APPROVAL = 'anonymous_require_approval'; +export const TENANT_SETTING_FEEDBACK_APPROVAL_POLICY_NEVER_REQUIRE_APPROVAL = 'never_require_approval'; +export const TENANT_SETTING_FEEDBACK_APPROVAL_POLICY_ALWAYS_REQUIRE_APPROVAL = 'always_require_approval'; + +export type TenantSettingFeedbackApprovalPolicy = + typeof TENANT_SETTING_FEEDBACK_APPROVAL_POLICY_ANONYMOUS_REQUIRE_APPROVAL | + typeof TENANT_SETTING_FEEDBACK_APPROVAL_POLICY_NEVER_REQUIRE_APPROVAL | + typeof TENANT_SETTING_FEEDBACK_APPROVAL_POLICY_ALWAYS_REQUIRE_APPROVAL; + // Collapse boards in header export const TENANT_SETTING_COLLAPSE_BOARDS_IN_HEADER_NO_COLLAPSE = 'no_collapse'; export const TENANT_SETTING_COLLAPSE_BOARDS_IN_HEADER_ALWAYS_COLLAPSE = 'always_collapse'; @@ -22,6 +32,8 @@ export type TenantSettingCollapseBoardsInHeader = interface ITenantSetting { brand_display?: TenantSettingBrandDisplay; root_board_id?: number; + allow_anonymous_feedback?: boolean; + feedback_approval_policy?: TenantSettingFeedbackApprovalPolicy; show_vote_count?: boolean; show_vote_button_in_board?: boolean; show_roadmap_in_header?: boolean; diff --git a/app/javascript/interfaces/json/IPost.ts b/app/javascript/interfaces/json/IPost.ts index cc39d528..5110606b 100644 --- a/app/javascript/interfaces/json/IPost.ts +++ b/app/javascript/interfaces/json/IPost.ts @@ -1,8 +1,11 @@ +import { PostApprovalStatus } from "../IPost"; + interface IPostJSON { id: number; title: string; slug?: string; description?: string; + approval_status: PostApprovalStatus; board_id: number; post_status_id?: number; likes_count: number; diff --git a/app/javascript/reducers/Moderation/feedbackReducer.ts b/app/javascript/reducers/Moderation/feedbackReducer.ts new file mode 100644 index 00000000..c73b5cf3 --- /dev/null +++ b/app/javascript/reducers/Moderation/feedbackReducer.ts @@ -0,0 +1,67 @@ +import { postRequestSuccess } from "../../actions/Post/requestPost"; +import { POSTS_REQUEST_FAILURE, POSTS_REQUEST_START, POSTS_REQUEST_SUCCESS, PostsRequestActionTypes } from "../../actions/Post/requestPosts"; +import { POST_UPDATE_FAILURE, POST_UPDATE_START, POST_UPDATE_SUCCESS, PostUpdateActionTypes, postUpdateSuccess } from "../../actions/Post/updatePost"; +import IPost from "../../interfaces/IPost"; +import postReducer from "../postReducer"; + +export interface ModerationFeedbackState { + posts: Array; + areLoading: boolean; + areUpdating: boolean; + error: string; +} + +const initialState: ModerationFeedbackState = { + posts: [], + areLoading: true, + areUpdating: false, + error: '', +}; + +const moderationFeedbackReducer = ( + state = initialState, + action: PostsRequestActionTypes | PostUpdateActionTypes, +) => { + switch (action.type) { + case POSTS_REQUEST_START: + return { + ...state, + areLoading: true, + }; + + case POST_UPDATE_START: + return { + ...state, + areUpdating: true, + }; + + case POSTS_REQUEST_SUCCESS: + return { + ...state, + areLoading: false, + error: '', + posts: action.posts.map(post => postReducer(undefined, postRequestSuccess(post))), + }; + + case POST_UPDATE_SUCCESS: + return { + ...state, + areUpdating: false, + error: '', + posts: state.posts.map(post => post.id === action.post.id ? postReducer(post, postUpdateSuccess(action.post)) : post), + }; + + case POSTS_REQUEST_FAILURE: + case POST_UPDATE_FAILURE: + return { + ...state, + areLoading: false, + error: action.error, + }; + + default: + return state; + } +} + +export default moderationFeedbackReducer; \ No newline at end of file diff --git a/app/javascript/reducers/SiteSettings/usersReducer.ts b/app/javascript/reducers/Moderation/usersReducer.ts similarity index 81% rename from app/javascript/reducers/SiteSettings/usersReducer.ts rename to app/javascript/reducers/Moderation/usersReducer.ts index aa81da7b..110696ad 100644 --- a/app/javascript/reducers/SiteSettings/usersReducer.ts +++ b/app/javascript/reducers/Moderation/usersReducer.ts @@ -5,17 +5,17 @@ import { USER_UPDATE_FAILURE, } from '../../actions/User/updateUser'; -export interface SiteSettingsUsersState { +export interface ModerationUsersState { areUpdating: boolean; error: string; } -const initialState: SiteSettingsUsersState = { +const initialState: ModerationUsersState = { areUpdating: false, error: '', }; -const siteSettingsUsersReducer = ( +const moderationUsersReducer = ( state = initialState, action: UserUpdateActionTypes, ) => { @@ -45,4 +45,4 @@ const siteSettingsUsersReducer = ( } } -export default siteSettingsUsersReducer; \ No newline at end of file +export default moderationUsersReducer; \ No newline at end of file diff --git a/app/javascript/reducers/moderationReducer.ts b/app/javascript/reducers/moderationReducer.ts new file mode 100644 index 00000000..881337ab --- /dev/null +++ b/app/javascript/reducers/moderationReducer.ts @@ -0,0 +1,55 @@ +import { POSTS_REQUEST_FAILURE, POSTS_REQUEST_START, POSTS_REQUEST_SUCCESS, PostsRequestActionTypes } from '../actions/Post/requestPosts'; +import { POST_UPDATE_FAILURE, POST_UPDATE_START, POST_UPDATE_SUCCESS, PostUpdateActionTypes } from '../actions/Post/updatePost'; +import { + UserUpdateActionTypes, + USER_UPDATE_START, + USER_UPDATE_SUCCESS, + USER_UPDATE_FAILURE, +} from '../actions/User/updateUser'; + +import moderationFeedbackReducer, { ModerationFeedbackState } from './Moderation/feedbackReducer'; +import moderationUsersReducer, { ModerationUsersState } from './Moderation/usersReducer'; + +interface ModerationState { + feedback: ModerationFeedbackState; + users: ModerationUsersState; +} + +const initialState: ModerationState = { + feedback: moderationFeedbackReducer(undefined, {} as any), + users: moderationUsersReducer(undefined, {} as any), +}; + +const moderationReducer = ( + state = initialState, + action: + PostsRequestActionTypes | + PostUpdateActionTypes | + UserUpdateActionTypes +): ModerationState => { + switch (action.type) { + case POSTS_REQUEST_START: + case POSTS_REQUEST_SUCCESS: + case POSTS_REQUEST_FAILURE: + case POST_UPDATE_START: + case POST_UPDATE_SUCCESS: + case POST_UPDATE_FAILURE: + return { + ...state, + feedback: moderationFeedbackReducer(state.feedback, action), + }; + + case USER_UPDATE_START: + case USER_UPDATE_SUCCESS: + case USER_UPDATE_FAILURE: + return { + ...state, + users: moderationUsersReducer(state.users, action), + }; + + default: + return state; + } +} + +export default moderationReducer; \ No newline at end of file diff --git a/app/javascript/reducers/postReducer.ts b/app/javascript/reducers/postReducer.ts index 96fc6aec..50e79d79 100644 --- a/app/javascript/reducers/postReducer.ts +++ b/app/javascript/reducers/postReducer.ts @@ -8,13 +8,14 @@ import { POST_UPDATE_SUCCESS, } from '../actions/Post/updatePost'; -import IPost from '../interfaces/IPost'; +import IPost, { POST_APPROVAL_STATUS_APPROVED } from '../interfaces/IPost'; const initialState: IPost = { id: 0, title: '', slug: null, description: null, + approvalStatus: POST_APPROVAL_STATUS_APPROVED, boardId: 0, postStatusId: null, likeCount: 0, @@ -40,6 +41,7 @@ const postReducer = ( title: action.post.title, slug: action.post.slug, description: action.post.description, + approvalStatus: action.post.approval_status, boardId: action.post.board_id, postStatusId: action.post.post_status_id, likeCount: action.post.likes_count, @@ -59,6 +61,7 @@ const postReducer = ( description: action.post.description, boardId: action.post.board_id, postStatusId: action.post.post_status_id, + approvalStatus: action.post.approval_status, }; default: diff --git a/app/javascript/reducers/rootReducer.ts b/app/javascript/reducers/rootReducer.ts index e6072947..c7740f89 100644 --- a/app/javascript/reducers/rootReducer.ts +++ b/app/javascript/reducers/rootReducer.ts @@ -7,8 +7,9 @@ import boardsReducer from './boardsReducer'; import postStatusesReducer from './postStatusesReducer'; import usersReducer from './usersReducer'; import currentPostReducer from './currentPostReducer'; -import siteSettingsReducer from './siteSettingsReducer'; import oAuthsReducer from './oAuthsReducer'; +import siteSettingsReducer from './siteSettingsReducer'; +import moderationReducer from './moderationReducer'; const rootReducer = combineReducers({ tenantSignUp: tenantSignUpReducer, @@ -18,8 +19,10 @@ const rootReducer = combineReducers({ postStatuses: postStatusesReducer, users: usersReducer, currentPost: currentPostReducer, - siteSettings: siteSettingsReducer, oAuths: oAuthsReducer, + + siteSettings: siteSettingsReducer, + moderation: moderationReducer, }); export type State = ReturnType diff --git a/app/javascript/reducers/siteSettingsReducer.ts b/app/javascript/reducers/siteSettingsReducer.ts index ef262707..19bd8dd6 100644 --- a/app/javascript/reducers/siteSettingsReducer.ts +++ b/app/javascript/reducers/siteSettingsReducer.ts @@ -61,13 +61,6 @@ import { POSTSTATUS_UPDATE_FAILURE, } from '../actions/PostStatus/updatePostStatus'; -import { - UserUpdateActionTypes, - USER_UPDATE_START, - USER_UPDATE_SUCCESS, - USER_UPDATE_FAILURE, -} from '../actions/User/updateUser'; - import { OAuthSubmitActionTypes, OAUTH_SUBMIT_START, @@ -93,7 +86,6 @@ import siteSettingsGeneralReducer, { SiteSettingsGeneralState } from './SiteSett import siteSettingsBoardsReducer, { SiteSettingsBoardsState } from './SiteSettings/boardsReducer'; import siteSettingsPostStatusesReducer, { SiteSettingsPostStatusesState } from './SiteSettings/postStatusesReducer'; import siteSettingsRoadmapReducer, { SiteSettingsRoadmapState } from './SiteSettings/roadmapReducer'; -import siteSettingsUsersReducer, { SiteSettingsUsersState } from './SiteSettings/usersReducer'; import siteSettingsAuthenticationReducer, { SiteSettingsAuthenticationState } from './SiteSettings/authenticationReducer'; import siteSettingsAppearanceReducer, { SiteSettingsAppearanceState } from './SiteSettings/appearanceReducer'; @@ -104,7 +96,6 @@ interface SiteSettingsState { postStatuses: SiteSettingsPostStatusesState; roadmap: SiteSettingsRoadmapState; appearance: SiteSettingsAppearanceState; - users: SiteSettingsUsersState; } const initialState: SiteSettingsState = { @@ -114,7 +105,6 @@ const initialState: SiteSettingsState = { postStatuses: siteSettingsPostStatusesReducer(undefined, {} as any), roadmap: siteSettingsRoadmapReducer(undefined, {} as any), appearance: siteSettingsAppearanceReducer(undefined, {} as any), - users: siteSettingsUsersReducer(undefined, {} as any), }; const siteSettingsReducer = ( @@ -131,8 +121,7 @@ const siteSettingsReducer = ( PostStatusOrderUpdateActionTypes | PostStatusDeleteActionTypes | PostStatusSubmitActionTypes | - PostStatusUpdateActionTypes | - UserUpdateActionTypes + PostStatusUpdateActionTypes ): SiteSettingsState => { switch (action.type) { case TENANT_UPDATE_START: @@ -198,14 +187,6 @@ const siteSettingsReducer = ( roadmap: siteSettingsRoadmapReducer(state.roadmap, action), }; - case USER_UPDATE_START: - case USER_UPDATE_SUCCESS: - case USER_UPDATE_FAILURE: - return { - ...state, - users: siteSettingsUsersReducer(state.users, action), - }; - default: return state; } diff --git a/app/models/post.rb b/app/models/post.rb index 6de73fa1..162a7a8a 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -3,7 +3,7 @@ class Post < ApplicationRecord extend FriendlyId belongs_to :board - belongs_to :user + belongs_to :user, optional: true belongs_to :post_status, optional: true has_many :likes, dependent: :destroy @@ -12,6 +12,12 @@ class Post < ApplicationRecord has_many :comments, dependent: :destroy has_many :post_status_changes, dependent: :destroy + enum approval_status: [ + :approved, + :pending, + :rejected + ] + validates :title, presence: true, length: { in: 4..128 } paginates_per Rails.application.posts_per_page @@ -43,5 +49,9 @@ class Post < ApplicationRecord order(created_at: :desc) end end + + def pending + where(approval_status: "pending") + end end end diff --git a/app/models/tenant_setting.rb b/app/models/tenant_setting.rb index 804cb9ff..59c9854e 100644 --- a/app/models/tenant_setting.rb +++ b/app/models/tenant_setting.rb @@ -16,4 +16,10 @@ class TenantSetting < ApplicationRecord :no_collapse, :always_collapse ] + + enum feedback_approval_policy: [ + :anonymous_require_approval, + :never_require_approval, + :always_require_approval, + ] end diff --git a/app/policies/post_policy.rb b/app/policies/post_policy.rb index 8ca21096..50597478 100644 --- a/app/policies/post_policy.rb +++ b/app/policies/post_policy.rb @@ -5,7 +5,7 @@ class PostPolicy < ApplicationPolicy def permitted_attributes_for_update if user.moderator? - [:title, :description, :board_id, :post_status_id] + [:title, :description, :board_id, :post_status_id, :approval_status] else [:title, :description] end diff --git a/app/policies/tenant_setting_policy.rb b/app/policies/tenant_setting_policy.rb index f54456ca..034ba406 100644 --- a/app/policies/tenant_setting_policy.rb +++ b/app/policies/tenant_setting_policy.rb @@ -4,6 +4,8 @@ class TenantSettingPolicy < ApplicationPolicy [ :brand_display, :root_board_id, + :allow_anonymous_feedback, + :feedback_approval_policy, :show_vote_count, :show_vote_button_in_board, :show_powered_by, diff --git a/app/views/boards/show.html.erb b/app/views/boards/show.html.erb index 7514ea77..41a4c5f2 100644 --- a/app/views/boards/show.html.erb +++ b/app/views/boards/show.html.erb @@ -5,7 +5,9 @@ board: @board, isLoggedIn: user_signed_in?, isPowerUser: user_signed_in? ? current_user.moderator? : false, + currentUserFullName: user_signed_in? ? current_user.full_name_or_email : '', tenantSetting: @tenant_setting, + componentRenderedAt: Time.now.to_i, authenticityToken: form_authenticity_token } ) diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index b02a7c85..0ccf253e 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -44,6 +44,9 @@ \ No newline at end of file diff --git a/app/views/site_settings/general.html.erb b/app/views/site_settings/general.html.erb index b2494c4b..04d0703c 100644 --- a/app/views/site_settings/general.html.erb +++ b/app/views/site_settings/general.html.erb @@ -14,6 +14,8 @@ showPoweredBy: @tenant_setting.show_powered_by, rootBoardId: @tenant_setting.root_board_id.to_s, customDomain: @tenant.custom_domain, + allowAnonymousFeedback: @tenant_setting.allow_anonymous_feedback, + feedbackApprovalPolicy: @tenant_setting.feedback_approval_policy, showRoadmapInHeader: @tenant_setting.show_roadmap_in_header, collapseBoardsInHeader: @tenant_setting.collapse_boards_in_header, locale: @tenant.locale diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index 568b8d75..387f0455 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -57,6 +57,29 @@ class Rack::Attack end end + # Throttle POST requests to /posts by IP address using anti-spam measures + throttle('posts/ip', limit: 1, period: 1.minute) do |req| + if req.path == '/posts' && req.post? + ip = req.get_header("action_dispatch.remote_ip") + real_req = ActionDispatch::Request.new(req.env) # Needed to parse JSON body + + # Check for honeypot field submission + honeypot_filled = real_req.params['post']['dnf1'] != "" || real_req.params['post']['dnf2'] != "" + + # Check for time of form render + too_fast_submit = Time.now.to_i - real_req.params[:post][:form_rendered_at] < 3 + + if honeypot_filled || too_fast_submit + Rack::Attack.cache.store.write("post-submit-antispam-#{ip}", true, expires_in: 1.minute) + end + + # Block if this IP was previously flagged + if Rack::Attack.cache.store.read("post-submit-antispam-#{ip}") + ip + end + end + end + ### Custom Throttle Response ### # By default, Rack::Attack returns an HTTP 429 for throttled responses, diff --git a/config/locales/backend/backend.en.yml b/config/locales/backend/backend.en.yml index 61205d6b..48c7cb5a 100644 --- a/config/locales/backend/backend.en.yml +++ b/config/locales/backend/backend.en.yml @@ -109,6 +109,10 @@ en: post: title: 'Title' description: 'Description' + approval_status: 'Approval status' + approval_status_approved: 'Approved' + approval_status_pending: 'Pending approval' + approval_status_rejected: 'Rejected' board_id: 'Post board' user_id: 'Post author' post_status_id: 'Post status' @@ -120,6 +124,8 @@ en: custom_domain: 'Custom domain' tenant_setting: brand_display: 'Display' + allow_anonymous_feedback: 'Allow anonymous feedback' + feedback_approval_policy: 'Feedback approval policy' show_vote_count: 'Show vote count to users' show_vote_button_in_board: 'Show vote buttons in board page' show_powered_by: 'Show "Powered by Astuto"' diff --git a/config/locales/en.yml b/config/locales/en.yml index ce6ff650..9aed96a4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,5 +1,7 @@ en: common: + words: + or: 'or' errors: unknown: 'An unknown error occurred, please try again' validations: @@ -65,6 +67,8 @@ en: back: 'Back' test: 'Test' clear: 'Clear' + approve: 'Approve' + reject: 'Reject' datetime: now: 'just now' minutes: @@ -80,6 +84,7 @@ en: menu: administration_header: 'Administration' site_settings: 'Site settings' + moderation: 'Moderation' profile_header: 'Profile' profile_settings: 'Profile settings' help_header: 'Help' @@ -97,12 +102,16 @@ en: board: new_post: submit_button: 'Submit feedback' + submit_anonymous_button: 'Submit anonymously' cancel_button: 'Cancel' login_button: 'Log in / Sign up' title: 'Title' description: 'Description (optional)' no_title: 'Title field is mandatory' + anonymous_submission_help: 'You are posting anonymously' + non_anonymous_submission_help: 'You are posting as %{name}' submit_success: 'Feedback published! You will be redirected soon...' + submit_pending: 'Your feedback has been submitted and is now pending moderator approval!' search_box: title: 'Search' filter_box: @@ -122,6 +131,7 @@ en: post: edit_button: 'Edit' published_by: 'Published by' + published_anonymously: 'Published anonymously' post_status_select: no_post_status: 'None' updates_box: @@ -153,7 +163,6 @@ en: boards: 'Boards' post_statuses: 'Statuses' roadmap: 'Roadmap' - users: 'Users' authentication: 'Authentication' appearance: 'Appearance' info_box: @@ -166,6 +175,12 @@ en: brand_setting_name: 'Name only' brand_setting_logo: 'Logo only' brand_setting_none: 'None' + subtitle_moderation: 'Moderation' + allow_anonymous_feedback_help: 'Unregistered users will be able to submit feedback.' + feedback_approval_policy_anonymous_require_approval: 'Require approval for anonymous feedback only' + feedback_approval_policy_never_require_approval: 'Never require approval' + feedback_approval_policy_always_require_approval: 'Always require approval' + feedback_approval_policy_help: 'If you require approval, submitted feedback will remain hidden from the public until a moderator or administrator approves it. Feedback submitted by moderators and administrators is always approved automatically.' subtitle_header: 'Header' collapse_boards_in_header_no_collapse: 'Never' collapse_boards_in_header_always_collapse: 'Always' @@ -195,21 +210,6 @@ en: appearance: title: 'Appearance' learn_more: 'Learn how to customize appearance' - users: - title: 'Users' - block: 'Block' - unblock: 'Unblock' - block_confirmation: "%{name} won't be able to log in until it is unblocked. Are you sure?" - unblock_confirmation: "%{name} will be able to log in and submit feedback. Are you sure?" - role_to_moderator_confirmation: "%{name} will be able to manage posts and users. Proceed only if you trust this person. Are you sure?" - role_to_admin_confirmation: "%{name} will be able to manage boards, posts, statuses, users and more. Proceed only if you trust this person. Are you sure?" - role_user: 'User' - role_moderator: 'Moderator' - role_admin: 'Administrator' - role_owner: 'Owner' - status_active: 'Active' - status_blocked: 'Blocked' - status_deleted: 'Deleted' authentication: title: 'Authentication' learn_more: 'Learn how to configure custom OAuth providers' @@ -230,3 +230,26 @@ en: subtitle_oauth_config: 'OAuth configuration' subtitle_user_profile_config: 'User profile configuration' client_secret_help: 'hidden for security purposes' + moderation: + menu: + feedback: 'Feedback' + users: 'Users' + feedback: + anonymous_feedback_allowed: 'Anonymous feedback allowed' + anonymous_feedback_not_allowed: 'Anonymous feedback not allowed' + change_feedback_moderation_settings: 'Change feedback moderation settings' + users: + title: 'Users' + block: 'Block' + unblock: 'Unblock' + block_confirmation: "%{name} won't be able to log in until it is unblocked. Are you sure?" + unblock_confirmation: "%{name} will be able to log in and submit feedback. Are you sure?" + role_to_moderator_confirmation: "%{name} will be able to manage posts and users. Proceed only if you trust this person. Are you sure?" + role_to_admin_confirmation: "%{name} will be able to manage boards, posts, statuses, users and more. Proceed only if you trust this person. Are you sure?" + role_user: 'User' + role_moderator: 'Moderator' + role_admin: 'Administrator' + role_owner: 'Owner' + status_active: 'Active' + status_blocked: 'Blocked' + status_deleted: 'Deleted' \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 6d0f2dfb..49df9518 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -55,6 +55,8 @@ Rails.application.routes.draw do resources :likes, only: [:index] resources :comments, only: [:index, :create, :update, :destroy] resources :post_status_changes, only: [:index] + + get '/moderation', on: :collection, to: 'posts#moderation' end resources :boards, only: [:index, :create, :update, :destroy, :show] do @@ -72,6 +74,10 @@ Rails.application.routes.draw do get 'post_statuses' get 'roadmap' get 'appearance' + end + + namespace :moderation do + get 'feedback' get 'users' end end diff --git a/db/migrate/20240708190830_add_anonymous_feedback_to_tenant_settings.rb b/db/migrate/20240708190830_add_anonymous_feedback_to_tenant_settings.rb new file mode 100644 index 00000000..14ebb7eb --- /dev/null +++ b/db/migrate/20240708190830_add_anonymous_feedback_to_tenant_settings.rb @@ -0,0 +1,6 @@ +class AddAnonymousFeedbackToTenantSettings < ActiveRecord::Migration[6.1] + def change + add_column :tenant_settings, :allow_anonymous_feedback, :boolean, default: true, null: false + add_column :tenant_settings, :feedback_approval_policy, :integer, default: 0, null: false + end +end diff --git a/db/migrate/20240708191529_add_approval_status_to_post.rb b/db/migrate/20240708191529_add_approval_status_to_post.rb new file mode 100644 index 00000000..2b4dbb10 --- /dev/null +++ b/db/migrate/20240708191529_add_approval_status_to_post.rb @@ -0,0 +1,5 @@ +class AddApprovalStatusToPost < ActiveRecord::Migration[6.1] + def change + add_column :posts, :approval_status, :integer, default: 0, null: false + end +end diff --git a/db/migrate/20240708191938_remove_not_null_user_id_from_posts.rb b/db/migrate/20240708191938_remove_not_null_user_id_from_posts.rb new file mode 100644 index 00000000..31b51180 --- /dev/null +++ b/db/migrate/20240708191938_remove_not_null_user_id_from_posts.rb @@ -0,0 +1,5 @@ +class RemoveNotNullUserIdFromPosts < ActiveRecord::Migration[6.1] + def change + change_column :posts, :user_id, :bigint, null: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 9e14d893..41c625d1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2024_05_21_124018) do +ActiveRecord::Schema.define(version: 2024_07_08_191938) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -117,12 +117,13 @@ ActiveRecord::Schema.define(version: 2024_05_21_124018) do t.string "title", null: false t.text "description" t.bigint "board_id", null: false - t.bigint "user_id", null: false + t.bigint "user_id" t.bigint "post_status_id" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.bigint "tenant_id", null: false t.string "slug" + t.integer "approval_status", default: 0, null: false t.index ["board_id"], name: "index_posts_on_board_id" t.index ["post_status_id"], name: "index_posts_on_post_status_id" t.index ["slug", "tenant_id"], name: "index_posts_on_slug_and_tenant_id", unique: true @@ -165,6 +166,8 @@ ActiveRecord::Schema.define(version: 2024_05_21_124018) do t.integer "collapse_boards_in_header", default: 0, null: false t.text "custom_css" t.boolean "show_powered_by", default: true, null: false + t.boolean "allow_anonymous_feedback", default: true, null: false + t.integer "feedback_approval_policy", default: 0, null: false t.index ["tenant_id"], name: "index_tenant_settings_on_tenant_id" end diff --git a/spec/models/post_spec.rb b/spec/models/post_spec.rb index c1d40dd8..f8d0dd41 100644 --- a/spec/models/post_spec.rb +++ b/spec/models/post_spec.rb @@ -41,13 +41,13 @@ RSpec.describe Post, type: :model do expect(no_status_post).to be_valid end - it 'has a reference to a user than cannot be nil' do + it 'has a reference to a user that can be nil (for anonymous feedback)' do no_user_post = FactoryBot.build(:post, user_id: nil) - expect(no_user_post).to be_invalid + expect(no_user_post).to be_valid end - it 'has a reference to a board than cannot be nil' do + it 'has a reference to a board that cannot be nil' do no_board_post = FactoryBot.build(:post, board_id: nil) expect(no_board_post).to be_invalid @@ -77,4 +77,14 @@ RSpec.describe Post, type: :model do expect { Post.search_by_name_or_description(nil) }.not_to raise_error expect { Post.search_by_name_or_description('dangerous symbols: " \' %') }.not_to raise_error end + + it 'has an approval status that can be approved, pending or rejected' do + expect(post.approval_status).to eq('approved') + + post.approval_status = 'pending' + expect(post).to be_valid + + post.approval_status = 'rejected' + expect(post).to be_valid + end end diff --git a/spec/models/tenant_setting_spec.rb b/spec/models/tenant_setting_spec.rb index af0898db..292a86db 100644 --- a/spec/models/tenant_setting_spec.rb +++ b/spec/models/tenant_setting_spec.rb @@ -46,4 +46,18 @@ RSpec.describe TenantSetting, type: :model do tenant_setting.collapse_boards_in_header = 'always_collapse' expect(tenant_setting).to be_valid end + + it 'has a setting to allow anonymous feedback' do + expect(tenant_setting.allow_anonymous_feedback).to be_truthy + end + + it 'has a setting to require approval for feedback' do + expect(tenant_setting.feedback_approval_policy).to eq('anonymous_require_approval') + + tenant_setting.feedback_approval_policy = 'never_require_approval' + expect(tenant_setting).to be_valid + + tenant_setting.feedback_approval_policy = 'always_require_approval' + expect(tenant_setting).to be_valid + end end diff --git a/spec/policies/post_policy_spec.rb b/spec/policies/post_policy_spec.rb index 5592df14..72113dbe 100644 --- a/spec/policies/post_policy_spec.rb +++ b/spec/policies/post_policy_spec.rb @@ -31,8 +31,8 @@ RSpec.describe PostPolicy do it { should permit(:update) } it { should permit(:destroy) } - it 'permits "title", "description", "board_id" and "post_status_id" attributes' do - permitted_attributes = [:title, :description, :board_id, :post_status_id] + it 'permits "title", "description", "board_id", "post_status_id" and "approval_status" attributes' do + permitted_attributes = [:title, :description, :board_id, :post_status_id, :approval_status] expect(subject.permitted_attributes_for_update).to eq(permitted_attributes) end end diff --git a/spec/routing/moderation_routing_spec.rb b/spec/routing/moderation_routing_spec.rb new file mode 100644 index 00000000..1bdbbd44 --- /dev/null +++ b/spec/routing/moderation_routing_spec.rb @@ -0,0 +1,17 @@ +require 'rails_helper' + +RSpec.describe 'moderation routing', :aggregate_failures, type: :routing do + let (:base_url) { '/moderation' } + + it 'routes feedback' do + expect(get: base_url + '/feedback').to route_to( + controller: 'moderation', action: 'feedback' + ) + end + + it 'routes users' do + expect(get: base_url + '/users').to route_to( + controller: 'moderation', action: 'users' + ) + end +end \ No newline at end of file diff --git a/spec/routing/site_settings_routing_spec.rb b/spec/routing/site_settings_routing_spec.rb index 93a86681..0f3f4adf 100644 --- a/spec/routing/site_settings_routing_spec.rb +++ b/spec/routing/site_settings_routing_spec.rb @@ -26,10 +26,4 @@ RSpec.describe 'site settings routing', :aggregate_failures, type: :routing do controller: 'site_settings', action: 'roadmap' ) end - - it 'routes users' do - expect(get: base_url + '/users').to route_to( - controller: 'site_settings', action: 'users' - ) - end end \ No newline at end of file diff --git a/spec/system/board_spec.rb b/spec/system/board_spec.rb index 13805e86..66d12fed 100644 --- a/spec/system/board_spec.rb +++ b/spec/system/board_spec.rb @@ -60,16 +60,6 @@ feature 'board', type: :system, js: true do end end - it 'renders a log in button if not logged in' do - visit board_path(board) - - within sidebar do - expect(page).to have_content(/Log in \/ Sign up/i) - click_link 'Log in / Sign up' - expect(page).to have_current_path(new_user_session_path) - end - end - it 'renders a submit feedback button that shows a form if logged in' do user.confirm sign_in user @@ -82,8 +72,6 @@ feature 'board', type: :system, js: true do click_button 'Submit feedback' # open submit form expect(page).to have_css(new_post_form) - expect(page).to have_content(/Title/i) - expect(page).to have_content(/Description/i) end end @@ -103,6 +91,9 @@ feature 'board', type: :system, js: true do fill_in 'Title', with: post_title fill_in 'Description (optional)', with: post_description + + sleep 5 # needed to avoid time check anti-spam measure + click_button 'Submit feedback' # submit end diff --git a/spec/system/site_settings/site_settings_users_spec.rb b/spec/system/moderation/moderation_users_spec.rb similarity index 96% rename from spec/system/site_settings/site_settings_users_spec.rb rename to spec/system/moderation/moderation_users_spec.rb index 28567728..35692019 100644 --- a/spec/system/site_settings/site_settings_users_spec.rb +++ b/spec/system/moderation/moderation_users_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'site settings: users', type: :system, js: true do +feature 'moderation: users', type: :system, js: true do let(:admin) { FactoryBot.create(:admin) } let(:user) { FactoryBot.create(:user) } @@ -14,7 +14,7 @@ feature 'site settings: users', type: :system, js: true do user - visit site_settings_users_path + visit moderation_users_path end it 'lets view existing users' do